Знакомство с FastAPI

в 8:18, , рубрики: fastapi, pydantic, python, starlette
Вместо предисловия

В нашей команде бытует хорошая практика фиксировать всё изменения, которые отправляются в продакшен в гитхабовских релизах. Однако, не вся наша команда имеет доступ в гитхаб, а о релизах хочется знать всем. Так сложилась традиция релиз из гитхаба дублировать в рабочем чате команды в телеграме. Что хорошо, гитхаб позволяет с помощь маркдауна красиво оформить релиз с разделением на секции и ссылками на задачи, которые отправляются на выкатку. Что плохо, простым 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 вы можете посмотреть в исходном коде проекта

Доклад окончен, всем спасибо!

Автор: Иван Феофанов

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js