Использование Ariadne и его интеграция c FastAPI и Starlette

от автора

С недавнего времени в Starlette прекращена поддержка GraphQL. Так что если вы, как и мы, занимались разработкой сервиса на FastAPI, то обновления до последней версии Starlette вас неприятно удивили.

Причины, по которым это случилось, не столь важны, остается просто принять произошедшее как данность. Но переходить с GraphQL обратно на REST нам не хотелось, стандарт подходил под наши задачи, а поэтому надо было найти альтернативу. Всем привет, это Данил Максимов, программист ZeBrains, в этой статье я расскажу, почему после обновления «жить стало лучше, жить стало веселее»(с), и на что надо обратить внимание при миграции на альтернативное решение.

Выбор библиотеки: почему мы остановились на Ariadne

Самый простой вариант выглядел очевидным: поддержка GraphQL в Starlette изначально была построена на базе Graphene, а в качестве одной из альтернатив предлагалась библиотека starlette-graphene3. Но мы уже успели «оценить» и слишком лаконичную документацию, и отсутствие стандартов для написания кода, и проблемы с расширяемостью. 

А потому свой выбор мы остановили на Ariadne: 

  • У него достаточно объемная и понятная документация.

  • Он построен на базе Apollo Federation, что позволяет пользоваться всеми плюшками от него.

  • Активно развивается и не является форками или доработками чужих решений — это самостоятельный продукт.

Главное, что привлекло наше внимание — в отличие от Graphene Ariadne идет от «обратного». Основа — graphql-схема, а по ней строятся все запросы, мутации или подписки.

Это дает гораздо больше гибкости для работы с типами, а также позволяет выстроить систему ошибок и описать ее в схеме. В варианте с использованием решения от Graphene набор ошибок, которые может вернуть мутация, никак не отражен в схеме (только если вы не патчите вручную методы ее построения), а значит — непредсказуем, если у вас нет исходного кода или документации. 

Кроме того, Ariadne прекрасно поддерживает на уровне библиотеки механизм подписок, и для этого не придется тянуть лишние либы (в отличие от того же Graphene). Плюс поддержка передачи файлов при работе с GraphQL изначально была не самой простой задачей, а Ariadne предоставляет ее «из коробки».

Реализация мутаций, запросов и подписок

Писать очередную статью «как переехать с библиотеки ХХХ на библиотеку YYY» — неинтересно. Если вы дочитали до этого момента, значит — в состоянии самостоятельно установить нужные зависимости. Мы же поговорим о том, на что стоит обратить внимание после переезда, что изменится непосредственно в коде. Рассматривать будем, как водится, на примере классического todo-приложения, демо-версия доступна по ссылке.

Запросы и мутации

В GraphQL запросы обрабатываются с помощью резолверов (преобразователей), каждый из которых принимает в себя два позиционных аргумента: obj и info. Пример из документации Ariadne:

def example_resolver(obj: Any, info: GraphQLResolveInfo):    return obj.do_something()   class FormResolver:    def __call__(self, obj: Any, info: GraphQLResolveInfo, **data):     . . .

Из кода выше мы видим, что нам доступны как функциональный, так и ООП подход. Первым делом — определим тип запросов Query в .graphql:

queries/schema.graphql

…  type Query {     getTasks(userId: ID!): [TaskType]!     getTask(userId: ID!, taskId: ID!): TaskType! }  …

Привяжем резолвер к допустимому типу поля схемы с помощью ObjectType, для которого необходимо будет указать метод .set_field(). Он принимает в себя два параметра: name, которое связывает его с одноименным полем схемы GraphQL и, собственно, нужный нам резолвер.

queries/__init__.py

from ariadne import ObjectType  from ariadne_example.app.api.queries import task  queries = ObjectType("Query")  queries.set_field("getTasks", task.resolve_get_user_tasks) queries.set_field("getTask", task.resolve_get_user_task_by_id)

Сами резолверы импортируются из отдельного файла, давайте их напишем:

queries/task.py

import json from typing import Any, List  from ariadne import convert_kwargs_to_snake_case from graphql import GraphQLResolveInfo from graphql_relay.node.node import from_global_id from sqlmodel import select  from ariadne_example.app.db.session import Session, engine from ariadne_example.app.models import Task  @convert_kwargs_to_snake_case def resolve_get_user_tasks(         obj: Any,         info: GraphQLResolveInfo,         user_id: str, ) -> List[dict]:     """Get user tasks"""     with Session(engine) as session:         local_user_id, _ = from_global_id(user_id)         statement = select(Task).where(Task.user_id == int(local_user_id))         tasks = session.execute(statement).scalars().all()         return [             Task(                 id=task.id,                 created_at=task.created_at,                 title=task.title,                 status=task.status,                 user_id=task.user_id             ).dict()             for task in tasks         ]  @convert_kwargs_to_snake_case def resolve_get_user_task_by_id(         obj: Any,         info: GraphQLResolveInfo,         task_id: str,         user_id: str, ) -> dict:     """Get user task by task ID."""     with Session(engine) as session:         local_task_id, _ = from_global_id(task_id)         local_user_id, _ = from_global_id(user_id)         statement = select(Task).where(Task.user_id == local_user_id, Task.id == local_task_id)         task = session.execute(statement).scalar_one()         return Task(             id=task.id,             created_at=task.created_at,             title=task.title,             status=task.status,             user_id=task.user_id,         ).dict()

Стандартный для todo-приложения набор резолверов, с помощью которого мы получаем список всех задач пользователя или какую-то конкретную задачу. 

Чуть сложнее ситуация обстоит с мутациями. Поскольку в Ariadne основой всего является схема .graphql, добавим в нее тип, соответствующий нашим мутациям:

schema.graphql

. . .  type Mutations {     createTask(userId: ID!, taskInput: TaskInput): Response     changeTaskStatus(taskId: ID!, newSatus: TaskStatusEnum): Response }  . . .

Для обработки схемы нам потребуется резолвер, который мы сопоставим с мутацией.

mutations/__init__.py

from ariadne import ObjectType  from .task import resolve_create_task  mutations = ObjectType('Mutation')  mutations.set_field('createTask', resolve_create_task)

В коде выше мы импортировали резолвер из файла task.py, давайте его напишем. 

Резолверы мутаций в Ariadne — функции, которые принимают в себя аргументы parent и info, а также произвольный набор аргументов, относящихся к мутации, и возвращают данные, которые отправляются пользователю как результат запроса. В нашем примере описаны два резолвера, отвечающие за создание новой задачи и изменение статуса уже существующей:

mutations/task.py

from typing import Any  import sqlalchemy.exc from ariadne import convert_kwargs_to_snake_case from graphql.type.definition import GraphQLResolveInfo from graphql_relay.node.node import from_global_id from sqlmodel import select  from ariadne_example.app.db.session import Session, engine from ariadne_example.app.core.struсtures import TaskStatusEnum, TASK_QUEUES from ariadne_example.app.models import Task from ariadne_example.app.core.exceptions import NotFoundError  @convert_kwargs_to_snake_case def resolve_create_task(         obj: Any,         info: GraphQLResolveInfo,         user_id: str,         task_input: dict, ) -> int:     with Session(engine) as session:         local_user_id, _ = from_global_id(user_id)         try:             task = Task(                 title=task_input.get("title"),                 created_at=task_input.get("created_at"),                 status=task_input.get("status"),                 user_id=local_user_id             )             session.add(task)             session.commit()             session.refresh(task)         except sqlalchemy.exc.IntegrityError:             raise NotFoundError(msg='Не найден пользователь с таким user_id')         return task.id   @convert_kwargs_to_snake_case async def resolve_change_task_status(         obj: Any,         info: GraphQLResolveInfo,         new_status: TaskStatusEnum,         task_id: str, ) -> None:     with Session(engine) as session:         local_task_id, _ = from_global_id(task_id)         try:             statement = select(Task).where(Task.id == local_task_id)             task = session.execute(statement)             task.status = new_status             session.add(task)             session.commit()             session.refresh(task)         except sqlalchemy.exc.IntegrityError:             raise NotFoundError(msg='Не найдена задача с таким task_id')         for queue in TASK_QUEUES:             queue.put(task)

Тут важно обратить внимание, что полезная нагрузка, которую возвращает мутация, представлена в виде простого dict. У нас нет возможности реализовать, как в Graphene класс, и указать в нем ожидаемые поля:

(вариант graphene)task.py

. . .  class CreateTask(graphene.Mutation):     task = graphene.Field(Task)      class Arguments:         user_id = graphene.ID()         input_data = graphene.Field()      def mutate(self, parent, info, user_id: str, input_data: Task):         local_user_id, _ = from_global_id(user_id)         session = get_session()         task = Task(title=input_data.get("title"), user_id=local_user_id)         session.add(task)         session.commit()         session.refresh()         return CreateTask(task=task)   . . .

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

Подписки в Ariadne

По устоявшейся традиции, первым делом определим тип в схеме:

schema.graphql

. . .  type Subscription {     taskStatusChanged: TaskType! }  . . .

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

Реализовывать это мы будем «по классике», с использованием WebSockets. Но просто открыть сокет — мало, нам понадобится генератор, который будет передавать данные при их изменении. Кроме того, не помешает иметь и «приемник», в который эти данные будут поступать.

subscriptions.py

import asyncio from typing import Any  from ariadne import convert_kwargs_to_snake_case, SubscriptionType from graphql import GraphQLResolveInfo  from ariadne_example.app.core.struсtures import TASK_QUEUES from ariadne_example.app.models import Task  subscription = SubscriptionType()   @subscription.source("taskStatusChanged") @convert_kwargs_to_snake_case async def task_source(obj: Any, info: GraphQLResolveInfo):     queue = asyncio.Queue()     TASK_QUEUES.append(queue)     try:         while True:             change_task = await queue.get()             queue.task_done()             yield change_task     except asyncio.CancelledError:         TASK_QUEUES.remove(queue)         raise   @subscription.field("taskStatusChanged") @convert_kwargs_to_snake_case def task_resolver(task: Task, info: Any):     return task

Источник подписки мы указываем в subscription.source(«taskStatusChanged»), генератор открывает сокет и транслирует нужные нам данные, а резолвер принимает их и передает пользователю.

Ошибки, эксепшены и мидлвары

Все, изложенное выше — сродни обычному тестовому заданию «напишите todo с использованием следующих технологий…». А теперь — поговорим серьезно 🙂

Пункт первый — мы отложили «на сладкое» вопрос формализации полезной нагрузки в мутациях. Пункт второй — классический слой ошибок GraphQL в целом позволяет прокинуть код ошибки в extensions и потом его оттуда получать, но для фронта было проблемой определить,  какой именно запрос или мутация завершился с ошибкой и как на эти ошибки реагировать.

Вспомним, что Ariadne пропагандирует подход «от схемы к коду», и добавим в .graphql нужные нам типы ошибок и статусов задач:

schema.graphql

. . .  enum ErrorTypeEnum {     SERVER_ERROR     NOT_FOUND_ERROR     VALIDATION_ERROR }  type ErrorType {     message: String     code: ErrorTypeEnum!     text: String }  enum TaskStatusEnum {     draft     in_process     delete     done }  . . .

Вынесем логику обработки в core и зададим структуру:

core/structures.py

import enum from typing import Optional from dataclasses import dataclass  from ariadne import EnumType, ScalarType   class ErrorTypes(enum.Enum):     SERVER_ERROR = enum.auto()     NOT_FOUND_ERROR = enum.auto()     VALIDATION_ERROR = enum.auto()   @dataclass class ErrorScalar:     message: Optional[str]     code: ErrorTypes     text: Optional[str]   class TaskStatusEnum(enum.Enum):     draft = "draft"     in_process = "in_process"     delete = "delete"     done = "done"  task_type_enum = EnumType("TaskStatusEnum", TaskStatusEnum) datetime_scalar = ScalarType("DateTime")  @datetime_scalar.serializer def serialize_datetime(value):     return value.isoformat()  TASK_QUEUES = []

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

core/exceptions.py

from typing import Optional, Dict, Any  from graphql import GraphQLError  from ariadne_example.app.core.struсtures import ErrorTypes, ErrorScalar   class BaseGraphQLError(GraphQLError):     def __init__(self, msg: str = "Server Error", extensions: Optional[Dict[str, Any]] = None):         if not hasattr(self, "_extensions"):             self._extensions = {"code": ErrorTypes.SERVER_ERROR.name}         if extensions is not None:             self._extensions = {**self._extensions, **extensions}         super().__init__(msg, extensions=self._extensions)      def parse(self) -> ErrorScalar:         parsed_exception = ErrorScalar(             message=self.extensions.get("user_message"),             code=self.extensions.get("code"),             text=self.message,         )         return parsed_exception   class ValidationError(BaseGraphQLError):     def __init__(self, msg: str, extensions: Optional[Dict[str, Any]] = None):         self._extensions = {"code": ErrorTypes.VALIDATION_ERROR.name}         super().__init__(msg, extensions=extensions)   class NotFoundError(BaseGraphQLError):     def __init__(self, msg: str, extensions: Optional[Dict[str, Any]] = None):         self._extensions = {"code": ErrorTypes.NOT_FOUND_ERROR.name}         super().__init__(msg, extensions=extensions)

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

core/middlewares.py

from ariadne.contrib.tracing.utils import is_introspection_field  from ariadne_example.app.core.exceptions import BaseGraphQLError   async def handle_error_middleware(resolver, obj, info, **args):     """     Если на этапе выполнения мутации или запроса будет выброшено исключение,     перехватить и вывести в качестве ошибки.     """     errors = []     value = {}      if is_introspection_field(info):         return resolver(obj, info, **args)      try:         value = await resolver(obj, info, **args)     except BaseGraphQLError as exc:         errors.append(exc.parse())         value = {**value, **{'errors': errors}}     except TypeError:         value = resolver(obj, info, **args)     return value

Краткий итог

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

Нам не хватало системы ошибок, чтобы фронт мог связать каждую с конкретным запросом или мутацией. Благодаря (пусть и вынужденному) переходу на Ariadne — мы получили возможность гибко настраивать схему GraphQL под свои задачи, через мидлвары автоматически перехватывать нужные исключения и форматировать их для работы с ошибками на фронте. А самое главное — у нас заработали подписки!

По ссылке — оба варианта реализации тестового приложения todo: на базе  Starlette, и на базе Ariadne. Вопросы, пожелания и проклятия — можно постить в комментариях или ко мне в телеграм @maximovd


ссылка на оригинал статьи https://habr.com/ru/articles/595777/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *