diff --git a/docs/usage/http-client-for-oas-operations.md b/docs/usage/http-client-for-oas-operations.md index aa0025e39..ab498a0cf 100644 --- a/docs/usage/http-client-for-oas-operations.md +++ b/docs/usage/http-client-for-oas-operations.md @@ -26,6 +26,7 @@ Property | Description `attachContentTypeForEmptyPayload` | `Boolean=false`. Attaches a `Content-Type` header to a `Request` even when no payload was provided for the `Request`. `http` | `Function=Http`. A function with an interface compatible with [HTTP Client](http-client.md). `userFetch` | `Function=cross-fetch`. Custom **asynchronous** fetch function that accepts two arguments: the `url` and the `Request` object and must return a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) object. More info in [HTTP Client](http-client.md) documentation. +`signal` | `AbortSignal=null`. AbortSignal object instance, which can be used to abort a request as desired. For all later references, we will always use following OpenAPI 3.0.0 definition when referring to a `spec`. @@ -153,6 +154,88 @@ SwaggerClient.execute({ }); // => Promise. ``` +#### Request cancellation with AbortSignal + +You may cancel requests with [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). +The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired. +Using AbortController, you can easily implement request timeouts. + +###### Node.js + +AbortController needs to be introduced in Node.js environment via [abort-controller](https://www.npmjs.com/package/abort-controller) npm package. + +```js +const SwaggerClient = require('swagger-client'); +const AbortController = require('abort-controller'); + +const controller = new AbortController(); +const { signal } = controller; +const timeout = setTimeout(() => { + controller.abort(); +}, 1); + +(async () => { + try { + await SwaggerClient.execute({ + spec, + pathName: '/users', + method: 'get', + parameters: { q: 'search string' }, + securities: { authorized: { BearerAuth: "3492342948239482398" } }, + signal, + }); + } catch (error) { + if (error.name === 'AbortError') { + console.error('request was aborted'); + } + } finally { + clearTimeout(timeout); + } +})(); +``` + +###### Browser + +AbortController is part of modern [Web APIs](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). +No need to install it explicitly. + +```html + + + + + + + check console in browser's dev. tools + + +``` + #### Alternate API It's also possible to call `execute` method from `SwaggerClient` instance. diff --git a/docs/usage/tags-interface.md b/docs/usage/tags-interface.md index 45e780f31..881782fa9 100644 --- a/docs/usage/tags-interface.md +++ b/docs/usage/tags-interface.md @@ -267,3 +267,73 @@ SwaggerClient({ url: 'http://petstore.swagger.io/v2/swagger.json' }) ``` + +#### Request cancellation with AbortSignal + +You may cancel requests with [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). +The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired. +Using AbortController, you can easily implement request timeouts. + +###### Node.js + +AbortController needs to be introduced in Node.js environment via [abort-controller](https://www.npmjs.com/package/abort-controller) npm package. + +```js +const SwaggerClient = require('swagger-client'); +const AbortController = require('abort-controller'); + +const controller = new AbortController(); +const { signal } = controller; +const timeout = setTimeout(() => { + controller.abort(); +}, 1); + +(async () => { + try { + await new SwaggerClient({ spec }) + .then(client => client.apis.default.getUserList({}, { signal })) + } catch (error) { + if (error.name === 'AbortError') { + console.error('request was aborted'); + } + } finally { + clearTimeout(timeout); + } +})(); +``` + +###### Browser + +AbortController is part of modern [Web APIs](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). +No need to install it explicitly. + +```html + + + + + + + check console in browser's dev. tools + + +``` diff --git a/package-lock.json b/package-lock.json index a9dcc7939..7958cb7ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2616,6 +2616,15 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "requires": { + "event-target-shim": "^5.0.0" + } + }, "acorn": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.6.0.tgz", @@ -4367,6 +4376,12 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true + }, "events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", diff --git a/package.json b/package.json index e63c15a46..c69571407 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@babel/register": "=7.16.5", "@commitlint/cli": "^15.0.0", "@commitlint/config-conventional": "^15.0.0", + "abort-controller": "^3.0.0", "babel-loader": "=8.2.3", "babel-plugin-lodash": "=3.3.4", "cross-env": "=7.0.3", diff --git a/src/execute/index.js b/src/execute/index.js index 1977af43e..7f1927159 100755 --- a/src/execute/index.js +++ b/src/execute/index.js @@ -98,6 +98,7 @@ export function buildRequest(options) { server, serverVariables, http, + signal, } = options; let { parameters, parameterBuilders } = options; @@ -123,6 +124,10 @@ export function buildRequest(options) { cookies: {}, }; + if (signal) { + req.signal = signal; + } + if (requestInterceptor) { req.requestInterceptor = requestInterceptor; } diff --git a/test/execute/main.js b/test/execute/main.js index c35884df9..ebe8667d1 100644 --- a/test/execute/main.js +++ b/test/execute/main.js @@ -1,4 +1,5 @@ import { Readable } from 'stream'; +import AbortController from 'abort-controller'; import { execute, buildRequest, self as stubs } from '../../src/execute/index.js'; import { normalizeSwagger } from '../../src/helpers.js'; @@ -157,6 +158,48 @@ describe('execute', () => { }); }); + test('should allow aborting request during execution', async () => { + // cross-fetch exposes FetchAPI methods onto global + require('cross-fetch/polyfill'); + + // Given + const spec = { + host: 'swagger.io', + schemes: ['https'], + paths: { + '/one': { + get: { + operationId: 'getMe', + }, + }, + }, + }; + + const spy = jest.fn().mockImplementation(() => Promise.resolve(new Response('data'))); + const controller = new AbortController(); + const { signal } = controller; + + const response = execute({ + userFetch: spy, + spec, + operationId: 'getMe', + signal, + }); + + controller.abort(); + await response; + + expect(spy.mock.calls.length).toEqual(1); + expect(spy.mock.calls[0][1]).toEqual({ + method: 'GET', + url: 'https://swagger.io/one', + credentials: 'same-origin', + headers: {}, + userFetch: spy, + signal, + }); + }); + test('should include values for query parameters', () => { // Given const spec = { diff --git a/test/interfaces.js b/test/interfaces.js index da643fbeb..275a50ba4 100644 --- a/test/interfaces.js +++ b/test/interfaces.js @@ -1,3 +1,5 @@ +import AbortController from 'abort-controller'; + import { mapTagOperations, makeApisTagOperationsOperationExecute, @@ -108,6 +110,31 @@ describe('intefaces', () => { }); }); + test('should pass signal option to execute', () => { + // Given + const spyMapTagOperations = jest.spyOn(stubs, 'mapTagOperations'); + const spyExecute = jest.fn(); + makeApisTagOperationsOperationExecute({ execute: spyExecute }); + const { cb } = spyMapTagOperations.mock.calls[0][0]; + + // When + const controller = new AbortController(); + const { signal } = controller; + const executer = cb({ pathName: '/one', method: 'GET' }); + executer(['param'], { signal }); + + // Then + expect(spyExecute.mock.calls.length).toEqual(1); + expect(spyExecute.mock.calls[0][0]).toEqual({ + spec: undefined, + operationId: undefined, + method: 'GET', + parameters: ['param'], + pathName: '/one', + signal, + }); + }); + test('should map tagOperations to execute', () => { const interfaceValue = makeApisTagOperationsOperationExecute({ spec: {