Я уверен, вы знаете, что такое 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)
Virviil
18.07.2023 06:04+1что быстро благодаря Rust
Что это значит?
baldr
18.07.2023 06:04Что это значит?
Вероятно вот это: Core validation logic for pydantic written in rust
Propan671 Автор
18.07.2023 06:04Именно, т.к. PydanticV2 написан на Rust, то либа получила существенный буст к производительности с его выходом.
Однако, она все еще совместима с PydanticV1, т.к. вторая версия сейчас, к сожалению, запускается не везде.
baldr
Ну, кстати, неплохо выглядит. Однако, очень бы не помешала и схема - документацию руками писать что ли?
Можно, впрочем, отдельным скриптом пробегаться по исходникам при сборке и выдергивать функции и аргументы в документацию.
Propan671 Автор
Для этого у объекта, создаваемого с помощью build_model есть возможность дернуть непосредственно Pydantic модель, а от нее уже получить OpenAPI схему метода.
Я так и делаю для пвтоматческого составления AsyncAPI схемы приложения в Propan.
Появится немного свободного времени - постараюсь добавить публичный метод и документацию для этого юзкейса.