Я уверен, вы знаете, что такое FastAPI. По результатам опросов Stackoverflow этот фреймворк уверенно входит в топ любимых фреймворков не только питонистов, но и разработчиков в целом. И не зря: за счет свеого подхода к сериализации данных он предоставляет действительно потрясающий опыт разработки.

На Хабре уже была отличная статья о том, как устроен FastAPI изнутри. Там достаточно подробно рассмотрены все ключевые концепции этого фреймворка, а также разобраны детали его реализации на базе Starlette.

В этой же статье я хочу поделиться некоторым опытом создания своего инструмента для сериализации данных на основе pydantic. После прочтения этой статьи вы сможете превратить практически любой Python фреймворк в идейное подобие FastAPI. Причем речь идет не только об HTTP фреймворках. Никто не запрещает вам сделать, например, фреймворк для создания CLI, или telegram-ботов, или того, чего ваша душа пожелает.

Интересно? Тогда добро пожаловать!

Что мы хотим получить?

Для начала стоит определиться, что мы понимаем под подобием FastAPI. Я закладываю сюда следующие критерии:

  • сериализация входящих данных на основе аннотации типов с использованием pydantic

  • удобная и простая в использовании система внедрения зависимостей

Также к авторским особенностям FastAPI, возможно, стоит отнести автоматическую генерацию OpenAPI схемы (хотя и сам @tiangolo говорит, что позаимствовал эту идею у Django Rest Framework). Однако, это не та фича, которая подойдет любому фреймворку. Не все же фреймворки HTTP.

Все остальное в FastAPI - это просто набор полезных батареек и оберток над Starlette. Так что будем считать, что реализуя вышеописанные пункты мы получаем идейное продолжение FastAPI.

Надеюсь, с этим определились.

Как мы хотим получить это?

В статье, указанной в начале, уже описана эта концепция и даже есть пример реализации, взятый из исходников FastAPI.

Идея лежит на поверхности. Просто посмотрите на два следующих куска кода:

def func(
    user_id: int,
    username: str,
):
    pass

И вот этот:

from pydantic import BaseModel

class FuncArguments(BaseModel):
    user_id: int
    username: str

Не замечаете никакого сходства? В любом случае, @tiangolo заметил. И сделал очень простую вещь: декоратор, который анализирует аннотацию функции и строит pydantic модель на ее основе.

Ну а дальше мы просто делаем немного магии в стиле

func(**FuncArguments(user_id=1, username="john").dict())

И вот уже наша функция вызывается с провалидированными с помощью pydantic аргументами.

Итак, псевдокод нашего декоратора выглядит следующим образом:

from functools import wraps

def validate(func):
    pydantic_args_model = build_pydantic_model(func)

    @wraps(func)
    def validate_wrapper(*args, **kwargs):
        arguments = merge_args_and_kwargs(args, kwargs)
        validated_args = pydantic_args_model(**arguments).dict()
        return func(**validated_args)
    
    return validate_wrapper

Плюсом данного подхода является то, что построение моделей происходит на этапе декорирования функции.

Во время рантайма у нас происходит только валидация pydantic модели (что быстро благодаря Rust) и вызов оригинальной функции.

Оверхед минимален.

Осталось только реализовать две функции:

  • build_pydantic_model

  • merge_args_and_kwargs

Детали их реализации останутся за рамками статьи, ничего сложного там нет (особенно во второй). При большом желании вы можете ознакомиться с исходниками.

В результате, мы имеем проект FastDepends, который позволяет нам превратить любой фреймворк в FastAPI.

Функционал заключается в следующем:

  • валидация входящих и исходящих аргументов функции с помощью pydantic

  • Depends, идентичные натуральным fastapi.Depends

  • Возможность писать собственные поля - расширения

  • поддержка как синхронного, так и асинхронного режимов работы

Flask + FastDepends

Попробуем разобраться на примере, что это нам дает.

Например, возьмем базовое приложение Flask и добавим туда декоратор inject из FastDepends.

from flask import Flask
from fast_depends import inject, Depends
from pydantic import Field, PositiveInt

app = Flask(__name__)

def get_user(user_id: PositiveInt = Field(..., alias="id")):
    return f"user-{user_id}"

@app.get("/<id>")
@inject
def hello(user: str = Depends(get_user)):
    return f"<p>Hello, {user}!</p>"

Оно уже работает! Теперь наш handler ожидает положительный id, получает по нему пользователя внутри Depends и передает этого пользователя внутрь самой ручки.

Обработка ошибок валидации

В таком виде Flask будет возвращать 500-ки при ошибках валидации, т.к. он не знает, что такое pydantic.ValidationError. Давайте его научим?

from functools import wraps
from pydantic import ValidationError

...

def catch_validation_error(handler):
    @wraps(handler)
    def catch_wrapper(*args, **kwargs):
        try:
            return handler(*args, **kwargs)
        except ValidationError as e:
            # возвращаем BadRequest с подробным описанием ошибки
            return e.json(), 400  
    return catch_wrapper

@app.get("/<id>")
@catch_validation_error
@inject
def hello(user: str = Depends(get_user)):
    return f"<p>Hello, {user}!</p>"

Убираем матрешку

Такая куча декораторов не кажется вам немного монструозной? Я предлагаю немного их спрятать.

from flask import Flask
from flask.scaffold import setupmethod

class FastFlask(Flask):
    @setupmethod
    def route(self, rule: str, **options):
        def decorator(f):
            f = catch_validation_error(inject(f))  # прячем все сюда
            endpoint = options.pop("endpoint", None)
            self.add_url_rule(rule, endpoint, f, **options)
            return f
        return decorator

app = FastFlask(__name__)

...

@app.get("/<id>")
def hello(user: str = Depends(get_user)):
    return f"<p>Hello, {user}!</p>"

Теперь намного лучше!

Обрабатываем другие виды параметров

Сейчас наш FastFlask умеет обрабатывать только параметры пути. Давайте добавим в него поддержку POST - JSON запросов, например.

Для этого в FastDepends есть специальный класс, метод use которого позволяет полностью модифицировать входящие аргументы вашей функции.

Таким образом вы можете добавлять, убирать, модифицировать набор аргументов, которые попадут в вашу функцию.

from flask import request
from fast_depends.library import CustomField

class Body(CustomField):
    def use(self, **kwargs):
        return {
            **super().use(**kwargs),
            self.param_name: (request.json or {}).get(self.param_name)
        }

...

@app.post("/")
def hello(user_id: PositiveInt = Body()):
    # ожидает JSON вида { "user_id": 1 }
    return { "Hi!": user_id }

Итак, у нас получилось модифицировать Flask таким образом, чтобы он умел валидировать входящие аргументы с помощью pydantic, корректно обрабатывать ошибки валидации, а также имел поддержку механизма внедрения зависимостей через Depends.

Достаточно похоже на FastAPI, не находите?

Аналогичным образом вы можете поступить с любым HTTP (и не только) Python фреймворком.

Итоговый код приложения вы можете посмотреть здесь

Зачем?

В целом, основная идея проекта - дать удобный инструментарий для разработчиков OSS проектов.

Например, вы можете ознакомиться с моим фреймворком для разработки event-driven микросервисов Propan, который полностью утилизирует все особенности FastDepends чтобы максимально приблизить опыт разработки к опыту, который дает вам FastAPI.

Также этот пакет может быть полезен для тех, кто не может сменить основной фреймворк на проекте, но вынужден расширять и дорабатывать его функционал. Надеюсь, он сможет сгладить ваши впечатления от легаси.

Комментарии (5)


  1. baldr
    18.07.2023 06:04

    Ну, кстати, неплохо выглядит. Однако, очень бы не помешала и схема - документацию руками писать что ли?

    Можно, впрочем, отдельным скриптом пробегаться по исходникам при сборке и выдергивать функции и аргументы в документацию.


    1. Propan671 Автор
      18.07.2023 06:04

      Для этого у объекта, создаваемого с помощью build_model есть возможность дернуть непосредственно Pydantic модель, а от нее уже получить OpenAPI схему метода.

      Я так и делаю для пвтоматческого составления AsyncAPI схемы приложения в Propan.

      Появится немного свободного времени - постараюсь добавить публичный метод и документацию для этого юзкейса.


  1. Virviil
    18.07.2023 06:04
    +1

    что быстро благодаря Rust

    Что это значит?


    1. baldr
      18.07.2023 06:04

      Что это значит?

      Вероятно вот это: Core validation logic for pydantic written in rust


      1. Propan671 Автор
        18.07.2023 06:04

        Именно, т.к. PydanticV2 написан на Rust, то либа получила существенный буст к производительности с его выходом.

        Однако, она все еще совместима с PydanticV1, т.к. вторая версия сейчас, к сожалению, запускается не везде.