diff --git a/README.md b/README.md index 114cddf2b..15082dfce 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Minimal GraphQL client supporting Node and browsers for scripts or simple apps - [Node](#node) - [Batching](#batching) - [Cancellation](#cancellation) + - [Middleware](#middleware) - [FAQ](#faq) - [Why do I have to install `graphql`?](#why-do-i-have-to-install-graphql) - [Do I need to wrap my GraphQL documents inside the `gql` template exported by `graphql-request`?](#do-i-need-to-wrap-my-graphql-documents-inside-the-gql-template-exported-by-graphql-request) @@ -235,7 +236,7 @@ const query = gql` name } } - +` // Function saved in the client runs and calculates fresh headers before each request const data = await client.request(query) ``` @@ -637,6 +638,39 @@ For Node.js v12 you can use [abort-controller](https://github.com/mysticatea/abo const abortController = new AbortController() ```` +### Middleware + +It's possible to use a middleware to pre-process any request or handle raw response. + +Request middleware example (set actual auth token to each request): +```ts +function middleware(request: RequestInit) { + const token = getToken(); + return { + ...request, + headers: { ...request.headers, 'x-auth-token': token }, + } +} + +const client = new GraphQLClient(endpoint, { requestMiddleware: middleware }) +``` + +Response middleware example (log request trace id if error caused): +```ts +function middleware(response: Response) { + if (response.errors) { + const traceId = response.headers.get('x-b3-traceid') || 'unknown' + console.error( + `[${traceId}] Request error: + status ${response.status} + details: ${response.errors}` + ) + } +} + +const client = new GraphQLClient(endpoint, { responseMiddleware: middleware }) +``` + ### ErrorPolicy By default GraphQLClient will throw when an error is received. However, sometimes you still want to resolve the (partial) data you received. diff --git a/src/index.ts b/src/index.ts index 685ca2404..7129c3e1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,7 @@ import { Variables, PatchedRequestInit, MaybeFunction, - GraphQLError, + Response, } from './types' import * as Dom from './types.dom' @@ -132,6 +132,7 @@ const post = async ({ headers, fetch, fetchOptions, + middleware, }: { url: string query: string | string[] @@ -140,10 +141,11 @@ const post = async ({ variables?: V headers?: Dom.RequestInit['headers'] operationName?: string + middleware?: (request: Dom.RequestInit) => Dom.RequestInit }) => { const body = createRequestBody(query, variables, operationName, fetchOptions.jsonSerializer) - return await fetch(url, { + let options: Dom.RequestInit = { method: 'POST', headers: { ...(typeof body === 'string' ? { 'Content-Type': 'application/json' } : {}), @@ -151,7 +153,11 @@ const post = async ({ }, body, ...fetchOptions, - }) + }; + if (middleware) { + options = middleware(options) + } + return await fetch(url, options) } /** @@ -165,6 +171,7 @@ const get = async ({ headers, fetch, fetchOptions, + middleware, }: { url: string query: string | string[] @@ -173,6 +180,7 @@ const get = async ({ variables?: V headers?: HeadersInit operationName?: string + middleware?: (request: Dom.RequestInit) => Dom.RequestInit }) => { const queryParams = buildGetQueryParams({ query, @@ -181,24 +189,22 @@ const get = async ({ jsonSerializer: fetchOptions.jsonSerializer } as TBuildGetQueryParams) - return await fetch(`${url}?${queryParams}`, { + let options: Dom.RequestInit = { method: 'GET', headers, ...fetchOptions, - }) + }; + if (middleware) { + options = middleware(options) + } + return await fetch(`${url}?${queryParams}`, options) } /** * GraphQL Client. */ export class GraphQLClient { - private url: string - private options: PatchedRequestInit - - constructor(url: string, options?: PatchedRequestInit) { - this.url = url - this.options = options || {} - } + constructor(private url: string, private readonly options: PatchedRequestInit = {}) {} /** * Send a GraphQL query to the server. @@ -207,18 +213,18 @@ export class GraphQLClient { query: string, variables?: V, requestHeaders?: Dom.RequestInit['headers'] - ): Promise<{ data: T; extensions?: any; headers: Dom.Headers; errors?: GraphQLError[]; status: number }> + ): Promise> async rawRequest( options: RawRequestOptions - ): Promise<{ data: T; extensions?: any; headers: Dom.Headers; errors?: GraphQLError[]; status: number }> + ): Promise> async rawRequest( queryOrOptions: string | RawRequestOptions, variables?: V, requestHeaders?: Dom.RequestInit['headers'] - ): Promise<{ data: T; extensions?: any; headers: Dom.Headers; errors?: GraphQLError[]; status: number }> { + ): Promise> { const rawRequestOptions = parseRawRequestArgs(queryOrOptions, variables, requestHeaders) - let { headers, fetch = crossFetch, method = 'POST', ...fetchOptions } = this.options + let { headers, fetch = crossFetch, method = 'POST', requestMiddleware, responseMiddleware , ...fetchOptions } = this.options let { url } = this if (rawRequestOptions.signal !== undefined) { fetchOptions.signal = rawRequestOptions.signal @@ -238,26 +244,32 @@ export class GraphQLClient { fetch, method, fetchOptions, + middleware: requestMiddleware, + }).then(response => { + if (responseMiddleware) { + responseMiddleware(response) + } + return response }) } /** * Send a GraphQL document to the server. */ - async request( + request( document: RequestDocument, variables?: V, requestHeaders?: Dom.RequestInit['headers'] ): Promise - async request(options: RequestOptions): Promise - async request( + request(options: RequestOptions): Promise + request( documentOrOptions: RequestDocument | RequestOptions, variables?: V, requestHeaders?: Dom.RequestInit['headers'] ): Promise { const requestOptions = parseRequestArgs(documentOrOptions, variables, requestHeaders) - let { headers, fetch = crossFetch, method = 'POST', ...fetchOptions } = this.options + let { headers, fetch = crossFetch, method = 'POST', requestMiddleware, responseMiddleware, ...fetchOptions } = this.options let { url } = this if (requestOptions.signal !== undefined) { fetchOptions.signal = requestOptions.signal @@ -265,7 +277,7 @@ export class GraphQLClient { const { query, operationName } = resolveRequestDocument(requestOptions.document) - const { data } = await makeRequest({ + return makeRequest({ url, query, variables: requestOptions.variables, @@ -277,26 +289,30 @@ export class GraphQLClient { fetch, method, fetchOptions, + middleware: requestMiddleware, + }).then(response => { + if (responseMiddleware) { + responseMiddleware(response) + } + return response.data }) - - return data } /** * Send GraphQL documents in batch to the server. */ - async batchRequests( + batchRequests( documents: BatchRequestDocument[], requestHeaders?: Dom.RequestInit['headers'] ): Promise - async batchRequests(options: BatchRequestsOptions): Promise - async batchRequests( + batchRequests(options: BatchRequestsOptions): Promise + batchRequests( documentsOrOptions: BatchRequestDocument[] | BatchRequestsOptions, requestHeaders?: Dom.RequestInit['headers'] ): Promise { const batchRequestOptions = parseBatchRequestArgs(documentsOrOptions, requestHeaders) - let { headers, fetch = crossFetch, method = 'POST', ...fetchOptions } = this.options + let { headers, fetch = crossFetch, method = 'POST', requestMiddleware, responseMiddleware, ...fetchOptions } = this.options let { url } = this if (batchRequestOptions.signal !== undefined) { fetchOptions.signal = batchRequestOptions.signal @@ -307,7 +323,7 @@ export class GraphQLClient { ) const variables = batchRequestOptions.documents.map(({ variables }) => variables) - const { data } = await makeRequest({ + return makeRequest({ url, query: queries, variables, @@ -319,9 +335,13 @@ export class GraphQLClient { fetch, method, fetchOptions, + middleware: requestMiddleware, + }).then(response => { + if (responseMiddleware) { + responseMiddleware(response) + } + return response.data }) - - return data } setHeaders(headers: Dom.RequestInit['headers']): GraphQLClient { @@ -364,6 +384,7 @@ async function makeRequest({ fetch, method = 'POST', fetchOptions, + middleware, }: { url: string query: string | string[] @@ -373,7 +394,8 @@ async function makeRequest({ fetch: any method: string fetchOptions: Dom.RequestInit -}): Promise<{ data: T; extensions?: any; headers: Dom.Headers; errors?: GraphQLError[]; status: number }> { + middleware?: (request: Dom.RequestInit) => Dom.RequestInit +}): Promise> { const fetcher = method.toUpperCase() === 'POST' ? post : get const isBathchingQuery = Array.isArray(query) @@ -385,6 +407,7 @@ async function makeRequest({ headers, fetch, fetchOptions, + middleware, }) const result = await getResult(response, fetchOptions.jsonSerializer) @@ -422,16 +445,16 @@ export async function rawRequest( query: string, variables?: V, requestHeaders?: Dom.RequestInit['headers'] -): Promise<{ data: T; extensions?: any; headers: Dom.Headers; status: number }> +): Promise> export async function rawRequest( options: RawRequestExtendedOptions -): Promise<{ data: T; extensions?: any; headers: Dom.Headers; status: number }> +): Promise> export async function rawRequest( urlOrOptions: string | RawRequestExtendedOptions, query?: string, variables?: V, requestHeaders?: Dom.RequestInit['headers'] -): Promise<{ data: T; extensions?: any; headers: Dom.Headers; status: number }> { +): Promise> { const requestOptions = parseRawRequestExtendedArgs(urlOrOptions, query, variables, requestHeaders) const client = new GraphQLClient(requestOptions.url) return client.rawRequest({ diff --git a/src/types.ts b/src/types.ts index 5249d446a..21d669adc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,8 +59,19 @@ export type MaybeFunction = T | (() => T); export type RequestDocument = string | DocumentNode -export type PatchedRequestInit = Omit - & {headers?: MaybeFunction}; +export interface Response { + data: T + extensions?: any + headers: Dom.Headers + errors?: GraphQLError[] + status: number +} + +export type PatchedRequestInit = Omit & { + headers?: MaybeFunction + requestMiddleware?: (request: Dom.RequestInit) => Dom.RequestInit + responseMiddleware?: (response: Response) => void +}; export type BatchRequestDocument = { document: RequestDocument diff --git a/tests/general.test.ts b/tests/general.test.ts index 4119c901d..097d7e901 100644 --- a/tests/general.test.ts +++ b/tests/general.test.ts @@ -117,6 +117,48 @@ test('basic error with raw request', async () => { ) }) +describe('middleware', () => { + let client: GraphQLClient + let requestMiddleware: jest.Mock + let responseMiddleware: jest.Mock + + beforeEach(() => { + ctx.res({ + body: { + data: { + result: 123, + }, + }, + }) + + requestMiddleware = jest.fn(req => ({ ...req })) + responseMiddleware = jest.fn() + client = new GraphQLClient(ctx.url, { requestMiddleware, responseMiddleware }) + }) + + it('request', async () => { + const requestPromise = client.request<{ result: number }>(`x`) + expect(requestMiddleware).toBeCalledTimes(1) + const res = await requestPromise + expect(responseMiddleware).toBeCalledTimes(1) + expect(res.result).toBe(123) + }) + + it('rawRequest', async () => { + const requestPromise = client.rawRequest<{ result: number }>(`x`) + expect(requestMiddleware).toBeCalledTimes(1) + await requestPromise + expect(responseMiddleware).toBeCalledTimes(1) + }) + + it('batchRequests', async () => { + const requestPromise = client.batchRequests<{ result: number }>([{ document: `x` }]) + expect(requestMiddleware).toBeCalledTimes(1) + await requestPromise + expect(responseMiddleware).toBeCalledTimes(1) + }) +}) + // todo needs to be tested in browser environment // the options under test here aren't used by node-fetch test.skip('extra fetch options', async () => {