Как сконфигурировать GraphQL request с интерсепторами на примере JWT авторизации

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

  1. GraphQL request - минималистичный и простой graphql клиент, который можно удобно сочетать с любым стейт менеджером.

  2. Интерсепторы - удобные методы модификации запросов и ответов, которые широко используются хорошо упакованными http клиентами типа axios.

  3. В рамках данного туториала мы рассмотрим вариант подобной конфигурации 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 служит для повторения исходного запроса и состоит из трех базовых действий:

  1. Восстанавливаем контекст исходного запроса

  2. Устанавливаем флаг, говорящий о том, что запрос повторный

  3. Повторяем исходный запрос

Шаг 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
    }

}

Здесь Вы можете увидеть два метода:

  1. setNext - предназначен для установки следующего перехватчика в цепи, ссылку на который мы будет хранить в свойстве nextHandler

  2. 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
    }
}
  1. Сначала проверяем, есть ли ошибки в запросе. Если нет, то передаем управление следующему обработчику. Если да, пытаемся получить выброшенный эксепшн.

  2. Из эксепшена достаем статус ответа и код ошибки.

  3. Проверяем, если код ошибки 401, то делаем запрос на рефреш токена, и записываем новый access token в localStorage

  4. После чего повторяем исходный запрос с помощью метода redo, который мы разбирали ранее.

  5. Если данная операция прошла успешно, то передаем запрос следующему обработчику. В ином случае выбрасываем ошибку и прекращаем обработку.

Шаг 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;
}

Абстракция стратегии представлена двумя методами:

  1. makeChain - хелпер, позволяющий удобно собрать цепочку обработчиков из массива

  2. 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, который:

  1. Определяет порядок вызова обработчиков

  2. Создает из них цепочку с помощью родительского метода makeChain

  3. Запускает цепочку обработки

Шаг 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',
})
  1. Переопределяем базовый метод request, поставляемый пакетом.

  2. Внутри нашего метода создаем контекст

  3. Устанавливаем начальные параметры запроса

  4. Отправляем запрос, устанавливаем ответ

  5. Возвращаем данные ответа

  6. Экспортируем созданный клиент

Таким образом, GQL готов к использованию.

Благодарю за прочтение. Буду рад Вашему фидбэку.

Ccылка на репозиторий

Источник: https://habr.com/ru/post/574592/


Интересные статьи

Интересные статьи

Data-science развивается очень быстро, в том числе благодаря росту объема доступных данных для анализа или построения моделей. Но для создания сложных моделей командам ан...
Часто требуется объяснить новичкам, как построен процесс разработки. Эта статья - разбор последовательности и значения каждого из этапов на примере стройки.Итак, есть иде...
Одной из самых нужных функций, которой нет в бесплатной версии GitLab, является возможность голосования против обнуления репозитория контролировать Merge request (MR), используя о...
Предлагаю ознакомиться с расшифровкой доклада Александра Сигачева Service Discovery в распределенных системах на примере Consul. Service Discovery создан для того, чтобы с минимальными затратами...
История сегодня пойдёт про автосервис в Москве и его продвижении в течении 8 месяцев. Первое знакомство было ещё пару лет назад при странных обстоятельствах. Пришёл автосервис за заявками,...