Skip to content

Commit b8502ad

Browse files
committed
feat: only-pay-what-you-use alternative
1 parent 8ce8089 commit b8502ad

File tree

5 files changed

+240
-30
lines changed

5 files changed

+240
-30
lines changed

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

+8-2
Original file line numberDiff line numberDiff line change
@@ -171,16 +171,22 @@ const { data, error, response } = await client.GET("/url");
171171

172172
### Path-property style
173173

174-
Alternatively to passing the path as parameter, you can select it as a property on the client:
174+
If you prefer selecting the path as a property, you can create a path based client:
175175

176176
```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+
177182
client["/blogposts/{post_id}"].GET({
178183
params: { post_id: "my-post" },
179184
query: { version: 2 },
180185
});
181186
```
182187

183-
This is strictly equivalent to `.GET("/blogposts/{post_id}", { ... } )`.
188+
Note that this has performance implications and does not allow to attach middlewares directly.
189+
See [`wrapAsPathBasedClient`](/openapi-fetch/api#wrapAsPathBasedClient) for more.
184190

185191
## Support
186192

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

+17-4
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export type ClientForPath<PathInfo extends Record<HttpMethod, {}>, Media extends
179179
) => Promise<FetchResponse<PathInfo[Method], Init, Media>>;
180180
};
181181

182-
export type Client<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType = MediaType> = {
182+
export interface Client<Paths extends {}, Media extends MediaType = MediaType> {
183183
/** Call a GET endpoint */
184184
GET: ClientMethod<Paths, "get", Media>;
185185
/** Call a PUT endpoint */
@@ -200,14 +200,27 @@ export type Client<Paths extends Record<string, Record<HttpMethod, {}>>, Media e
200200
use(...middleware: Middleware[]): void;
201201
/** Unregister middleware */
202202
eject(...middleware: Middleware[]): void;
203-
} & {
204-
[Path in keyof Paths]: ClientForPath<Paths[Path], Media>;
205-
};
203+
}
206204

207205
export default function createClient<Paths extends {}, Media extends MediaType = MediaType>(
208206
clientOptions?: ClientOptions,
209207
): Client<Paths, Media>;
210208

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+
211224
/** Serialize primitive params to string */
212225
export declare function serializePrimitiveParam(
213226
name: string,

packages/openapi-fetch/src/index.js

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

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-
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+
},
188212
/** Register middleware */
189213
use(...middleware) {
190214
for (const m of middleware) {
@@ -207,19 +231,60 @@ export default function createClient(clientOptions) {
207231
}
208232
},
209233
};
234+
}
210235

211-
const handler = {
212-
get: (coreClient, property) => {
213-
if (property in coreClient) {
214-
return coreClient[property];
215-
}
236+
class UrlCallForwarder {
237+
constructor(client, url) {
238+
this.client = client;
239+
this.url = url;
240+
}
216241

217-
// Assume the property is an URL.
218-
return Object.fromEntries(methods.map((method) => [method, (init) => coreFetch(property, { ...init, method })]));
219-
},
220-
};
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+
}
221267

222-
return new Proxy(coreClient, handler);
268+
const clientProxyHandler = {
269+
// Assume the property is an URL.
270+
get: (coreClient, url) => new UrlCallForwarder(coreClient, url),
271+
};
272+
273+
/**
274+
* Wrap openapi-fetch client to support a path based API.
275+
* @type {import("./index.js").wrapAsPathBasedClient}
276+
*/
277+
export function wrapAsPathBasedClient(coreClient) {
278+
return new Proxy(coreClient, clientProxyHandler);
279+
}
280+
281+
/**
282+
* Convenience method to an openapi-fetch path based client.
283+
* Strictly equivalent to `wrapAsPathBasedClient(createClient(...))`.
284+
* @type {import("./index.js").createPathBasedClient}
285+
*/
286+
export function createPathBasedClient(clientOptions) {
287+
return wrapAsPathBasedClient(createClient(clientOptions));
223288
}
224289

225290
// utils

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

+94-5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import createClient, {
44
type Middleware,
55
type MiddlewareCallbackParams,
66
type QuerySerializerOptions,
7+
createPathBasedClient,
78
} from "../src/index.js";
89
import { server, baseUrl, useMockRequestHandler, toAbsoluteURL } from "./fixtures/mock-server.js";
910
import type { paths } from "./fixtures/api.js";
@@ -21,6 +22,19 @@ afterEach(() => server.resetHandlers());
2122
afterAll(() => server.close());
2223

