GraphQL: доступ запрещен

от автора

Жил-был один маленький разработчик, работал себе над 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 в РФ.

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


ссылка на оригинал статьи https://habr.com/ru/company/hh/blog/688158/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *