Skip to content

Commit 4fca1e4

Browse files
authored
Explode query params by default (#1399)
1 parent 11842a0 commit 4fca1e4

File tree

8 files changed

+276
-30
lines changed

8 files changed

+276
-30
lines changed

.changeset/chilly-cheetahs-attack.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-fetch": minor
3+
---
4+
5+
⚠️ **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).

docs/src/content/docs/openapi-fetch/api.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ client.get("/my-url", options);
3939

4040
### querySerializer
4141

42-
This library uses <a href="https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams" target="_blank" rel="noopener noreferrer">URLSearchParams</a> to <a href="https://swagger.io/docs/specification/serialization/" target="_blank" rel="noopener noreferrer">serialize query parameters</a>. 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:
42+
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:
4343

4444
```ts
4545
const { data, error } = await GET("/search", {

packages/openapi-fetch/src/index.ts

+53-5
Original file line numberDiff line numberDiff line change
@@ -301,16 +301,64 @@ export default function createClient<Paths extends {}>(
301301

302302
/** serialize query params to string */
303303
export function defaultQuerySerializer<T = unknown>(q: T): string {
304-
const search = new URLSearchParams();
304+
const search: string[] = [];
305305
if (q && typeof q === "object") {
306306
for (const [k, v] of Object.entries(q)) {
307-
if (v === undefined || v === null) {
308-
continue;
307+
const value = defaultQueryParamSerializer([k], v);
308+
if (value !== undefined) {
309+
search.push(value);
309310
}
310-
search.set(k, v);
311311
}
312312
}
313-
return search.toString();
313+
return search.join("&");
314+
}
315+
316+
/** serialize different query param schema types to a string */
317+
export function defaultQueryParamSerializer<T = unknown>(
318+
key: string[],
319+
value: T,
320+
): string | undefined {
321+
if (value === null || value === undefined) {
322+
return undefined;
323+
}
324+
if (typeof value === "string") {
325+
return `${deepObjectPath(key)}=${encodeURIComponent(value)}`;
326+
}
327+
if (typeof value === "number" || typeof value === "boolean") {
328+
return `${deepObjectPath(key)}=${String(value)}`;
329+
}
330+
if (Array.isArray(value)) {
331+
const nextValue: string[] = [];
332+
for (const item of value) {
333+
const next = defaultQueryParamSerializer(key, item);
334+
if (next !== undefined) {
335+
nextValue.push(next);
336+
}
337+
}
338+
return nextValue.join(`&`);
339+
}
340+
if (typeof value === "object") {
341+
const nextValue: string[] = [];
342+
for (const [k, v] of Object.entries(value)) {
343+
if (v !== undefined && v !== null) {
344+
const next = defaultQueryParamSerializer([...key, k], v);
345+
if (next !== undefined) {
346+
nextValue.push(next);
347+
}
348+
}
349+
}
350+
return nextValue.join("&");
351+
}
352+
return encodeURIComponent(`${deepObjectPath(key)}=${String(value)}`);
353+
}
354+
355+
/** flatten a node path into a deepObject string */
356+
function deepObjectPath(path: string[]): string {
357+
let output = path[0]!;
358+
for (const k of path.slice(1)) {
359+
output += `[${k}]`;
360+
}
361+
return output;
314362
}
315363

316364
/** serialize body object to string */

packages/openapi-fetch/test/index.test.ts

+26-12
Original file line numberDiff line numberDiff line change
@@ -157,46 +157,60 @@ describe("client", () => {
157157
});
158158

159159
describe("query", () => {
160-
it("basic", async () => {
160+
it("primitives", async () => {
161161
const client = createClient<paths>();
162162
mockFetchOnce({ status: 200, body: "{}" });
163-
await client.GET("/blogposts/{post_id}", {
163+
await client.GET("/query-params", {
164164
params: {
165-
path: { post_id: "my-post" },
166-
query: { version: 2, format: "json" },
165+
query: { string: "string", number: 0, boolean: false },
167166
},
168167
});
169168

170169
expect(fetchMocker.mock.calls[0][0]).toBe(
171-
"/blogposts/my-post?version=2&format=json",
170+
"/query-params?string=string&number=0&boolean=false",
172171
);
173172
});
174173

175174
it("array params", async () => {
176175
const client = createClient<paths>();
177176
mockFetchOnce({ status: 200, body: "{}" });
178-
await client.GET("/blogposts", {
177+
await client.GET("/query-params", {
179178
params: {
180-
query: { tags: ["one", "two", "three"] },
179+
query: { array: ["one", "two", "three"] },
181180
},
182181
});
183182

184183
expect(fetchMocker.mock.calls[0][0]).toBe(
185-
"/blogposts?tags=one%2Ctwo%2Cthree",
184+
"/query-params?array=one&array=two&array=three",
185+
);
186+
});
187+
188+
it("object params", async () => {
189+
const client = createClient<paths>();
190+
mockFetchOnce({ status: 200, body: "{}" });
191+
await client.GET("/query-params", {
192+
params: {
193+
query: {
194+
object: { foo: "foo", deep: { nested: { object: "bar" } } },
195+
},
196+
},
197+
});
198+
199+
expect(fetchMocker.mock.calls[0][0]).toBe(
200+
"/query-params?object[foo]=foo&object[deep][nested][object]=bar",
186201
);
187202
});
188203

189204
it("empty/null params", async () => {
190205
const client = createClient<paths>();
191206
mockFetchOnce({ status: 200, body: "{}" });
192-
await client.GET("/blogposts/{post_id}", {
207+
await client.GET("/query-params", {
193208
params: {
194-
path: { post_id: "my-post" },
195-
query: { version: undefined, format: null as any },
209+
query: { string: undefined, number: null as any },
196210
},
197211
});
198212

199-
expect(fetchMocker.mock.calls[0][0]).toBe("/blogposts/my-post");
213+
expect(fetchMocker.mock.calls[0][0]).toBe("/query-params");
200214
});
201215

202216
describe("querySerializer", () => {

packages/openapi-fetch/test/v1.d.ts

+46
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,52 @@ export interface paths {
196196
};
197197
};
198198
};
199+
"/query-params": {
200+
get: {
201+
parameters: {
202+
query?: {
203+
string?: string;
204+
number?: number;
205+
boolean?: boolean;
206+
array?: string[];
207+
object?: {
208+
foo: string;
209+
deep: {
210+
nested: {
211+
object: string;
212+
};
213+
};
214+
};
215+
};
216+
};
217+
responses: {
218+
200: {
219+
content: {
220+
"application/json": {
221+
status: string;
222+
};
223+
};
224+
};
225+
default: components["responses"]["Error"];
226+
};
227+
};
228+
parameters: {
229+
query?: {
230+
string?: string;
231+
number?: number;
232+
boolean?: boolean;
233+
array?: string[];
234+
object?: {
235+
foo: string;
236+
deep: {
237+
nested: {
238+
object: string;
239+
};
240+
};
241+
};
242+
};
243+
};
244+
};
199245
"/default-as-error": {
200246
get: {
201247
responses: {

packages/openapi-fetch/test/v1.yaml

+56
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,62 @@ paths:
196196
description: No Content
197197
500:
198198
$ref: '#/components/responses/Error'
199+
/query-params:
200+
parameters:
201+
- in: query
202+
name: string
203+
schema:
204+
type: string
205+
- in: query
206+
name: number
207+
schema:
208+
type: number
209+
- in: query
210+
name: boolean
211+
schema:
212+
type: boolean
213+
- in: query
214+
name: array
215+
schema:
216+
type: array
217+
items:
218+
type: string
219+
- in: query
220+
name: object
221+
schema:
222+
type: object
223+
required:
224+
- foo
225+
- deep
226+
properties:
227+
foo:
228+
type: string
229+
deep:
230+
type: object
231+
required:
232+
- nested
233+
properties:
234+
nested:
235+
type: object
236+
required:
237+
- object
238+
properties:
239+
object:
240+
type: string
241+
get:
242+
responses:
243+
200:
244+
content:
245+
application/json:
246+
schema:
247+
type: object
248+
properties:
249+
status:
250+
type: string
251+
required:
252+
- status
253+
default:
254+
$ref: '#/components/responses/Error'
199255
/default-as-error:
200256
get:
201257
responses:

packages/openapi-fetch/test/v7-beta.d.ts

+63
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,69 @@ export interface paths {
394394
patch?: never;
395395
trace?: never;
396396
};
397+
"/query-params": {
398+
parameters: {
399+
query?: {
400+
string?: string;
401+
number?: number;
402+
boolean?: boolean;
403+
array?: string[];
404+
object?: {
405+
foo: string;
406+
deep: {
407+
nested: {
408+
object: string;
409+
};
410+
};
411+
};
412+
};
413+
header?: never;
414+
path?: never;
415+
cookie?: never;
416+
};
417+
get: {
418+
parameters: {
419+
query?: {
420+
string?: string;
421+
number?: number;
422+
boolean?: boolean;
423+
array?: string[];
424+
object?: {
425+
foo: string;
426+
deep: {
427+
nested: {
428+
object: string;
429+
};
430+
};
431+
};
432+
};
433+
header?: never;
434+
path?: never;
435+
cookie?: never;
436+
};
437+
requestBody?: never;
438+
responses: {
439+
200: {
440+
headers: {
441+
[name: string]: unknown;
442+
};
443+
content: {
444+
"application/json": {
445+
status: string;
446+
};
447+
};
448+
};
449+
default: components["responses"]["Error"];
450+
};
451+
};
452+
put?: never;
453+
post?: never;
454+
delete?: never;
455+
options?: never;
456+
head?: never;
457+
patch?: never;
458+
trace?: never;
459+
};
397460
"/default-as-error": {
398461
parameters: {
399462
query?: never;

0 commit comments

Comments
 (0)