Skip to content

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

Merged
merged 4 commits into from
Aug 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor Author

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.

Copy link
Contributor

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)


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
22 changes: 20 additions & 2 deletions packages/openapi-fetch/test/index.bench.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import axios from "axios";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { Fetcher } from "openapi-typescript-fetch";
import { nanoid } from "nanoid";
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 @@ -41,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 @@ -60,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 @@ -74,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 @@ -96,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 @@ -113,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