// Deterministic OpenAPI 3.0.3 generator from api-summary.json / toir.api.dsl. // // This script is part of the Tier 1 deterministic preprocessing layer. // It converts the canonical api-summary (produced by tools/api-summary.mjs) // into a valid OpenAPI 3.0.3 document. // // Usage: // node tools/api-summary-to-openapi.mjs --out openapi.json // npm run generate:openapi // // No LLM involvement. The output is reproducible from DSL + this script alone. import { writeFileSync } from 'node:fs'; import path from 'node:path'; import { buildApiSummary } from './api-summary.mjs'; const rootDir = process.cwd(); // --------------------------------------------------------------------------- // DSL scalar → OpenAPI type // --------------------------------------------------------------------------- function dslTypeToOpenApi(dslType) { switch (dslType) { case 'uuid': return { type: 'string', format: 'uuid' }; case 'string': return { type: 'string' }; case 'text': return { type: 'string' }; case 'integer': return { type: 'integer', format: 'int32' }; case 'number': return { type: 'number' }; case 'decimal': return { type: 'string', format: 'decimal' }; case 'date': return { type: 'string', format: 'date-time' }; case 'boolean': return { type: 'boolean' }; default: // enum names and DTO references handled by caller return null; } } // --------------------------------------------------------------------------- // Resolve a DSL field type to an OpenAPI schema reference or inline schema. // dtoNames — Set of known DTO names in the api summary. // enumNames — Set of known enum names (derived from type mappings table). // --------------------------------------------------------------------------- function resolveFieldType(dslType, dtoNames, enumNames) { if (!dslType) return { type: 'object' }; // Array type: "DTO.Foo[]" if (dslType.endsWith('[]')) { const inner = dslType.slice(0, -2); return { type: 'array', items: resolveFieldType(inner, dtoNames, enumNames) }; } // Scalar const scalar = dslTypeToOpenApi(dslType); if (scalar) return scalar; // DTO reference if (dtoNames.has(dslType)) { return { $ref: `#/components/schemas/${dslType.replace(/^DTO\./, '')}` }; } // Enum reference if (enumNames.has(dslType)) { return { $ref: `#/components/schemas/${dslType}` }; } // Unknown — emit as string with x-dsl-type annotation return { type: 'string', 'x-dsl-type': dslType }; } // --------------------------------------------------------------------------- // Build OpenAPI schema object from a DTO definition // --------------------------------------------------------------------------- function buildDtoSchema(dto, dtoNames, enumNames) { const properties = {}; const required = []; for (const field of dto.fields) { const schema = resolveFieldType(field.type, dtoNames, enumNames); if (field.description && schema.$ref) { // OpenAPI 3.0.3: $ref cannot have sibling keys — wrap with allOf properties[field.name] = { allOf: [schema], description: field.description }; } else { if (field.description) schema.description = field.description; properties[field.name] = schema; } if (field.required) required.push(field.name); } const schema = { type: 'object', properties, }; if (dto.description) schema.description = dto.description; if (required.length > 0) schema.required = required; return schema; } // --------------------------------------------------------------------------- // Convert DSL HTTP method to OpenAPI method key // --------------------------------------------------------------------------- function methodKey(method) { return (method ?? 'get').toLowerCase(); } // --------------------------------------------------------------------------- // Build OpenAPI path item for an endpoint // --------------------------------------------------------------------------- function buildPathOperation(endpoint, apiDescription, dtoNames, enumNames) { const operation = {}; if (endpoint.description) operation.summary = endpoint.description; // Security — all endpoints require bearer auth operation.security = [{ bearerAuth: [] }]; // Tags — derive from API name or path const tag = apiDescription ? apiDescription.replace(/^API управления\s+/i, '').replace(/ами$/, '') : undefined; if (tag) operation.tags = [tag]; // Parameters — detect path params by matching attribute names against {param} in the path const pathTemplate = endpoint.path ?? ''; const pathParamNames = new Set( [...pathTemplate.matchAll(/\{(\w+)\}/g)].map((m) => m[1]), ); const pathParams = endpoint.attributes.filter((a) => pathParamNames.has(a.name)); if (pathParams.length > 0) { operation.parameters = pathParams.map((p) => ({ name: p.name, in: 'path', required: true, schema: resolveFieldType(p.type, dtoNames, enumNames), ...(p.description ? { description: p.description } : {}), })); } // Request body const requestAttr = endpoint.attributes.find((a) => a.name === 'request'); if (requestAttr) { operation.requestBody = { required: true, content: { 'application/json': { schema: resolveFieldType(requestAttr.type, dtoNames, enumNames), }, }, }; } // Response const responseAttr = endpoint.attributes.find((a) => a.name === 'response'); const responseSchema = responseAttr ? resolveFieldType(responseAttr.type, dtoNames, enumNames) : { type: 'object' }; const successCode = endpoint.method === 'POST' && !endpoint.path?.endsWith('/page') ? '201' : '200'; operation.responses = { [successCode]: { description: 'Success', content: { 'application/json': { schema: responseSchema, }, }, }, '401': { description: 'Unauthorized' }, '403': { description: 'Forbidden' }, }; if (endpoint.method === 'DELETE') { operation.responses = { '204': { description: 'No content' }, '401': { description: 'Unauthorized' }, '403': { description: 'Forbidden' }, '404': { description: 'Not found' }, }; delete operation.responses['201']; } return operation; } // --------------------------------------------------------------------------- // Main builder // --------------------------------------------------------------------------- function buildOpenApi(summary) { const dtoNames = new Set(summary.dtos.map((d) => d.name)); // Build enum map from api-summary enums block (fully declared enums with values) const enumMap = new Map((summary.enums ?? []).map((e) => [e.name, e])); // Also collect enum names referenced in DTO fields that are not in the declared enums // (covers cases where enums are declared in domain.dsl but referenced in api.dsl) const enumNames = new Set(enumMap.keys()); for (const dto of summary.dtos) { for (const field of dto.fields) { const t = field.type?.replace('[]', ''); if (t && !dtoNames.has(t) && !dslTypeToOpenApi(t)) { enumNames.add(t); } } } // Schemas — one per DTO const schemas = {}; for (const dto of summary.dtos) { const schemaName = dto.name.replace(/^DTO\./, ''); schemas[schemaName] = buildDtoSchema(dto, dtoNames, enumNames); } // Enum schemas — use actual values when available, otherwise annotate as opaque string enum for (const enumName of enumNames) { const enumDef = enumMap.get(enumName); if (enumDef && enumDef.values.length > 0) { schemas[enumName] = { type: 'string', enum: enumDef.values.map((v) => v.name), 'x-enum-labels': Object.fromEntries( enumDef.values.filter((v) => v.label).map((v) => [v.name, v.label]), ), ...(enumDef.description ? { description: enumDef.description } : {}), }; } else { schemas[enumName] = { type: 'string', 'x-dsl-enum': enumName, description: `Enum: ${enumName} (values defined in domain/*.api.dsl)`, }; } } // Paths const paths = {}; for (const api of summary.apis) { for (const endpoint of api.endpoints) { if (!endpoint.path) continue; const pathKey = endpoint.path; if (!paths[pathKey]) paths[pathKey] = {}; const opKey = methodKey(endpoint.method); paths[pathKey][opKey] = buildPathOperation(endpoint, api.description, dtoNames, enumNames); } } return { openapi: '3.0.3', info: { title: 'KIS-TOiR API', description: 'Equipment maintenance management system. Generated from domain/toir.api.dsl via tools/api-summary-to-openapi.mjs.', version: '1.0.0', }, servers: [ { url: '/api', description: 'Default server', }, ], components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', }, }, schemas, }, paths, }; } // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- const args = process.argv.slice(2); const outIndex = args.indexOf('--out'); const outPath = outIndex !== -1 ? args[outIndex + 1] : 'openapi.json'; const summary = buildApiSummary(rootDir); const openApiDoc = buildOpenApi(summary); const outputPath = path.resolve(rootDir, outPath); writeFileSync(outputPath, `${JSON.stringify(openApiDoc, null, 2)}\n`, 'utf8'); console.log(`Generated ${path.relative(rootDir, outputPath)}`);