Slide 1

Slide 1 text

Костыли, баги, проблемы Как мы делали библиотеку для JSON:API на FastAPI + pydantic

Slide 2

Slide 2 text

Руководитель группы разработки платформы видеонаблюдения и видеоаналитики в МТС ИИ Сурен Хоренян 02

Slide 3

Slide 3 text

Что за JSON:API? The Bikeshed effect - эффект сарая 03

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

Что за JSON:API? JSON:API это anti-bikeshed-effect • Строгая спецификация: *касается только CRUD, но не бизнес-логики описаны требования в формате must, must not • Мало вариативности: только незначительные пункты описаны как may, may not • Единообразие: любой ресурс (от самого простого до самого комплексного) построен по единому формату API, не требуется дополнительное документирование* 05

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

Общий стек между продуктовыми командами Зачем переезжать на FastAPI 07

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

Схемы для валидации Первые шаги TortoiseORM (на тот момент ещё не было даже SQLA 1.4) 08

Slide 14

Slide 14 text

Благодаря изначально заложенной архитектуре со слоями переехать на 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

Slide 15

Slide 15 text

Обработка запроса в FastAPI Как создавать Views? Валидация данных через Pydantic Подготовка данных через фабрику Создание объекта через фабрику Из этого состоят стандартные CRUD Возврат данных (pydantic, FastAPI) 10

Slide 16

Slide 16 text

Код для 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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

Имена схем 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

Slide 22

Slide 22 text

Имена схем 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

Slide 23

Slide 23 text

СОЗДАВАТЬ ДВУСТОРОННИЕ СВЯЗКИ: “Выравнивать” связи - из строковых аннотаций типов получать реальные объекты Строить отношения между схемами Знать обо всех созданных схемах 18

Slide 24

Slide 24 text

Решение: Знать обо всех созданных схемах Строить отношения между схемами 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

Slide 25

Slide 25 text

Создана вспомога- тельная схема, чтобы передавать дополнительную информацию об отношении (связке): Строить отношения между схемами 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

Slide 26

Slide 26 text

Создание связей от 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

Slide 27

Slide 27 text

Создание связей от 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

Slide 28

Slide 28 text

Как отрезолвить связанные схемы Строить отношения между схемами def create_jsonapi_object_schemas(...) -> JSONAPIObjectSchemas: ... schema.update_forward_refs(**registry.schemas) • schema.update_forward_refs • схемы по именам доступны в registry 23

Slide 29

Slide 29 text

Поле 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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

Инициализировать 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

Slide 32

Slide 32 text

Инициализировать 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

Slide 33

Slide 33 text

ПЕРВЫЙ ПОДХОД Как принимать FastAPI зависимости? На view объявить дополнительный метод init_dependencies В начале каждого запроса подготавливать зависимости и устанавливать на текущий view (он же инициализируется на каждый запрос) 28

Slide 34

Slide 34 text

Модификация текущего 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

Slide 35

Slide 35 text

Зависимости как модель 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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

БОНУС: большая боль по рекурсивной подгрузке связей "/users?include=bio,posts,posts.comments,posts.comments.author" Что легко: • Взять связи с модели: Post->author, Post->comments Что сложно: • С полученных author и comments взять вложенные связи, а с них снова взять вложенные связи 32

Slide 38

Slide 38 text

Рекурсивная подгрузка связей Шаги: • Посмотреть, какие ресурсы нужно подгружать • Построить схемы на лету • Прописать 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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

Исключения в формате 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

Slide 41

Slide 41 text

Применение на практике Наконец, к коду

Slide 42

Slide 42 text

Модель, схема Подготовка сущностей 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

Slide 43

Slide 43 text

Никакой бизнес-логики 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

Slide 44

Slide 44 text

Подключение 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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

Документация: 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

Slide 48

Slide 48 text

Создание сущности 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

Slide 49

Slide 49 text

Получение сущности 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

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

Что планируем? Атомарные операции (atomic batch actions) Библиотека на GitHub https://github.com/mts-ai/FastAPI-JSONAPI Переезд на новую версию FastAPI и Pydantic V2 Поддержка SQLAlchemy 2.0 Relationship resources Обновить поддержку Tortoise-ORM 46

Slide 52

Slide 52 text

Вопросы https://t.me/mtsai Канал MTS AI Чат по Python в МТС Канал Сурена https://t.me/mts_python https://t.me/Khorenyan 47

Slide 53

Slide 53 text

Спасибо за внимание! 48