В нашей команде бытует хорошая практика фиксировать все изменения, которые отправляются в продакшен в гитхабовских релизах. Однако, не вся наша команда имеет доступ в гитхаб, а о релизах хочется знать всем. Так сложилась традиция релиз из гитхаба дублировать в рабочем чате команды в телеграме. Что хорошо, гитхаб позволяет с помощь маркдауна красиво оформить релиз с разделением на секции и ссылками на задачи, которые отправляются на выкатку. Что плохо, простым copy/paste всю эту красоту в телеграм не перенесёшь и приходится тратить время на довольно нудную работу по повторному оформлению релиза, но уже в телеграме. Ну а посколько программисты народ ленивый, я решил этот процесс автоматизировать.
Исходные данные:
- Гитхаб умеет сообщать обо всём, что происходит в репозитории с помощью вебхуков
- Вся необходимая для формирования релиза информация содержится в теле запроса, который кидает вебхук
- Авторизация идёт через подпись запроса секретом, который проставляется в настройках вебхука
Соответственно, задача заключается в том, чтобы поднять HTTP API, который сможет принять POST запрос, проверить подпись, извлечь нужную информацию из тела запроса и передать её дальше по инстанции. Как тут не попробовать FastAPI, на который я давно глаз положил?
Кто такой FastAPI?
FastAPI — это фреймворк для создания лаконичных и довольно быстрых HTTP API-серверов со встроенными валидацией, сериализацией и асинхронностью,
что называется, из коробки. Стоит он на плечах двух других фреймворков: работой с web в FastAPI занимается Starlette, а за валидацию отвечает Pydantic.
Комбайн получился легким, неперегруженным и более, чем достаточным по функционалу.
Необходимый минимум
Для работы FastAPI необходим ASGI-сервер, по дефолту документация предлагает uvcorn, базирующийся на uvloop, однако FastAPI также может работать и с другими серверами, например, c hypercorn
Вот мои зависимости:
[packages]
fastapi = "*"
uvicorn = "*"
И этого более чем достаточно.
Для более тщательных читателей в конце статьи есть ссылка на репозиторий с ботом, там можно посмотреть на зависимости для разработки и тестирования.
Ну что, pipenv install -d
и начали!
Собираем API
Надо заметить, что подход к оформлению хэндлеров в FastAPI чрезвычайно напоминает такой же в Flask, Bottle, да тысячи их. Видимо, миллионы мух не могут-таки ошибаться.
В самом первом приближении мой роут для обработки релиза выглядел так:
from fastapi import FastAPI
from starlette import status
from starlette.responses import Response
from models import Body
app = FastAPI() # noqa: pylint=invalid-name
@app.post("/release/")
async def release(*,
body: Body,
chat_id: str = None):
await proceed_release(body, chat_id)
return Response(status_code=status.HTTP_200_OK)
Тут надо отметить, что при таких параметрах, переданных в хендлер, FastAPI будет пытаться сериализовать тело запроса в Body
, а параметр chat_id
будет искать в URL params
Файл models.py
:
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, HttpUrl
class Author(BaseModel):
login: str
avatar_url: HttpUrl
class Release(BaseModel):
name: str
draft: bool = False
tag_name: str
html_url: HttpUrl
author: Author
created_at: datetime
published_at: datetime = None
body: str
class Body(BaseModel):
action: str
release: Release
Здесь прекрасно видно, как выглядят модели Pydantic. Их можно вкладывать, причем как сущностями, так и списками, к примеру так:
class Body(BaseModel):
action: str
releases: List[Release]
Ещё мы обязаны задать типы полям моделей и FastAPI будет мапить входящий запрос на переданную ему модель с учётом типов. В случае несовпадения он отдаст ошибку валидации. В случае, если поля во входящих данных нет и в модели не проставлено значение по-умолчанию — тоже.
Кроме базовых питоньих типов Pydantic предлагает ещё достаточно много своих собственных типов данных, в моём примере это тип HttpUrl, то есть входящая строка должна быть валидным URL со схемой и доменом первого уровня, в противном случае FastAPI отдаст ошибку валидации. Остальные типы Pydantic можно посмотреть здесь
Таким образом валидация и сериализация данных настраивается уже при задании модели данных.
Аутентификация
FastAPI поддерживает достаточно методов аутентификации по умолчанию, но, поскольку здесь используется гитхабовская подпись запроса, авторизацию пришлось колхозить самостоятельно,
ну да оно и к лучшему — больше интересного!
Я вынес роуты FastAPI в отдельный роутер, а в основном файле оставил авторизацию и управление документацией:
from fastapi import FastAPI, HTTPException, Depends
from starlette import status
from starlette.requests import Request
import settings
from router import api_router
from utils import check_auth
docs_kwargs = {} # noqa: pylint=invalid-name
if settings.ENVIRONMENT == 'production':
docs_kwargs = dict(docs_url=None, redoc_url=None) # noqa: pylint=invalid-name
app = FastAPI(**docs_kwargs) # noqa: pylint=invalid-name
async def check_auth_middleware(request: Request):
if settings.ENVIRONMENT in ('production', 'test'):
body = await request.body()
if not check_auth(body, request.headers.get('X-Hub-Signature', '')):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
app.include_router(api_router, dependencies=[Depends(check_auth_middleware)])
Обратите внимание, что request.body
— это функция, причем асинхронная. В FastAPI(а на деле в Starlette) асинхронность везде, это надо обязательно помнить.
Что касается документации, то это ещё один большой плюс FastAPI — он автоматически генерит документацию в формате OpenAPI и отдаёт её в формате Swagger/ReDoc в зависимости от где вы смотрите, ваш_сайт/docs или ваш_сайт/redoc соответственно.
В моем случае я решил документацию в проде вообще убрать. Ну его.
Соответственно, файл с роутами превратился в это:
from fastapi import APIRouter
from starlette import status
from starlette.responses import Response
from bot import proceed_release
from models import Body, Actions
api_router = APIRouter() # noqa: pylint=invalid-name
@api_router.post("/release/")
async def release(*,
body: Body,
chat_id: str = None,
release_only: bool = False):
if (body.release.draft and not release_only) \
or body.action == Actions.released:
res = await proceed_release(body, chat_id)
return Response(status_code=res.status_code)
return Response(status_code=status.HTTP_200_OK)
А всё
Это действительно весь код, который запускает быстрый HTTP API-сервер с аутентификацией, валидацией и документацией.
Итого
FastAPI — действительно отличный инструмент, если вам по душе лаконичность и, вместе с тем, понятность кода. Кроме того, он асинхронен(фу, вы что, в 2020-ом году пишете синхронный код?
я тоже), быстр и это не идёт в ущерб функциональности.
Так что если на горизонте маячит новый проект, для которого важны производительность, документация и валидация, то, вероятно, имеет смысл посмотреть в сторону FastAPI.
Вместо послесловия
Конечно, проект получился несколько больше, чем описанные мной три файла. Там их на самом деле шесть — собственно бот, описывать которого я не стал, ибо нерелевантно, а также утилиты и настройки, вынесенные в отдельные файлы для большего порядку.
Всё это, а также тесты, докерфайл и настройку github actions вы можете посмотреть в исходном коде проекта
Доклад окончен, всем спасибо!