Речь в статье пойдет о специальных инструментах для работы с GraphQL на Python в качестве клиента. Если вы используете GraphQL в своих сервисах на Python, то, скорее всего, сталкивались с необходимостью писать и хранить строки, содержащие GraphQL запросы, а так же писать Python классы для хранения результатов этих запросов. Вероятнее всего, вам было неудобно: не хотелось дублировать схожие запросы, постоянно править классы при частом изменении схемы и т.п. Вот и мне тоже. Поэтому, решая описанные проблемы, появились на свет два пакета: graphql-query и graphql2python.

Запросы с пакетом graphql-query

Почти все популярные GraphQL клиенты на Python (https://graphql.org/code/#python-client) предлагают писать запросы в виде строк, т.е. не предоставляют инструменты для генерации валидных запросов из классов. Поэтому для их использования необходимо писать свои шаблоны, которые создают строки запросов из фрагментов, общих параметров и т.п.

graphql-query -- это набор специальных классов, реализующих объекты GraphQL запросов (https://graphql.org/learn/queries/) с методами render, которые генерируют результирующую строку с запросом на основе созданных шаблонов. В качестве шаблонов исользуется классический пакет jinja2.

Подробная документация graphql-query доступна по ссылке https://denisart.github.io/graphql-query/. В данной статье разберем как реализовать несколько примеров запросов из официальной документации GraphQL.

Самый простой запрос

Для быстрой демонстрации посмотрим как создать очень короткий и простой запрос. Такой запрос в документации выглядит так

{
  hero {
    name
  }
}

Реализовать его на graphql-query можно следующим образом

from graphql_query import Operation, Query

hero = Query(name="hero", fields=["name"])
operation = Operation(type="query", queries=[hero])

print(operation.render())
# query {
#  hero {
#    name
#  }
# }

Метод render для объекта graphql_query.Operation как раз возвращает финальную строку с запросом. Внутри массива fields объекта graphql_query.Query можно передавать

  • строки (имена полей);

  • объекты graphql_query.Field;

  • фрагменты и встроенные фрагменты (Inline Fragments);

Фрагменты, аргументы и переменные

Рассмотри запрос, который содержит переменные (Variables), аргументы (Arguments) и фрагменты (Fragments):

query HeroComparison($first: Int = 3) {
  leftComparison: hero(episode: EMPIRE) {
    ...comparisonFields
  }
  rightComparison: hero(episode: JEDI) {
    ...comparisonFields
  }
}

fragment comparisonFields on Character {
  name
  friendsConnection(first: $first) {
    totalCount
    edges {
      node {
        name
      }
    }
  }
}

Это хороший пример для демонстрации, так как содержит два запроса (вместе с алиасами) и аргументы полей. Код на graphql-query для данного запроса выглядит так

from graphql_query import Argument, Operation, Query, Fragment, Field, Variable

# определяем переменные и аргументы,
# которые потом можно использовать в других запросах
var_first = Variable(name="first", type="Int", default="3")

arg_first = Argument(name="first", value=var_first)
arg_empire = Argument(name="episode", value="EMPIRE")
arg_jedi = Argument(name="episode", value="JEDI")

# создаем фрагмент comparisonFields
comparisonFields = Fragment(
    name="comparisonFields",
    type="Character",
    fields=[
        "name",
        Field(
            name="friendsConnection",
            # объект graphql_query.Field тоже имеет поле arguments
            arguments=[arg_first],
            fields=[
                "totalCount",
                Field(
                    name="edges",
                    fields=[Field(name="node", fields=["name"])]
                )
            ]
        )
    ]
)

# создаем первый запрос
leftComparison = Query(
    name="hero",
    alias="leftComparison",  # alias для этого запроса
    arguments=[arg_empire],
    fields=[comparisonFields]  # передаем фрагмент в качестве поля
)

# создаем второй запрос
rightComparison = Query(
    name="hero",
    alias="rightComparison",  # alias для этого запроса
    arguments=[arg_jedi],
    fields=[comparisonFields]  # тоже передаем фрагмент в качестве поля
)

# финальный запрос
operation = Operation(
    type="query",
    name="HeroComparison",
    queries=[leftComparison, rightComparison],
    fragments=[comparisonFields],
    variables=[var_first]
)
print(operation.render())
# query HeroComparison(
#   $first: Int = 3
# ) {
#   leftComparison: hero(
#     episode: EMPIRE
#   ) {
#     ...comparisonFields
#   }
#
#   rightComparison: hero(
#     episode: JEDI
#   ) {
#     ...comparisonFields
#   }
# }
#
# fragment comparisonFields on Character {
#   name
#   friendsConnection(
#     first: $first
#   ) {
#     totalCount
#     edges {
#       node {
#         name
#       }
#     }
#   }
# }

Ясно, что для запросов leftComparison и rightComparison можно создать отдельную функцию и избежать дублирования кода

def generate_hero(alias: str, argument: graphql_query.Argument) -> graphql_query.Query:
    return Query(
        name="hero",
        alias=alias,
        arguments=[argument],
        fields=[comparisonFields]  # тоже передаем фрагмент в качестве поля
    )

leftComparison = generate_hero("leftComparison", arg_empire)
rightComparison = generate_hero("rightComparison", arg_jedi)

Остальные примеры можно посмотреть в документации: директивы, __typename и др.

Генерация дата-модели с пакетом graphql2python

Есть два хороших примера софта, который генерирует модель по схемам

graphql2python -- схожий инструмент, который генерирует pydantic классы из GraphQL схемы. Документация этого пакета доступна по ссылке https://denisart.github.io/graphql2python/. Рассмотрим несколько примеров.

После установки graphql2python c помощью pip достаточно выполнить команду

graphql2python render --config ./graphql2python.yaml

где graphql2python.yaml - это ваш файл с конфигурацией. В самом простом виде этот файл может выглядеть так

# graphql2python.yaml
schema: ./schema.graphql
output: ./model.py

Подробное описание конфигурации доступно на гравной странице документации. Запуск приведенной выше команды создат (или перезапишет) файл ./output.py, в котором будет сгенерированный код на python.

Скаляры

Если определить в GraphQL схеме кастомные скаляры

# in schema.graphql
...

"in the format: dd/mm/yyyy"
scalar DateTime

...

то на выходе мы получим

# output.py

...

# The `Boolean` scalar type represents `true` or `false`.
Boolean = str


# in the format: dd/mm/yyyy
DateTime = str


# The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most
# often used by GraphQL to represent free-form human-readable text.
String = str

Для стандартных GraphQL скаляров комментарии будут взяты из graphql-core. По умолчанию все результирующие типы будут str. Это легко исправить с помощью конфига. Для

# graphql2python.yaml
schema: ...
output: ...
options:
  scalar_pytypes:
    String: str
    Float: float
    Int: int
    ID: str
    Boolean: bool
    DateTime: datetime

мы получаем

# output.py

...

# The `Boolean` scalar type represents `true` or `false`.
Boolean = bool


# in the format: dd/mm/yyyy
DateTime = datetime


# The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most
# often used by GraphQL to represent free-form human-readable text.
String = str

...

Не стандартные типы python пока недоступны. Это будет исправлено в ближайших релизах, когда будут добавлены кастомные импорты, кастомные шаблоны.

Enum и Union

Для рендеринга GraphQL Enum и GraphQL Union используются enum.Enum и typing.Union соответственно. Вот пример

# schema.graphql

...

enum Episode {
  NEWHOPE
  EMPIRE
  JEDI
}

"""
Wherever we return a SearchResult type in our schema,
we might get a Human, a Droid, or a Starship.
"""
union SearchResult = Human | Droid | Starship

Для такой схемы (определение типов Human, Droid и Starship опущено) мы получаем следующий результат

# output.py

...

class Episode(enum.Enum):
    """
    An Enum type
    See https://graphql.org/learn/schema/#enumeration-types
    """
    EMPIRE = "EMPIRE"
    JEDI = "JEDI"
    NEWHOPE = "NEWHOPE"


# Wherever we return a SearchResult type in our schema,
# we might get a Human, a Droid, or a Starship.
SearchResult = _t.Union[
    'Droid',
    'Human',
    'Starship',
]

...

Объект typing.Union не может содержать один элемент. В таком случае мы получим

# output.py для схемы
#
# """
# Wherever we return a SearchResult type in our schema,
# we might get a Human, a Droid, or a Starship.
# """
# union SearchResult = Human

...

# Wherever we return a SearchResult type in our schema,
# we might get a Human, a Droid, or a Starship.
SearchResult = _t.TypeVar('SearchResult', bound='Human')

...

В этих примерах _t есть сокращение от typing, т.е.

import typing as _t

Полный пример

Рассмотрим тренировочную схему из https://countries.trevorblades.com/ и запустим для нее генерацию со следующим конфигом

# graphql2python.yaml
schema: ./schema.graphql
output: ./model.py
options:
  each_field_optional: true
  scalar_pytypes:
    Boolean: bool

Мы получим следующий выходной файл

"""Auto-generated by graphql2python."""

# pylint: disable-all
# mypy: ignore-errors

import enum
import typing as _t
from datetime import date, datetime

from pydantic import BaseModel, Field

__all__ = [
    "GraphQLBaseModel",
    # scalars
    "Boolean",
    "ID",
    "String",
    "_Any",
    # enums
    # unions
    "_Entity",
    # interfaces
    # objects
    "Continent",
    "Country",
    "Language",
    "State",
    "_Service",
]


class GraphQLBaseModel(BaseModel):
    """Base Model for GraphQL object."""

    class Config:
        allow_population_by_field_name = True
        json_encoders = {
            # custom output conversion for datetime
            datetime: lambda dt: dt.isoformat()
        }
        smart_union = True


# The `Boolean` scalar type represents `true` or `false`.
Boolean = bool


# The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID
# type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an
# input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID.
ID = str


# The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most
# often used by GraphQL to represent free-form human-readable text.
String = str


# A Scalar type
# See https://graphql.org/learn/schema/#scalar-types
_Any = str


# A Union type
# See https://graphql.org/learn/schema/#union-types
_Entity = _t.Union[
    'Continent',
    'Country',
    'Language',
]


class Continent(GraphQLBaseModel):
    """
    An Object type
    See https://graphql.org/learn/schema/#object-types-and-fields
    """
    code: _t.Optional['ID'] = Field(default=None)
    countries: _t.Optional[_t.List['Country']] = Field(default_factory=list)
    name: _t.Optional['String'] = Field(default=None)
    typename__: _t.Literal["Continent"] = Field(default="Continent", alias="__typename")


class Country(GraphQLBaseModel):
    """
    An Object type
    See https://graphql.org/learn/schema/#object-types-and-fields
    """
    capital: _t.Optional['String'] = Field(default=None)
    code: _t.Optional['ID'] = Field(default=None)
    continent: _t.Optional['Continent'] = Field(default=None)
    currency: _t.Optional['String'] = Field(default=None)
    emoji: _t.Optional['String'] = Field(default=None)
    emojiU: _t.Optional['String'] = Field(default=None)
    languages: _t.Optional[_t.List['Language']] = Field(default_factory=list)
    name: _t.Optional['String'] = Field(default=None)
    native: _t.Optional['String'] = Field(default=None)
    phone: _t.Optional['String'] = Field(default=None)
    states: _t.Optional[_t.List['State']] = Field(default_factory=list)
    typename__: _t.Literal["Country"] = Field(default="Country", alias="__typename")


class Language(GraphQLBaseModel):
    """
    An Object type
    See https://graphql.org/learn/schema/#object-types-and-fields
    """
    code: _t.Optional['ID'] = Field(default=None)
    name: _t.Optional['String'] = Field(default=None)
    native: _t.Optional['String'] = Field(default=None)
    rtl: _t.Optional['Boolean'] = Field(default=None)
    typename__: _t.Literal["Language"] = Field(default="Language", alias="__typename")


class State(GraphQLBaseModel):
    """
    An Object type
    See https://graphql.org/learn/schema/#object-types-and-fields
    """
    code: _t.Optional['String'] = Field(default=None)
    country: _t.Optional['Country'] = Field(default=None)
    name: _t.Optional['String'] = Field(default=None)
    typename__: _t.Literal["State"] = Field(default="State", alias="__typename")


class _Service(GraphQLBaseModel):
    """
    An Object type
    See https://graphql.org/learn/schema/#object-types-and-fields
    """
    sdl: _t.Optional['String'] = Field(default=None)
    typename__: _t.Literal["_Service"] = Field(default="_Service", alias="__typename")


Continent.update_forward_refs()
Country.update_forward_refs()
Language.update_forward_refs()
State.update_forward_refs()
_Service.update_forward_refs()

Изменение базового класса GraphQLBaseModel пока недоступно. Это планируется исправить в грядущих релизах.
Блок

Continent.update_forward_refs()
Country.update_forward_refs()
Language.update_forward_refs()
State.update_forward_refs()
_Service.update_forward_refs()

нужен для того, чтобы pydantic мог правильно работать с полямя, имеющими тип, совпадающий с самим объектом (https://docs.pydantic.dev/usage/postponed_annotations/).

Заключение

Функционал graphql2python был в первую очередь сделан для рабочих нужд. В грядущих релизах graphql2python планируется расширить возможность настраивать выходной файл с моделью. В планах

  • кастомные импорты;

  • кастомные шаблоны (например, если вы хотите использовать не pydantic, а https://github.com/lidatong/dataclasses-json);

  • алиасы не только для полей, но и для произвольных объектов;

  • добавить рендер InputObjectType;

  • загрузка GraphQL схемы не только из файла, но и по ссылке;

  • более расширенная настройка выходного файла включая хуки (дополнительный вызов isort, black и т.п.):

  • многое другое, что будет необходимо или желаемо :)

Если вы используете GraphQL на python и хотите внести вклад в развитие сообщества graphql-python, то можете предлагать свои улучшения в проекты https://github.com/denisart/graphql-query, https://github.com/denisart/graphql2python и, конечно, основные проекты python GraphQL сообщества https://github.com/graphql-python.

Комментарии (0)