From ff4b40ff72c7bce2471d4565b32da3cf58a70a78 Mon Sep 17 00:00:00 2001 From: Lucas Date: Sat, 28 Jun 2025 08:35:36 +0200 Subject: [PATCH 1/6] feat(openapi-fetch): add support for pathSerializer option - Introduced `pathSerializer` to `ClientOptions` for enhanced path handling. - Updated `createClient` function to utilize `pathSerializer` from options. - Modified `createFinalURL` to apply the provided `pathSerializer`. --- packages/openapi-fetch/src/index.d.ts | 7 +++++++ packages/openapi-fetch/src/index.js | 9 +++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) 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("?")) { From fadd03a64337c03a49dc58248b32e2ea31911052 Mon Sep 17 00:00:00 2001 From: Lucas Date: Sat, 28 Jun 2025 08:35:55 +0200 Subject: [PATCH 2/6] test: add pathSerializer tests for custom serialization --- .../openapi-fetch/test/common/params.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) 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", () => { From af533fc1649154241bfbc4fa96b18eae402f90b6 Mon Sep 17 00:00:00 2001 From: Lucas Date: Sat, 28 Jun 2025 08:44:23 +0200 Subject: [PATCH 3/6] chore: add changeset --- .changeset/late-moments-build.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/late-moments-build.md 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. From 620ba5acf8484267c559ec19666eac04ae4a48c6 Mon Sep 17 00:00:00 2001 From: Lucas Date: Sat, 28 Jun 2025 08:58:24 +0200 Subject: [PATCH 4/6] docs: update openapi-fetch docs --- docs/openapi-fetch/api.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/openapi-fetch/api.md b/docs/openapi-fetch/api.md index 9926adbbd..a3dd9436d 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](#pathserialization) | | (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,6 +36,7 @@ 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](#pathserialization) | | `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/"`) | | `fetch` | `fetch` | Fetch instance used for requests (default: fetch from `createClient`) | @@ -208,7 +210,33 @@ const { data, error } = await client.POST("/tokens", { }); ``` -## Path serialization +## Path Serializer + +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: From fbb3e16d5679f3442650eb45c7fcf7e376c34992 Mon Sep 17 00:00:00 2001 From: Lucas Date: Sat, 28 Jun 2025 08:59:58 +0200 Subject: [PATCH 5/6] chore: fix link --- docs/openapi-fetch/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/openapi-fetch/api.md b/docs/openapi-fetch/api.md index a3dd9436d..63b075ea2 100644 --- a/docs/openapi-fetch/api.md +++ b/docs/openapi-fetch/api.md @@ -210,7 +210,7 @@ const { data, error } = await client.POST("/tokens", { }); ``` -## Path Serializer +## 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. From 3ff91ad01892945b093733e7dfe53777af57b3f8 Mon Sep 17 00:00:00 2001 From: Lucas Date: Sat, 28 Jun 2025 09:01:22 +0200 Subject: [PATCH 6/6] chore: actually fix link --- docs/openapi-fetch/api.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/openapi-fetch/api.md b/docs/openapi-fetch/api.md index 63b075ea2..8461d7630 100644 --- a/docs/openapi-fetch/api.md +++ b/docs/openapi-fetch/api.md @@ -19,7 +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](#pathserialization) | +| `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 @@ -36,9 +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](#pathserialization) | +| `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)) |