From f67f06ce7271a7f65c1fc32ed56fc59d4fcb9eb8 Mon Sep 17 00:00:00 2001 From: leemour Date: Sat, 3 Oct 2026 17:14:06 +0200 Subject: [PATCH] feat(codegen): preserve unions and RPC body schemas --- CHANGELOG.md | 10 +++ README.md | 2 + src/codegen/index.ts | 8 ++- src/codegen/manifest.ts | 15 ++++- src/codegen/model.ts | 14 ++++- src/codegen/pipeline.ts | 19 ++++++ src/codegen/typescript.ts | 4 +- src/codegen/unions.test.ts | 125 +++++++++++++++++++++++++++++++++++++ src/codegen/valibot.ts | 7 ++- 9 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 src/codegen/unions.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 52adfc3..6b7acea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ Versions before 0.8.0 are in the [git tags](https://github.com/leemour/cli-core/ Every entry says what changed as a caller sees it, why, and what to watch for — the rules are [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog). +## Unreleased + +### Changed — may break callers + +- Consumers switching exhaustively over `SchemaNode` or API parameter locations must handle explicit unions and RPC body parameters. Existing HTTP models generate the same artifacts. + +### Added + +- API generators preserve explicit unions and Boolean literals, validate nested references, and emit schema definitions for shared command input traversal. RPC parameters can belong to the JSON body; multipart references retain their format. + ## 0.16.0 — 02.10.2026 ### Added diff --git a/README.md b/README.md index aa6ec35..0f00c99 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ The generated schemas import `@leemour/cli-core/codegen/runtime` at run time — helpers, not the generator. Numbers are expected from a lossless JSON parser (`lossless-json`): a 64-bit integer comes out as its exact decimal string, and any other integer that does not fit a JS number fails instead of rounding. Objects are loose, so a field the API added later passes through. +Unions retain every alternative, including recursive references and Boolean literals. +`definitionsGenerator` emits the same schema nodes for runtime input traversal; RPC parameters can be in the JSON body. Enums and discriminated unions are **strict**: a value or subtype the snapshot does not know fails, so decode a live stream (updates, webhooks) only where that is what you want. diff --git a/src/codegen/index.ts b/src/codegen/index.ts index 8bfd6a0..3d34534 100644 --- a/src/codegen/index.ts +++ b/src/codegen/index.ts @@ -3,7 +3,13 @@ * manifest and a coverage page. Build-time only, and format-neutral: it parses no file format and * adds no dependency. The adapter from OpenAPI, Postman or anything else lives in the consumer. */ -export { type CoverageOptions, coverageGenerator, type ManifestOperation, manifestGenerator } from "./manifest.js" +export { + type CoverageOptions, + coverageGenerator, + definitionsGenerator, + type ManifestOperation, + manifestGenerator, +} from "./manifest.js" export type { ApiBody, ApiModel, diff --git a/src/codegen/manifest.ts b/src/codegen/manifest.ts index 96c5265..bd21bff 100644 --- a/src/codegen/manifest.ts +++ b/src/codegen/manifest.ts @@ -14,7 +14,7 @@ export interface ManifestOperation { deprecated?: boolean parameters: readonly { name: string - in: "path" | "query" | "header" + in: "path" | "query" | "header" | "body" required: boolean description?: string schema: SchemaNode @@ -63,6 +63,19 @@ export const manifestGenerator = return [{ path: options.path, content }] } +export const definitionsGenerator = + (options: { path: string; coreImport?: string }): CodeGenerator => + (model: ApiModel) => { + const tree = new SchemaTree(model) + const definitions = Object.fromEntries(tree.named.map(({ name, schema }) => [name, schema.schema])) + return [ + { + path: options.path, + content: `import type { SchemaNode } from "${options.coreImport ?? "@leemour/cli-core/codegen"}"\n\nexport const definitions: Readonly> = ${JSON.stringify(definitions, null, 2)}\n`, + }, + ] + } + const cell = (text: string | undefined): string => (text ?? "").replace(/\s+/g, " ").replace(/\|/g, "\\|").trim() export interface CoverageOptions { diff --git a/src/codegen/model.ts b/src/codegen/model.ts index 99942c3..3fabc00 100644 --- a/src/codegen/model.ts +++ b/src/codegen/model.ts @@ -39,10 +39,17 @@ interface SchemaCommon { export type SchemaNode = SchemaCommon & ( | { type: "ref"; ref: string } - | { type: "string"; enum?: readonly string[]; minLength?: number; maxLength?: number; pattern?: string } + | { + type: "string" + format?: "binary" | "file-reference" + enum?: readonly string[] + minLength?: number + maxLength?: number + pattern?: string + } | { type: "integer"; format?: "int32" | "int64"; minimum?: number; maximum?: number; enum?: readonly number[] } | { type: "number"; minimum?: number; maximum?: number } - | { type: "boolean" } + | { type: "boolean"; enum?: readonly boolean[] } | { type: "array"; items: SchemaNode; minItems?: number; maxItems?: number; uniqueItems?: boolean } | { type: "object" @@ -50,6 +57,7 @@ export type SchemaNode = SchemaCommon & required: readonly string[] additionalProperties?: SchemaNode } + | { type: "union"; of: readonly SchemaNode[] } /** Inheritance: every member must resolve to an object, and the fields are merged. */ | { type: "allOf"; of: readonly SchemaNode[] } /** Only where the source itself says "anything". An adapter never falls back to this. */ @@ -73,7 +81,7 @@ export interface ApiSchema { export interface ApiParameter { name: string - in: "path" | "query" | "header" + in: "path" | "query" | "header" | "body" required: boolean description?: string schema: SchemaNode diff --git a/src/codegen/pipeline.ts b/src/codegen/pipeline.ts index c37106c..1de37cd 100644 --- a/src/codegen/pipeline.ts +++ b/src/codegen/pipeline.ts @@ -46,6 +46,7 @@ const refsIn = (node: SchemaNode, found: string[] = []): string[] => { for (const property of Object.values(node.properties)) refsIn(property, found) if (node.additionalProperties) refsIn(node.additionalProperties, found) break + case "union": case "allOf": for (const member of node.of) refsIn(member, found) break @@ -104,6 +105,24 @@ export const validateModel = (model: ApiModel): void => { if (!operation.effect) problems.push(`operation "${operation.id}" is not classified as read, write or destructive`) } + const validUnions = (node: SchemaNode, where: string): void => { + if ((node.type === "boolean" || node.type === "string" || node.type === "integer") && node.enum?.length === 0) + problems.push(`${where}: an enum has no values`) + if (node.type === "union" || node.type === "allOf") { + if (node.type === "union" && node.of.length === 0) problems.push(`${where}: a union has no members`) + for (const member of node.of) validUnions(member, where) + } else if (node.type === "array") validUnions(node.items, `${where}[]`) + else if (node.type === "object") { + for (const [name, property] of Object.entries(node.properties)) validUnions(property, `${where}.${name}`) + if (node.additionalProperties) validUnions(node.additionalProperties, where) + } + } + for (const schema of model.schemas) validUnions(schema.schema, `schema "${schema.id}"`) + for (const operation of model.operations) { + for (const parameter of operation.parameters) validUnions(parameter.schema, `operation "${operation.id}"`) + if (operation.requestBody?.schema) validUnions(operation.requestBody.schema, `operation "${operation.id}" request`) + if (operation.response?.schema) validUnions(operation.response.schema, `operation "${operation.id}" response`) + } const schemaIds = new Set(model.schemas.map((schema) => schema.id)) const unresolved = (where: string, node: SchemaNode | undefined) => { if (!node) return diff --git a/src/codegen/typescript.ts b/src/codegen/typescript.ts index ec7d97d..58b9e3a 100644 --- a/src/codegen/typescript.ts +++ b/src/codegen/typescript.ts @@ -29,7 +29,9 @@ class TypeWriter { case "number": return "number" case "boolean": - return "boolean" + return node.enum ? node.enum.map(String).join(" | ") : "boolean" + case "union": + return `(${node.of.map((member) => this.node(member, where)).join(" | ")})` case "unknown": return "unknown" case "array": { diff --git a/src/codegen/unions.test.ts b/src/codegen/unions.test.ts new file mode 100644 index 0000000..1e94a57 --- /dev/null +++ b/src/codegen/unions.test.ts @@ -0,0 +1,125 @@ +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import * as v from "valibot" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { + type ApiModel, + definitionsGenerator, + generate, + manifestGenerator, + typesGenerator, + valibotGenerator, + validateModel, +} from "./index.js" + +const model: ApiModel = { + source: { kind: "other" }, + schemas: [ + { id: "Address", schema: { type: "union", of: [{ type: "integer", format: "int64" }, { type: "string" }] } }, + { id: "File", schema: { type: "string", format: "binary" } }, + { + id: "Node", + schema: { + type: "union", + of: [ + { type: "string" }, + { + type: "object", + properties: { next: { type: "ref", ref: "Node" }, ready: { type: "boolean", enum: [true] } }, + required: ["ready"], + }, + ], + }, + }, + ], + operations: [ + { + id: "readNode", + binding: { kind: "rpc", name: "readNode" }, + tags: [], + parameters: [{ name: "address", in: "body", required: true, schema: { type: "ref", ref: "Address" } }], + response: { required: true, confidence: "contract", schema: { type: "ref", ref: "Node" } }, + effect: "read", + source: { location: "methods/readNode" }, + }, + ], +} + +describe("generated unions", () => { + let root: string + let schemas: Record + + beforeAll(async () => { + root = mkdtempSync(join(dirname(fileURLToPath(import.meta.url)), ".tmp-unions-")) + for (const artifact of generate( + model, + [ + typesGenerator({ path: "types.ts" }), + valibotGenerator({ path: "schemas.ts", typesImport: "./types.js", runtimeImport: "../runtime.js" }), + manifestGenerator({ path: "manifest.ts", coreImport: "../index.js" }), + definitionsGenerator({ path: "definitions.ts", coreImport: "../index.js" }), + ], + { banner: ["synthetic union contract"] }, + )) + writeFileSync(join(root, artifact.path), artifact.content) + ;({ schemas } = await import(join(root, "schemas.ts"))) + }) + + afterAll(() => rmSync(root, { recursive: true, force: true })) + + it("compiles recursive alternatives, Boolean literals and RPC body parameters together", () => { + writeFileSync( + join(root, "tsconfig.json"), + JSON.stringify({ + extends: "../../../tsconfig.json", + compilerOptions: { + noEmit: true, + composite: false, + declaration: false, + declarationMap: false, + rootDir: "../..", + }, + include: ["*.ts"], + }), + ) + const tsc = join(root, "../../../node_modules/.bin", process.platform === "win32" ? "tsc.cmd" : "tsc") + expect(() => execFileSync(tsc, ["-p", join(root, "tsconfig.json")], { stdio: "pipe" })).not.toThrow() + }, 30_000) + + it("preserves large integers and accepts each alternative while rejecting wrong literals", () => { + const address = schemas.Address as v.GenericSchema + expect(v.parse(address, { isLosslessNumber: true, value: "9007199254740993" })).toBe("9007199254740993") + expect(v.parse(address, "@example")).toBe("@example") + expect(v.safeParse(address, false).success).toBe(false) + const node = schemas.Node as v.GenericSchema + expect(v.safeParse(node, { ready: true, next: { ready: true, next: "end" } }).success).toBe(true) + expect(v.safeParse(node, { ready: false }).success).toBe(false) + const file = schemas.File as v.GenericSchema + expect(v.safeParse(file, "attach://photo").success).toBe(true) + expect(v.safeParse(file, "local-path").success).toBe(false) + }) + + it("emits runtime definitions with source formats and resolves response references", async () => { + const { definitions } = await import(join(root, "definitions.ts")) + expect(definitions.File).toEqual({ type: "string", format: "binary" }) + const { operations } = await import(join(root, "manifest.ts")) + expect(operations[0].response.schema).toBe("Node") + expect(definitions.Node.type).toBe("union") + }) + + it("refuses empty alternatives and missing references inside nested unions", () => { + expect(() => validateModel({ ...model, schemas: [{ id: "Empty", schema: { type: "union", of: [] } }] })).toThrow( + /union has no members/, + ) + expect(() => + validateModel({ + ...model, + schemas: [ + { id: "Missing", schema: { type: "array", items: { type: "union", of: [{ type: "ref", ref: "Absent" }] } } }, + ], + }), + ).toThrow(/unknown schema "Absent"/) + }) +}) diff --git a/src/codegen/valibot.ts b/src/codegen/valibot.ts index ccb7479..cec25ed 100644 --- a/src/codegen/valibot.ts +++ b/src/codegen/valibot.ts @@ -34,6 +34,7 @@ class SchemaWriter { case "string": { if (node.enum) return `v.picklist(${JSON.stringify(node.enum)})` const actions: string[] = [] + if (node.format === "binary") actions.push('v.startsWith("attach://")') if (node.minLength !== undefined) actions.push(`v.minLength(${node.minLength})`) if (node.maxLength !== undefined) actions.push(`v.maxLength(${node.maxLength})`) if (node.pattern !== undefined) { @@ -58,7 +59,11 @@ class SchemaWriter { this.helpers.add("number") return `number(${range(node)})` case "boolean": - return "v.boolean()" + if (!node.enum) return "v.boolean()" + if (node.enum.length === 1) return `v.literal(${node.enum[0]})` + return `v.union([${node.enum.map((value) => `v.literal(${value})`).join(", ")}])` + case "union": + return `v.union([${node.of.map((member) => this.node(member, where)).join(", ")}])` case "unknown": return "v.unknown()" case "array": {