Skip to content

Commit

Permalink
feat: added middleware support (#170)
Browse files Browse the repository at this point in the history
Co-authored-by: Jason Kuhrt <jasonkuhrt@me.com>
Co-authored-by: alexander.sokolov <alexander.sokolov@collabio.team>
  • Loading branch information
3 people authored Jun 24, 2022
1 parent dec0296 commit dec0319
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 37 deletions.
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
```
Expand Down Expand Up @@ -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<unknown>) {
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.
Expand Down
91 changes: 57 additions & 34 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
Variables,
PatchedRequestInit,
MaybeFunction,
GraphQLError,
Response,
} from './types'
import * as Dom from './types.dom'

Expand Down Expand Up @@ -132,6 +132,7 @@ const post = async <V = Variables>({
headers,
fetch,
fetchOptions,
middleware,
}: {
url: string
query: string | string[]
Expand All @@ -140,18 +141,23 @@ const post = async <V = Variables>({
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' } : {}),
...headers,
},
body,
...fetchOptions,
})
};
if (middleware) {
options = middleware(options)
}
return await fetch(url, options)
}

/**
Expand All @@ -165,6 +171,7 @@ const get = async <V = Variables>({
headers,
fetch,
fetchOptions,
middleware,
}: {
url: string
query: string | string[]
Expand All @@ -173,6 +180,7 @@ const get = async <V = Variables>({
variables?: V
headers?: HeadersInit
operationName?: string
middleware?: (request: Dom.RequestInit) => Dom.RequestInit
}) => {
const queryParams = buildGetQueryParams<V>({
query,
Expand All @@ -181,24 +189,22 @@ const get = async <V = Variables>({
jsonSerializer: fetchOptions.jsonSerializer
} as TBuildGetQueryParams<V>)

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.
Expand All @@ -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<Response<T>>
async rawRequest<T = any, V = Variables>(
options: RawRequestOptions<V>
): Promise<{ data: T; extensions?: any; headers: Dom.Headers; errors?: GraphQLError[]; status: number }>
): Promise<Response<T>>
async rawRequest<T = any, V = Variables>(
queryOrOptions: string | RawRequestOptions<V>,
variables?: V,
requestHeaders?: Dom.RequestInit['headers']
): Promise<{ data: T; extensions?: any; headers: Dom.Headers; errors?: GraphQLError[]; status: number }> {
): Promise<Response<T>> {
const rawRequestOptions = parseRawRequestArgs<V>(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
Expand All @@ -238,34 +244,40 @@ 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<T = any, V = Variables>(
request<T = any, V = Variables>(
document: RequestDocument,
variables?: V,
requestHeaders?: Dom.RequestInit['headers']
): Promise<T>
async request<T = any, V = Variables>(options: RequestOptions<V>): Promise<T>
async request<T = any, V = Variables>(
request<T = any, V = Variables>(options: RequestOptions<V>): Promise<T>
request<T = any, V = Variables>(
documentOrOptions: RequestDocument | RequestOptions<V>,
variables?: V,
requestHeaders?: Dom.RequestInit['headers']
): Promise<T> {
const requestOptions = parseRequestArgs<V>(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
}

const { query, operationName } = resolveRequestDocument(requestOptions.document)

const { data } = await makeRequest<T, V>({
return makeRequest<T, V>({
url,
query,
variables: requestOptions.variables,
Expand All @@ -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<T extends any = any, V = Variables>(
batchRequests<T extends any = any, V = Variables>(
documents: BatchRequestDocument<V>[],
requestHeaders?: Dom.RequestInit['headers']
): Promise<T>
async batchRequests<T = any, V = Variables>(options: BatchRequestsOptions<V>): Promise<T>
async batchRequests<T = any, V = Variables>(
batchRequests<T = any, V = Variables>(options: BatchRequestsOptions<V>): Promise<T>
batchRequests<T = any, V = Variables>(
documentsOrOptions: BatchRequestDocument<V>[] | BatchRequestsOptions<V>,
requestHeaders?: Dom.RequestInit['headers']
): Promise<T> {
const batchRequestOptions = parseBatchRequestArgs<V>(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
Expand All @@ -307,7 +323,7 @@ export class GraphQLClient {
)
const variables = batchRequestOptions.documents.map(({ variables }) => variables)

const { data } = await makeRequest<T, (V | undefined)[]>({
return makeRequest<T, (V | undefined)[]>({
url,
query: queries,
variables,
Expand All @@ -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 {
Expand Down Expand Up @@ -364,6 +384,7 @@ async function makeRequest<T = any, V = Variables>({
fetch,
method = 'POST',
fetchOptions,
middleware,
}: {
url: string
query: string | string[]
Expand All @@ -373,7 +394,8 @@ async function makeRequest<T = any, V = Variables>({
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<Response<T>> {
const fetcher = method.toUpperCase() === 'POST' ? post : get
const isBathchingQuery = Array.isArray(query)

Expand All @@ -385,6 +407,7 @@ async function makeRequest<T = any, V = Variables>({
headers,
fetch,
fetchOptions,
middleware,
})
const result = await getResult(response, fetchOptions.jsonSerializer)

Expand Down Expand Up @@ -422,16 +445,16 @@ export async function rawRequest<T = any, V = Variables>(
query: string,
variables?: V,
requestHeaders?: Dom.RequestInit['headers']
): Promise<{ data: T; extensions?: any; headers: Dom.Headers; status: number }>
): Promise<Response<T>>
export async function rawRequest<T = any, V = Variables>(
options: RawRequestExtendedOptions<V>
): Promise<{ data: T; extensions?: any; headers: Dom.Headers; status: number }>
): Promise<Response<T>>
export async function rawRequest<T = any, V = Variables>(
urlOrOptions: string | RawRequestExtendedOptions<V>,
query?: string,
variables?: V,
requestHeaders?: Dom.RequestInit['headers']
): Promise<{ data: T; extensions?: any; headers: Dom.Headers; status: number }> {
): Promise<Response<T>> {
const requestOptions = parseRawRequestExtendedArgs<V>(urlOrOptions, query, variables, requestHeaders)
const client = new GraphQLClient(requestOptions.url)
return client.rawRequest<T, V>({
Expand Down
15 changes: 13 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,19 @@ export type MaybeFunction<T> = T | (() => T);

export type RequestDocument = string | DocumentNode

export type PatchedRequestInit = Omit<Dom.RequestInit, "headers">
& {headers?: MaybeFunction<Dom.RequestInit['headers']>};
export interface Response<T> {
data: T
extensions?: any
headers: Dom.Headers
errors?: GraphQLError[]
status: number
}

export type PatchedRequestInit = Omit<Dom.RequestInit, "headers"> & {
headers?: MaybeFunction<Dom.RequestInit['headers']>
requestMiddleware?: (request: Dom.RequestInit) => Dom.RequestInit
responseMiddleware?: (response: Response<unknown>) => void
};

export type BatchRequestDocument<V = Variables> = {
document: RequestDocument
Expand Down
42 changes: 42 additions & 0 deletions tests/general.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down

0 comments on commit dec0319

Please sign in to comment.