-
Notifications
You must be signed in to change notification settings - Fork 4.6k
API Fetch: Support custom fetch handlers #12365
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
Conversation
|
Can't we just invert the order of some middlewares and ensure the registered middlewares are added last (just before the raw handler) which means breaking the middleware chain is ok? Any reason this is not a valid alternative? |
Couldn't this be a breaking change for folks using custom middlewares? They can get different results if we run the custom middlewares at the end just before the raw handler. For example, the same example provided in the README.md: import apiFetch from '@wordpress/api-fetch';
apiFetch.use( ( options, next ) => {
const start = Date.now();
const result = next( options );
result.then( () => {
console.log( 'The request took ' + Date.now() - start );
} );
return result;
} );The output will display now a shorter amount of time, because the built-in middlewares have been already executed. I know this is a not realistic example, but wanted to illustrate that we might cause unexpected results to people using custom middlewares. I think it's better if we avoid a breaking change so that's why I went for the finalware approach suggested by @jsnajdr. |
packages/api-fetch/src/index.js
Outdated
| return nextMiddleware( nextOptions, next ); | ||
| } | ||
|
|
||
| // Next middleware is the last one, so no next arg is passed |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a subtle change in behavior that affects only internal usage; but I'm curious if there's any need or precedent in middleware patterns for omitting the next argument for the last in the stack vs. passing a noop (guaranteeing a middleware to receive next as a function).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did this mainly to simplify the expected arguments in the toHaveBeenCalledWith call of the unit test checking the behavior when using a custom fetch handler.
But you're right, I didn't remember the noop function, which is also easy to test and guarantees consistency since all middlewares will receive a next function.
I'll apply this change in a bit.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in 9bce879
|
I'm pretty okay with what's proposed here, more-so I think than your suggestion @youknowriad . My unease is likely with any "dependency" between middlewares, in this case some middleware which is expected to take place at a point before the default fetch behavior, because of the fragility in relying on a specific order in which middlewares are applied, and an assumption of full control over which middlewares are applied (at least in a plugin landscape, this is very unlikely to be guaranteed). Another option I could see is to leverage our existing pattern of middleware creators, i.e. Pseudo-code: function createFetchMiddleware( handler ) {
const middleware = ( options ) => handler( options );
middleware.fetchMiddleware = true;
return middleware;
}
function apiFetch( options ) {
const steps = [
...middlewares,
fetchAllMiddleware,
httpV1Middleware,
namespaceEndpointMiddleware,
userLocaleMiddleware,
];
if ( ! some( steps, 'fetchMiddleware' ) ) {
steps.push( createFetchMiddleware( defaultHandler ) );
}
// ...
}This middleware's a bit unique in how we'd expect it to (a) always exist and (b) be the terminating middleware. Under those assumptions, we could modify the implementation a bit to either test only the last of the middleware stack, or change the behavior of |
packages/api-fetch/package.json
Outdated
| "@wordpress/i18n": "file:../i18n", | ||
| "@wordpress/url": "file:../url" | ||
| "@wordpress/url": "file:../url", | ||
| "lodash": "^4.17.10" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not strictly opposed to it since it's a library which in a wp-admin context will most certainly be loaded anyways, it's worth pointing out that for the npm distributable, this has a sizeable impact in bundle size, for what effectively amounts to a usage of what can otherwise be expressed as () => {}.
The "called with" test, if an argument for having something with referential equality, is not one I think we need to guarantee anyways. The guarantee is simply that the middleware is passed a function (expect.any( Function )).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 I think we can avoid avoiding pulling all of lodash as a requirement here, especially since we're only using noop. @mmtr generally we need to be a bit more careful on the trade-offs costs of pulling packages into libraries vs applications. (Think of the standalone case of someone only wanting to use the api-fetch package for instance).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In theory this becomes a non-issue if we revisit something like #9374
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
|
Looks like one failure in I've restarted the e2e run, but we should investigate if it fails again. |
I'm personally fine with the currently proposed solution as it is going to be easier to use for developers. By using the explicit override |
|
There are two distinct concepts involved here:
The fetch handler is by definition the last function that sees a request and the first one that sees the response. It's not in the middle of anything. Calling it a "middleware" makes the user API confusing: the middleware chain becomes an array of objects of different type and with different constraints, and it allows the user to write nonsense code: // does order matter for `fetchHandler`? or is it detected and moved to the end?
[ middle1, fetchHandler, middle2 ]
// only one `fetchHandler` ever runs, but the API lets me specify multiple ones?
[ middle1, fetchHandler1, middle2, fetchHandler2, middle3 ]That's why I'd strongly prefer a |
I agree with this. We add the fetch handler to the list of middlewares, but it is intentionally added at the end, so it is not really a middleware to me. I think is ok to have it in the middlewares chain if that simplifies the code (same function will execute both middlewares and fetch) as long as we do that only internally and we don't expose publicly it as a middleware, since that can confuse the developers. |
3f94782 to
1fe31aa
Compare
The current implementation is the reason why I got confused as someone not familiar with the internals of the if ( steps.length > ( index + 1 ) ) {which is targeted to the fetch handler. |
I think that the confusion comes from the name used in the
Based on this, I applied some changes in 78cc4a3 which renames |
packages/api-fetch/README.md
Outdated
| apiFetch.setFetchHandler( ( options ) => { | ||
| const { url, path, data, method } = options; | ||
|
|
||
| return axios({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick:
Arrays and Function Calls
Always include extra spaces around elements and arguments:
packages/api-fetch/src/index.js
Outdated
| ...middlewares, | ||
| ]; | ||
|
|
||
| const steps = [ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd normally not have brought it up, but since you did some refactoring here, I'm a bit curious why we construct as much as we do here on every call to apiFetch. Why not just initialize the scoped middlewares to include fetchAllMiddleware et al? I think by eliminating steps we could even make clearer that the fetch handler is the terminating behavior. Looking at runStep's creation by index + 1, it took me more than a few seconds to understand how we'd not risk some undefined value being called as a function.†
† In fact, while this probably won't normally occur, I think an error would be thrown if the fetch handler actually called next ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
diff --git a/packages/api-fetch/src/test/index.js b/packages/api-fetch/src/test/index.js
index d993a8b63..0149a9efb 100644
--- a/packages/api-fetch/src/test/index.js
+++ b/packages/api-fetch/src/test/index.js
@@ -196,5 +196,6 @@ describe( 'apiFetch', () => {
expect( customFetchHandler ).toHaveBeenCalledWith( {
path: '/random?_locale=user',
}, expect.any( Function ) );
+ expect( customFetchHandler.mock.calls[ 0 ][ 1 ] ).not.toThrow();
} );
} );● apiFetch › should not use the default fetch handler when using a custom fetch handler
expect(function).not.toThrow()
Expected the function not to throw an error.
Instead, it threw:
TypeError: nextStep is not a function
136 | const nextStep = steps[ index ];
137 | const next = runStep( index + 1 );
> 138 | return nextStep( nextOptions, next );
| ^
139 | };
140 |
141 | return runStep( 0 )( options );
at nextStep (packages/api-fetch/src/index.js:138:10)
at Object.<anonymous> (node_modules/expect/build/to_throw_matchers.js:51:9)
at Object.throwingMatcher [as toThrow] (node_modules/expect/build/index.js:320:33)
at Object.toThrow (packages/api-fetch/src/test/index.js:199:57)
197 | path: '/random?_locale=user',
198 | }, expect.any( Function ) );
> 199 | expect( customFetchHandler.mock.calls[ 0 ][ 1 ] ).not.toThrow();
| ^
200 | } );
201 | } );
202 |
at Object.toThrow (packages/api-fetch/src/test/index.js:199:57)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd normally not have brought it up, but since you did some refactoring here, I'm a bit curious why we construct as much as we do here on every call to
apiFetch. Why not just initialize the scopedmiddlewaresto includefetchAllMiddlewareet al? I think by eliminatingstepswe could even make clearer that the fetch handler is the terminating behavior. Looking atrunStep's creation byindex + 1, it took me more than a few seconds to understand how we'd not risk someundefinedvalue being called as a function.†† In fact, while this probably won't normally occur, I think an error would be thrown if the fetch handler actually called
next?
After consulting with @mmtr, I pushed a few commits to sort out my concerns.
In which case, we probably shouldn't pass it |
|
|
||
| let fetchHandler = defaultFetchHandler; | ||
|
|
||
| function setFetchHandler( newFetchHandler ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know this file was already lacking so JSDocs, but maybe a good time to start adding those.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added in e2dc3d3
gziolo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for iterating on the implementation. It should be all clear now that fetchHandler differs from middlewares.
|
|
||
| function registerMiddleware( middleware ) { | ||
| middlewares.push( middleware ); | ||
| middlewares.unshift( middleware ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, much easier to follow than using reverse on the list of middlewares 👍
| } | ||
|
|
||
| function apiFetch( options ) { | ||
| const steps = [ ...middlewares, fetchHandler ]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for updating this code here. It is now clear that fetchHandler is something unrelated to middlewares.
packages/api-fetch/src/test/index.js
Outdated
|
|
||
| apiFetch.use( ( actualOptions, next ) => { | ||
| expect( actualOptions ).toBe( expectedOptions ); | ||
| expect( actualOptions ).toEqual( expectedOptions ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From https://jestjs.io/docs/en/expect#tobevalue:
Use
.toBeto compare primitive values or to check referential identity of object instances. It callsObject.isto compare values, which is even better for testing than===strict equality operator.
Nit: I think the 2nd check (. toEqual) is obsolete in this test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right. toEqual is useful for checking the "deep" equality of two different objects. Removed in e2dc3d3
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From https://jestjs.io/docs/en/expect#tobevalue:
Use
.toBeto compare primitive values or to check referential identity of object instances. It callsObject.isto compare values, which is even better for testing than===strict equality operator.Nit: I think the 2nd check (
. toEqual) is obsolete in this test.
The idea was to check that neither the reference changed nor the value mutated. I think toBe could return true even if the value was directly mutated?
var a = {}
var b = a
b.foo = 1
Object.is( a, b )
// true
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
b.foo = 1 also implies a.foo = 1, so if the reference is the same, the value will be the same:
const a = {};
const b = a;
b.foo = 1;
expect( a ).toBe( b ); // true
expect( a ).toEqual( b ); // true
expect( a.foo ).toEqual( 1 ); // trueThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd thought there could be some case where one is not sufficient to capture both conditions, but on reflection I think toBe should be enough. 👍
|
The scope of 4.7 (WordPress 5.0.1) is frozen at this stage. We need approvals from release leads to include anything. This is targeted for 4.8 at the moment. |
0ee5821 to
86b5cfe
Compare
86b5cfe to
a5bbeec
Compare
|
Any idea of when this might land? I do wonder if a more complex model like gitflow would be interesting for folks. It's more overhead but we'd be able to continue development on a particular release while another one is being polished for specific bugfixes. |
|
Yes, we discussed using gitflow like approach. @youknowriad proposed a bit simplified flow 2 weeks ago which we want to try first: We want to start simple and iterate. The fact that we manage packages from Gutenberg and they need also to feed WordPress core is suboptimal. However, this is a bigger issue as we can't move codebase easily to WordPress core codebase because it is svn based, so our tooling won't work with it out of the box. The other way around is also a complex task to accomplish. If you have some ideas how to make it all work with Lerna and all requirements listed in the shared post, we can team up and improve the flow. As of today, this PR is planned for 4.9 which should align with 5.1 release cycle of WordPress core. I think beta is planned for the middle of January, which is where we would publish packages to npm with this patch included. |
|
Thanks for the links and estimate @gziolo!
💯 What's interesting in this case is that there are several release cycles to consider.
I can maybe add a comment on that post if I can clarify my thoughts a bit (and maybe draw a few diagrams). But really great to hear folks are thinking about this. |
That would be ideal, however, with how use Lerna to manage monorepo at the moment, it's hard to achieve when you publish from
Actually, Gutenberg 4.8 is going to contain more changes than what will go into WordPress 5.0.3. Plugin is going to be updated of the |
|
Thanks all for your work here. |
Description
This PR adds support to the
api-fetchpackage for using custom fetch handlers instead of usingwindow.fetch, which is still the default fetch handler.This is needed in applications like Calypso which uses
wpcom.jsfor performing requests to the REST API.However, right now the only way of doing that is by breaking the middleware chain, which involves to register again the middlewares that are already registered by Gutenberg. Furthermore, if the middleware is not exposed publicly (like
userLocaleMiddleware) we need to copy the code of the middleware into Calypso. This is not ideal, since it has a maintenance cost every time there is an upgrade of theapi-fetchpackage.With this change, it will be possible to define a custom fetch handler with the
setFetchHandlermethod that will override the last middleware executed, so the chain is not broken.More context in Automattic/wp-calypso#28271.
How has this been tested?
With unit tests
Checklist: