import type * as __ManduNodeCryptoTypes0 from "node:crypto"; /** * Mandu OpenAPI Generator * Contract에서 OpenAPI 3.0 스펙 자동 생성 */ import type { z } from "zod"; import type { RoutesManifest, RouteSpec } from "../spec/schema"; import type { ContractSchema, MethodRequestSchema, ResponseSchemaWithExamples, SchemaExamples, } from "../contract/schema"; import path from "path"; import { getZodTypeName, getZodInnerType, getZodArrayElementType, getZodEffectsSchema, getZodObjectShape, getZodChecks, getZodEnumValues, getZodUnionOptions, getZodLiteralValue, getZodDefaultValue, isZodRequired, } from "../contract/zod-utils"; // ============================================ // Schema name-hint side table // ============================================ /** * Side-channel naming hints for generated OpenAPI schemas. * * The hoist pass needs a preferred name for each object schema but we * don't want to serialize that hint into the output document. A WeakMap * keyed by the generated `OpenAPISchema` object gives us name access * during the post-processing pass without polluting the spec JSON. * * Hints are advisory — the hoist pass falls back to a deterministic * `Schema_` name when no hint is attached. */ const schemaNameHints = new WeakMap(); function setSchemaNameHint(schema: OpenAPISchema | undefined, hint: string | undefined): void { if (!schema || !hint) return; // Don't overwrite an existing hint — first attribution wins. This matches // the "first-come wins" policy for structurally-identical schemas with // different user-given names. if (!schemaNameHints.has(schema)) { schemaNameHints.set(schema, hint); } } // ============================================ // OpenAPI Types // ============================================ export interface OpenAPIInfo { title: string; version: string; description?: string; } export interface OpenAPIServer { url: string; description?: string; } export interface OpenAPIParameter { name: string; in: "query" | "path" | "header" | "cookie"; required?: boolean; description?: string; schema: OpenAPISchema; } export interface OpenAPIExample { summary?: string; description?: string; value: unknown; } export interface OpenAPIRequestBody { required?: boolean; description?: string; content: { [mediaType: string]: { schema: OpenAPISchema; examples?: Record; }; }; } export interface OpenAPIResponse { description: string; content?: { [mediaType: string]: { schema: OpenAPISchema; examples?: Record; }; }; } export interface OpenAPIOperation { summary?: string; description?: string; operationId?: string; tags?: string[]; parameters?: OpenAPIParameter[]; requestBody?: OpenAPIRequestBody; responses: { [statusCode: string]: OpenAPIResponse; }; } export interface OpenAPIPathItem { get?: OpenAPIOperation; post?: OpenAPIOperation; put?: OpenAPIOperation; patch?: OpenAPIOperation; delete?: OpenAPIOperation; } export interface OpenAPISchema { type?: string; format?: string; description?: string; properties?: Record; items?: OpenAPISchema; required?: string[]; enum?: unknown[]; default?: unknown; minimum?: number; maximum?: number; minLength?: number; maxLength?: number; pattern?: string; nullable?: boolean; oneOf?: OpenAPISchema[]; anyOf?: OpenAPISchema[]; allOf?: OpenAPISchema[]; $ref?: string; } export interface OpenAPIDocument { openapi: "3.0.3"; info: OpenAPIInfo; servers?: OpenAPIServer[]; paths: Record; components?: { schemas?: Record; }; tags?: Array<{ name: string; description?: string }>; } // ============================================ // Zod to OpenAPI Conversion // ============================================ /** * Convert Zod schema to OpenAPI schema * Note: This is a simplified conversion. For production, * consider using zod-to-openapi or similar library. */ export function zodToOpenAPISchema(zodSchema: z.ZodTypeAny): OpenAPISchema { const typeName = getZodTypeName(zodSchema); // Handle ZodOptional — optionality is expressed by the parent object's // `required[]` array (or by `parameter.required: false`), NOT by the // field's own schema. Emitting `nullable: true` here would conflate // "may be absent" with "may literally be null", which breaks Postman, // codegen, and Swagger UI (they'd all treat the field as nullable). if (typeName === "ZodOptional") { const inner = getZodInnerType(zodSchema); return inner ? zodToOpenAPISchema(inner) : {}; } // Handle ZodDefault if (typeName === "ZodDefault") { const inner = getZodInnerType(zodSchema); const innerSchema = inner ? zodToOpenAPISchema(inner) : {}; return { ...innerSchema, default: getZodDefaultValue(zodSchema) }; } // Handle ZodNullable if (typeName === "ZodNullable") { const inner = getZodInnerType(zodSchema); const innerSchema = inner ? zodToOpenAPISchema(inner) : {}; return { ...innerSchema, nullable: true }; } // Handle ZodEffects (coerce, transform, etc.) if (typeName === "ZodEffects") { const effectsSchema = getZodEffectsSchema(zodSchema); return effectsSchema ? zodToOpenAPISchema(effectsSchema) : {}; } // Handle ZodString if (typeName === "ZodString") { const schema: OpenAPISchema = { type: "string" }; for (const check of getZodChecks(zodSchema)) { if (check.kind === "email") schema.format = "email"; if (check.kind === "uuid") schema.format = "uuid"; if (check.kind === "url") schema.format = "uri"; if (check.kind === "datetime") schema.format = "date-time"; if (check.kind === "min") schema.minLength = check.value; if (check.kind === "max") schema.maxLength = check.value; if (check.kind === "regex") schema.pattern = check.regex?.source; } return schema; } // Handle ZodNumber if (typeName === "ZodNumber") { const schema: OpenAPISchema = { type: "number" }; for (const check of getZodChecks(zodSchema)) { if (check.kind === "int") schema.type = "integer"; if (check.kind === "min") schema.minimum = check.value; if (check.kind === "max") schema.maximum = check.value; } return schema; } // Handle ZodBoolean if (typeName === "ZodBoolean") { return { type: "boolean" }; } // Handle ZodArray if (typeName === "ZodArray") { const elementType = getZodArrayElementType(zodSchema); return { type: "array", items: elementType ? zodToOpenAPISchema(elementType) : {}, }; } // Handle ZodObject if (typeName === "ZodObject") { const properties: Record = {}; const required: string[] = []; const shape = getZodObjectShape(zodSchema) ?? {}; for (const [key, value] of Object.entries(shape)) { properties[key] = zodToOpenAPISchema(value); // Check if field is required if (isZodRequired(value)) { required.push(key); } } return { type: "object", properties, ...(required.length > 0 ? { required } : {}), }; } // Handle ZodEnum if (typeName === "ZodEnum") { return { type: "string", enum: getZodEnumValues(zodSchema) as unknown[], }; } // Handle ZodUnion if (typeName === "ZodUnion") { const options = getZodUnionOptions(zodSchema) ?? []; return { oneOf: options.map((opt) => zodToOpenAPISchema(opt)), }; } // Handle ZodLiteral if (typeName === "ZodLiteral") { const value = getZodLiteralValue(zodSchema); return { type: typeof value as string, enum: [value], }; } // Handle ZodVoid/ZodUndefined (no content) if (typeName === "ZodVoid" || typeName === "ZodUndefined") { return {}; } // Handle ZodAny/ZodUnknown if (typeName === "ZodAny" || typeName === "ZodUnknown") { return {}; } // Fallback return {}; } // ============================================ // Helpers // ============================================ /** * Convert SchemaExamples to OpenAPI examples format */ function convertExamples(examples: SchemaExamples): Record { const result: Record = {}; for (const [name, value] of Object.entries(examples)) { result[name] = { value }; } return result; } /** * Check if response schema has examples (ResponseSchemaWithExamples type) */ function isResponseSchemaWithExamples( schema: z.ZodTypeAny | ResponseSchemaWithExamples | undefined ): schema is ResponseSchemaWithExamples { return ( schema !== undefined && typeof schema === "object" && "schema" in schema ); } // ============================================ // OpenAPI Generation // ============================================ /** * Get HTTP status description */ function getStatusDescription(status: number): string { const descriptions: Record = { 200: "OK", 201: "Created", 204: "No Content", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 500: "Internal Server Error", }; return descriptions[status] || "Response"; } /** * Generate OpenAPI parameters from method request schema */ function generateParameters( methodSchema: MethodRequestSchema, routePattern: string ): OpenAPIParameter[] { const parameters: OpenAPIParameter[] = []; // Extract path parameters from pattern const pathParamMatches = routePattern.matchAll(/:(\w+)/g); for (const match of pathParamMatches) { parameters.push({ name: match[1], in: "path", required: true, schema: { type: "string" }, }); } // Query parameters if (methodSchema.query) { const querySchema = zodToOpenAPISchema(methodSchema.query); if (querySchema.properties) { for (const [name, schema] of Object.entries(querySchema.properties)) { parameters.push({ name, in: "query", required: querySchema.required?.includes(name) ?? false, schema: schema as OpenAPISchema, }); } } } // Header parameters if (methodSchema.headers) { const headerSchema = zodToOpenAPISchema(methodSchema.headers); if (headerSchema.properties) { for (const [name, schema] of Object.entries(headerSchema.properties)) { parameters.push({ name, in: "header", required: headerSchema.required?.includes(name) ?? false, schema: schema as OpenAPISchema, }); } } } return parameters; } /** * Convert an arbitrary identifier to a PascalCase token suitable for an * OpenAPI component name. Non-alphanumeric characters become word * separators; leading digits are prefixed with `_` so the result is a * valid JSON Schema identifier. */ function pascal(input: string): string { if (!input) return ""; const words = input .split(/[^a-zA-Z0-9]+/g) .filter((w) => w.length > 0) .map((w) => w[0].toUpperCase() + w.slice(1)); const joined = words.join(""); if (!joined) return ""; return /^[0-9]/.test(joined) ? `_${joined}` : joined; } /** * Derive a naming hint root for a contract. Prefers the explicit * `contract.name`; falls back to the route id (e.g. `api/users` → * `ApiUsers`). Guarantees we always have a non-empty PascalCase prefix * for generated component names. */ function deriveContractNameRoot(contract: ContractSchema, route: RouteSpec): string { const hint = contract.name ?? route.id; const pascalized = pascal(hint); return pascalized || "Schema"; } /** * Generate OpenAPI operation for a method */ function generateOperation( route: RouteSpec, method: string, contract: ContractSchema ): OpenAPIOperation { const methodSchema = contract.request[method] as MethodRequestSchema | undefined; const operation: OpenAPIOperation = { summary: contract.description, operationId: `${route.id}_${method.toLowerCase()}`, tags: contract.tags || [route.id], responses: {}, }; // Derive a naming hint root from the contract for the hoist pass. // Example: `contract.name = "users"` → `Users`. Falls back to a // route-derived identifier so every contract has *some* stable prefix. const nameRoot = deriveContractNameRoot(contract, route); // Parameters if (methodSchema) { const params = generateParameters(methodSchema, route.pattern); if (params.length > 0) { operation.parameters = params; } // Request body if (methodSchema.body) { const bodySchema = zodToOpenAPISchema(methodSchema.body); // Hint: `UsersBody` / `UsersPostBody` — method-qualified avoids // collisions when GET/POST/PUT on the same contract have distinct // request bodies. setSchemaNameHint(bodySchema, `${nameRoot}${pascal(method)}Body`); const requestBodyContent: OpenAPIRequestBody["content"]["application/json"] = { schema: bodySchema, }; // Add examples if provided if (methodSchema.examples) { requestBodyContent.examples = convertExamples(methodSchema.examples); } operation.requestBody = { required: true, content: { "application/json": requestBodyContent, }, }; } } // Responses for (const [statusCode, responseSchemaOrWithExamples] of Object.entries(contract.response)) { const status = parseInt(statusCode, 10); if (isNaN(status)) continue; // Handle ResponseSchemaWithExamples or plain ZodTypeAny let zodSchema: z.ZodTypeAny; let examples: SchemaExamples | undefined; if (isResponseSchemaWithExamples(responseSchemaOrWithExamples)) { zodSchema = responseSchemaOrWithExamples.schema; examples = responseSchemaOrWithExamples.examples; } else { zodSchema = responseSchemaOrWithExamples as z.ZodTypeAny; } const schema = zodToOpenAPISchema(zodSchema); // Hint: `UsersResponse200` / `UsersPostResponse201`. Status-qualified // so `200` and `400` responses don't collide when both are // `{ error: string }` style objects. setSchemaNameHint(schema, `${nameRoot}${pascal(method)}Response${statusCode}`); const hasContent = Object.keys(schema).length > 0; const responseContent: OpenAPIResponse["content"] = hasContent ? { "application/json": { schema, ...(examples && { examples: convertExamples(examples) }), }, } : undefined; operation.responses[statusCode] = { description: getStatusDescription(status), ...(responseContent && { content: responseContent }), }; } // Default 500 response if not defined if (!operation.responses["500"]) { operation.responses["500"] = { description: "Internal Server Error", content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" }, message: { type: "string" }, }, }, }, }, }; } return operation; } /** * Load contract from file */ async function loadContract(contractPath: string, rootDir: string): Promise { try { const fullPath = path.join(rootDir, contractPath); const module = await import(fullPath); return module.default; } catch (error) { console.warn(`Failed to load contract: ${contractPath}`, error); return null; } } /** * Generate OpenAPI document from manifest and contracts */ export async function generateOpenAPIDocument( manifest: RoutesManifest, rootDir: string, options: { title?: string; version?: string; description?: string; servers?: OpenAPIServer[]; /** * Hoist schemas that appear ≥`hoistThreshold` times into * `components.schemas` and replace inline occurrences with `$ref`. * @default true */ hoistSchemas?: boolean; /** * Minimum occurrence count required to hoist a schema. * @default 2 */ hoistThreshold?: number; } = {} ): Promise { const paths: Record = {}; const tags = new Set(); for (const route of manifest.routes) { // Skip routes without contracts if (!route.contractModule || route.kind !== "api") continue; const contract = await loadContract(route.contractModule, rootDir); if (!contract) continue; const pathItem: OpenAPIPathItem = {}; // Generate operations for each method const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"]; for (const method of methods) { if (contract.request[method]) { const operation = generateOperation(route, method, contract); pathItem[method.toLowerCase() as keyof OpenAPIPathItem] = operation; // Collect tags for (const tag of operation.tags || []) { tags.add(tag); } } } // Convert Mandu pattern to OpenAPI pattern // /api/users/:id -> /api/users/{id} const openApiPattern = route.pattern.replace(/:(\w+)/g, "{$1}"); paths[openApiPattern] = pathItem; } const doc: OpenAPIDocument = { openapi: "3.0.3", info: { title: options.title || "Mandu API", version: options.version || `${manifest.version}.0.0`, description: options.description || "Generated by Mandu Framework", }, servers: options.servers || [ { url: "http://localhost:3000", description: "Development server" }, ], paths, tags: Array.from(tags).map((name) => ({ name })), }; // Post-processing: hoist shared schemas into components.schemas. // `hoistSchemas: false` → pure backwards-compatible inline output. const hoistEnabled = options.hoistSchemas !== false; if (hoistEnabled) { await hoistSharedSchemas(doc, { threshold: options.hoistThreshold ?? 2, }); } return doc; } // ============================================ // Schema hoisting (post-processing) // ============================================ export interface HoistOptions { /** Minimum occurrences required to hoist. Default `2`. */ threshold?: number; } interface SchemaOccurrence { /** Structural hash of the schema body (identity key). */ hash: string; /** Every parent reference we might need to rewrite to `$ref`. */ sites: Array<{ parent: Record; key: string | number }>; /** The canonical schema body (first-seen value; all sites share equal shape). */ body: OpenAPISchema; /** Ordered list of name hints collected at each occurrence site. */ hints: string[]; } /** * Hoist shared object schemas into `components.schemas` and replace * inline occurrences with `$ref` pointers. * * Only hoists: * - `type: "object"` schemas (primitives / enums / unions-of-primitives stay inline). * - Schemas appearing at a "hoistable site" (requestBody/response root or a top-level * property of either). Path/query/header parameter schemas are explicitly skipped. * - Schemas reaching the occurrence threshold (default 2). * * Naming: * - First hint wins for structurally-identical schemas. * - Name collisions across structurally-different schemas are disambiguated with * `_v2`, `_v3`, … suffixes (deterministic by walk order). * - Hint-less schemas fall back to `Schema_`. */ export async function hoistSharedSchemas( doc: OpenAPIDocument, options: HoistOptions = {} ): Promise { const threshold = Math.max(2, options.threshold ?? 2); const occurrences = new Map(); // --- Pass 1: walk every hoistable site and record occurrences. --- for (const pathItem of Object.values(doc.paths)) { for (const method of ["get", "post", "put", "patch", "delete"] as const) { const op = pathItem[method]; if (!op) continue; // Request body root schema (+ top-level property values). if (op.requestBody?.content) { for (const mediaObj of Object.values(op.requestBody.content)) { recordHoistableTree(mediaObj as Record, "schema", occurrences); } } // Response root schema (+ top-level property values). for (const resp of Object.values(op.responses)) { if (!resp.content) continue; for (const mediaObj of Object.values(resp.content)) { recordHoistableTree(mediaObj as Record, "schema", occurrences); } } } } // --- Pass 2: pick the winners (count ≥ threshold, type === "object"). --- const winners: Array<{ name: string; hash: string; body: OpenAPISchema; sites: SchemaOccurrence["sites"] }> = []; const nameIndex = new Map(); // name → hash (for collision detection) // Stable iteration: sort by hash so hoisted output is deterministic // regardless of JS property enumeration order quirks. const entries = Array.from(occurrences.entries()).sort(([a], [b]) => a.localeCompare(b)); for (const [hash, entry] of entries) { if (entry.sites.length < threshold) continue; if (!isHoistableObject(entry.body)) continue; // Pick the first-seen hint; fall back to the hash-derived default. const preferred = entry.hints[0] || `Schema_${hash.slice(0, 8)}`; let finalName = preferred; let suffix = 2; // If the name is already claimed by a *different* hash, walk `_v2`, `_v3`… while (nameIndex.has(finalName) && nameIndex.get(finalName) !== hash) { finalName = `${preferred}_v${suffix++}`; } nameIndex.set(finalName, hash); winners.push({ name: finalName, hash, body: entry.body, sites: entry.sites }); } if (winners.length === 0) return; // --- Pass 3: install winners into components.schemas + rewrite sites. --- if (!doc.components) doc.components = {}; if (!doc.components.schemas) doc.components.schemas = {}; const schemas = doc.components.schemas; for (const winner of winners) { schemas[winner.name] = winner.body; const ref: OpenAPISchema = { $ref: `#/components/schemas/${winner.name}` }; for (const site of winner.sites) { // Replace inline schema with a `$ref` pointer. We write a *new* // ref object per site (JSON.stringify would collapse shared refs // anyway, but distinct identities make future passes safer). (site.parent as Record)[site.key] = { ...ref }; } } } /** * Walk a container object and record every hoistable schema site. A * hoistable site is: * * 1. The root schema at `container[key]` (typically the `"schema"` key of a * requestBody/response media type object). * 2. Every top-level property of that root, *if* the root is an object schema. * * Nested properties beyond depth 1 are intentionally ignored — hoisting * deep nests produces spec trees that are harder for codegen tools to * follow than the inline original. */ function recordHoistableTree( container: Record, key: string, occurrences: Map ): void { const root = container[key] as OpenAPISchema | undefined; if (!root || typeof root !== "object") return; // Skip anything that's already a $ref (e.g., from an earlier pass). if ("$ref" in root && root.$ref) return; // Record the root itself (regardless of object vs primitive — we filter // in pass 2 so that primitives with many occurrences still get counted // but are never emitted as component entries). recordOccurrence(root, container as Record, key, occurrences); // Drill into top-level properties for object roots. if (root.type === "object" && root.properties) { for (const [propKey, propSchema] of Object.entries(root.properties)) { if (!propSchema || typeof propSchema !== "object") continue; if ("$ref" in propSchema && propSchema.$ref) continue; recordOccurrence( propSchema as OpenAPISchema, root.properties as Record, propKey, occurrences ); } } // Drill into array items when the root is an array — the element shape // is the real reusable schema, not the array wrapper itself. if (root.type === "array" && root.items) { const items = root.items; if (items && typeof items === "object" && !("$ref" in items && items.$ref)) { recordOccurrence( items, root as unknown as Record, "items", occurrences ); } } } function recordOccurrence( schema: OpenAPISchema, parent: Record, key: string | number, occurrences: Map ): void { const hash = structuralHash(schema); let entry = occurrences.get(hash); if (!entry) { entry = { hash, sites: [], body: schema, hints: [] }; occurrences.set(hash, entry); } entry.sites.push({ parent, key }); const hint = schemaNameHints.get(schema); if (hint && !entry.hints.includes(hint)) { entry.hints.push(hint); } } /** * A schema is hoistable iff it is an object with ≥1 property. We * deliberately skip: * - primitives (`string`, `number`, `boolean`, `integer`) — they are * small enough that inline is clearer than a pointer chase. * - enums and unions of primitives — same argument, and codegen tools * tend to de-duplicate these separately. * - empty object schemas — no information to share. * - $ref passthroughs — already hoisted. * - object schemas with a composed keyword (allOf/oneOf/anyOf) but no * `properties` — these are structural compositions, not data shapes. */ function isHoistableObject(schema: OpenAPISchema): boolean { if (!schema || typeof schema !== "object") return false; if (schema.$ref) return false; if (schema.type !== "object") return false; if (!schema.properties) return false; if (Object.keys(schema.properties).length === 0) return false; return true; } /** * Stable structural hash of a schema. Sorts object keys recursively * before stringifying so `{a:1,b:2}` and `{b:2,a:1}` hash identically, * then feeds the canonical string through SHA-256. */ function structuralHash(schema: OpenAPISchema): string { const canonical = stableStringify(schema); return sha256Hex(canonical); } function stableStringify(value: unknown): string { if (value === null || value === undefined) return "null"; if (typeof value === "boolean" || typeof value === "number") return JSON.stringify(value); if (typeof value === "string") return JSON.stringify(value); if (Array.isArray(value)) { return "[" + value.map((v) => stableStringify(v)).join(",") + "]"; } if (typeof value === "object") { const keys = Object.keys(value as Record).sort(); const parts: string[] = []; for (const k of keys) { const v = (value as Record)[k]; if (v === undefined) continue; parts.push(JSON.stringify(k) + ":" + stableStringify(v)); } return "{" + parts.join(",") + "}"; } return JSON.stringify(value); } /** * Synchronous SHA-256 hex digest. Uses `Bun.CryptoHasher` when present, * then Node `crypto.createHash` as a fallback. WebCrypto is async-only * so we intentionally skip it here — the hoist pass hashes many small * payloads and the sync path keeps the post-process simple and fast. * * This is a purely structural identity key (not a security primitive), * so the brief Node-fallback surface is acceptable. */ function sha256Hex(input: string): string { const bunGlobal = (globalThis as { Bun?: { CryptoHasher?: new (algo: string) => { update(input: string | Uint8Array): void; digest(encoding: "hex"): string } } }).Bun; if (bunGlobal?.CryptoHasher) { const hasher = new bunGlobal.CryptoHasher("sha256"); hasher.update(input); return hasher.digest("hex"); } // Node fallback — `require` at call time to keep module-load lean. // Callers who run in an edge runtime without Node crypto should have // Bun available; we therefore don't bother with a WebCrypto async // path (it would turn `hoistSharedSchemas` into an unnecessarily async // cascade on the hot path). // eslint-disable-next-line @typescript-eslint/no-require-imports const nodeCrypto = require("node:crypto") as typeof __ManduNodeCryptoTypes0; return nodeCrypto.createHash("sha256").update(input).digest("hex"); } // ============================================ // YAML Serialization // ============================================ /** * Quote a string value for YAML if required by the spec. * * Follows a conservative subset of YAML 1.2 rules: any scalar that * could be confused for a YAML reserved word, a number, a boolean, a * null, or that contains indicator characters must be double-quoted. * Everything else is emitted as a plain scalar for maximum * readability. */ function yamlScalar(value: unknown): string { if (value === null) return "null"; if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number") { if (!Number.isFinite(value)) return ".nan"; return String(value); } if (typeof value === "string") { // Quote strings that a YAML 1.2 parser would otherwise interpret // as a non-string scalar (null/bool/number) or that contain indicator // characters at positions where they are load-bearing. // // Conservative heuristic: // - empty string // - leading/trailing whitespace (would be silently trimmed) // - reserved scalars: null / true / false / yes / no / on / off (+ ~) // - parses cleanly as a YAML number (integer or float) // - contains a flow indicator (`[ ]`, `{ }`, `,`) or a comment start `#` // - contains `: ` (key separator) anywhere, or ends with `:` // - starts with an indicator char that would collide with block syntax: // `-` (sequence), `?` (mapping key), `&`/`*` (anchor/alias), // `!` (tag), `|`/`>` (block scalar), `'`/`"` / backtick / `%`/`@` // - embedded newline const numericLike = /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(value); const reservedScalar = /^(~|null|true|false|yes|no|on|off)$/i.test(value); const leadingIndicator = /^[-?&*!|>'"%@`]/.test(value); const hasFlowOrComment = /[\[\]{},#]/.test(value); const hasKeyLike = /:\s/.test(value) || value.endsWith(":"); const needsQuote = value === "" || /^[\s]|[\s]$/.test(value) || reservedScalar || numericLike || leadingIndicator || hasFlowOrComment || hasKeyLike || value.includes("\n"); if (!needsQuote) return value; // Use double quotes with JSON-style escaping (valid YAML subset). return JSON.stringify(value); } // Fallback: JSON-stringify anything exotic. return JSON.stringify(value); } function emitYAML(value: unknown, indent: number, lines: string[]): void { const pad = " ".repeat(indent); if (value === null || typeof value !== "object") { lines.push(`${pad}${yamlScalar(value)}`); return; } if (Array.isArray(value)) { if (value.length === 0) { lines.push(`${pad}[]`); return; } for (const item of value) { if (item !== null && typeof item === "object" && !Array.isArray(item)) { const entries = Object.entries(item).filter(([, v]) => v !== undefined); if (entries.length === 0) { lines.push(`${pad}- {}`); continue; } const [firstKey, firstVal] = entries[0]; lines.push(`${pad}- ${yamlKeyValue(firstKey, firstVal, indent + 1)}`); for (let i = 1; i < entries.length; i++) { const [k, v] = entries[i]; lines.push(`${pad} ${yamlKeyValue(k, v, indent + 1)}`); } } else { lines.push(`${pad}- ${yamlScalar(item).trimStart()}`); } } return; } // Plain object. const entries = Object.entries(value as Record).filter( ([, v]) => v !== undefined ); if (entries.length === 0) { lines.push(`${pad}{}`); return; } for (const [k, v] of entries) { if (v !== null && typeof v === "object") { const isEmptyArray = Array.isArray(v) && v.length === 0; const isEmptyObject = !Array.isArray(v) && Object.keys(v).length === 0; if (isEmptyArray) { lines.push(`${pad}${k}: []`); continue; } if (isEmptyObject) { lines.push(`${pad}${k}: {}`); continue; } lines.push(`${pad}${k}:`); emitYAML(v, indent + 1, lines); } else { lines.push(`${pad}${k}: ${yamlScalar(v)}`); } } } function yamlKeyValue(key: string, value: unknown, indent: number): string { if (value === null || typeof value !== "object") { return `${key}: ${yamlScalar(value)}`; } // Nested object / array: emit header + drop into recursion on subsequent lines. // The caller pushes lines after this; we return the header only. const tmp: string[] = []; emitYAML(value, indent, tmp); return `${key}:\n${tmp.join("\n")}`; } /** * Convert an OpenAPI document to a YAML string. * * Emits a conservative YAML 1.2 subset — two-space indentation, * double-quoted scalars where required, literal `[]` / `{}` for empty * collections. Produces output that round-trips cleanly through * Swagger UI, openapi-generator, and `yq`. */ export function openAPIToYAML(doc: OpenAPIDocument): string { const lines: string[] = []; emitYAML(doc, 0, lines); // Terminate with a newline per POSIX text-file convention so `cat` // doesn't elide the final byte. return lines.join("\n") + "\n"; } /** * Convert OpenAPI document to JSON string */ export function openAPIToJSON(doc: OpenAPIDocument): string { return JSON.stringify(doc, null, 2); } // ============================================ // Content hashing (for ETag) // ============================================ /** * Compute a SHA-256 hex digest of the serialized OpenAPI document. * * Uses `Bun.CryptoHasher` when available (zero allocations beyond the * input buffer) and falls back to the WebCrypto API so the helper works * under edge runtimes (Cloudflare Workers, Deno Deploy). */ export async function hashOpenAPIJSON(json: string): Promise { const bunGlobal = (globalThis as { Bun?: { CryptoHasher?: new (algo: string) => { update(input: string | Uint8Array): void; digest(encoding: "hex"): string } } }).Bun; if (bunGlobal?.CryptoHasher) { const hasher = new bunGlobal.CryptoHasher("sha256"); hasher.update(json); return hasher.digest("hex"); } // WebCrypto fallback (browser / edge runtimes). const subtle = (globalThis as { crypto?: { subtle?: SubtleCrypto } }).crypto?.subtle; if (subtle) { const data = new TextEncoder().encode(json); const buf = await subtle.digest("SHA-256", data); return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } // Last-resort Node fallback. const nodeCrypto = await import("node:crypto"); return nodeCrypto.createHash("sha256").update(json).digest("hex"); } // ============================================ // Build-time artifact emission // ============================================ export interface OpenAPIArtifactPaths { json: string; yaml: string; } export interface OpenAPIArtifactResult { /** OpenAPI 3.0.3 document serialized as JSON (two-space indented). */ json: string; /** OpenAPI 3.0.3 document serialized as YAML. */ yaml: string; /** SHA-256 hex digest of `json` — used as the runtime ETag. */ hash: string; /** Absolute paths of the emitted artifacts (matches the dir argument). */ paths: OpenAPIArtifactPaths; /** Number of route paths captured in the document. */ pathCount: number; } /** * Generate and write both `openapi.json` and `openapi.yaml` under the * given output directory. Returns the serialized bodies and the SHA-256 * hash so callers (e.g., `mandu build`) can log a stable artifact id * and the runtime endpoint can skip re-hashing on boot. * * Safe to call repeatedly — the directory is created if absent and the * existing files are overwritten atomically by `Bun.write`. */ export async function writeOpenAPIArtifacts( manifest: RoutesManifest, rootDir: string, outDir: string, options: Parameters[2] = {} ): Promise { const { mkdir } = await import("node:fs/promises"); const pathMod = await import("node:path"); const doc = await generateOpenAPIDocument(manifest, rootDir, options); const json = openAPIToJSON(doc); const yaml = openAPIToYAML(doc); const hash = await hashOpenAPIJSON(json); const absoluteDir = pathMod.isAbsolute(outDir) ? outDir : pathMod.join(rootDir, outDir); await mkdir(absoluteDir, { recursive: true }); const jsonPath = pathMod.join(absoluteDir, "openapi.json"); const yamlPath = pathMod.join(absoluteDir, "openapi.yaml"); await Bun.write(jsonPath, json); await Bun.write(yamlPath, yaml); return { json, yaml, hash, paths: { json: jsonPath, yaml: yamlPath }, pathCount: Object.keys(doc.paths).length, }; } /** * Read previously emitted OpenAPI artifacts from disk. Returns * `null` when either file is missing so the caller can fall back to * on-demand generation without logging a misleading error. */ export async function readOpenAPIArtifacts( outDir: string, rootDir: string ): Promise<{ json: string; yaml: string; hash: string } | null> { const pathMod = await import("node:path"); const absoluteDir = pathMod.isAbsolute(outDir) ? outDir : pathMod.join(rootDir, outDir); const jsonPath = pathMod.join(absoluteDir, "openapi.json"); const yamlPath = pathMod.join(absoluteDir, "openapi.yaml"); const jsonFile = Bun.file(jsonPath); const yamlFile = Bun.file(yamlPath); if (!(await jsonFile.exists()) || !(await yamlFile.exists())) { return null; } const json = await jsonFile.text(); const yaml = await yamlFile.text(); const hash = await hashOpenAPIJSON(json); return { json, yaml, hash }; }