diff --git a/.changeset/late-moments-build.md b/.changeset/late-moments-build.md new file mode 100644 index 000000000..03ee57e78 --- /dev/null +++ b/.changeset/late-moments-build.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": minor +--- + +Added support for setting a custom path serializers either globally or per request. This allows you to customize how path parameters are serialized in the URL. E.g. you can use a custom serializer to prevent encoding of a path parameter, if you need to pass a value that should not be encoded. diff --git a/docs/openapi-fetch/api.md b/docs/openapi-fetch/api.md index 9926adbbd..8461d7630 100644 --- a/docs/openapi-fetch/api.md +++ b/docs/openapi-fetch/api.md @@ -19,6 +19,7 @@ createClient(options); | `fetch` | `fetch` | Fetch instance used for requests (default: `globalThis.fetch`) | | `querySerializer` | QuerySerializer | (optional) Provide a [querySerializer](#queryserializer) | | `bodySerializer` | BodySerializer | (optional) Provide a [bodySerializer](#bodyserializer) | +| `pathSerializer` | PathSerializer | (optional) Provide a [pathSerializer](#pathserializer) | | (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) ([docs](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options) | ## Fetch options @@ -35,8 +36,9 @@ client.GET("/my-url", options); | `body` | `{ [name]:value }` | [requestBody](https://spec.openapis.org/oas/latest.html#request-body-object) data for the endpoint | | `querySerializer` | QuerySerializer | (optional) Provide a [querySerializer](#queryserializer) | | `bodySerializer` | BodySerializer | (optional) Provide a [bodySerializer](#bodyserializer) | +| `pathSerializer` | PathSerializer | (optional) Provide a [pathSerializer](#pathserializer) | | `parseAs` | `"json"` \| `"text"` \| `"arrayBuffer"` \| `"blob"` \| `"stream"` | (optional) Parse the response using [a built-in instance method](https://developer.mozilla.org/en-US/docs/Web/API/Response#instance_methods) (default: `"json"`). `"stream"` skips parsing altogether and returns the raw stream. | -| `baseUrl` | `string` | Prefix the fetch URL with this option (e.g. `"https://myapi.dev/v1/"`) | +| `baseUrl` | `string` | Prefix the fetch URL with this option (e.g. `"https://myapi.dev/v1/"`) | | `fetch` | `fetch` | Fetch instance used for requests (default: fetch from `createClient`) | | `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)) | @@ -208,7 +210,33 @@ const { data, error } = await client.POST("/tokens", { }); ``` -## Path serialization +## pathSerializer + +Similar to [querySerializer](#queryserializer) and [bodySerializer](#bodyserializer), `pathSerializer` allows you to customize how path parameters are serialized. This is useful when your API uses a non-standard path serialization format, or you want to change the default behavior. + +### Custom Path Serializer + +You can provide a custom path serializer when creating the client: + +```ts +const client = createClient({ + pathSerializer(pathname, pathParams) { + let result = pathname; + for (const [key, value] of Object.entries(pathParams)) { + result = result.replace(`{${key}}`, `[${value}]`); + } + return result; + }, +}); + +const { data, error } = await client.GET("/users/{id}", { + params: { path: { id: 5 } }, +}); + +// URL: `/users/[5]` +``` + +### Default Path Serializer openapi-fetch supports path serialization as [outlined in the 3.1 spec](https://swagger.io/docs/specification/serialization/#path). This happens automatically, based on the specific format in your OpenAPI schema: diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts index 79dec3d77..7f70c0d6b 100644 --- a/packages/openapi-fetch/src/index.d.ts +++ b/packages/openapi-fetch/src/index.d.ts @@ -23,6 +23,8 @@ export interface ClientOptions extends Omit { querySerializer?: QuerySerializer | QuerySerializerOptions; /** global bodySerializer */ bodySerializer?: BodySerializer; + /** global pathSerializer */ + pathSerializer?: PathSerializer; headers?: HeadersOptions; /** RequestInit extension object to pass as 2nd argument to fetch when supported (defaults to undefined) */ requestInitExt?: Record; @@ -64,6 +66,8 @@ export type QuerySerializerOptions = { export type BodySerializer = (body: OperationRequestBodyContent) => any; +export type PathSerializer = (pathname: string, pathParams: Record) => string; + type BodyType = { json: T; text: Awaited>; @@ -117,6 +121,7 @@ export type RequestOptions = ParamsOption & baseUrl?: string; querySerializer?: QuerySerializer | QuerySerializerOptions; bodySerializer?: BodySerializer; + pathSerializer?: PathSerializer; parseAs?: ParseAs; fetch?: ClientOptions["fetch"]; headers?: HeadersOptions; @@ -127,6 +132,7 @@ export type MergedOptions = { parseAs: ParseAs; querySerializer: QuerySerializer; bodySerializer: BodySerializer; + pathSerializer: PathSerializer; fetch: typeof globalThis.fetch; }; @@ -323,6 +329,7 @@ export declare function createFinalURL( path?: Record; }; querySerializer: QuerySerializer; + pathSerializer: PathSerializer; }, ): string; diff --git a/packages/openapi-fetch/src/index.js b/packages/openapi-fetch/src/index.js index 4b226c870..987a34852 100644 --- a/packages/openapi-fetch/src/index.js +++ b/packages/openapi-fetch/src/index.js @@ -28,6 +28,7 @@ export default function createClient(clientOptions) { fetch: baseFetch = globalThis.fetch, querySerializer: globalQuerySerializer, bodySerializer: globalBodySerializer, + pathSerializer: globalPathSerializer, headers: baseHeaders, requestInitExt = undefined, ...baseOptions @@ -51,6 +52,7 @@ export default function createClient(clientOptions) { parseAs = "json", querySerializer: requestQuerySerializer, bodySerializer = globalBodySerializer ?? defaultBodySerializer, + pathSerializer: requestPathSerializer, body, ...init } = fetchOptions || {}; @@ -73,6 +75,8 @@ export default function createClient(clientOptions) { }); } + const pathSerializer = requestPathSerializer || globalPathSerializer || defaultPathSerializer; + const serializedBody = body === undefined ? undefined @@ -110,7 +114,7 @@ export default function createClient(clientOptions) { let id; let options; let request = new CustomRequest( - createFinalURL(schemaPath, { baseUrl: finalBaseUrl, params, querySerializer }), + createFinalURL(schemaPath, { baseUrl: finalBaseUrl, params, querySerializer, pathSerializer }), requestInit, ); let response; @@ -132,6 +136,7 @@ export default function createClient(clientOptions) { parseAs, querySerializer, bodySerializer, + pathSerializer, }); for (const m of middlewares) { if (m && typeof m === "object" && typeof m.onRequest === "function") { @@ -615,7 +620,7 @@ export function defaultBodySerializer(body, headers) { export function createFinalURL(pathname, options) { let finalURL = `${options.baseUrl}${pathname}`; if (options.params?.path) { - finalURL = defaultPathSerializer(finalURL, options.params.path); + finalURL = options.pathSerializer(finalURL, options.params.path); } let search = options.querySerializer(options.params.query ?? {}); if (search.startsWith("?")) { diff --git a/packages/openapi-fetch/test/common/params.test.ts b/packages/openapi-fetch/test/common/params.test.ts index 67a322bf6..e6f7fb475 100644 --- a/packages/openapi-fetch/test/common/params.test.ts +++ b/packages/openapi-fetch/test/common/params.test.ts @@ -191,6 +191,106 @@ describe("params", () => { // expect post_id to be encoded properly expect(actualPathname).toBe("/path-params/%F0%9F%A5%B4"); }); + + describe("pathSerializer", () => { + test("global", async () => { + let actualPathname = ""; + const client = createObservedClient( + { + pathSerializer: (pathname, pathParams) => { + // Custom serializer that wraps path values in brackets + let result = pathname; + for (const [key, value] of Object.entries(pathParams)) { + result = result.replace(`{${key}}`, `[${value}]`); + } + return result; + }, + }, + async (req) => { + actualPathname = new URL(req.url).pathname; + return Response.json({}); + }, + ); + + await client.GET("/resources/{id}", { + params: { + path: { id: 123 }, + }, + }); + + expect(actualPathname).toBe("/resources/[123]"); + }); + + test("per-request", async () => { + let actualPathname = ""; + const client = createObservedClient( + { + pathSerializer: (pathname, pathParams) => { + // Default global serializer (should be overridden) + let result = pathname; + for (const [key, value] of Object.entries(pathParams)) { + result = result.replace(`{${key}}`, `global-${value}`); + } + return result; + }, + }, + async (req) => { + actualPathname = new URL(req.url).pathname; + return Response.json({}); + }, + ); + + await client.GET("/resources/{id}", { + params: { + path: { id: 456 }, + }, + pathSerializer: (pathname, pathParams) => { + // Per-request serializer should override global + let result = pathname; + for (const [key, value] of Object.entries(pathParams)) { + result = result.replace(`{${key}}`, `request-${value}`); + } + return result; + }, + }); + + expect(actualPathname).toBe("/resources/request-456"); + }); + + test("complex path params with custom serializer", async () => { + let actualPathname = ""; + const client = createObservedClient( + { + pathSerializer: (pathname, pathParams) => { + // Custom serializer that handles different value types + let result = pathname; + for (const [key, value] of Object.entries(pathParams)) { + if (typeof value === "string") { + result = result.replace(`{${key}}`, `custom:${value}`); + } else { + result = result.replace(`{${key}}`, `other:${value}`); + } + } + return result; + }, + }, + async (req) => { + actualPathname = new URL(req.url).pathname; + return Response.json({}); + }, + ); + + await client.GET("/path-params/{string}", { + params: { + path: { + string: "test-value", + }, + }, + }); + + expect(actualPathname).toBe("/path-params/custom:test-value"); + }); + }); }); describe("header", () => {