DEV Community

Alex Kiryushin
Alex Kiryushin

Posted on

How to configure GraphQL request with interceptors on the example of JWT authentication

  1. GraphQL request - minimalistic and simple graphql client that can be conveniently combined with any state manager.
  2. Interceptors - сonvenient methods for modifying requests and responses that are widely used by http clients such as axios.

  3. As part of this tutorial, we will consider a configuration option for a GraphQL request using the example of forwarding a header with an access token to a request and intercepting a 401 response error to refresh this token.

Link to documentation: https://www.npmjs.com/package/graphql-request


So let's get started.

Step 1. Installing the package

yarn add graphql-request graphql
Enter fullscreen mode Exit fullscreen mode

Step 2. Create a request context class

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

This class will contain data about the request, response (upon receipt), as well as store the reference to the GQL client itself.
To set the request context, two methods are used: setRequest and setResponse. Each of them applies an appropriate strategy of using interceptors, each of which we will discuss below.

Let's take a look at the snapshot structure:

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

The snapshot receives a reference to the execution context, and also saves the state of the original request for subsequent restoration (if necessary) using the restore method

The sendRequest method will serve as a wrapper for gql-request, making it possible to create a snapshot of the original request using the createSnapshot method

NativeRequestAdapter is an adapter that serves to bring our context object to the form that the native gql-request can work with:

export function NativeRequestAdapter (ctx: GQLContext){
    return Array.of(ctx.req.type, ctx.req.variables, ctx.req.headers)
}
Enter fullscreen mode Exit fullscreen mode

The redo method is used to repeat the original request and consists of three basic steps:
1) Reconstructing the context of the original request
2) Set the flag indicating that the request is repeated
3) Repeat the original request

Step 3. Registering our own error type

export class GraphQLError extends Error {
    code: number;

    constructor(message: string, code: number) {
        super(message)
        this.code = code
    }
}
Enter fullscreen mode Exit fullscreen mode

In this case, we are simply extending the structure of a native JS error by adding a response code there.

Step 4. Writing an abstraction for an interceptor

For writing an abstraction of an interceptor, the "Chain of Responsibility (СoR)" behavioral programming pattern is perfect. This pattern allows you to sequentially transfer objects along a chain of handlers, each of which independently decides how exactly the received object should be processed (in our case, the object will be our request context), as well as whether it is worth passing it further along the chain.
So let's take a closer look at this concept:

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
    }

}
Enter fullscreen mode Exit fullscreen mode

You can see two methods here:

  1. setNext - designed to set the next interceptor in the chain, a reference to which we will store in the nextHandler property
  2. intercept - the parent method is intended to transfer control to the next handler. This method will be used by child classes if necessary


Step 5. Request Interceptor Implementation

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) 
    }

}
Enter fullscreen mode Exit fullscreen mode

This interceptor gets the access token from localStorage and adds a header with the token to the request context

Step 6. Response Interceptor Implementation

Here we will implement interception of 401 errors and, if received, we will make a request to refresh the token and repeat the original request.

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()
                localStorage.setItem('token', res.refreshToken.access_token)
                await ctx.redo()

                return await super.intercept(ctx)
            } catch (e) {
                throw Error
            }
        }
        throw Error
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. First, we check if there are any errors in the request. If not, then we transfer control to the next handler. If so, we are trying to get the exсeption.

  2. From the exсeption we get the response status and the error code

  3. Сheck if the error code is 401, then we make a request to refresh the token, and write a new access token in localStorage

  4. Then we repeat the original request using the redo method, which we discussed earlier.

  5. If this operation is successful, then we pass the request to the next handler. Otherwise, throw an error and stop processing.

Step 7. Writing a strategy abstraction

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;
}
Enter fullscreen mode Exit fullscreen mode

Strategy abstraction is represented by two methods:

  1. makeChain - a helper that allows you to conveniently assemble a chain of handlers from an array
  2. handle - a method that implements the main logic of the processing strategy, we will describe it in the implementations

Step 8. Implementing request and response interception strategies

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

As we can see, both strategies look absolutely identical in structure. Notice the handle method, which:

  1. Determines the order of invocation of handlers
  2. Creates a chain of them using the parent makeChain method
  3. And starts the processing


Step 9. Putting it all together.

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',
})
Enter fullscreen mode Exit fullscreen mode
  1. Override the base request method supplied by the package.
  2. Inside our method, create a context
  3. Set the initial parameters of the request
  4. Send a request and set a response
  5. Returning response data
  6. Export the created client

Thanks for reading. I would be glad to receive your feedback.
Link to repository: https://github.com/IAlexanderI1994/gql-request-article

Top comments (2)

Collapse
 
jagged3dge profile image
jagged3dge • Edited

Thank you for this brilliant article. The usage of Chain of Responsibility is brilliant for the objective.

I got everything except for this part in the src/index.ts file in your repo:

try {
    await ctx.sendRequest()
} catch (e) {
    await ctx.setResponse(e.response) // <= TS should show `Object `e` is of type unknown` error 
}
Enter fullscreen mode Exit fullscreen mode

Why does the error object e have a response key? What is the expected type of the object e here?

Collapse
 
ialexanderi1994 profile image
Alex Kiryushin • Edited

Hey! Thanks for your comment.
In the world of GraphQL, there are conditionally two types of errors:

  1. Expected (they are always given with status 200 - for example, incorrect password entry).
  2. Unexpected (for example, the GraphQL gateway has crashed or the request contains fields that are not described in the schema)

This construction serves the second case. And in this example, e.response will have a basic GraphQL error contract - I'll attach it below.

dev-to-uploads.s3.amazonaws.com/up...

Sorry for the long answer - after a long and productive year I took a short vacation :)

Happy Coding!