GraphQL request - минималистичный и простой graphql клиент, который можно удобно сочетать с любым стейт менеджером.
Интерсепторы - удобные методы модификации запросов и ответов, которые широко используются хорошо упакованными http клиентами типа axios.
В рамках данного туториала мы рассмотрим вариант подобной конфигурации GraphQL request на примере проброса в запрос заголовка c access токеном и перехвата 401 ошибки ответа для рефреша этого токена
Ссылка на документацию по пакету: https://www.npmjs.com/package/graphql-request
Итак, приступим.
Шаг 1. Устанавливаем пакет
yarn add graphql-request graphql
Шаг 2. Создаем класс контекста запроса
export class GQLContext {
private client: GraphQLClient
private snapshot: RequestSnapshot;
private readonly requestInterceptor = new RequestStrategy();
private readonly responseInterceptor = new ResponseStrategy();
public req: GQLRequest;
public res: GQLResponse;
public isRepeated = false;
constructor(client: GraphQLClient) {
this.client = client
}
async setRequest(req: GQLRequest) {
this.req = req
await this.requestInterceptor.handle(this)
}
async setResponse(res: GQLResponse) {
this.res = res
await this.responseInterceptor.handle(this)
}
async sendRequest(): Promise<GQLResponse> {
if (!this.snapshot) {
this.createSnapshot()
}
const res = await this.client.rawRequest.apply(this.client, new NativeRequestAdapter(this)) as GQLResponse
await this.setResponse(res)
return this.res
}
async redo(): Promise<GQLResponse> {
await this.snapshot.restore()
this.isRepeated = true
return await this.sendRequest()
}
createSnapshot() {
this.snapshot = new RequestSnapshot(this)
}
}
Данный класс будет содержать данные о запросе, ответе ( при его получении ), а так же хранить референс на сам GQL клиент.
Для установки контекста запроса используется два билд метода: setRequest и setResponse. Каждый из них реализует соответствующую стратегию применения интерсепторов, каждую из которых мы рассмотрим ниже.
Рассмотрим структуру снэпшота:
export class RequestSnapshot {
instance: GQLContext;
init: GQLRequest;
constructor(ctx: GQLContext) {
this.instance = ctx
this.init = ctx.req
}
async restore() {
await this.instance.setRequest(this.init)
}
}
Снэпшот получает референс на контекст выполнения, а так же сохраняет состояние исходного запроса для последующего восстановления (при необходимости) с помощью метода restore
Метод sendRequest будет служить оберткой для gql-request, имплементируя возможность создания снэпшота исходного запроса с помощью метода createSnapshot
NativeRequestAdapter - адаптер, который служит для приведения нашего объекта контекста к виду, с которым умеет работать нативный gql-request:
export function NativeRequestAdapter (ctx: GQLContext){
return Array.of(ctx.req.type, ctx.req.variables, ctx.req.headers)
}
Метод redo служит для повторения исходного запроса и состоит из трех базовых действий:
Восстанавливаем контекст исходного запроса
Устанавливаем флаг, говорящий о том, что запрос повторный
Повторяем исходный запрос
Шаг 3. Регистрируем собственный тип ошибки
export class GraphQLError extends Error {
code: number;
constructor(message: string, code: number) {
super(message)
this.code = code
}
}
В данном случае мы просто расширяем структуру обычной ошибки JS, добавив туда код ответа.
Шаг 4. Пишем абстракцию перехватчика
Для написания абстракции перехватчика отлично подойдет поведенческий паттерн программирования «Цепочка обязанностей(СoR)». Данный паттерн позволяет последовательно передавать объекты по цепочке обработчиков, каждый из которых самостоятельно решает, как именно нужно обработать принимаемый объект (которым будет являться наш контекст запроса), а так же стоит ли передавать его дальше по цепи.
Итак, давайте рассмотрим эту концепцию чуть ближе:
export type GQLRequest = {
type: string;
variables?: any;
headers?: Record<string, string>
}
export type GQLResponse = {
data: any
extensions?: any
headers: Headers,
status: number
errors?: any[];
}
interface Interceptor {
setNext(interceptor: Interceptor): Interceptor;
intercept(type: GQLContext): Promise<GQLContext>;
}
export abstract class AbstractInterceptor implements Interceptor {
private nextHandler: Interceptor;
public setNext(interceptor: Interceptor): Interceptor {
this.nextHandler = interceptor
return interceptor
}
public async intercept(ctx: GQLContext) {
if (this.nextHandler) return await this.nextHandler.intercept(ctx)
return ctx
}
}
Здесь Вы можете увидеть два метода:
setNext - предназначен для установки следующего перехватчика в цепи, ссылку на который мы будет хранить в свойстве nextHandler
intercept - родительский метод предназначен для передачи управления следующему обработчику. Этим методом будут пользоваться дочерние классы при необходимости
Шаг 5. Имплементация перехватчика запросов
export class AuthInterceptor extends AbstractInterceptor{
intercept(ctx: GQLContext): Promise<GQLContext> {
if (typeof window !== 'undefined') {
const token = window.localStorage.getItem('token')
if (!!token && token !== 'undefined') {
ctx.req.headers = {
...ctx.req.headers,
Authorization: `Bearer ${token}`
}
}
}
return super.intercept(ctx)
}
}
Данный перехватчик достает access token из localStorage и добавляет в контекст запроса заголовок с токеном
Шаг 6. Имплементация перехватчика ответов
Здесь мы реализуем перехват 401 ошибки и в случае ее получения, сделаем запрос на рефреш токена и повторим исходный запрос.
export const REFRESH_TOKEN = gql`
query refreshToken {
refreshToken{
access_token
}
}
`
export class HandleRefreshToken extends AbstractInterceptor {
async intercept(ctx: GQLContext): Promise<GQLContext> {
if ( !('errors' in ctx.res)) return await super.intercept(ctx)
const exception = ctx.res.errors[0]?.extensions?.exception
if (!exception) return await super.intercept(ctx)
const Error = new GraphQLError(exception.message, exception.status)
if (Error.code === 401 && !ctx.isRepeated && typeof window !== 'undefined') {
try {
await ctx.setRequest({type: REFRESH_TOKEN})
const res = await ctx.sendRequest()
//@ts-ignore
localStorage.setItem('token', res.refreshToken.access_token)
await ctx.redo()
return await super.intercept(ctx)
} catch (e) {
throw Error
}
}
throw Error
}
}
Сначала проверяем, есть ли ошибки в запросе. Если нет, то передаем управление следующему обработчику. Если да, пытаемся получить выброшенный эксепшн.
Из эксепшена достаем статус ответа и код ошибки.
Проверяем, если код ошибки 401, то делаем запрос на рефреш токена, и записываем новый access token в localStorage
После чего повторяем исходный запрос с помощью метода redo, который мы разбирали ранее.
Если данная операция прошла успешно, то передаем запрос следующему обработчику. В ином случае выбрасываем ошибку и прекращаем обработку.
Шаг 7. Пишем абстракцию стратегии
export abstract class InterceptStrategy {
protected makeChain(collection: AbstractInterceptor[]) {
collection.forEach((handler, index) => collection[index + 1] && handler.setNext(collection[index + 1]))
}
abstract handle(ctx: GQLContext): any;
}
Абстракция стратегии представлена двумя методами:
makeChain - хелпер, позволяющий удобно собрать цепочку обработчиков из массива
Handle - метод, имплементирующий основную логику стратегии обработки, его мы будем описать в имплементациях
Шаг 8. Имплементация стратегий перехвата запросов и ответов
export class RequestStrategy extends InterceptStrategy{
async handle(ctx: GQLContext): Promise<GQLContext> {
const handlersOrder: AbstractInterceptor[] = [
new AuthInterceptor(),
]
this.makeChain(handlersOrder)
return await handlersOrder[0].intercept(ctx)
}
}
export class ResponseStrategy extends InterceptStrategy{
async handle(ctx: GQLContext): Promise<GQLResponse['data']> {
const handlersOrder: AbstractInterceptor[] = [
new HandleRefreshToken(),
new RetrieveDataInterceptor(),
]
this.makeChain(handlersOrder)
return await handlersOrder[0].intercept(ctx)
}
}
Как мы видим, обе стратегии выглядят по структуре абсолютно идентично. Имплементируется метод handle, который:
Определяет порядок вызова обработчиков
Создает из них цепочку с помощью родительского метода makeChain
Запускает цепочку обработки
Шаг 9. Собираем все вместе.
const request = async function (this: GraphQLClient, type: string, variables: any, headers = {}): Promise<any> {
const ctx = new GQLContext(this)
await ctx.setRequest({type, variables, headers})
try {
await ctx.sendRequest()
} catch (e) {
await ctx.setResponse(e.response)
}
return ctx.res
}
GraphQLClient.prototype.request = request
export const client = new GraphQLClient('http://localhost:4000/graphql', {
credentials: 'include',
})
Переопределяем базовый метод request, поставляемый пакетом.
Внутри нашего метода создаем контекст
Устанавливаем начальные параметры запроса
Отправляем запрос, устанавливаем ответ
Возвращаем данные ответа
Экспортируем созданный клиент
Таким образом, GQL готов к использованию.
Благодарю за прочтение. Буду рад Вашему фидбэку.