Год приключений с graphene-python

Год приключений с graphene-python


image


Всем привет, я python-разработчик. Последний год я работал с graphene-python + django ORM и за это время я пытался создать какой-то инструмент, чтобы сделать работу с graphene удобнее. В результате у меня получилась небольшая кодовая база graphene-framework и набор некоторых правил, чем я бы и хотел поделиться.


image


Что такое 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, используя переданные данные и возвращает ошибки, если какие-то условия не соблюдены.


Стоит лишь взглянуть на этот код, как становится видна его нерасширяемость и неподдерживаемость из-за:


  1. Слишком большое количество аргументов у функции mutate, число которых может увеличиться еще, если мы захотим добавить еще поля, подлежащие редактированию.
  2. Чтобы мутации выглядели одинаково со стороны клиента, они должны возвращать errors и ok, чтобы всегда можно было понять их статус и чем он обусловлен.
  3. Поиск и извлечение объекта в функции mutate. Функция мутация оперирует постом, а если его нет, то и мутация не должна происходить.
  4. Проверка прав доступа в мутации. Мутация не должна происходить, если пользователь не имеет прав на это (редактировать некоторый пост).
  5. Бесполезный первый аргумент (корень, который всегда None для полей верхнего уровня, чем и является наша мутация).
  6. Непредсказуемый набор ошибок: если у вас нет исходного кода или документации, то вы не узнаете, какие ошибки может вернуть эта мутация, так как они не отражены в схеме.
  7. Слишком много шаблонных проверок ошибок, которые проводятся непосредственно в методе 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)



Базовый класс мутаций


Как упомянуто в пункте №2, мутации должны возвращать 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
    }
}

Содержать все мутации в глобальной области видимости не лучшая практика. Вот несколько причин почему:


  1. С ростом количества мутаций, все сложнее и сложнее становится найти ту мутацию, которая вам нужна.
  2. Из-за одного пространства имен, необходимо включать в название мутации "название ее модуля", например updatePost.
  3. Необходимо передавать id в качестве аргумента мутации.

Я предлагаю использовать корневые мутации. Их цель решить эти проблемы посредством разделения мутаций в отдельные области видимости и освободить мутации от логики по доступу к объектам и правам доступа к ним.


Новый запрос выглядит так:


mutation($id: ID!, $input: PostUpdateInput!) {
    post(id: $id) {
        update(input: $input) {
            ok
        }
    }
}

Аргументы запроса остаются прежними. Теперь функция изменения "вызывается" внутри post, что позволяет реализовать следующую логику:


  1. Если id не передается в post, то он возвращает {}. Это позволяет продолжить выполнение мутаций внутри. Используется для мутаций, которые не требуют корневого элемента (например, для создания объектов).
  2. Если id передается, происходит извлечение соответствующего элемента.
  3. Если объект не найден, возвращается None и на этом выполнение запроса завершается, мутация не вызывается.
  4. Если объект найден, то проверить права пользователя на манипуляции над ним.
  5. Если у пользователя нет прав, возвращается None и на этом выполнение запроса завершается, мутация не вызывается.
  6. Если у пользователя права есть, то возвращается найденный объект и мутация получает его в качестве корня — первого аргумента.

Таким образом, код мутации меняется на:


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.


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


Исходный код можно посмотреть здесь.

Комментарии 4

    0

    Рассматривали ли, возможность передавать информацию о структурах и типах через метаданные в DRF? Если да, то почему предпочли описывать собственные интерфейсы для мутаций объектов?


    Возникает ли проблема select n+1 при попытке использовать фильтр по id?


    P.S.: Долгое время искал в себе мотивацию сделать себе адаптер для graphql, но всегда быстрее было дополнить drf.

      0

      В проекте вообще не использовал DRF, поэтому и не рассматривал возможность какой бы то ни было интеграции. А что имеется ввиду под "информацией о структурах и типах"?


      Проблема select n + 1 решается при помощи пакета graphene-django-optimizer, он анализирует запрос и по возможности оптимизирует обращение к БД, а также позволяет описывать правила для оптимизации в более сложных ситуациях.

        0
        DRF предоставляет схемы для View по options-запросу, что позволяет передавать описание типов на каждое поле форм и описывать ограничения только со стороны бекенда.
          0

          Да, занятная шутка. Но она для DRF, а он никак не клеится с graphql. Но спасибо за ссылку!

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое