Эта статья была опубликована в понедельник, 7 марта 2022 года, автором Ghislain Thau @ The Guild Blog
В отличие от REST, GraphQL не использует Status Code, чтобы отличить успешный результат от исключения. В REST можно отправить ответ с соответствующим кодом состояния, чтобы проинформировать вызывающую сторону о виде возникшего исключения, например:
-
400
: неправильный запрос (например, недопустимые входные данные). -
401
или403
: неавторизованный (требуется аутентификация) или запрещенный (недостаточное разрешение) -
404
: ресурс не найден -
500
: внутренняя ошибка сервера - и многое другое.
В GraphQL мы управляем ошибками по-другому. В этой статье мы рассмотрим, как:
- добиться лучшего представления ошибок, чем то, которое по умолчанию предлагает GraphQL
- добиться безопасности типов с помощью Typescript и GraphQL Code Generator
- обрабатывать ошибки от кода нижнего уровня до резольверов
- и, наконец, получить дополнительную безопасность, используя функциональный подход.
Все примеры кода можно найти в этом репозитории.
Обработка ошибок в GraphQL по умолчанию
В GraphQL все запросы должны направляться к одной конечной точке:
POST https://example.com/graphql.
API GraphQL всегда будет возвращать код состояния 200 OK
, даже в случае ошибки.
Вы получите ошибку 5xx в случае, если сервер вообще недоступен.
На самом деле это обычная практика, но она не является обязательной. Вы можете отправлять ответы GraphQL с различными кодами состояния. Однако это должно быть реализовано на уровне сервера, а не на уровне преобразователей операций.
В качестве примера можно привести реализацию graphql-helix некоторых случаев ошибок с различными кодами состояния, например, 400 для синтаксиса GraphQL или ошибки валидации, 405 при использовании недопустимого метода HTTP (например, GET для мутаций или использование чего-либо другого, кроме POST или GET для запросов).
Стандартный механизм обработки ошибок GraphQL возвращает JSON, содержащий:
- ключ
data
, который содержит данные, соответствующие вызванной операции GraphQL (запрос, мутация, подписка) и - ключ
errors
, который содержит массив ошибок, возвращенных сервером, с сообщением и местоположением.
{
"errors": [
{
"message": "User not found",
"locations": [{ "line": 6, "column": 7 }],
"path": ["user", 1]
}
]
}
Эта стандартная обработка ошибок неудовлетворительна по многим причинам:
- она не является легко разбираемой, она больше предназначена для чтения человеком
- она не типизирована: вы можете добавить дополнительную информацию, например, код ошибки и другие поля, но они не являются частью схемы
- он не самодокументирован: глядя на сигнатуру операции, вы не знаете, что может не сработать
- он рассматривает некоторые результаты домена как ошибки / исключения, в то время как они являются просто альтернативными результатами операции. Например, сущность не найдена для заданного идентификатора и пользователь не имеет разрешения на доступ к сущности относятся к доменной модели, в то время как исключения времени выполнения, такие как сбой связи сервера GraphQL с базой данных / внешним микросервисом или тайминг операции из-за медлительности, являются настоящими исключениями: мы не получаем никакого результата, относящегося к доменной модели, только неожиданную ошибку времени выполнения.
Улучшенная моделизация ошибок с помощью Union и интерфейсов
В GraphQL есть поддержка типов Union. Мы можем воспользоваться этим и разработать нашу схему, чтобы указать все возможные результаты: ожидаемые и неожиданные / случаи ошибок.
GraphQL Union доступен только для типов, но не для входов. Однако директива oneOf
в будущем устранит этот пробел.
Если вы используете Envelop или GraphQL Yoga (оба разработаны The Guild), вы можете воспользоваться этим уже сегодня, используя этот плагин Envelop).
Тогда мы сможем делать запросы, подобные следующим:
query {
entity: entity(id: "10", userId: "u-4") {
...fields
}
notFound: entity(id: "100", userId: "u-1") {
...fields
}
notAllowed: entity(id: "1", userId: "u-4") {
...fields
}
invalidEntityId: entity(id: "1a", userId: "u-4") {
...fields
}
}
fragment fields on EntityResult {
__typename
... on Entity {
id
name
}
... on BaseError {
message
}
}
И получить ожидаемый результат или правильно набранные альтернативные результаты в случае ошибок:
{
"data": {
"entity": {
"__typename": "Entity",
"id": "10",
"name": "Entity #10"
},
"notFound": {
"__typename": "NotFoundError",
"message": "Entity 100 not found"
},
"notAllowed": {
"__typename": "NotAllowedError",
"message": "User u-4 isn't allowed to access entity 1"
},
"invalidEntityId": {
"__typename": "InvalidInputError",
"message": "Input validation failed for fields: [entityId]"
}
}
}
Использование типов GraphQL Union для случаев ошибок дает множество преимуществ:
- Безопасность типов: ошибки также типизируются.
- Потребитель не может игнорировать ошибки (они должны быть обработаны с помощью встроенных фрагментов)
- Самодокументируемость: сигнатура операции включает все возможные случаи (результат и ошибки), поэтому требуется меньше документации для объяснения возможных случаев ошибок.
- Неожиданные результаты теперь являются просто другими возможными результатами
Реализация обработки ошибок на основе союзных типов
Дана следующая схема:
type Entity {
id: ID!
name: String!
}
type Query {
entity(id: ID!, userId: ID!): Entity
}
Если что-то не получается во время выполнения, мы можем:
- выбросить ошибку и использовать стандартный механизм ошибок GraphQL, описанный выше
- вернуть
null
: это неудовлетворительно, потому что мы не можем знать причину, по которой операция вернулаnull
.
Давайте вместо этого реализуем обработку ошибок с помощью типов Union со следующими изменениями:
interface BaseError {
message: String!
}
type InvalidInputError implements BaseError {
message: String!
}
type NotFoundError implements BaseError {
message: String!
}
type UnknownError implements BaseError {
message: String!
}
type NotAllowedError implements BaseError {
message: String!
}
type Entity {
id: ID!
name: String!
}
union EntityResult = Entity | NotFoundError | NotAllowedError | InvalidInputError | UnknownError
type Query {
entity(id: ID!, userId: ID!): EntityResult!
}
Мы определяем интерфейс BaseError
, который реализуют все конкретные Errors.
Затем мы определяем результат запроса как тип объединения ожидаемого результата (Entity
) и других возможных результатов (NotFoundError
, NotAllowedError
и т.д.). Таким образом, мы подробно описываем все возможные результаты, а также можем сделать наш тип результата ненулевым (с помощью символа !
).
Для более подробного объяснения вы можете прочитать эти статьи:
- Саша Соломон: 200 OK! Обработка ошибок в GraphQL
- Лорин Кваст: Обработка ошибок GraphQL как чемпион с помощью союзов и интерфейсов
- Marc-André Giroux: Руководство по ошибкам GraphQL.
- Посмотрите это видео от @notrab:
Обработка изменений схемы в GraphQL Server
Поскольку мы изменили схему, нам также необходимо изменить резольверы запросов, мутаций и типов.
Для простых случаев мы можем просто вернуть правильный объект из резольвера запросов:
const resolvers = {
Query: {
entity: async (parent, { id, userId }, context) => {
const entity = await context.api.getEntityById(id)
const isAllowed = await context.api.isUserAllowedForEntity(id, userId)
if (!entity) {
return {
__typename: 'NotFoundError',
message: `Entity ${id} not found`
}
}
if (!isAllowed) {
return {
__typename: 'NotAllowedError',
message: `User ${userId} not allowed for entity ${id}`
}
}
return {
__typename: 'Entity',
...entity
}
}
}
}
Поскольку мы включаем __typename
и поля соответствуют определениям типов Schema, нет необходимости определять резольверы типов.
Более подробно об этом решении вы можете прочитать в этой статье Лорина Кваста: Обработка ошибок GraphQL как чемпион с помощью союзов и интерфейсов.
Типобезопасная обработка ошибок на стороне клиента
Давайте перейдем на следующий уровень, используя Typescript и GraphQL Code Generator.
GraphQL Code Generator — это инструмент, разработанный The Guild, который генерирует определения типов, соответствующие схеме GraphQL. У него есть несколько плагинов, и мы будем использовать три из них:
- typescript: генерирует определения типов для Typescript
- typescript-resolvers: генерирует определения типов для всех операций resolvers
- add: добавляет/подготавливает текст к сгенерированному файлу определений типов, это позволяет нам
импортировать
некоторые из наших типов в сгенерированный файл.
Специфические классы ошибок для всех типов ошибок схемы GraphQL
Сначала мы определили соответствующие классы Error для различных случаев ошибок:
export class InvalidInputError extends Error {}
export class NotFoundError extends Error {}
export class NoUserRightsError extends Error {}
export class UnknownError extends Error {}
При использовании удаленных API у нас часто есть возможность генерировать типы автоматически из JSON
схемы для REST API, из файлов protobuf
файлов для API на основе gRPC, из базы данных
схемы и т.д. Возможно, вы даже используете внешний API через SDK, который уже
предоставляет вам все типы. В таких случаях создание специализированных классов Error не является обязательным. Тем не менее, это
тем не менее, это может быть хорошей идеей, чтобы обеспечить специфические для приложения ошибки, а не нагромождать низкоуровневые ошибки сторонних разработчиков.
ошибки. Для таких случаев предложение Ecma TC39 для класса Error
Cause полезно, поскольку оно позволяет связывать ошибки в цепочку. Существуют полифиллы: Pony
Причина или error-cause.
Необходимо небольшое обходное решение, поскольку graphql-js
рассматривает возврат ошибок и бросание ошибок одинаково в резолверах, поэтому нам нужно обернуть наши ошибки, прежде чем мы сможем безопасно вернуть их из резолвера. Давайте воспользуемся простым пользовательским типом обертки и функцией-конструктором:
export type WrappedError<E extends Error> = {
_tag: 'WrappedError'
err: NonNullable<E>
}
export const wrappedError = <E extends Error>(err: E) => ({
_tag: 'WrappedError',
err
})
codegen
Mapping
GraphQL Code Generator конфигурируется с помощью YAML-файла codegen.yml
. В разделе mappers
мы сопоставляем каждый тип нашего GraphQL Union с соответствующим типом Typescript.
overwrite: true
schema: '**/*/typedefs.graphql'
documents: null
generates:
src/generated/graphql.ts:
plugins:
- add:
content: '/* eslint-disable */'
- add:
content: "import * as E from './src/errors';"
- 'typescript'
- 'typescript-resolvers'
config:
mappers:
InputFieldValidation: 'E.InputFieldError'
InvalidInputError: 'E.WrappedError<E.InvalidInputError>'
NotFoundError: 'E.WrappedError<E.NotFoundError>'
NotAllowedError: 'E.WrappedError<E.NotAllowedError>'
UnknownError: 'E.WrappedError<E.UnknownError>'
Entity: './src/model/data/entity.data#Entity'
Мы используем плагин add
для импорта общего типа-обертки WrappedError
и типов Errors. Если бы мы этого не сделали, нам пришлось бы определять полный путь для каждого маппера, например:
NotFoundError: 'import("./src/errors").WrappedError<import("./src/errors").NotFoundError>'
Эта конфигурация codegen
генерирует типы Typescript из схемы и соответствующие сигнатуры для операций (запрос/мутация/подписка) и резольверов типов. Теперь у нас есть статическая безопасность типов, вывод типов и подсказки в редакторе кода.
Помощь движку GraphQL в принятии решения о том, какой тип возвращать
В первой версии резольвера запросов мы возвращали объекты, которые соответствовали типам GraphQL и были помечены __typename
, поэтому движок GraphQL мог просто возвращать их без необходимости использования резольверов типов.
Но теперь мы возвращаем различные объекты, которые будут сопоставлены с соответствующим типом GraphQL. И эти объекты не помечены __typename
. Так как же движок GraphQL узнает, что нужно сопоставить значение, возвращенное резольвером запроса?
В конфигурации codegen
мы определили отображения между типами схем GraphQL и типами Typescript. Но это не используется во время выполнения. Эта конфигурация используется только GraphQL Code Generator для генерации соответствующих типов и сигнатур резольверов, чтобы вы получили безопасность типов во время компиляции и подсказки редактора кода.
Для этого у резольверов типов есть специальный полевой резольвер: __isTypeOf
— это функция резольвера поля на каждом из резольверов типов GraphQL результата объединения типов запроса. Этот резольвер поля выполняется для всех типов, которые образуют тип объединения результатов запроса: когда один из них возвращает true
, движок GraphQL будет использовать этот резольвер типа для генерации соответствующего объекта результата.
Перепишите резольверы для использования типов ошибок и __isTypeOf
.
Теперь, когда мы определили правильное соответствие между типами схемы GraphQL и типами Typescript, давайте перепишем резольверы:
const resolvers = {
Query: {
async entity(parent, { id, userId }, context) {
const entity = await context.api.getEntityById(id);
const isAllowed = await context.api.isUserAllowedForEntity(id, userId);
const error = (
!entity ? new NotFoundError(`Entity ${id} not found`) :
!isAllowed ? new NotAllowedError(`User ${userId} not allowed for entity ${id}`) :
null
);
if (error) {
return wrappedError(error);
}
return entity;
},
},
Entity: {
__isTypeOf: (parent): parent instanceof Entity,
id: (parent) => parent.id,
name: (parent) => parent.name,
},
NotFoundError: {
__isTypeOf: (parent) => parent.err instanceof NotFoundError,
message: (parent) => parent.err.message,
},
// same for all other error types: NotAllowedError, InvalidInputsError, UnknownError
};
Выбор полей запроса
Наш запрос теперь может возвращать несколько типов, поэтому нам нужно адаптировать выбор полей запроса и использовать фрагменты:
query entity($id: ID!, $userId: ID!) {
entity(id: $id, userId: $userId) {
__typename
... on Entity {
id
name
}
... on BaseError {
message
}
}
}
Хорошая практика — всегда иметь фрагмент, соответствующий интерфейсу BaseError
: это делает клиентский код защищенным на случай, если к типу результата Union будет добавлена еще одна ошибка.
Еще более безопасная обработка ошибок с использованием парадигмы функционального программирования
Предыдущий раздел был посвящен тому, как обеспечить безопасность типов и вывод типов в резольверах и как генерировать правильный тип GraphQL из значения Union типа резольвера запросов.
Приведенный выше код резольвера запроса для получения сущности и проверки, разрешен ли пользователь, является слишком упрощенным. В реальной жизни мы, вероятно, получили бы эти данные из одного или двух источников данных (база данных, распределенный кэш) или даже из другого внешнего микросервиса с интерфейсом REST или gRPC. Эти вызовы получения данных могут выдавать ошибки или возвращать null
.
Мы хотим обрабатывать эти ошибки, преобразовывать бессмысленные и небезопасные значения null
в осмысленные ошибки, а также безопасно передавать эти ошибки преобразователям запросов.
Монады в помощь
Для того чтобы превратить наши бросовые и небезопасные API в безопасные функции получения данных, мы будем использовать типы данных и методы из парадигмы функционального программирования. В частности, мы будем использовать библиотеку fp-ts (но вы можете выбрать библиотеку FP по своему вкусу, например, purify, effect, monio и другие).
fp-ts
раскрывает несколько полезных типов данных и функций, что позволяет работать над безопасными абстракциями, используя композиционные функции. В частности, мы будем использовать:
-
Either
: представляет результат вычисления, которое может закончиться неудачей. -
Option
: представляет возможные нулевые/неопределенные значения безопасным способом -
Task
: ленивый Promise, представляет результат асинхронной операции -
TaskEither
: представляет результат асинхронной операции, которая может завершиться неудачей (комбинацияTask
иEither
, как следует из названия).
Сделайте API более безопасными
Для целей этой статьи рассмотрим простой mock API. Он представляет собой бросовый API, например, gRPC API.
// returns an Entity or throws
export async function fetchEntity(id: number): Promise<Entity> {
const entity = entities.find(e => e.id === id);
if (!entity) {
throw new Error(`Entity ${id} not found`));
}
return new Entity(entity);
}
// returns a User or throws
export async function fetchUser(id: UserId): Promise<User> {
const user = users.find(u => u.id === id);
if (!user) {
throw new Error(`User ${id} not found`);
}
return new User(user);
}
Поскольку у нас асинхронный API, который может бросать, мы будем использовать TaskEither
, чтобы обернуть эти вызовы. Either<E, A>
(и соответственно TaskEither<E, A>
) параметризуется двумя типами: E
, который представляет ошибку, и A
, который представляет тип данных, если вызов был успешным. По соглашению, левая часть представляет ошибку, а правая — тип данных успешного результата.
Мы можем создать его экземпляр, используя один из его основных конструкторов: of
, left
(для непосредственного создания ошибки), right
(для создания успеха) или один из его удобных конструкторов:
-
tryCatch
: если поставленная функция бросает, сохраните настроенную Ошибку в левой части, иначе сохраните успешный результат в правой части. -
fromPredicate
: если предикат, примененный к значению, неверен, в левой части сохраняется настроенная Ошибка, в противном случае значение сохраняется в правой части -
fromNullable
: если значениеnull
илиundefined
, сохраните настроенную Ошибку в левой части, иначе сохраните значение в правой части.
Теперь это наш безопасный API:
export const getEntity = (id: number): TE.TaskEither<NotFoundError, Entity> =>
TE.tryCatch(
() => fetchEntity(id),
e => new NotFoundError(e.message)
)
export const getUser = (id: UserId): TE.TaskEither<Error, User> =>
TE.tryCatch(
() => fetchUser(id),
e => new NotFoundError(e.message)
)
Мы не только сделали наш API безопаснее с точки зрения кода, но и лучше с точки зрения документации: теперь мы можем видеть, какие Ошибки возвращает API, чего не было, когда мы использовали Promise и reject
(или try/catch
).
Теперь, когда мы разобрались с получением данных, давайте потреблять эти данные. Первая функциональность, которую мы добавим, — это проверка прав пользователя. В этом простом примере мы не запрашиваем внешнюю службу, мы получили данные в типах Entity и User. Мы можем создать функцию проверки, не заботясь о том, присутствует ли пользователь или сущность, поскольку этим будут заниматься монады (Either, TaskEither, Option). Мы также можем решить представить отсутствие разрешения как ошибку: во второй функции мы используем Either.fromPredicate
для преобразования значения false
в пользовательскую NotAllowedError
:
// unsafe API
const isUserAllowedForEntity = async (user: User, entity: Entity): Promise<boolean> => {
try {
return !entity.restrictions.includes(user.country)
} catch (e) {
throw e
}
}
// safe API
export const getIsUserAllowedForEntity = (user: User, entity: Entity): TE.TaskEither<UnknownError, boolean> =>
TE.tryCatch(
() => isUserAllowedForEntity(user, entity),
e => new UnknownError(e.message)
)
const isNotAllowedAsError =
(errorMsg: string) =>
(isAllowed: boolean): TE.TaskEither<NotAllowedError, boolean> =>
pipe(
isAllowed,
TE.fromPredicate(Boolean, _ => new NotAllowedError(errorMsg))
)
export const isUserAllowedForEntityAsError =
(user: User) =>
(entity: Entity): TE.TaskEither<NotAllowedError | UnknownError, boolean> =>
pipe(
getIsUserAllowedForEntity(user, entity),
TE.chainW(isNotAllowedAsError(`User ${user.id} isn't allowed to access entity ${entity.id}`))
)
Теперь, когда оба вызова получения разрешения Entity и User стали более безопасными, мы можем объединить их, чтобы создать функцию получения Entity с включенной проверкой разрешения User:
export const getEntityForUser = (id: number, userId: UserId): TE.TaskEither<NotFoundError | NotAllowedError, Entity> =>
pipe(
getUser(userId),
TE.chainW(user => pipe(getEntity(id), TE.chainFirstW(isUserAllowedForEntityAsError(user))))
)
Мы получаем пользователя, затем сущность, и когда у нас есть и то, и другое, мы проверяем разрешение пользователя для этой сущности. Здесь нужно отметить два момента:
- поскольку
isUserAllowedForEntityAsError
зависит как от Пользователя, так и от Сущности, и мы получили их по отдельности, у нас получился вложенный трубопровод (вложенный трубопровод должен закрываться надuser
). Далее мы рассмотрим альтернативный синтаксис, позволяющий избавиться от этой вложенности. - Функция
chainFirst
позволяет нам выполнять вычисления последовательно, но сохранять только результат первого вычисления, если второе вычисление успешно. Вот что она делает:- получает сущность (как
TaskEither
)- если она левая (некоторая
Error
), то не будет выполнятьсяisUserAllowedForEntityAsError
- если это право (
Entity
), то выполняетсяisUserAllowedForEntityAsError
- если это левая (
NotAllowedError
), то возвращаетсяTaskEither
, содержащая эту ошибку - если это право (
true
), он игнорирует его и возвращает результат предыдущего вычисления (TaskEither
, содержащийEntity
).
- если она левая (некоторая
- получает сущность (как
Избегайте вложенных pipe
с помощью нотации Do
Нотация Do
является адаптацией своего родного аналога в Haskell. Она позволяет построить контекстный объект, который отслеживает развернутые значения, полученные в результате различных операций в конвейере. После его создания с помощью функций ADT Do
или bind
/bindTo
каждая последующая операция в конвейере имеет доступ к этому контекстному объекту, что устраняет необходимость во вложении.
Давайте перепишем нашу функцию, используя нотацию Do
:
export const getEntityForUser = (id: number, userId: UserId): TE.TaskEither<NotFoundError | NotAllowedError, Entity> =>
pipe(
getUser(userId),
TE.bindTo('user'),
TE.bind('entity', _ => getEntity(id)),
TE.bind('isAllowed', ({ user, entity }) => isUserAllowedForEntityAsError(user)(entity)),
TE.map(({ entity }) => entity)
)
Поскольку функции bind
/bindTo
возвращают экземпляры ADT (здесь TaskEither
), мы сохраняем отказоустойчивое поведение. В этом примере, если вызов getUser
вернет Left<NotFoundError>
, мы не вызовем ни getEntity
, ни isUserAllowedForEntityAsError
, он вернет TaskEither
, содержащий NotFoundError
.
Используя нотацию Do
, мы получаем конвейер без гнезд, но теряем программирование без точек.
Резольвер запросов
Теперь, когда у нас есть безопасный API, мы можем перейти к разрешителю запросов. Сначала мы проверим входы, а затем запросим данные.
Валидация вводимых данных
Для проверки вводимых данных мы также создадим функцию, которая запустит валидатор на каждом вводе и вернет Either
. Таким образом мы сможем интегрировать результат проверки в конвейер.
Query: {
entity(_, args, _2) {
const { id: entityId, userId } = args;
const validatedInputs = validate({
entityId: validateIsNumber(entityId, 'entityId'),
userId: validateIsUserId(userId, 'userId'),
});
return pipe(
validatedInputs,
// more code here
)();
}
},
Функции validateIsNumber
и validateIsUserId
возвращают Either<{ field: string; message: string }, TypeOfTheInput>
. Если ввод не прошел проверку, мы получаем объект с именем поля и сообщением о проверке в поле Left
, в противном случае мы получаем значение ввода в поле Right
.
Функция validate
принимает запись поля в функцию валидатора и возвращает либо InvalidInputError
, либо объект входного имени входного значения.
Получение данных и возврат
Query: {
entity(_, args, _2) {
const { id: entityId, userId } = args;
const validatedInputs = validate({
entityId: validateIsNumber(entityId, 'entityId'),
userId: validateIsUserId(userId, 'userId'),
});
return pipe(
validatedInputs,
TE.fromEither,
TE.chain(({ entityId, userId }) => getEntityForUser(Number(entityId), userId)),
TE.foldW(
taskWrappedError,
T.of,
),
)();
}
},
После проверки вводимых данных следующим шагом в конвейере является получение данных с помощью getEntityForUser
. Поскольку этот вызов возвращает TaskEither
, нам необходимо преобразовать предыдущий Either
в TaskEither
с помощью TaskEither.fromEither
. Конечно, из-за безотказной природы ADT, эта функция не будет вызвана, если входные данные не прошли проверку.
Наконец, мы выходим из абстракции, используя деструктор TaskEither.fold
(также называемый match
): эта функция выполняет сопоставление шаблонов для TaskEither
и выполняет первый обратный вызов, если это Left
, или второй обратный вызов, если это Right
. В данный момент мы хотим вернуть WrappedError
или Entity
от нашего распознавателя запросов, мы не хотим возвращать TaskEither
, потому что нам нужен простой распознаватель типов, который действует на правильный тип. Абстракция ADT остается в границах нашей программы, и этой границей мы считаем преобразователь запроса (или мутации, или подписки).
Бонус: улучшите распознаватели типов ошибок с помощью currying
.
Currying
— это техника преобразования функции, принимающей несколько аргументов, в последовательность функций, каждая из которых принимает один аргумент. Это позволяет программировать без точек, что дает возможность более чистого обратного вызова или композиции функций в конвейере.
Мы неоднократно использовали этот прием:
pipe(
// ...
getEntity(id),
TE.chainFirstW(isUserAllowedForEntityAsError(user))
// ...
)
Функция isUserAllowedForEntityAsError
является curried-функцией: при вызове с входом user
она возвращает функцию, которая принимает вход entity
. Если бы функция не была curried, мы бы написали:
pipe(
// ...
getEntity(id),
TE.chainFirstW(entity => isUserAllowedForEntityAsError(user, entity))
// ...
)
Зная это, мы можем улучшить повторяющийся код распознавателя типов:
NotFoundError: {
__isTypeOf: (parent) => parent.err instanceof NotFoundError,
message: (parent) => parent.err.message,
},
NotAllowedError: {
__isTypeOf: (parent) => parent.err instanceof NotAllowedError,
message: (parent) => parent.err.message,
},
// same for all other error types: InvalidInputsError, UnknownError
Этот код будет одинаковым для всех ошибок (возможно, с несколькими дополнительными распознавателями полей для некоторых ошибок). Чтобы сделать его лучше, мы создадим функцию curried typeguard и curried field resolver для ошибки:
// curried typeguard
const isWrappedError =
<E extends Error>(errorClass: Type<E>) =>
(v: any): v is WrappedError<E> =>
v?._tag === 'WrappedError' && v.err instanceof errorClass
// curried field extractor
const wrappedErrorField =
<E extends Error>(errorClass: Type<E>) =>
<TKey extends keyof E>(prop: TKey) =>
(wrappedErr: WrappedError<E>): E[TKey] =>
wrappedErr.err[prop]
Теперь мы можем переписать наши резольверы типов ошибок следующим образом:
NotFoundError: {
__isTypeOf: isWrappedError(NotFoundError),
message: wrappedErrorField(NotFoundError)('message'),
},
NotAllowedError: {
__isTypeOf: isWrappedError(NotAllowedError),
message: wrappedErrorField(NotAllowedError)('message'),
},
// same for all other error types: InvalidInputsError, UnknownError
А поскольку этот код является общим, мы можем извлечь его в фабричную функцию резольвера типов:
const errorTypesCommonResolvers = <E extends Error>(errorClass: Type<E>) => ({
__isTypeOf: isWrappedError(errorClass),
message: wrappedErrorField(errorClass)('message')
})
И переписать разрешители типов наших ошибок следующим образом:
export const queryResolvers: Resolvers = {
NotFoundError: errorTypesCommonResolvers(NotFoundError),
NotAllowedError: errorTypesCommonResolvers(NotAllowedError)
// same for all other error types: InvalidInputsError, UnknownError
}
И если у нас есть ошибка с большим количеством полей (например, мы добавляем поле validations
к InvalidInputError
), мы все равно можем настроить ее, используя оператор spread на объекте резольвера типа:
export const queryResolvers: Resolvers = {
NotFoundError: errorTypesCommonResolvers(NotFoundError),
NotAllowedError: errorTypesCommonResolvers(NotAllowedError),
// same for UnknownError type
// customized error type resolver
InvalidInputError: {
...errorTypesCommonResolvers(InvalidInputError),
validations: wrappedErrorField(InvalidInputError)('validations')
}
}
Подведение итогов
GraphQL дает вам возможность моделировать ошибки вашего домена и доставлять их вызывающей стороне документированным и безопасным для типов способом. Используя Typescript, вы получаете безопасность типов для большей части вашего кода. В сочетании с GraphQL Code Generator вы даже получаете полностью типизированные функции резольвера. И, наконец, мощные концепции функционального программирования, применяемые к вашим резольверам GraphQL, позволяют вам получить максимально безопасную работу с вашими небезопасными API.