Skip to content

Latest commit

 

History

History
1346 lines (1001 loc) · 95.9 KB

File metadata and controls

1346 lines (1001 loc) · 95.9 KB

CC-BY-NC-4.0

Дизайн GraphQL-схем — делаем АПИ удобным, предотвращаем боль и страдания

Рекомендации и правила озвученные в этой статье были выработаны за 3 года использования GraphQL как на стороне сервера (при построении схем) так и на клиентской стороне (написания GraphQL-запросов и покрытием клиентского кода статическим анализом). Также в этой статье используются рекомендации и опыт Caleb Meredith (автора PostGraphQL, ex-сотрудник Facebook) и инженеров Shopify.

Эта статья может поменяться в будущем, т.к. текущие правила носят рекомендательный характер и могут со временем улучшиться, измениться или вовсе стать антипаттерном. Но то что здесь написано, выстрадано временем и болью от использования кривых GraphQL-схем.

Если вы считаете, что какое-либо правило полная лажа или оно не полностью раскрыто, или хотите добавить свое – пожалуйста откройте issue или стукните меня в телеграмме по нику @nodkz. Ошибки и опечатки можно поправить, нажав на карандашик в правом верхнем углу. Я только начал формировать правила, но довести дело до конца и привести всё в божеский вид – мы сможем только вместе.

TL;DR всех правил

  • 1. Правила именования
    • 1.1. Используйте camelCase для именования GraphQL-полей и аргументов.
    • 1.2. Используйте UpperCamelCase для именования GraphQL-типов.
    • 1.3. Используйте CAPITALIZED_WITH_UNDERSCORES для именования значений ENUM-типов.
  • 2. Правила типов
    • 2.1. Используйте кастомные скалярные типы, если вы хотите объявить поля или аргументы с определенным семантическим значением.
    • 2.2. Используйте Enum для полей, которые содержат определенный набор значений.
  • 3. Правила полей (Output)
    • 3.1. Давайте полям понятные смысловые имена, а не то как они реализованы.
    • 3.2. Делайте поля обязательными NonNull, если данные в поле возвращаются при любой ситуации.
    • 3.3. Достигайте максимального описания обязательных полей.
  • 4. Правила аргументов (Input)
    • 4.1. Группируйте взаимосвязанные аргументы вместе в новый input-тип.
    • 4.2. Используйте строгие скалярные типы для аргументов, например DateTime вместо String.
    • 4.3. Помечайте аргументы как NonNull, если они обязательны для выполнения запроса.
  • 5. Правила списков
    • 5.1. Для фильтрации списков используйте аргумент filter, который содержит в себе все доступные фильтры.
    • 5.2. Для сортировки списков используйте аргумент sort, который должен быть Enum или [Enum!].
    • 5.3. Для ограничения возвращаемых элементов в списке используйте аргументы limit со значением по умолчанию и skip.
    • 5.4. Для пагинации используйте аргументы page, perPage и возвращайте output-тип с полями items с массивом элементов и pageInfo с метаданными для удобной отрисовки страниц на клиенте.
    • 5.5. Для бесконечных списков (infinite scroll) используйте Relay Cursor Connections Specification.
  • 6. Правила Мутаций
    • 6.1. Используйте Namespace-типы для группировки мутаций в рамках одного ресурса!
    • 6.2. Выходите за рамки CRUD – создавайте небольшие мутации для разных логических операций над ресурсами.
    • 6.3. Рассмотрите возможность выполнения мутаций сразу над несколькими элементами (однотипные batch-изменения).
    • 6.4. У мутаций должны быть четко описаны все обязательные аргументы, не должно быть вариантов либо-либо.
    • 6.5. У мутации вкладывайте все переменные в один уникальный input аргумент.
    • 6.6. Мутация должна возвращать свой уникальный Payload-тип.
      • 6.6.1. В ответе мутации возвращайте измененный ресурс и его id.
      • 6.6.2. В ответе мутации возвращайте статус операции.
      • 6.6.3. В ответе мутации возвращайте поле с типом Query.
      • 6.6.4. В ответе мутации возвращайте поле error с типизированными пользовательскими ошибками.
  • 7. Правила связей между типами (relationships)
    • 7.1. GraphQL-схема должна быть "волосатой"
  • 10. Прочие правила
    • 10.1. Для документации используйте markdown
  • A. Appendix
    • A-1 Полезные ссылки

1. Правила именования

GraphQL для проверки имен полей и типов использует следующую регулярку /[_A-Za-z][_0-9A-Za-z]*/. Согласно нее можно использовать camelCase, under_score, UpperCamelCase, CAPITALIZED_WITH_UNDERSCORES. Слава богу kebab-case ни в каком виде не поддерживается.

Так что же лучше выбрать для именования?

Абстрактно можно обратиться к исследованию Eye Tracking'а по camelCase и under_score. В этом исследовании явного фаворита не выявлено.

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

1.1. Используйте camelCase для именования GraphQL-полей и аргументов.

Поля:

type User {
+  isActive: boolean # GOOD
-  is_active: boolean # BAD
}

Аргументы:

type Query {
+  users(perPage: Int): boolean # GOOD
-  users(per_page: Int): boolean # BAD
}

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

Согласно википедии следующие клиентские языки (потребители GraphQL-апи) придерживаются следующих конвенций по именованию переменных:

  • JavaScript — camelCase
  • Java — camelCase
  • Swift — camelCase
  • Kotlin — camelCase

Конечно каждый у себя "на кухне" может использовать under_score. Но в среднем по больнице используется camelCase. Если найдете какое-нибудь исследование по процентовке использования camelCase и under_score в том или ином языке программирования — дайте пожалуйста знать, очень тяжело гуглится вся эта тема. Кругом сплошной субъективизм.

А ещё, если залезть в кишки graphql и посмотреть его IntrospectionQuery, то он также написан используя camelCase.

PS. Мне очень печально видеть в документации MySQL или PostgreSQL примеры с названием полей через under_score. Потом все это дело кочует в код бэкенда, далее появляется в GraphQL апи, а потом и в коде клиентов. Блин, ведь эти БД спокойно позволяют сразу объявлять поля в camelCase. Но так как на начале не определились с конвенцией имен, а взяли как в документации, то потом происходят холивары что under_score лучше чем camelCase. Ведь переименовывать поля на уже едущем проекте больно и чревато ошибками. В общем, тот кто выбрал under_score, тот и должен маппить поля в camelCase для клиентов, т.е. бэкендер!

1.2. Используйте UpperCamelCase для именования GraphQL-типов.

А вот именование типов, в отличии от полей уже происходит немного по другому.

