Skip to content

Commit 9686188

Browse files
committed
feat: support client["/endpoint"].GET() style calls
1 parent 8e5fa3a commit 9686188

File tree

5 files changed

+94
-51
lines changed

5 files changed

+94
-51
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/index.md

+13
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,19 @@ 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+
Alternatively to passing the path as parameter, you can select it as a property on the client:
175+
176+
```ts
177+
client["/blogposts/{post_id}"].GET({
178+
params: { post_id: "my-post" },
179+
query: { version: 2 },
180+
});
181+
```
182+
183+
This is strictly equivalent to `.GET("/blogposts/{post_id}", { ... } )`.
184+
172185
## Support
173186

174187
| Platform | Support |

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

+19-5
Original file line numberDiff line numberDiff line change
@@ -156,18 +156,30 @@ 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

170-
export interface Client<Paths extends {}, Media extends MediaType = MediaType> {
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+
182+
export type Client<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType = MediaType> = {
171183
/** Call a GET endpoint */
172184
GET: ClientMethod<Paths, "get", Media>;
173185
/** Call a PUT endpoint */
@@ -188,7 +200,9 @@ export interface Client<Paths extends {}, Media extends MediaType = MediaType> {
188200
use(...middleware: Middleware[]): void;
189201
/** Unregister middleware */
190202
eject(...middleware: Middleware[]): void;
191-
}
203+
} & {
204+
[Path in keyof Paths]: ClientForPath<Paths[Path], Media>;
205+
};
192206

193207
export default function createClient<Paths extends {}, Media extends MediaType = MediaType>(
194208
clientOptions?: ClientOptions,

packages/openapi-fetch/src/index.js

+22-33
Original file line numberDiff line numberDiff line change
@@ -176,39 +176,15 @@ export default function createClient(clientOptions) {
176176
return { error, response };
177177
}
178178

179-
return {
180-
/** Call a GET endpoint */
181-
async GET(url, init) {
182-
return coreFetch(url, { ...init, method: "GET" });
183-
},
184-
/** Call a PUT endpoint */
185-
async PUT(url, init) {
186-
return coreFetch(url, { ...init, method: "PUT" });
187-
},
188-
/** Call a POST endpoint */
189-
async POST(url, init) {
190-
return coreFetch(url, { ...init, method: "POST" });
191-
},
192-
/** Call a DELETE endpoint */
193-
async DELETE(url, init) {
194-
return coreFetch(url, { ...init, method: "DELETE" });
195-
},
196-
/** Call a OPTIONS endpoint */
197-
async OPTIONS(url, init) {
198-
return coreFetch(url, { ...init, method: "OPTIONS" });
199-
},
200-
/** Call a HEAD endpoint */
201-
async HEAD(url, init) {
202-
return coreFetch(url, { ...init, method: "HEAD" });
203-
},
204-
/** Call a PATCH endpoint */
205-
async PATCH(url, init) {
206-
return coreFetch(url, { ...init, method: "PATCH" });
207-
},
208-
/** Call a TRACE endpoint */
209-
async TRACE(url, init) {
210-
return coreFetch(url, { ...init, method: "TRACE" });
211-
},
179+
const methods = ["GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"];
180+
181+
const methodMembers = Object.fromEntries(
182+
methods.map((method) => [method, (url, init) => coreFetch(url, { ...init, method })]),
183+
);
184+
185+
const coreClient = {
186+
...methodMembers,
187+
212188
/** Register middleware */
213189
use(...middleware) {
214190
for (const m of middleware) {
@@ -231,6 +207,19 @@ export default function createClient(clientOptions) {
231207
}
232208
},
233209
};
210+
211+
const handler = {
212+
get: (coreClient, property) => {
213+
if (property in coreClient) {
214+
return coreClient[property];
215+
}
216+
217+
// Assume the property is an URL.
218+
return Object.fromEntries(methods.map((method) => [method, (init) => coreFetch(property, { ...init, method })]));
219+
},
220+
};
221+
222+
return new Proxy(coreClient, handler);
234223
}
235224

236225
// utils

packages/openapi-fetch/test/index.test.ts

+35-13
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,6 @@ afterEach(() => server.resetHandlers());
2121
afterAll(() => server.close());
2222

2323
describe("client", () => {
24-
it("generates all proper functions", () => {
25-
const client = createClient<paths>();
26-
27-
expect(client).toHaveProperty("GET");
28-
expect(client).toHaveProperty("PUT");
29-
expect(client).toHaveProperty("POST");
30-
expect(client).toHaveProperty("DELETE");
31-
expect(client).toHaveProperty("OPTIONS");
32-
expect(client).toHaveProperty("HEAD");
33-
expect(client).toHaveProperty("PATCH");
34-
expect(client).toHaveProperty("TRACE");
35-
});
36-
3724
describe("TypeScript checks", () => {
3825
it("marks data or error as undefined, but never both", async () => {
3926
const client = createClient<paths>({
@@ -1843,6 +1830,41 @@ describe("client", () => {
18431830
);
18441831
});
18451832
});
1833+
1834+
describe("URL as property style call", () => {
1835+
it("performs a call without params", async () => {
1836+
const client = createClient<paths>({ baseUrl });
1837+
const { getRequest } = useMockRequestHandler({
1838+
baseUrl,
1839+
method: "get",
1840+
path: "/anyMethod",
1841+
});
1842+
await client["/anyMethod"].GET();
1843+
expect(getRequest().method).toBe("GET");
1844+
});
1845+
1846+
it("performs a call with params", async () => {
1847+
const client = createClient<paths>({ baseUrl });
1848+
const { getRequestUrl } = useMockRequestHandler({
1849+
baseUrl,
1850+
method: "get",
1851+
path: "/blogposts/:post_id",
1852+
status: 200,
1853+
body: { message: "OK" },
1854+
});
1855+
1856+
await client["/blogposts/{post_id}"].GET({
1857+
// expect error on number instead of string.
1858+
// @ts-expect-error
1859+
params: { path: { post_id: 1234 } },
1860+
});
1861+
1862+
await client["/blogposts/{post_id}"].GET({
1863+
params: { path: { post_id: "1234" } },
1864+
});
1865+
expect(getRequestUrl().pathname).toBe("/blogposts/1234");
1866+
});
1867+
});
18461868
});
18471869

18481870
// test that the library behaves as expected inside commonly-used patterns

0 commit comments

Comments
 (0)