Skip to content

Commit

Permalink
Add beforeRequest hook (#516)
Browse files Browse the repository at this point in the history
  • Loading branch information
jstewmon authored and sindresorhus committed Jul 14, 2018
1 parent fb5185a commit 107756f
Show file tree
Hide file tree
Showing 6 changed files with 212 additions and 32 deletions.
70 changes: 38 additions & 32 deletions readme.md
Expand Up @@ -258,6 +258,24 @@ Determines if a `got.HTTPError` is thrown for error responses (non-2xx status co

If this is disabled, requests that encounter an error status code will be resolved with the `response` instead of throwing. This may be useful if you are checking for resource availability and are expecting error responses.

###### hooks

Type: `Object<string, Array<Function>>`<br>
Default: `{ beforeRequest: [] }`

Hooks allow modifications during the request lifecycle. Hook functions may be async and are run serially.

###### hooks.beforeRequest

Type: `Array<Function>`<br>
Default: `[]`

Called with the normalized request options. Got will make no further changes to the request before it is sent. This is especially useful in conjunction with [`got.extend()`](#instances) and [`got.create()`](advanced-creation.md) when you want to create an API client that uses HMAC-signing.

See the [AWS section](#aws) for an example.

**Note**: Modifying the `body` is not recommended because the `content-length` header has already been computed and assigned.

#### Streams

**Note**: Progress events, redirect events and request/response events can also be used with promises.
Expand Down Expand Up @@ -626,47 +644,35 @@ got('http://unix:/var/run/docker.sock:/containers/json');
got('unix:/var/run/docker.sock:/containers/json');
```


## AWS

Requests to AWS services need to have their headers signed. This can be accomplished by using the [`aws4`](https://www.npmjs.com/package/aws4) package. This is an example for querying an ["Elasticsearch Service"](https://aws.amazon.com/elasticsearch-service/) host with a signed request.
Requests to AWS services need to have their headers signed. This can be accomplished by using the [`aws4`](https://www.npmjs.com/package/aws4) package. This is an example for querying an ["API Gateway"](https://docs.aws.amazon.com/apigateway/api-reference/signing-requests/) with a signed request.

```js
const url = require('url');
const AWS = require('aws-sdk');
const aws4 = require('aws4');
const got = require('got');
const config = require('./config');

// Reads keys from the environment or `~/.aws/credentials`. Could be a plain object.
const awsConfig = new AWS.Config({ region: config.region });

function request(url, options) {
const awsOpts = {
region: awsConfig.region,
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
method: 'GET',
json: true
};

// We need to parse the URL before passing it to `got` so `aws4` can sign the request
options = {
...url.parse(url),
...awsOpts,
...options
};

aws4.sign(options, awsConfig.credentials);

return got(options);
}

request(`https://${config.host}/production/users/1`);
const credentials = await new AWS.CredentialProviderChain().resolvePromise();

// Create a Got instance to use relative paths and signed requests
const awsClient = got.extend(
{
baseUrl: 'https://<api-id>.execute-api.<api-region>.amazonaws.com/<stage>/',
hooks: {
beforeRequest: [
async options => {
await credentials.getPromise();
aws4.sign(options, credentials);
}
]
}
}
);

request(`https://${config.host}/production/`, {
// All usual `got` options
const response = await awsClient('endpoint/path', {
// Request-specific options
});
```

Expand Down
3 changes: 3 additions & 0 deletions source/index.js
Expand Up @@ -23,6 +23,9 @@ const defaults = {
throwHttpErrors: true,
headers: {
'user-agent': `${pkg.name}/${pkg.version} (https://github.com/sindresorhus/got)`
},
hooks: {
beforeRequest: []
}
}
};
Expand Down
27 changes: 27 additions & 0 deletions source/normalize-arguments.js
Expand Up @@ -8,6 +8,7 @@ const urlToOptions = require('./url-to-options');
const isFormData = require('./is-form-data');

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 @@ -185,5 +186,31 @@ module.exports = (url, options, defaults) => {
delete options.timeout;
}

if (is.nullOrUndefined(options.hooks)) {
options.hooks = {};
}
if (is.object(options.hooks)) {
for (const hookEvent of knownHookEvents) {
const hooks = options.hooks[hookEvent];
if (is.nullOrUndefined(hooks)) {
options.hooks[hookEvent] = [];
} else if (is.array(hooks)) {
hooks.forEach(
(hook, index) => {
if (!is.function_(hook)) {
throw new TypeError(
`Parameter \`hooks.${hookEvent}[${index}]\` must be a function, not ${is(hook)}`
);
}
}
);
} else {
throw new TypeError(`Parameter \`hooks.${hookEvent}\` must be an array, not ${is(hooks)}`);
}
}
} else {
throw new TypeError(`Parameter \`hooks\` must be an object, not ${is(options.hooks)}`);
}

return options;
};
5 changes: 5 additions & 0 deletions source/request-as-event-emitter.js
Expand Up @@ -241,6 +241,11 @@ module.exports = (options = {}) => {
options.headers['content-length'] = uploadBodySize;
}

for (const hook of options.hooks.beforeRequest) {
// eslint-disable-next-line no-await-in-loop
await hook(options);
}

get(options);
} catch (error) {
emitter.emit('error', error);
Expand Down
34 changes: 34 additions & 0 deletions test/arguments.js
Expand Up @@ -101,6 +101,40 @@ test('throws TypeError when `url` is passed as an option', async t => {
await t.throws(got({url: 'example.com'}), {instanceOf: TypeError});
});

test('throws TypeError when `hooks` is not an object', async t => {
await t.throws(
() => got(s.url, {hooks: 'not object'}),
{
instanceOf: TypeError,
message: 'Parameter `hooks` must be an object, not string'
}
);
});

test('throws TypeError when known `hooks` value is not an array', async t => {
await t.throws(
() => got(s.url, {hooks: {beforeRequest: {}}}),
{
instanceOf: TypeError,
message: 'Parameter `hooks.beforeRequest` must be an array, not Object'
}
);
});

test('throws TypeError when known `hooks` array item is not a function', async t => {
await t.throws(
() => got(s.url, {hooks: {beforeRequest: [{}]}}),
{
instanceOf: TypeError,
message: 'Parameter `hooks.beforeRequest[0]` must be a function, not Object'
}
);
});

test('allows extra keys in `hooks`', async t => {
await t.notThrows(() => got(`${s.url}/test`, {hooks: {extra: {}}}));
});

test.after('cleanup', async () => {
await s.close();
});
105 changes: 105 additions & 0 deletions test/hooks.js
@@ -0,0 +1,105 @@
import test from 'ava';
import delay from 'delay';
import {createServer} from './helpers/server';
import got from '..';

let s;

test.before('setup', async () => {
s = await createServer();
const echoHeaders = (req, res) => {
res.statusCode = 200;
res.write(JSON.stringify(req.headers));
res.end();
};
s.on('/', echoHeaders);
await s.listen(s.port);
});

test('beforeRequest receives normalized options', async t => {
await got(
s.url,
{
json: true,
hooks: {
beforeRequest: [
options => {
t.is(options.path, '/');
t.is(options.hostname, 'localhost');
}
]
}
}
);
});

test('beforeRequest allows modifications', async t => {
const res = await got(
s.url,
{
json: true,
hooks: {
beforeRequest: [
options => {
options.headers.foo = 'bar';
}
]
}
}
);
t.is(res.body.foo, 'bar');
});

test('beforeRequest awaits async function', async t => {
const res = await got(
s.url,
{
json: true,
hooks: {
beforeRequest: [
async options => {
await delay(100);
options.headers.foo = 'bar';
}
]
}
}
);
t.is(res.body.foo, 'bar');
});

test('beforeRequest rejects when beforeRequest throws', async t => {
await t.throws(
() => got(s.url, {
hooks: {
beforeRequest: [
() => {
throw new Error('oops');
}
]
}
}),
{
instanceOf: Error,
message: 'oops'
}
);
});

test('beforeRequest rejects when beforeRequest rejects', async t => {
await t.throws(
() => got(s.url, {
hooks: {
beforeRequest: [() => Promise.reject(new Error('oops'))]
}
}),
{
instanceOf: Error,
message: 'oops'
}
);
});

test.after('cleanup', async () => {
await s.close();
});

0 comments on commit 107756f

Please sign in to comment.