- type blogPost { # BAD
- type Blog_Post { # so-so
+ type BlogPost { # GOOD
    title: String!
  }

В самом GraphQL уже есть скалярные типы String, Int, Boolean, Float. Они именуются через UpperCamelCase.

Также внутренние типы GraphQL-интроспекции __Type, __Field, __InputValue и пр. Именуются через UpperCamelCase с двумя символами подчеркивания в начале.

А еще GraphQL статический типизированный язык запросов. И из GraphQL-запросов много кто генерирует тайп-дефинишены для статического анализа кода. Так вот если посмотреть как в JS именуют сложные типы во Flowtype и TypeScript — то тут тоже обычно используется UpperCamelCase.

И опять таки согласно википедии по конвенции для классов и деклараций типов используется UpperCamelCase в JavaScript, Java, Swift и Kotlin.

1.3. Используйте CAPITALIZED_WITH_UNDERSCORES для именования значений ENUM-типов.

Enum в GraphQL используются для перечисления списка возможных значение у некого типа.

enum Sort {
+  NAME_ASC # GOOD
-  nameAsc # BAD
-  NameAsc # BAD
   NAME_DESC
   AGE_ASC
   AGE_DESC
}

В самом GraphQL для интроспекции в типе __TypeKind используются следующие значения: SCALAR, OBJECT, INPUT_OBJECT, NON_NULL и другие. Т.е. используется CAPITALIZED_WITH_UNDERSCORES.

Если относиться к Enum-типу как к типу с набором констант, то в большинстве языков программирования константы именуются через CAPITALIZED_WITH_UNDERSCORES.

Также CAPITALIZED_WITH_UNDERSCORES будет хорошо смотреться в самих GraphQL-запросах, чтобы четко идентифицировать Enum'ы:

query {
  findUser(sort: ID_DESC) {
    id
    name
  }
}

2. Правила типов

GraphQL по спецификации содержит всего 5 скалярных типов – String (строка), Int (целое число), Float (число с точкой), Boolean (булево значение), ID (строка с уникальным идентификатором). Все эти типы легко передаются через JSON и доступны на любом языке программирования.

Но когда речь заходит о каком-то скалярном типе не входящего в синтаксис JSON, например Date, то тут уже необходимо бэкендеру самостоятельно определяться с форматом данных, чтоб их можно было сериализовать и передать клиенту через JSON. А также получить значение этого типа от клиента и обратно десериализовать его на сервере.

В таких случаях GraphQL позволяет создавать свои кастомные скалярные типы. О том как это делается написано здесь.

2.1. Используйте кастомные скалярные типы, если вы хотите объявить поля или аргументы с определенным семантическим значением.

Если поле возвращает тип String, то клиентам вашего API нет возможности понять имеет ли это поле какое-то семантическое значение, какое-нибудь ограничение. Например, с помощью типа String вы можете передавать обычный текст, HTML, строку длиной в 255 символов, строку в base64, дату или любое другое хитрое значение.

Для того чтобы сделать ваше АПИ более прозрачным для команды, создавайте кастомные скалярные типы. Например: HTML, String255, Base64, DateString.

Бэкендерам это позволит единожды написать методы валидации, (де-)сериализации для кастомных скалярных типов и избежать дублирование кода в резолверах.

Фронтендерам это позволит написать компоненты для отображения или ввода данных и явно переиспользовать их для конкретных скалярных типов.

type Article {
  id: ID!
-  description: String
+  description: HTML
}

В таком случае легче понимать, что в поле description прилетает html-документ и скорее всего его не нужно эскейпить при отображении в браузере, а показывать как есть.

2.2. Используйте Enum для полей, которые содержат определенный набор значений.

Частенько в схемах встречаются поля, которые содержат определенный набор значений. Например: пол, статус заявки, код страны, вид оплаты. Использовать просто тип String или Int в таких случаях никоим образом не помогает вашим клиентам понять, какие значения могут быть получены.

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

Значения полей с типом Enum проверяются на этапе валидации запроса. А также они могут проверяться на этапе разработки линтерами и статическими анализаторами. Это на порядок полезнее и безопаснее, чем тупо перечислять значения в документации.

type User {
-  status: String # BAD
+  status: StatusEnum # GOOD
}

+ enum StatusEnum {
+   ACTIVE
+   PENDING
+   REJECTED
+ }

3. Правила полей (Output)

В GraphQL поля используются для передачи данных с сервера на клиент. Они могут быть описаны обычными скалярными типами, а также сложными структурами – Output-типами (GraphQLObjectType).

3.1. Давайте полям понятные смысловые имена, а не то как они реализованы.

Необходимо полям давать понятные имена. Это очень простое и банальное правило. К примеру, у нас есть следующий тип:

type Meeting {
-  body_htML: String # BAD
+  description: HTML # GOOD
}

Человек который в первый раз видит тип Meeting, будет гадать, что конкретно хранится в поле bodyHtml. Здорово если бэкендеры не ленятся и оставляют описание к полям. Но черт возьми, можно же поле в АПИ назвать description, а в базе пусть хранится как bodyHtml, тогда и без документации все понятно.

Часто в базах и моделях бывает бардак в именовании, и чтоб перед Доном Педро не было стыдно – в АПИ можно все правильно замапить в красивые и понятные имена полей.

3.2. Делайте поля обязательными NonNull, если данные в поле возвращаются при любой ситуации.

Помечая поля в вашей схеме как NonNull (обязательные) позволят клиентам при статическом анализе делать меньше проверок в коде. Если вдруг бэкендер не вернет данные по обязательному полю, то GraphQL вернет ошибку о том, что данных нет. При этом занулит родительский объект. И если родительский объект тоже обязателен, то выбросит ошибку еще выше. В любом случае клиент не получит объект (тип) без данных для обязательного поля.

Также это правило распространяется на возвращение массивов.

type MyLists {
  list1: [String]   # [], [null], null
  list2: [String]!  # [], [null]
  list3: [String!]  # [], null
  list4: [String!]! # []                <-- BETTER!
}

Если вы всегда возвращаете массив, то пометьте его как [SomeType]! (new GraphQLNonNull(new GraphQLList(SomeType))). по умолчанию у GraphQL все поля nullable, поэтому клиентам необходимо сперва проверить наличие массива, а потом по нему проходиться. Иногда это очень загрязняет код. Поэтому если вы всегда возвращаете массив, то помечайте поле как NonNull. При этом сам массив внутри себя может содержать null элементы и если не может, то помечайте его как [SomeType!]! (new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(SomeType)))).

Если boolean не делать обязательным, то тогда у него может быть три состояния true/false/null:

type MyBool {
  bool1: Boolean  # true, false, null
  bool2: Boolean! # true, false
}

3.3. Достигайте максимального описания обязательных полей.

Проблема:

Бывают случаи, когда в зависимости от определенного значения мы ожидаем те или иные параметры. К примеру, у нас есть жалобы Claim которые поступают либо по почте, либо по телефону. Поле operatorCode содержит в себе код оператора, который принял звонок. Т.е. оно всегда заполнено если указан телефон, и пустое если жалоба пришла по почте.

type Claim {
  text: String!
  phone: String
  operatorCode: String
  email: String
}

Цель:

Укажем тип жалобы Claim таким образом, чтобы максимально описать поля, которые мы обязательно получим в том или ином случае. Благодаря этому, клиенту придется делать меньше проверок.

Решения:

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

Существует несколько решений этой проблемы:
  1. Сгруппировать взаимосвязанные поля на уровень ниже в новый output-тип
  2. Использовать union-типы в связке с fragments
Решение 1: Группировка взаимосвязанных полей вместе в новый output-тип

Выносите взаимосвязанные поля на уровень ниже в новый output-тип. Берем поля phone и operatorCode и группируем их в типе ClaimByPhone. Тогда нашу схему можно представить в следующем виде:

type Claim {
  text: String!
  byPhone: ClaimByPhone
  byMail: ClaimByMail
}

type ClaimByPhone {
  phone: String!
  operatorCode: String!
}

type ClaimByMail {
  email: String!
}

Обратите внимание, что если поле byPhone не пустое, то оно обязательно будет содержать номера телефона и кода оператора. Становится возможным взаимосвязанные поля делать обязательными.

Решение 2: Использование Union типов в связке с fragments

Если вы не знакомы с Union типами, их можно описать как "либо либо". Подробнее про Union типы следует почитать тут.

# Опишем базовый тип для жалобы
type ClaimBase {
  text: String!
}

# Опишем тип жалобы, пришедшей по телефону
type ClaimByPhone implements ClaimBase {
  text: String!
  phone: String!
  operatorCode: String!
}

# Опишем тип жалобы, пришедшей по почте
type ClaimByMail implements ClaimBase {
  text: String!
  email: String!
}

# Опишем union тип жалобы
union Claim = ClaimByPhone | ClaimByMail

Теперь клиент запрашивает claim с помощью фрагментов, запрос выглядит так:

query {
  claim {
    ... on ClaimByPhone {
      text
      phone
      operatorCode
    }
    ... on ClaimByMail {
      text
      email
    }
  }
}

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

4. Правила аргументов

4.1. Группируйте взаимосвязанные аргументы вместе в новый input-тип.

Часто бывают ситуации, когда несколько аргументов по логике взаимосвязаны друг с другом. К примеру, вы возвращаете список статей [Article], и позволяете этот список фильтровать по трем полям lang, userId, rating и ограничивать размер выборки limit. Крайне нежелательно смешивать все эти аргументы на одном уровне:

  • клиенту будет тяжело понять к чему какой аргумент относится
  • в будущем у вас может возникнуть проблема, что одно имя аргумента уже используется для других целей.

Смело группируйте взаимосвязанные аргументы, например все аргументы фильтрации можно положить в аргумент filter с типом ArticleFilter:

type Query {
  articles(filter: ArticleFilter, limit: Int): [Article]
}

input ArticleFilter {
  lang: Stirng
  userId: Int
  rating: MinMaxInput
}

input MinMaxInput {
  min: Int
  max: Int
}

Из кода выше, также обратите внимание как поступили с фильтрацией по полю rating. Вместо двух разрозненных полей ratingMin и ratingMax был заведен новый input-тип MinMaxInput.

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

4.2. Используйте строгие скалярные типы для аргументов, например DateTime вместо String.

Используйте более строгие типы для входных данных. Например, скалярный тип DateTime вместо String. Как вы знаете в GraphQL всего 5 скалярных типов и типа для дат в нем нет. Но в GraphQL есть возможность создания своих скалярных типов, которая позволяет задать описание и написать методы валидации, сериализации и десериализации полученных значений.

type Mutation {
-  setTime(date: String):   SetTimePayload  # BAD
+  setTime(date: DateTime): SetTimePayload  # GOOD
}

Во-первых, это позволяет вам единожды описать методы конвертации значений от клиента в серверное представление (в нашем примере в объект Date).

Во-вторых, это обеспечивает ясность и побуждает клиентов использовать более строгие элементы управления вводом (например, виджет выбора даты вместо поля с произвольным текстом).

А вот и пример создания такого скалярного типа в Node.js:

import { GraphQLScalarType, GraphQLError } from 'graphql';

export default new GraphQLScalarType({
  // 1) --- ОПРЕДЕЛЯЕМ МЕТАДАННЫЕ ТИПА ---
  // У каждого типа, должно быть уникальное имя
  name: 'DateTime',
  // Хорошим тоном будет предоставить описание для вашего типа, чтобы оно отображалось в документации
  description: 'A string which represents a HTTP URL',

  // 2) --- ОПРЕДЕЛЯЕМ КАК ТИП ОТДАВАТЬ КЛИЕНТУ ---
  // Чтобы передать клиенту в GraphQL-ответе значение вашего поля
  // вам необходимо определить функцию `serialize`,
  // которая превратит значение в допустимый json-тип
  serialize: (v: Date) => v.getTime(), // return 1536417553

  // 3) --- ОПРЕДЕЛЯЕМ КАК ТИП ПРИНИМАТЬ ОТ КЛИЕНТА ---
  // Чтобы принять значение от клиента, провалидировать его и преобразовать
  // в нужный тип/объект для работы на сервере вам нужно определить две функции:

  // 3.1) первая это `parseValue`, используется если клиент передал значение через GraphQL-переменную:
  // {
  //   variableValues: { "date": 1536417553 }
  //   source: `query ($date: DateTimestamp) { setDate(date: $date) }`
  // }
  parseValue: (v: integer) => new Date(v),

  // 3.2) вторая это `parseLiteral`, используется если клиент передал значение в теле GraphQL-запроса:
  // {
  //   source: `query { setDate(date: 1536417553) }`
  // }
  parseLiteral: (ast) => {
    if (ast.kind === Kind.STRING) {
      throw new GraphQLError('Field error: value must be Integer');
    } else if (ast.kind === Kind.INT) {
      return new Date(parseInt(ast.value, 10)); // ast value is always in string format
    }
    return null;
  },
});

4.3. Помечайте аргументы как required, если они обязательны для выполнения запроса.

По умолчанию, все поля и аргументы в GraphQL являются nullable – необязательными. Поэтому хорошим тоном будет помечать обязательные аргументы для выполнения запроса как GraphQLNonNull или если в формате SDL то с восклицательным знаком – String!. Это позволит отловить ошибку на клиенте еще на уровне статического анализа кода, а не в рантайме.

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

Например, для получения списка статей клиент должен указать кол-во возвращаемых записей:

type Query {
  articles(limit: Int!): [Article]
}

Хотя, иногда злодействовать не стоит и можно воспользоваться значением по умолчанию:

type Query {
  articles(limit: Int! = 10): [Article]
}

Тогда клиент может не передавать значение для обязательного аргумента limit, оно будет равно по умолчанию 10. Но если вдруг клиент захочет надурачить систему и в запросе передаст null – query { articles(limit: null) }, то сервер вернет ошибку Expected type Int!, found null..

В общем, обязательные аргументы в GraphQL работают хорошо и их стоит использовать для более строгого описания вашей GraphQL-схемы.

5. Правила списков

Я не встречал ни одного АПИ, которое бы не возвращало список элементов. Либо это постраничная листалка, либо что-то построенное на курсорах для бесконечных списков. Списки надо фильтровать, сортировать, ограничивать кол-во возвращаемых элементов. Сам GraphQL никак не ограничивает свободу реализации, но для того чтобы сформировать некое единообразие, необходимо завести стандарт.

5.1. Для фильтрации списков используйте аргумент filter, который содержит в себе все доступные фильтры.

Как вы думаете, как лучше организовать фильтрацию?

type Query {
  articles(authorId: Int, tags: [String], lang: LangEnum): [Article]
}

или через аргумент filter с типом ArticleFilter:

type Query {
  articles(filter: ArticleFilter): [Article]
}

