From 3a3c092e0c9b5ca3821f3c8caba029f99c2ee987 Mon Sep 17 00:00:00 2001 From: Mathias Klippinge Date: Thu, 12 Sep 2024 08:59:03 +0200 Subject: [PATCH] Add support for AbortSignal (#557) * Add support for `signal` AbortSignal in mappersmith request * Add node integration test for abortSignal * Implement support for abort signal in Gateways * Implement support for abort signal in XHR Gateway * Add changeset * Update README * Link to MDN docs * Update changelog * Add tests showing the request params might be undefined --- .changeset/sharp-vans-approve.md | 25 +++++ README.md | 31 ++++- spec/integration/browser/integration.spec.js | 40 ++++++- spec/integration/node/integration.spec.js | 112 ++++++++++++++++--- src/client-builder.spec.ts | 47 ++++++++ src/gateway/fetch.ts | 9 +- src/gateway/http.ts | 17 ++- src/gateway/xhr.ts | 18 +++ src/manifest.ts | 48 ++++---- src/method-descriptor.ts | 10 +- src/request.spec.ts | 87 ++++++++++++++ src/request.ts | 13 ++- src/types.ts | 1 + 13 files changed, 400 insertions(+), 58 deletions(-) create mode 100644 .changeset/sharp-vans-approve.md diff --git a/.changeset/sharp-vans-approve.md b/.changeset/sharp-vans-approve.md new file mode 100644 index 00000000..f641bb10 --- /dev/null +++ b/.changeset/sharp-vans-approve.md @@ -0,0 +1,25 @@ +--- +'mappersmith': minor +--- + +# Add support for abort signals + +The [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) interface represents a signal object that allows you to communicate with an asynchronous operation (such as a fetch request) and abort it if required via an AbortController object. All gateway APIs (Fetch, HTTP and XHR) support this interface via the `signal` parameter: + +```javascript +const abortController = new AbortController() +// Start a long running task... +client.Bitcoin.mine({ signal: abortController.signal }) +// This takes too long, abort! +abortController.abort() +``` + +# Minor type fixes + +The return value of some functions on `Request` have been updated to highlight that they might return undefined: + +- `Request#body()` +- `Request#auth()` +- `Request#timeout()` + +The reasoning behind this change is that if you didn't pass them (and no middleware set them) they might simply be undefined. So the types were simply wrong before. If you experience a "breaking change" due to this change, then it means you have a potential bug that you didn't properly handle before. diff --git a/README.md b/README.md index 53b64237..509b725d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ __Mappersmith__ is a lightweight rest client for node.js and the browser. It cre - [Headers](#headers) - [Basic Auth](#basic-auth) - [Timeout](#timeout) + - [Abort Signal](#abort-signal) - [Alternative host](#alternative-host) - [Alternative path](#alternative-path) - [Binary data](#binary-data) @@ -319,6 +320,34 @@ client.User.all({ maxWait: 500 }) __NOTE__: A default timeout can be configured with the use of the [TimeoutMiddleware](#middleware-timeout), check the middleware section below for more information. __NOTE__: The `timeoutAttr` param can be set at manifest level. +### Abort Signal + +The [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) interface represents a signal object that allows you to communicate with an asynchronous operation (such as a fetch request) and abort it if required via an AbortController object. All gateway APIs (Fetch, HTTP and XHR) support this interface via the `signal` parameter: + +```javascript +const abortController = new AbortController() +client.User.all({ signal: abortController.signal }) +// abort! +abortController.abort() +``` + +If `signal` is not possible as a special parameter for your API you can configure it through the param `signalAttr`: + +```javascript +// ... +{ + all: { path: '/users', signalAttr: 'abortSignal' } +} +// ... + +const abortController = new AbortController() +client.User.all({ abortSignal: abortController.signal }) +// abort! +abortController.abort() +``` + +__NOTE__: The `signalAttr` param can be set at manifest level. + ### Alternative host There are some cases where a resource method resides in another host, in those cases you can use the `host` key to configure a new host: @@ -1184,7 +1213,7 @@ describe('Feature', () => { ## Gateways -Mappersmith has a pluggable transport layer and it includes by default three gateways: xhr, http and fetch. Mappersmith will pick the correct gateway based on the environment you are running (nodejs or the browser). +Mappersmith has a pluggable transport layer and it includes by default three gateways: xhr, http and fetch. Mappersmith will pick the correct gateway based on the environment you are running (nodejs, service worker or the browser). You can write your own gateway, take a look at [XHR](https://github.com/tulios/mappersmith/blob/master/src/gateway/xhr.js) for an example. To configure, import the `configs` object and assign the gateway option, like: diff --git a/spec/integration/browser/integration.spec.js b/spec/integration/browser/integration.spec.js index 8019ce3d..e0e4417b 100644 --- a/spec/integration/browser/integration.spec.js +++ b/spec/integration/browser/integration.spec.js @@ -26,6 +26,12 @@ describe('integration', () => { }) }) + describe('CSRF', () => { + integrationTestsForGateway(Fetch, { host: '/proxy' }, (gateway, params) => { + csrfSpec(forge(createManifest(params.host), gateway)) + }) + }) + describe('with raw binary', () => { it('GET /api/binary.pdf', (done) => { const Client = forge(createManifest(params.host), gateway) @@ -100,11 +106,37 @@ describe('integration', () => { }) }) }) - }) - describe('CSRF', () => { - integrationTestsForGateway(Fetch, { host: '/proxy' }, (gateway, params) => { - csrfSpec(forge(createManifest(params.host), gateway)) + describe('aborting a request', () => { + it('aborts the request', (done) => { + const Client = forge( + { + host: params.host, + fetch, + resources: { + Timeout: { + get: { path: '/api/timeout.json' }, + }, + }, + }, + gateway + ) + const abortController = new AbortController() + const request = Client.Timeout.get({ waitTime: 666, signal: abortController.signal }) + // Fire the request, but abort after 1ms + setTimeout(() => { + abortController.abort() + }, 1) + request + .then((response) => { + done.fail(`Expected this request to fail: ${errorMessage(response)}`) + }) + .catch((response) => { + expect(response.status()).toEqual(400) + expect(response.error()).toMatch(/The operation was aborted/i) + done() + }) + }) }) }) }) diff --git a/spec/integration/node/integration.spec.js b/spec/integration/node/integration.spec.js index 2249a006..5e24628f 100644 --- a/spec/integration/node/integration.spec.js +++ b/spec/integration/node/integration.spec.js @@ -4,8 +4,10 @@ import 'core-js/stable' import 'regenerator-runtime/runtime' import md5 from 'js-md5' import integrationTestsForGateway from 'spec/integration/shared-examples' +import fetch from 'node-fetch' import HTTP from 'src/gateway/http' +import Fetch from 'src/gateway/fetch' import forge, { configs } from 'src/index' import createManifest from 'spec/integration/support/manifest' import { errorMessage, INVALID_ADDRESS } from 'spec/integration/support' @@ -18,6 +20,10 @@ describe('integration', () => { const params = { host: 'http://localhost:9090' } const keepAliveHelper = keepAlive(params.host, gateway) + beforeAll(() => { + configs.gateway = HTTP + }) + describe('event callbacks', () => { let gatewayConfigs = {} @@ -35,7 +41,7 @@ describe('integration', () => { }) it('should call the callbacks', (done) => { - const Client = forge(createManifest(params.host)) + const Client = forge(createManifest(params.host), gateway) Client.Book.all().then(() => { expect(gatewayConfigs.onRequestWillStart).toHaveBeenCalledWith(jasmine.any(Object)) expect(gatewayConfigs.onRequestSocketAssigned).toHaveBeenCalledWith(jasmine.any(Object)) @@ -120,20 +126,21 @@ describe('integration', () => { describe('with raw binary', () => { it('GET /api/binary.pdf', (done) => { - console.log('starting test 1') - const Client = forge({ - host: params.host, - resources: { - Binary: { - get: { path: '/api/binary.pdf', binary: true }, + const Client = forge( + { + host: params.host, + resources: { + Binary: { + get: { path: '/api/binary.pdf', binary: true }, + }, }, }, - }) + gateway + ) Client.Binary.get() .then((response) => { expect(response.status()).toEqual(200) expect(md5(response.data())).toEqual('7e8dfc5e83261f49206a7cd860ccae0a') - console.log('finishing test 1') done() }) .catch((response) => { @@ -145,14 +152,17 @@ describe('integration', () => { describe('on network errors', () => { it('returns the original error', (done) => { - const Client = forge({ - host: INVALID_ADDRESS, - resources: { - PlainText: { - get: { path: '/api/plain-text' }, + const Client = forge( + { + host: INVALID_ADDRESS, + resources: { + PlainText: { + get: { path: '/api/plain-text' }, + }, }, }, - }) + gateway + ) Client.PlainText.get() .then((response) => { done.fail(`Expected this request to fail: ${errorMessage(response)}`) @@ -164,5 +174,77 @@ describe('integration', () => { }) }) }) + + describe('aborting a request', () => { + it('aborts the request', (done) => { + const Client = forge( + { + host: params.host, + resources: { + Timeout: { + get: { path: '/api/timeout.json' }, + }, + }, + }, + gateway + ) + const abortController = new AbortController() + const request = Client.Timeout.get({ waitTime: 666, signal: abortController.signal }) + // Fire the request, but abort after 1ms + setTimeout(() => { + abortController.abort() + }, 1) + request + .then((response) => { + done.fail(`Expected this request to fail: ${errorMessage(response)}`) + }) + .catch((response) => { + expect(response.status()).toEqual(400) + expect(response.error()).toMatch(/The operation was aborted/i) + done() + }) + }) + }) + }) + + describe('Fetch', () => { + const gateway = Fetch + const params = { host: 'http://localhost:9090' } + + beforeAll(() => { + configs.gateway = Fetch + }) + + describe('aborting a request', () => { + it('aborts the request', (done) => { + const Client = forge( + { + host: params.host, + fetch, + resources: { + Timeout: { + get: { path: '/api/timeout.json' }, + }, + }, + }, + gateway + ) + const abortController = new AbortController() + const request = Client.Timeout.get({ waitTime: 666, signal: abortController.signal }) + // Fire the request, but abort after 1ms + setTimeout(() => { + abortController.abort() + }, 1) + request + .then((response) => { + done.fail(`Expected this request to fail: ${errorMessage(response)}`) + }) + .catch((response) => { + expect(response.status()).toEqual(400) + expect(response.error()).toMatch(/This operation was aborted/i) + done() + }) + }) + }) }) }) diff --git a/src/client-builder.spec.ts b/src/client-builder.spec.ts index e5640398..19dc7e02 100644 --- a/src/client-builder.spec.ts +++ b/src/client-builder.spec.ts @@ -3,7 +3,11 @@ import { Manifest, GlobalConfigs } from './manifest' import type { GatewayConfiguration } from './gateway/types' import { Gateway } from './gateway/index' import Request from './request' +import Response from './response' import { getManifest, getManifestWithResourceConf } from '../spec/ts-helper' +import MockGateway from './gateway/mock' +import { configs as defaultConfigs } from './index' +import { mockRequest } from './test/index' describe('ClientBuilder', () => { let GatewayClassFactory: () => typeof Gateway @@ -96,6 +100,49 @@ describe('ClientBuilder', () => { expect(request.body()).toEqual('blog post') }) + it('accepts manifest level timeoutAttr', async () => { + mockRequest({ + method: 'get', + url: 'http://example.org/users/1?timeout=123', + response: { + status: 200, + body: { + name: 'John Doe', + }, + }, + }) + + const GatewayClassFactory = () => MockGateway + const manifest = { ...getManifest(), timeoutAttr: 'customTimeout' } + const clientBuilder = new ClientBuilder(manifest, GatewayClassFactory, defaultConfigs) + const client = clientBuilder.build() + await expect(client.User.byId({ id: 1, timeout: 123, customTimeout: 456 })).resolves.toEqual( + expect.any(Response) + ) + }) + + it('accepts manifest level signalAttr', async () => { + mockRequest({ + method: 'get', + url: 'http://example.org/users/1?signal=123', + response: { + status: 200, + body: { + name: 'John Doe', + }, + }, + }) + + const GatewayClassFactory = () => MockGateway + const manifest = { ...getManifest(), signalAttr: 'customSignal' } + const clientBuilder = new ClientBuilder(manifest, GatewayClassFactory, defaultConfigs) + const client = clientBuilder.build() + const abortController = new AbortController() + await expect( + client.User.byId({ id: 1, signal: 123, customSignal: abortController.signal }) + ).resolves.toEqual(expect.any(Response)) + }) + describe('when a resource method is called', () => { it('calls the gateway with the correct request', async () => { const manifest = getManifest() diff --git a/src/gateway/fetch.ts b/src/gateway/fetch.ts index e3ccdeff..faa63e71 100644 --- a/src/gateway/fetch.ts +++ b/src/gateway/fetch.ts @@ -37,7 +37,7 @@ export class Fetch extends Gateway { this.performRequest('delete') } - performRequest(method: Method) { + performRequest(requestMethod: Method) { const fetch = configs.fetch if (!fetch) { @@ -47,7 +47,7 @@ export class Fetch extends Gateway { } const customHeaders: Record = {} - const body = this.prepareBody(method, customHeaders) as BodyInit + const body = this.prepareBody(requestMethod, customHeaders) as BodyInit const auth = this.request.auth() if (auth) { @@ -57,8 +57,9 @@ export class Fetch extends Gateway { } const headers = assign(customHeaders, this.request.headers()) - const requestMethod = this.shouldEmulateHTTP() ? 'post' : method - const init: RequestInit = assign({ method: requestMethod, headers, body }, this.options().Fetch) + const method = this.shouldEmulateHTTP() ? 'post' : requestMethod + const signal = this.request.signal() + const init: RequestInit = assign({ method, headers, body, signal }, this.options().Fetch) const timeout = this.request.timeout() let timer: ReturnType | null = null diff --git a/src/gateway/http.ts b/src/gateway/http.ts index 509b0983..905f735a 100644 --- a/src/gateway/http.ts +++ b/src/gateway/http.ts @@ -39,14 +39,15 @@ export class HTTP extends Gateway { this.performRequest('delete') } - performRequest(method: Method) { + performRequest(requestMethod: Method) { const headers: Record = {} // FIXME: Deprecated API // eslint-disable-next-line n/no-deprecated-api const defaults = url.parse(this.request.url()) - const requestMethod = this.shouldEmulateHTTP() ? 'post' : method - const body = this.prepareBody(method, headers) + const method = this.shouldEmulateHTTP() ? 'post' : requestMethod + const body = this.prepareBody(requestMethod, headers) const timeout = this.request.timeout() + const signal = this.request.signal() this.canceled = false @@ -61,9 +62,9 @@ export class HTTP extends Gateway { const handler = defaults.protocol === 'https:' ? https : http - const requestParams: HTTPRequestParams = assign(defaults, { - method: requestMethod, - headers: assign(headers, this.request.headers()), + const requestParams: http.RequestOptions = assign(defaults, { + method, + headers: assign(headers, this.request.headers() as http.OutgoingHttpHeaders), }) const auth = this.request.auth() @@ -87,6 +88,10 @@ export class HTTP extends Gateway { httpOptions.onRequestWillStart(requestParams) } + if (signal) { + requestParams.signal = signal + } + const httpRequest = handler.request(requestParams, (httpResponse) => this.onResponse(httpResponse, httpOptions, requestParams) ) diff --git a/src/gateway/xhr.ts b/src/gateway/xhr.ts index 39ce8d51..4c478bdf 100644 --- a/src/gateway/xhr.ts +++ b/src/gateway/xhr.ts @@ -22,6 +22,7 @@ export class XHR extends Gateway { this.setHeaders(xmlHttpRequest, {}) this.configureTimeout(xmlHttpRequest) this.configureBinary(xmlHttpRequest) + this.configureAbort(xmlHttpRequest) xmlHttpRequest.send() } @@ -31,6 +32,7 @@ export class XHR extends Gateway { this.setHeaders(xmlHttpRequest, {}) this.configureTimeout(xmlHttpRequest) this.configureBinary(xmlHttpRequest) + this.configureAbort(xmlHttpRequest) xmlHttpRequest.send() } @@ -80,6 +82,21 @@ export class XHR extends Gateway { } } + configureAbort(xmlHttpRequest: XMLHttpRequest) { + const signal = this.request.signal() + if (signal) { + signal.addEventListener('abort', () => { + xmlHttpRequest.abort() + }) + xmlHttpRequest.addEventListener('abort', () => { + this.dispatchClientError( + 'The operation was aborted', + new Error('The operation was aborted') + ) + }) + } + } + configureCallbacks(xmlHttpRequest: XMLHttpRequest) { xmlHttpRequest.addEventListener('load', () => { if (this.canceled) { @@ -128,6 +145,7 @@ export class XHR extends Gateway { this.setHeaders(xmlHttpRequest, customHeaders) this.configureTimeout(xmlHttpRequest) this.configureBinary(xmlHttpRequest) + this.configureAbort(xmlHttpRequest) xmlHttpRequest.send(body) } diff --git a/src/manifest.ts b/src/manifest.ts index 2aed72c4..92e36209 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -22,18 +22,19 @@ export type ResourceTypeConstraint = { } export interface ManifestOptions { - host: string allowResourceHostOverride?: boolean - parameterEncoder?: ParameterEncoderFn - bodyAttr?: string - headersAttr?: string authAttr?: string - timeoutAttr?: string - hostAttr?: string + bodyAttr?: string clientId?: string gatewayConfigs?: Partial - resources?: Resources + headersAttr?: string + host: string + hostAttr?: string middleware?: Middleware[] + parameterEncoder?: ParameterEncoderFn + resources?: Resources + signalAttr?: string + timeoutAttr?: string /** * @deprecated - use `middleware` instead */ @@ -56,36 +57,38 @@ type CreateMiddlewareParams = Partial { - public host: string public allowResourceHostOverride: boolean - public parameterEncoder: ParameterEncoderFn - public bodyAttr?: string - public headersAttr?: string public authAttr?: string - public timeoutAttr?: string - public hostAttr?: string + public bodyAttr?: string public clientId: string | null - public gatewayConfigs: GatewayConfiguration - public resources: Resources public context: Context + public gatewayConfigs: GatewayConfiguration + public headersAttr?: string + public host: string + public hostAttr?: string public middleware: Middleware[] + public parameterEncoder: ParameterEncoderFn + public resources: Resources + public signalAttr?: string + public timeoutAttr?: string constructor( options: ManifestOptions, { gatewayConfigs, middleware = [], context = {} }: GlobalConfigs ) { - this.host = options.host this.allowResourceHostOverride = options.allowResourceHostOverride || false - this.parameterEncoder = options.parameterEncoder || encodeURIComponent - this.bodyAttr = options.bodyAttr - this.headersAttr = options.headersAttr this.authAttr = options.authAttr - this.timeoutAttr = options.timeoutAttr - this.hostAttr = options.hostAttr + this.bodyAttr = options.bodyAttr this.clientId = options.clientId || null + this.context = context this.gatewayConfigs = assign({}, gatewayConfigs, options.gatewayConfigs) + this.headersAttr = options.headersAttr + this.host = options.host + this.hostAttr = options.hostAttr + this.parameterEncoder = options.parameterEncoder || encodeURIComponent this.resources = options.resources || ({} as Resources) - this.context = context + this.signalAttr = options.signalAttr + this.timeoutAttr = options.timeoutAttr // TODO: deprecate obj.middlewares in favor of obj.middleware const clientMiddleware = options.middleware || options.middlewares || [] @@ -130,6 +133,7 @@ export class Manifest { authAttr: this.authAttr, timeoutAttr: this.timeoutAttr, hostAttr: this.hostAttr, + signalAttr: this.signalAttr, }, definition ) diff --git a/src/method-descriptor.ts b/src/method-descriptor.ts index 2d180c29..5d3b2890 100644 --- a/src/method-descriptor.ts +++ b/src/method-descriptor.ts @@ -3,7 +3,6 @@ import type { Middleware } from './middleware/index' export interface MethodDescriptorParams { allowResourceHostOverride?: boolean - parameterEncoder?: ParameterEncoderFn authAttr?: string binary?: boolean bodyAttr?: string @@ -14,10 +13,12 @@ export interface MethodDescriptorParams { method?: string middleware?: Array middlewares?: Array + parameterEncoder?: ParameterEncoderFn params?: Params path: string | ((args: RequestParams) => string) pathAttr?: string queryParamAlias?: Record + signalAttr?: string timeoutAttr?: string } @@ -40,11 +41,11 @@ export interface MethodDescriptorParams { * @param {String|Function} params.path * @param {String} params.pathAttr. Default: 'path' * @param {Object} params.queryParamAlias + * @param {Number} params.signalAttr - signal attribute name. Default: 'signal' * @param {Number} params.timeoutAttr - timeout attribute name. Default: 'timeout' */ export class MethodDescriptor { public readonly allowResourceHostOverride: boolean - public readonly parameterEncoder: ParameterEncoderFn public readonly authAttr: string public readonly binary: boolean public readonly bodyAttr: string @@ -54,19 +55,21 @@ export class MethodDescriptor { public readonly hostAttr: string public readonly method: string public readonly middleware: Middleware[] + public readonly parameterEncoder: ParameterEncoderFn public readonly params?: RequestParams public readonly path: string | ((args: RequestParams) => string) public readonly pathAttr: string public readonly queryParamAlias: Record + public readonly signalAttr: string public readonly timeoutAttr: string constructor(params: MethodDescriptorParams) { this.allowResourceHostOverride = params.allowResourceHostOverride || false - this.parameterEncoder = params.parameterEncoder || encodeURIComponent this.binary = params.binary || false this.headers = params.headers this.host = params.host this.method = params.method || 'get' + this.parameterEncoder = params.parameterEncoder || encodeURIComponent this.params = params.params this.path = params.path this.queryParamAlias = params.queryParamAlias || {} @@ -76,6 +79,7 @@ export class MethodDescriptor { this.headersAttr = params.headersAttr || 'headers' this.hostAttr = params.hostAttr || 'host' this.pathAttr = params.pathAttr || 'path' + this.signalAttr = params.signalAttr || 'signal' this.timeoutAttr = params.timeoutAttr || 'timeout' const resourceMiddleware = params.middleware || params.middlewares || [] diff --git a/src/request.spec.ts b/src/request.spec.ts index 4e40070c..ea59e749 100755 --- a/src/request.spec.ts +++ b/src/request.spec.ts @@ -177,6 +177,18 @@ describe('Request', () => { ) }) + it('ignores "special" params', async () => { + const abortController = new AbortController() + const request = new Request(methodDescriptor, { + ...requestParams, + timeout: 123, + signal: abortController.signal, + }) + expect(request.path()).toEqual( + '/path?param=request-value&method-desc-param=method-desc-value&request-param=request-value' + ) + }) + it('returns result of method descriptor path function', async () => { const methodDescriptor = new MethodDescriptor({ ...methodDescriptorArgs, @@ -734,6 +746,11 @@ describe('Request', () => { const request = new Request(methodDescriptor, { differentParam: 'abc123' }) expect(request.body()).toEqual('abc123') }) + + it('can return undefined', () => { + const request = new Request(methodDescriptor, { ...requestParams, body: undefined }) + expect(request.body()).toBeUndefined() + }) }) describe('#auth', () => { @@ -751,6 +768,11 @@ describe('Request', () => { const request = new Request(methodDescriptor, { differentAuthParam: authData }) expect(request.auth()).toEqual(authData) }) + + it('can return undefined', () => { + const request = new Request(methodDescriptor, { ...requestParams, auth: undefined }) + expect(request.auth()).toBeUndefined() + }) }) describe('#timeout', () => { @@ -767,6 +789,37 @@ describe('Request', () => { const request = new Request(methodDescriptor, { differentTimeoutParam: 1000 }) expect(request.timeout()).toEqual(1000) }) + + it('can return undefined', () => { + const request = new Request(methodDescriptor, { ...requestParams, timeout: undefined }) + expect(request.timeout()).toBeUndefined() + }) + }) + + describe('#signal', () => { + it('returns requestParams signal', async () => { + const abortController = new AbortController() + const params = { ...requestParams, signal: abortController.signal } + const request = new Request(methodDescriptor, params) + expect(request.signal()).toEqual(params.signal) + }) + + it('returns the configured signal param from params', () => { + const methodDescriptor = new MethodDescriptor({ + ...methodDescriptorArgs, + signalAttr: 'differentSignalParam', + }) + const abortController = new AbortController() + const request = new Request(methodDescriptor, { + differentSignalParam: abortController.signal, + }) + expect(request.signal()).toEqual(abortController.signal) + }) + + it('can return undefined', () => { + const request = new Request(methodDescriptor, requestParams) + expect(request.signal()).toBeUndefined() + }) }) describe('#isBinary', () => { @@ -782,6 +835,7 @@ describe('Request', () => { describe('#enhance', () => { it('returns a new request enhanced by current request', async () => { + const abortController = new AbortController() const request = new Request(methodDescriptor, requestParams) const extras = { auth: { 'enhanced-auth': 'enhanced-auth-value' }, @@ -790,6 +844,7 @@ describe('Request', () => { host: 'http://enhanced-host.com', params: { 'enhanced-param': 'enhanced-param-value' }, timeout: 100, + signal: abortController.signal, } expect(request.enhance(extras)).toEqual( new Request(methodDescriptor, { @@ -801,6 +856,7 @@ describe('Request', () => { host: extras.host, headers: { ...extras.headers, ...requestParams.headers }, timeout: 100, + signal: abortController.signal, }) ) }) @@ -850,6 +906,16 @@ describe('Request', () => { expect(enhancedRequest.timeout()).toEqual(2000) }) + it('creates a new request based on the current request replacing the signal', () => { + const abortController = new AbortController() + const abortController2 = new AbortController() + const request = new Request(methodDescriptor, { signal: abortController.signal }) + const enhancedRequest = request.enhance({ signal: abortController2.signal }) + expect(enhancedRequest).not.toEqual(request) + expect(enhancedRequest.signal()).toEqual(abortController2.signal) + expect(enhancedRequest.signal()).toEqual(abortController.signal) + }) + it('creates a new request based on the current request replacing the path', () => { const request = new Request({ ...methodDescriptor, params: {} }) const enhancedRequest = request.enhance({ path: '/new-path' }) @@ -875,6 +941,13 @@ describe('Request', () => { expect(enhancedRequest.timeout()).toEqual(1000) }) + it('does not remove the previously assigned "signal"', () => { + const abortController = new AbortController() + const request = new Request(methodDescriptor, { signal: abortController.signal }) + const enhancedRequest = request.enhance({}) + expect(enhancedRequest.signal()).toEqual(abortController.signal) + }) + it('does not remove the previously assigned "host" if allowResourceHostOverride=true', () => { const methodDescriptor = new MethodDescriptor({ ...methodDescriptorArgs, @@ -946,6 +1019,20 @@ describe('Request', () => { }) }) + describe('for requests with a different "signal" key', () => { + it('creates a new request based on the current request replacing the custom "signal"', () => { + const abortController = new AbortController() + const methodDescriptor = new MethodDescriptor({ + ...methodDescriptorArgs, + timeoutAttr: 'snowflake', + }) + const request = new Request(methodDescriptor, { snowflake: 1000 }) + const enhancedRequest = request.enhance({ signal: abortController.signal }) + expect(enhancedRequest).not.toEqual(request) + expect(enhancedRequest.signal()).toEqual(abortController.signal) + }) + }) + describe('for requests with a different "host" key', () => { it('creates a new request based on the current request replacing the custom "host"', () => { const methodDescriptor = new MethodDescriptor({ diff --git a/src/request.ts b/src/request.ts index f41f9eb3..48849689 100644 --- a/src/request.ts +++ b/src/request.ts @@ -49,6 +49,7 @@ export class Request { key !== this.methodDescriptor.authAttr && key !== this.methodDescriptor.timeoutAttr && key !== this.methodDescriptor.hostAttr && + key !== this.methodDescriptor.signalAttr && key !== this.methodDescriptor.pathAttr ) } @@ -238,15 +239,19 @@ export class Request { } public body() { - return this.requestParams[this.methodDescriptor.bodyAttr] as Body + return this.requestParams[this.methodDescriptor.bodyAttr] as Body | undefined } public auth() { - return this.requestParams[this.methodDescriptor.authAttr] as Auth + return this.requestParams[this.methodDescriptor.authAttr] as Auth | undefined } public timeout() { - return this.requestParams[this.methodDescriptor.timeoutAttr] as number + return this.requestParams[this.methodDescriptor.timeoutAttr] as number | undefined + } + + public signal() { + return this.requestParams[this.methodDescriptor.signalAttr] as AbortSignal | undefined } /** @@ -267,6 +272,7 @@ export class Request { const hostKey = this.methodDescriptor.hostAttr const timeoutKey = this.methodDescriptor.timeoutAttr const pathKey = this.methodDescriptor.pathAttr + const signalKey = this.methodDescriptor.signalAttr // Note: The result of merging an instance of RequestParams with instance of Params // is simply a RequestParams with even more [param: string]'s on it. @@ -281,6 +287,7 @@ export class Request { extras.host && (requestParams[hostKey] = extras.host) extras.timeout && (requestParams[timeoutKey] = extras.timeout) extras.path && (requestParams[pathKey] = extras.path) + extras.signal && (requestParams[signalKey] = extras.signal) const nextContext = { ...this.requestContext, ...requestContext } diff --git a/src/types.ts b/src/types.ts index fa5e0d9d..f5ee5dec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,6 +38,7 @@ export interface RequestParams { readonly path?: string readonly params?: Params readonly timeout?: number + readonly signal?: AbortSignal [param: string]: object | Primitive | undefined | null | NestedParam | NestedParamArray }