Skip to content

Commit

Permalink
feat: support client["/endpoint"].GET() style calls (#1791)
Browse files Browse the repository at this point in the history
* feat: support `client["/endpoint"].GET()` style calls

* feat: only-pay-what-you-use alternative

* Use prototype chain to memoize PathCallForwarder (and rename)

* Add benchmarks
  • Loading branch information
gzm0 authored Aug 10, 2024
1 parent 84ce19a commit a956d5d
Show file tree
Hide file tree
Showing 7 changed files with 301 additions and 4 deletions.
5 changes: 5 additions & 0 deletions .changeset/stale-donuts-smoke.md
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
37 changes: 37 additions & 0 deletions docs/openapi-fetch/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,43 @@ client.GET("/my-url", options);
| `middleware` | `Middleware[]` | [See docs](/openapi-fetch/middleware-auth) |
| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal`, …) ([docs](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options)) |

## wrapAsPathBasedClient

**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:

```ts
const client = createClient<paths>(clientOptions);
const pathBasedClient = wrapAsPathBasedClient(client);

pathBasedClient["/my-url"].GET(fetchOptions);
```

The `fetchOptions` are the same than for the base client.

A path based client can lead to better type inference but comes at a runtime cost due to the use of a Proxy.

**createPathBasedClient** is a convenience method combining `createClient` and `wrapAsPathBasedClient` if you only want to use the path based call style:

```ts
const client = createPathBasedClient<paths>(clientOptions);

client["/my-url"].GET(fetchOptions);
```

Note that it does not allow you to attach middlewares. If you need middlewares, you need to use the full form:

```ts
const client = createClient<paths>(clientOptions);

client.use(...);

const pathBasedClient = wrapAsPathBasedClient(client);

client.use(...); // the client reference is shared, so the middlewares will propagate.

pathBasedClient["/my-url"].GET(fetchOptions);
```

## querySerializer

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).
Expand Down
19 changes: 19 additions & 0 deletions docs/openapi-fetch/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

If you prefer selecting the path as a property, you can create a path based client:

```ts
import { createPathBasedClient } from "openapi-fetch";
import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typescript

const client = createPathBasedClient<paths>({ baseUrl: "https://myapi.dev/v1" });

client["/blogposts/{post_id}"].GET({
params: { post_id: "my-post" },
query: { version: 2 },
});
```

Note that this has performance implications and does not allow to attach middlewares directly.
See [`wrapAsPathBasedClient`](/openapi-fetch/api#wrapAsPathBasedClient) for more.

## Support

| Platform | Support |
Expand Down
33 changes: 30 additions & 3 deletions packages/openapi-fetch/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,17 +156,29 @@ export type MaybeOptionalInit<Params extends Record<HttpMethod, {}>, Location ex
? FetchOptions<FilterKeys<Params, Location>> | undefined
: FetchOptions<FilterKeys<Params, Location>>;

// The final init param to accept.
// - Determines if the param is optional or not.
// - Performs arbitrary [key: string] addition.
// Note: the addition It MUST happen after all the inference happens (otherwise TS can’t infer if init is required or not).
type InitParam<Init> = HasRequiredKeys<Init> extends never
? [(Init & { [key: string]: unknown })?]
: [Init & { [key: string]: unknown }];

export type ClientMethod<
Paths extends Record<string, Record<HttpMethod, {}>>,
Method extends HttpMethod,
Media extends MediaType,
> = <Path extends PathsWithMethod<Paths, Method>, Init extends MaybeOptionalInit<Paths[Path], Method>>(
url: Path,
...init: HasRequiredKeys<Init> extends never
? [(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)
: [Init & { [key: string]: unknown }]
...init: InitParam<Init>
) => Promise<FetchResponse<Paths[Path][Method], Init, Media>>;

export type ClientForPath<PathInfo extends Record<HttpMethod, {}>, Media extends MediaType> = {
[Method in keyof PathInfo as Uppercase<string & Method>]: <Init extends MaybeOptionalInit<PathInfo, Method>>(
...init: InitParam<Init>
) => Promise<FetchResponse<PathInfo[Method], Init, Media>>;
};

export interface Client<Paths extends {}, Media extends MediaType = MediaType> {
/** Call a GET endpoint */
GET: ClientMethod<Paths, "get", Media>;
Expand Down Expand Up @@ -194,6 +206,21 @@ export default function createClient<Paths extends {}, Media extends MediaType =
clientOptions?: ClientOptions,
): Client<Paths, Media>;

export type PathBasedClient<
Paths extends Record<string, Record<HttpMethod, {}>>,
Media extends MediaType = MediaType,
> = {
[Path in keyof Paths]: ClientForPath<Paths[Path], Media>;
};

export declare function wrapAsPathBasedClient<Paths extends {}, Media extends MediaType = MediaType>(
client: Client<Paths, Media>,
): PathBasedClient<Paths, Media>;

export declare function createPathBasedClient<Paths extends {}, Media extends MediaType = MediaType>(
clientOptions?: ClientOptions,
): PathBasedClient<Paths, Media>;

/** Serialize primitive params to string */
export declare function serializePrimitiveParam(
name: string,
Expand Down
79 changes: 79 additions & 0 deletions packages/openapi-fetch/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,85 @@ export default function createClient(clientOptions) {
};
}

class PathCallForwarder {
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);
}
}

class PathClientProxyHandler {
constructor() {
this.client = null;
}

// Assume the property is an URL.
get(coreClient, url) {
const forwarder = new PathCallForwarder(coreClient, url);
this.client[url] = forwarder;
return forwarder;
}
}

/**
* Wrap openapi-fetch client to support a path based API.
* @type {import("./index.js").wrapAsPathBasedClient}
*/
export function wrapAsPathBasedClient(coreClient) {
const handler = new PathClientProxyHandler();
const proxy = new Proxy(coreClient, handler);

// Put the proxy on the prototype chain of the actual client.
// This means if we do not have a memoized PathCallForwarder,
// we fall back to the proxy to synthesize it.
// However, the proxy itself is not on the hot-path (if we fetch the same
// endpoint multiple times, only the first call will hit the proxy).
function Client() {}
Client.prototype = proxy;

const client = new Client();

// Feed the client back to the proxy handler so it can store the generated
// PathCallForwarder.
handler.client = client;

return client;
}

/**
* 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

/**
Expand Down
21 changes: 20 additions & 1 deletion packages/openapi-fetch/test/index.bench.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { setupServer } from "msw/node";
import { Fetcher } from "openapi-typescript-fetch";
import superagent from "superagent";
import { afterAll, bench, describe } from "vitest";
import createClient from "../dist/index.js";
import createClient, { createPathBasedClient } from "../dist/index.js";
import * as openapiTSCodegen from "./fixtures/openapi-typescript-codegen.min.js";

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

bench("openapi-fetch (path based)", async () => {
createPathBasedClient({ baseUrl: BASE_URL });
});

bench("openapi-typescript-fetch", async () => {
const fetcher = Fetcher.for();
fetcher.configure({
Expand All @@ -59,6 +63,7 @@ describe("setup", () => {

describe("get (only URL)", () => {
const openapiFetch = createClient({ baseUrl: BASE_URL });
const openapiFetchPath = createPathBasedClient({ baseUrl: BASE_URL });
const openapiTSFetch = Fetcher.for();
openapiTSFetch.configure({
baseUrl: BASE_URL,
Expand All @@ -73,6 +78,10 @@ describe("get (only URL)", () => {
await openapiFetch.GET("/url");
});

bench("openapi-fetch (path based)", async () => {
await openapiFetchPath["/url"].GET();
});

bench("openapi-typescript-fetch", async () => {
await openapiTSFetchGET();
});
Expand All @@ -95,6 +104,10 @@ describe("get (headers)", () => {
baseUrl: BASE_URL,
headers: { "x-base-header": 123 },
});
const openapiFetchPath = createPathBasedClient({
baseUrl: BASE_URL,
headers: { "x-base-header": 123 },
});
const openapiTSFetch = Fetcher.for();
openapiTSFetch.configure({
baseUrl: BASE_URL,
Expand All @@ -112,6 +125,12 @@ describe("get (headers)", () => {
});
});

bench("openapi-fetch (path based)", async () => {
await openapiFetchPath["/url"].GET({
headers: { "x-header-1": 123, "x-header-2": 456 },
});
});

bench("openapi-typescript-fetch", async () => {
await openapiTSFetchGET(null, {
headers: { "x-header-1": 123, "x-header-2": 456 },
Expand Down
Loading

0 comments on commit a956d5d

Please sign in to comment.