2324
describe("client", () => {
25+
it("generates all proper functions", () => {
26+
const client = createClient<paths>();
27+
28+
expect(client).toHaveProperty("GET");
29+
expect(client).toHaveProperty("PUT");
30+
expect(client).toHaveProperty("POST");
31+
expect(client).toHaveProperty("DELETE");
32+
expect(client).toHaveProperty("OPTIONS");
33+
expect(client).toHaveProperty("HEAD");
34+
expect(client).toHaveProperty("PATCH");
35+
expect(client).toHaveProperty("TRACE");
36+
});
37+
2438
describe("TypeScript checks", () => {
2539
it("marks data or error as undefined, but never both", async () => {
2640
const client = createClient<paths>({
@@ -1858,9 +1872,10 @@ describe("client", () => {
18581872
});
18591873
});
18601874

1861-
describe("URL as property style call", () => {
1875+
describe("path based client", () => {
18621876
it("performs a call without params", async () => {
1863-
const client = createClient<paths>({ baseUrl });
1877+
const client = createPathBasedClient<paths>({ baseUrl });
1878+
18641879
const { getRequest } = useMockRequestHandler({
18651880
baseUrl,
18661881
method: "get",
@@ -1871,13 +1886,23 @@ describe("client", () => {
18711886
});
18721887

18731888
it("performs a call with params", async () => {
1874-
const client = createClient<paths>({ baseUrl });
1889+
const client = createPathBasedClient<paths>({ baseUrl });
18751890
const { getRequestUrl } = useMockRequestHandler({
18761891
baseUrl,
18771892
method: "get",
18781893
path: "/blogposts/:post_id",
18791894
status: 200,
1880-
body: { message: "OK" },
1895+
body: { title: "Blog post title" },
1896+
});
1897+
1898+
// Wrong method
1899+
// @ts-expect-error
1900+
await client["/blogposts/{post_id}"].POST({
1901+
params: {
1902+
// Unknown property `path`.
1903+
// @ts-expect-error
1904+
path: { post_id: "1234" },
1905+
},
18811906
});
18821907

18831908
await client["/blogposts/{post_id}"].GET({
@@ -1886,10 +1911,74 @@ describe("client", () => {
18861911
params: { path: { post_id: 1234 } },
18871912
});
18881913

1889-
await client["/blogposts/{post_id}"].GET({
1914+
const { data, error } = await client["/blogposts/{post_id}"].GET({
18901915
params: { path: { post_id: "1234" } },
18911916
});
1917+
18921918
expect(getRequestUrl().pathname).toBe("/blogposts/1234");
1919+
1920+
// Check typing of data.
1921+
if (error) {
1922+
// Fail, but we need the if above for type inference.
1923+
expect(error).toBeUndefined();
1924+
} else {
1925+
// @ts-expect-error
1926+
data.not_a_blogpost_property;
1927+
// Check typing of result value.
1928+
expect(data.title).toBe("Blog post title");
1929+
}
1930+
});
1931+
1932+
it("performs a POST call", async () => {
1933+
const client = createPathBasedClient<paths>({ baseUrl });
1934+
const { getRequest } = useMockRequestHandler({
1935+
baseUrl,
1936+
method: "post",
1937+
path: "/anyMethod",
1938+
});
1939+
await client["/anyMethod"].POST();
1940+
expect(getRequest().method).toBe("POST");
1941+
});
1942+
1943+
it("performs a PUT call with a request body", async () => {
1944+
const mockData = { status: "success" };
1945+
1946+
const client = createPathBasedClient<paths>({ baseUrl });
1947+
const { getRequestUrl } = useMockRequestHandler({
1948+
baseUrl,
1949+
method: "put",
1950+
path: "/blogposts",
1951+
status: 201,
1952+
body: mockData,
1953+
});
1954+
1955+
await client["/blogposts"].PUT({
1956+
body: {
1957+
title: "New Post",
1958+
body: "<p>Best post yet</p>",
1959+
// Should be a number, not a Date.
1960+
// @ts-expect-error
1961+
publish_date: new Date("2023-03-31T12:00:00Z"),
1962+
},
1963+
});
1964+
1965+
const { data, error, response } = await client["/blogposts"].PUT({
1966+
body: {
1967+
title: "New Post",
1968+
body: "<p>Best post yet</p>",
1969+
publish_date: new Date("2023-03-31T12:00:00Z").getTime(),
1970+
},
1971+
});
1972+
1973+
// assert correct URL was called
1974+
expect(getRequestUrl().pathname).toBe("/blogposts");
1975+
1976+
// assert correct data was returned
1977+
expect(data).toEqual(mockData);
1978+
expect(response.status).toBe(201);
1979+
1980+
// assert error is empty
1981+
expect(error).toBeUndefined();
18931982
});
18941983
});
18951984
});

0 commit comments

Comments
 (0)