Skip to content

Commit

Permalink
Add ability to merge instances (#510)
Browse files Browse the repository at this point in the history
  • Loading branch information
szmarczak authored and sindresorhus committed Aug 10, 2018
1 parent ae5b114 commit f0b190a
Show file tree
Hide file tree
Showing 10 changed files with 337 additions and 44 deletions.
167 changes: 146 additions & 21 deletions advanced-creation.md
Expand Up @@ -14,14 +14,6 @@ Configure a new `got` instance with the provided settings.<br>
To inherit from parent, set it as `got.defaults.options` or use [`got.mergeOptions(defaults.options, options)`](readme.md#gotmergeoptionsparentoptions-newoptions).<br>
**Note**: Avoid using [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#Spread_in_object_literals) as it doesn't work recursively.

##### methods

Type: `Object`

An array of supported request methods.

To inherit from parent, set it as `got.defaults.methods`.

##### handler

Type: `Function`<br>
Expand Down Expand Up @@ -53,7 +45,6 @@ const settings = {
// It's a Promise
return next(options);
},
methods: got.defaults.methods,
options: got.mergeOptions(got.defaults.options, {
json: true
})
Expand All @@ -64,31 +55,42 @@ const jsonGot = got.create(settings);

```js
const defaults = {
handler: (options, next) => next(options),
methods: [
'get',
'post',
'put',
'patch',
'head',
'delete'
],
options: {
retries: 2,
retry: {
retries: 2,
methods: [
'get',
'put',
'head',
'delete',
'options',
'trace'
],
statusCodes: [
408,
413,
429,
502,
503,
504
]
},
cache: false,
decompress: true,
useElectronNet: false,
throwHttpErrors: true,
headers: {
'user-agent': `${pkg.name}/${pkg.version} (https://github.com/sindresorhus/got)`
},
hooks: {
beforeRequest: []
}
}
};

// Same as:
const defaults = {
handler: got.defaults.handler,
methods: got.defaults.methods,
options: got.defaults.options
};

Expand All @@ -98,7 +100,6 @@ const unchangedGot = got.create(defaults);
```js
const settings = {
handler: got.defaults.handler,
methods: got.defaults.methods,
options: got.mergeOptions(got.defaults.options, {
headers: {
unicorn: 'rainbow'
Expand All @@ -111,3 +112,127 @@ const unicorn = got.create(settings);
// Same as:
const unicorn = got.extend({headers: {unicorn: 'rainbow'}});
```

### Merging instances

Got supports composing multiple instances together. This is very powerful. You can create a client that limits download speed and then compose it with an instance that signs a request. It's like plugins without any of the plugin mess. You just create instances and then compose them together.

#### got.mergeInstances(instanceA, instanceB, ...)

Merges many instances into a single one:
- options are merged using [`got.mergeOptions()`](readme.md#gotmergeoptionsparentoptions-newoptions) (+ hooks are merged too),
- handlers are stored in an array.

## Examples

Some examples of what kind of instances you could compose together:

#### Denying redirects that lead to other sites than specified

```js
const controlRedirects = got.create({
options: got.defaults.options,
handler: (options, next) => {
const promiseOrStream = next(options);
return promiseOrStream.on('redirect', resp => {
const host = new URL(resp.url).host;
if (options.allowedHosts && !options.allowedHosts.includes(host)) {
promiseOrStream.cancel(`Redirection to ${host} is not allowed`);
}
});
}
});
```

#### Limiting download & upload

It's very useful in case your machine's got a little amount of RAM.

```js
const limitDownloadUpload = got.create({
options: got.defaults.options,
handler: (options, next) => {
let promiseOrStream = next(options);
if (typeof options.downloadLimit === 'number') {
promiseOrStream.on('downloadProgress', progress => {
if (progress.transferred > options.downloadLimit && progress.percent !== 1) {
promiseOrStream.cancel(`Exceeded the download limit of ${options.downloadLimit} bytes`);
}
});
}

if (typeof options.uploadLimit === 'number') {
promiseOrStream.on('uploadProgress', progress => {
if (progress.transferred > options.uploadLimit && progress.percent !== 1) {
promiseOrStream.cancel(`Exceeded the upload limit of ${options.uploadLimit} bytes`);
}
});
}

return promiseOrStream;
}
});
```

#### No user agent

```js
const noUserAgent = got.extend({
headers: {
'user-agent': null
}
});
```

#### Custom endpoint

```js
const httpbin = got.extend({
baseUrl: 'https://httpbin.org/'
});
```

#### Signing requests

```js
const crypto = require('crypto');
const getMessageSignature = (data, secret) => crypto.createHmac('sha256', secret).update(data).digest('hex').toUpperCase();
const signRequest = got.extend({
hooks: {
beforeRequest: [
options => {
options.headers['sign'] = getMessageSignature(options.body || '', process.env.SECRET);
}
]
}
});
```

#### Putting it all together

If these instances are different modules and you don't want to rewrite them, use `got.mergeInstances()`.

**Note**: The `noUserAgent` instance must be placed at the end of chain as the instances are merged in order. Other instances do have the `user-agent` header.

```js
const merged = got.mergeInstances(controlRedirects, limitDownloadUpload, httpbin, signRequest, noUserAgent);

(async () => {
// There's no 'user-agent' header :)
await merged('/');
/* HTTP Request =>
* GET / HTTP/1.1
* accept-encoding: gzip, deflate
* sign: F9E66E179B6747AE54108F82F8ADE8B3C25D76FD30AFDE6C395822C530196169
* Host: httpbin.org
* Connection: close
*/

const MEGABYTE = 1048576;
await merged('http://ipv4.download.thinkbroadband.com/5MB.zip', {downloadLimit: MEGABYTE});
// CancelError: Exceeded the download limit of 1048576 bytes

await merged('https://jigsaw.w3.org/HTTP/300/301.html', {allowedHosts: ['google.com']});
// CancelError: Redirection to jigsaw.w3.org is not allowed
})();
```
3 changes: 3 additions & 0 deletions readme.md
Expand Up @@ -35,6 +35,7 @@ It was created because the popular [`request`](https://github.com/request/reques
- [WHATWG URL support](#url)
- [Electron support](#useelectronnet)
- [Instances with custom defaults](#instances)
- [Composable](advanced-creation.md#merging-instances)
- [Used by ~2000 packages and ~500K repos](https://github.com/sindresorhus/got/network/dependents)
- Actively maintained

Expand Down Expand Up @@ -796,6 +797,8 @@ const custom = got.extend({
})();
```

*Need to merge some instances into a single one? Check out [`got.mergeInstances()`](advanced-creation.md#merging-instances).*


## Related

Expand Down
16 changes: 13 additions & 3 deletions source/create.js
Expand Up @@ -5,10 +5,20 @@ const asPromise = require('./as-promise');
const normalizeArguments = require('./normalize-arguments');
const merge = require('./merge');
const deepFreeze = require('./deep-freeze');
const mergeInstances = require('./merge-instances');

const next = options => options.stream ? asStream(options) : asPromise(options);
const mergeOptions = (defaults, options = {}) => merge({}, defaults, options);

const aliases = [
'get',
'post',
'put',
'patch',
'head',
'delete'
];

const create = defaults => {
defaults = merge({}, defaults);
if (!defaults.handler) {
Expand All @@ -31,18 +41,18 @@ const create = defaults => {
got.create = create;
got.extend = (options = {}) => create({
options: mergeOptions(defaults.options, options),
methods: defaults.methods,
handler: defaults.handler
});

got.mergeInstances = (...args) => create(mergeInstances(args));

got.stream = (url, options) => {
options = mergeOptions(defaults.options, options);
options.stream = true;
return defaults.handler(normalizeArguments(url, options, defaults), next);
};

const methods = defaults.methods.map(method => method.toLowerCase());
for (const method of methods) {
for (const method of aliases) {
got[method] = (url, options) => got(url, {...options, method});
got.stream[method] = (url, options) => got.stream(url, {...options, method});
}
Expand Down
8 changes: 0 additions & 8 deletions source/index.js
Expand Up @@ -3,14 +3,6 @@ const pkg = require('../package.json');
const create = require('./create');

const defaults = {
methods: [
'GET',
'POST',
'PUT',
'PATCH',
'HEAD',
'DELETE'
],
options: {
retry: {
retries: 2,
Expand Down
3 changes: 3 additions & 0 deletions source/known-hook-events.js
@@ -0,0 +1,3 @@
'use strict';

module.exports = ['beforeRequest'];
42 changes: 42 additions & 0 deletions source/merge-instances.js
@@ -0,0 +1,42 @@
'use strict';
const merge = require('./merge');
const knownHookEvents = require('./known-hook-events');

module.exports = (instances, methods) => {
const handlers = instances.map(instance => instance.defaults.handler);
const size = instances.length - 1;

let options = {};
const hooks = {};
for (const instance of instances) {
options = merge({}, options, instance.defaults.options);

const instanceHooks = instance.defaults.options.hooks;
if (instanceHooks) {
for (const name of knownHookEvents) {
if (!instanceHooks[name]) {
continue;
}

if (hooks[name]) {
hooks[name] = hooks[name].concat(instanceHooks[name]);
} else {
hooks[name] = [...instanceHooks[name]];
}
}
}
}

options.hooks = hooks;

return {
methods,
options,
handler: (options, next) => {
let iteration = -1;
const iterate = options => handlers[++iteration](options, iteration === size ? next : iterate);

return iterate(options);
}
};
};
10 changes: 9 additions & 1 deletion source/normalize-arguments.js
Expand Up @@ -9,9 +9,9 @@ const urlParseLax = require('url-parse-lax');
const isRetryOnNetworkErrorAllowed = require('./is-retry-on-network-error-allowed');
const urlToOptions = require('./url-to-options');
const isFormData = require('./is-form-data');
const knownHookEvents = require('./known-hook-events');

const retryAfterStatusCodes = new Set([413, 429, 503]);
const knownHookEvents = ['beforeRequest'];

module.exports = (url, options, defaults) => {
if (Reflect.has(options, 'url') || (is.object(url) && Reflect.has(url, 'url'))) {
Expand Down Expand Up @@ -51,6 +51,14 @@ module.exports = (url, options, defaults) => {
...options
};

const {baseUrl} = options;
Object.defineProperty(options, 'baseUrl', {
set: () => {
throw new Error('Failed to set baseUrl. Options are normalized already.');
},
get: () => baseUrl
});

if (options.stream && options.json) {
options.json = false;
}
Expand Down
12 changes: 12 additions & 0 deletions test/arguments.js
Expand Up @@ -170,3 +170,15 @@ test('throws TypeError when known `hooks` array item is not a function', async t
test('allows extra keys in `hooks`', async t => {
await t.notThrows(() => got(`${s.url}/test`, {hooks: {extra: {}}}));
});

test('throws when trying to modify baseUrl after options got normalized', async t => {
const instanceA = got.create({
methods: [],
options: {baseUrl: 'https://example.com'},
handler: options => {
options.baseUrl = 'https://google.com';
}
});

await t.throws(instanceA('/'), 'Failed to set baseUrl. Options are normalized already.');
});

0 comments on commit f0b190a

Please sign in to comment.