Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add beforeRequest hook #516

Merged
merged 12 commits into from
Jul 14, 2018
Merged

Add beforeRequest hook #516

merged 12 commits into from
Jul 14, 2018

Conversation

jstewmon
Copy link
Contributor

This PR build on the leverage of being able to create got instances (thanks @knksmith57!) by adding a finalize option, which eases the use of HMAC request signatures, such as AWS V4.

I hope the updated example in the readme makes the motivation clear.

I explored implementing this using an event, but it works better if the hook can be an async function (also demonstrated in the readme).

Along the way, I made a couple of tweaks to other areas of the code, which I included here in separate commits. I can pull those off if they're not wanted, but I thought they were small enough that it would just be easier to propose them alongside this new feature.

@szmarczak
Copy link
Collaborator

Note: creating got instances is still in the works.

Seems good, but I don't see any sense of that. You could just do:

const sign = ...;

const instance = got.create({
	options: got.defaults.options,
	methods: got.defaults.methods,
	handler: (url, options, next) => {
		options.headers['sign'] = sign(options);

		return next(url, options);
	}
});

Along the way, I made a couple of tweaks to other areas of the code, which I included here in separate commits.

Can you do another PR for that? To make everything more clear :)

@@ -228,6 +228,9 @@ module.exports = (options = {}) => {
options.headers['content-length'] = uploadBodySize;
}

if (is.function(options.finalize)) {
await options.finalize(options);
}
Copy link
Collaborator

@szmarczak szmarczak Jul 11, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If got.create wasn't existing, then it'd be very helpful. I think this option is useless.

Copy link
Contributor Author

@jstewmon jstewmon Jul 11, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry, but we are completely misunderstanding each other.

If I understand your suggestion regarding a handler, then a custom handler would run before the default handler.

The entire point of this feature is to create an opportunity to add a signature after the request has been normalized, just before it is sent.

Here's a little test that might illustrate the difference:

test('does something useful', async t => {
	const instance = got.create({
		baseUrl: `${s.url}/api/`,
		options: got.defaults.options,
		methods: got.defaults.methods,
		handler: (url, options, next) => {
			t.is(options.hostname, 'localhost')
			t.is(options.path, '/api/?foo=bar')
			return next(url, options);
		}
	});
	await instance('?foo=bar');
});

result:


  1 test failed

  does something useful

  /Users/jstewmon/dev/forks/got/test/finalize.js:95

   94:     handler: (url, options, next) => {
   95:       t.is(options.hostname, 'example.com')
   96:       t.is(options.path, '/api/?foo=bar')

  Difference:

  - undefined
  + 'example.com'

  Object.handler (test/finalize.js:95:6)
  got (source/create.js:34:20)
  test/finalize.js:100:8

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying this out :)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be .on('beforeRequest', ...). Options may be overridden.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@szmarczak We could handle that while merging options if needed. if beforeRequest is set and we're merging in options with another beforeRequest, we just wrap it in a function that calls both.

I don't like events that allow mutation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered doing that in assignOptions, but I decided that it was better to leave that choice to the caller, since I think it is pretty easy:

const got = require('got');
const a = got.extend({beforeRequest: opts => { opts.header.foo = 'bar'; }});
const b = a.extend({beforeRequest: opts => { a.defaults.beforeRequest(opts); opts.header.bar = 'foo'; }});

If the functions are chained, I don't think there's a convenient way to replace the beforeRequest option if that's the behavior one desires.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we just wrap it in a function that calls both.

@sindresorhus We need to be strict here. Only for beforeRequest and retries.

I decided that it was better to leave that choice to the caller, since I think it is pretty easy

It's OK for now. Lots of things is gonna change if #510 gets merged. #510 needs a lot of polishing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a look at #510 and had the same initial reaction as others - I don't get it. :-)

Personally, I don't see providing merge/extend features as creating a lot of leverage. Since the options are accessible on a got instance, the caller can always use a merge strategy of their choosing to create the configuration for a new client. If it were my choice, I'd probably just provide Object.assign({}, old, new) semantics for creating new instances.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jstewmon It's not as easy as it seems to be ;) That's for the future: Got plugins.

readme.md Outdated
@@ -243,6 +243,15 @@ 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.

###### finalize
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think beforeRequest would be a clearer name.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm ok with that. I chose finalize to indicate that this is the very last thing that happens before the request is sent.

I'll rename.

@jstewmon jstewmon changed the title add finalize option to ease request signing add beforeRequest option to ease request signing Jul 11, 2018
{
json: true,
beforeRequest: async options => {
return new Promise(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the delay module (we already have it as a devDependency here).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I didn't see it. Thanks and done!

@sindresorhus sindresorhus changed the title add beforeRequest option to ease request signing Add beforeRequest option Jul 11, 2018
readme.md Outdated
Type: `Function`<br>
Default: `undefined`

Called with the normalized request options just before the request is sent. You can modify the object. 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.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth noting that changing the body is not recommended, since the content-length has already been set?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

@brandon93s
Copy link
Contributor

This is similar in objective to interceptors in axios. It's also a great place to add support for plugins, or hooks. An API like the following would allow us to expand this to different hooks and provide support for multiple hooks at once:

const plugin = got.hooks.beforeRequest.add(async req => {

});

got.hooks.beforeRequest.remove(plugin);

Hooks here could be things like:

  • Authentication
  • Logging / Debugging
    • For example: chalk() of network activity in development mode
  • Etc

I support the current implementation, but wanted to bring up the alternative above if we want to future proof the implementation.

@jstewmon
Copy link
Contributor Author

I actually ended up coming back to got for this after trying to solve the problem with axios interceptors. :-)

axios request interceptors run before their dispatchRequest method, which is where they normalize the request.

But, that's not your point...

I considered allowing the config setting an array or a function, but I decided that it was best to start the conversation around the feature by making it as simple as possible.

If we allow the setting to be an array (without providing a custom interface), I think that might provide better mechanics for anyone wanting to apply a custom merge strategy to the options for a new instance (versus the function wrapping discussed earlier).

If it's conceivable that additional hook points would be added in the future, then we might consider having a top-level hooks setting, which is an object where they keys are the hook names and the values are Array|Function.

@szmarczak
Copy link
Collaborator

szmarczak commented Jul 12, 2018

@brandon93s +1. ~~~But how do hooks compare to EventEmitter? What's the advantage?~~~

@sindresorhus
Copy link
Owner

sindresorhus commented Jul 12, 2018

@szmarczak Events are meant as one-way notifications. Hooks let us transform.

@jstewmon
Copy link
Contributor Author

I rebased off master and updated the interface to reflect the discussion about future-proofing the configuration interface for additional hook events.

readme.md Outdated
}

request(`https://${config.host}/production/users/1`);
// Create a Got instance to use relative paths and signed requests
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what I wanted! A real example :) Now I know the advantage of beforeRequest. It's always before request. No need to merge anything (you could, but you now, if you merge two merged groups the order is not what you wanted, so beforeRequest does that job 🎉 ) Cool 🦄

readme.md Outdated

request(`https://${config.host}/production/`, {
// All usual `got` options
const response = await awsClient('/endpoint/path', {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace:

const response = await awsClient('/endpoint/path', {
	// Request-specific options
});

With:

const response = await awsClient('/endpoint/path', options);

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@szmarczak I don't think we should add variables that are not defined in the example. Ideally, examples should be copy-paste runnnable. I also think the comment helps make it clearer.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay then :)

@@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would do: await hook(options); // eslint-disable-line no-await-in-loop

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@szmarczak Let's try not to nitpick too much. The super minor stuff we can just fix ourselves when merging.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just want to note that we should keep the style of coding. That's all.

@@ -23,6 +23,9 @@ const defaults = {
throwHttpErrors: true,
headers: {
'user-agent': `${pkg.name}/${pkg.version} (https://github.com/sindresorhus/got)`
},
hooks: {
beforeRequest: []
Copy link
Collaborator

@szmarczak szmarczak Jul 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just want to note: got.create({options: {}, methods: [], handler: () => {}}) shouldn't throw. It'd be better if hooks were done like @brandon93s has suggested:

An API like the following would allow us to expand this to different hooks and provide support for multiple hooks at once:

const plugin = got.hooks.beforeRequest.add(async req => {

});

got.hooks.beforeRequest.remove(plugin);

Hooks here could be things like:

  • Authentication
  • Logging / Debugging
    • For example: chalk() of network activity in development mode
  • Etc

Copy link
Contributor Author

@jstewmon jstewmon Jul 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I strongly disagree. I'll repeat the rationale I provided earlier in the discussion:

If we allow the setting to be an array (without providing a custom interface), I think that might provide better mechanics for anyone wanting to apply a custom merge strategy to the options for a new instance (versus the function wrapping discussed earlier).

Requiring the use of a custom interface adds no value and prohibits advanced configuration scenarios.

Copy link
Collaborator

@szmarczak szmarczak Jul 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we allow the setting to be an array (without providing a custom interface)

OK. But this doesn't change anything.

Requiring the use of a custom interface adds no value and prohibits advanced configuration scenarios.

I disagree. What advanced configuration scenarios does it prohibit?

I think that might provide better mechanics for anyone wanting to apply a custom merge strategy to the options for a new instance (versus the function wrapping discussed earlier).

You're right here.

Let's assume there's got.hooks and handler receives a new argument: hooks

{
	options,
	methods,
	handler: (options, hooks, next) => ...
}

What about that? Maybe that's just me, probably I'm wrong. I just haven't seen that from your side.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW, do we really need to specify empty hooks in the defaults?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer just a simple array too.

@szmarczak Making it just an array enables the user to construct it however they want. They can, for example, order it based on a condition.

Same reason we have:

got(..., {
	headers: {foo: true}
});

Instead of:

got.headers.add({foo: true});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see your point now...

I suspect that there is a performance advantage to defining the defaults.

Without the defaults, an object and knownHookEvents.length arrays will be created for every request, resulting in more work for the garbage collector.

Further, I think that dynamically creating the properties will introduce a new hidden class for every request. So, I think it is best to keep the default defined.

These are certainly micro optimizations, but I don't see a disadvantage to defining the defaults. Do you?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't :) But look: there are other properties that get redefined: options.timeout, options.followRedirect, options.retry and more. I don't think it's a big deal.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The array approach does come with the caveat that we can't support addition or removal of hooks from an instance since the options are run through an Object.freeze. A new instance would have to be created to modify hooks - they must be provided at instantiation.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brandon93s I would consider that a feature. Immutability is good, it prevents a lot of weird bugs.

Do you have any use-case for when you would want to modify hooks after instantiation?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, nothing in particular comes to mind. I'm comfortable with how it has been implemented here. I prefer the consistent API over additional methods to maintain hooks.

@@ -8,6 +8,7 @@ const urlToOptions = require('./url-to-options');
const isFormData = require('./is-form-data');

const RETRY_AFTER_STATUS_CODES = new Set([413, 429, 503]);
const knownHookEvents = ['beforeRequest'];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be named KNOWN_HOOK_EVENTS to keep the consistency of the code style :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw RETRY_AFTER_STATUS_CODES was chosen recently in #508, but I'm wondering if y'all would like to reconsider that choice and make it retryAfterStatusCodes as was the alternative suggestion in that discussion.

This is the only place in this package where this naming style is used. I know that uppercase constants is conventional in some languages, but not JS. Further, having two styles for variable names requires a decision to be made about when to use the uppercase style. Is it for all vars declared as const? It is for const outside of a function?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jstewmon I agree. I feel like I never know when to put it in uppercase. Better to just always use normal camelcase, indeed.

readme.md Outdated
@@ -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>
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is missing >

readme.md Outdated
const awsClient = got.extend(
{
baseUrl: 'https://<api-id>.execute-api.<api-region>.amazonaws.com/<stage>/',
beforeRequest: async options => {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

beforeRequest needs to be nested in hooks

readme.md Outdated

request(`https://${config.host}/production/`, {
// All usual `got` options
const response = await awsClient('/endpoint/path', {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@szmarczak I don't think we should add variables that are not defined in the example. Ideally, examples should be copy-paste runnnable. I also think the comment helps make it clearer.

@@ -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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@szmarczak Let's try not to nitpick too much. The super minor stuff we can just fix ourselves when merging.

@sindresorhus sindresorhus changed the title Add beforeRequest option Add beforeRequest hook Jul 13, 2018
readme.md Outdated

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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just Object.freeze that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few ways this could be strictly prohibited, but I think this is case where it's best to provide guidance but not prohibit advanced usage. Someone may have a valid reason for modifying the body, so this note is here to hint they they probably need to reassign content-length if they change body.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. Or maybe just move

https://github.com/jstewmon/got/blob/c0aa4d74b9d9d94c858520586b5def68440cd9a9/source/request-as-event-emitter.js#L246

right after try before it calculates the body size?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would violate the expectation that got will make no further changes to the options before the request is sent. ;-)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would violate the expectation that got will make no further changes to the options before the request is sent.

But that's only one header... I think that'd be fine :)

@sindresorhus What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If that is a signed header, it will break the whole thing. It's a big deal.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, then you're right. You should note that too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated to say that Got will make no further changes to the request before it is sent.

I used the signing feature as an example of why I did not want to move the call before the last attempt to set content-length, but we shouldn't specifically mention request signing in the docs for beforeRequest, since it's not tightly coupled to request signing.

Copy link
Collaborator

@szmarczak szmarczak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About changing content-length header:

If that is a signed header, it will break the whole thing.

Can you note that? People will need to sign that again then.

readme.md Outdated
@@ -270,7 +270,7 @@ Hooks allow modifications during the request lifecycle. Hook functions may be as
Type: `Array<Function>`<br>
Default: `[]`

Called with the normalized request options just before the request is sent. You can modify the object. 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.
Called with the normalized request options just before the request is sent. 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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Butter butter. Don't repeat yourself ;)

just before the request is sent

it says that for itself :)

@sindresorhus sindresorhus merged commit 107756f into sindresorhus:master Jul 14, 2018
@sindresorhus
Copy link
Owner

This looks great now. Thank you so much for working on this, @jstewmon :)

szmarczak added a commit to szmarczak/got that referenced this pull request Jul 15, 2018
@jstewmon jstewmon deleted the finalize branch July 25, 2018 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants