Skip to content

Commit

Permalink
Merge pull request #19 from jmeas/updates
Browse files Browse the repository at this point in the history
Updates
jamesplease authored Feb 4, 2018

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
2 parents fa89765 + f045d5d commit ebbf5b6
Showing 3 changed files with 163 additions and 45 deletions.
98 changes: 67 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
@@ -11,9 +11,8 @@ that prevents duplicate requests.

### Motivation

A common feature of libraries or frameworks that make HTTP requests is that they deduplicate
requests that are exactly the same. I find that deduplicating requests is a useful feature
that makes sense as a standalone lib.
A common feature of libraries or frameworks that build abstractions around HTTP requests is that
they deduplicate requests that are exactly the same. This library extracts that functionality.

### Installation

@@ -31,50 +30,35 @@ yarn add fetch-dedupe

### Getting Started

This example demonstrates using fetch-dedupe with the
This example demonstrates using Fetch Dedupe with the
[ES2015 module syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import).

```js
import { getRequestKey, fetchDedupe } from 'fetch-dedupe';

const url = '/test/2';
const fetchOptions = {
method: 'GET',
method: 'PATCH',
body: JSON.stringify({ a: 12 })
};

// First, build a request key. A request key is a unique string
// that identifies the request.
// `getRequestKey` is a built-in key generator that will work for
// most situations, although you can make your own.
const requestKey = getRequestKey({
url,
...fetchOptions
});

// The API of `fetchDedupe` is the same as fetch, except that it
// has an additional argument. Pass the `requestKey` in that
// third argument
fetchDedupe(url, fetchOptions, {
requestKey,
responseType: 'json'
}).then(res => {
fetchDedupe(url, fetchOptions, {responseType: 'json'}).then(res => {
console.log('Got some data', res.data);
});

// Additional requests are deduped. Nifty.
fetchDedupe(url, fetchOptions, {
requestKey,
responseType: 'json'
}).then(res => {
fetchDedupe(url, fetchOptions, {responseType: 'json'}).then(res => {
console.log('Got some data', res.data);
});
```

#### Important: Read this!

Note that with `fetch`, you usually read the body yourself. Fetch Dedupe reads the body
for you, so you **cannot** do it, or else an error will be thrown.
When using `fetch`, you typically read the body yourself by calling, say, `.json()` on the
response. Fetch Dedupe reads the body for you, so you **cannot** do it, or else an error
will be thrown.

```js
// Normal usage of `fetch`:
@@ -103,21 +87,54 @@ This library exports the following methods:
- `isRequestInFlight()`
- `clearRequestCache()`

##### `fetchDedupe( input, init, dedupeOptions )`
##### `fetchDedupe( input [, init], dedupeOptions )`

A wrapper around `global.fetch()`. The first two arguments are the same ones that you're used to.
Refer to
[the fetch() documentation on MDN](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)
for more.

Note that `init` is optional, as with `global.fetch()`.

The third option is `dedupeOptions`. This is an object with three attributes:

* `requestKey`: A string that is used to determine if two requests are identical. Required.
* `responseType`: Any of the methods from [the Body mixin](https://developer.mozilla.org/en-US/docs/Web/API/Body).
Typically, you will want to use `json`. Required.
* `requestKey`: A string that is used to determine if two requests are identical. You may pass this
to configure how the request key is generated. Optional.
* `dedupe`: Whether or not to dedupe the request. Pass `false` and it will be as if this library
was not even being used. Defaults to `true`.

Given the two possible value types of `input`, optional second argument, there are a way few ways that you can
call `fetchDedupe`. Let's run through valid calls to `fetchDedupe`:

```js
import { fetchDedupe } from 'fetch-dedupe';

// Omitting `init` and using a URL string as `input`
fetchDedupe('/test/2', {responseType: 'json'});

// Using a URL string as `input`, with numerous `init` configurations
// and specifying several `dedupeOptions`
fetchDedupe('/test/2', {
method: 'PATCH',
body: JSON.stringify({value: true}),
credentials: 'include'
}, {
responseType: 'json',
requestKey: generateCustomKey(opts),
dedupe: false
})

// Omitting `init` and using a Request as `input`
const req = new Request('/test/2');
fetchDedupe(req, {responseType: 'json'});

