Инструменты для GraphQL клиента на Python

от автора

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


ссылка на оригинал статьи https://habr.com/ru/post/707374/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *