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