-
-
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
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"openapi-fetch": minor | ||
--- | ||
|
||
Add support for `client["/endpoint"].GET()` style calls |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -233,6 +233,60 @@ export default function createClient(clientOptions) { | |
}; | ||
} | ||
|
||
class UrlCallForwarder { | ||
constructor(client, url) { | ||
this.client = client; | ||
this.url = url; | ||
} | ||
|
||
GET(init) { | ||
return this.client.GET(this.url, init); | ||
} | ||
PUT(init) { | ||
return this.client.PUT(this.url, init); | ||
} | ||
POST(init) { | ||
return this.client.POST(this.url, init); | ||
} | ||
DELETE(init) { | ||
return this.client.DELETE(this.url, init); | ||
} | ||
OPTIONS(init) { | ||
return this.client.OPTIONS(this.url, init); | ||
} | ||
HEAD(init) { | ||
return this.client.HEAD(this.url, init); | ||
} | ||
PATCH(init) { | ||
return this.client.PATCH(this.url, init); | ||
} | ||
TRACE(init) { | ||
return this.client.TRACE(this.url, init); | ||
} | ||
} | ||
|
||
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 commentThe reason will be displayed to describe this comment to others. Learn more. We could memoize
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 const myMethod = client["/my-path"];
function performanceSensitive() {
myMethod.GET(...);
} There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 commentThe 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! |
||
}; | ||
|
||
/** | ||
* Wrap openapi-fetch client to support a path based API. | ||
* @type {import("./index.js").wrapAsPathBasedClient} | ||
*/ | ||
export function wrapAsPathBasedClient(coreClient) { | ||
return new Proxy(coreClient, clientProxyHandler); | ||
} | ||
|
||
/** | ||
* Convenience method to an openapi-fetch path based client. | ||
* Strictly equivalent to `wrapAsPathBasedClient(createClient(...))`. | ||
* @type {import("./index.js").createPathBasedClient} | ||
*/ | ||
export function createPathBasedClient(clientOptions) { | ||
return wrapAsPathBasedClient(createClient(clientOptions)); | ||
} | ||
|
||
// utils | ||
|
||
/** | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ import createClient, { | |
type Middleware, | ||
type MiddlewareCallbackParams, | ||
type QuerySerializerOptions, | ||
createPathBasedClient, | ||
} from "../src/index.js"; | ||
import { server, baseUrl, useMockRequestHandler, toAbsoluteURL } from "./fixtures/mock-server.js"; | ||
import type { paths } from "./fixtures/api.js"; | ||
|
@@ -1870,6 +1871,116 @@ describe("client", () => { | |
); | ||
}); | ||
}); | ||
|
||
describe("path based client", () => { | ||
it("performs a call without params", async () => { | ||
const client = createPathBasedClient<paths>({ baseUrl }); | ||
|
||
const { getRequest } = useMockRequestHandler({ | ||
baseUrl, | ||
method: "get", | ||
path: "/anyMethod", | ||
}); | ||
await client["/anyMethod"].GET(); | ||
expect(getRequest().method).toBe("GET"); | ||
}); | ||
|
||
it("performs a call with params", async () => { | ||
const client = createPathBasedClient<paths>({ baseUrl }); | ||
const { getRequestUrl } = useMockRequestHandler({ | ||
baseUrl, | ||
method: "get", | ||
path: "/blogposts/:post_id", | ||
status: 200, | ||
body: { title: "Blog post title" }, | ||
}); | ||
|
||
// Wrong method | ||
// @ts-expect-error | ||
await client["/blogposts/{post_id}"].POST({ | ||
params: { | ||
// Unknown property `path`. | ||
// @ts-expect-error | ||
path: { post_id: "1234" }, | ||
}, | ||
}); | ||
|
||
await client["/blogposts/{post_id}"].GET({ | ||
// expect error on number instead of string. | ||
// @ts-expect-error | ||
params: { path: { post_id: 1234 } }, | ||
}); | ||
|
||
const { data, error } = 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 commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
||
|
||
// Check typing of data. | ||
if (error) { | ||
// Fail, but we need the if above for type inference. | ||
expect(error).toBeUndefined(); | ||
} else { | ||
// @ts-expect-error | ||
data.not_a_blogpost_property; | ||
// Check typing of result value. | ||
expect(data.title).toBe("Blog post title"); | ||
} | ||
}); | ||
|
||
it("performs a POST call", async () => { | ||
const client = createPathBasedClient<paths>({ baseUrl }); | ||
const { getRequest } = useMockRequestHandler({ | ||
baseUrl, | ||
method: "post", | ||
path: "/anyMethod", | ||
}); | ||
await client["/anyMethod"].POST(); | ||
expect(getRequest().method).toBe("POST"); | ||
}); | ||
|
||
it("performs a PUT call with a request body", async () => { | ||
const mockData = { status: "success" }; | ||
|
||
const client = createPathBasedClient<paths>({ baseUrl }); | ||
const { getRequestUrl } = useMockRequestHandler({ | ||
baseUrl, | ||
method: "put", | ||
path: "/blogposts", | ||
status: 201, | ||
body: mockData, | ||
}); | ||
|
||
await client["/blogposts"].PUT({ | ||
body: { | ||
title: "New Post", | ||
body: "<p>Best post yet</p>", | ||
// Should be a number, not a Date. | ||
// @ts-expect-error | ||
publish_date: new Date("2023-03-31T12:00:00Z"), | ||
}, | ||
}); | ||
|
||
const { data, error, response } = await client["/blogposts"].PUT({ | ||
body: { | ||
title: "New Post", | ||
body: "<p>Best post yet</p>", | ||
publish_date: new Date("2023-03-31T12:00:00Z").getTime(), | ||
}, | ||
}); | ||
|
||
// assert correct URL was called | ||
expect(getRequestUrl().pathname).toBe("/blogposts"); | ||
|
||
// assert correct data was returned | ||
expect(data).toEqual(mockData); | ||
expect(response.status).toBe(201); | ||
|
||
// assert error is empty | ||
expect(error).toBeUndefined(); | ||
}); | ||
}); | ||
}); | ||
|
||
// test that the library behaves as expected inside commonly-used patterns | ||
|
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)