Год приключений с 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.
Мне сложно назвать себя опытным разработчиком, поэтому буду очень рад любой обратной связи, а также предложениям.
ссылка на оригинал статьи https://habr.com/ru/post/461939/
Добавить комментарий