Skip to content

Commit 2bbeb92

Browse files
committed
Improve query + path serialization
1 parent 61f408a commit 2bbeb92

File tree

14 files changed

+1336
-358
lines changed

14 files changed

+1336
-358
lines changed

.changeset/lovely-needles-try.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-fetch": patch
3+
---
4+
5+
Add support for automatic label & matrix path serialization.

.changeset/shiny-trees-eat.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-fetch": patch
3+
---
4+
5+
Remove leading question marks from querySerializer

.changeset/spicy-kings-wait.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-fetch": minor
3+
---
4+
5+
⚠️ Breaking change: no longer supports deeply-nested objects/arrays for query & path serialization.

docs/data/contributors.json

+1-1
Large diffs are not rendered by default.

docs/openapi-fetch/api.md

+59-9
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ createClient<paths>(options);
1818
| `baseUrl` | `string` | Prefix all fetch URLs with this option (e.g. `"https://myapi.dev/v1/"`) |
1919
| `fetch` | `fetch` | Fetch instance used for requests (default: `globalThis.fetch`) |
2020
| `querySerializer` | QuerySerializer | (optional) Provide a [querySerializer](#queryserializer) |
21+
| `pathSerializer` | PathSerializer | (optional) Provide a [pathSerializer](#pathserializer) |
2122
| `bodySerializer` | BodySerializer | (optional) Provide a [bodySerializer](#bodyserializer) |
2223
| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) ([docs](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options) |
2324

@@ -34,36 +35,85 @@ client.get("/my-url", options);
3435
| `params` | ParamsObject | [path](https://swagger.io/specification/#parameter-locations) and [query](https://swagger.io/specification/#parameter-locations) params for the endpoint |
3536
| `body` | `{ [name]:value }` | [requestBody](https://spec.openapis.org/oas/latest.html#request-body-object) data for the endpoint |
3637
| `querySerializer` | QuerySerializer | (optional) Provide a [querySerializer](#queryserializer) |
38+
| `pathSerializer` | PathSerializer | (optional) Provide a [pathSerializer](#pathserializer) |
3739
| `bodySerializer` | BodySerializer | (optional) Provide a [bodySerializer](#bodyserializer) |
3840
| `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. |
3941
| `fetch` | `fetch` | Fetch instance used for requests (default: fetch from `createClient`) |
4042
| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal`, …) ([docs](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options)) |
4143

4244
### querySerializer
4345

44-
By default, this library serializes query parameters using `style: form` and `explode: true` [according to the OpenAPI specification](https://swagger.io/docs/specification/serialization/#query). To change the default behavior, you can supply your own `querySerializer()` function either on the root `createClient()` as well as optionally on an individual request. This is useful if your backend expects modifications like the addition of `[]` for array params:
46+
String, number, and boolean query params are straightforward when forming a request, but arrays and objects not so much. OpenAPI supports [different ways of handling each](https://swagger.io/docs/specification/serialization/#query). By default, this library serializes arrays using `style: "form", explode: true`, and objects using `style: "deepObject", explode: true`.
47+
48+
To change that behavior, you can supply `querySerializer` options that control how `object` and `arrays` are serialized for query params. This can either be passed on `createClient()` to control every request, or on individual requests for just one:
49+
50+
| Option | Type | Description |
51+
| :-------------- | :---------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------- |
52+
| `array` | SerializerOptions | Set `style` and `explode` for arrays ([docs](https://swagger.io/docs/specification/serialization/#query)). Default: `{ style: "form", explode: true }`. |
53+
| `object` | SerializerOptions | Set `style` and `explode` for objects ([docs](https://swagger.io/docs/specification/serialization/#query)). Default: `{ style: "deepObject", explode: true }`. |
54+
| `allowReserved` | `boolean` | Set to `true` to skip URL encoding (⚠️ may break the request) ([docs](https://swagger.io/docs/specification/serialization/#query)). Default: `false`. |
4555

4656
```ts
47-
const { data, error } = await GET("/search", {
48-
params: {
49-
query: { tags: ["food", "california", "healthy"] },
57+
const client = createClient({
58+
querySerializer: {
59+
array: {
60+
style: "pipeDelimited", // "form" (default) | "spaceDelimited" | "pipeDelimited"
61+
explode: true,
62+
},
63+
object: {
64+
style: "form", // "form" | "deepObject" (default)
65+
explode: true,
66+
},
5067
},
51-
querySerializer(q) {
68+
});
69+
```
70+
71+
#### Function
72+
73+
Sometimes your backend doesn’t use one of the standard serialization methods, in which case you can pass a function to `querySerializer` to serialize the entire string yourself with no restrictions:
74+
75+
```ts
76+
const client = createClient({
77+
querySerializer(queryParam) {
5278
let s = [];
53-
for (const [k, v] of Object.entries(q)) {
79+
for (const [k, v] of Object.entries(queryParam)) {
5480
if (Array.isArray(v)) {
5581
for (const i of v) {
56-
s.push(`${k}[]=${i}`);
82+
s.push(`${k}[]=${encodeURIComponent(i)}`);
5783
}
5884
} else {
59-
s.push(`${k}=${v}`);
85+
s.push(`${k}=${encodeURLComponent(v)}`);
6086
}
6187
}
62-
return s.join("&"); // ?tags[]=food&tags[]=california&tags[]=healthy
88+
return encodeURI(s.join(",")); // ?tags[]=food,tags[]=california,tags[]=healthy
6389
},
6490
});
6591
```
6692

93+
::: warning
94+
95+
When serializing yourself, the string will be kept exactly as-authored, so you’ll have to call [encodeURI](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI) or [encodeURIComponent](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) to escape special characters.
96+
97+
:::
98+
99+
### pathSerializer
100+
101+
If your backend doesn’t use the standard `{param_name}` syntax, you can control the behavior of how path params are serialized according to the spec ([docs](https://swagger.io/docs/specification/serialization/#path)):
102+
103+
```ts
104+
const client = createClient({
105+
pathSerializer: {
106+
style: "label", // "simple" (default) | "label" | "matrix"
107+
},
108+
});
109+
```
110+
111+
::: info
112+
113+
The `explode` behavior ([docs](https://swagger.io/docs/specification/serialization/#path)) is determined automatically by the pathname, depending on whether an asterisk suffix is present or not, e.g. `/users/{id}` vs `/users/{id*}`. Globs are **NOT** supported, and the param name must still match exactly; the asterisk is only a suffix.
114+
115+
:::
116+
67117
### bodySerializer
68118

69119
Similar to [querySerializer](#querySerializer), bodySerializer allows you to customize how the requestBody is serialized if you don’t want the default [JSON.stringify()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) behavior. You probably only need this when using `multipart/form-data`:

docs/openapi-fetch/index.md

+2
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ openapi-fetch infers types from the URL. Prefer static string values over dynami
139139

140140
:::
141141

142+
This library also supports the **label** and **matrix** serialization styles as well ([docs](https://swagger.io/docs/specification/serialization/#path)) automatically.
143+
142144
### Request
143145

144146
The `GET()` request shown needed the `params` object that groups [parameters by type](https://spec.openapis.org/oas/latest.html#parameter-object) (`path` or `query`). If a required param is missing, or the wrong type, a type error will be thrown.

packages/openapi-fetch/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"lint": "pnpm run \"/^lint:/\"",
5656
"lint:js": "eslint \"{src,test}/**/*.{js,ts}\"",
5757
"lint:prettier": "prettier --check \"{src,test}/**/*\"",
58-
"generate-types": "cd ../openapi-typescript && pnpm run build && cd ../openapi-fetch ../openapi-typescript/bin/cli.js ./test/fixtures/api.yaml -o ./test/fixtures/v7-beta.test.ts && npx openapi-typescript ./test/fixtures/api.yaml -o ./test/fixtures/api.d.ts",
58+
"generate-types": "cd ../openapi-typescript && pnpm run build && cd ../openapi-fetch && ../openapi-typescript/bin/cli.js ./test/fixtures/api.yaml -o ./test/fixtures/v7-beta.d.ts && npx openapi-typescript ./test/fixtures/api.yaml -o ./test/fixtures/api.d.ts",
5959
"pretest": "pnpm run generate-types",
6060
"test": "pnpm run \"/^test:/\"",
6161
"test:js": "vitest run",

packages/openapi-fetch/src/index.d.ts

+76-9
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export interface ClientOptions extends Omit<RequestInit, "headers"> {
2020
/** custom fetch (defaults to globalThis.fetch) */
2121
fetch?: typeof fetch;
2222
/** global querySerializer */
23-
querySerializer?: QuerySerializer<unknown>;
23+
querySerializer?: QuerySerializer<unknown> | QuerySerializerOptions;
2424
/** global bodySerializer */
2525
bodySerializer?: BodySerializer<unknown>;
2626
headers?: HeadersOptions;
@@ -36,6 +36,32 @@ export type QuerySerializer<T> = (
3636
: Record<string, unknown>,
3737
) => string;
3838

39+
/** @see https://swagger.io/docs/specification/serialization/#query */
40+
export type QuerySerializerOptions = {
41+
/** Set serialization for arrays. @see https://swagger.io/docs/specification/serialization/#query */
42+
array?: {
43+
/** default: "form" */
44+
style: "form" | "spaceDelimited" | "pipeDelimited";
45+
/** default: true */
46+
explode: boolean;
47+
};
48+
/** Set serialization for objects. @see https://swagger.io/docs/specification/serialization/#query */
49+
object?: {
50+
/** default: "deepObject" */
51+
style: "form" | "deepObject";
52+
/** default: true */
53+
explode: boolean;
54+
};
55+
/**
56+
* The `allowReserved` keyword specifies whether the reserved characters
57+
* `:/?#[]@!$&'()*+,;=` in parameter values are allowed to be sent as they
58+
* are, or should be percent-encoded. By default, allowReserved is `false`,
59+
* and reserved characters are percent-encoded.
60+
* @see https://swagger.io/docs/specification/serialization/#query
61+
*/
62+
allowReserved?: boolean;
63+
};
64+
3965
export type BodySerializer<T> = (body: OperationRequestBodyContent<T>) => any;
4066

4167
type BodyType<T = unknown> = {
@@ -98,7 +124,7 @@ export type FetchResponse<T, O extends FetchOptions> =
98124

99125
export type RequestOptions<T> = ParamsOption<T> &
100126
RequestBodyOption<T> & {
101-
querySerializer?: QuerySerializer<T>;
127+
querySerializer?: QuerySerializer<T> | QuerySerializerOptions;
102128
bodySerializer?: BodySerializer<T>;
103129
parseAs?: ParseAs;
104130
fetch?: ClientOptions["fetch"];
@@ -133,14 +159,55 @@ export default function createClient<Paths extends {}>(
133159
TRACE: ClientMethod<Paths, "trace">;
134160
};
135161

136-
/** Serialize query params to string */
137-
export declare function defaultQuerySerializer<T = unknown>(q: T): string;
162+
/** Serialize primitive params to string */
163+
export declare function serializePrimitiveParam(
164+
name: string,
165+
value: string,
166+
options?: { allowReserved?: boolean },
167+
): string;
138168

139-
/** Serialize query param schema types according to expected default OpenAPI 3.x behavior */
140-
export declare function defaultQueryParamSerializer<T = unknown>(
141-
key: string[],
142-
value: T,
143-
): string | undefined;
169+
/** Serialize object param to string */
170+
export declare function serializeObjectParam(
171+
name: string,
172+
value: Record<string, unknown>,
173+
options: {
174+
style: "simple" | "label" | "matrix" | "form" | "deepObject";
175+
explode: boolean;
176+
allowReserved?: boolean;
177+
},
178+
): string;
179+
180+
/** Serialize array param to string */
181+
export declare function serializeArrayParam(
182+
name: string,
183+
value: unknown[],
184+
options: {
185+
style:
186+
| "simple"
187+
| "label"
188+
| "matrix"
189+
| "form"
190+
| "spaceDelimited"
191+
| "pipeDelimited";
192+
explode: boolean;
193+
allowReserved?: boolean;
194+
},
195+
): string;
196+
197+
/** Serialize query params to string */
198+
export declare function createQuerySerializer<T = unknown>(
199+
options?: QuerySerializerOptions,
200+
): (queryParams: T) => string;
201+
202+
/**
203+
* Handle different OpenAPI 3.x serialization styles
204+
* @type {import("./index.js").defaultPathSerializer}
205+
* @see https://swagger.io/docs/specification/serialization/#path
206+
*/
207+
export declare function defaultPathSerializer(
208+
pathname: string,
209+
pathParams: Record<string, unknown>,
210+
): string;
144211

145212
/** Serialize body object to string */
146213
export declare function defaultBodySerializer<T>(body: T): string;

0 commit comments

Comments
 (0)