Жил-был один маленький разработчик, работал себе над REST API и горя не знал. Но вот приходит к нему тимлид и предлагает затащить GraphQL. Казалось бы: классный и мощный GraphQL — это запросто! Но в процессе проектирования API разработчик столкнулся с неожиданными проблемами и суровыми испытаниями: система оказалась довольно сложна и полна различных прав и ролей.

Всем привет! Меня зовут Олег, я — бэкенд-разработчик системы Talantix. В этой статье я расскажу о том, как работать с доступом к данным в GraphQL.

Немного про доменную область

Talantix — это ATS для HR. А если простыми словами — рабочий стол рекрутера. Здесь он ведет вакансии, перемещает по этапам кандидатов, пишет им письма, оставляет злобные комментарии, создает встречи, в общем, делает всё что душеньке угодно.

Система Talantix
Система Talantix

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

Обработка ошибок "по старинке"

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

Как же мы работали со всем этим в REST API? Допустим, у нас есть GET на кандидата, кандидат в базе существует и доступен пользователю. В этом случае мы отдаем ответ со статусом 200, а в теле ответа — данные кандидата.

GET /persons/1
200 OK

{
  "id": 1,
  "firstName": "Олег"
}

Если же кандидата нет или он недоступен нашему пользователю, мы можем отдать ответ 404, а в теле ответа прокомментируем, почему произошла данная ошибка и какой тип она имеет. Похожим образом API ведет себя в случае изменяющих запросов, например, при желании удалить некоторого кандидата, мы можем получить ответ 403. А в теле ответа будет присутствовать пояснение, что у вас не хватает на это прав.

GET /persons/1
404 Not Found

{
  "errorType": "NOT_FOUND",
  "message": "Кандидат не найден"
}

DELETE /persons/1
403 Forbidden

{
  "errorType": "ACCESS_DENIED",
  "message": "Вы не имеете права удалять кандидатов"
}

GraphQL не завязан на конкретный протокол общения, и, в случае с HTTP, мы имеем один endpoint, который принимает на вход запрос, возвращает код 200 и в теле ответа данные, которые мы попросили. Код ответа в этом случае ни на что не влияет и никак не обрабатывается, и для обработки ошибок нужен иной механизм.

Ошибки бывают разные

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

В мире GraphQL есть готовый механизм, а точнее поле errors, для выдачи ошибок. На скриншоте видно, как при ошибке в указании id в поле errors появляется объект с описанием и типом ошибки, путем до узла в котором ошибка возникла и другими полями. Данное поле можно расширять под свои нужды, дополняя иными данными. Техническая ошибка здесь выглядит уместно и удобно, но с бизнес-ошибками все сложнее. Их мы рассмотрим далее.

Интерфейс GraphiQL - пробуем допустить ошибку в запросе
Интерфейс GraphiQL - пробуем допустить ошибку в запросе

Вникаем в проблему с null

Допустим, в нашей системе имеется тип Person, у которого есть поля id и firstName:

type Person {
  id: Int!,
  firstName: String
}

Пользователь хочет запросить персону по некоторому id,

{
  person(id: 123) {
    id
    firstName
  }
}

но такого кандидата в нашей системе нет. Самый простой способ сказать ему об этом — отдать null в качестве ответа:

{  
  "person": null
}

В этом случае такой подход сработает. Единственное ограничение — пользователю никак не различить кейс "кандидат отсутствует" от "кандидат ему недоступен". Однако, если это не требуется, такой способ вполне сгодится.

Теперь немного усложним задачу — добавим персоне опциональное поле email.

type Person {
  id: Int!,
  firstName: String,
  email: String
}

Допустим, в нашей системе есть роли, которым недоступны контактные данные кандидата — например, наблюдатели за вакансией. Тогда на их запрос данных кандидата в качестве email мы обязаны отдать null.

{
  "person": {
    "id": 123,
    "firstName": "Олег",
    "email": null
  }
}

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

Для полноты рассмотренных вариантов сделаем email обязательным полем. Об этом в схеме GraphQL нам говорит восклицательный знак:

type Person {
  id: Int!,
  firstName: String,
  email: String!
}

И здесь возникает другая проблема: при отсутствии доступа мы обязаны отдать null в качестве email, но при этом будем конфликтовать с нашей же схемой. Тогда в лучшем случае будет ругаться наш валидатор, а в худшем — наш пользователь.

Первый заход на решение

Напомним, что в REST в различных ситуациях мы отдавали различный статус ответа: 200, 404 и другие. Попробуем сэмулировать такой статус ответа отдельным полем в нашей схеме. Введем новый тип UserError:

enum ErrorType {
  NOT_FOUND
  ACCESS_DENIED
}

