diff --git a/.gitignore b/.gitignore index 8a76002c5..5dcb9b25c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules dist .DS_Store *.log +coverage \ No newline at end of file diff --git a/README.md b/README.md index 278e3aefa..30afa4f1d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Minimal GraphQL client supporting Node and browsers for scripts or simple apps - [Browser](#browser) - [Node](#node) - [Batching](#batching) + - [Cancellation](#cancellation) - [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) @@ -85,6 +86,17 @@ const client = new GraphQLClient(endpoint, { headers: {} }) client.request(query, variables).then((data) => console.log(data)) ``` +You can also use the single argument function variant: + +```js +request({ + url: endpoint, + document: query, + variables: variables, + requestHeaders: headers, +}).then((data) => console.log(data)) +``` + ## Node Version Support We only officially support [LTS Node versions](https://github.com/nodejs/Release#release-schedule). We also make an effort to support two additional versions: @@ -539,6 +551,40 @@ import { batchRequests } from 'graphql-request'; })().catch((error) => console.error(error)) ``` +### Cancellation + +It is possible to cancel a request using an `AbortController` signal. + +You can define the `signal` in the `GraphQLClient` constructor: + +```ts + const abortController = new AbortController() + + const client = new GraphQLClient(endpoint, { signal: abortController.signal }) + client.request(query) + + abortController.abort() +``` + +You can also set the signal per request (this will override an existing GraphQLClient signal): + +```ts + const abortController = new AbortController() + + const client = new GraphQLClient(endpoint) + client.request({ document: query, signal: abortController.signal }) + + abortController.abort() +``` + +In Node environment, `AbortController` is supported since version v14.17.0. +For Node.js v12 you can use [abort-controller](https://github.com/mysticatea/abort-controller) polyfill. + +```` + import 'abort-controller/polyfill' + + const abortController = new AbortController() +```` ## FAQ diff --git a/package.json b/package.json index 4d08db925..569b61d40 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test:node": "jest --testEnvironment node", "test:dom": "jest --testEnvironment jsdom", "test": "yarn test:node && yarn test:dom", + "test:coverage": "yarn test --coverage", "release:stable": "dripip stable", "release:preview": "dripip preview", "release:pr": "dripip pr" @@ -51,6 +52,7 @@ "graphql": "14.x || 15.x" }, "devDependencies": { + "abort-controller": "^3.0.0", "@prisma-labs/prettier-config": "^0.1.0", "@types/body-parser": "^1.19.1", "@types/express": "^4.17.13", diff --git a/src/index.ts b/src/index.ts index d63374b0e..46398fd3e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,10 +2,40 @@ import crossFetch, * as CrossFetch from 'cross-fetch' import { OperationDefinitionNode } from 'graphql/language/ast' import { print } from 'graphql/language/printer' import createRequestBody from './createRequestBody' -import { BatchRequestDocument, ClientError, RequestDocument, Variables } from './types' +import { + parseBatchRequestArgs, + parseRawRequestArgs, + parseRequestArgs, + parseBatchRequestsExtendedArgs, + parseRawRequestExtendedArgs, + parseRequestExtendedArgs, +} from './parseArgs' +import { + BatchRequestDocument, + BatchRequestsOptions, + ClientError, + RawRequestOptions, + RequestDocument, + RequestOptions, + BatchRequestsExtendedOptions, + RawRequestExtendedOptions, + RequestExtendedOptions, + Variables, +} from './types' import * as Dom from './types.dom' -export { BatchRequestDocument, ClientError, RequestDocument, Variables } +export { + BatchRequestDocument, + BatchRequestsOptions, + BatchRequestsExtendedOptions, + ClientError, + RawRequestOptions, + RawRequestExtendedOptions, + RequestDocument, + RequestOptions, + RequestExtendedOptions, + Variables, +} /** * Convert the given headers configuration into a plain object. @@ -152,7 +182,7 @@ const get = async ({ } /** - * todo + * GraphQL Client. */ export class GraphQLClient { private url: string @@ -163,21 +193,37 @@ export class GraphQLClient { this.options = options || {} } - rawRequest( + /** + * Send a GraphQL query to the server. + */ + async rawRequest( query: string, variables?: V, requestHeaders?: Dom.RequestInit['headers'] + ): Promise<{ data: T; extensions?: any; headers: Dom.Headers; status: number }> + async rawRequest( + options: RawRequestOptions + ): Promise<{ data: T; extensions?: any; headers: Dom.Headers; status: number }> + async rawRequest( + queryOrOptions: string | RawRequestOptions, + variables?: V, + requestHeaders?: Dom.RequestInit['headers'] ): Promise<{ data: T; extensions?: any; headers: Dom.Headers; status: number }> { + const rawRequestOptions = parseRawRequestArgs(queryOrOptions, variables, requestHeaders) + let { headers, fetch = crossFetch, method = 'POST', ...fetchOptions } = this.options let { url } = this + if (rawRequestOptions.signal !== undefined) { + fetchOptions.signal = rawRequestOptions.signal + } return makeRequest({ url, - query, - variables, + query: rawRequestOptions.query, + variables: rawRequestOptions.variables, headers: { ...resolveHeaders(headers), - ...resolveHeaders(requestHeaders), + ...resolveHeaders(rawRequestOptions.requestHeaders), }, operationName: undefined, fetch, @@ -193,19 +239,30 @@ export class GraphQLClient { document: RequestDocument, variables?: V, requestHeaders?: Dom.RequestInit['headers'] + ): Promise + async request(options: RequestOptions): Promise + async 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 { url } = this + if (requestOptions.signal !== undefined) { + fetchOptions.signal = requestOptions.signal + } - const { query, operationName } = resolveRequestDocument(document) + const { query, operationName } = resolveRequestDocument(requestOptions.document) const { data } = await makeRequest({ url, query, - variables, + variables: requestOptions.variables, headers: { ...resolveHeaders(headers), - ...resolveHeaders(requestHeaders), + ...resolveHeaders(requestOptions.requestHeaders), }, operationName, fetch, @@ -217,17 +274,29 @@ export class GraphQLClient { } /** - * Send a GraphQL document to the server. + * Send GraphQL documents in batch to the server. */ async batchRequests( documents: BatchRequestDocument[], requestHeaders?: Dom.RequestInit['headers'] + ): Promise + async batchRequests(options: BatchRequestsOptions): Promise + async batchRequests( + documentsOrOptions: BatchRequestDocument[] | BatchRequestsOptions, + requestHeaders?: Dom.RequestInit['headers'] ): Promise { + const batchRequestOptions = parseBatchRequestArgs(documentsOrOptions, requestHeaders) + let { headers, fetch = crossFetch, method = 'POST', ...fetchOptions } = this.options let { url } = this + if (batchRequestOptions.signal !== undefined) { + fetchOptions.signal = batchRequestOptions.signal + } - const queries = documents.map(({ document }) => resolveRequestDocument(document).query) - const variables = documents.map(({ variables }) => variables) + const queries = batchRequestOptions.documents.map( + ({ document }) => resolveRequestDocument(document).query + ) + const variables = batchRequestOptions.documents.map(({ variables }) => variables) const { data } = await makeRequest({ url, @@ -235,7 +304,7 @@ export class GraphQLClient { variables, headers: { ...resolveHeaders(headers), - ...resolveHeaders(requestHeaders), + ...resolveHeaders(batchRequestOptions.requestHeaders), }, operationName: undefined, fetch, @@ -330,20 +399,32 @@ async function makeRequest({ } /** - * todo + * Send a GraphQL Query to the GraphQL server for execution. */ export async function rawRequest( url: string, query: string, variables?: V, requestHeaders?: Dom.RequestInit['headers'] +): Promise<{ data: T; extensions?: any; headers: Dom.Headers; status: number }> +export async function rawRequest( + options: RawRequestExtendedOptions +): Promise<{ data: T; extensions?: any; headers: Dom.Headers; status: number }> +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 }> { - const client = new GraphQLClient(url) - return client.rawRequest(query, variables, requestHeaders) + const requestOptions = parseRawRequestExtendedArgs(urlOrOptions, query, variables, requestHeaders) + const client = new GraphQLClient(requestOptions.url) + return client.rawRequest({ + ...requestOptions, + }) } /** - * Send a GraphQL Document to the GraphQL server for exectuion. + * Send a GraphQL Document to the GraphQL server for execution. * * @example * @@ -381,9 +462,19 @@ export async function request( document: RequestDocument, variables?: V, requestHeaders?: Dom.RequestInit['headers'] +): Promise +export async function request(options: RequestExtendedOptions): Promise +export async function request( + urlOrOptions: string | RequestExtendedOptions, + document?: RequestDocument, + variables?: V, + requestHeaders?: Dom.RequestInit['headers'] ): Promise { - const client = new GraphQLClient(url) - return client.request(document, variables, requestHeaders) + const requestOptions = parseRequestExtendedArgs(urlOrOptions, document, variables, requestHeaders) + const client = new GraphQLClient(requestOptions.url) + return client.request({ + ...requestOptions, + }) } /** @@ -420,13 +511,22 @@ export async function request( * await batchRequests('https://foo.bar/graphql', [{ query: gql`...` }]) * ``` */ -export async function batchRequests( +export async function batchRequests( url: string, documents: BatchRequestDocument[], requestHeaders?: Dom.RequestInit['headers'] +): Promise +export async function batchRequests( + options: BatchRequestsExtendedOptions +): Promise +export async function batchRequests( + urlOrOptions: string | BatchRequestsExtendedOptions, + documents?: BatchRequestDocument[], + requestHeaders?: Dom.RequestInit['headers'] ): Promise { - const client = new GraphQLClient(url) - return client.batchRequests(documents, requestHeaders) + const requestOptions = parseBatchRequestsExtendedArgs(urlOrOptions, documents, requestHeaders) + const client = new GraphQLClient(requestOptions.url) + return client.batchRequests({ ...requestOptions }) } export default request diff --git a/src/parseArgs.ts b/src/parseArgs.ts new file mode 100644 index 000000000..3e037c6d8 --- /dev/null +++ b/src/parseArgs.ts @@ -0,0 +1,104 @@ +import { + BatchRequestDocument, + BatchRequestsOptions, + RawRequestOptions, + RequestDocument, + RequestOptions, + BatchRequestsExtendedOptions, + RawRequestExtendedOptions, + RequestExtendedOptions, + Variables, +} from './types' +import * as Dom from './types.dom' + +export function parseRequestArgs( + documentOrOptions: RequestDocument | RequestOptions, + variables?: V, + requestHeaders?: Dom.RequestInit['headers'] +): RequestOptions { + return (documentOrOptions as RequestOptions).document + ? (documentOrOptions as RequestOptions) + : { + document: documentOrOptions as RequestDocument, + variables: variables, + requestHeaders: requestHeaders, + signal: undefined, + } +} + +export function parseRawRequestArgs( + queryOrOptions: string | RawRequestOptions, + variables?: V, + requestHeaders?: Dom.RequestInit['headers'] +): RawRequestOptions { + return (queryOrOptions as RawRequestOptions).query + ? (queryOrOptions as RawRequestOptions) + : { + query: queryOrOptions as string, + variables: variables, + requestHeaders: requestHeaders, + signal: undefined, + } +} + +export function parseBatchRequestArgs( + documentsOrOptions: BatchRequestDocument[] | BatchRequestsOptions, + requestHeaders?: Dom.RequestInit['headers'] +): BatchRequestsOptions { + return (documentsOrOptions as BatchRequestsOptions).documents + ? (documentsOrOptions as BatchRequestsOptions) + : { + documents: documentsOrOptions as BatchRequestDocument[], + requestHeaders: requestHeaders, + signal: undefined, + } +} + +export function parseRequestExtendedArgs( + urlOrOptions: string | RequestExtendedOptions, + document?: RequestDocument, + variables?: V, + requestHeaders?: Dom.RequestInit['headers'] +): RequestExtendedOptions { + return (urlOrOptions as RequestExtendedOptions).document + ? (urlOrOptions as RequestExtendedOptions) + : { + url: urlOrOptions as string, + document: document as RequestDocument, + variables: variables, + requestHeaders: requestHeaders, + signal: undefined, + } +} + +export function parseRawRequestExtendedArgs( + urlOrOptions: string | RawRequestExtendedOptions, + query?: string, + variables?: V, + requestHeaders?: Dom.RequestInit['headers'] +): RawRequestExtendedOptions { + return (urlOrOptions as RawRequestExtendedOptions).query + ? (urlOrOptions as RawRequestExtendedOptions) + : { + url: urlOrOptions as string, + query: query as string, + variables: variables, + requestHeaders: requestHeaders, + signal: undefined, + } +} + +export function parseBatchRequestsExtendedArgs( + urlOrOptions: string | BatchRequestsExtendedOptions, + documents?: BatchRequestDocument[], + requestHeaders?: Dom.RequestInit['headers'] +): BatchRequestsExtendedOptions { + return (urlOrOptions as BatchRequestsExtendedOptions).documents + ? (urlOrOptions as BatchRequestsExtendedOptions) + : { + url: urlOrOptions as string, + documents: documents as BatchRequestDocument[], + requestHeaders: requestHeaders, + signal: undefined, + } +} diff --git a/src/types.ts b/src/types.ts index bd6a5c448..0a77e67e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import { DocumentNode } from 'graphql/language/ast' +import * as Dom from './types.dom' export type Variables = { [key: string]: any } @@ -60,3 +61,29 @@ export type BatchRequestDocument = { document: RequestDocument variables?: V } + +export type RawRequestOptions = { + query: string + variables?: V + requestHeaders?: Dom.RequestInit['headers'] + signal?: Dom.RequestInit['signal'] +} + +export type RequestOptions = { + document: RequestDocument + variables?: V + requestHeaders?: Dom.RequestInit['headers'] + signal?: Dom.RequestInit['signal'] +} + +export type BatchRequestsOptions = { + documents: BatchRequestDocument[] + requestHeaders?: Dom.RequestInit['headers'] + signal?: Dom.RequestInit['signal'] +} + +export type RequestExtendedOptions = { url: string } & RequestOptions + +export type RawRequestExtendedOptions = { url: string } & RawRequestOptions + +export type BatchRequestsExtendedOptions = { url: string } & BatchRequestsOptions diff --git a/tests/__helpers.ts b/tests/__helpers.ts index a0875f54b..11736d66b 100644 --- a/tests/__helpers.ts +++ b/tests/__helpers.ts @@ -43,7 +43,7 @@ type MockResult = { }[] } -export function setupTestServer(): Context { +export function setupTestServer(delay?: number): Context { const ctx = {} as Context beforeAll(async () => { const port = await getPort() @@ -58,7 +58,11 @@ export function setupTestServer() ctx.url = `http://localhost:${port}` ctx.res = (spec?: T): MockResult => { const requests: CapturedRequest[] = [] - ctx.server.use('*', function mock(req, res) { + ctx.server.use('*', async function mock(req, res) { + if (delay) { + await sleep(delay) + } + req.headers.host = 'DYNAMIC' requests.push({ method: req.method, @@ -130,3 +134,9 @@ export function createApolloServerContext({ typeDefs, resolvers }: ApolloServerC return ctx } + +export function sleep(timeout: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, timeout) + }) +} diff --git a/tests/signal.test.ts b/tests/signal.test.ts new file mode 100644 index 000000000..dc295c318 --- /dev/null +++ b/tests/signal.test.ts @@ -0,0 +1,248 @@ +import { batchRequests, GraphQLClient, rawRequest, request } from '../src' +import { setupTestServer, sleep } from './__helpers' +import 'abort-controller/polyfill' + +const ctx = setupTestServer(20) + +it('should abort a request when the signal is defined in the GraphQLClient', async () => { + const abortController = new AbortController() + abortController.abort() + expect.assertions(1) + + const client = new GraphQLClient(ctx.url, { signal: abortController.signal }) + + try { + await client.request('{ me { id } }') + } catch (error) { + expect((error as Error).message).toEqual('The user aborted a request.') + } +}) + +it('should abort a request when the signal is defined in GraphQLClient and after the request has been sent', async () => { + const abortController = new AbortController() + ctx.res({ + body: { + data: { + me: { + id: 'some-id', + }, + }, + }, + }).spec.body! + + expect.assertions(1) + + const client = new GraphQLClient(ctx.url, { signal: abortController.signal }) + client.request('{ me { id } }').catch((error) => { + expect((error as Error).message).toEqual('The user aborted a request.') + }) + + await sleep(10) + abortController.abort() + await sleep(20) +}) + +it('should abort a raw request when the signal is defined in the GraphQLClient', async () => { + const abortController = new AbortController() + abortController.abort() + expect.assertions(1) + + const client = new GraphQLClient(ctx.url, { signal: abortController.signal }) + + try { + await client.rawRequest(ctx.url, `{ me { id } }`) + } catch (error) { + expect((error as Error).message).toEqual('The user aborted a request.') + } +}) + +it('should abort batch requests when the signal is defined in the GraphQLClient', async () => { + const abortController = new AbortController() + abortController.abort() + expect.assertions(1) + + const client = new GraphQLClient(ctx.url, { signal: abortController.signal }) + + try { + await client.batchRequests([{ document: `{ me { id } }` }, { document: `{ me { id } }` }]) + } catch (error) { + expect((error as Error).message).toEqual('The user aborted a request.') + } +}) + +it('should abort a request when the signal overrides GraphQLClient settings', async () => { + const abortController = new AbortController() + abortController.abort() + expect.assertions(1) + + const client = new GraphQLClient(ctx.url) + + try { + await client.request({ + document: '{ me { id } }', + signal: abortController.signal, + }) + } catch (error) { + expect((error as Error).message).toEqual('The user aborted a request.') + } +}) + +it('should abort a raw request when the signal overrides GraphQLClient settings', async () => { + const abortController = new AbortController() + abortController.abort() + expect.assertions(1) + + const client = new GraphQLClient(ctx.url) + + try { + await client.rawRequest({ query: '{ me { id } }', signal: abortController.signal }) + } catch (error) { + expect((error as Error).message).toEqual('The user aborted a request.') + } +}) + +it('should abort batch requests when the signal overrides GraphQLClient settings', async () => { + const abortController = new AbortController() + abortController.abort() + expect.assertions(1) + + const client = new GraphQLClient(ctx.url) + + try { + await client.batchRequests({ + documents: [{ document: `{ me { id } }` }, { document: `{ me { id } }` }], + signal: abortController.signal, + }) + } catch (error) { + expect((error as Error).message).toEqual('The user aborted a request.') + } +}) + +it('should abort a request', async () => { + const abortController = new AbortController() + abortController.abort() + expect.assertions(1) + + try { + await request({ + url: ctx.url, + document: '{ me { id } }', + signal: abortController.signal, + }) + } catch (error) { + expect((error as Error).message).toEqual('The user aborted a request.') + } +}) + +it('should abort a request after the request has been sent', async () => { + const abortController = new AbortController() + ctx.res({ + body: { + data: { + me: { + id: 'some-id', + }, + }, + }, + }).spec.body! + + expect.assertions(1) + + request({ + url: ctx.url, + document: '{ me { id } }', + signal: abortController.signal, + }).catch((error) => { + expect((error as Error).message).toEqual('The user aborted a request.') + }) + + await sleep(10) + abortController.abort() + await sleep(20) +}) + +it('should abort a raw request', async () => { + const abortController = new AbortController() + abortController.abort() + expect.assertions(1) + + try { + await rawRequest({ + url: ctx.url, + query: '{ me { id } }', + signal: abortController.signal, + }) + } catch (error) { + expect((error as Error).message).toEqual('The user aborted a request.') + } +}) + +it('should abort a raw request after the request has been sent', async () => { + const abortController = new AbortController() + ctx.res({ + body: { + data: { + me: { + id: 'some-id', + }, + }, + }, + }).spec.body! + + expect.assertions(1) + + rawRequest({ + url: ctx.url, + query: '{ me { id } }', + signal: abortController.signal, + }).catch((error) => { + expect((error as Error).message).toEqual('The user aborted a request.') + }) + + await sleep(10) + abortController.abort() + await sleep(20) +}) + +it('should abort batch requests', async () => { + const abortController = new AbortController() + abortController.abort() + expect.assertions(1) + + try { + await batchRequests({ + url: ctx.url, + documents: [{ document: `{ me { id } }` }, { document: `{ me { id } }` }], + signal: abortController.signal, + }) + } catch (error) { + expect((error as Error).message).toEqual('The user aborted a request.') + } +}) + +it('should abort batch requests after a request has been sent', async () => { + const abortController = new AbortController() + ctx.res({ + body: { + data: { + me: { + id: 'some-id', + }, + }, + }, + }).spec.body! + + expect.assertions(1) + + batchRequests({ + url: ctx.url, + documents: [{ document: `{ me { id } }` }, { document: `{ me { id } }` }], + signal: abortController.signal, + }).catch((error) => { + expect((error as Error).message).toEqual('The user aborted a request.') + }) + + await sleep(10) + abortController.abort() + await sleep(20) +}) diff --git a/yarn.lock b/yarn.lock index c7705b2c8..0ec4d49f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1147,6 +1147,13 @@ abab@^2.0.3, abab@^2.0.5: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@^1.3.5, accepts@~1.3.7: version "1.3.7" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" @@ -2121,6 +2128,11 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"