diff --git a/.changeset/stale-donuts-smoke.md b/.changeset/stale-donuts-smoke.md new file mode 100644 index 000000000..6fca93c01 --- /dev/null +++ b/.changeset/stale-donuts-smoke.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": minor +--- + +Add support for `client["/endpoint"].GET()` style calls diff --git a/docs/openapi-fetch/api.md b/docs/openapi-fetch/api.md index 0a337aaae..542f632b0 100644 --- a/docs/openapi-fetch/api.md +++ b/docs/openapi-fetch/api.md @@ -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(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(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(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). diff --git a/docs/openapi-fetch/index.md b/docs/openapi-fetch/index.md index 619c7d9c1..1f57879fa 100644 --- a/docs/openapi-fetch/index.md +++ b/docs/openapi-fetch/index.md @@ -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({ 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 | diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts index 2ba91147f..2dde5350a 100644 --- a/packages/openapi-fetch/src/index.d.ts +++ b/packages/openapi-fetch/src/index.d.ts @@ -156,17 +156,29 @@ export type MaybeOptionalInit, Location ex ? FetchOptions> | undefined : FetchOptions>; +// 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 = HasRequiredKeys extends never + ? [(Init & { [key: string]: unknown })?] + : [Init & { [key: string]: unknown }]; + export type ClientMethod< Paths extends Record>, Method extends HttpMethod, Media extends MediaType, > = , Init extends MaybeOptionalInit>( url: Path, - ...init: HasRequiredKeys 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 ) => Promise>; +export type ClientForPath, Media extends MediaType> = { + [Method in keyof PathInfo as Uppercase]: >( + ...init: InitParam + ) => Promise>; +}; + export interface Client { /** Call a GET endpoint */ GET: ClientMethod; @@ -194,6 +206,21 @@ export default function createClient; +export type PathBasedClient< + Paths extends Record>, + Media extends MediaType = MediaType, +> = { + [Path in keyof Paths]: ClientForPath; +}; + +export declare function wrapAsPathBasedClient( + client: Client, +): PathBasedClient; + +export declare function createPathBasedClient( + clientOptions?: ClientOptions, +): PathBasedClient; + /** Serialize primitive params to string */ export declare function serializePrimitiveParam( name: string, diff --git a/packages/openapi-fetch/src/index.js b/packages/openapi-fetch/src/index.js index e878ef0b8..ded445de6 100644 --- a/packages/openapi-fetch/src/index.js +++ b/packages/openapi-fetch/src/index.js @@ -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 /** diff --git a/packages/openapi-fetch/test/index.bench.js b/packages/openapi-fetch/test/index.bench.js index 99463b3ba..fdf780870 100644 --- a/packages/openapi-fetch/test/index.bench.js +++ b/packages/openapi-fetch/test/index.bench.js @@ -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"; @@ -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({ @@ -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, @@ -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(); }); @@ -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, @@ -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 }, diff --git a/packages/openapi-fetch/test/index.test.ts b/packages/openapi-fetch/test/index.test.ts index 0055dcba7..fe79c4e15 100644 --- a/packages/openapi-fetch/test/index.test.ts +++ b/packages/openapi-fetch/test/index.test.ts @@ -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"; @@ -1870,6 +1871,116 @@ describe("client", () => { ); }); }); + + describe("path based client", () => { + it("performs a call without params", async () => { + const client = createPathBasedClient({ 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({ 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"); + + // 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({ 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({ baseUrl }); + const { getRequestUrl } = useMockRequestHandler({ + baseUrl, + method: "put", + path: "/blogposts", + status: 201, + body: mockData, + }); + + await client["/blogposts"].PUT({ + body: { + title: "New Post", + body: "

Best post yet

", + // 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: "

Best post yet

", + 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