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 2 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
54 changes: 54 additions & 0 deletions packages/openapi-fetch/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,60 @@ export default function createClient(clientOptions) {
};
}

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

const clientProxyHandler = {
// Assume the property is an URL.
get: (coreClient, url) => new UrlCallForwarder(coreClient, url),
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 could memoize UrlCallForwarder here on either:

  • An internal map
  • The proxied object itself (that would probably imply creating another intermediary object in wrapAsPathBasedClient to not pollute the core client).

However, I'm not sure it is worth the effort:

The lazy getter technique is primarily for statically known getter names. We can adjust it to work with proxies, but we'll still be left with an indirection through the proxy:

const clientProxyHandler = {
  // Assume the property is an URL.
  get: (coreClient, url) => {
    if (!(url in coreClient)) {
      // BAD: pollutes client, but OK for the example here.
      coreClient[url] = new UrlCallForwarder(coreClient, url);
    }
    return coreClient[url];
  }
}

Based on optimizing JS, on modern Chrome, Proxies lead to 30x slowdown (with trivial no-op indirection). At this point, I doubt that the additional object allocation is noticeable.

It feels like we're a bit stuck in this sub-optimal situation. Something we can do for users that want high-performance and path based calls is recommend pre-calculating the UrlCallForwarder:

const myMethod = client["/my-path"];

function performanceSensitive() {
  myMethod.GET(...);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hum, I just had an idea: We could potentially get around the proxy limitation by chaining prototypes strangely:

If we inherit (as in: prototype chain) from the proxy with the object we use to memoize the properties, that might mitigate the performance impact.

I'll give this a try.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just saw this improvement. Yeah I think this solves my concerns. I hate making “vibe-based” comments, especially when we don’t have solid benchmarking in the project. But once we give people a good DX like this, it’s tough to take it away again, and it’s easy to be stuck with any performance loss permanently.

But your explorations here seem to have found a happy medium of adding great DX but without incurring a noticeable perf hit (IMO having a proxy for 100% of calls would be a noticeable perf hit, and we managed to avoid that).

Overall I’m pretty happy with this approach!

};

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

/**
* 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
111 changes: 111 additions & 0 deletions packages/openapi-fetch/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import createClient, {
type Middleware,
type MiddlewareCallbackParams,
type QuerySerializerOptions,
createPathBasedClient,
} from "../src/index.js";
import { server, baseUrl, useMockRequestHandler, toAbsoluteURL } from "./fixtures/mock-server.js";
import type { paths } from "./fixtures/api.js";
Expand Down Expand Up @@ -1870,6 +1871,116 @@ describe("client", () => {
);
});
});

describe("path based client", () => {
it("performs a call without params", async () => {
const client = createPathBasedClient<paths>({ baseUrl });

const { getRequest } = useMockRequestHandler({
baseUrl,
method: "get",
path: "/anyMethod",
});
await client["/anyMethod"].GET();
expect(getRequest().method).toBe("GET");
});

it("performs a call with params", async () => {
const client = createPathBasedClient<paths>({ baseUrl });
const { getRequestUrl } = useMockRequestHandler({
baseUrl,
method: "get",
path: "/blogposts/:post_id",
status: 200,
body: { title: "Blog post title" },
});

// Wrong method
// @ts-expect-error
await client["/blogposts/{post_id}"].POST({
params: {
// Unknown property `path`.
// @ts-expect-error
path: { post_id: "1234" },
},
});

await client["/blogposts/{post_id}"].GET({
// expect error on number instead of string.
// @ts-expect-error
params: { path: { post_id: 1234 } },
});

const { data, error } = await client["/blogposts/{post_id}"].GET({
params: { path: { post_id: "1234" } },
});

expect(getRequestUrl().pathname).toBe("/blogposts/1234");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment: these are great tests, by the way! 🙂 I would also like to test this API for POST calls and fetches with requestbodies in addition to what you have here. But this is a great start.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added some more. They are all adapted from the base tests above.

I'm a bit unsure how much we want to cover this API: On one hand, it is a new public API, so we should probably err on the "too-many-tests-side". On the other hand, the integration is quite thin, so a lot of the core type inference and calling logic is re-used. So I'm not sure it makes sense to essentially test it twice.

WDYT?


// Check typing of data.
if (error) {
// Fail, but we need the if above for type inference.
expect(error).toBeUndefined();
} else {
// @ts-expect-error
data.not_a_blogpost_property;
// Check typing of result value.
expect(data.title).toBe("Blog post title");
}
});

it("performs a POST call", async () => {
const client = createPathBasedClient<paths>({ baseUrl });
const { getRequest } = useMockRequestHandler({
baseUrl,
method: "post",
path: "/anyMethod",
});
await client["/anyMethod"].POST();
expect(getRequest().method).toBe("POST");
});

it("performs a PUT call with a request body", async () => {
const mockData = { status: "success" };

const client = createPathBasedClient<paths>({ baseUrl });
const { getRequestUrl } = useMockRequestHandler({
baseUrl,
method: "put",
path: "/blogposts",
status: 201,
body: mockData,
});

await client["/blogposts"].PUT({
body: {
title: "New Post",
body: "<p>Best post yet</p>",
// Should be a number, not a Date.
// @ts-expect-error
publish_date: new Date("2023-03-31T12:00:00Z"),
},
});

const { data, error, response } = await client["/blogposts"].PUT({
body: {
title: "New Post",
body: "<p>Best post yet</p>",
publish_date: new Date("2023-03-31T12:00:00Z").getTime(),
},
});

// assert correct URL was called
expect(getRequestUrl().pathname).toBe("/blogposts");

// assert correct data was returned
expect(data).toEqual(mockData);
expect(response.status).toBe(201);

// assert error is empty
expect(error).toBeUndefined();
});
});
});

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