From 747ed84cde0525230b481af9e10e8b975edec2cf Mon Sep 17 00:00:00 2001 From: Rob Masson Date: Sun, 16 Aug 2026 10:28:54 -0700 Subject: [PATCH 1/3] fix(sdk-generator): treat binary-format application/json responses as raw An application/json success response whose schema is type: string, format: binary (a raw blob served with a JSON content type, e.g. GET /trajectories/{trajectory}/contents) was typed as a plain string op. HTTP clients auto-decode the JSON body before the SDK sees it, so the typed string decode fails on every successful non-empty response. Route such responses down the raw-response path like other binary content. Co-Authored-By: Claude Fable 5 --- .../__tests__/frontend/parse-spec.test.ts | 59 +++++++++++++++++++ .../src/frontend/operation-parser.ts | 2 +- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/sdk-generator/__tests__/frontend/parse-spec.test.ts b/packages/sdk-generator/__tests__/frontend/parse-spec.test.ts index 00369de..151bcc1 100644 --- a/packages/sdk-generator/__tests__/frontend/parse-spec.test.ts +++ b/packages/sdk-generator/__tests__/frontend/parse-spec.test.ts @@ -87,6 +87,52 @@ const rawFixture = { }, }, }, + "/api/v1/trajectories/{trajectory}/contents": { + get: { + operationId: "get_api_v1_trajectories_contents", + parameters: [ + { + name: "trajectory", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Raw trajectory JSON blob served as application/json", + content: { + "application/json": { + schema: { type: "string", format: "binary" }, + }, + }, + }, + }, + }, + }, + "/api/v1/configs/{config}/summary": { + get: { + operationId: "get_api_v1_configs_summary", + parameters: [ + { + name: "config", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Plain JSON string", + content: { + "application/json": { + schema: { type: "string" }, + }, + }, + }, + }, + }, + }, }, }; @@ -320,6 +366,19 @@ describe("parseOpenApiSpec detects raw responses", () => { const content = configs.operations.find((o) => o.name === "content")!; expect(content.rawResponse).toBe(true); }); + + it("marks binary-format application/json responses as raw", () => { + const trajectories = ast.resources.find((r) => r.name === "trajectories")!; + const contents = trajectories.operations.find((o) => o.name === "contents")!; + expect(contents.rawResponse).toBe(true); + }); + + it("keeps plain application/json string responses typed", () => { + const configs = ast.resources.find((r) => r.name === "configs")!; + const summary = configs.operations.find((o) => o.name === "summary")!; + expect(summary.rawResponse).toBeFalsy(); + expect(summary.returnType).toEqual({ kind: "primitive", type: "string" }); + }); }); describe("parseOpenApiSpec preserves no-content response descriptions", () => { diff --git a/packages/sdk-generator/src/frontend/operation-parser.ts b/packages/sdk-generator/src/frontend/operation-parser.ts index 3259825..cfa1f5a 100644 --- a/packages/sdk-generator/src/frontend/operation-parser.ts +++ b/packages/sdk-generator/src/frontend/operation-parser.ts @@ -238,7 +238,7 @@ function extractSuccessResponse( } const jsonContent = successResponse.content["application/json"]; - if (jsonContent?.schema) { + if (jsonContent?.schema && jsonContent.schema.format !== "binary") { return { returnType: jsonSchemaToTypeRef(jsonContent.schema), returnDescription, From 601272c5fc30aa93c25a24cd0b3df4961ff29cb4 Mon Sep 17 00:00:00 2001 From: Rob Masson Date: Sun, 16 Aug 2026 11:06:56 -0700 Subject: [PATCH 2/3] test(sdk-generator): pin both flipped production ops through the full pipeline Adversarial audit of the binary-raw fix found the classification change also flips GET /private_service_definitions/{app_id}/{private_service_id} (same binary-format application/json shape, same always-crashing typed emission today) and that no test proved the generated output. Add a parse-to-emission test covering both production shapes plus the typed plain-string control. Co-Authored-By: Claude Fable 5 --- .../__tests__/backends/elixir.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/packages/sdk-generator/__tests__/backends/elixir.test.ts b/packages/sdk-generator/__tests__/backends/elixir.test.ts index 3fc3e08..6d8d58a 100644 --- a/packages/sdk-generator/__tests__/backends/elixir.test.ts +++ b/packages/sdk-generator/__tests__/backends/elixir.test.ts @@ -998,3 +998,92 @@ describe("elixir nullable composite descriptors", () => { ); }); }); + +describe("elixir emission of binary-format application/json responses", () => { + // Mirrors the two production ops whose 200 response is a raw blob served + // with a JSON content type: trajectories contents and private service + // definitions. Both must come out as raw ops, never decode: :string. + const binaryRawFixture = { + openapi: "3.0.0", + info: { title: "Binary Raw API", version: "1.0.0" }, + paths: { + "/api/v1/trajectories/{trajectory}/contents": { + get: { + operationId: "get_api_v1_trajectories_contents", + parameters: [ + { name: "trajectory", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { + "200": { + description: "Raw trajectory JSON blob", + content: { + "application/json": { schema: { type: "string", format: "binary" } }, + }, + }, + }, + }, + }, + "/api/v1/private_service_definitions/{app_id}/{private_service_id}": { + get: { + operationId: "get_api_v1_private_service_definitions", + parameters: [ + { name: "app_id", in: "path", required: true, schema: { type: "string" } }, + { + name: "private_service_id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Raw service definition", + content: { + "application/json": { schema: { type: "string", format: "binary" } }, + }, + }, + }, + }, + }, + "/api/v1/configs/{config}/summary": { + get: { + operationId: "get_api_v1_configs_summary", + parameters: [ + { name: "config", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { + "200": { + description: "Plain JSON string", + content: { "application/json": { schema: { type: "string" } } }, + }, + }, + }, + }, + }, + }; + + it("emits raw ops for binary-format json responses and typed ops for plain strings", () => { + const spec = parseOpenApiSpec(binaryRawFixture, { + apiBase: "/api", + defaultVersion: "v1", + }); + const files = generateElixir(spec, { outDir: "sdk" }); + + const trajectories = Object.entries(files).find(([path]) => + path.includes("trajectories") + )![1]; + expect(trajectories).toContain("raw: true"); + expect(trajectories).toContain("{:ok, Req.Response.t()}"); + expect(trajectories).not.toContain("decode: :string"); + + const definitions = Object.entries(files).find(([path]) => + path.includes("private_service_definitions") + )![1]; + expect(definitions).toContain("raw: true"); + expect(definitions).not.toContain("decode: :string"); + + const configs = Object.entries(files).find(([path]) => path.includes("configs"))![1]; + expect(configs).toContain("decode: :string"); + expect(configs).not.toContain("raw: true"); + }); +}); From 18d38fe8123557967e9f2c99f287bb56e81bae01 Mon Sep 17 00:00:00 2001 From: Rob Masson Date: Sun, 16 Aug 2026 11:18:28 -0700 Subject: [PATCH 3/3] docs(sdk-generator): explain the binary-format raw check Records why a JSON content type does not make a binary blob typed, and that the check reads a top-level format only (no spec shape wraps binary in a $ref or allOf today). Co-Authored-By: Claude Fable 5 --- packages/sdk-generator/src/frontend/operation-parser.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/sdk-generator/src/frontend/operation-parser.ts b/packages/sdk-generator/src/frontend/operation-parser.ts index cfa1f5a..5b9a1a9 100644 --- a/packages/sdk-generator/src/frontend/operation-parser.ts +++ b/packages/sdk-generator/src/frontend/operation-parser.ts @@ -237,6 +237,10 @@ function extractSuccessResponse( return { returnType: { kind: "void" }, returnDescription }; } + // A binary-format schema is a raw blob even when served with a JSON content + // type, and must not be typed: HTTP clients decode the JSON body before the + // SDK's own decode runs. Only a top-level format is inspected; no spec shape + // wraps binary in a $ref or allOf today. const jsonContent = successResponse.content["application/json"]; if (jsonContent?.schema && jsonContent.schema.format !== "binary") { return {