Skip to content

Commit a956d5d

Browse files
authored
feat: support client["/endpoint"].GET() style calls (#1791)
* feat: support `client["/endpoint"].GET()` style calls * feat: only-pay-what-you-use alternative * Use prototype chain to memoize PathCallForwarder (and rename) * Add benchmarks
1 parent 84ce19a commit a956d5d

File tree

7 files changed

+301
-4
lines changed

7 files changed

+301
-4
lines changed

.changeset/stale-donuts-smoke.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-fetch": minor
3+
---
4+
5+
Add support for `client["/endpoint"].GET()` style calls

docs/openapi-fetch/api.md

+37
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,43 @@ client.GET("/my-url", options);
4040
| `middleware` | `Middleware[]` | [See docs](/openapi-fetch/middleware-auth) |
4141
| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal`, …) ([docs](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options)) |
4242

43+
## wrapAsPathBasedClient
44+
45+
**wrapAsPathBasedClient** wraps the result of `createClient()` to return a [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy)-based client that allows path-indexed calls:
46+
47+
```ts
48+
const client = createClient<paths>(clientOptions);
49+
const pathBasedClient = wrapAsPathBasedClient(client);
50+
51+
pathBasedClient["/my-url"].GET(fetchOptions);
52+
```
53+
54+
The `fetchOptions` are the same than for the base client.
55+
56+
A path based client can lead to better type inference but comes at a runtime cost due to the use of a Proxy.
57+
58+
**createPathBasedClient** is a convenience method combining `createClient` and `wrapAsPathBasedClient` if you only want to use the path based call style:
59+
60+
```ts
61+
const client = createPathBasedClient<paths>(clientOptions);
62+
63+
client["/my-url"].GET(fetchOptions);
64+
```
65+
66+
Note that it does not allow you to attach middlewares. If you need middlewares, you need to use the full form:
67+
68+
```ts
69+
const client = createClient<paths>(clientOptions);
70+
71+
client.use(...);
72+
73+
const pathBasedClient = wrapAsPathBasedClient(client);
74+
75+
client.use(...); // the client reference is shared, so the middlewares will propagate.
76+
77+
pathBasedClient["/my-url"].GET(fetchOptions);
78+
```
79+
4380
## querySerializer
4481

4582
OpenAPI supports [different ways of serializing objects and arrays](https://swagger.io/docs/specification/serialization/#query) for parameters (strings, numbers, and booleans—primitives—always behave the same way). By default, this library serializes arrays using `style: "form", explode: true`, and objects using `style: "deepObject", explode: true`, but you can customize that behavior with the `querySerializer` option (either on `createClient()` to control every request, or on individual requests for just one).

docs/openapi-fetch/index.md

+19
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,25 @@ const { data, error, response } = await client.GET("/url");
169169
| `error` | `5xx`, `4xx`, or `default` response if not OK; otherwise `undefined` |
170170
| `response` | [The original Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) which contains `status`, `headers`, etc. |
171171

172+
### Path-property style
173+
174+
If you prefer selecting the path as a property, you can create a path based client:
175+
176+
```ts
177+
import { createPathBasedClient } from "openapi-fetch";
178+
import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typescript
179+
180+
const client = createPathBasedClient<paths>({ baseUrl: "https://myapi.dev/v1" });
181+
182+
client["/blogposts/{post_id}"].GET({
183+
params: { post_id: "my-post" },
184+
query: { version: 2 },
185+
});
186+
```
187+
188+
Note that this has performance implications and does not allow to attach middlewares directly.
189+
See [`wrapAsPathBasedClient`](/openapi-fetch/api#wrapAsPathBasedClient) for more.
190+
172191
## Support
173192

174193
| Platform | Support |

packages/openapi-fetch/src/index.d.ts

+30-3
Original file line numberDiff line numberDiff line change
@@ -156,17 +156,29 @@ export type MaybeOptionalInit<Params extends Record<HttpMethod, {}>, Location ex
156156
? FetchOptions<FilterKeys<Params, Location>> | undefined
157157
: FetchOptions<FilterKeys<Params, Location>>;
158158

159+
// The final init param to accept.
160+
// - Determines if the param is optional or not.
161+
// - Performs arbitrary [key: string] addition.
162+
// Note: the addition It MUST happen after all the inference happens (otherwise TS can’t infer if init is required or not).
163+
type InitParam<Init> = HasRequiredKeys<Init> extends never
164+
? [(Init & { [key: string]: unknown })?]
165+
: [Init & { [key: string]: unknown }];
166+
159167
export type ClientMethod<
160168
Paths extends Record<string, Record<HttpMethod, {}>>,
161169
Method extends HttpMethod,
162170
Media extends MediaType,
163171
> = <Path extends PathsWithMethod<Paths, Method>, Init extends MaybeOptionalInit<Paths[Path], Method>>(
164172
url: Path,
165-
...init: HasRequiredKeys<Init> extends never
166-
? [(Init & { [key: string]: unknown })?] // note: the arbitrary [key: string]: addition MUST happen here after all the inference happens (otherwise TS can’t infer if it’s required or not)
167-
: [Init & { [key: string]: unknown }]
173+
...init: InitParam<Init>
168174
) => Promise<FetchResponse<Paths[Path][Method], Init, Media>>;
169175

176+
export type ClientForPath<PathInfo extends Record<HttpMethod, {}>, Media extends MediaType> = {
177+
[Method in keyof PathInfo as Uppercase<string & Method>]: <Init extends MaybeOptionalInit<PathInfo, Method>>(
178+
...init: InitParam<Init>
179+
) => Promise<FetchResponse<PathInfo[Method], Init, Media>>;
180+
};
181+
170182
export interface Client<Paths extends {}, Media extends MediaType = MediaType> {
171183
/** Call a GET endpoint */
172184
GET: ClientMethod<Paths, "get", Media>;
@@ -194,6 +206,21 @@ export default function createClient<Paths extends {}, Media extends MediaType =
194206
clientOptions?: ClientOptions,
195207
): Client<Paths, Media>;
196208

209+
export type PathBasedClient<
210+
Paths extends Record<string, Record<HttpMethod, {}>>,
211+
Media extends MediaType = MediaType,
212+
> = {
213+
[Path in keyof Paths]: ClientForPath<Paths[Path], Media>;
214+
};
215+
216+
export declare function wrapAsPathBasedClient<Paths extends {}, Media extends MediaType = MediaType>(
217+
client: Client<Paths, Media>,
218+
): PathBasedClient<Paths, Media>;
219+
220+
export declare function createPathBasedClient<Paths extends {}, Media extends MediaType = MediaType>(
221+
clientOptions?: ClientOptions,
222+
): PathBasedClient<Paths, Media>;
223+
197224
/** Serialize primitive params to string */
198225
export declare function serializePrimitiveParam(
199226
name: string,

packages/openapi-fetch/src/index.js

+79
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,85 @@ export default function createClient(clientOptions) {
233233
};
234234
}
235235

236+
class PathCallForwarder {
237+
constructor(client, url) {
238+
this.client = client;
239+
this.url = url;
240+
}
241+
242+
GET(init) {
243+
return this.client.GET(this.url, init);
244+
}
245+
PUT(init) {
246+
return this.client.PUT(this.url, init);
247+
}
248+
POST(init) {
249+
return this.client.POST(this.url, init);
250+
}
251+
DELETE(init) {
252+
return this.client.DELETE(this.url, init);
253+
}
254+
OPTIONS(init) {
255+
return this.client.OPTIONS(this.url, init);
256+
}
257+
HEAD(init) {
258+
return this.client.HEAD(this.url, init);
259+
}
260+
PATCH(init) {
261+
return this.client.PATCH(this.url, init);
262+
}
263+
TRACE(init) {
264+
return this.client.TRACE(this.url, init);
265+
}
266+
}
267+
268+
class PathClientProxyHandler {
269+
constructor() {
270+
this.client = null;
271+
}
272+
273+
// Assume the property is an URL.
274+
get(coreClient, url) {
275+
const forwarder = new PathCallForwarder(coreClient, url);
276+
this.client[url] = forwarder;
277+
return forwarder;
278+
}
279+
}
280+
281+
/**
282+
* Wrap openapi-fetch client to support a path based API.
283+
* @type {import("./index.js").wrapAsPathBasedClient}
284+
*/
285+
export function wrapAsPathBasedClient(coreClient) {
286+
const handler = new PathClientProxyHandler();
287+
const proxy = new Proxy(coreClient, handler);
288+
289+
// Put the proxy on the prototype chain of the actual client.
290+
// This means if we do not have a memoized PathCallForwarder,
291+
// we fall back to the proxy to synthesize it.
292+
// However, the proxy itself is not on the hot-path (if we fetch the same
293+
// endpoint multiple times, only the first call will hit the proxy).
294+
function Client() {}
295+
Client.prototype = proxy;
296+
297+
const client = new Client();
298+
299+
// Feed the client back to the proxy handler so it can store the generated
300+
// PathCallForwarder.
301+
handler.client = client;
302+
303+
return client;
304+
}
305+
306+
/**
307+
* Convenience method to an openapi-fetch path based client.
308+
* Strictly equivalent to `wrapAsPathBasedClient(createClient(...))`.
309+
* @type {import("./index.js").createPathBasedClient}
310+
*/
311+
export function createPathBasedClient(clientOptions) {
312+
return wrapAsPathBasedClient(createClient(clientOptions));
313+
}
314+
236315
// utils
237316

238317
/**

packages/openapi-fetch/test/index.bench.js

+20-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { setupServer } from "msw/node";
44
import { Fetcher } from "openapi-typescript-fetch";
55
import superagent from "superagent";
66
import { afterAll, bench, describe } from "vitest";
7-
import createClient from "../dist/index.js";
7+
import createClient, { createPathBasedClient } from "../dist/index.js";
88
import * as openapiTSCodegen from "./fixtures/openapi-typescript-codegen.min.js";
99

1010
const BASE_URL = "https://api.test.local";
@@ -40,6 +40,10 @@ describe("setup", () => {
4040
createClient({ baseUrl: BASE_URL });
4141
});
4242

43+
bench("openapi-fetch (path based)", async () => {
44+
createPathBasedClient({ baseUrl: BASE_URL });
45+
});
46+
4347
bench("openapi-typescript-fetch", async () => {
4448
const fetcher = Fetcher.for();
4549
fetcher.configure({
@@ -59,6 +63,7 @@ describe("setup", () => {
5963

6064
describe("get (only URL)", () => {
6165
const openapiFetch = createClient({ baseUrl: BASE_URL });
66+
const openapiFetchPath = createPathBasedClient({ baseUrl: BASE_URL });
6267
const openapiTSFetch = Fetcher.for();
6368
openapiTSFetch.configure({
6469
baseUrl: BASE_URL,
@@ -73,6 +78,10 @@ describe("get (only URL)", () => {
7378
await openapiFetch.GET("/url");
7479
});
7580

81+
bench("openapi-fetch (path based)", async () => {
82+
await openapiFetchPath["/url"].GET();
83+
});
84+
7685
bench("openapi-typescript-fetch", async () => {
7786
await openapiTSFetchGET();
7887
});
@@ -95,6 +104,10 @@ describe("get (headers)", () => {
95104
baseUrl: BASE_URL,
96105
headers: { "x-base-header": 123 },
97106
});
107+
const openapiFetchPath = createPathBasedClient({
108+
baseUrl: BASE_URL,
109+
headers: { "x-base-header": 123 },
110+
});
98111
const openapiTSFetch = Fetcher.for();
99112
openapiTSFetch.configure({
100113
baseUrl: BASE_URL,
@@ -112,6 +125,12 @@ describe("get (headers)", () => {
112125
});
113126
});
114127

128+
bench("openapi-fetch (path based)", async () => {
129+
await openapiFetchPath["/url"].GET({
130+
headers: { "x-header-1": 123, "x-header-2": 456 },
131+
});
132+
});
133+
115134
bench("openapi-typescript-fetch", async () => {
116135
await openapiTSFetchGET(null, {
117136
headers: { "x-header-1": 123, "x-header-2": 456 },

0 commit comments

Comments
 (0)