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"); + }); +}); 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..5b9a1a9 100644 --- a/packages/sdk-generator/src/frontend/operation-parser.ts +++ b/packages/sdk-generator/src/frontend/operation-parser.ts @@ -237,8 +237,12 @@ 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) { + if (jsonContent?.schema && jsonContent.schema.format !== "binary") { return { returnType: jsonSchemaToTypeRef(jsonContent.schema), returnDescription,