Skip to content

Commit 59a6640

Browse files
authored
fix(openapi): serialize exploded query objects (#1517)
1 parent c757e13 commit 59a6640

3 files changed

Lines changed: 146 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,51 @@ const encodeReservedAware = (raw: string, allowReserved: boolean): string => {
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* (
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‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,107 @@ it.effect("serializes form-exploded query arrays as repeated parameters", () =>
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 }) => {

0 commit comments

Comments
 (0)