GraphQL: доступ запрещен
Жил-был один маленький разработчик, работал себе над 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 в РФ.
Ваши ответы помогут нам составить наиболее объективную картину, которой мы по традиции поделимся со всеми.