Skip to content

Commit 4717c98

Browse files
Add support for different serialization strategies for query parameters
see https://swagger.io/docs/specification/v3_0/serialization/#query-parameters
1 parent 4461aa4 commit 4717c98

5 files changed

Lines changed: 326 additions & 27 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { ParamSerializer } from "./ParameterSerializer.js";
2+
import {
3+
QuerySerializationStyles,
4+
SerializationOptions,
5+
} from "../types/index.js";
6+
7+
type SerializerOptions = Record<
8+
string,
9+
SerializationOptions<QuerySerializationStyles>
10+
>;
11+
12+
function getDecodedUrlString(serializer: ParamSerializer): string {
13+
return decodeURIComponent(serializer.getSearchParams().toString());
14+
}
15+
16+
describe("QueryParamSerializer", () => {
17+
describe("Primitive Values", () => {
18+
test.each([
19+
{
20+
value: "value",
21+
expected: "key=value",
22+
},
23+
{
24+
value: 123,
25+
expected: "key=123",
26+
},
27+
{
28+
value: true,
29+
expected: "key=true",
30+
},
31+
{
32+
value: false,
33+
expected: "key=false",
34+
},
35+
])("serializes primitive value $value", ({ value, expected }) => {
36+
const options: SerializerOptions = {};
37+
const serializer = new ParamSerializer(options);
38+
39+
serializer.serializeQueryParam("key", value);
40+
41+
expect(getDecodedUrlString(serializer)).toBe(expected);
42+
});
43+
});
44+
45+
describe("Array Serialization", () => {
46+
test.each([
47+
{
48+
style: "form",
49+
explode: true,
50+
expected: "arrayParam=value1&arrayParam=value2",
51+
},
52+
{
53+
style: "form",
54+
explode: false,
55+
expected: "arrayParam=value1,value2",
56+
},
57+
{
58+
style: "spaceDelimited",
59+
explode: false,
60+
// URLSearchParams encodes spaces as plus signs (+)
61+
// https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs
62+
expected: "arrayParam=value1+value2",
63+
},
64+
{
65+
style: "pipeDelimited",
66+
explode: false,
67+
expected: "arrayParam=value1|value2",
68+
},
69+
] as const)(
70+
"serializes arrays in $style style with explode=$explode",
71+
({ style, explode, expected }) => {
72+
const options: SerializerOptions = {
73+
arrayParam: { style, explode },
74+
};
75+
const serializer = new ParamSerializer(options);
76+
77+
serializer.serializeQueryParam("arrayParam", ["value1", "value2"]);
78+
79+
expect(getDecodedUrlString(serializer)).toBe(expected);
80+
},
81+
);
82+
83+
test("throws an error for unsupported array serialization styles", () => {
84+
const options: SerializerOptions = {
85+
arrayParam: { style: "deepObject" },
86+
};
87+
const serializer = new ParamSerializer(options);
88+
89+
expect(() => {
90+
serializer.serializeQueryParam("arrayParam", ["value1", "value2"]);
91+
}).toThrow("Unsupported serialization style for arrays: 'deepObject'");
92+
});
93+
});
94+
95+
describe("Object Serialization", () => {
96+
test.each([
97+
{
98+
style: "form",
99+
explode: true,
100+
expected: "foo=bar&baz=qux",
101+
},
102+
{
103+
style: "form",
104+
explode: false,
105+
expected: "objectParam=foo,bar,baz,qux",
106+
},
107+
{
108+
style: "deepObject",
109+
explode: true,
110+
expected: "objectParam[foo]=bar&objectParam[baz]=qux",
111+
},
112+
{
113+
style: "contentJSON",
114+
explode: true,
115+
expected: 'objectParam={"foo":"bar","baz":"qux"}',
116+
},
117+
] as const)(
118+
"serializes objects in $style style with explode=$explode",
119+
({ style, explode, expected }) => {
120+
const options: SerializerOptions = {
121+
objectParam: { style, explode },
122+
};
123+
const serializer = new ParamSerializer(options);
124+
125+
serializer.serializeQueryParam("objectParam", {
126+
foo: "bar",
127+
baz: "qux",
128+
});
129+
130+
expect(getDecodedUrlString(serializer)).toBe(expected);
131+
},
132+
);
133+
134+
test("throws an error for unsupported object serialization styles", () => {
135+
const options: SerializerOptions = {
136+
objectParam: { style: "pipeDelimited" },
137+
};
138+
const serializer = new ParamSerializer(options);
139+
140+
expect(() => {
141+
serializer.serializeQueryParam("objectParam", { foo: "bar" });
142+
}).toThrow(
143+
"Unsupported serialization style for objects: 'pipeDelimited'",
144+
);
145+
});
146+
});
147+
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {
2+
QuerySerializationStyles,
3+
SerializationOptions,
4+
} from "../types/index.js";
5+
6+
type QuerySerializationOptions = SerializationOptions<QuerySerializationStyles>;
7+
8+
export class ParamSerializer {
9+
private readonly searchParams: URLSearchParams;
10+
private readonly options: Record<string, QuerySerializationOptions>;
11+
12+
constructor(options: Record<string, QuerySerializationOptions>) {
13+
this.options = options;
14+
this.searchParams = new URLSearchParams();
15+
}
16+
17+
serializeQueryParam(key: string, value: unknown): this {
18+
const config = this.options[key];
19+
20+
switch (true) {
21+
case Array.isArray(value):
22+
this.handleArraySerialization(key, value, config);
23+
break;
24+
case typeof value === "object" && value !== null:
25+
this.handleObjectSerialization(key, value, config);
26+
break;
27+
default:
28+
this.handlePrimitiveSerialization(key, value);
29+
}
30+
31+
return this;
32+
}
33+
34+
getSearchParams(): URLSearchParams {
35+
return this.searchParams;
36+
}
37+
38+
private handlePrimitiveSerialization(key: string, value: unknown): void {
39+
this.searchParams.append(key, String(value));
40+
}
41+
42+
private handleArraySerialization(
43+
key: string,
44+
value: unknown[],
45+
styleOptions: QuerySerializationOptions = { style: "form", explode: true },
46+
): void {
47+
const { style, explode = true } = styleOptions;
48+
49+
switch (style) {
50+
case "form":
51+
this.serializeArrayForm(key, value, explode);
52+
break;
53+
case "spaceDelimited":
54+
this.serializeArrayDelimited(key, value, " ");
55+
break;
56+
case "pipeDelimited":
57+
this.serializeArrayDelimited(key, value, "|");
58+
break;
59+
default:
60+
throw new Error(
61+
`Unsupported serialization style for arrays: '${style}'`,
62+
);
63+
}
64+
}
65+
66+
private serializeArrayForm(
67+
key: string,
68+
value: unknown[],
69+
explode: boolean,
70+
): void {
71+
if (explode) {
72+
value.forEach((item) => this.searchParams.append(key, String(item)));
73+
} else {
74+
this.serializeArrayDelimited(key, value, ",");
75+
}
76+
}
77+
78+
private serializeArrayDelimited(
79+
key: string,
80+
value: unknown[],
81+
delimiter: string,
82+
): void {
83+
this.searchParams.append(key, value.join(delimiter));
84+
}
85+
86+
private handleObjectSerialization(
87+
key: string,
88+
value: object,
89+
styleOptions: QuerySerializationOptions = {
90+
style: "deepObject",
91+
explode: true,
92+
},
93+
): void {
94+
const { style, explode = true } = styleOptions;
95+
96+
switch (style) {
97+
case "form":
98+
this.serializeObjectForm(key, value, explode);
99+
break;
100+
case "deepObject":
101+
this.serializeObjectDeepObject(key, value);
102+
break;
103+
case "contentJSON":
104+
this.searchParams.append(key, JSON.stringify(value));
105+
break;
106+
default:
107+
throw new Error(
108+
`Unsupported serialization style for objects: '${style}'`,
109+
);
110+
}
111+
}
112+
113+
private serializeObjectForm(
114+
key: string,
115+
value: object,
116+
explode: boolean,
117+
): void {
118+
if (explode) {
119+
Object.entries(value).forEach(([k, v]) =>
120+
this.searchParams.append(k, String(v)),
121+
);
122+
} else {
123+
const serialized = Object.entries(value)
124+
.map(([k, v]) => `${k},${v}`)
125+
.join(",");
126+
this.searchParams.append(key, serialized);
127+
}
128+
}
129+
130+
private serializeObjectDeepObject(key: string, value: object): void {
131+
Object.entries(value).forEach(([k, v]) =>
132+
this.searchParams.append(`${key}[${k}]`, String(v)),
133+
);
134+
}
135+
}

packages/commons/src/core/Request.test.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Request from "./Request.js";
22
import { AxiosInstance } from "axios";
33
import { jest } from "@jest/globals";
4-
import { QueryParameters } from "../types/index.js";
4+
import { OpenAPIOperation, QueryParameters } from "../types/index.js";
55

66
const requestFn = jest.fn();
77

@@ -20,8 +20,14 @@ describe("query parameters", () => {
2020
method: "GET",
2121
} as const;
2222

23-
const executeRequest = (query: QueryParameters): string => {
24-
const request = new Request(op, { queryParameters: query });
23+
const executeRequest = (
24+
query: QueryParameters,
25+
opOverwrites?: Partial<OpenAPIOperation>,
26+
): string => {
27+
const request = new Request(
28+
{ ...op, ...opOverwrites },
29+
{ queryParameters: query },
30+
);
2531
request.execute(mockedAxios);
2632
const requestConfig = requestFn.mock.calls[0][0] as {
2733
params: URLSearchParams;
@@ -60,13 +66,21 @@ describe("query parameters", () => {
6066
expect(query).toBe("foo=bar&foo=bam");
6167
});
6268

63-
test("Number, boolean, JSON", () => {
64-
const query = executeRequest({
65-
foo: 1,
66-
bar: true,
67-
baz: { some: "value" },
68-
});
69+
test("Number, boolean, JSON, deepObject", () => {
70+
const query = executeRequest(
71+
{
72+
foo: 1,
73+
bar: true,
74+
baz: { some: "value" },
75+
deep: { object: "value" },
76+
},
77+
{
78+
serialization: { query: { baz: { style: "contentJSON" } } },
79+
},
80+
);
6981

70-
expect(query).toBe("foo=1&bar=true&baz=%7B%22some%22%3A%22value%22%7D");
82+
expect(query).toBe(
83+
"foo=1&bar=true&baz=%7B%22some%22%3A%22value%22%7D&deep%5Bobject%5D=value",
84+
);
7185
});
7286
});

packages/commons/src/core/Request.ts

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
AxiosRequestConfig,
1212
RawAxiosRequestHeaders,
1313
} from "axios";
14+
import { ParamSerializer } from "./ParameterSerializer.js";
1415

1516
export class Request<TOp extends OpenAPIOperation> {
1617
private readonly operationDescriptor: TOp;
@@ -91,30 +92,18 @@ export class Request<TOp extends OpenAPIOperation> {
9192
}
9293

9394
if (typeof query === "object") {
94-
const searchParams = new URLSearchParams();
95+
const serializer = new ParamSerializer(
96+
this.operationDescriptor.serialization?.query ?? {},
97+
);
9598

9699
for (const [key, value] of Object.entries(query)) {
97100
if (value === undefined) {
98101
continue;
99102
}
100-
101-
if (Array.isArray(value)) {
102-
for (const arrayItem of value) {
103-
searchParams.append(key, arrayItem);
104-
}
105-
} else {
106-
searchParams.append(
107-
key,
108-
typeof value === "string" ||
109-
typeof value === "number" ||
110-
typeof value === "boolean"
111-
? value.toString()
112-
: JSON.stringify(value),
113-
);
114-
}
103+
serializer.serializeQueryParam(key, value);
115104
}
116105

117-
return searchParams;
106+
return serializer.getSearchParams();
118107
}
119108

120109
throw new Error(`Unexpected query parameter type (${typeof query})`);

0 commit comments

Comments
 (0)