// Request as `input` with an `init` object. Note that the `init`
// object takes precedence over the Request values.
fetchDedupe(req, {method: 'PATCH'}, {responseType: 'json'});
```

##### `getRequestKey({ url, method, responseType, body })`

Returns a unique request key based on the passed-in values. All of the values,
@@ -126,10 +143,10 @@ including `body`, must be strings.
Every value is optional, but the deduplication logic is improved by adding the
most information that you can.

> Note: The method is case-insensitive.
> Note: The `method` option is case-insensitive.
> Note: You don't need to use this method. You can generate a key in whatever way that you want. This
should work for most use cases, though.
> Note: You do not need to use this method to generate a request key. You can generate the key
in whatever way that you want. This should work for most use cases, though.

```js
import { getRequestKey } from 'fetch-dedupe';
@@ -146,6 +163,9 @@ const keyTwo = getRequestKey({
title: 'My Name is Red'
})
});

keyOne === keyTwo;
// => false
```

##### `isRequestInFlight( requestKey )`
@@ -166,6 +186,10 @@ const key = getRequestKey({
const readingBooksAlready = isRequestInFlight(key);
```

> Now: We **strongly** recommend that you manually pass in `requestKey` to `fetchDedupe`
if you intend to use this method. In other words, _do not_ rely on being able to
reliably reproduce the request key that is created when a `requestKey` is not passed in.

##### `clearRequestCache()`

Wipe the cache of in-flight requests.
@@ -174,7 +198,7 @@ Wipe the cache of in-flight requests.
### FAQ & Troubleshooting

##### An empty response throws an error
##### An empty response is throwing an error, what gives?

Empty text strings are not valid JSON.

@@ -204,3 +228,15 @@ know what its content type is.

Just strings for now, which should work for the majority of APIs. Support for other body types
is in the works.

### Implementors

These are projects that build abstractions around HTTP requests using Fetch Dedupe under the hood.

- [React Request](https://github.com/jmeas/react-request)

Are you using it on a project? Add it to this list by opening a Pull Request

### Acknowledgements

[Apollo](https://www.apollographql.com/) inspired me to write this library.
40 changes: 32 additions & 8 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@ export function clearRequestCache() {
// This loops through all of the handlers for the request and either
// resolves or rejects them.
function resolveRequest({ requestKey, res, err }) {
const handlers = requests[requestKey];
const handlers = requests[requestKey] || [];

handlers.forEach(handler => {
if (res) {
@@ -43,15 +43,39 @@ function resolveRequest({ requestKey, res, err }) {
export function fetchDedupe(
input,
init,
{ requestKey, responseType, dedupe = true }
dedupeOptions
) {
let opts, initToUse;
if (dedupeOptions) {
opts = dedupeOptions;
initToUse = init;
} else if (init && init.responseType) {
opts = init;
initToUse = {};
} else {
throw new Error('dedupeOptions are required.')
}

const { requestKey, responseType = '', dedupe = true } = opts;

// Build the default request key if one is not passed
let requestKeyToUse = requestKey || getRequestKey({
// If `input` is a request, then we use that URL
url: input.url || input,
// We prefer values from `init` over request objects. With `fetch()`, init
// takes priority over a passed-in request
method: initToUse.method || input.method || '',
body: initToUse.body || input.body || '',
responseType: responseType
});

let proxyReq;
if (dedupe) {
if (!requests[requestKey]) {
requests[requestKey] = [];
if (!requests[requestKeyToUse]) {
requests[requestKeyToUse] = [];
}

const handlers = requests[requestKey];
const handlers = requests[requestKeyToUse];
const requestInFlight = Boolean(handlers.length);
const requestHandler = {};
proxyReq = new Promise((resolve, reject) => {
@@ -66,7 +90,7 @@ export function fetchDedupe(
}
}

const request = fetch(input, init).then(
const request = fetch(input, initToUse).then(
res => {
// The response body is a ReadableStream. ReadableStreams can only be read a single
// time, so we must handle that in a central location, here, before resolving
@@ -75,15 +99,15 @@ export function fetchDedupe(
res.data = data;

if (dedupe) {
resolveRequest({ requestKey, res });
resolveRequest({ requestKey: requestKeyToUse, res });
} else {
return res;
}
});
},
err => {
if (dedupe) {
resolveRequest({ requestKey, err });
resolveRequest({ requestKey: requestKeyToUse, err });
} else {
return Promise.reject(err);
}
70 changes: 64 additions & 6 deletions test/index.test.js
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ function hangingPromise() {
}

fetchMock.get('/test/hangs', hangingPromise());
fetchMock.get('/test/hangs/2', hangingPromise());

describe('isRequestInFlight', () => {
test('renders false when it is not in flight', () => {
@@ -107,19 +108,76 @@ describe('getRequestKey', () => {

describe('fetchDedupe', () => {
test('only calls fetch once for duplicate requests', () => {
fetchDedupe('/test/hangs', {}, { requestKey: 'pasta' });
fetchDedupe('/test/hangs', {}, { requestKey: 'pasta' });
fetchDedupe('/test/hangs', {}, { requestKey: 'pasta' });
fetchDedupe('/test/hangs', {}, { requestKey: 'pasta', responseType: 'json' });
fetchDedupe('/test/hangs', {}, { requestKey: 'pasta', responseType: 'json' });
fetchDedupe('/test/hangs', {}, { requestKey: 'pasta', responseType: 'json' });
expect(fetchMock.calls('/test/hangs').length).toBe(1);
});

test('respects the dedupe:false option', () => {
fetchDedupe('/test/hangs', {}, { requestKey: 'pasta', dedupe: false });
fetchDedupe('/test/hangs', {}, { requestKey: 'pasta' });
fetchDedupe('/test/hangs', {}, { requestKey: 'pasta' });
fetchDedupe('/test/hangs', {}, { requestKey: 'pasta', dedupe: false, responseType: 'json' });
fetchDedupe('/test/hangs', {}, { requestKey: 'pasta', responseType: 'json' });
fetchDedupe('/test/hangs', {}, { requestKey: 'pasta', responseType: 'json' });
expect(fetchMock.calls('/test/hangs').length).toBe(2);
});

test('allows for optional init', () => {
fetchDedupe('/test/hangs', { requestKey: 'pasta', responseType: 'json' });
fetchDedupe('/test/hangs', { requestKey: 'pasta', responseType: 'json' });
fetchDedupe('/test/hangs', { requestKey: 'pasta', responseType: 'json' });
expect(fetchMock.calls('/test/hangs').length).toBe(1);
});

test('allows for optional request key', () => {
fetchDedupe('/test/hangs', { responseType: 'json' });
fetchDedupe('/test/hangs', { responseType: 'json' });
fetchDedupe('/test/hangs', { responseType: 'json' });
expect(fetchMock.calls('/test/hangs').length).toBe(1);
});

test('allows for optional request key, still producing a unique key', () => {
// First request to /test/hangs/2
fetchDedupe('/test/hangs', { responseType: 'json' });
fetchDedupe('/test/hangs', { responseType: 'json' });

// First request to /test/hangs
fetchDedupe('/test/hangs/2', { responseType: 'json' });
fetchDedupe('/test/hangs/2', { responseType: 'json' });

// Second request to /test/hangs/2
fetchDedupe('/test/hangs/2', {body: 'hello'}, { responseType: 'json' });

expect(fetchMock.calls('/test/hangs').length).toBe(1);
expect(fetchMock.calls('/test/hangs/2').length).toBe(2);
});

test('throws with no dedupeOptions', () => {
expect(() => {
fetchDedupe('/test/hangs');
}).toThrow();
});

test('supports a Request as input', () => {
const req = new Request('/test/hangs', {
method: 'GET'
});
fetchDedupe(req, { responseType: 'json' });
fetchDedupe(req, { responseType: 'json' });
fetchDedupe(req, { responseType: 'json' });
expect(fetchMock.calls('/test/hangs').length).toBe(1);
});

test('prefers init values with request values', () => {
const req = new Request('/test/hangs', {
method: 'GET',
body: 'what'
});

fetchDedupe(req, {method: 'PATCH', body: 'ok'}, { responseType: 'json' });
fetchDedupe('/test/hangs', {method: 'PATCH', body: 'ok'}, { responseType: 'json' });
expect(fetchMock.calls('/test/hangs').length).toBe(1);
});

test('non-deduped requests that succeed to behave as expected', done => {
fetchMock.get(
'/test/succeeds',

0 comments on commit ebbf5b6

Please sign in to comment.