Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added middleware support #170

Merged
merged 3 commits into from Jun 24, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
36 changes: 35 additions & 1 deletion README.md
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, { requestMiddleware: middleware })
avsokolov marked this conversation as resolved.
Show resolved Hide resolved
```

### 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
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
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
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