From eb67a6d65745cc0374d5a201b45237c4dd18b4f8 Mon Sep 17 00:00:00 2001 From: Lukas Fritze Date: Fri, 22 Nov 2024 11:34:38 +0100 Subject: [PATCH] feat: Add support for different serialization strategies for query parameters see https://swagger.io/docs/specification/v3_0/serialization/#query-parameters --- .../src/core/ParameterSerializer.test.ts | 147 ++++++++++++++++++ .../commons/src/core/ParameterSerializer.ts | 135 ++++++++++++++++ packages/commons/src/core/Request.test.ts | 34 ++-- packages/commons/src/core/Request.ts | 23 +-- .../commons/src/types/OpenAPIOperation.ts | 14 ++ 5 files changed, 326 insertions(+), 27 deletions(-) create mode 100644 packages/commons/src/core/ParameterSerializer.test.ts create mode 100644 packages/commons/src/core/ParameterSerializer.ts 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 =