Речь в статье пойдет о специальных инструментах для работы с 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.
