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
13 changes: 5 additions & 8 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,15 +645,12 @@ export const makeAPI = <Creds>(config: ClientConfig<Creds>) => {
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<string, string> = {};
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<string, unknown>,
)) {
if (value !== undefined) {
extraQuery[key] = String(value);
}
}
);
if (Object.keys(extraQuery).length > 0) {
request = HttpClientRequest.setUrlParams(request, extraQuery);
}
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/traits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
): Record<string, string | string[]> => {
const query: Record<string, string | string[]> = {};
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,
Expand Down
56 changes: 56 additions & 0 deletions packages/core/test/build-request-parts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});
});
Loading