type UserError {
  errorType: ErrorType!
  message: String
}

Он будет содержать enum, который по сути является текстовым аналогом такого статуса ответа. 404 будет соответствовать NOT_FOUND, 403 — ACCESS_DENIED. Кроме такого enum в UserError присутствует еще и сообщение об ошибке, если оно требуется. Тип ошибки — это прекрасно, но надо ее где-то вернуть, а наша схема — это граф. Мы могли бы вернуть данную ошибку в узле errors, как поступаем с техническими ошибками, но обработка такой ошибки неудобна, а отсутствие строгости схемы в этом поле влечет за собой проблему поддержки контракта.

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

{
  person(id: 123) {
    error {
      errorType
      message
    }
    id
    firstName
    email
  }
}

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

{
  "person": {
    "error": {
      "errorType": "NOT_FOUND",
      "message": "Кандидат не найден"
    },
    "id": null,
    "firstName": null,
    "email": null
  }
}

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

Пока вы читали все эти выкладки, возможно вам в голову пришла идея — "Можно же просто не отдавать поля персоны в ответе, ведь undefined !== null". Увы, но нет.

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

Решение получше

В REST, кроме разных статусов ответа, мы также давали различное тело ответа в зависимости от ошибки. И тут на горизонте появляется тип union, присутствующий в стандарте GraphQL. Сделаем тип Person объединением двух других типов — PersonError и PersonItem:

enum PersonErrorType {
  NOT_FOUND
}

type PersonError {
  errorType: PersonErrorType!
  message: String
}

union Person = PersonItem | PersonError

Это означает, что узел типа Person в ответе вернется нам как объект типа PersonItem с полями кандидата, либо как объект типа PersonError с полями ошибки. С точки зрения схемы эти два типа теперь взаимоисключающие, что будет отражено и в запросе в дальнейшем.

Нам было удобно изменить наш UserError: мы сузили его до кандидата и назвали PersonError. Теперь он содержит только те ошибки, которые в действительности может вернуть наш сервер. А другие ошибки в enum-е, такие как ACCESS_DENIED, не смущают нашего пользователя, заставляя обрабатывать их для тех сущностей, для которых их просто не может быть согласно бизнес логике нашей системы.

Как же будет выглядеть наш запрос?

{
  person(id: 123) {
    __typename
    ... on PersonError {
      errorType
      message
    }
    ... on PersonItem {
      id
      firstName
      email
    }
  }
}

Здесь мы видим специальный синтаксис фрагментов с ключевым словом on. В нашем запросе он говорит: "отдай мне поля errorType и message, если вернулся тип PersonError. Если же тип этого узла равен PersonItem, то отдай мне поля кандидата". В отличие от включения ошибки внутрь самой сущности, поля ошибки и поля самого кандидата взаимоисключают друг друга и не могут быть в ответе одновременно.

Здесь же можно заметить интересное поле __typename — так называемое метаполе в GraphQL. Метаполя в GraphQL присутствуют по умолчанию во всех узлах запроса. Поле __typename, как можно догадаться из названия, равно имени типа этого узла. При использовании с union, это поле особенно полезно, так как пользователь API может завязываться не на какие-то специфичные поля самих типов, а на его имя.

То, как будет выглядеть ответ на такой запрос, будет зависеть от доступности нашего кандидата. Если кандидат присутствует в системе и доступен нашему пользователю, мы отдадим его поля, а поле __typename будет равно PersonItem:

{
  "person": {
    "__typename": "PersonItem",
    "id": 123,
    "firstName": "Олег",
    "email": "email"
  }
}

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

Если кандидат недоступен или его просто нет в базе, поле __typename станет равным PersonError, а вместо полей кандидата в JSON будет присутствовать errorType и message.

{
  "person": {
    "__typename": "PersonError",
    "errorType": "NOT_FOUND",
    "message": "Кандидат не найден"
  }
}

В нашем примере с кандидатом поле errorType принимает одно допустимое значение. Может показаться, что это необоснованное усложнение схемы — можно вернуться к случаю, когда мы возвращали null, ведь нам не нужно разбирать причину отсутствия данных. Частично это так, но надо помнить, что наша система постоянно обрастает новой функциональностью, а ее бизнес-логика усложняется. И если в один прекрасный момент появится новый вид ошибки для кандидата, вроде ACCESS_DENIED, то в API, где предусмотрены пользовательские ошибки на уровне типов, будет очень просто добавить новую ошибку в enum, расширяя это API, а не переделывая нашу схему кардинально.

Реализация на бэкенде

Для работы с GraphQL мы используем библиотеку SPQR, которая основана на базовой библиотеке graphql-java. Если хотите узнать плюсы и минусы различных библиотек для GraphQL на java, то советую почитать статью от Артема (в ней есть и видеоверсия). В этой библиотеке используется code-first подход: сначала вы пишете классы сущностей, описываете связи между ними, пишете резолверы, а после этого библиотека сама генерирует схему для клиента.

Итак, у нас будет базовый интерфейс Person, помеченный нотацией GraphQLUnion:

@GraphQLUnion(
  name = "Person", 
  description = "person", 
  possibleTypeAutoDiscovery = true
)
public interface Person {}

public class PersonItem implements Person {
  private Integer id;
  private String firstName;
  //...
}

public class PersonError implements Person {
  private PersonErrorType errorType;
  private String message;
  //...
}

Здесь мы указываем имя типа для union, также опциально можно указать описание для нашей документации. Еще у нас имеется параметр possibleTypeAutoDiscovery равный true, который говорит библиотеке, чтобы она сама поискала имплементации данного интерфейса в рантайме. Именно они станут перечислением типов в нашем union. Но есть и другой вариант: там вы можете сами перечислить необходимые вам реализации, используя параметр possibleTypes.

Преимущества работы с union

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

  • Нет никаких конфликтов с NonNull аннотациями.

  • Поле __typename позволяет удобно организовать обработку ответа, например, с помощью паттерна стратегия и не завязываться на какие-либо поля самой сущности.

  • Кастомизированный errorType под каждую сущность. На первый взгляд может показаться, что это увеличение бойлерплейта и однотипных классов. Но с другой стороны — получается самодокументируемое API, поэтому возможные ошибки в каждом конкретном случае диктуются и ограничиваются самой схемой.

Но как же email...

На примерах с персоной мы поняли как классно работать с union, посмотрели как это выглядит на бэкенде. Но тут мы вспоминаем, что в способе с null разбирали примеры с email, и перед нами встала проблема различимости заполненности поля от его недоступности. Напомню, у нас есть персона с обязательным полем email и пользователи, роль которых не позволяет им просматривать контактные данные кандидата. Городить отдельный тип со своей ошибкой для такого простого поля, как email, кажется оверхедом, ведь это всего лишь строка.

Подойдем к этой проблеме с другой стороны, по факту у нас есть полная версия кандидата, и версия этого же кандидата без каких-то полей. В данном случае оно одно — email. Посему попробуем описать это на языке типов в нашей схеме. Введем новый тип персоны PersonPublicItem , который будет содержать ограниченный набор полей, доступный пользователям независимо от их роли в системе. А также введем еще один тип — PersonFullItem, с полной информацией о кандидате, которая доступна только определенным менеджерам:

type PersonPublicItem {
  id: Int!
  firstName: String
}

type PersonFullItem {
  id: Int!
  firstName: String
  email: String!
}

union Person = PersonError | PersonPublicItem | PersonFullItem

Теперь наш тип Person будет объединением из трех типов — ошибки, открытой персоны и закрытой. Данная схема решает нашу задачу, позволяя отдать тот или иной тип с email или без, в зависимости от роли пользователя.

Но в реальных задачах та же открытая персона может содержать немало полей, и в этом случае нам придется копировать их все в тип закрытой персоны. При этом схема никак не контролирует согласованность в количестве этих полей, одинаковом названии и типе. В ООП мы бы использовали наследование, в GraphQL наряду с union присутствуют интерфейсы.

Заведем интерфейс PesonItem с базовыми полями, которые мы хотим видеть во всех его реализациях:

interface PersonItem {
  id: Int!
  firstName: String
}

type PersonPublicItem implements PersonItem {
  id: Int!
  firstName: String
}

type PersonFullItem implements PersonItem {
  id: Int!
  firstName: String
  email: String!
}

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

Однако преимущества налицо:

  • Во-первых, данный интерфейс на уровне схемы обязывает нас иметь все базовые поля в классах реализация. Это добавляет строгости к типизации и неймингу этих полей.

  • Во-вторых, в коде такой интерфейс будет являться абстрактным классом, поэтому вам не придется дублировать его поля в имплементациях. Учитывая, что мы используем code-first подход, за нас данную схему сгенерирует библиотека.

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

# Запрос без дублирования
{   
  person(id: 123) {
    ... on PersonItem {
      id         
      firstName      
    }      
    ... on PersonFullItem {
      email      
    }    
  }
}

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

Заключение

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

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

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

Видеоверсию этой статьи можно посмотреть на нашем канале по ссылке.

Где вам хочется работать 

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

Пройдите этот опрос и расскажите о своих впечатлениях от сегодняшнего IT в РФ.

