Год приключений с graphene-python
Всем привет, я python-разработчик. Последний год я работал с graphene-python + django ORM и за это время я пытался создать какой-то инструмент, чтобы сделать работу с graphene удобнее. В результате у меня получилась небольшая кодовая база graphene-framework
и набор некоторых правил, чем я бы и хотел поделиться.
Что такое graphene-python?
Если верить graphene-python.org, то:
Graphene-Python — это библиотека для простого создания GraphQL APIs используя Python. Ее главная задача — предоставить простое, но в то же время расширяемое API, чтобы сделать жизнь программистов проще.
Ее главная задача — предоставить простое, но в то же время расширяемое API, чтобы сделать жизнь программистов проще.
Да, в действительности graphene простой и расширяемый, но, как мне кажется, слишком простой для больших и быстрорастущих приложений. Короткая документация (вместо нее я использовал исходных код — он намного более многословен), а также отсутствие стандартов написания кода делает эту библиотеку не лучшим выбором для вашего следующего API.
Как бы то ни было, я решил использовать ее в проекте и столкнулся с рядом проблем, к счастью, решив большую часть из них (спасибо богатым недокументированным возможностям graphene). Некоторые из моих решений чисто архитектурные и могут быть использованы "из коробки", без моего "фреймворка". Однако остальная их часть все же требует некоторой кодовой базы.
Эта статья — не документация, а в каком-то смысле короткое описание того пути, что я прошел и проблем, что я решил тем или иным способом с кратким обоснованием моего выбора. В этой части я уделил внимание мутациям и вещам, связанным с ними.
Цель написания статьи — получить любую значимую обратную связь, так что буду ждать критику в комментариях!
Замечание: перед тем, как продолжить чтение статьи, настоятельно рекомендую ознакомиться с тем, что такое GraphQL.
Мутации
Большая часть обсуждений о GraphQL сфокусирована на получении данных, однако любая уважающая себя платформа также требует способ изменять данные, хранящиеся на сервере.
Давайте начнем с мутаций.
Рассмотрим следующий код:
class UpdatePostMutation(graphene.Mutation):
class Arguments:
post_id = graphene.ID(required=True)
title = graphene.String(required=True)
content = graphene.String(required=True)
image_urls = graphene.List(graphene.String, required=False)
allow_comments = graphene.Boolean(required=True)
contact_email = graphene.String(required=True)
ok = graphene.Boolean(required=True)
errors = graphene.List(graphene.String, required=True)
def mutate(_, info, post_id, title, content, image_urls, allow_comments, contact_email):
errors = []
try:
post = get_post_by_id(post_id)
except PostNotFound:
return UpdatePostMutation(ok=False, errors=['post_not_found'])
if not info.context.user.is_authenticated:
errors.append('not_authenticated')
if len(title) < TITLE_MIN_LENGTH:
errors.append('title_too_short')
if not is_email(contact_email):
errors.append('contact_email_not_valid')
if post.owner != info.context.user:
errors.append('not_post_owner')
if Post.objects.filter(title=title).exists():
errors.append('title_already_taken')
if not errors:
post = Utils.update_post(post, title, content, image_urls, allow_comments, contact_email)
return UpdatePostMutation(ok=bool(errors), errors=errors)
UpdatePostMutation
изменяет пост с заданным id
, используя переданные данные и возвращает ошибки, если какие-то условия не соблюдены.
Стоит лишь взглянуть на этот код, как становится видна его нерасширяемость и неподдерживаемость из-за:
- Слишком большое количество аргументов у функции
mutate
, число которых может увеличиться еще, если мы захотим добавить еще поля, подлежащие редактированию. - Чтобы мутации выглядели одинаково со стороны клиента, они должны возвращать
errors
иok
, чтобы всегда можно было понять их статус и чем он обусловлен. - Поиск и извлечение объекта в функции
mutate
. Функция мутация оперирует постом, а если его нет, то и мутация не должна происходить. - Проверка прав доступа в мутации. Мутация не должна происходить, если пользователь не имеет прав на это (редактировать некоторый пост).
- Бесполезный первый аргумент (корень, который всегда
None
для полей верхнего уровня, чем и является наша мутация). - Непредсказуемый набор ошибок: если у вас нет исходного кода или документации, то вы не узнаете, какие ошибки может вернуть эта мутация, так как они не отражены в схеме.
- Слишком много шаблонных проверок ошибок, которые проводятся непосредственно в методе
mutate
, который предполагает изменение данных, а не разнообразные проверки. Идеальныйmutate
должен состоять из одной строки — вызова функции редактирования поста.
Вкратце, mutate
должен изменять данные, а не заботиться о таких сторонних задачах, как доступ к объектам и проверка входных данных. Наша цель прийти к чему-то вроде:
def mutate(post, info, input):
post = Utils.update_post(post, **input)
return UpdatePostMutation(post=post)
А теперь давайте разберем пункты выше.
Пользовательские типы
Поле email
передается как строка, в то время как это строка определенного формата. Каждый раз API принимает email, он должен проверять его корректность. Так что лучшим решением будет создать пользовательский тип.
class Email(graphene.String):
# ...
Это может выглядеть очевидным, однако стоило упоминания.
Входные типы
Используйте входные типы для своих мутаций. Даже если они не подлежат переиспользованию в других местах. Благодаря входным типам запросы становятся меньше, следовательно их проще читать и быстрее писать.
class UpdatePostInput(graphene.InputObjectType):
title = graphene.String(required=True)
content = graphene.String(required=True)
image_urls = graphene.List(graphene.String, required=False)
allow_comments = graphene.Boolean(required=True)
contact_email = graphene.String(required=True)
До:
mutation(
$post_id: ID!,
$title: String!,
$content: String!,
$image_urls: String!,
$allow_comments: Boolean!,
$contact_email: Email!
) {
updatePost(
post_id: $post_id,
title: $title,
content: $content,
image_urls: $image_urls,
allow_comments: $allow_comments,
contact_email: $contact_email,
) {
ok
}
}
После:
mutation($id: ID!, $input: UpdatePostInput!) {
updatePost(id: $id, input: $input) {
ok
}
}
Код мутации изменяется на:
class UpdatePostMutation(graphene.Mutation):
class Arguments:
input = UpdatePostInput(required=True)
id = graphene.ID(required=True)
ok = graphene.Boolean(required=True)
errors = graphene.List(graphene.String, required=True)
def mutate(_, info, input, id):
# ...
if not errors:
post = Utils.update_post(post, **input.__dict__)
return UpdatePostMutation(errors=errors)
Базовый класс мутаций
Как упомянуто в пункте №7, мутации должны возвращать errors
и ok
, чтобы всегда можно было понять их статус и чем он обусловлен. Это достаточно просто, мы создаем базовый класс:
class MutationPayload(graphene.ObjectType):
ok = graphene.Boolean(required=True)
errors = graphene.List(graphene.String, required=True)
query = graphene.Field('main.schema.Query', required=True)
def resolve_ok(self, info):
return len(self.errors or []) == 0
def resolve_errors(self, info):
return self.errors or []
def resolve_query(self, info):
return {}
Несколько замечаний:
- Реализован метод
resolve_ok
, так что нам не придется рассчитыватьok
самим. - Поле
query
— это корневойQuery
, который позволяет запрашивать данные прямо внутри запроса мутации (данные будут запрошены после выполнения мутации).mutation($id: ID!, $input: PostUpdateInput!) { updatePost(id: $id, input: $input) { ok query { profile { totalPosts } } } }
Это очень удобно, когда клиент обновляет некоторые данные после выполнения мутации и не хочет просить бекендера вернуть весь этот набор. Чем меньше кода вы пишете, тем проще его обслуживать. Эту идею я взял отсюда.
С базовым классом мутации код превращается в:
class UpdatePostMutation(MutationPayload, graphene.Mutation):
class Arguments:
input = UpdatePostInput(required=True)
id = graphene.ID(required=True)
def mutate(_, info, input, id):
# ...
Корневые мутации
Наш запрос мутации сейчас выглядит так:
mutation($id: ID!, $input: PostUpdateInput!) {
updatePost(id: $id, input: $input) {
ok
}
}
Содержать все мутации в глобальной области видимости не лучшая практика. Вот несколько причин почему:
- С ростом количества мутаций, все сложнее и сложнее становится найти ту мутацию, которая вам нужна.
- Из-за одного пространства имен, необходимо включать в название мутации "название ее модуля", например
update
Post
. - Необходимо передавать
id
в качестве аргумента мутации.
Я предлагаю использовать корневые мутации. Их цель решить эти проблемы посредством разделения мутаций в отдельные области видимости и освободить мутации от логики по доступу к объектам и правам доступа к ним.
Новый запрос выглядит так:
mutation($id: ID!, $input: PostUpdateInput!) {
post(id: $id) {
update(input: $input) {
ok
}
}
}
Аргументы запроса остаются прежними. Теперь функция изменения "вызывается" внутри post
, что позволяет реализовать следующую логику:
- Если
id
не передается вpost
, то он возвращает{}
. Это позволяет продолжить выполнение мутаций внутри. Используется для мутаций, которые не требуют корневого элемента (например, для создания объектов). - Если
id
передается, происходит извлечение соответствующего элемента. - Если объект не найден, возвращается
None
и на этом выполнение запроса завершается, мутация не вызывается. - Если объект найден, то проверить права пользователя на манипуляции над ним.
- Если у пользователя нет прав, возвращается
None
и на этом выполнение запроса завершается, мутация не вызывается. - Если у пользователя права есть, то возвращается найденный объект и мутация получает его в качестве корня — первого аргумента.
Таким образом, код мутации меняется на:
class UpdatePostMutation(MutationPayload, graphene.Mutation):
class Arguments:
input = UpdatePostInput()
def mutate(post, info, input):
if post is None:
return None
errors = []
if not info.context.user.is_authenticated:
errors.append('not_authenticated')
if len(title) < TITLE_MIN_LENGTH:
errors.append('title_too_short')
if Post.objects.filter(title=title).exists():
errors.append('title_already_taken')
if not errors:
post = Utils.update_post(post, **input.__dict__)
return UpdatePostMutation(errors=errors)
- Корень мутации — первый аргумент — теперь объект типа
Post
, над которым и производится мутация. - Проверка прав доступа перенесена в код корневой мутации.
Код корневой мутации:
class PostMutationRoot(MutationRoot):
class Meta:
model = Post
has_permission = lambda post, user: post.owner == user
update = UpdatePostMutation.Field()
Интерфейс ошибок
Чтобы сделать набор ошибок предсказуемым, они должны быть отражены в схеме.
- Так как мутации могут вернуть несколько ошибок, то ошибки должны быть быть списком
- Так как ошибки представлены разными типами, для конкретной мутации должен существовать свой
Union
ошибок. - Чтобы ошибки оставались похожими друг на друга, они должны реализовывать интерфейс, назовем его
ErrorInterface
. Пусть он содержит два поля:ok
иmessage
.
Таким образом, ошибки должны иметь тип [SomeMutationErrorsUnion]!
. Все подтипы SomeMutationErrorsUnion
должны реализовывать ErrorInterface
.
Получаем:
class NotAuthenticated(graphene.ObjectType):
message = graphene.String(required=True, default_value='not_authenticated')
class Meta:
interfaces = [ErrorInterface, ]
class TitleTooShort(graphene.ObjectType):
message = graphene.String(required=True, default_value='title_too_short')
class Meta:
interfaces = [ErrorInterface, ]
class TitleAlreadyTaken(graphene.ObjectType):
message = graphene.String(required=True, default_value='title_already_taken')
class Meta:
interfaces = [ErrorInterface, ]
class UpdatePostMutationErrors(graphene.Union):
class Meta:
types = [NotAuthenticated, TitleIsTooShort, TitleAlreadyTaken, ]
Выглядит неплохо, но слишком много кода. Используем метакласс, чтобы генерировать эти ошибки на лету:
class PostErrors(metaclass=ErrorMetaclass):
errors = [
'not_authenticated',
'title_too_short',
'title_already_taken',
]
class UpdatePostMutationErrors(graphene.Union):
class Meta:
types = [PostErrors.not_authenticated, PostErrors.title_too_short, PostErrors.title_already_taken, ]
Добавим объявление возвращаемых ошибок в мутацию:
class UpdatePostMutation(MutationPayload, graphene.Mutation):
class Arguments:
input = UpdatePostInput()
errors = graphene.List(UpdatePostMutationErrors, required=True)
def mutate(post, info, input):
# ...
Проверка на наличие ошибок
Мне кажется, что метод mutate
не должен заботиться о чем-либо, кроме мутации данных. Чтобы этого достичь, необходимо вынести проверку на наличие ошибок их кода этой функции.
Опуская реализацию, вот результат:
class UpdatePostMutation(DefaultMutation):
class Arguments:
input = UpdatePostInput()
class Meta:
root_required = True
authentication_required = True # Может быть опущено, так как равно True по умолчанию
# An iterable of tuples (error_class, checker)
checks = [
(
PostErrors.title_too_short,
lambda post, input: len(input.title) < TITLE_MIN_LENGTH
),
(
PostErrors.title_already_taken,
lambda post, input: Post.objects.filter(title=input.title).exists()
),
]
def mutate(post, info, input):
post = Utils.update_post(post, **input.__dict__)
return UpdatePostMutation()
Перед началом выполнения функции mutate
, вызывается каждый checker (второй элемент членов массива checks
). Если возвращено True
— найдена соответствующая ошибка. Если ни одной ошибки не найдено, происходит вызов функции mutate
.
Поясню:
- Функции-проверки принимают те же аргументы, что и функция
mutate
. - Функции проверки должны вернуть
True
, если найдена ошибка. - Проверки авторизации и наличия корневого элемента достаточно общие и вынесены в флаги
Meta
. authentication_required
добавляет проверку авторизации если равноTrue
.root_required
добавляет "root is not None
" проверку.UpdatePostMutationErrors
больше не требуется. Юнион возможных ошибок создается на лету в зависимости от классов ошибок массиваchecks
.
Дженерики
DefaultMutation
, использованная в прошлом разделе, добавляет pre_mutate
метод, который позволяет изменить входные аргументы до проверки ошибок, и, соответственно, вызова мутации.
Также присутствует стартовый набор дженериков, которые делают код короче, а жизнь проще.
Примечание: на данный момент код дженериков специфичен для django ORM
CreateMutation
Требует один из параметров model
или create_function
. По умолчанию create_function
выглядит так:
model._default_manager.create(**data, owner=user)
Это может выглядеть небезопасно, однако не забывайте о том, что есть встроенная проверка типов в graphql, а также проверки в мутации.
Также предоставляет post_mutate
метод, который вызывается после create_function
с аргументами (instance_created, user)
, результат которой будет возвращен клиенту.
UpdateMutation
Позволяет задать update_function
. По умолчанию:
def default_update_function(instance, user=None, **data):
instance.__dict__.update(data)
instance.save()
return instance
root_required
равен True
по умолчанию.
Также предоставляет post_mutate
метод, который вызывается после update_function
с аргументами (instance_updated, user)
, результат которой будет возвращен клиенту.
И это то, что нам нужно!
Итоговый код:
class UpdatePostMutation(UpdateMutation):
class Arguments:
input = UpdatePostInput()
class Meta:
checks = [
(
PostErrors.title_too_short,
lambda post, input: len(input.title) < TITLE_MIN_LENGTH
),
(
PostErrors.title_already_taken,
lambda post, input: Post.objects.filter(title=input.title).exists()
),
]
DeleteMutation
Позволяет задать delete_function
. По умолчанию:
def default_delete_function(instance, user=None, **data):
instance.delete()
Заключение
В данной статье рассмотрен только один аспект, хоть на мой взгляд он и самый сложный. У меня есть некоторые мысли о резолверах и типах, а также общих вещах в graphene-python.
Мне сложно назвать себя опытным разработчиком, поэтому буду очень рад любой обратной связи, а также предложениям.
Исходный код можно посмотреть здесь.
Автор: donnyyyyy