Skip to content

Explode query params by default #1399

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

Merged
merged 1 commit into from
Oct 20, 2023
Merged
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/chilly-cheetahs-attack.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion docs/src/content/docs/openapi-fetch/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ client.get("/my-url", options);

### querySerializer

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:
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", {
Expand Down
58 changes: 53 additions & 5 deletions packages/openapi-fetch/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,16 +301,64 @@ export default function createClient<Paths extends {}>(

/** serialize query params to string */
export function defaultQuerySerializer<T = unknown>(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<T = unknown>(
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 */
Expand Down
38 changes: 26 additions & 12 deletions packages/openapi-fetch/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,46 +157,60 @@ describe("client", () => {
});

describe("query", () => {
it("basic", async () => {
it("primitives", async () => {
const client = createClient<paths>();
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<paths>();
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<paths>();
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<paths>();
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", () => {
Expand Down
46 changes: 46 additions & 0 deletions packages/openapi-fetch/test/v1.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
56 changes: 56 additions & 0 deletions packages/openapi-fetch/test/v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
63 changes: 63 additions & 0 deletions packages/openapi-fetch/test/v7-beta.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading