From 3ee8ed987722df0754a895907bf92bf113f86fbd Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Mon, 14 Sep 2026 21:10:59 +1000 Subject: [PATCH] fix(api): expand deepObject query parameters into per-key pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object-valued query parameters in the v2 spec all declare `style: deepObject`, which puts one `param[key]=value` pair per entry on the wire. The client serialized them as a JSON blob instead, so a caller passing `page: { size: 100 }` reached the server with no page size and no cursor at all — a paginated walk silently returned the first default-sized page forever. Expand every object-valued query parameter into its own pairs. Arrays are untouched: those are the repeated-key form `normalizeUrlValue` already handles. Co-Authored-By: Claude Opus 5 --- packages/api/README.md | 12 +++--- packages/api/src/internal/client.ts | 26 ++++++++++++- packages/api/src/internal/client.unit.test.ts | 39 +++++++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/packages/api/README.md b/packages/api/README.md index d8bc61146a..d9f8ad8ff9 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -157,10 +157,10 @@ because the upstream documents differ between environments — staging's `v2-jso served by two backend variants that disagree about some paths. Entries may carry a `$comment` field to document why an override exists. -### Known limitation: `deepObject` query parameters +### `deepObject` query parameters -Three v2 operations declare object-valued query parameters with `style: deepObject`: -`v2-list-organization-members`, `v2-list-organization-projects`, and -`v2-list-organization-github-connections`. The client currently serializes these as JSON strings -rather than the expected `page[size]=...` form. Do not rely on those parameters until this is -fixed. +Several v2 list operations declare object-valued query parameters with `style: deepObject` +(`page`, `filter`). The client expands each own entry into its own `param[key]=value` pair, so +`{ page: { size: 100, after: cursor } }` reaches the wire as `page[size]=100&page[after]=…`. Every +object-valued query parameter in the spec is `deepObject`, so the expansion is applied to all of +them rather than driven by a per-parameter style recorded in the generated contract. diff --git a/packages/api/src/internal/client.ts b/packages/api/src/internal/client.ts index e55466a4a6..dd050b06ff 100644 --- a/packages/api/src/internal/client.ts +++ b/packages/api/src/internal/client.ts @@ -269,6 +269,15 @@ function prepareClient( return options?.transformClient ? options.transformClient(retried) : Effect.succeed(retried); } +/** + * Whether a query-parameter value is the object form OpenAPI serializes as + * `style: deepObject`. Arrays are excluded — they are the repeated-key form + * `normalizeUrlValue` already handles. + */ +function isDeepObjectValue(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function normalizeUrlValue(value: unknown): string | ReadonlyArray { const revealed = revealRedactedValue(value); @@ -448,9 +457,22 @@ function buildRequest( const query: Record> = {}; for (const param of definition.queryParams) { const value = revealRedactedValue(Reflect.get(input, param)); - if (value !== undefined) { - query[param] = normalizeUrlValue(value); + if (value === undefined) { + continue; + } + // `style: deepObject` — every object-valued query parameter this spec + // declares (the v2 `page` / `filter` pairs) — is one `param[key]=value` on + // the wire, not a JSON blob: `page: { size: 100 }` must arrive as + // `page[size]=100` or the server reads no page size at all. + if (isDeepObjectValue(value)) { + for (const [key, entry] of Object.entries(value)) { + if (entry !== undefined) { + query[`${param}[${key}]`] = normalizeUrlValue(entry); + } + } + continue; } + query[param] = normalizeUrlValue(value); } if (Object.keys(query).length > 0) { request = HttpClientRequest.setUrlParams(request, query); diff --git a/packages/api/src/internal/client.unit.test.ts b/packages/api/src/internal/client.unit.test.ts index 803aa4128b..14ce04b926 100644 --- a/packages/api/src/internal/client.unit.test.ts +++ b/packages/api/src/internal/client.unit.test.ts @@ -1142,4 +1142,43 @@ describe("makeSupabaseApiClient", () => { expect(result.data.attributes.storage.upstream_target).toBe("main"); expect(result.data.attributes.api.db_pool).toBeNull(); }); + // `style: deepObject` — every object-valued query parameter in the spec — is + // one `param[key]=value` pair per entry. Serialized as a JSON blob instead, + // the server reads no page size and no cursor, so a paginated walk silently + // returns the first default-sized page forever. + test("expands deepObject query parameters into one pair per entry", async () => { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const client = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed( + jsonResponse(request, 200, { + data: [], + links: { first: null, last: null, prev: null, next: null }, + }), + ); + }), + ), + ), + ); + + await Effect.runPromise( + client.execute(operationDefinitions.v2ListNotebooks, { + ref: "abcdefghijklmnopqrst", + page: { size: 100, after: "cursor-1" }, + filter: { name: "sales" }, + }), + ); + + expect(seenRequest).toBeDefined(); + expect(requestUrlParam(seenRequest!, "page[size]")).toBe("100"); + expect(requestUrlParam(seenRequest!, "page[after]")).toBe("cursor-1"); + expect(requestUrlParam(seenRequest!, "filter[name]")).toBe("sales"); + // The un-expanded names never reach the wire. + expect(requestUrlParam(seenRequest!, "page")).toBeUndefined(); + expect(requestUrlParam(seenRequest!, "filter")).toBeUndefined(); + }); });