Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Moscow Python Meetup №84. Сурен Хоренян. Сложности реализации JSON:API на FastAPI + Pydantic

Moscow Python Meetup №84. Сурен Хоренян. Сложности реализации JSON:API на FastAPI + Pydantic

Проблемы, с которыми мы столкнулись при реализации JSON:API на FastAPI. Что удалось реализовать, что пришлось подпереть костылями, а что осталось нерешенным.

Видео: https://youtu.be/8MGr7wBj6u4

Moscow Python: http://moscowpython.ru
Курсы Learn Python: http://learn.python.ru
Moscow Python Podcast: http://podcast.python.ru
Заявки на доклады: https://bit.ly/mp-speaker

Moscow Python Meetup

August 24, 2023
Tweet

More Decks by Moscow Python Meetup

Other Decks in Programming

Transcript

  1. Что за JSON:API? The Bikeshed effect - эффект сарая Время,

    потраченное на обсуждение пункта (задачи, проблемы), обратно пропорционально рассматриваемой сумме (выгоды, пользы). 04
  2. Что за JSON:API? JSON:API это anti-bikeshed-effect • Строгая спецификация: *касается

    только CRUD, но не бизнес-логики описаны требования в формате must, must not • Мало вариативности: только незначительные пункты описаны как may, may not • Единообразие: любой ресурс (от самого простого до самого комплексного) построен по единому формату API, не требуется дополнительное документирование* 05
  3. Тренировка на Flask Подобное мы уже делали для Flask (на

    основе заброшенного форка) В библиотеке: https://moscowpython.ru/meetup/77/api-json-python/ Flask, marshmallow Система плагинов Прошлогодний митап №77 06
  4. Общий стек между продуктовыми командами asyncio Pydantic, аннотации типов Инъекции

    зависимостей Зачем переезжать на FastAPI (уже есть и в Flask) 07
  5. Общий стек между продуктовыми командами asyncio Встроенная интерактивная документация Pydantic,

    аннотации типов Инъекции зависимостей Зачем переезжать на FastAPI (не сильный аргумент, в библиотеке для Flask это решается через плагин) (уже есть и в Flask) 07
  6. Общий стек между продуктовыми командами asyncio Встроенная интерактивная документация Pydantic,

    аннотации типов Инъекции зависимостей Так сейчас модно Зачем переезжать на FastAPI (не сильный аргумент, в библиотеке для Flask это решается через плагин) (уже есть и в Flask) 07
  7. Благодаря изначально заложенной архитектуре со слоями переехать на SQLA было

    несложно Переезд на SQLAlchemy Dependencies FastAPI-JSONAPI Storage Other apps Custom code Other Applications App 1 Celery Kafka … CUSTOM CODE Resource Manager Logical data abstraction ROUTING API generation according to JSON:API specification FastAPI Pydantic DATA LAYER JSON API 1.0 Client pagination filter Sparse fieldsets Include related objects sort ??? Tortoise ORM SQLAlchemy DATA postgresql CRUD 09
  8. Обработка запроса в FastAPI Как создавать Views? Валидация данных через

    Pydantic Подготовка данных через фабрику Создание объекта через фабрику Из этого состоят стандартные CRUD Возврат данных (pydantic, FastAPI) 10
  9. Код для list view (list, create) async def get( self,

    query_params: InvitationQueryStringManager = Depends(InvitationQueryStringManager), session: AsyncSession = Depends(get_as_session), account: Account = Depends(get_target_account_from_query_or_auth_account), _: bool = Depends(permissions_get_list), ) -> Union[Select, JSONAPIResultListSchema]: invitation_query = select( # get invitations Invitation, ).where( # auth or target account Invitation.account_id == account.id, ) dl = SqlalchemyEngine( schema=self.jsonapi.schema_list, model=self.jsonapi.model, session=session, query=invitation_query, ) return await self.get_paginated_result( dl=dl, query_params=query_params, ) async def post( self, query_params: QueryStringManager, data_for_create: InvitationCreateSchema, session: AsyncSession = Depends(get_as_session), target_account: Account = Depends(get_target_account_from_jsonapi_body), _: bool = Depends(permissions_create), ) -> JSONAPIResultDetailSchema: invitation = await self.invitation_create( data_for_create=data_for_create, account_for_invitation=target_account, session=session, ) dl = SqlalchemyEngine( schema=self.jsonapi.schema_detail, model=self.jsonapi.model, session=session, ) view_kwargs = {'id': invitation.id} return await self.get_detailed_result( dl=dl, view_kwargs=view_kwargs, query_params=query_params, ) 11
  10. Для обработки каждого CRUD действия над каждой сущностью необходимо делать

    новые view, которые выполняют те же самые действия: Подготовка сессии (для бд) Очевидная копипаста Подготовка данных через фабрику Создание объекта через фабрику Подготовка ответа (через схемы) Всё описывали вручную Переделывать пришлось очень много 12
  11. Избавиться от копипасты можно через дженерики - универсальные / автоматически

    генерируемые блоки кода, шаблоны. Копипаста vs Generics Поддержать инъекцию зависимостей Избавиться от множества ответственных Если больше одного ответственного, то ответственных нет. Зачем существует слой data-layer, если вся его работа легла на фабрики? Нужно получать не только сессию, но и прочие зависимости (например, ограничения доступа, предзагрузка других зависимостей, авторизация, и тд). 13
  12. Требования к решению Инъекция зависимостей (важно) (напоминает Django, в частности

    ModelViewSet в DRF) Не писать ни строчки логики при реализации view. Только декларативный подход Расширяемость - поддержать возможность изменить любую часть логики для каждого отдельного view в своей реализации, не делая PR в основную библиотеку Не заставлять разработчика следить за соблюдением правил JSON:API - всё должно происходить автоматически. Поэтому создаваемые схемы должны быть обычными и привычными Поддержка relationships (для реализации includes) 14
  13. Технические проблемы, с которыми мы столкнулись Имена схем pydantic должны

    быть уникальны, иначе не работает Swagger Проблема циклических импортов Как строить отношения между разными схемами, которые объявлены в разных модулях? При генерации схем необходимо соблюдать правила JSON:API, добавлять служебные поля, не применять лишние Изменяемость View, data-layer Как принимать FastAPI зависимости, если мы хотим избавиться от объявления views? Выброс HTTP исключений - в FastAPI единый стандарт ответа ошибки (документация OpenAPI) 15
  14. Имена схем pydantic должны быть уникальны Пришлось добавлять глобальные кэши

    на уровне классов. Выглядит как костыль, но решения лучше мы не придумали. class SchemaBuilder: object_schemas_cache = {} relationship_schema_cache = {} base_jsonapi_object_schemas_cache = {} def create_jsonapi_object_schemas(...) -> JSONAPIObjectSchemas: if use_schema_cache and schema in self.object_schemas_cache and includes is not_passed: return self.object_schemas_cache[schema] ... def create_relationship_data_schema(...) -> RelationshipInfoSchema: cache_key = (base_name, field_name, relationship_info.resource_type, relationship_info.many) if cache_key in self.relationship_schema_cache: return self.relationship_schema_cache[cache_key] ... def _build_jsonapi_object(...) -> Type[JSONAPIObjectSchemaType]: if use_schema_cache and base_name in self.base_jsonapi_object_schemas_cache: return self.base_jsonapi_object_schemas_cache[base_name] ... 16
  15. Имена схем pydantic должны быть уникальны Добавление уникальных суффиксов на

    имена генерируемых схем schema_in_post = schema_in_post or schema schema_name_in_post_suffix = "" if any(schema_in_post is cmp_schema for cmp_schema in [schema, schema_in_patch]): schema_name_in_post_suffix = "InPost" schema_in_patch = schema_in_patch or schema schema_name_in_patch_suffix = "" if any(schema_in_patch is cmp_schema for cmp_schema in [schema, schema_in_post]): schema_name_in_patch_suffix = "InPatch" 17
  16. СОЗДАВАТЬ ДВУСТОРОННИЕ СВЯЗКИ: “Выравнивать” связи - из строковых аннотаций типов

    получать реальные объекты Строить отношения между схемами Знать обо всех созданных схемах 18
  17. Решение: Знать обо всех созданных схемах Строить отношения между схемами

    class Registry: def __init__(self): self._known = {} def add(self, schema): self._known[schema.__name__] = schema def get(self, name: str): return self._known.get(name) @property def schemas(self): return dict(self._known) registry = Registry() class RegistryMeta(ModelMetaclass): def __new__(mcs, *args, **kwargs): # any other way to get all known schemas? schema = super().__new__(mcs, *args, **kwargs) registry.add(schema) return schema • Новый метакласс с сохранением информации по созданным классам • Базовый класс, от которого необходимо создавать все схемы 19
  18. Создана вспомога- тельная схема, чтобы передавать дополнительную информацию об отношении

    (связке): Строить отношения между схемами class RelationshipInfo(BaseModel): resource_type: str many: bool = False related_view: str = None related_view_kwargs: Dict[str, str] = {} resource_id_example: str = "1" id_field_name: str = "id" # TODO: Pydantic V2 use model_config class Config: frozen = True • to-one или to-many • тип ресурса (JSON:API) • имя поля primary key • пример для swagger 20
  19. Создание связей от Post к: Строить отношения между схемами class

    PostBaseSchema(BaseModel): ... user: "UserSchema" = Field( relationship=RelationshipInfo( resource_type="user", ), ) comments: List["PostCommentSchema"] = Field( relationship=RelationshipInfo( resource_type="post_comment", many=True, ), ) • user - to-one • comments - to-many 21
  20. Создание связей от PostComment к: Строить отношения между схемами class

    PostCommentBaseSchema(BaseModel): ... post: "PostSchema" = Field( relationship=RelationshipInfo( resource_type="post", ), ) author: "UserSchema" = Field( relationship=RelationshipInfo( resource_type="user", ), ) • post - to-one • author - to-one Имя поля как на модели, не обязано соответствовать типу сущности 22
  21. Как отрезолвить связанные схемы Строить отношения между схемами def create_jsonapi_object_schemas(...)

    -> JSONAPIObjectSchemas: ... schema.update_forward_refs(**registry.schemas) • schema.update_forward_refs • схемы по именам доступны в registry 23
  22. Поле id требуется только для обновления При генерации схем соблюдать

    правила JSON:API schema_name = f"{name}RelationshipJSONAPI".format(name=name) relationship_schema = pydantic.create_model( schema_name, id=(str, Field(..., description="Resource object id", example=relationship_info.resource_id_example)), type=(str, Field(default=relationship_info.resource_type, description="Resource type")), __base__=BaseJSONAPIRelationshipSchema, ) wrapped_object_jsonapi_schema = pydantic.create_model( f"{base_schema_name}ObjectDataJSONAPI", data=(object_jsonapi_schema, ...), __base__=BaseJSONAPIDataInSchema, ) return wrapped_object_jsonapi_schema Данные приходят / уходят в поле data 24
  23. Инициализировать view на каждый запрос Изменяемость View, data-layer • Внутри

    view и data-layer можно сохранять на экземпляр любые промежуточные данные (снова напоминает Django) pros: • “время” на инициализацию (можно пренебречь) • обновление архитектуры (снова) cons: 25
  24. Инициализировать view на каждый запрос. Храним: Изменяемость View, data-layer •

    Информацию о запросе request и своя отдельная структура QS Manager class ViewBase: """ Views are inited for each request """ data_layer_cls = BaseDataLayer method_dependencies: Dict[HTTPMethod, HTTPMethodConfig] = {} def __init__(self, *, request: Request, jsonapi: RoutersJSONAPI, **options): self.request: Request = request self.jsonapi: RoutersJSONAPI = jsonapi self.options: dict = options self.query_params: QueryStringManager = QueryStringManager(request=request) • Обертка jsonapi, в которой ссылки на pydantic схемы и SQLA модели 26
  25. Инициализировать data-layer на каждый запрос. Храним: Изменяемость View, data-layer •

    Модель • Схему • Имя id поля в ссылке и на модели class BaseDataLayer: ... def __init__(...): ... self.model = model self.schema = schema self.url_id_field = url_id_field self.id_name_field = id_name_field self.disable_collection_count: bool = disable_collection_count self.default_collection_count: int = default_collection_count 27
  26. ПЕРВЫЙ ПОДХОД Как принимать FastAPI зависимости? На view объявить дополнительный

    метод init_dependencies В начале каждого запроса подготавливать зависимости и устанавливать на текущий view (он же инициализируется на каждый запрос) 28
  27. Модификация текущего view, “под капотом” прокидываем всё в data-layer Дополнительный

    метод init_dependencies • Объявляем зависимости (dependency injection) в стиле зависмостей в FastAPI views class SessionDependencyMixin: session: AsyncSession async def init_dependencies( self, session: AsyncSession = Depends(Connector.get_session), ): self.session = session def get_data_layer_kwargs(self): return {"session": self.session} class DetailViewBase( SessionDependencyMixin, DetailViewBaseGeneric, ): """Generic view base (detail)""" class ListViewBase( SessionDependencyMixin, ListViewBaseGeneric, ): """Generic view base (list)""" (первый вариант) Плюсы: • Неявное использование • Дополнительный метод для подготовки данных (надо объявлять самим) • Одна зависимость на все view – нет возможности добавить разные правила на разные действия (например, разрешить get всем, но ограничить post) Минусы: 29
  28. Зависимости как модель pydantic Зависимости на каждый view def one():

    return 1 def two(): return 2 class CommonDependency(BaseModel): key_1: int = Depends(one) class GetDependency(BaseModel): key_2: int = Depends(two) class DependencyMix(CommonDependency, GetDependency): pass (второй и финальный вариант) Зависимости в декларативном виде def common_handler(view: ViewBase, dto: CommonDependency) -> dict: return {"key_1": dto.key_1} def get_handler(view: ViewBase, dto: DependencyMix): return {"key_2": dto.key_2} class DetailView(DetailViewBaseGeneric): method_dependencies = { HTTPMethod.ALL: HTTPMethodConfig( dependencies=CommonDependency, prepare_data_layer_kwargs=common_handler, ), HTTPMethod.GET: HTTPMethodConfig( dependencies=GetDependency, prepare_data_layer_kwargs=get_handler, ), } 30
  29. FastAPI подготавливает зависимости на основе сигнатуры функции. Зависимости объявили, а

    как их подгружать? ОБНОВЛЯЕМ СИГНАТУРУ def _update_signature_for_resource_list_view( self, wrapper: Callable[..., Any], additional_dependency_params: Iterable[Parameter] = (), ) -> Signature: sig = signature(wrapper) params, tail_params = self._get_separated_params(sig) filter_params, include_params = create_additional_query_params(schema=self.schema_detail) extra_params = [] extra_params.extend(self._create_pagination_query_params()) extra_params.extend(filter_params) extra_params.append(self._create_filters_query_dependency_param()) extra_params.append(self._create_sort_query_dependency_param()) extra_params.extend(include_params) return sig.replace(parameters=params + extra_params + list(additional_dependency_params) + tail_params) 31
  30. БОНУС: большая боль по рекурсивной подгрузке связей "/users?include=bio,posts,posts.comments,posts.comments.author" Что легко:

    • Взять связи с модели: Post->author, Post->comments Что сложно: • С полученных author и comments взять вложенные связи, а с них снова взять вложенные связи 32
  31. Рекурсивная подгрузка связей Шаги: • Посмотреть, какие ресурсы нужно подгружать

    • Построить схемы на лету • Прописать relationships, заполнить includes Костыли: • Всё обрабатываем как список, но в конце, если запросили одну сущность, достаём элемент из списка • Очень много вложенных вызовов ради декомпозиции. Чтобы не пробрасывать много параметров, применили contextvars Это только подготовка к обработке зависимостей (в детали не погружаемся) for related_field_name in include.split(SPLIT_REL): object_schemas = self.jsonapi.schema_builder.create_jsonapi_object_schemas( schema=current_relation_schema, includes=[related_field_name], compute_included_schemas=bool([related_field_name]), ) relationships_schema = object_schemas.relationships_schema schemas_include = object_schemas.can_be_included_schemas current_relation_field: ModelField = current_relation_schema.__fields__[related_field_name] current_relation_schema: Type[TypeSchema] = current_relation_field.type_ relationship_info: RelationshipInfo = current_relation_field.field_info.extra["relationship"] included_object_schema: Type[JSONAPIObjectSchema] = schemas_include[related_field_name] if not isinstance(current_db_item, Iterable): # xxx: less if/else current_db_item = [current_db_item] # ctx vars to skip multi-level args passing relationships_schema_ctx_var.set(relationships_schema) object_schema_ctx_var.set(object_schemas.object_jsonapi_schema) previous_resource_type_ctx_var.set(previous_resource_type) related_field_name_ctx_var.set(related_field_name) relationship_info_ctx_var.set(relationship_info) included_object_schema_ctx_var.set(included_object_schema) current_db_item = self.process_db_items_and_prepare_includes( parent_db_items=current_db_item, included_objects=included_objects, ) 33
  32. Зачем строить схемы на ходу? { "data":{ "type":"user", "id":"1", "attributes":{

    "name":"John" }, "relationships":{} }, "includes": [] } ДОБАВЛЯТЬ НУЖНЫЕ ПОЛЯ (includes, relationships, и тд) Например, мы не запрашивали relationships { "data":{ "type":"user", "id":"1", "attributes":{ "name":"John" } } } VS Используем стандартную схему Схема построенная на основе параметров запроса УБИРАТЬ НЕНУЖНЫЕ ПОЛЯ (не возвращать то, что не запрашивали) 34
  33. Исключения в формате JSON:API Ловим все исключения и возвращаем в

    нужном формате async def base_exception_handler(request: Request, exc: HTTPException): return JSONResponse( status_code=exc.status_code, content={"errors": [exc.as_dict]}, ) def init(app: FastAPI): app.add_exception_handler(HTTPException, base_exception_handler) 35
  34. Модель, схема Подготовка сущностей class User(Base): __tablename__ = "users" id

    = Column(Integer, primary_key=True) name = Column(Text, nullable=True) class UserSchema(BaseModel): name: str Зависимости class SessionDependency(BaseModel): session: AsyncSession = Depends(Connector.get_session) class Config: arbitrary_types_allowed = True def session_dependency_handler( view: ViewBase, dto: SessionDependency, ) -> Dict[str, Any]: return { "session": dto.session, } 37
  35. Никакой бизнес-логики View классы class UserListView(ListViewBaseGeneric): method_dependencies = { HTTPMethod.ALL:

    HTTPMethodConfig( dependencies=SessionDependency, prepare_data_layer_kwargs=session_dependency_handler, ) } class UserDetailView(DetailViewBaseGeneric): method_dependencies = { HTTPMethod.ALL: HTTPMethodConfig( dependencies=SessionDependency, prepare_data_layer_kwargs=session_dependency_handler, ) } 38
  36. Подключение view Указываем • Префикс пути • Теги • View

    классы • Схема • Модель • Тип ресурса router = APIRouter() RoutersJSONAPI( router=router, path="/user", tags=["User"], resource_type="user", class_list=UserListView, class_detail=UserDetailView, schema=UserSchema, model=User, ) 39
  37. Swagger USER GET POST DELETE GET DELETE PATCH /users /users

    /users /users/{obj_id} /users/{obj_id} /users/{obj_id} Get list of 'user' objects Create object 'user' Delete objects 'user' by filters Get object 'user' by id Delete object 'user' by id Patch object 'user' by id 40
  38. filter[status] string (query) Available values: active, archive, block -- filter[email]

    string (query) filter[id] integer (query) filter string (query) filter[email] filter[id] filter Автоматическая документация User Пагинация Фильтрация GET /user Get list of user objects Parameters Name Descriotion page[size] integer (query) Default value : 25 25 page[number] integer (query) Default value : 1 1 page[offset] integer (query) page[limit] integer (query) filter[name] integer (query) page[offset] page[limit] filter[name] Filter docs Examples: • Filter for interval: 41
  39. Документация: sorts, includes sort string (query) include string (query) Available

    includes: posts,bio,computers Sorting docs Examples: • email – sort by email ASC • -email – sort by email DESC • created_at,-email – sort by created_at ASC and by email DESC sort • postrs • bio • computers Default value: posts,bio,computers 42
  40. Создание сущности Request Response POST /users HTTP/1.1 Content-Type: application/vnd.api+json {

    "data": { "type": "user", "attributes": { "name": "John" } } } HTTP/1.1 201 Created Content-Type: application/vnd.api+json { "data": { "attributes": { "name": "John" }, "id": "1", "links": { "self": "/users/1" }, "type": "user" } } 43
  41. Получение сущности GET /users/1 HTTP/1.1 Content-Type: application/vnd.api+json HTTP/1.1 200 OK

    Content-Type: application/vnd.api+json { "data": { "attributes": { "name": "John" }, "id": "1", "links": { "self": "/users/1" }, "type": "user" } } Request Response 44
  42. Что мы получили Не надо писать бизнес-логику, необходимо описывать только

    модели и схемы Избавились от фабрик – всю дополнительную / кастомную логику нужно реализовывать внутри data-layer, переопределяя там методы Поддержка кастомных фильтров бд Права доступа (и прочие зависимости) нужно объявлять в декларативном виде: перечисляя их на модели Сокращено время на ревью Возможность установить айди при создании сущности Избавились от копипасты Меньше кода -> меньше ошибок 45
  43. Что планируем? Атомарные операции (atomic batch actions) Библиотека на GitHub

    https://github.com/mts-ai/FastAPI-JSONAPI Переезд на новую версию FastAPI и Pydantic V2 Поддержка SQLAlchemy 2.0 Relationship resources Обновить поддержку Tortoise-ORM 46
  44. Вопросы https://t.me/mtsai Канал MTS AI Чат по Python в МТС

    Канал Сурена https://t.me/mts_python https://t.me/Khorenyan 47