Привет, Хаброжители! Стартовала весенняя распродажа от издательства «Питер».

Эту статью также можно было бы назвать «Чего по науке нельзя делать с GraphQL».

Читая различные посты в блогах и руководства, мы узнаем, что существует некий правильный способ работы с GraphQL. Но вдруг там о чем-то не упоминается? Потому, что какие-то вещи невозможно реализовать, либо потому, что та или иная реализация – это «очень плохая идея»?

Давайте немного повеселимся и поиграем с GraphQL нетрадиционным образом. Я не призываю вас реализовывать какие-либо из идей, изложенных здесь, и им определенно не место в продакшен-коде (но, если вы совершенно уверены в том, что делаете – почему нет). В этой статье я просто продемонстрирую несколько экспериментов, которые сам проделал с GraphQL. Некоторые из них – просто классные фокусы. Другие могут вам по-настоящему пригодиться. Мне же все они кажутся потрясающими.

Конечные точки GraphQL на основе ресурсов

Допустим, у нас есть поле post, которое извлекает соответствующую запись (пост) по ее ID:

type Root {
  post(id: ID!): Post
}

Можно запросить это поле вот так:

query FetchPostContent($postId: ID!) {
  post(id: $postId) {
    title
    content
    date
  }
}

...и для этого нам необходимо передать ID поста как переменную:

{
  "postId": 1
}

Далее предположим, что у нас появилось новое требование: конечная точка GraphQL должна, вдобавок, выбирать пост и по его URL. Как нам это сделать?

Один из вариантов – добавить в схему новое поле postByURL:

type Root {
  postByURL(url: URL!): Post
}

Затем можно запросить это новое поле вот так:

query FetchPostContent($postURL: URL!) {
  postByURL(url: $postURL) {
    title
    content
    date
  }
}

...передавая URL как переменную:

{
  "postURL": "https://newapi.getpop.org/uncategorized/hello-world/"
}

Как вариант, можно модифицировать поле post, чтобы оно могло получать либо ID, либо URL, но ни первое, ни второе не было обязательным:

type Root {
  post(id: ID, url: URL): Post
}

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

Вот я и подумал: почему бы не сделать так, чтобы URL поста становился конечной точкой GraphQL, в то время как извлеченный корневой элемент уже является объектом-постом? Сработает ли это?

Иными словами, мог бы GraphQL эмулировать REST, так, чтобы конечная точка у него была не единственная. Напротив, чтобы каждый ресурс на сайте мог служить конечной точкой сам себе, и все запросы к таким конечным точкам удовлетворялись через GraphQL?

Да, это работает. У меня в блоге на WordPress каждый пост является сам себе конечной точкой GraphQL, для этого я просто добавляю /api/graphql в конце  URL поста. Я не иду, начиная с Root, вместо этого каждая конечная точка извлекает запрошенный объект-пост как собственный корень, в данном случае, Post.

Следовательно, у нас получается такая конечная точка GraphQL: newapi.getpop.org/uncategorized/hello-world/api/graphql/. Я не задал в клиенте GraphiQL команду ее запрашивать, но мы можем указать, какие свойства нас интересуют, через параметр ?query=, воспользовавшись синтаксисом PQL (он похож на GQL, но лучше приспособлен для передачи запросов через URL).

Вот запрос (где Post является корнем запроса):

{
  title
  content
  date
}

Его можно выполнить, загрузив этот URL: ${ postURL }/api/graphql/?query=title|content|date.

Все это по-прежнему GraphQL, и мы можем углубиться в эту кроличью нору настолько, насколько захотим. Аргументы полей тоже можно передавать таким образом. Загрузив этот URL, выполним следующий запрос:

{
  title
  content
  formattedDate: date(format: "d/m/Y")
  author {
    name
  }
  comments {
    date
    content
    author {
      name
    }
  }
  categories {
    name
  }
  tags {
    name
  }
}

Хотите получить все посты, а не какой-то отдельный? При выполнении запроса к конечной точке posts/api/graphql (которая соответствует списку всех постов, а не к какому-то одному) корневой элемент становится [Post].

Почему я считаю, что это круто? Потому что такая стратегия позволяет по-настоящему объединить все самое лучшее от REST и GraphQL. Представьте: вы листаете сайт и читаете какой-то пост в блоге, и тут вы захотели выбрать всего его данные для кросс-поста на вашем сайте. Тогда вам всего лишь требуется прикрепить /api/graphql к URL поста в блоге и предоставить список полей, которые будут запрашиваться через ?query=. Вуаля – данные у вас.

Программируем шлюз в запросе

Шлюз – это метод предоставления внешних API в рамках схемы GraphQL. Именно эти проблемы решаются в StepZen при помощи сшивания схемы, а в Apollo при помощи федеративного подхода.

Когда нам нужен шлюз? Тогда, когда требуется обращаться к данным нашей компании, предоставляемым только через унаследованные API (возможно, основанные на REST), либо данные от стороннего провайдера. Если предоставлять эти данные в рамках работы с нашим сервисом GraphQL, то все сразу упростится: мы сможем использовать единственный интерфейс (GraphQL) для взаимодействия с любыми данными, требуемыми нашим приложением.

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

Предупреждение: не следует предоставлять публичный сервис, в котором пользователи могли бы свободно указывать ту конечную точку, с которой нужно выбирать данные. Они могут указать на очень большой ресурс, который весит много мегабайт, и этот ресурс займет всю полосу передачи данных, в которой должны были бы работать наши серверы. То есть, мы несем затраты, ничего особенно не получая.

Таким образом, эта стратегия целесообразна лишь в случае, когда для доступа к конечной точке пользователям требуется проходить аутентификацию, либо когда сервер GraphQL предоставляет персистентные запросы, так, чтобы мы могли опубликовать запрос на стороне сервера и ограничить пользователям возможность создавать собственные запросы на стороне клиента.

Давайте это сделаем! Шлюз для подключения к конечной точке REST – это просто поле getJSON, извлекающее данные из предоставленной конечной точки, после чего эти данные предоставляются в виде специализированного скалярного типа JSONObject:

type Root {
  getJSON(url: URL): JSONObject
}

Данные в JSONObject могут выглядеть вот так (что получается в результате запрашивания конечной точки REST для поста в блоге WordPress):

{
  "id": 1657,
  "date": "2020-12-21T08:24:18",
  "date_gmt": "2020-12-21T08:24:18",
  "guid": {
    "rendered": "https:\/\/newapi.getpop.org\/?p=1657"
  },
  "modified": "2021-01-13T17:12:34",
  "modified_gmt": "2021-01-13T17:12:34",
  "slug": "a-tale-of-two-cities-teaser",
  "status": "publish",
  "type": "post",
  "link": "https:\/\/newapi.getpop.org\/uncategorized\/a-tale-of-two-cities-teaser\/",
  "title": {
    "rendered": "A tale of two cities – teaser"
  },
  "content": {
    "rendered": "\n<p>It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness, it was the spring of hope, it was the winter of despair, we had everything before us, we had nothing before us, we were all going direct to Heaven, we were all going direct the other way\u2014in short, the period was so far like the present period, that some of its noisiest authorities insisted on its being received, for good or for evil, in the superlative degree of comparison only.<\/p>\n",
    "protected": false
  },
  "excerpt": {
    "rendered": "<p>It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness, it was the spring of hope, it&hellip; <a class=\"more-link\" href=\"https:\/\/newapi.getpop.org\/uncategorized\/a-tale-of-two-cities-teaser\/\">Continue reading <span class=\"screen-reader-text\">A tale of two cities &#8211; teaser<\/span><\/a><\/p>\n",
    "protected": false
  },
  "author": 1,
  "featured_media": 0,
  "comment_status": "closed",
  "ping_status": "closed",
  "sticky": false,
  "template": "",
  "format": "standard",
  "meta": [],
  "categories": [
    1
  ],
  "tags": [
    191
  ]
}

Следующий запрос выполняет поле getJSON:

query FetchPostData($endpoint: URL!) {
  postData: getJSON(url: $endpoint)
}

Когда у нас есть данные, нам нужно обратиться к конкретному интересующему нас элементу. Например, если нам нужно содержимое поста в блоге, то мы можем получить эту информацию по пути content.rendered. Для этого мы создаем другое поле, extract, которое, если известны JSONObject и путь, позволяет извлечь элемент из:

type Root {
  extract(object: JSONObject, path: String): Mixed
}

Обратите внимание: это поле возвращает тип Mixed, а это специализированный скалярный тип для представления всех встроенных скалярных типов (StringIntFloatBoolean и ID). Чтобы обращаться с массивами, также можно создать поле extractList:

type Root {
  extractList(object: JSONObject, path: String): [Mixed]
}

