Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/openapi-query-object-serialization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

**Fix: OpenAPI query parameters that use form-style exploded objects now serialize each object field as a query parameter.**
46 changes: 40 additions & 6 deletions packages/plugins/openapi/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,51 @@ const encodeReservedAware = (raw: string, allowReserved: boolean): string => {
return out;
};

const queryParamValues = (value: unknown, param: OperationParameter): string[] => {
type QueryParamEntry = readonly [name: string, value: string];

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);

const queryParamEntries = (value: unknown, param: OperationParameter): QueryParamEntry[] => {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) return [primitiveToString(value)];

const style = Option.getOrUndefined(param.style) ?? "form";
const explode = Option.getOrElse(param.explode, () => true);

if (explode) return value.map(primitiveToString);
if (isRecord(value)) {
const entries = Object.entries(value).filter(
([, nested]) => nested !== undefined && nested !== null,
);

if (style === "form") {
if (explode) {
// OAS form + explode=true serializes an object as top-level query
// fields, e.g. `{ region: "west", tier: "standard" }` ->
// `region=west&tier=standard`.
return entries.map(([name, nested]) => [name, primitiveToString(nested)]);
}

return [
[
param.name,
entries.flatMap(([name, nested]) => [name, primitiveToString(nested)]).join(","),
],
];
}

if (style === "deepObject") {
return entries.map(([name, nested]) => [`${param.name}[${name}]`, primitiveToString(nested)]);
}

return [[param.name, primitiveToString(value)]];
}

if (!Array.isArray(value)) return [[param.name, primitiveToString(value)]];

if (explode) return value.map((nested) => [param.name, primitiveToString(nested)]);

const separator = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ",";
return [value.map(primitiveToString).join(separator)];
return [[param.name, value.map(primitiveToString).join(separator)]];
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -903,8 +937,8 @@ export const buildRequest = Effect.fn("OpenApi.buildRequest")(function* (
for (const param of operation.parameters) {
if (param.location !== "query") continue;
const value = readParamValue(args, param);
for (const paramValue of queryParamValues(value, param)) {
request = HttpClientRequest.appendUrlParam(request, param.name, paramValue);
for (const [name, paramValue] of queryParamEntries(value, param)) {
request = HttpClientRequest.appendUrlParam(request, name, paramValue);
}
}

Expand Down
101 changes: 101 additions & 0 deletions packages/plugins/openapi/src/sdk/query-serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,107 @@ it.effect("serializes form-exploded query arrays as repeated parameters", () =>
),
);

it.effect("serializes default form query objects using their fields", () =>
Effect.promise(() =>
withServer(async ({ baseUrl, requests }) => {
const operation = OperationBinding.make({
method: "get",
servers: [],
pathTemplate: "/domains",
requestBody: Option.none(),
responseBody: Option.none(),
parameters: [
OperationParameter.make({
name: "params",
location: "query",
required: false,
schema: Option.some({
type: "object",
additionalProperties: { type: "string" },
}),
// Query parameters default to form + explode=true in OAS.
style: Option.none(),
explode: Option.none(),
allowReserved: Option.none(),
description: Option.none(),
}),
],
});

await Effect.runPromise(
invokeWithLayer(
operation,
{ params: { region: "west", tier: "standard" } },
baseUrl,
{},
{},
FetchHttpClient.layer,
),
);

const url = new URL(requests[0]!, "http://executor.test");
expect(url.searchParams.get("region")).toBe("west");
expect(url.searchParams.get("tier")).toBe("standard");
expect(url.searchParams.has("params")).toBe(false);
}),
),
);

it.effect("serializes non-exploded and deep-object query objects", () =>
Effect.promise(() =>
withServer(async ({ baseUrl, requests }) => {
const operation = OperationBinding.make({
method: "get",
servers: [],
pathTemplate: "/filters",
requestBody: Option.none(),
responseBody: Option.none(),
parameters: [
OperationParameter.make({
name: "color",
location: "query",
required: false,
schema: Option.some({ type: "object" }),
style: Option.some("form"),
explode: Option.some(false),
allowReserved: Option.none(),
description: Option.none(),
}),
OperationParameter.make({
name: "filter",
location: "query",
required: false,
schema: Option.some({ type: "object" }),
style: Option.some("deepObject"),
explode: Option.some(true),
allowReserved: Option.none(),
description: Option.none(),
}),
],
});

await Effect.runPromise(
invokeWithLayer(
operation,
{
color: { R: 100, G: 200, B: 150 },
filter: { status: "active", owner: "alice" },
},
baseUrl,
{},
{},
FetchHttpClient.layer,
),
);

const url = new URL(requests[0]!, "http://executor.test");
expect(url.searchParams.get("color")).toBe("R,100,G,200,B,150");
expect(url.searchParams.get("filter[status]")).toBe("active");
expect(url.searchParams.get("filter[owner]")).toBe("alice");
}),
),
);

it.effect("uses operation base URL and preserves reserved path expansion when allowed", () =>
Effect.promise(() =>
withServer(async ({ baseUrl, requests }) => {
Expand Down