From 0e3ab086caa5283ad316a79d4e568ff57113c415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:27:13 +0200 Subject: [PATCH] fix(cli): omit absent optional request bodies Rationale: Generated public commands decode an omitted optional body as undefined, but the shared Effect HTTP client serialized that no-content branch as JSON. This made body-less worker-bootstrap requests fail server validation. Skip request-body encoding only when the endpoint explicitly permits HttpApiSchema.NoContent and the payload is undefined; explicit JSON remains encoded and null continues to fail input validation. Rejected: Per-operation request construction would duplicate the generated client path and leave every other optional OpenAPI body broken. Treating all undefined payloads as absent would weaken required-body behavior. Risk: This changes the pinned Effect patch shared with the custom-verb fix; the additions are independent HttpApiClient hunks and require rebase/combined-patch verification if that PR lands first. Tested: bun install --force --frozen-lockfile; bun run test test/generated-command.test.ts; bun run generate:check; mise run check; git diff --check. --- patches/effect@4.0.0-rc.109.patch | 12 +++++ test/generated-command.test.ts | 77 +++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/patches/effect@4.0.0-rc.109.patch b/patches/effect@4.0.0-rc.109.patch index 00f5b1c..06eee1d 100644 --- a/patches/effect@4.0.0-rc.109.patch +++ b/patches/effect@4.0.0-rc.109.patch @@ -58,6 +58,12 @@ index 9036e8e06ff04fbf3b2e42e085bb8bfcedf16b68..ec31b9ff1eceb5dac5b0954e601037c6 }); }; }; +@@ -109,1 +109,2 @@ export const makeClient = (api, options) => Effect.gen(function* () { + const encodeHeaders = UndefinedOr.map(endpoint.headers, Schema.encodeUnknownEffect); ++ const payloadCanBeOmitted = payloadSchemas.some(schema => HttpApiSchema.isNoContent(schema.ast)); +@@ -121,1 +122,1 @@ export const makeClient = (api, options) => Effect.gen(function* () { +- if (encodePayload !== undefined) { ++ if (encodePayload !== undefined && !(payloadCanBeOmitted && request.payload === undefined)) { diff --git a/dist/unstable/httpapi/HttpApiEndpoint.d.ts b/dist/unstable/httpapi/HttpApiEndpoint.d.ts index e95cfc448c7fd374d1781c59b601dec9703fd9a1..e2047691b17e9a16fbaa86b752161f8c6e89b000 100644 --- a/dist/unstable/httpapi/HttpApiEndpoint.d.ts @@ -119,6 +125,12 @@ index 365329af41ab4f3e4ec462baadf905c31cc24cf2..8c9466bcc496e4e247f2181bcf7d74b5 }) } } +@@ -396,1 +396,2 @@ export const makeClient = HttpApiSchema.isNoContent(schema.ast)) + const encodeHeaders = UndefinedOr.map(endpoint.headers, Schema.encodeUnknownEffect) +@@ -419,1 +420,1 @@ export const makeClient = { ); }); + test("optional request bodies are omitted from the request", async () => { + let received: Request | undefined; + await expect( + runGenerated( + "orderDrafts.createWorkerBootstrap", + ["--input", "-"], + '{"path":{"id":"odft_123"}}', + (input, init) => { + received = new Request(input, init); + return Promise.resolve(orderDraftNotFoundResponse()); + }, + ), + ).rejects.toMatchObject({ + _tag: "GeneratedCommandFailure", + reason: "api", + status: 404, + }); + + expect(received?.headers.get("content-type")).toBeNull(); + expect(await received?.text()).toBe(""); + }); + + test("optional request bodies preserve explicit JSON values", async () => { + let received: Request | undefined; + await expect( + runGenerated( + "orderDrafts.createWorkerBootstrap", + ["--input", "-"], + '{"path":{"id":"odft_123"},"body":{"ttl_seconds":60}}', + (input, init) => { + received = new Request(input, init); + return Promise.resolve(orderDraftNotFoundResponse()); + }, + ), + ).rejects.toMatchObject({ + _tag: "GeneratedCommandFailure", + reason: "api", + status: 404, + }); + + expect(received?.headers.get("content-type")).toContain( + "application/json", + ); + expect(await received?.json()).toEqual({ ttl_seconds: 60 }); + }); + + test("explicit null optional request bodies are rejected before transport", async () => { + let requests = 0; + await expect( + runGenerated( + "orderDrafts.createWorkerBootstrap", + ["--input", "-"], + '{"path":{"id":"odft_123"},"body":null}', + () => { + requests += 1; + return Promise.resolve(Response.json({})); + }, + ), + ).rejects.toMatchObject({ + _tag: "GeneratedCommandFailure", + reason: "input", + }); + + expect(requests).toBe(0); + }); + test("rejects malformed and excess input before transport", async () => { let requests = 0; const transport = () => { @@ -876,3 +942,14 @@ function machineOperation() { completed_at: null, }; } + +function orderDraftNotFoundResponse() { + return Response.json( + { + success: false, + errors: [{ code: 7002, message: "Order draft not found." }], + result: {}, + }, + { status: 404 }, + ); +}