302 lines
9.5 KiB
JavaScript
302 lines
9.5 KiB
JavaScript
// 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)}`);
|