input ArticleFilter {
  authorId: Int
  tags: [String]
  lang: LangEnum
}

Конечно, лучше всего организовать через дополнительный тип ArticleFilter. На это есть несколько причин:

  • если вы будете добавлять новые аргументы не относящиеся к фильтрации (сортировка, лимит, офсет, номер страницы, курсор, язык и прочее), то ваши аргументы не будут путаться друг с другом
  • на клиенте для статического анализа вы получите ArticleFilter тип. Иначе клиенты будут вынуждены собирать такой тип вручную, что чревато ошибками
  • тупо легче читать и воспринимать вашу схему, когда в ней 3-5 аргументов а не 33 аргумента с возможной фильтрацией. Есть аргумент filter и если нужно, провались в него и там уже посмотри все 33 поля для фильтрации
  • этот фильтр можно переиспользовать несколько раз в вашем апи, если список статей можно запросить из нескольких мест

Также важно договориться как назвать поле для фильтрации. А то если у вас 5, 10 или 100 разработчиков, то на выходе в схеме у вас появиться куча названий для аргумента фильтрации — filter, where, condition, f и прочий нестандарт. Если учитывать что есть базы SQL и noSQL, есть всякие кэши и прочие сервисы, то самым адекватным именем для аргумента фильтрации является — filter. Оно понятно и подходит для всех! А вот этот where в основном для SQL-бэкендеров.

5.2. Для сортировки списков используйте аргумент sort, который должен быть Enum или [Enum!].

Когда в списке много записей, то может потребоваться сортировка по полю. А иногда требуется сортировка по нескольким полям.

Для начала команде необходимо выбрать имя для аргумента сортировки. На ум приходит следующие популярные названия — sort, order, orderBy. Т.к. слово order переводиться не только как порядок, но и как заказ; и используется в основном только в реляционных базах данных. То лучшим выбором имени для поля сортировки будет — sort. Оно однозначно трактуется и будет понятно всем.

Когда с именем аргумента определились, то необходимо выбрать тип для аргумента сортировки:

  • Если взять String, то фронтендеру будет тяжело указать правильные значения и при этом мы не получаем возможности валидации параметров средствами GraphQL.
  • Можно создать input-тип input ArticleSort { field: SortFieldsEnum, order: AscDescEnum } — структура у которой можно выбрать имя поля и тип сортировки. Но такой подход не подойдет, если у вас появится полнотекстовая сортировка или сортировка по близости. У них просто нет значения DESC (обратной сортировки).
  • Остается один самый простой и верный способ — использовать Enum для перечисления списка доступных сортировок enum ArticleSort { ID_ASC, ID_DESC, TEXT_MATCH, CLOSEST }. В таком случае вы можете явно указать доступные способы для сортировки.

Также если внимательно прочитать как объявляются типы Enum на сервере, то помимо ключа ID_ASC, можно задать значение id ASC (для SQL), либо { id: 1 } (для NoSQL). Т.е. клиенты видят унифицированный ключ ID_ASC, а вот на сервере в resolve-методе вы получаете уже готовое значение для подстановки в запрос. Конвертация ключа сортировки происходит внутри Enum-типа, что в свою очередь сделает код вашего resolve-метода меньше и чище.

Ну а теперь, чтобы иметь возможность сортировать по нескольким полям, нам просто необходимо дать возможность передавать массив значений для сортировки. В итоге получим следующее объявление сортировки:

type Query {
  articles(sort: [ArticleSort!]): [Article]
}

enum ArticleSort {
  ID_ASC, ID_DESC, TEXT_MATCH, CLOSEST
}

5.3. Для ограничения возвращаемых элементов в списке используйте аргументы limit со значением по умолчанию и skip.

С ограничением кол-ва элементов в списке и возможностью сдвига все банально просто. Используйте аргументы с названиями limit и skip. Единственно для лимита хорошо бы задать значение по умолчанию, чтоб клиенты могли не указывать это значение. И сделать limit обязательным, чтоб нельзя было передать null.

type Query {
  articles(
    skip: Int,
    limit: Int! = 20
  ): [Article]
}

5.4. Для пагинации используйте аргументы page, perPage и возвращайте output-тип с полями items с массивом элементов и pageInfo с метаданными для удобной отрисовки страниц на клиенте.

Альтернативой для ограничения возвращаемых элементов в списке limit и skip может выступить пагинация.

Для пагинации лучше всего использовать аргументы с именами page и perPage, которые NonNull и co значением по умолчанию:

type Query {
  articles(
    page: Int! = 1
    perPage: Int! = 20
  ): [Article]
}

Но если вы остановитесь только на аргументах page и perPage, то польза от вашей пагинации для клиентов будет ничем не лучше limit и skip. Для того, чтобы клиентское приложение могло отрисовать нормально пагинацию, ему необходимо предоставить не только сами элементы списка, но и дополнительные метаданные как минимум с общим кол-вом страниц и записей. Для метаданных пагинации необходимо завести следующий общий тип PaginationInfo:

type PaginationInfo {
  # Total number of pages
  totalPages: Int!

  # Total number of items
  totalItems: Int!

  # Current page number
  page: Int!

  # Number of items per page
  perPage: Int!

  # When paginating forwards, are there more items?
  hasNextPage: Boolean!

  # When paginating backwards, are there more items?
  hasPreviousPage: Boolean!
}

Сакральный смысл всех этих полей в PaginationInfo, чтоб легко можно было отрисовать пагинацию на клиенте без дополнительных вычислений. А еще представьте себе лагающий интернет и нервного пользователя – если он в пагинации успел щелкнуть 50 раз по разным страницам за 5 секунд, то что прилетит в ответ от сервера, мы даже представить себе не можем, тем более отрисовать правильно пагинацию. Поэтому и необходима детальная метаинформация от сервера.

В случае предоставления метаданных для пагинации мы уже не можем взять просто и вернуть массив найденных элементов. Нам необходимо будет завести новый тип ArticlePagination для возврата результатов с сервера. И вот здесь опять появляется повод к выработке стандарта:

type Query {
  articles(
    page: Int! = 1
    perPage: Int! = 20
  ): ArticlePagination
}

type ArticlePagination {
  # Array of objects.
  items: [Article!]!

  # Information to aid in pagination.
  pageInfo: PaginationInfo!
}

У ArticlePagination должно быть как минимум два поля:

  • items — NonNull-массив элементов
  • pageInfo — NonNull-объект с метаданными пагинации totalPages, totalItems, page, perPage

5.5. Для бесконечных списков (infinite scroll) используйте Relay Cursor Connections Specification.

У пагинации есть недостаток, когда добавляются или удаляются элементы, то при переходе на следующую страницу вы можете столкнуться с проблемами:

  • under-fetching — это когда в начале списка удаляется элемент, и при переходе на следующую страницу клиент пропускает запись, которая "убежала" на предыдущую страницу
  • over-fetching — это когда добавляются новые записи в начало списка, и при переходе на следующую страницу клиент повторно видит записи которые были на предыдущей странице

Для решения этой проблемы Facebook разработал спецификацию Relay Cursor Connections Specification. Она идеально подходит для создания бесконечных (infinite scroll) списков. А коль есть спецификация, то значит есть некий стандарт которому может следовать команда разработчиков и не изобретать велосипеды.

