(llm-first): context budget, validation, and eval harness, orchestration general-prompt
This commit is contained in:
301
tools/api-summary-to-openapi.mjs
Normal file
301
tools/api-summary-to-openapi.mjs
Normal file
@@ -0,0 +1,301 @@
|
||||
// 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)}`);
|
||||
Reference in New Issue
Block a user