Жил-был один маленький разработчик, работал себе над REST API и горя не знал. Но вот приходит к нему тимлид и предлагает затащить GraphQL. Казалось бы: классный и мощный GraphQL — это запросто! Но в процессе проектирования API разработчик столкнулся с неожиданными проблемами и суровыми испытаниями: система оказалась довольно сложна и полна различных прав и ролей.
Всем привет! Меня зовут Олег, я — бэкенд-разработчик системы Talantix. В этой статье я расскажу о том, как работать с доступом к данным в GraphQL.
Немного про доменную область
Talantix — это ATS для HR. А если простыми словами — рабочий стол рекрутера. Здесь он ведет вакансии, перемещает по этапам кандидатов, пишет им письма, оставляет злобные комментарии, создает встречи, в общем, делает всё что душеньке угодно.
В данной статье примеры будут завязаны на кандидатах. В нашем коде у них исторически сложилось название 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 появляется объект с описанием и типом ошибки, путем до узла в котором ошибка возникла и другими полями. Данное поле можно расширять под свои нужды, дополняя иными данными. Техническая ошибка здесь выглядит уместно и удобно, но с бизнес-ошибками все сложнее. Их мы рассмотрим далее.
Вникаем в проблему с 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)
Urgen
15.09.2022 11:58Убираем обязательность из полей ответа. Клиент сам может решить, что ему запрашивать.
Пишем ошибку доступа в стандартный блок ошибок.
И не нужно заморачиваться с юнионами
PersonItem/PersonError
.genroelgvozo Автор
15.09.2022 15:34вы путаете обязательность заполнения и ответа)
NonNull (или восклицательный знак) означает не обязательность ответа, а то, Что если вы его запросили, оно точно не null. И избавляться от этих пометок это самое последнее что хочется делать. Клиенту хочется знать что всегда заполнено, а на что ему надо обрабатывать null-ы
GraphQL на то он и нужен, что никакой обязательности ответов нет, запрашивай что хочешь (иначе смысл?)genroelgvozo Автор
15.09.2022 15:39ну и соответственно тот самый кейс - запросили email, выдали вам null
как вам понять недоступен он или не заполнен? В массив errors писать можно об этом, но это неудобно (нет строгой типизации в ошибках, просто набор полей, перечисление типов ошибок). Получается что контракт по ошибкам хранится где-то в другом месте, а о сущностях - в схеме. Хочется чтобы было единое место знаний - схема.
Urgen
16.09.2022 14:11Отчасти с вами согласен, в массиве ошибок ошибки пишутся в произвольном формате, который схемой не задаётся. На нашем проекте о нём мы просто договорились. На бэке он генерится
Однако, если в email пришёл null, то это и есть null, т.к. в противном случае пришла бы ошибка.
AlexeiA
15.09.2022 15:34Не хватает пары примеров, какие конкретно ваши проблемы решил переезд на GraphQL
genroelgvozo Автор
15.09.2022 15:35Хороший вопрос, но о причинах перехода (они же примеры, и они же в итоге и дали профит) описано в другой нашей статье https://habr.com/ru/company/hh/blog/677972/
panzerfaust
Какая-то тут недосказанность в истории с PersonItem и PersonFullItem. У вас же наверняка есть кейсы, когда у объекта Х полей и лишь несколько из них появляются всегда. Получается, что все же придется идти на оверхед и заменять примитивные типы алгебраическими.
genroelgvozo Автор
Не очень понял с чего вывод про наверняка) И все таки, что значит "появляются всегда". Если речь о том, что есть место в приложении, где мы показываем маленькую часть объекта - так GraphQL и нужен для этого, клиент просит только эти поля.
В реальности у нас все устроено как я описал тут. Кейс про email я выбрал для наглядности, основная проблема у нас была с вакансиями, которые показываем в откликах кандидата (их могу видеть все, но в саму вакансию перейти не могут). Вероятно нет сложных кейсов потому, что это все таки кабинет в рамках компании, а не сайт hh.ru, где есть поконтактный доступ (часть полей может быть скрыто, но и это не проблема, их то как раз можно целиком выделять в алгебраический тип блоком - например ContactsItem и ContactsError)
У нас же у компании либо куплен доступ ко всему кабинету, либо нет. А для отдельных ролей могут быть просто видны названия чужих вакансий, или наоборот не видны чужие кандидаты (если откроют по прямой ссылке такого)
ris58h
Список полей предлагается делать или как? Можно пример?
genroelgvozo Автор
Может не понял про список полей, но у кандидата будет поле contacts типа Contacts, как-то так:
Да, могут быть разные блоки, еще например ФИО и день рождения, их можно целиком еще в один объект. Если прям хочется динамические блоки в зависимости от ролей, и динамически включать и выключать, то тут конечно придется уже наверное более синтетические списки этих самых полей в дженерном виде.
Пример достаточно топорный, там можно накручивать.. Могут быть полные и не полные контакты (будет три состояния, вообще недоступны, виден только часть, видны все). + со списками еще логика такая - выдаем то, что доступно тебе (когда запрашиваешь "мои вакансии", тут все как в обычном REST API)