diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index d70c9b045..191ad3b72 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -645,15 +645,12 @@ export const makeAPI = (config: ClientConfig) => { request = yield* HttpClientRequest.bodyJson(parts.body)(request); } } else if (method === "GET" && parts.body !== undefined) { - // For GET requests, remaining non-annotated fields go as query params - const extraQuery: Record = {}; - for (const [key, value] of Object.entries( + // For GET requests, remaining non-annotated fields go as query + // params. Plain objects flatten to deepObject dot-notation + // (`filter.exact=v`) instead of `[object Object]`. + const extraQuery = Traits.buildExtraQueryParams( parts.body as Record, - )) { - if (value !== undefined) { - extraQuery[key] = String(value); - } - } + ); if (Object.keys(extraQuery).length > 0) { request = HttpClientRequest.setUrlParams(request, extraQuery); } diff --git a/packages/core/src/traits.ts b/packages/core/src/traits.ts index 52f93c837..136d1a457 100644 --- a/packages/core/src/traits.ts +++ b/packages/core/src/traits.ts @@ -730,6 +730,33 @@ const setQueryValue = ( } }; +/** + * Build query params for the non-annotated leftover fields that the client + * appends to GET requests (the `method === "GET" && parts.body !== undefined` + * fallback in `client.ts`). + * + * Mirrors the annotated-query fix above: plain objects flatten to OpenAPI + * `deepObject` dot-notation instead of serializing as `[object Object]`. + * Everything else keeps the legacy `String(value)` serialization — including + * top-level arrays, which have always joined with `,` on this path (unlike + * annotated query params, which send repeated pairs); changing that here + * would alter working behavior for existing callers. + */ +export const buildExtraQueryParams = ( + body: Record, +): Record => { + const query: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (value === undefined) continue; + if (isPlainObject(value)) { + setQueryValue(query, key, value); + } else { + query[key] = String(value); + } + } + return query; +}; + export const buildRequestParts = ( ast: AST.AST, httpTrait: HttpTrait, diff --git a/packages/core/test/build-request-parts.test.ts b/packages/core/test/build-request-parts.test.ts index 0679ddf43..ec31e9266 100644 --- a/packages/core/test/build-request-parts.test.ts +++ b/packages/core/test/build-request-parts.test.ts @@ -150,3 +150,59 @@ describe("buildRequestParts — deepObject query params", () => { expect(parts.query).toEqual({ type: "A", id: ["1", "2"] }); }); }); + +// The GET fallback in client.ts puts non-annotated leftover fields into the +// query string. It must flatten plain objects like the annotated path, but +// preserve its own legacy serialization for everything else (notably +// top-level arrays, which have always comma-joined via String()). + +describe("buildExtraQueryParams — GET leftover-field fallback", () => { + it("flattens plain objects to dot-notation", () => { + expect( + T.buildExtraQueryParams({ filter: { exact: "a", contains: "b" } }), + ).toEqual({ "filter.exact": "a", "filter.contains": "b" }); + }); + + it("recurses into nested plain objects", () => { + expect(T.buildExtraQueryParams({ a: { b: { c: 1 } } })).toEqual({ + "a.b.c": "1", + }); + }); + + it("serializes array members of objects as repeated params", () => { + expect(T.buildExtraQueryParams({ tag: { not: ["a", "b"] } })).toEqual({ + "tag.not": ["a", "b"], + }); + }); + + it("keeps legacy String() serialization for scalars and top-level arrays", () => { + expect( + T.buildExtraQueryParams({ + type: "A", + count: 3, + flag: false, + ids: ["1", "2"], + nul: null, + }), + ).toEqual({ + type: "A", + count: "3", + flag: "false", + ids: "1,2", + nul: "null", + }); + }); + + it("keeps legacy String() serialization for non-plain objects", () => { + const date = new Date("2026-01-02T03:04:05.000Z"); + expect(T.buildExtraQueryParams({ before: date })).toEqual({ + before: String(date), + }); + }); + + it("skips undefined fields", () => { + expect(T.buildExtraQueryParams({ a: undefined, b: "x" })).toEqual({ + b: "x", + }); + }); +});