Skip to content

Commit be536cb

Browse files
committed
feat!: add path parameter serialization option
1 parent 3e7631f commit be536cb

File tree

7 files changed

+262
-76
lines changed

7 files changed

+262
-76
lines changed

src/openapi-typescript/fetch-factory.ts

Lines changed: 17 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
OpenapiPaths,
66
FetchOptions,
77
} from "./types/fetch-options";
8+
import { queryBuilder } from "./query-builder";
9+
import { pathBuilder } from "./path-builder";
810

911
export const FetchFactory = {
1012
build: <Paths extends OpenapiPaths<Paths>>(options?: InitParameters) => {
@@ -25,11 +27,21 @@ function fetchFactory<Paths>(options?: InitParameters) {
2527
>(input: Path, init: { method: Method } & FetchOptions<Operation>) {
2628
const options = init as unknown as { method: Method } & AllFetchOptions;
2729

28-
const path = getPath(input as string, options.parameters?.path);
29-
const query = getQuery(
30-
options.parameters?.query ?? null,
31-
serialization?.explode
30+
const qBuilder = queryBuilder({
31+
style: serialization?.query?.style ?? "form",
32+
explode: serialization?.query?.explode ?? false,
33+
});
34+
35+
const pBuilder = pathBuilder({
36+
style: serialization?.path?.style ?? "simple",
37+
explode: serialization?.path?.explode ?? false,
38+
});
39+
40+
const path = pBuilder.getPath(
41+
input as string,
42+
options.parameters?.path ?? null
3243
);
44+
const query = qBuilder.getQuery(options.parameters?.query ?? null);
3345
const url = basePath + path + query;
3446

3547
const fetchInit = buildInit(defaultInit, options);
@@ -54,45 +66,7 @@ function buildInit(
5466
return {
5567
...Object.assign({}, { ...defaultInit }, { ...options }),
5668
body: options.body ? JSON.stringify(options.body) : undefined,
57-
method: (options.method ?? "GET").toUpperCase(),
69+
method: options.method?.toUpperCase(),
5870
headers: Object.assign({}, defaultInit.headers, options.headers),
5971
};
6072
}
61-
62-
function getPath(path: string, pathParams?: Record<string, string | number>) {
63-
if (!pathParams) {
64-
return path;
65-
}
66-
return path.replace(/\{([^}]+)\}/g, (_, key) => {
67-
const value = encodeURIComponent(pathParams[key] as string);
68-
return value;
69-
});
70-
}
71-
72-
function getQuery(
73-
params: Record<string, string | number | string[] | number[]> | null,
74-
explode?: boolean
75-
): string {
76-
if (!params) {
77-
return "";
78-
}
79-
80-
const searchParams = Object.entries(params).map(([key, value]) => {
81-
if (Array.isArray(value)) {
82-
if (explode) {
83-
return value
84-
.map(
85-
(v) => `${encodeURIComponent(key)}=${encodeURIComponent(`${v}`)}`
86-
)
87-
.join("&");
88-
} else {
89-
return `${encodeURIComponent(key)}=${value
90-
.map((v) => encodeURIComponent(`${v}`))
91-
.join(",")}`;
92-
}
93-
}
94-
return `${encodeURIComponent(key)}=${encodeURIComponent(`${params[key]}`)}`;
95-
});
96-
97-
return "?" + searchParams.join("&");
98-
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { PathSerializationStyle } from "../types/common";
2+
3+
export function pathBuilder(options: {
4+
style: PathSerializationStyle;
5+
explode: boolean;
6+
}) {
7+
function getPath(
8+
path: string,
9+
params: Record<
10+
string,
11+
string | number | string[] | number[] | Record<string, string | number>
12+
> | null
13+
) {
14+
const { explode } = options;
15+
if (!params) {
16+
return path;
17+
}
18+
19+
const resolvedPath = path.replace(/\{([^}]+)\}/g, (_, pathKey) => {
20+
const value = params[pathKey];
21+
if (explode) {
22+
if (!Array.isArray(value) && typeof value === "object") {
23+
return getObjectExploded(value);
24+
}
25+
}
26+
27+
if (Array.isArray(value)) {
28+
return getArray(value);
29+
} else if (typeof value === "object") {
30+
return getObject(value);
31+
}
32+
33+
return encodeURIComponent(params[pathKey] as string);
34+
});
35+
36+
return resolvedPath;
37+
}
38+
39+
return { getPath };
40+
}
41+
42+
function getArray(value: (string | number)[]) {
43+
return value.join(",");
44+
}
45+
46+
function getObjectExploded(value: Record<string, string | number>) {
47+
return Object.entries(value)
48+
.map(([subKey, subValue]) => `${subKey}=${subValue}`)
49+
.join(",");
50+
}
51+
52+
function getObject(value: Record<string, string | number>) {
53+
return Object.entries(value)
54+
.map(([subKey, subValue]) => `${subKey},${subValue}`)
55+
.join(",");
56+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { QuerySerializationStyle } from "../types/common";
2+
3+
export function queryBuilder(options: {
4+
style: QuerySerializationStyle;
5+
explode: boolean;
6+
}) {
7+
const { style, explode } = options;
8+
9+
function getQuery(
10+
params: Record<
11+
string,
12+
string | number | string[] | number[] | Record<string, string | number>
13+
> | null
14+
): string {
15+
if (!params) {
16+
return "";
17+
}
18+
19+
const searchParams = Object.entries(params).map(([key, value]) => {
20+
if (explode) {
21+
if (Array.isArray(value)) {
22+
return getArrayExploded(key, value);
23+
} else if (typeof value === "object") {
24+
return getObjectExploded(key, value);
25+
}
26+
}
27+
28+
if (Array.isArray(value)) {
29+
return getArray(key, value, style);
30+
} else if (typeof value === "object") {
31+
return getObject(key, value);
32+
}
33+
34+
return `${encodeURIComponent(key)}=${encodeURIComponent(
35+
`${params[key]}`
36+
)}`;
37+
});
38+
39+
return "?" + searchParams.join("&");
40+
}
41+
42+
return { getQuery };
43+
}
44+
45+
function getArray(
46+
key: string,
47+
value: (string | number)[],
48+
style: QuerySerializationStyle
49+
) {
50+
const QuerySeparator = {
51+
form: ",",
52+
spaceDelimited: "%20",
53+
pipeDelimited: "|",
54+
} satisfies Record<QuerySerializationStyle, string>;
55+
56+
return `${encodeURIComponent(key)}=${value
57+
.map((v) => encodeURIComponent(`${v}`))
58+
.join(QuerySeparator[style])}`;
59+
}
60+
61+
function getArrayExploded(key: string, value: (string | number)[]) {
62+
return value
63+
.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(`${v}`)}`)
64+
.join("&");
65+
}
66+
67+
function getObject(key: string, value: Record<string, string | number>) {
68+
return `${encodeURIComponent(key)}=${Object.entries(value)
69+
.map(([subKey, subVal]) => `${subKey},${subVal}`)
70+
.join(",")}`;
71+
}
72+
73+
function getObjectExploded(_: string, value: Record<string, string | number>) {
74+
return Object.entries(value)
75+
.map(
76+
([subKey, subVal]) =>
77+
`${encodeURIComponent(subKey)}=${encodeURIComponent(`${subVal}`)}`
78+
)
79+
.join("&");
80+
}

src/types/common.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,22 @@ export type InitParameters = {
22
baseUrl?: string;
33
defaultInit?: Omit<RequestInit, "method">;
44
fetchMethod?: typeof fetch;
5-
parameterSerialization?: { explode?: boolean };
5+
parameterSerialization?: {
6+
path?: { explode?: boolean; style?: PathSerializationStyle };
7+
query?: { explode?: boolean; style?: QuerySerializationStyle };
8+
};
69
};
710

11+
export type QuerySerializationStyle =
12+
| "form"
13+
| "spaceDelimited"
14+
| "pipeDelimited";
15+
// | "deepObject"
16+
17+
export type PathSerializationStyle = "simple";
18+
//| "label"
19+
//| "matrix"
20+
821
export type HttpMethod =
922
| "get"
1023
| "post"

test/openapi-typescript/fetch-factory.test.ts

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -272,38 +272,6 @@ describe("Generated fetch request", () => {
272272
});
273273
});
274274

275-
describe("Handles parameter serialization", () => {
276-
const queryFetch = FetchFactory.build<paths>({ fetchMethod: mockedFetch });
277-
const explodeQueryFetch = FetchFactory.build<paths>({
278-
parameterSerialization: { explode: true },
279-
fetchMethod: mockedFetch,
280-
});
281-
282-
it("resolves query array as comma separated when default", () => {
283-
queryFetch("/pet/findByStatus", {
284-
method: "get",
285-
// @ts-ignore
286-
parameters: { query: { status: ["available", "free"] } },
287-
});
288-
289-
expect((mockedFetch.mock.calls[0] as any)[0]).toBe(
290-
"/pet/findByStatus?status=available,free"
291-
);
292-
});
293-
294-
it("resolves query array as comma separated when set to explode", () => {
295-
explodeQueryFetch("/pet/findByStatus", {
296-
method: "get",
297-
// @ts-ignore
298-
parameters: { query: { status: ["available", "free"] } },
299-
});
300-
301-
expect((mockedFetch.mock.calls[0] as any)[0]).toBe(
302-
"/pet/findByStatus?status=available&status=free"
303-
);
304-
});
305-
});
306-
307275
describe("Generated fetch response", () => {
308276
it("calls json() of response", async () => {
309277
const response = await customFetch("/store/inventory", { method: "get" });
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { pathBuilder } from "../../src/openapi-typescript/path-builder";
2+
3+
describe("Path parameter serialization", () => {
4+
describe("explode=false", () => {
5+
it("resolves simple path parameter", () => {
6+
const pBuilder = pathBuilder({ style: "simple", explode: false });
7+
const path = pBuilder.getPath("/pet/{petId}", { petId: 42 });
8+
9+
expect(path).toBe("/pet/42");
10+
});
11+
12+
it("resolves array parameter", () => {
13+
const pBuilder = pathBuilder({ style: "simple", explode: false });
14+
const path = pBuilder.getPath("/pet/{petId}", { petId: [42, 53] });
15+
16+
expect(path).toBe("/pet/42,53");
17+
});
18+
19+
it("resolves object parameter", () => {
20+
const pBuilder = pathBuilder({ style: "simple", explode: false });
21+
const path = pBuilder.getPath("/pet/{petId}", {
22+
petId: { hello1: "world1", hello2: "world2" },
23+
});
24+
25+
expect(path).toBe("/pet/hello1,world1,hello2,world2");
26+
});
27+
});
28+
29+
describe("explode=true", () => {
30+
it("resolves object parameter", () => {
31+
const pBuilder = pathBuilder({ style: "simple", explode: true });
32+
const path = pBuilder.getPath("/pet/{petId}", {
33+
petId: { hello1: "world1", hello2: "world2" },
34+
});
35+
36+
expect(path).toBe("/pet/hello1=world1,hello2=world2");
37+
});
38+
});
39+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { queryBuilder } from "../../src/openapi-typescript/query-builder";
2+
3+
describe("Query parameter serialization", () => {
4+
describe("explode=false", () => {
5+
it("resolves array as comma separated string", () => {
6+
const qBuilder = queryBuilder({ explode: false, style: "form" });
7+
const query = qBuilder.getQuery({ status: ["available", "free"] });
8+
9+
expect(query).toBe("?status=available,free");
10+
});
11+
12+
it("resolves array as pipe separated string", () => {
13+
const qBuilder = queryBuilder({ explode: false, style: "pipeDelimited" });
14+
const query = qBuilder.getQuery({ status: ["available", "free"] });
15+
16+
expect(query).toBe("?status=available|free");
17+
});
18+
19+
it("resolves array as pipe separated string", () => {
20+
const qBuilder = queryBuilder({
21+
explode: false,
22+
style: "spaceDelimited",
23+
});
24+
const query = qBuilder.getQuery({ status: ["available", "free"] });
25+
26+
expect(query).toBe("?status=available%20free");
27+
});
28+
29+
it("resolves object as comma separated key+value string", () => {
30+
const qBuilder = queryBuilder({ explode: false, style: "form" });
31+
const query = qBuilder.getQuery({
32+
groupType: { status: "available", status2: "free" },
33+
});
34+
35+
expect(query).toBe("?groupType=status,available,status2,free");
36+
});
37+
});
38+
39+
describe("explode=true", () => {
40+
it("resolves array as duplicated keys", () => {
41+
const qBuilder = queryBuilder({ explode: true, style: "form" });
42+
const query = qBuilder.getQuery({ status: ["available", "free"] });
43+
44+
expect(query).toBe("?status=available&status=free");
45+
});
46+
47+
it("resolves object by spreading its values", () => {
48+
const qBuilder = queryBuilder({ explode: true, style: "form" });
49+
const query = qBuilder.getQuery({
50+
groupType: { status: "available", status2: "free" },
51+
});
52+
53+
expect(query).toBe("?status=available&status2=free");
54+
});
55+
});
56+
});

0 commit comments

Comments
 (0)