diff --git a/.idea/API Tools.iml b/.idea/API Tools.iml
index cf216399..c315801d 100644
--- a/.idea/API Tools.iml
+++ b/.idea/API Tools.iml
@@ -6,6 +6,8 @@
+
+
diff --git a/packages/commons/src/core/ParameterSerializer.test.ts b/packages/commons/src/core/ParameterSerializer.test.ts
new file mode 100644
index 00000000..05e87e9c
--- /dev/null
+++ b/packages/commons/src/core/ParameterSerializer.test.ts
@@ -0,0 +1,147 @@
+import { ParamSerializer } from "./ParameterSerializer.js";
+import {
+ QuerySerializationStyles,
+ SerializationOptions,
+} from "../types/index.js";
+
+type SerializerOptions = Record<
+ string,
+ SerializationOptions
+>;
+
+function getDecodedUrlString(serializer: ParamSerializer): string {
+ return decodeURIComponent(serializer.getSearchParams().toString());
+}
+
+describe("QueryParamSerializer", () => {
+ describe("Primitive Values", () => {
+ test.each([
+ {
+ value: "value",
+ expected: "key=value",
+ },
+ {
+ value: 123,
+ expected: "key=123",
+ },
+ {
+ value: true,
+ expected: "key=true",
+ },
+ {
+ value: false,
+ expected: "key=false",
+ },
+ ])("serializes primitive value $value", ({ value, expected }) => {
+ const options: SerializerOptions = {};
+ const serializer = new ParamSerializer(options);
+
+ serializer.serializeQueryParam("key", value);
+
+ expect(getDecodedUrlString(serializer)).toBe(expected);
+ });
+ });
+
+ describe("Array Serialization", () => {
+ test.each([
+ {
+ style: "form",
+ explode: true,
+ expected: "arrayParam=value1&arrayParam=value2",
+ },
+ {
+ style: "form",
+ explode: false,
+ expected: "arrayParam=value1,value2",
+ },
+ {
+ style: "spaceDelimited",
+ explode: false,
+ // URLSearchParams encodes spaces as plus signs (+)
+ // https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs
+ expected: "arrayParam=value1+value2",
+ },
+ {
+ style: "pipeDelimited",
+ explode: false,
+ expected: "arrayParam=value1|value2",
+ },
+ ] as const)(
+ "serializes arrays in $style style with explode=$explode",
+ ({ style, explode, expected }) => {
+ const options: SerializerOptions = {
+ arrayParam: { style, explode },
+ };
+ const serializer = new ParamSerializer(options);
+
+ serializer.serializeQueryParam("arrayParam", ["value1", "value2"]);
+
+ expect(getDecodedUrlString(serializer)).toBe(expected);
+ },
+ );
+
+ test("throws an error for unsupported array serialization styles", () => {
+ const options: SerializerOptions = {
+ arrayParam: { style: "deepObject" },
+ };
+ const serializer = new ParamSerializer(options);
+
+ expect(() => {
+ serializer.serializeQueryParam("arrayParam", ["value1", "value2"]);
+ }).toThrow("Unsupported serialization style for arrays: 'deepObject'");
+ });
+ });
+
+ describe("Object Serialization", () => {
+ test.each([
+ {
+ style: "form",
+ explode: true,
+ expected: "foo=bar&baz=qux",
+ },
+ {
+ style: "form",
+ explode: false,
+ expected: "objectParam=foo,bar,baz,qux",
+ },
+ {
+ style: "deepObject",
+ explode: true,
+ expected: "objectParam[foo]=bar&objectParam[baz]=qux",
+ },
+ {
+ style: "contentJSON",
+ explode: true,
+ expected: 'objectParam={"foo":"bar","baz":"qux"}',
+ },
+ ] as const)(
+ "serializes objects in $style style with explode=$explode",
+ ({ style, explode, expected }) => {
+ const options: SerializerOptions = {
+ objectParam: { style, explode },
+ };
+ const serializer = new ParamSerializer(options);
+
+ serializer.serializeQueryParam("objectParam", {
+ foo: "bar",
+ baz: "qux",
+ });
+
+ expect(getDecodedUrlString(serializer)).toBe(expected);
+ },
+ );
+
+ test("throws an error for unsupported object serialization styles", () => {
+ const options: SerializerOptions = {
+ objectParam: { style: "pipeDelimited" },
+ };
+ const serializer = new ParamSerializer(options);
+
+ expect(() => {
+ serializer.serializeQueryParam("objectParam", { foo: "bar" });
+ }).toThrow(
+ "Unsupported serialization style for objects: 'pipeDelimited'",
+ );
+ });
+ });
+});
diff --git a/packages/commons/src/core/ParameterSerializer.ts b/packages/commons/src/core/ParameterSerializer.ts
new file mode 100644
index 00000000..9a79bf46
--- /dev/null
+++ b/packages/commons/src/core/ParameterSerializer.ts
@@ -0,0 +1,135 @@
+import {
+ QuerySerializationStyles,
+ SerializationOptions,
+} from "../types/index.js";
+
+type QuerySerializationOptions = SerializationOptions;
+
+export class ParamSerializer {
+ private readonly searchParams: URLSearchParams;
+ private readonly options: Record;
+
+ constructor(options: Record) {
+ this.options = options;
+ this.searchParams = new URLSearchParams();
+ }
+
+ serializeQueryParam(key: string, value: unknown): this {
+ const config = this.options[key];
+
+ switch (true) {
+ case Array.isArray(value):
+ this.handleArraySerialization(key, value, config);
+ break;
+ case typeof value === "object" && value !== null:
+ this.handleObjectSerialization(key, value, config);
+ break;
+ default:
+ this.handlePrimitiveSerialization(key, value);
+ }
+
+ return this;
+ }
+
+ getSearchParams(): URLSearchParams {
+ return this.searchParams;
+ }
+
+ private handlePrimitiveSerialization(key: string, value: unknown): void {
+ this.searchParams.append(key, String(value));
+ }
+
+ private handleArraySerialization(
+ key: string,
+ value: unknown[],
+ styleOptions: QuerySerializationOptions = { style: "form", explode: true },
+ ): void {
+ const { style, explode = true } = styleOptions;
+
+ switch (style) {
+ case "form":
+ this.serializeArrayForm(key, value, explode);
+ break;
+ case "spaceDelimited":
+ this.serializeArrayDelimited(key, value, " ");
+ break;
+ case "pipeDelimited":
+ this.serializeArrayDelimited(key, value, "|");
+ break;
+ default:
+ throw new Error(
+ `Unsupported serialization style for arrays: '${style}'`,
+ );
+ }
+ }
+
+ private serializeArrayForm(
+ key: string,
+ value: unknown[],
+ explode: boolean,
+ ): void {
+ if (explode) {
+ value.forEach((item) => this.searchParams.append(key, String(item)));
+ } else {
+ this.serializeArrayDelimited(key, value, ",");
+ }
+ }
+
+ private serializeArrayDelimited(
+ key: string,
+ value: unknown[],
+ delimiter: string,
+ ): void {
+ this.searchParams.append(key, value.join(delimiter));
+ }
+
+ private handleObjectSerialization(
+ key: string,
+ value: object,
+ styleOptions: QuerySerializationOptions = {
+ style: "deepObject",
+ explode: true,
+ },
+ ): void {
+ const { style, explode = true } = styleOptions;
+
+ switch (style) {
+ case "form":
+ this.serializeObjectForm(key, value, explode);
+ break;
+ case "deepObject":
+ this.serializeObjectDeepObject(key, value);
+ break;
+ case "contentJSON":
+ this.searchParams.append(key, JSON.stringify(value));
+ break;
+ default:
+ throw new Error(
+ `Unsupported serialization style for objects: '${style}'`,
+ );
+ }
+ }
+
+ private serializeObjectForm(
+ key: string,
+ value: object,
+ explode: boolean,
+ ): void {
+ if (explode) {
+ Object.entries(value).forEach(([k, v]) =>
+ this.searchParams.append(k, String(v)),
+ );
+ } else {
+ const serialized = Object.entries(value)
+ .map(([k, v]) => `${k},${v}`)
+ .join(",");
+ this.searchParams.append(key, serialized);
+ }
+ }
+
+ private serializeObjectDeepObject(key: string, value: object): void {
+ Object.entries(value).forEach(([k, v]) =>
+ this.searchParams.append(`${key}[${k}]`, String(v)),
+ );
+ }
+}
diff --git a/packages/commons/src/core/Request.test.ts b/packages/commons/src/core/Request.test.ts
index 4cb1c406..1f07f5b0 100644
--- a/packages/commons/src/core/Request.test.ts
+++ b/packages/commons/src/core/Request.test.ts
@@ -1,7 +1,7 @@
import Request from "./Request.js";
import { AxiosInstance } from "axios";
import { jest } from "@jest/globals";
-import { QueryParameters } from "../types/index.js";
+import { OpenAPIOperation, QueryParameters } from "../types/index.js";
const requestFn = jest.fn();
@@ -20,8 +20,14 @@ describe("query parameters", () => {
method: "GET",
} as const;
- const executeRequest = (query: QueryParameters): string => {
- const request = new Request(op, { queryParameters: query });
+ const executeRequest = (
+ query: QueryParameters,
+ opOverwrites?: Partial,
+ ): string => {
+ const request = new Request(
+ { ...op, ...opOverwrites },
+ { queryParameters: query },
+ );
request.execute(mockedAxios);
const requestConfig = requestFn.mock.calls[0][0] as {
params: URLSearchParams;
@@ -60,13 +66,21 @@ describe("query parameters", () => {
expect(query).toBe("foo=bar&foo=bam");
});
- test("Number, boolean, JSON", () => {
- const query = executeRequest({
- foo: 1,
- bar: true,
- baz: { some: "value" },
- });
+ test("Number, boolean, JSON, deepObject", () => {
+ const query = executeRequest(
+ {
+ foo: 1,
+ bar: true,
+ baz: { some: "value" },
+ deep: { object: "value" },
+ },
+ {
+ serialization: { query: { baz: { style: "contentJSON" } } },
+ },
+ );
- expect(query).toBe("foo=1&bar=true&baz=%7B%22some%22%3A%22value%22%7D");
+ expect(query).toBe(
+ "foo=1&bar=true&baz=%7B%22some%22%3A%22value%22%7D&deep%5Bobject%5D=value",
+ );
});
});
diff --git a/packages/commons/src/core/Request.ts b/packages/commons/src/core/Request.ts
index 380b0586..1e61cb3a 100644
--- a/packages/commons/src/core/Request.ts
+++ b/packages/commons/src/core/Request.ts
@@ -11,6 +11,7 @@ import {
AxiosRequestConfig,
RawAxiosRequestHeaders,
} from "axios";
+import { ParamSerializer } from "./ParameterSerializer.js";
export class Request {
private readonly operationDescriptor: TOp;
@@ -91,30 +92,18 @@ export class Request {
}
if (typeof query === "object") {
- const searchParams = new URLSearchParams();
+ const serializer = new ParamSerializer(
+ this.operationDescriptor.serialization?.query ?? {},
+ );
for (const [key, value] of Object.entries(query)) {
if (value === undefined) {
continue;
}
-
- if (Array.isArray(value)) {
- for (const arrayItem of value) {
- searchParams.append(key, arrayItem);
- }
- } else {
- searchParams.append(
- key,
- typeof value === "string" ||
- typeof value === "number" ||
- typeof value === "boolean"
- ? value.toString()
- : JSON.stringify(value),
- );
- }
+ serializer.serializeQueryParam(key, value);
}
- return searchParams;
+ return serializer.getSearchParams();
}
throw new Error(`Unexpected query parameter type (${typeof query})`);
diff --git a/packages/commons/src/types/OpenAPIOperation.ts b/packages/commons/src/types/OpenAPIOperation.ts
index 820ab856..b230a166 100644
--- a/packages/commons/src/types/OpenAPIOperation.ts
+++ b/packages/commons/src/types/OpenAPIOperation.ts
@@ -2,6 +2,17 @@ import { AnyResponse, Response } from "./Response.js";
import { AnyRequest, RequestType } from "./RequestType.js";
import { HttpMethod, HttpStatus } from "./http.js";
+export type QuerySerializationStyles =
+ | "form"
+ | "spaceDelimited"
+ | "pipeDelimited"
+ | "deepObject"
+ | "contentJSON";
+export interface SerializationOptions {
+ style: TStyle;
+ explode?: boolean;
+}
+
export interface OpenAPIOperation<
TIgnoredRequest extends AnyRequest = RequestType,
IgnoredResponse extends AnyResponse = Response,
@@ -9,6 +20,9 @@ export interface OpenAPIOperation<
operationId: string;
path: string;
method: HttpMethod;
+ serialization?: {
+ query?: Record>;
+ };
}
export type InferredRequestType =
diff --git a/packages/commons/src/types/RequestType.ts b/packages/commons/src/types/RequestType.ts
index af9ddb7c..f08232f2 100644
--- a/packages/commons/src/types/RequestType.ts
+++ b/packages/commons/src/types/RequestType.ts
@@ -29,7 +29,7 @@ type RequestWithHeaders = THeaders extends EmptyRequestComponent
type RequestWithQueryParameters = TQuery extends EmptyRequestComponent
? RequestWithOptionalHeaders
: {
- queryParameters: TQuery & HttpHeaders;
+ queryParameters: TQuery & QueryParameters;
};
export type RequestType<