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
57 return out;57 return out;
58};58};
59 59
60const queryParamValues = (value: unknown, param: OperationParameter): string[] => {60type QueryParamEntry = readonly [name: string, value: string];
61
62const isRecord = (value: unknown): value is Record<string, unknown> =>
63 typeof value === "object" && value !== null && !Array.isArray(value);
64
65const queryParamEntries = (value: unknown, param: OperationParameter): QueryParamEntry[] => {
61 if (value === undefined || value === null) return [];66 if (value === undefined || value === null) return [];
62 if (!Array.isArray(value)) return [primitiveToString(value)];
63 67
64 const style = Option.getOrUndefined(param.style) ?? "form";68 const style = Option.getOrUndefined(param.style) ?? "form";
65 const explode = Option.getOrElse(param.explode, () => true);69 const explode = Option.getOrElse(param.explode, () => true);
66 70
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)]);
68 102
69 const separator = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ",";103 const separator = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ",";
70 return [value.map(primitiveToString).join(separator)];104 return [[param.name, value.map(primitiveToString).join(separator)]];
71};105};
72 106
73// ---------------------------------------------------------------------------107// ---------------------------------------------------------------------------
@@ -903,8 +937,8 @@ export const buildRequest = Effect.fn("OpenApi.buildRequest")(function* ( Ā· 829 unmodified lines
903 for (const param of operation.parameters) {937 for (const param of operation.parameters) {
904 if (param.location !== "query") continue;938 if (param.location !== "query") continue;
905 const value = readParamValue(args, param);939 const value = readParamValue(args, param);
906 for (const paramValue of queryParamValues(value, param)) {940 for (const [name, paramValue] of queryParamEntries(value, param)) {
907 request = HttpClientRequest.appendUrlParam(request, param.name, paramValue);941 request = HttpClientRequest.appendUrlParam(request, name, paramValue);
908 }942 }
909 }943 }
910 944
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
98 ),98 ),
99);99);
100 100
101it.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
147it.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
101it.effect("uses operation base URL and preserves reserved path expansion when allowed", () =>202it.effect("uses operation base URL and preserves reserved path expansion when allowed", () =>
102 Effect.promise(() =>203 Effect.promise(() =>
103 withServer(async ({ baseUrl, requests }) => {204 withServer(async ({ baseUrl, requests }) => {