Правда вид GraphQL-запросов у infinite scroll не очень, на первый взгляд он выглядит непонятно и требует объяснения для клиентов:

{
  articles(first: 10, after: "opaqueCursor") {
    edges {
      cursor
      node { # только на 3-уровне вложенности получаем данные записи
        id
        name
      }
    }
    pageInfo {
      hasNextPage
    }
  }
}

Поэтому если у вас есть возможность для ваших клиентов помимо Infinite Scroll передать еще и обычную пагинацию – то они вам только спасибо скажут за возможность выбора в получении данных.

6. Правила Мутаций

Сколько схем я не смотрел, но больше всего бардака разводят в Мутациях. Следующие правила позволят вам сделать ваше АПИ сухим, чистым и удобным.

6.1. Используйте Namespace-типы для группировки мутаций в рамках одного ресурса.

В большинстве GraphQL-схем страшно заглядывать в мутации. На АПИ среднего размера кол-во мутаций может легко переваливать за 50-100 штук, и это все на одном уровне. Ковыряться и искать нужную операцию в таком списке достаточно сложно.

Shopify рекомендует придерживаться такого именования для мутаций collection<Action>. В списке это позволяет хоть как-то сгруппировать операции над одним ресурсом. Кто-то противник такого подхода, и форсит использование <action>Collection.

В любом случае есть способ получше – используйте Namespace-типы. Это такие типы которые содержат в себе набор операций над одним ресурсом. Если представить путь запроса в dot-нотации, то выглядит он так Mutation.<collection>.<action>.

Соответственно клиенты легко будут понимать и видеть по документации, какие именно методы доступны над "ресурсом/моделью" article. А их запросы к серверу будут иметь следующий вид:

mutation {
  article {
    like(id: 15)
  }

  ### Forget about ugly mutations names!
  # artileLike
  # likeArticle
}

На стороне сервера в Node.js это делается достаточно легко. Я приведу несколько примеров реализации такого подхода с использованием разных библиотек:

Стандартная реализация с пакетом graphql:

// Create Namespace type for Article mutations
const ArticleMutations = new GraphQLObjectType({
  name: 'ArticleMutations',
  fields: () => {
    like: {
      type: GraphQLBoolean,
      resolve: () => { /* resolver code */ },
    },
    unlike: {
      type: GraphQLBoolean,
      resolve: () => { /* resolver code */ },
    },
  },
});

// Add `article` to regular mutation type with small magic
const Mutation = new GraphQLObjectType({
  name: 'Mutation',
  fields: () => {
    article: {
      type: ArticleMutations,
      resolve: () => ({}), // ✨✨✨ magic! which allows to proceed call of sub-methods
    }
  },
});

С помощью пакета graphql-tools:

const typeDefs = gql`
type Mutation {
    article: ArticleMutations
  }

  type ArticleMutations {
    like: Boolean
    unlike: Boolean
  }
`;

const resolvers = {
  Mutation: {
    article: () => ({}), // ✨✨✨ magic! which allows to proceed call of sub-methods
  }
  ArticleMutations: {
    like: () => { /* resolver code */ },
    unlike: () => { /* resolver code */ },
  },
};

С помощью graphql-compose:

schemaComposer.Mutation.addNestedFields({
  'article.like': { // ✨✨✨ magic! Just use dot-notation with `addNestedFields` method
    type: 'Boolean',
    resolve: () => { /* resolver code */ }
  },
  'article.unlike': {
    type: 'Boolean',
    resolve: () => { /* resolver code */ }
  },
});

С помощью type-graphql:

Автор считает, что подход с вложенными мутациями нарушает семантику мутаций из-за возможности запускать их параллельно.

@ObjectType()
class ArticleMutationType {
  @Field()
  like!: boolean;

  @Field()
  logout!: boolean;
}

@Resolver(ArticleMutationType)
class ArticleMutationResolver {
  @FieldResolver(() => Boolean)
  like() { /* resolver code */}

  @FieldResolver(() => Boolean)
  logout() { /* resolver code */}
}

@Resolver()
class ArticleResolver {
  @Mutation(() => ArticleMutationType)
  articles() {
    return {}; // ✨✨✨ magic
  }
}

С помощью @nestjs/graphql code first:

@ObjectType()
export class ArticleMutations {
  @Field()
  like: boolean;

  @Field()
  logout: boolean;
}

@Resolver(() => ArticleMutations)
export class ArticleMutationActionsResolver {
  @ResolveField()
  like(): boolean { /* resolver code */ }

  @ResolveField()
  logout(): boolean { /* resolver code */ }
}

@Resolver()
export class ArticleMutationResolver {
  @Mutation(() => ArticleMutations)
  async article() {
    return {}; // ✨✨✨ old proven magic
  }
}

Но у такого подхода в nestjs будет один недостаток – на вложенные мутации перестают работать guards. Вам придется логику гвардов закладывать в резолверы. Выбор как всегда за вами.

Как выполнять вложенные мутации последовательно

Если вдруг Фронтендеры в одном запросе выполняют серийно несколько мутаций (что является антипатерном – нужно делать общую мутацию), то они смогут получить тоже самое поведение на вложенных мутациях через алиасы. Детально можно ознакомиться с этим тестом, где мутации like/unlike работают асинхронно с таймаутом на время выполнения. Кратко тест и результаты его работы выглядят так:

await graphql({
  schema,
  source: `
  mutation {
    op1: article { like(id: 1) }
    op2: article { like(id: 2) }
    op3: article { unlike(id: 3) }
    op4: article { like(id: 4) }
  }
`,
});

expect(serialResults).toEqual([
  'like 1 executed with timeout 100ms',
  'like 2 executed with timeout 100ms',
  'unlike 3 executed with timeout 5ms',
  'like 4 executed with timeout 100ms',
]);

Итак, правило для избежания бардака в мутациях - используйте Namespace-типы для группировки мутаций в рамках одного ресурса!

6.2. Выходите за рамки CRUD – создавайте небольшие мутации для разных логических операций над ресурсами.

type ArticleMutations {
   create(...): Payload
   update(...): Payload
+  like(...): Payload
+  unlike(...): Payload
+  publish(...): Payload
+  unpublish(...): Payload
}

С GraphQL надо отходить от CRUD (create, read, update, delete). Если вешать все изменения на мутацию update, то достаточно быстро она станет массивной и тяжелой в обслуживании. Здесь речь идет не о простом редактировании полей заголовок и описание, а о "сложных" операциях. К примеру, для публикации статей создайте мутации publish, unpublish. Необходимо добавить лайки к статье - создайте две мутации like, unlike. Помните, что потребители вашего АПИ слабо представляют себе структуру взаимосвязей ваших данных, и за что каждое поле отвечает. А набор мутаций над ресурсом быстро позволяет фронтендеру "въехать" в набор возможных операций.

Да и бэкендеру в будущем будет легче отслеживать по логам, что пользователи чаще всего дергают. И оптимизировать узкие места.

6.3. Рассмотрите возможность выполнения мутаций сразу над несколькими элементами (однотипные batch-изменения).

