Skip to content

Commit

Permalink
feat: Add support for different serialization strategies for query pa…
Browse files Browse the repository at this point in the history
  • Loading branch information
Lukas Fritze committed Nov 22, 2024
1 parent 999675d commit eb67a6d
Show file tree
Hide file tree
Showing 5 changed files with 326 additions and 27 deletions.
147 changes: 147 additions & 0 deletions packages/commons/src/core/ParameterSerializer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { ParamSerializer } from "./ParameterSerializer.js";
import {
QuerySerializationStyles,
SerializationOptions,
} from "../types/index.js";

type SerializerOptions = Record<
string,
SerializationOptions<QuerySerializationStyles>
>;

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'",
);
});
});
});
135 changes: 135 additions & 0 deletions packages/commons/src/core/ParameterSerializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {
QuerySerializationStyles,
SerializationOptions,
} from "../types/index.js";

type QuerySerializationOptions = SerializationOptions<QuerySerializationStyles>;

export class ParamSerializer {
private readonly searchParams: URLSearchParams;
private readonly options: Record<string, QuerySerializationOptions>;

constructor(options: Record<string, QuerySerializationOptions>) {
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)),
);
}
}
34 changes: 24 additions & 10 deletions packages/commons/src/core/Request.test.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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<OpenAPIOperation>,
): string => {
const request = new Request(
{ ...op, ...opOverwrites },
{ queryParameters: query },
);
request.execute(mockedAxios);
const requestConfig = requestFn.mock.calls[0][0] as {
params: URLSearchParams;
Expand Down Expand Up @@ -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",
);
});
});
23 changes: 6 additions & 17 deletions packages/commons/src/core/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
AxiosRequestConfig,
RawAxiosRequestHeaders,
} from "axios";
import { ParamSerializer } from "./ParameterSerializer.js";

export class Request<TOp extends OpenAPIOperation> {
private readonly operationDescriptor: TOp;
Expand Down Expand Up @@ -91,30 +92,18 @@ export class Request<TOp extends OpenAPIOperation> {
}

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})`);
Expand Down
Loading

0 comments on commit eb67a6d

Please sign in to comment.