Skip to content

feat(openapi-fetch): add support for pathSerializer option #2362

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

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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/late-moments-build.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 30 additions & 2 deletions docs/openapi-fetch/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ createClient<paths>(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
Expand All @@ -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)) |
Expand Down Expand Up @@ -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:

Expand Down
7 changes: 7 additions & 0 deletions packages/openapi-fetch/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface ClientOptions extends Omit<RequestInit, "headers"> {
querySerializer?: QuerySerializer<unknown> | QuerySerializerOptions;
/** global bodySerializer */
bodySerializer?: BodySerializer<unknown>;
/** global pathSerializer */
pathSerializer?: PathSerializer;
headers?: HeadersOptions;
/** RequestInit extension object to pass as 2nd argument to fetch when supported (defaults to undefined) */
requestInitExt?: Record<string, unknown>;
Expand Down Expand Up @@ -64,6 +66,8 @@ export type QuerySerializerOptions = {

export type BodySerializer<T> = (body: OperationRequestBodyContent<T>) => any;

export type PathSerializer = (pathname: string, pathParams: Record<string, unknown>) => string;

type BodyType<T = unknown> = {
json: T;
text: Awaited<ReturnType<Response["text"]>>;
Expand Down Expand Up @@ -117,6 +121,7 @@ export type RequestOptions<T> = ParamsOption<T> &
baseUrl?: string;
querySerializer?: QuerySerializer<T> | QuerySerializerOptions;
bodySerializer?: BodySerializer<T>;
pathSerializer?: PathSerializer;
parseAs?: ParseAs;
fetch?: ClientOptions["fetch"];
headers?: HeadersOptions;
Expand All @@ -127,6 +132,7 @@ export type MergedOptions<T = unknown> = {
parseAs: ParseAs;
querySerializer: QuerySerializer<T>;
bodySerializer: BodySerializer<T>;
pathSerializer: PathSerializer;
fetch: typeof globalThis.fetch;
};

Expand Down Expand Up @@ -323,6 +329,7 @@ export declare function createFinalURL<O>(
path?: Record<string, unknown>;
};
querySerializer: QuerySerializer<O>;
pathSerializer: PathSerializer;
},
): string;

Expand Down
9 changes: 7 additions & 2 deletions packages/openapi-fetch/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default function createClient(clientOptions) {
fetch: baseFetch = globalThis.fetch,
querySerializer: globalQuerySerializer,
bodySerializer: globalBodySerializer,
pathSerializer: globalPathSerializer,
headers: baseHeaders,
requestInitExt = undefined,
...baseOptions
Expand All @@ -51,6 +52,7 @@ export default function createClient(clientOptions) {
parseAs = "json",
querySerializer: requestQuerySerializer,
bodySerializer = globalBodySerializer ?? defaultBodySerializer,
pathSerializer: requestPathSerializer,
body,
...init
} = fetchOptions || {};
Expand All @@ -73,6 +75,8 @@ export default function createClient(clientOptions) {
});
}

const pathSerializer = requestPathSerializer || globalPathSerializer || defaultPathSerializer;

const serializedBody =
body === undefined
? undefined
Expand Down Expand Up @@ -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;
Expand All @@ -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") {
Expand Down Expand Up @@ -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("?")) {
Expand Down
100 changes: 100 additions & 0 deletions packages/openapi-fetch/test/common/params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<paths>(
{
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<paths>(
{
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<paths>(
{
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", () => {
Expand Down