Skip to content
Draft
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
12 changes: 6 additions & 6 deletions packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 24 additions & 2 deletions packages/api/src/internal/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function normalizeUrlValue(value: unknown): string | ReadonlyArray<string> {
const revealed = revealRedactedValue(value);

Expand Down Expand Up @@ -448,9 +457,22 @@ function buildRequest(
const query: Record<string, string | ReadonlyArray<string>> = {};
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);
Expand Down
39 changes: 39 additions & 0 deletions packages/api/src/internal/client.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading