-
-
Notifications
You must be signed in to change notification settings - Fork 529
feat: support client["/endpoint"].GET()
style calls
#1791
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
🦋 Changeset detectedLatest commit: f570294 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
CI failure is being addressed in #1792. |
client["/endpoint"].GET()
style calls
Thanks for submitting this! But I don’t see an attached issue discussing why this is needed / what problem this is solving. I’m seeing a significant performance regression by using Proxies and additional logic just for what seems to be a minor API change? Could you talk more why this direction was sought? What prior art it references? Does it solve any problems for you? I’m not necessarily against adding this, but as stated in the CONTRIBUTING guidelines, changing the API in a PR without any prior discussion isn’t recommended, especially if it impacts performance for all users like this PR does (sidenote: for many libraries, yes, things like Proxies are negligible; while we don’t want to micro-optimize to no end, this library being a fetch client strives to shave off every nanosecond possible, and this introduces the biggest slowdown, performance-wise, in this library to-date so we’d better weigh the cost of what we get in return!). Edit: note on performanceTo dig more into my thoughts on performance, Optimizing JavaScript for fun and profit is a great article written recently on V8 performance. Particularly this note:
In your code, every fetch will be noticeably slower because the compiler can’t optimize the static entries like we have currently. And again, this is where library code differs from application code. If you’re writing this in one place, in an app, that only gets called once, who cares? Proxies are great. But this is a widely-used, lower-level library where every millisecond matters (and if you read the project goals, performance is a major concern). Here it does matter micro-optimizing everything, because this will get called hundreds if not thousands of times. There are ways to avoid this perf hit with lazy/“smart” getters, which I’d probably be OK with the trade-off! You get a slow first hit, and “free” subsequent hits, but still worth profiling. |
🤦 sorry, my bad. This fixes (and is discussed in) #1526. The primary use case is better type inference / go to definition. Another reason why I'm interested in this is thinner mocking (e.g. with jest-mock-extended). import { paths } from "...";
const mockClient = mock<Client<paths>>();
mockClient["/path"].GET(...).resolvedValue(...); If you have discouraging performance numbers we can investigate a pay-only-if-you-use it alternative (probably wrapping the result of (please excuse my brevity, I'm in-between things, but that missing cross-ref was embarassing). |
Ah, thanks! That issue came to mind but just wanted to check that this built off that convo. No worries—I think there are some great arguments in there.
I’d love to explore this, yeah! Just edited my original comment with more info. Benchmarking in general is really tricky (it’s very hard to automate regression tests for it, without investing significant time and resources into maintaining it), but in general I’d like to explore all possible options of avoiding using a Proxy for everything because of the performance hit. It slows down everything by quite a lot (see naïve implementation; I may have made a mistake but hopefully this gives some reference): ![]() If this API just took a separate codepath entirely I think that would be ideal. |
packages/openapi-fetch/src/index.js
Outdated
} | ||
|
||
// Assume the property is an URL. | ||
return Object.fromEntries(methods.map((method) => [method, (init) => coreFetch(property, { ...init, method })])); |
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.
Performance regression: taking previously-static properties, and iterating over them on every fetch call will mean a big performance hit when it’s all added up. Every time we convert a static object with known properties into another loop means the overall runtime extends by a little bit each time. Prefer “WET” code here when possible (and again, this isn’t the norm on all codebases; just library code)!
expect(client).toHaveProperty("OPTIONS"); | ||
expect(client).toHaveProperty("HEAD"); | ||
expect(client).toHaveProperty("PATCH"); | ||
expect(client).toHaveProperty("TRACE"); |
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.
Suggestion: OK this is kind of a dumb test, I’ll admit, but we should at least improve this rather than remove it outright (this tests both runtime code and type code in one test, which is a recurring theme in the current test suite)
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 removed it because it was failing (due to the replacement with a proxy). If we go for a more static alternative, I'll be able to add it again.
await client["/blogposts/{post_id}"].GET({ | ||
params: { path: { post_id: "1234" } }, | ||
}); | ||
expect(getRequestUrl().pathname).toBe("/blogposts/1234"); |
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.
Comment: these are great tests, by the way! 🙂 I would also like to test this API for POST
calls and fetches with requestbodies in addition to what you have here. But this is a great start.
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 have added some more. They are all adapted from the base tests above.
I'm a bit unsure how much we want to cover this API: On one hand, it is a new public API, so we should probably err on the "too-many-tests-side". On the other hand, the integration is quite thin, so a lot of the core type inference and calling logic is re-used. So I'm not sure it makes sense to essentially test it twice.
WDYT?
TY for the quick feedback. I'll attempt a separate factory alternative tomorrow. RE performance:
|
Yes that’s correct! In the README and docs we do mention the code size as a proxy for performance, but at the end of the day performance > code size. Because this library is meant to be a typed Also taking into account some people may wrap this with another library such as TanStack Fetch, openapi-fetch usually won’t be the only slowdown for every request (and to that extent, every nanosecond we introduce here counts against the “budget” that users allot for how slow their entire SDK stack can be, so let’s minimize our impact) |
800efd6
to
ad69d62
Compare
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've added a commit that proposes an opt-in alternative.
I have pointed out points I think might be problematic individually.
@@ -169,6 +169,25 @@ const { data, error, response } = await client.GET("/url"); | |||
| `error` | `5xx`, `4xx`, or `default` response if not OK; otherwise `undefined` | | |||
| `response` | [The original Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) which contains `status`, `headers`, etc. | | |||
|
|||
### Path-property style |
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.
We can also remove this section here, if you feel it is too prominent.
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.
No this is great! Docs aren’t too prescriptive. Better to just add it anywhere to start. Always easy to clean up after-the-fact. But missing information is worse than information in the wrong place (the Algolia search helps with that bit)
packages/openapi-fetch/src/index.js
Outdated
|
||
const clientProxyHandler = { | ||
// Assume the property is an URL. | ||
get: (coreClient, url) => new UrlCallForwarder(coreClient, url), |
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.
We could memoize UrlCallForwarder
here on either:
- An internal map
- The proxied object itself (that would probably imply creating another intermediary object in
wrapAsPathBasedClient
to not pollute the core client).
However, I'm not sure it is worth the effort:
The lazy getter technique is primarily for statically known getter names. We can adjust it to work with proxies, but we'll still be left with an indirection through the proxy:
const clientProxyHandler = {
// Assume the property is an URL.
get: (coreClient, url) => {
if (!(url in coreClient)) {
// BAD: pollutes client, but OK for the example here.
coreClient[url] = new UrlCallForwarder(coreClient, url);
}
return coreClient[url];
}
}
Based on optimizing JS, on modern Chrome, Proxies lead to 30x slowdown (with trivial no-op indirection). At this point, I doubt that the additional object allocation is noticeable.
It feels like we're a bit stuck in this sub-optimal situation. Something we can do for users that want high-performance and path based calls is recommend pre-calculating the UrlCallForwarder
:
const myMethod = client["/my-path"];
function performanceSensitive() {
myMethod.GET(...);
}
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.
Hum, I just had an idea: We could potentially get around the proxy limitation by chaining prototypes strangely:
If we inherit (as in: prototype chain) from the proxy with the object we use to memoize the properties, that might mitigate the performance impact.
I'll give this a try.
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.
Just saw this improvement. Yeah I think this solves my concerns. I hate making “vibe-based” comments, especially when we don’t have solid benchmarking in the project. But once we give people a good DX like this, it’s tough to take it away again, and it’s easy to be stuck with any performance loss permanently.
But your explorations here seem to have found a happy medium of adding great DX but without incurring a noticeable perf hit (IMO having a proxy for 100% of calls would be a noticeable perf hit, and we managed to avoid that).
Overall I’m pretty happy with this approach!
await client["/blogposts/{post_id}"].GET({ | ||
params: { path: { post_id: "1234" } }, | ||
}); | ||
expect(getRequestUrl().pathname).toBe("/blogposts/1234"); |
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 have added some more. They are all adapted from the base tests above.
I'm a bit unsure how much we want to cover this API: On one hand, it is a new public API, so we should probably err on the "too-many-tests-side". On the other hand, the integration is quite thin, so a lot of the core type inference and calling logic is re-used. So I'm not sure it makes sense to essentially test it twice.
WDYT?
It does not look like the proxy has any measurable performance impact: Without the memoization:
With the memoization:
|
Thanks for taking the time to inspect that! Perhaps you’re right, and the way we’re using it, proxies wouldn’t have slowed this library down at all even in the worst-case scenarios. But at least you minimized any potential impact as much as possible, and I’m happy with it! Also really appreciate all the great work you’ve done in improving benchmarking as well. I’d be happy to ship this! Thanks for adding such a great feature 🙂 |
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.
Great docs, test, and code!
Changes
Allow calls of the form
client["/path"].GET(...)
by using a JavaScript proxy object.How to Review
Suggested order:
Points to watch out for:
Checklist
docs/
updated (if necessary)pnpm run update:examples
run (only applicable for openapi-typescript)