History

fix(openapi): serialize exploded query objects (#1517)

kitze ๐Ÿš€ committed 3 months ago

Commit 59a66401 parent c757e133 files ยท +146 โˆ’6Browse files

Unified ยท Split

3 files
  1. .changeset/openapi-query-object-serialization.md +5 โˆ’0
  2. packages/plugins/openapi/src/sdk/invoke.ts +40 โˆ’6
  3. packages/plugins/openapi/src/sdk/query-serialization.test.ts +101 โˆ’0
.changeset/openapi-query-object-serialization.md+5โˆ’0
@@ -0,0 +1,5 @@
1+---
2+"executor": patch
3+---
4+
5+**Fix: OpenAPI query parameters that use form-style exploded objects now serialize each object field as a query parameter.**
packages/plugins/openapi/src/sdk/invoke.ts+40โˆ’6
@@ -57,17 +57,51 @@ const encodeReservedAware = (raw: string, allowReserved: boolean): string => { ยท 56 unmodified lines
5757 return out;
5858 };
5959
60โˆ’const queryParamValues = (value: unknown, param: OperationParameter): string[] => {
60+type QueryParamEntry = readonly [name: string, value: string];
61+
62+const isRecord = (value: unknown): value is Record<string, unknown> =>
63+ typeof value === "object" && value !== null && !Array.isArray(value);
64+
65+const queryParamEntries = (value: unknown, param: OperationParameter): QueryParamEntry[] => {
6166 if (value === undefined || value === null) return [];
62โˆ’ if (!Array.isArray(value)) return [primitiveToString(value)];
6367
6468 const style = Option.getOrUndefined(param.style) ?? "form";
6569 const explode = Option.getOrElse(param.explode, () => true);
6670
67โˆ’ if (explode) return value.map(primitiveToString);
71+ if (isRecord(value)) {
72+ const entries = Object.entries(value).filter(
73+ ([, nested]) => nested !== undefined && nested !== null,
74+ );
75+
76+ if (style === "form") {
77+ if (explode) {
78+ // OAS form + explode=true serializes an object as top-level query
79+ // fields, e.g. `{ region: "west", tier: "standard" }` ->
80+ // `region=west&tier=standard`.
81+ return entries.map(([name, nested]) => [name, primitiveToString(nested)]);
82+ }
83+
84+ return [
85+ [
86+ param.name,
87+ entries.flatMap(([name, nested]) => [name, primitiveToString(nested)]).join(","),
88+ ],
89+ ];
90+ }
91+
92+ if (style === "deepObject") {
93+ return entries.map(([name, nested]) => [`${param.name}[${name}]`, primitiveToString(nested)]);
94+ }
95+
96+ return [[param.name, primitiveToString(value)]];
97+ }
98+
99+ if (!Array.isArray(value)) return [[param.name, primitiveToString(value)]];
100+
101+ if (explode) return value.map((nested) => [param.name, primitiveToString(nested)]);
68102
69103 const separator = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ",";
70โˆ’ return [value.map(primitiveToString).join(separator)];
104+ return [[param.name, value.map(primitiveToString).join(separator)]];
71105 };
72106
73107 // ---------------------------------------------------------------------------
@@ -903,8 +937,8 @@ export const buildRequest = Effect.fn("OpenApi.buildRequest")(function* ( ยท 829 unmodified lines
903937 for (const param of operation.parameters) {
904938 if (param.location !== "query") continue;
905939 const value = readParamValue(args, param);
906โˆ’ for (const paramValue of queryParamValues(value, param)) {
907โˆ’ request = HttpClientRequest.appendUrlParam(request, param.name, paramValue);
940+ for (const [name, paramValue] of queryParamEntries(value, param)) {
941+ request = HttpClientRequest.appendUrlParam(request, name, paramValue);
908942 }
909943 }
910944
packages/plugins/openapi/src/sdk/query-serialization.test.ts+101โˆ’0
@@ -98,6 +98,107 @@ it.effect("serializes form-exploded query arrays as repeated parameters", () => ยท 97 unmodified lines
9898 ),
9999 );
100100
101+it.effect("serializes default form query objects using their fields", () =>
102+ Effect.promise(() =>
103+ withServer(async ({ baseUrl, requests }) => {
104+ const operation = OperationBinding.make({
105+ method: "get",
106+ servers: [],
107+ pathTemplate: "/domains",
108+ requestBody: Option.none(),
109+ responseBody: Option.none(),
110+ parameters: [
111+ OperationParameter.make({
112+ name: "params",
113+ location: "query",
114+ required: false,
115+ schema: Option.some({
116+ type: "object",
117+ additionalProperties: { type: "string" },
118+ }),
119+ // Query parameters default to form + explode=true in OAS.
120+ style: Option.none(),
121+ explode: Option.none(),
122+ allowReserved: Option.none(),
123+ description: Option.none(),
124+ }),
125+ ],
126+ });
127+
128+ await Effect.runPromise(
129+ invokeWithLayer(
130+ operation,
131+ { params: { region: "west", tier: "standard" } },
132+ baseUrl,
133+ {},
134+ {},
135+ FetchHttpClient.layer,
136+ ),
137+ );
138+
139+ const url = new URL(requests[0]!, "http://executor.test");
140+ expect(url.searchParams.get("region")).toBe("west");
141+ expect(url.searchParams.get("tier")).toBe("standard");
142+ expect(url.searchParams.has("params")).toBe(false);
143+ }),
144+ ),
145+);
146+
147+it.effect("serializes non-exploded and deep-object query objects", () =>
148+ Effect.promise(() =>
149+ withServer(async ({ baseUrl, requests }) => {
150+ const operation = OperationBinding.make({
151+ method: "get",
152+ servers: [],
153+ pathTemplate: "/filters",
154+ requestBody: Option.none(),
155+ responseBody: Option.none(),
156+ parameters: [
157+ OperationParameter.make({
158+ name: "color",
159+ location: "query",
160+ required: false,
161+ schema: Option.some({ type: "object" }),
162+ style: Option.some("form"),
163+ explode: Option.some(false),
164+ allowReserved: Option.none(),
165+ description: Option.none(),
166+ }),
167+ OperationParameter.make({
168+ name: "filter",
169+ location: "query",
170+ required: false,
171+ schema: Option.some({ type: "object" }),
172+ style: Option.some("deepObject"),
173+ explode: Option.some(true),
174+ allowReserved: Option.none(),
175+ description: Option.none(),
176+ }),
177+ ],
178+ });
179+
180+ await Effect.runPromise(
181+ invokeWithLayer(
182+ operation,
183+ {
184+ color: { R: 100, G: 200, B: 150 },
185+ filter: { status: "active", owner: "alice" },
186+ },
187+ baseUrl,
188+ {},
189+ {},
190+ FetchHttpClient.layer,
191+ ),
192+ );
193+
194+ const url = new URL(requests[0]!, "http://executor.test");
195+ expect(url.searchParams.get("color")).toBe("R,100,G,200,B,150");
196+ expect(url.searchParams.get("filter[status]")).toBe("active");
197+ expect(url.searchParams.get("filter[owner]")).toBe("alice");
198+ }),
199+ ),
200+);
201+
101202 it.effect("uses operation base URL and preserves reserved path expansion when allowed", () =>
102203 Effect.promise(() =>
103204 withServer(async ({ baseUrl, requests }) => {