import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' const sourcePath = 'contracts/api/kbx.api.json' const sourceText = fs.readFileSync(sourcePath, 'utf8') const source = JSON.parse(sourceText) const sha = crypto.createHash('sha256').update(sourceText).digest('hex') const operations = source.operations ?? [] const ids = new Set() const routes = new Set() for (const op of operations) { if (!op.id || !op.method || !op.path) throw new Error(`Invalid API operation: ${JSON.stringify(op)}`) if (ids.has(op.id)) throw new Error(`Duplicate operation id: ${op.id}`) ids.add(op.id) const routeKey = `${op.method} ${op.path}` if (routes.has(routeKey)) throw new Error(`Duplicate API route: ${routeKey}`) routes.add(routeKey) if (!['GET','POST','PUT','PATCH','DELETE'].includes(op.method)) throw new Error(`Unsupported method: ${op.method}`) if (op.idempotency === 'required' && op.method === 'GET') throw new Error(`GET cannot require idempotency: ${op.id}`) } const manifest = { schemaVersion: source.schemaVersion, apiVersion: source.apiVersion, sourceSha256: sha, operationCount: operations.length, operations: operations.map(({ source: _source, ...op }) => op), } fs.mkdirSync('generated', { recursive: true }) fs.writeFileSync('generated/api-manifest.json', JSON.stringify(manifest, null, 2) + '\n') const problemSchemaText = fs.readFileSync('contracts/problems/kbx.problem.schema.json','utf8') const problemSchemaSha = crypto.createHash('sha256').update(problemSchemaText).digest('hex') const problemSchema = JSON.parse(problemSchemaText) const problemTypes = ['validation','businessRule','conflict','permission','notFound','integration','system'] .map(key => problemSchema.$defs?.[key]?.allOf?.[1]?.properties?.type?.const) .filter(Boolean) fs.writeFileSync('generated/problem-manifest.json', JSON.stringify({ schemaVersion: '1.0', sourceSha256: problemSchemaSha, types: problemTypes, }, null, 2) + '\n') const tsOps = operations.map(op => ` ${JSON.stringify(op.id)}: ${JSON.stringify({ id: op.id, method: op.method, path: op.path, permission: op.permission ?? null, kind: op.kind, idempotency: op.idempotency, successStatuses: op.successStatuses ?? [200], ...(op.contentType ? { contentType: op.contentType } : {}), ...(op.responseType ? { responseType: op.responseType } : {}), })},`).join('\n') const ts = `// Generated from ${sourcePath}. Do not edit.\nimport type { KbxApiOperationDefinition } from '../api'\n\nexport const kbxApiSourceSha256 = '${sha}' as const\nexport const kbxApiCatalog = {\n${tsOps}\n} as const satisfies Record\n\nexport type KbxApiOperationId = keyof typeof kbxApiCatalog\nexport type KbxApiOperation = (typeof kbxApiCatalog)[KbxApiOperationId]\n` fs.mkdirSync('packages/kbx-contracts/src/generated', { recursive: true }) fs.writeFileSync('packages/kbx-contracts/src/generated/apiCatalog.ts', ts) const csItems = operations.map(op => ` new(${JSON.stringify(op.id)}, ${JSON.stringify(op.method)}, ${JSON.stringify(op.path)}, ${op.permission ? JSON.stringify(op.permission) : 'null'}, ${JSON.stringify(op.kind)}, ${JSON.stringify(op.idempotency)}, new[] { ${(op.successStatuses ?? [200]).join(', ')} })`).join(',\n') const cs = `// Generated from ${sourcePath}. Do not edit.\nnamespace Shared.Contracts.Generated;\n\npublic sealed record KbxApiOperationContract(\n string Id, string Method, string Path, string? Permission, string Kind, string Idempotency, IReadOnlyList SuccessStatuses);\n\npublic static class KbxApiCatalog\n{\n public const string SourceSha256 = "${sha}";\n public static readonly IReadOnlyList All = new KbxApiOperationContract[]\n {\n${csItems}\n };\n}\n` fs.mkdirSync('backend/Shared/Contracts/Generated', { recursive: true }) fs.writeFileSync('backend/Shared/Contracts/Generated/KbxApiCatalog.g.cs', cs) const paths = {} for (const op of operations) { const normalizedPath = op.path.replace(/\{([^}:]+):[^}]+\}/g, '{$1}') const method = op.method.toLowerCase() paths[normalizedPath] ??= {} const parameters = [...normalizedPath.matchAll(/\{([^}]+)\}/g)].map(m => ({ name: m[1], in: 'path', required: true, schema: { type: 'string' } })) paths[normalizedPath][method] = { operationId: op.id, tags: [op.id.split('.')[0].toUpperCase()], parameters, responses: Object.fromEntries([ ...(op.successStatuses ?? [200]).map(status => [String(status), { description: 'Success' }]), ['400', { description: 'Validation problem', content: { 'application/json': { schema: { '$ref': '#/components/schemas/KbxProblem' } } } }], ['403', { description: 'Permission problem', content: { 'application/json': { schema: { '$ref': '#/components/schemas/KbxProblem' } } } }], ['404', { description: 'Not found problem', content: { 'application/json': { schema: { '$ref': '#/components/schemas/KbxProblem' } } } }], ['409', { description: 'Conflict problem', content: { 'application/json': { schema: { '$ref': '#/components/schemas/KbxProblem' } } } }], ['422', { description: 'Business problem', content: { 'application/json': { schema: { '$ref': '#/components/schemas/KbxProblem' } } } }], ['500', { description: 'System problem', content: { 'application/json': { schema: { '$ref': '#/components/schemas/KbxProblem' } } } }], ]), 'x-kbx-permission': op.permission ?? null, 'x-kbx-idempotency': op.idempotency, 'x-kbx-kind': op.kind, } } const openapi = { openapi: '3.1.0', info: { title: 'KBX API Contract', version: source.apiVersion }, paths, components: { schemas: { KbxProblem: problemSchema } }, 'x-kbx-source-sha256': sha, } fs.writeFileSync('contracts/api/openapi.kbx.json', JSON.stringify(openapi, null, 2) + '\n') console.log(`API contracts generated: ${operations.length} operations, SHA ${sha.slice(0,12)}`)