How to configure GraphQL request with interceptors on the example of JWT authentication
- GraphQL request - minimalistic and simple graphql client that can be conveniently combined with any state manager.
- Interceptors - сonvenient methods for modifying requests and responses that are widely used by http clients such as axios.
- 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
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)
}
}
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)
}
}
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)
}
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
}
}
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
}
}
You can see two methods here:
- setNext - designed to set the next interceptor in the chain, a reference to which we will store in the nextHandler property
- 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)
}
}
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
}
}
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.
From the exсeption we get the response status and the error code
Сheck if the error code is 401, then we make a request to refresh the token, and write a new access token in localStorage
Then we repeat the original request using the redo method, which we discussed earlier.
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;
}
Strategy abstraction is represented by two methods:
- makeChain - a helper that allows you to conveniently assemble a chain of handlers from an array
- 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)
}
}
As we can see, both strategies look absolutely identical in structure. Notice the handle method, which:
- Determines the order of invocation of handlers
- Creates a chain of them using the parent makeChain method
- 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',
})
- Override the base request method supplied by the package.
- Inside our method, create a context
- Set the initial parameters of the request
- Send a request and set a response
- Returning response data
- 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