Ваши ответы помогут нам составить наиболее объективную картину, которой мы по традиции поделимся со всеми.

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


  1. panzerfaust
    15.09.2022 09:32
    +3

    Городить отдельный тип со своей ошибкой для такого простого поля, как email, кажется оверхедом, ведь это всего лишь строка.

    Какая-то тут недосказанность в истории с PersonItem и PersonFullItem. У вас же наверняка есть кейсы, когда у объекта Х полей и лишь несколько из них появляются всегда. Получается, что все же придется идти на оверхед и заменять примитивные типы алгебраическими.


    1. genroelgvozo Автор
      15.09.2022 10:08

      Не очень понял с чего вывод про наверняка) И все таки, что значит "появляются всегда". Если речь о том, что есть место в приложении, где мы показываем маленькую часть объекта - так GraphQL и нужен для этого, клиент просит только эти поля.

      В реальности у нас все устроено как я описал тут. Кейс про email я выбрал для наглядности, основная проблема у нас была с вакансиями, которые показываем в откликах кандидата (их могу видеть все, но в саму вакансию перейти не могут). Вероятно нет сложных кейсов потому, что это все таки кабинет в рамках компании, а не сайт hh.ru, где есть поконтактный доступ (часть полей может быть скрыто, но и это не проблема, их то как раз можно целиком выделять в алгебраический тип блоком - например ContactsItem и ContactsError)

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


      1. ris58h
        15.09.2022 11:29

        можно целиком выделять в алгебраический тип блоком - например ContactsItem и ContactsError

        Список полей предлагается делать или как? Можно пример?


        1. genroelgvozo Автор
          15.09.2022 11:42

          Может не понял про список полей, но у кандидата будет поле contacts типа Contacts, как-то так:

          union Contacts = ContactsItem | ContactsError
          
          type ContactsItem {
            items: Contact[]
          }
          
          type Contact {
            type: ContactType #email,phone,...
            value: String
          }
          
          type ContactsError {
            errorType: ContactsErrorType 
            # тут тип, например "Неоплачен доступ", "Кандидат скрыл данные" и т.д
          }

          Да, могут быть разные блоки, еще например ФИО и день рождения, их можно целиком еще в один объект. Если прям хочется динамические блоки в зависимости от ролей, и динамически включать и выключать, то тут конечно придется уже наверное более синтетические списки этих самых полей в дженерном виде.

          Пример достаточно топорный, там можно накручивать.. Могут быть полные и не полные контакты (будет три состояния, вообще недоступны, виден только часть, видны все). + со списками еще логика такая - выдаем то, что доступно тебе (когда запрашиваешь "мои вакансии", тут все как в обычном REST API)


  1. Urgen
    15.09.2022 11:58

    1. Убираем обязательность из полей ответа. Клиент сам может решить, что ему запрашивать.

    2. Пишем ошибку доступа в стандартный блок ошибок.

    И не нужно заморачиваться с юнионами PersonItem/PersonError.


    1. genroelgvozo Автор
      15.09.2022 15:34

      вы путаете обязательность заполнения и ответа)

      NonNull (или восклицательный знак) означает не обязательность ответа, а то, Что если вы его запросили, оно точно не null. И избавляться от этих пометок это самое последнее что хочется делать. Клиенту хочется знать что всегда заполнено, а на что ему надо обрабатывать null-ы
      GraphQL на то он и нужен, что никакой обязательности ответов нет, запрашивай что хочешь (иначе смысл?)


      1. genroelgvozo Автор
        15.09.2022 15:39

        ну и соответственно тот самый кейс - запросили email, выдали вам null

        как вам понять недоступен он или не заполнен? В массив errors писать можно об этом, но это неудобно (нет строгой типизации в ошибках, просто набор полей, перечисление типов ошибок). Получается что контракт по ошибкам хранится где-то в другом месте, а о сущностях - в схеме. Хочется чтобы было единое место знаний - схема.


        1. Urgen
          16.09.2022 14:11

          Отчасти с вами согласен, в массиве ошибок ошибки пишутся в произвольном формате, который схемой не задаётся. На нашем проекте о нём мы просто договорились. На бэке он генерится

          Однако, если в email пришёл null, то это и есть null, т.к. в противном случае пришла бы ошибка.


  1. AlexeiA
    15.09.2022 15:34

    Не хватает пары примеров, какие конкретно ваши проблемы решил переезд на GraphQL


    1. genroelgvozo Автор
      15.09.2022 15:35

      Хороший вопрос, но о причинах перехода (они же примеры, и они же в итоге и дали профит) описано в другой нашей статье https://habr.com/ru/company/hh/blog/677972/


  1. dont_faint
    17.09.2022 01:38

    Хорошая статья! Буду ждать следующий выпуск :half_troll: