С недавнего времени в 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
