From 28b1b627e5ecdaa9690c6fe8b1b64b92f15cb26d Mon Sep 17 00:00:00 2001 From: Rob Masson Date: Sat, 15 Aug 2026 22:15:02 -0700 Subject: [PATCH] fix(sdk-generator): stop dropping nullable on composite schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI 3.0 marks nullability with 'nullable: true', which legitimately rides composite shapes without a sibling type: allOf + component $ref (Message.acl, StatusPing.user, ...), oneOf unions (Message.user), and enum (Message.agent_mode). jsonSchemaToTypeRef checked nullable only after the $ref/enum/oneOf/allOf branches had already returned, so the flag was silently dropped for every composite shape and the generated Elixir decoders crashed on nulls the platform legitimately sends (archastro-elixir v0.3.2 decode audit). Scalar nullables fell through to the check and worked. Peel nullable off first, before any shape branch, so every shape wraps in {kind: nullable} through the one existing mechanism. Covers REST and channel emission alike — both flow through jsonSchemaToTypeRef. Co-Authored-By: Claude Fable 5 --- .../__tests__/backends/elixir.test.ts | 133 ++++++++++++++++++ .../__tests__/frontend/parse-spec.test.ts | 113 +++++++++++++++ .../src/frontend/schema-parser.ts | 14 +- 3 files changed, 255 insertions(+), 5 deletions(-) diff --git a/packages/sdk-generator/__tests__/backends/elixir.test.ts b/packages/sdk-generator/__tests__/backends/elixir.test.ts index 7e5be36..3fc3e08 100644 --- a/packages/sdk-generator/__tests__/backends/elixir.test.ts +++ b/packages/sdk-generator/__tests__/backends/elixir.test.ts @@ -865,3 +865,136 @@ describe("elixir backend", () => { expect(output).not.toMatch(/def post_api_v1_auth_login\(/); }); }); + +describe("elixir nullable composite descriptors", () => { + // The decode audit against the live platform showed nullable: true being + // dropped whenever it rides a composite shape (allOf+$ref, oneOf union, + // enum), so generated decoders crashed on nulls the platform legitimately + // sends. Every shape must emit a {:nullable, _} descriptor, on REST and + // channel surfaces alike. + const nullableFixture = { + openapi: "3.0.0", + info: { title: "Nullable API", version: "1.0.0" }, + paths: { + "/api/v1/messages": { + get: { + operationId: "list_messages", + responses: { + "200": { + description: "ok", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Acl: { + type: "object", + properties: { grants: { type: "array", items: { type: "string" } } }, + }, + Badge: { + type: "object", + properties: { label: { type: "string" } }, + }, + Message: { + type: "object", + required: ["id"], + properties: { + id: { type: "string" }, + acl: { + allOf: [{ $ref: "#/components/schemas/Acl" }], + nullable: true, + }, + user: { + nullable: true, + oneOf: [{ type: "string" }, { $ref: "#/components/schemas/Badge" }], + }, + agent_mode: { enum: ["default", "plan"], nullable: true }, + badge: { allOf: [{ $ref: "#/components/schemas/Badge" }] }, + mode: { enum: ["a", "b"] }, + }, + }, + }, + }, + "x-channels": [ + { + name: "ChatChannel", + description: "Nullable-shape fixture channel", + joins: [ + { + pattern: "api:chat:{room_id}", + name: "join_room", + params: { + type: "object", + required: ["room_id"], + properties: { room_id: { type: "string" } }, + }, + returns: { type: "object" }, + }, + ], + messages: [], + pushes: [ + { + event: "message_added", + description: "Carries a nullable ref in its payload", + payload: { + type: "object", + required: ["message"], + properties: { + message: { + allOf: [{ $ref: "#/components/schemas/Message" }], + nullable: true, + }, + }, + }, + }, + ], + }, + ], + }; + + function nullableOutput(): string { + const spec = parseOpenApiSpec(nullableFixture, { + apiBase: "/api", + defaultVersion: "v1", + }); + return Object.values(generateElixir(spec, { outDir: "sdk" })).join("\n"); + } + + it("emits nullable descriptors for ref, union, and enum fields", () => { + const output = nullableOutput(); + expect(output).toContain( + '{"acl", {:optional, {:nullable, {:ref, ArchAstro.SDK.Types.Acl}}}}' + ); + expect(output).toContain( + '{"user", {:optional, {:nullable, {:union, [:string, {:ref, ArchAstro.SDK.Types.Badge}]}}}}' + ); + expect(output).toContain( + '{"agent_mode", {:optional, {:nullable, {:enum, ["default", "plan"]}}}}' + ); + }); + + it("leaves non-nullable composite fields unwrapped", () => { + const output = nullableOutput(); + expect(output).toContain( + '{"badge", {:optional, {:ref, ArchAstro.SDK.Types.Badge}}}' + ); + expect(output).toContain('{"mode", {:optional, {:enum, ["a", "b"]}}}'); + expect(output).not.toContain( + '{:nullable, {:ref, ArchAstro.SDK.Types.Badge}}' + ); + }); + + it("emits nullable descriptors on channel push payloads", () => { + const output = nullableOutput(); + expect(output).toContain( + '{"message", {:nullable, {:ref, ArchAstro.SDK.Types.Message}}}' + ); + }); +}); diff --git a/packages/sdk-generator/__tests__/frontend/parse-spec.test.ts b/packages/sdk-generator/__tests__/frontend/parse-spec.test.ts index 9b3a99f..00369de 100644 --- a/packages/sdk-generator/__tests__/frontend/parse-spec.test.ts +++ b/packages/sdk-generator/__tests__/frontend/parse-spec.test.ts @@ -638,3 +638,116 @@ describe("parseOpenApiSpec plain oneOf (no discriminator)", () => { expect(shape.refDeps).toContain("Square"); }); }); + +describe("nullable on composite schemas", () => { + // OpenAPI 3.0 marks nullability with `nullable: true`, which legitimately + // appears without a sibling `type` on ref, union, and enum fields. Each + // shape must wrap in {kind: "nullable"} exactly like scalar nullables do. + const nullableSpec = { + openapi: "3.0.0", + info: { title: "Nullable API", version: "1.0.0" }, + paths: { + "/api/v1/messages": { + get: { + operationId: "list_messages", + responses: { + "200": { + description: "ok", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Acl: { + type: "object", + properties: { grants: { type: "array", items: { type: "string" } } }, + }, + Badge: { + type: "object", + properties: { label: { type: "string" } }, + }, + Message: { + type: "object", + required: ["id"], + properties: { + id: { type: "string" }, + // nullable allOf + component ref (the Message.acl idiom) + acl: { + allOf: [{ $ref: "#/components/schemas/Acl" }], + nullable: true, + }, + // nullable oneOf union (the Message.user idiom) + user: { + nullable: true, + oneOf: [{ type: "string" }, { $ref: "#/components/schemas/Badge" }], + }, + // nullable enum (the Message.agent_mode idiom) + agent_mode: { enum: ["default", "plan"], nullable: true }, + // non-nullable controls for each shape + badge: { allOf: [{ $ref: "#/components/schemas/Badge" }] }, + target: { + oneOf: [{ type: "string" }, { $ref: "#/components/schemas/Badge" }], + }, + mode: { enum: ["a", "b"] }, + }, + }, + }, + }, + }; + + const ast = parseOpenApiSpec(nullableSpec, { + name: "nullable-sdk", + version: "0.1.0", + baseUrl: "https://example.com", + apiBase: "/api", + defaultVersion: "v1", + }); + const message = ast.schemas.find((s) => s.name === "Message")!; + const field = (name: string) => message.fields.find((f) => f.name === name)!.type; + + it("wraps a nullable allOf-ref in nullable", () => { + expect(field("acl")).toEqual({ + kind: "optional", + inner: { kind: "nullable", inner: { kind: "ref", schema: "Acl" } }, + }); + }); + + it("wraps a nullable oneOf union in nullable", () => { + const type = field("user"); + expect(type.kind).toBe("optional"); + const inner = (type as { inner: { kind: string; inner?: unknown } }).inner; + expect(inner.kind).toBe("nullable"); + expect((inner.inner as { kind: string }).kind).toBe("union"); + }); + + it("wraps a nullable enum in nullable", () => { + expect(field("agent_mode")).toEqual({ + kind: "optional", + inner: { + kind: "nullable", + inner: { kind: "enum", values: ["default", "plan"] }, + }, + }); + }); + + it("does not wrap non-nullable composites", () => { + expect(field("badge")).toEqual({ + kind: "optional", + inner: { kind: "ref", schema: "Badge" }, + }); + expect((field("target") as { inner: { kind: string } }).inner.kind).toBe( + "union" + ); + expect(field("mode")).toEqual({ + kind: "optional", + inner: { kind: "enum", values: ["a", "b"] }, + }); + }); +}); diff --git a/packages/sdk-generator/src/frontend/schema-parser.ts b/packages/sdk-generator/src/frontend/schema-parser.ts index ce79e35..b7779cd 100644 --- a/packages/sdk-generator/src/frontend/schema-parser.ts +++ b/packages/sdk-generator/src/frontend/schema-parser.ts @@ -80,6 +80,15 @@ export function parseSchemas(components: OpenApiComponents): { * Convert a JSON Schema into our TypeRef representation. */ export function jsonSchemaToTypeRef(schema: JsonSchema): TypeRef { + // Nullability must be peeled off before any shape branch: OpenAPI 3.0 puts + // `nullable: true` alongside $ref/allOf refs, oneOf unions, and enums just + // as legitimately as alongside scalar types, and the early returns below + // would otherwise silently drop it for every composite shape. + if (schema.nullable) { + const inner = jsonSchemaToTypeRef({ ...schema, nullable: undefined }); + return { kind: "nullable", inner }; + } + if (schema.$ref) { return { kind: "ref", schema: extractRefName(schema.$ref) }; } @@ -118,11 +127,6 @@ export function jsonSchemaToTypeRef(schema: JsonSchema): TypeRef { return jsonSchemaToTypeRef(schema.allOf[0]!); } - if (schema.nullable) { - const inner = jsonSchemaToTypeRef({ ...schema, nullable: undefined }); - return { kind: "nullable", inner }; - } - switch (schema.type) { case "string": if (schema.format === "date-time" || schema.format === "datetime") {