Skip to content

Commit

Permalink
Add support for AbortSignal (#557)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
klippx committed Sep 12, 2024
1 parent 3139fea commit 3a3c092
Show file tree
Hide file tree
Showing 13 changed files with 400 additions and 58 deletions.
25 changes: 25 additions & 0 deletions .changeset/sharp-vans-approve.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

### <a name="abort-signal"></a> 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.

### <a name="alternative-host"></a> 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:
Expand Down Expand Up @@ -1184,7 +1213,7 @@ describe('Feature', () => {
## <a name="gateways"></a> 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:
Expand Down
40 changes: 36 additions & 4 deletions spec/integration/browser/integration.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
})
})
})
})
})
112 changes: 97 additions & 15 deletions spec/integration/node/integration.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 = {}

Expand All @@ -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))
Expand Down Expand Up @@ -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) => {
Expand All @@ -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)}`)
Expand All @@ -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()
})
})
})
})
})
47 changes: 47 additions & 0 deletions src/client-builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 5 additions & 4 deletions src/gateway/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class Fetch extends Gateway {
this.performRequest('delete')
}

performRequest(method: Method) {
performRequest(requestMethod: Method) {
const fetch = configs.fetch

if (!fetch) {
Expand All @@ -47,7 +47,7 @@ export class Fetch extends Gateway {
}

const customHeaders: Record<string, string> = {}
const body = this.prepareBody(method, customHeaders) as BodyInit
const body = this.prepareBody(requestMethod, customHeaders) as BodyInit
const auth = this.request.auth()

if (auth) {
Expand All @@ -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<typeof setTimeout> | null = null
Expand Down
Loading

0 comments on commit 3a3c092

Please sign in to comment.