Речь в статье пойдет о специальных инструментах для работы с 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
Есть два хороших примера софта, который генерирует модель по схемам
graphql code generator на TypeScript;
datamodel-code-generator на python для генерации модели из openapi.
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.