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", () => {