Использование типа Mixed – не идеальное решение, но, учитывая, что GraphQL в настоящее время не поддерживает объединения скалярных типов, мне такая уловка кажется приемлемой. В качестве альтернативы можно было бы создавать различные поля для разных возвращаемых типов (extractStringextractIntextractFloatextractBoolean и extractID), а затем повторить эту операцию для каждого, чтобы возвращать типы массивов (extractStringListextractIntListextractFloatListextractBooleanList и extractIDList), но мне такой способ кажется слишком многословным.

Вследствие использования Mixed получается, что клиент GraphiQL будет показывать ошибки на основе ввода в виде Mixed там, где ожидаются типы String или Int, но в противном случае сервер GraphQL справится с ним без всяких проблем (поскольку вместе со специализированным скалярным типом предоставляется и собственная стратегия преобразования типов , тип Mixed, в принципе, будет принимать что угодно).

Продолжим. Можно предоставить несколько запросов и связать их друг с другом при помощи директивы @export, которая будет предоставлять вывод поля из некоторого запроса в качестве ввода для последующего запроса.

В этом запросе данные JSON экспортируются под динамической переменной $_postData, а затем оттуда извлекается конкретный элемент, к которому ведет путь content.rendered:

query FetchPostData($endpoint: URL!) {
  postData: getJSON(url: $endpoint) @export(as: "_postData")
}

query ExtractPostContent($_postData: Object! = {}) {
  postContent: extract(object: $_postData, path: "content.rendered")
}

# This is a hack to make GraphiQL execute several queries in a single request.
# Select operation "__ALL" from the dropdown when pressing on the "Run" button
query __ALL { id }

...и мы указываем конечную точку через переменную:

{
  "endpoint": "https://newapi.getpop.org/wp-json/wp/v2/posts/1657/"
}

Вуаля! Теперь мы можем обращаться в запросах GraphQL к данным конечной точки REST, любой конечной точки REST.

Обращение с контентом из любого поста в блоге

Давайте подумаем, как можно было бы применить вышеизложенные идеи (это могло бы быть бесполезно, но все равно интересно). Теперь мы можем с легкостью обращаться к данным с любой конечной точки REST, а в GraphQL предоставляются колоссальные возможности обращения с данными, это делается при помощи специализированных директив. Давайте подружим две этих посылки, чтобы можно было манипулировать данными, поступающими с любой конечной точки.

Например, поскольку я запрограммировал директиву @translate , использующую Google Translate API, и теперь могу переводить любой контент с любого сайта (в данном случае – с английского на французский):

query FetchPostData($endpoint: URL!) {
  postData: getJSON(url: $endpoint) @export(as: "_postData")
}

query ExtractPostContent($_postData: Object! = {}) {
  postContent: extract(object: $_postData, path: "content.rendered") @export(as: "_postContent")
}

query TranslatePostContent($_postContent: String! = "") {
  translatedPostContent: echoStr(value: $_postContent) @translate(from: "en", to: "fr")
}

query __ALL { id }

Этот запрос переводит все описания из всех моих описаний из всех моих репозиториев на GitHub с английского на испанский:

query FetchGitHubData($endpointURL: String!) {
  ghData: getJSON(url: $endpointURL) @export(as: "_ghData")
}

query TranslateRepoDescriptions($_ghData: Object! = {}) {
  repoDescriptions: extract(object: $_ghData, path: "description") @forEach @translate(from: "en", to: "es", nestedUnder: -1) @export(as: "_repoData")
}

query __ALL { id }

...передаю конечную точку:

{
  "endpointURL": "https://api.github.com/users/leoloso/repos"
}

Заключение

Коллеги вам скажут, что использовать GraphQL таким образом нельзя. А начальник просто запретит так делать. Кроме того, в Интернете много написано о том, как именно нужно использовать GraphQL и не отклоняться от этих правил.

Но можно же просто поиграть. Убедитесь, какие потрясающие вещи достижимы при помощи GraphQL – не потому, что так правильно, а просто потому, что «так можно было». Надеюсь, мои эксперименты вам понравились.

 

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


  1. alexesDev
    15.03.2022 11:29

    С таким подходом теряются данные о том, что вернет endpoint. Там может быть Post!, Post или еще что-то. Это нужно держать в голове или где-то, а не в данных introspection. В общем... сила graphql в общих типах между беком и фронтом... без понятия какой codegen типов на фронте вы сможете тут использовать.

    Так же разваливается schema stitching, которая очень часто нужна в крупных проектах. А игра с GraphQL для меня это https://www.graphql-tools.com/ =)