diff --git a/.changeset/chilly-cheetahs-attack.md b/.changeset/chilly-cheetahs-attack.md
new file mode 100644
index 000000000..54d46e72a
--- /dev/null
+++ b/.changeset/chilly-cheetahs-attack.md
@@ -0,0 +1,5 @@
+---
+"openapi-fetch": minor
+---
+
+⚠️ **Breaking**: change default querySerializer behavior to produce `style: form`, `explode: true` query params [according to the OpenAPI specification]((https://swagger.io/docs/specification/serialization/#query). Also adds support for `deepObject`s (square bracket style).
diff --git a/docs/src/content/docs/openapi-fetch/api.md b/docs/src/content/docs/openapi-fetch/api.md
index 3fac22d1f..989c038ff 100644
--- a/docs/src/content/docs/openapi-fetch/api.md
+++ b/docs/src/content/docs/openapi-fetch/api.md
@@ -39,7 +39,7 @@ client.get("/my-url", options);
### querySerializer
-This library uses URLSearchParams to serialize query parameters. For complex query param types (e.g. arrays) you’ll need to provide your own `querySerializer()` method that transforms query params into a URL-safe string:
+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:
```ts
const { data, error } = await GET("/search", {
diff --git a/packages/openapi-fetch/src/index.ts b/packages/openapi-fetch/src/index.ts
index 7bc3dc24b..f079b2fd2 100644
--- a/packages/openapi-fetch/src/index.ts
+++ b/packages/openapi-fetch/src/index.ts
@@ -301,16 +301,64 @@ export default function createClient(
/** serialize query params to string */
export function defaultQuerySerializer(q: T): string {
- const search = new URLSearchParams();
+ const search: string[] = [];
if (q && typeof q === "object") {
for (const [k, v] of Object.entries(q)) {
- if (v === undefined || v === null) {
- continue;
+ const value = defaultQueryParamSerializer([k], v);
+ if (value !== undefined) {
+ search.push(value);
}
- search.set(k, v);
}
}
- return search.toString();
+ return search.join("&");
+}
+
+/** serialize different query param schema types to a string */
+export function defaultQueryParamSerializer(
+ key: string[],
+ value: T,
+): string | undefined {
+ if (value === null || value === undefined) {
+ return undefined;
+ }
+ if (typeof value === "string") {
+ return `${deepObjectPath(key)}=${encodeURIComponent(value)}`;
+ }
+ if (typeof value === "number" || typeof value === "boolean") {
+ return `${deepObjectPath(key)}=${String(value)}`;
+ }
+ if (Array.isArray(value)) {
+ const nextValue: string[] = [];
+ for (const item of value) {
+ const next = defaultQueryParamSerializer(key, item);
+ if (next !== undefined) {
+ nextValue.push(next);
+ }
+ }
+ return nextValue.join(`&`);
+ }
+ if (typeof value === "object") {
+ const nextValue: string[] = [];
+ for (const [k, v] of Object.entries(value)) {
+ if (v !== undefined && v !== null) {
+ const next = defaultQueryParamSerializer([...key, k], v);
+ if (next !== undefined) {
+ nextValue.push(next);
+ }
+ }
+ }
+ return nextValue.join("&");
+ }
+ return encodeURIComponent(`${deepObjectPath(key)}=${String(value)}`);
+}
+
+/** flatten a node path into a deepObject string */
+function deepObjectPath(path: string[]): string {
+ let output = path[0]!;
+ for (const k of path.slice(1)) {
+ output += `[${k}]`;
+ }
+ return output;
}
/** serialize body object to string */
diff --git a/packages/openapi-fetch/test/index.test.ts b/packages/openapi-fetch/test/index.test.ts
index d6792c2c8..19b6a9b10 100644
--- a/packages/openapi-fetch/test/index.test.ts
+++ b/packages/openapi-fetch/test/index.test.ts
@@ -157,46 +157,60 @@ describe("client", () => {
});
describe("query", () => {
- it("basic", async () => {
+ it("primitives", async () => {
const client = createClient();
mockFetchOnce({ status: 200, body: "{}" });
- await client.GET("/blogposts/{post_id}", {
+ await client.GET("/query-params", {
params: {
- path: { post_id: "my-post" },
- query: { version: 2, format: "json" },
+ query: { string: "string", number: 0, boolean: false },
},
});
expect(fetchMocker.mock.calls[0][0]).toBe(
- "/blogposts/my-post?version=2&format=json",
+ "/query-params?string=string&number=0&boolean=false",
);
});
it("array params", async () => {
const client = createClient();
mockFetchOnce({ status: 200, body: "{}" });
- await client.GET("/blogposts", {
+ await client.GET("/query-params", {
params: {
- query: { tags: ["one", "two", "three"] },
+ query: { array: ["one", "two", "three"] },
},
});
expect(fetchMocker.mock.calls[0][0]).toBe(
- "/blogposts?tags=one%2Ctwo%2Cthree",
+ "/query-params?array=one&array=two&array=three",
+ );
+ });
+
+ it("object params", async () => {
+ const client = createClient();
+ mockFetchOnce({ status: 200, body: "{}" });
+ await client.GET("/query-params", {
+ params: {
+ query: {
+ object: { foo: "foo", deep: { nested: { object: "bar" } } },
+ },
+ },
+ });
+
+ expect(fetchMocker.mock.calls[0][0]).toBe(
+ "/query-params?object[foo]=foo&object[deep][nested][object]=bar",
);
});
it("empty/null params", async () => {
const client = createClient();
mockFetchOnce({ status: 200, body: "{}" });
- await client.GET("/blogposts/{post_id}", {
+ await client.GET("/query-params", {
params: {
- path: { post_id: "my-post" },
- query: { version: undefined, format: null as any },
+ query: { string: undefined, number: null as any },
},
});
- expect(fetchMocker.mock.calls[0][0]).toBe("/blogposts/my-post");
+ expect(fetchMocker.mock.calls[0][0]).toBe("/query-params");
});
describe("querySerializer", () => {
diff --git a/packages/openapi-fetch/test/v1.d.ts b/packages/openapi-fetch/test/v1.d.ts
index d62040b4a..098c09e77 100644
--- a/packages/openapi-fetch/test/v1.d.ts
+++ b/packages/openapi-fetch/test/v1.d.ts
@@ -196,6 +196,52 @@ export interface paths {
};
};
};
+ "/query-params": {
+ get: {
+ parameters: {
+ query?: {
+ string?: string;
+ number?: number;
+ boolean?: boolean;
+ array?: string[];
+ object?: {
+ foo: string;
+ deep: {
+ nested: {
+ object: string;
+ };
+ };
+ };
+ };
+ };
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ status: string;
+ };
+ };
+ };
+ default: components["responses"]["Error"];
+ };
+ };
+ parameters: {
+ query?: {
+ string?: string;
+ number?: number;
+ boolean?: boolean;
+ array?: string[];
+ object?: {
+ foo: string;
+ deep: {
+ nested: {
+ object: string;
+ };
+ };
+ };
+ };
+ };
+ };
"/default-as-error": {
get: {
responses: {
diff --git a/packages/openapi-fetch/test/v1.yaml b/packages/openapi-fetch/test/v1.yaml
index ea23a0db4..a3cd95e6c 100644
--- a/packages/openapi-fetch/test/v1.yaml
+++ b/packages/openapi-fetch/test/v1.yaml
@@ -196,6 +196,62 @@ paths:
description: No Content
500:
$ref: '#/components/responses/Error'
+ /query-params:
+ parameters:
+ - in: query
+ name: string
+ schema:
+ type: string
+ - in: query
+ name: number
+ schema:
+ type: number
+ - in: query
+ name: boolean
+ schema:
+ type: boolean
+ - in: query
+ name: array
+ schema:
+ type: array
+ items:
+ type: string
+ - in: query
+ name: object
+ schema:
+ type: object
+ required:
+ - foo
+ - deep
+ properties:
+ foo:
+ type: string
+ deep:
+ type: object
+ required:
+ - nested
+ properties:
+ nested:
+ type: object
+ required:
+ - object
+ properties:
+ object:
+ type: string
+ get:
+ responses:
+ 200:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ type: string
+ required:
+ - status
+ default:
+ $ref: '#/components/responses/Error'
/default-as-error:
get:
responses:
diff --git a/packages/openapi-fetch/test/v7-beta.d.ts b/packages/openapi-fetch/test/v7-beta.d.ts
index 41a44bd7e..ababc0ff2 100644
--- a/packages/openapi-fetch/test/v7-beta.d.ts
+++ b/packages/openapi-fetch/test/v7-beta.d.ts
@@ -394,6 +394,69 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/query-params": {
+ parameters: {
+ query?: {
+ string?: string;
+ number?: number;
+ boolean?: boolean;
+ array?: string[];
+ object?: {
+ foo: string;
+ deep: {
+ nested: {
+ object: string;
+ };
+ };
+ };
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: {
+ parameters: {
+ query?: {
+ string?: string;
+ number?: number;
+ boolean?: boolean;
+ array?: string[];
+ object?: {
+ foo: string;
+ deep: {
+ nested: {
+ object: string;
+ };
+ };
+ };
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ status: string;
+ };
+ };
+ };
+ default: components["responses"]["Error"];
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/default-as-error": {
parameters: {
query?: never;
diff --git a/packages/openapi-fetch/test/v7-beta.test.ts b/packages/openapi-fetch/test/v7-beta.test.ts
index 2e3a64524..6ec419534 100644
--- a/packages/openapi-fetch/test/v7-beta.test.ts
+++ b/packages/openapi-fetch/test/v7-beta.test.ts
@@ -166,46 +166,60 @@ describe("client", () => {
});
describe("query", () => {
- it("basic", async () => {
+ it("primitives", async () => {
const client = createClient();
mockFetchOnce({ status: 200, body: "{}" });
- await client.GET("/blogposts/{post_id}", {
+ await client.GET("/query-params", {
params: {
- path: { post_id: "my-post" },
- query: { version: 2, format: "json" },
+ query: { string: "string", number: 0, boolean: false },
},
});
expect(fetchMocker.mock.calls[0][0]).toBe(
- "/blogposts/my-post?version=2&format=json",
+ "/query-params?string=string&number=0&boolean=false",
);
});
it("array params", async () => {
const client = createClient();
mockFetchOnce({ status: 200, body: "{}" });
- await client.GET("/blogposts", {
+ await client.GET("/query-params", {
params: {
- query: { tags: ["one", "two", "three"] },
+ query: { array: ["one", "two", "three"] },
},
});
expect(fetchMocker.mock.calls[0][0]).toBe(
- "/blogposts?tags=one%2Ctwo%2Cthree",
+ "/query-params?array=one&array=two&array=three",
+ );
+ });
+
+ it("object params", async () => {
+ const client = createClient();
+ mockFetchOnce({ status: 200, body: "{}" });
+ await client.GET("/query-params", {
+ params: {
+ query: {
+ object: { foo: "foo", deep: { nested: { object: "bar" } } },
+ },
+ },
+ });
+
+ expect(fetchMocker.mock.calls[0][0]).toBe(
+ "/query-params?object[foo]=foo&object[deep][nested][object]=bar",
);
});
it("empty/null params", async () => {
const client = createClient();
mockFetchOnce({ status: 200, body: "{}" });
- await client.GET("/blogposts/{post_id}", {
+ await client.GET("/query-params", {
params: {
- path: { post_id: "my-post" },
- query: { version: undefined, format: null as any },
+ query: { string: undefined, number: null as any },
},
});
- expect(fetchMocker.mock.calls[0][0]).toBe("/blogposts/my-post");
+ expect(fetchMocker.mock.calls[0][0]).toBe("/query-params");
});
describe("querySerializer", () => {