Правило скорректировано по замечаниям: Ivan Goncharov #42 Дата последней корректировки: 17.05.2019

type ArticleMutations {
-  deleteArticle(id: Int!): Payload
+  deleteArticle(id: [Int!]!): Payload
}

Клиентские приложения становятся более умными и удобными. Часто пользователю предлагаются batch-операции – добавление нескольких записей, массового удаления или сортировки. Отправлять операции по одной будет накладно. Как-то агрегировать их в сложный GraphQL-запрос с несколькими мутациями, т.е. динамически генерировать один общий запрос на клиенте - совершенно отвратительная идея:

mutation DeleteArticles { # BAD
  op1: deleteArticle(id: 1)
  op2: deleteArticle(id: 2)
  op3: deleteArticle(id: 5)
  op4: deleteArticle(id: 5)
}

Если GraphQL-запрос динамически создается в рантайме (само тело запроса, а не сбор переменных) – то скорее всего вы делаете что-то не так. Форма запроса должна задаваться разработчиками на этапе написания кода. Это позволяет проверять запросы линтерами и статическими анализаторами; а также позволяет "скомпилировать" запрос (сконвертировать в AST) для Relay/Apollo. При динамической генерации запроса на клиенте в браузере теряются все эти проверки и оптимизации.

Благодаря GraphQL спецификации - List Input Coercion вы можете в схеме объявить аргумент id как массив id: [Int!]!, тогда клиенты смогут передавать в своих запросах как просто число, так и массив:

mutation DeleteArticles {
  op1: deleteArticle(id: [1, 2, 5]) # works
  op2: deleteArticle(id: 7) # works too
}

Тоже самое работает, если использовать переменные (можно посмотреть тесты):

await graphql({
  schema,
  source: `
    mutation DeleteArticles($id: [Int!]!) {
      deleteArticle(id: $id)
    }
  `,
  variableValues: { id: 777 }, // Should be `Array`, but works with `Int` too
});

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

6.4. У мутаций должны быть четко описаны все обязательные аргументы, не должно быть вариантов либо-либо.

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

К примеру, ваше АПИ позволяет отправить разные письма с помощью мутации sendEmail(type: PASSWORD_RESET, params: JSON). Для того чтобы выбрать шаблон, вы передаете Enum аргумент с типом письма и для него передаете какие-то параметры.

Проблема такого подхода в том, что клиент заранее точно не знает какие параметры необходимо передать для того или иного типа писем. К тому же, если в будущем проводить рефакторинг схемы, то статическая типизация нам не позволит отловить ошибки на клиенте.

Лучше разбивать мутации на несколько штук с жестким описанием аргументов. Например: sendEmailPasswordReset(login: String!, note: String). При этом не забываем аргументы помечать как обязательные, если без них операция не отработает.

Также бывают ситуации, когда вы обязаны передать либо один аргумент, либо другой. К примеру, мы можем отправить письмо по сбросу пароля если укажут login или email – sendResetPassword(login: String, email: String).

В таком случае мы не можем оба аргумента в нашей мутации сделать обязательными. О том что не передан обязательный аргумент, мы узнаем только в рантайме. Да и фронтендеру будет не сразу понятно, что надо передавать либо login, либо email. А что будет если передать оба аргумента от разных пользователей?

Для решения этой проблемы просто заводится две мутации, где жестко расписаны обязательные аргументы:

type Mutation {
-  sendResetPassword(login: String, email: Email)
+  sendResetPasswordByLogin(login: String!)  # login NonNull
+  sendResetPasswordByEmail(email: Email!)   # email NonNull
}

Не экономьте на мутациях и старайтесь избегать слабой типизации.

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

6.5. У мутации вкладывайте все переменные в один уникальный input аргумент.

Старайтесь в мутациях использовать один аргумент input. Его гораздо легче использовать на клиентской стороне. Клиенту потребуется передать всего одну переменную, а не вагон для каждого аргумента в мутации.

# Good:
mutation ($input: UpdatePostInput!) {
  updatePost(input: $input) { ... }
}

# Not so good – гораздо сложнее писать запрос (дубль переменных)
mutation ($id: ID!, $newText: String, ...) {
  updatePost(id: $id, newText: $newText, ...) { ... }
}

Если у мутации на верхнем уровне один-два аргумента, то при таком подходе они становятся более стройными и читабельными. При этом без дополнительных затрат, кроме нескольких дополнительных нажатий клавиш, вложение аргументов позволяет вам полностью использовать возможности GraphQL, в качестве version-less API (безверсионного апи). Вложенность дает вам возможность расширять типы с течением времени и избегать конфликтов в именовании полей.

Также при статической типизации с помощью Typescript или Flowtype гораздо легче отследить изменения в вашем АПИ, когда в коде идет привязка к одному сложному типу, а не набору разрозненных аргументов.

Думайте о вложении аргументов в один общий аргумент input, как об инвестиции в будущие изменения вашего GraphQL API.

При этом не экономьте на типах – для каждой мутации заводите свой Input-тип с уникальным именем. Это позволит вам менять мутации, не оглядываясь на то, что новая семантика может поломать другие мутации.

Также по состоянию на конец 2018 года в спецификации GraphQL нет возможности деприкейтить аргументы (помечать их как устаревшие). Но вот деприкейтить поля внутри типа input можно. Это еще один повод использовать аргумент input со вложенностью.

6.6. Мутация должна возвращать свой уникальный Payload-тип.

Результат выполнения мутации тоже необходимо возвращать в виде вложенного Payload-типа с уникальным именем. Это позволяет вам расширять ответы мутаций дополнительными полями с данными. При этом не ломать другие мутации, когда вы редактируете уникальный тип ответа для текущей мутации.

Даже если вы хотите вернуть только одну запись из вашей мутации, не поддавайтесь искушению вернуть этот тип напрямую. Возвращая данные напрямую (без обертки в Payload-тип), вы лишаете себя возможности в будущем легко добавить дополнительные поля для возврата. GraphQL version-less API (безверсионного апи) хорошо работает когда типы расширяются, а не модифицируются. Вложение ответа в свой кастомный Payload-тип – это инвестиции в будущие изменения вашего АПИ.

type Mutation {
-  createPerson(input: ...): Person                # BAD
+  createPerson(input: ...): CreatePersonPayload   # GOOD
}

+ type CreatePersonPayload {
+   recordId: ID
+   record: Person
+   # ... любые другие поля, которые пожелаете
+ }

Важно отметить, что возвращаемые поля в вашем Payload-типе должны быть nullable (необязательными). Т.е. если вы будете возвращать ошибку например в поле error, то вы не сможете гарантировать наличие данных в поле record. Этот момент может всплыть, когда фронтендеры начнут вас просить сделать эти поля обязательными, т.к. статический анализ заставляет их делать дополнительную проверку на наличие данных. Вы спокойно должны им сказать, что им необходимо делать проверку, ведь данные реально могут отсутствовать.

6.6.1. В ответе мутации возвращайте измененный ресурс и его id.

В Payload-типе мутации необходимо возвращать измененный ресурс. Лучше всего если поле, которое возвращает измененный ресурс будет иметь фиксированное имя – record. Тогда фронтендеры смогут на автомате считывать результаты вашей мутации и не тратить время на поиск поля, в котором возвращается измененный ресурс.

Также желательно не полениться и предоставить поле recordId, которое возвращает ID измененного ресурса. К примеру, при создании новой записи, если запросили только поле recordId, то это позволит не тянуть весь объект, а просто вернуть id созданной записи.

type CreatePersonPayload {
+  recordId: ID
+  record: Person
  # ... любые другие поля, которые пожелаете
}

6.6.2. В ответе мутации возвращайте статус операции.

Любая мутация это операция на изменение данных. И желательно предоставить механизм для быстрого определения прошла мутация успешно или нет. В мире REST API, да и вообще в мире HTTP достаточно активно используются HTTP status code – так почему же не взять и не перенять в каком-то виде эту практику?!

Как вы знаете GraphQL не зависит от протокола – может быть http, web-sockets, telnet, ssh и прочее. В запросе может опрашиваться много ресурсов и возвращаться много разных ошибок. И многие REST разработчики "страдают" от отсутствия статус кодов, кто-то даже ошибочно пытается их прикрутить к GraphQL-серверу. Так вот почему не взять и не добавить status поле в ответе вашей мутации?!

type CreatePersonPayload {
   record: Person
+  status: CreatePersonStatus! # fail, success, etc. ИЛИ 201, 403, 404 etc.
   # ... любые другие поля, которые пожелаете
}

Желательно, чтобы поле status было типом Enum с фиксированным набором значений. Тип статуса может быть уникальным, либо общим для всех мутаций – надо смотреть по ситуации.

6.6.3. В ответе мутации возвращайте поле с типом Query.

Если ваши мутации возвращают Payload-тип, то обязательно добавляйте в него поле query с типом Query.

type Mutation {
  likePost(id: 1): LikePostPayload
}

type LikePostPayload {
   record: Post
+  query: Query
}

Это позволит клиентам за один раунд-трип не только вызвать мутацию, но и получить вагон данных для обновления своего приложения. К примеру, мы лайкаем какую-то статью likePost и тут же в ответе через поле query можем запросить любые данные которые доступны нам в АПИ (в нашем примере список последних статей с активностью lastActivePosts).

mutation {
  likePost(id: 1) {
    record {
      id
      title
      likes
    }
    query {
      lastActivePosts {
        id
        title
        likes
      }
    }
  }
}

Если мутация возвращает query, то для фронтендеров открывается дичайший профит – возможность сразу запросить любые данные для своего приложения после какой-нибудь страшной мутации за один http-запрос. А если на клиенте используются Relay или ApolloClient с именованными фрагментами, то обновить половину приложения становиться проще простого. Не надо писать второй запрос на получение данных и как-то пробрасывать их в нужное место. Всё обновиться магическим образом само, просто надо написать такую мутацию с существующими фрагментами из вашего приложения:

mutation {
  likePost(id: 1) {
    query {
      ...LastActivePostsComponent
      ...ActiveUsersComponent
    }
  }
}

На стороне сервера прикрутить query в Payload-типе можно следующим образом:

Стандартная реализация с пакетом graphql:

const QueryType = new GraphQLObjectType({ name: 'Query', fields: ... });
const MutationType new GraphQLObjectType({ name: 'Mutation', fields: () => {
  likePost: {
    args: { id: { type: new GraphQLNonNull(GraphQLInt) } },
    type: new GraphQLObjectType({
      name: 'LikePostPayload',
      fields: {
        record: { type: PostType },
        recordId: { type: GraphQLInt },
        query: { type: new GraphQLNonNull(QueryType) }, // ✨✨✨ magic – add 'query' field with 'Query' root-type
      },
    }),
    resolve: async (_, { id }, context) => {
      const post = await context.DB.Post.find(id);
      post.like();
      return {
        record: post,
        recordId: post.id,
        query: {}, // ✨✨✨ magic - just return empty Object
      };
    },
  }
}});

С помощью пакета graphql-tools:

const typeDefs = gql`
  type Query { ...}
  type Post { ... }
  type Mutation {
    likePost(id: Int!): LikePostPayload
  }
  type LikePostPayload {
    recordId: Int
    record: Post
    # ✨✨✨ magic – add 'query' field with 'Query' root-type
    query: Query!
  }
`;

const resolvers = {
  Mutation: {
    likePost: async (_, { id }, context) => {
      const post = await context.DB.Post.find(id);
      post.like();
      return {
        record: post,
        recordId: post.id,
        query: {}, // ✨✨✨ magic - just return empty Object
      };
    },
  }
};

С помощью graphql-compose:

schemaComposer.Mutation.addFields({
  likePost: {
    args: { id: 'Int!' },
    type: `type LikePostPayload {
      record: Post
      recordId: Int
      # ✨✨✨ magic – add 'query' field with 'Query' root-type
      query: Query!
    }`,
    resolve: async (_, { id }, context) => {
      const post = await context.DB.Post.find(id);
      post.like();
      return {
        record: post,
        recordId: post.id,
        query: {}, // ✨✨✨ magic - just return empty Object
      };
    },
  }
}});

6.6.4. В ответе мутации возвращайте поле error с типизированной пользовательской ошибкой.

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

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

Эту проблему можно решить, если добавить поле error в Payload мутации, где вы сможете возвращать ошибку бизнес-логики. Т.е. если клиент запросил поле error, то вы отлавливаете ошибку в резолвере и просто возвращаете ее в виде обычного объекта. А если клиент не запросил поле error, то вы просто пробрасываете ошибку наверх, тогда она автоматически будет добавлена в массив глобальных ошибок в поле errors на самом верхнем уровне GraphQL-ответа. Т.е. клиент в любом случае получит ошибку из резолвера, только теперь у него есть выбор где ее получить – как обычно на верхнем уровне в массиве глобальных ошибок errors, либо получить типизированную ошибку в пэйлоаде мутации в поле error.

type Mutation {
  createPost(title: String): CreatePostPayload
}

type CreatePostPayload {
   record: Post
+  error: ErrorInterface
}

При этом поле error должно быть описано интерфейсом ErrorInterface:

interface ErrorInterface {
  message: String
}

Это позволит вам возвращать разные типы ошибок, которые могут содержать дополнительные поля. Например мы будем возвращать ValidatorError, если при сохранении записи не прошла валидация значений; MongoError если произошла ошибка в базе данных; и в любом другом случае будем возвращать RuntimeError, если не смогли распознать ни один из конкретных типов ошибок описанных ранее:

type ValidatorError implements ErrorInterface {
  message: String
  """Source of the validation error from the model path"""
  path: String
  """Field value which occurs the validation error"""
  value: JSON
}

type MongoError implements ErrorInterface {
  """MongoDB error message"""
  message: String
  """MongoDB error code"""
  code: Int
}

"""General type of error when no one of concrete error type are received"""
type RuntimeError implements ErrorInterface {
  """Error message"""
  message: String
}

Но самое главное, когда вы возвращаете error: ErrorInterface, то вы предоставляете клиенту выбор того, насколько он детально хочет получить информацию об возможной ошибке. Можно просто получить сообщение об ошибке, не обращая внимания на доп поля, через следующий GraphQL-запрос:

mutation CreatePost {
  createPost(title: "How to return GraphQL errors?") {
    record {
      id
      title
    }
    error {
      message  # <-- Just give me a message, no matter what kind of error
    }
  }
}

Ну а если клиент дотошный, и умеет обрабатывать разные типы ошибок, то он сможет написать следующий запрос:

mutation CreatePost {
  createPost(title: "How to return GraphQL errors?") {
    record {
      id
      title
    }
    error {
      message
      __typename  # <--- Client will receive error type name
      ...on ValidatorError { # <--- Request additional fields according to error type
        path
        value
      }
      ...on MongoError {
        code
      }
    }
  }
}

PS. Раньше в этом правиле предлагалось использовать Union-типы и возвращать массив ошибок, но практика показала, что их тяжело создавать бэкендерам, да и фронтендерам не особо удобно. Поэтому был проработан текущий упрощенный вариант, где используются только интерфейсы. И т.к. работа резолвера может быть прервана только одним исключением, то нет никакого смысла возвращать массив ошибок, как предлагалось ранее.

7. Правила связей между типами (relationships)**

Концептуальная разница GraphQL от REST API в том, что реализацию логики получения связанных ресурсов перенесли с клиента на сервер. Если с REST API фронтендеры гадают (без hypermedia) как запрашивать связанные ресурсы, пишут слой склейки/довытаскивания данных на клиенте, то с GraphQL этим делом занимаются те люди, которые прекрасно понимают свой data domain, и делают это на порядок быстрее и эффективнее. Тем более, когда связанные ресурсы дергаются в рамках сервера, не тратится много времени на долгие раунд-трипы между клиентом и сервером для подзапросов. И вы не ограничены 4-мя браузерными подключениями на домен, внутри сервера отправляйте хоть 200 одновременных запросов, если хватает мощей.

7.1. GraphQL-схема должна быть "волосатой"

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

  • каждый компонент имеет свой GraphQL-фрагмент (может иметь несколько фрагментов)
  • GraphQL-фрагмент содержит только те поля, которые необходимы текущей компоненте
  • чтобы сформировать GraphQL-запрос, они композируют компоненты и фрагменты

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

Чтобы GraphQL-фрагменты хорошо работали на фронтенде, необходимо чтобы бэкендеры указывали в своей схеме как можно больше связей между своими типами. Как бы оправдывали слово Graph. Иначе, если схема содержит мало связей и похожа на дерево, то ее можно называть RestQL.

Волосатый GraphQL (Hairy GraphQL) – это GraphQL-схема, которая содержит много связей между типами.

В качестве примера можно привести сильно связную, "волосатую" схему GitHub АПИ (посмотрите сколько связей между типами):

hairly-github

А в качестве слабо связного, "лысого" RestQL АПИ за пример сойдет вот такое древовидное АПИ:

hairly-amazon

Данные картинки сделаны с помощью инструмента graphql-voyager от Ivan Goncharov и Roman Gotsiy. Можно загрузить интроспекцию любой схемы и визуально оценить ее – "волосатая" она или "лысая".

Т.е. чем больше связей в вашей схеме, тем легче фронтендерам делать запросы используя GraphQL-фрагменты. К сожалению, на "лысой" схеме применить фрагментный подход нормально не получится. В слабо связном графе сложнее из одной вершины достать другую. Поэтому фронтендеры будут вынуждены писать новые запросы через корень, чтобы получить необходимые данные. А не просто дозапросить у текущего Типа по какому-то полю связанные данные с другим Типом. Одним словом, обратно возвращаемся в каменный 2000 год REST API.

10. Прочие правила

10.1. Для документации используйте markdown

Правило предложил: Ivan Goncharov

Документация - это первоклассная особенность в GraphQL. Чтобы она оставалась актуальной, ее описание производится непосредственно в коде GraphQL-типов. Документация доступна через интроспекцию.

По спецификации GraphQL вы можете использовать markdown для свойства description типов, полей и аргументов.

Большинство клиентов GraphQL (GraphiQL, Playground, Altair, VSCode plugins) умеют корректно отображать markdown разметку конечным пользователям.

A. Appendix

A-1. Полезные ссылки


WIP: Следующие правила в процессе формирования

  • 8. Правила по версионированию схемы
    • TODO: Rule #4: It’s easier to add fields than to remove them.
    • TODO: Unique payload type. Use a unique payload type for each mutation and add the mutation’s output as a field to that payload type.
  • 9. Правила по бизнес-логике
    • TODO: Rule #2: Never expose implementation details in your API design.
    • TODO: Rule #3: Design your API around the business domain, not the implementation, user-interface, or legacy APIs.
    • TODO: Rule #5: Major business-object types should always implement Node.
    • TODO: Rule #12: The API should provide business logic, not just data. Complex calculations should be done on the server, in one place, not on the client, in many places.
    • TODO: Rule #13: Provide the raw data too, even when there’s business logic around it.

Some rules from Shopify

The one that may have stood out to you already, and is hopefully fairly obvious, is the inclusion of the CollectionMembership type in the schema. The collection memberships table is used to represent the many-to-many relationship between products and collections. Now read that last sentence again: the relationship is between products and collections; from a semantic, business domain perspective, collection memberships have nothing to do with anything. They are an implementation detail.

Rule #2: Never expose implementation details in your API design.


On a closely related note, a good API does not model the user interface either. The implementation and the UI can both be used for inspiration and input into your API design, but the final driver of your decisions must always be the business domain.

Even more importantly, existing REST API choices should not necessarily be copied. The design principles behind REST and GraphQL can lead to very different choices, so don't assume that what worked for your REST API is a good choice for GraphQL.

Rule #3: Design your API around the business domain, not the implementation, user-interface, or legacy APIs.


Rule #5: Major business-object types should always implement Node.

interface Node {
  id: ID!
}

It hints to the client that this object is persisted and retrievable by the given ID, which allows the client to accurately and efficiently manage local caches and other tricks. Most of your major identifiable business objects (e.g. products, collections, etc) should implement Node.


Rule #12: The API should provide business logic, not just data. Complex calculations should be done on the server, in one place, not on the client, in many places.

This last point is a critical piece of design philosophy: the server should always be the single source of truth for any business logic. An API almost always exists to serve more than one client, and if each of those clients has to implement the same logic then you've effectively got code duplication, with all the extra work and room for error which that entails.

type Collection implements Node {
  # ...
  hasProduct(id: ID!): Bool!
}

Rule #13: Provide the raw data too, even when there's business logic around it.

Clients should be able to do the business logic themselves, if they have to. You can’t predict all of the logic a client is going to want


  1. TODO: Правила версионирования

Я в корне не согласен с правилом №21 от Shopify:

Rule #21: Structure mutation inputs to reduce duplication, even if this requires relaxing requiredness constraints on certain fields.

Экономию на типах сложно оправдать. С таким подходом от Shopify будет трудно изменять ваше апи в будущем.


Exposing a schema element (field, argument, type, etc) should be driven by an actual need and use case. GraphQL schemas can easily be evolved by adding elements, but changing or removing them are breaking changes and much more difficult.

Rule #4: It's easier to add fields than to remove them.