Skip to content
Merged
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
133 changes: 133 additions & 0 deletions packages/sdk-generator/__tests__/backends/elixir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}}'
);
});
});
113 changes: 113 additions & 0 deletions packages/sdk-generator/__tests__/frontend/parse-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
});
});
});
14 changes: 9 additions & 5 deletions packages/sdk-generator/src/frontend/schema-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
}
Expand Down Expand Up @@ -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") {
Expand Down
Loading