Files
KArtSell.Aegis/docs/Design/kbx-foundation-v36/scripts/generate-api-contracts.mjs
T

94 lines
5.8 KiB
JavaScript

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<string, KbxApiOperationDefinition>\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<int> SuccessStatuses);\n\npublic static class KbxApiCatalog\n{\n public const string SourceSha256 = "${sha}";\n public static readonly IReadOnlyList<KbxApiOperationContract> 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)}`)