Skip to content

Commit

Permalink
Explode query params by default (#1399)
Browse files Browse the repository at this point in the history
  • Loading branch information
drwpow authored Oct 20, 2023
1 parent 11842a0 commit 4fca1e4
Show file tree
Hide file tree
Showing 8 changed files with 276 additions and 30 deletions.
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

0 comments on commit 4fca1e4

Please sign in to comment.