Files
openapi-clean-arch-gen/generate.ts
didavila cd00eb39ca feat: add TypeScript-based OpenAPI Clean Architecture generator
- Introduced a new TypeScript file (generate.ts) for generating Angular code with Clean Architecture from OpenAPI/Swagger specifications.
- Implemented a CLI using Commander.js for user input and options.
- Added functions for analyzing Swagger files, generating code, organizing files, and creating a report.
- Integrated Mustache templates for generating models, repositories, use cases, and mappers.
- Created a build process with TypeScript and updated package.json to include build scripts and dependencies.
- Added TypeScript configuration (tsconfig.json) for compiling the TypeScript code.
- Updated the main entry point in package.json to point to the compiled JavaScript file in the dist directory.
2026-03-23 17:01:22 +01:00

596 lines
20 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { execSync } from 'child_process';
import fs from 'fs-extra';
import path from 'path';
import yaml from 'js-yaml';
import mustache from 'mustache';
import { program } from 'commander';
// Desactivar escape HTML para que los literales < y > generen tipos genéricos válidos de TS.
(mustache as any).escape = function(text: string): string { return text; };
// Colores para console (sin dependencias externas)
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
green: '\x1b[32m',
blue: '\x1b[34m',
yellow: '\x1b[33m',
red: '\x1b[31m',
cyan: '\x1b[36m'
};
type Color = keyof typeof colors;
function log(message: string, color: Color = 'reset'): void {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function logSuccess(message: string): void {
log(`${message}`, 'green');
}
function logInfo(message: string): void {
log(` ${message}`, 'blue');
}
function logWarning(message: string): void {
log(`⚠️ ${message}`, 'yellow');
}
function logError(message: string): void {
log(`${message}`, 'red');
}
function logStep(message: string): void {
log(`\n🚀 ${message}`, 'cyan');
}
// Configuración del CLI
program
.name('generate-clean-arch')
.description('Generador de código Angular con Clean Architecture desde OpenAPI/Swagger')
.version('1.0.0')
.option('-i, --input <file>', 'Archivo OpenAPI/Swagger (yaml o json)', 'swagger.yaml')
.option('-o, --output <dir>', 'Directorio de salida', './src/app')
.option('-t, --templates <dir>', 'Directorio de templates personalizados', './templates')
.option('--skip-install', 'No instalar dependencias')
.option('--dry-run', 'Simular sin generar archivos')
.parse(process.argv);
interface CliOptions {
input: string;
output: string;
templates: string;
skipInstall?: boolean;
dryRun?: boolean;
}
const options = program.opts() as CliOptions;
// Validar que existe openapi-generator-cli
function checkOpenApiGenerator(): boolean {
try {
execSync('openapi-generator-cli version', { stdio: 'ignore' });
return true;
} catch (error) {
return false;
}
}
// Instalar openapi-generator-cli
function installOpenApiGenerator(): void {
logStep('Instalando @openapitools/openapi-generator-cli...');
try {
execSync('npm install -g @openapitools/openapi-generator-cli', { stdio: 'inherit' });
logSuccess('OpenAPI Generator CLI instalado correctamente');
} catch (error) {
logError('Error al instalar OpenAPI Generator CLI');
process.exit(1);
}
}
// Crear estructura de directorios
function createDirectoryStructure(baseDir: string): void {
const dirs = [
path.join(baseDir, 'data/dtos'),
path.join(baseDir, 'data/repositories'),
path.join(baseDir, 'data/mappers'),
path.join(baseDir, 'domain/repositories'),
path.join(baseDir, 'domain/use-cases'),
path.join(baseDir, 'di/repositories'),
path.join(baseDir, 'di/use-cases'),
path.join(baseDir, 'entities/models')
];
dirs.forEach(dir => {
fs.ensureDirSync(dir);
});
logSuccess('Estructura de directorios creada');
}
interface SwaggerAnalysis {
tags: any[];
paths: Record<string, any>;
swagger: any;
}
// Analizar el swagger para extraer tags y dominios
function analyzeSwagger(swaggerFile: string): SwaggerAnalysis {
logStep('Analizando archivo OpenAPI...');
try {
const fileContent = fs.readFileSync(swaggerFile, 'utf8');
const swagger = yaml.load(fileContent) as any;
const tags = swagger.tags || [];
const paths = swagger.paths || {};
logInfo(`Encontrados ${tags.length} tags en el API`);
logInfo(`Encontrados ${Object.keys(paths).length} endpoints`);
tags.forEach((tag: any) => {
logInfo(` - ${tag.name}: ${tag.description || 'Sin descripción'}`);
});
return { tags, paths, swagger };
} catch (error: any) {
logError(`Error al leer el archivo Swagger: ${error.message}`);
process.exit(1);
}
}
// Generar código con OpenAPI Generator
function generateCode(swaggerFile: string, templatesDir: string): string {
logStep('Generando código desde OpenAPI...');
const tempDir = path.join(process.cwd(), '.temp-generated');
// Limpiar directorio temporal
if (fs.existsSync(tempDir)) {
fs.removeSync(tempDir);
}
try {
const command = `openapi-generator-cli generate \
-i "${swaggerFile}" \
-g typescript-angular \
--global-property models \
-t "${templatesDir}" \
-o "${tempDir}" \
--additional-properties=ngVersion=17.0.0,modelFileSuffix=.dto`;
execSync(command, { stdio: 'inherit' });
logSuccess('Código generado correctamente');
return tempDir;
} catch (error) {
logError('Error al generar código');
if (fs.existsSync(tempDir)) {
fs.removeSync(tempDir);
}
process.exit(1);
}
}
// Organizar archivos según Clean Architecture (DTOs)
function organizeFiles(tempDir: string, outputDir: string): void {
logStep('Organizando archivos DTO generados...');
const sourceDir = path.join(tempDir, 'model');
const destDir = path.join(outputDir, 'data/dtos');
let filesMoved = 0;
if (fs.existsSync(sourceDir)) {
fs.ensureDirSync(destDir);
const files = fs.readdirSync(sourceDir).filter(file => file.endsWith('.dto.ts'));
files.forEach(file => {
const sourcePath = path.join(sourceDir, file);
const destPath = path.join(destDir, file);
fs.copySync(sourcePath, destPath);
filesMoved++;
logInfo(` ${file}${path.relative(process.cwd(), destPath)}`);
});
}
logSuccess(`${filesMoved} DTOs movidos correctamente`);
}
// Utilidad para mapear tipos OpenAPI elementales a TypeScript
function mapSwaggerTypeToTs(type: string): string {
const typeMap: Record<string, string> = {
'integer': 'number',
'string': 'string',
'boolean': 'boolean',
'number': 'number',
'array': 'Array<any>',
'object': 'any'
};
return typeMap[type] || 'any';
}
interface GeneratedCount {
models: number;
repositories: number;
mappers: number;
useCases: number;
providers: number;
}
// Generar Clean Architecture con Mustache
function generateCleanArchitecture(analysis: SwaggerAnalysis, outputDir: string, templatesDir: string): GeneratedCount {
logStep('Generando artefactos de Clean Architecture usando Mustache...');
let generatedCount: GeneratedCount = { models: 0, repositories: 0, mappers: 0, useCases: 0, providers: 0 };
const schemas = analysis.swagger.components?.schemas || {};
// 1. Generar Modelos, Entidades y Mappers a partir de Schemas
Object.keys(schemas).forEach(schemaName => {
// Sanitizar nombres base para que coincidan con cómo OpenAPI los emite (sin Dto duplicado)
const baseName = schemaName.replace(/Dto$/, '');
// variables para model
const rawProperties = schemas[schemaName].properties || {};
const requiredProps: string[] = schemas[schemaName].required || [];
const varsMap = Object.keys(rawProperties).map(k => {
let tsType = mapSwaggerTypeToTs(rawProperties[k].type);
if (rawProperties[k].$ref) {
// Simple extración del tipo de la ref
tsType = rawProperties[k].$ref.split('/').pop() || 'any';
} else if (rawProperties[k].type === 'array' && rawProperties[k].items?.$ref) {
tsType = `Array<${rawProperties[k].items.$ref.split('/').pop()}>`;
}
return {
name: k,
dataType: tsType,
description: rawProperties[k].description || '',
required: requiredProps.includes(k)
};
});
const modelViewData = {
models: [{
model: {
classname: baseName,
classFilename: baseName.toLowerCase(),
classVarName: baseName.charAt(0).toLowerCase() + baseName.slice(1),
description: schemas[schemaName].description || '',
vars: varsMap
}
}],
// Para plantillas que esperan allModels o importaciones (mapper)
allModels: [{ model: { vars: varsMap } }]
};
// Y para mapper.mustache, que además pide apiInfo
const mapperViewData = {
...modelViewData,
apiInfo: {
apis: [{
operations: {
classname: baseName,
classFilename: baseName.toLowerCase(),
classVarName: baseName.charAt(0).toLowerCase() + baseName.slice(1),
}
}]
}
};
// Model (Entities)
const modelTemplatePath = path.join(templatesDir, 'model-entity.mustache');
if (fs.existsSync(modelTemplatePath)) {
const template = fs.readFileSync(modelTemplatePath, 'utf8');
const output = mustache.render(template, modelViewData);
const destPath = path.join(outputDir, 'entities/models', `${baseName.toLowerCase()}.model.ts`);
fs.writeFileSync(destPath, output);
generatedCount.models++;
logInfo(` ${baseName.toLowerCase()}.model.ts → ${path.relative(process.cwd(), destPath)}`);
}
// Mapper
const mapperTemplatePath = path.join(templatesDir, 'mapper.mustache');
if (fs.existsSync(mapperTemplatePath)) {
const template = fs.readFileSync(mapperTemplatePath, 'utf8');
const output = mustache.render(template, mapperViewData);
const destPath = path.join(outputDir, 'data/mappers', `${baseName.toLowerCase()}.mapper.ts`);
fs.writeFileSync(destPath, output);
generatedCount.mappers++;
}
});
// 2. Generar Casos de Uso y Repositorios a partir de Paths/Tags
const tagsMap: Record<string, any[]> = {};
// Agrupar operaciones por Tag
Object.keys(analysis.paths).forEach(pathKey => {
const pathObj = analysis.paths[pathKey];
Object.keys(pathObj).forEach(method => {
const op = pathObj[method];
if (op.tags && op.tags.length > 0) {
const tag = op.tags[0]; // Usamos el primer tag
if (!tagsMap[tag]) tagsMap[tag] = [];
// Parsear parámetros
const allParams = (op.parameters || []).map((p: any) => ({
paramName: p.name,
dataType: mapSwaggerTypeToTs(p.schema?.type),
description: p.description || '',
required: p.required
}));
// Añadir body como parámetro si existe
if (op.requestBody) {
let bodyType = 'any';
const content = op.requestBody.content?.['application/json']?.schema;
if (content) {
if (content.$ref) bodyType = content.$ref.split('/').pop() || 'any';
else if (content.type) bodyType = mapSwaggerTypeToTs(content.type);
}
allParams.push({
paramName: 'body',
dataType: bodyType,
description: op.requestBody.description || '',
required: true
});
}
// Parsear respuestas
let returnType = 'void';
let returnBaseType = 'void';
let isListContainer = false;
const responseSchema = op.responses?.['200']?.content?.['application/json']?.schema;
if (responseSchema) {
if (responseSchema.$ref) {
returnType = responseSchema.$ref.split('/').pop() || 'any';
returnBaseType = returnType;
} else if (responseSchema.type === 'array' && responseSchema.items?.$ref) {
returnBaseType = responseSchema.items.$ref.split('/').pop() || 'any';
returnType = `Array<${returnBaseType}>`;
isListContainer = true;
}
}
tagsMap[tag].push({
nickname: op.operationId || `${method}${pathKey.replace(/\//g, '_')}`,
summary: op.summary || '',
notes: op.description || '',
httpMethod: method.toLowerCase(),
path: pathKey,
allParams: allParams.map((p: any, i: number) => ({ ...p, '-last': i === allParams.length - 1 })),
hasQueryParams: (op.parameters || []).some((p: any) => p.in === 'query'),
queryParams: (op.parameters || []).filter((p: any) => p.in === 'query').map((p: any, i: number, arr: any[]) => ({ paramName: p.name, '-last': i === arr.length - 1 })),
hasBodyParam: !!op.requestBody,
bodyParam: 'body',
returnType: returnType !== 'void' ? returnType : false,
returnBaseType: returnBaseType !== 'void' ? returnBaseType : false,
isListContainer: isListContainer,
vendorExtensions: {}
});
}
});
});
// Generar por cada Tag
Object.keys(tagsMap).forEach(tag => {
// Buscar si ese tag cruza con alguna entidad para importarla
const imports: any[] = [];
Object.keys(schemas).forEach(s => {
// Import heurístico burdo
if (tagsMap[tag].some((op: any) => op.returnType === s || op.returnType === `Array<${s}>`)) {
imports.push({ classname: s, classFilename: s.toLowerCase(), classVarName: s.charAt(0).toLowerCase() + s.slice(1) });
}
});
const apiViewData = {
apiInfo: {
apis: [{
operations: {
classname: tag,
classFilename: tag.toLowerCase(),
constantName: tag.toUpperCase().replace(/[^A-Z0-9]/g, '_'),
operation: tagsMap[tag],
imports: imports
}
}]
}
};
// Use Case Contract
const ucContractPath = path.join(templatesDir, 'api.use-cases.contract.mustache');
if (fs.existsSync(ucContractPath)) {
const template = fs.readFileSync(ucContractPath, 'utf8');
const output = mustache.render(template, apiViewData);
const destPath = path.join(outputDir, 'domain/use-cases', `${tag.toLowerCase()}.use-cases.contract.ts`);
fs.writeFileSync(destPath, output);
generatedCount.useCases++;
}
// Use Case Impl
const ucImplPath = path.join(templatesDir, 'api.use-cases.impl.mustache');
if (fs.existsSync(ucImplPath)) {
const template = fs.readFileSync(ucImplPath, 'utf8');
const output = mustache.render(template, apiViewData);
const destPath = path.join(outputDir, 'domain/use-cases', `${tag.toLowerCase()}.use-cases.impl.ts`);
fs.writeFileSync(destPath, output);
generatedCount.useCases++;
}
// Repository Contract
const repoContractPath = path.join(templatesDir, 'api.repository.contract.mustache');
if (fs.existsSync(repoContractPath)) {
const template = fs.readFileSync(repoContractPath, 'utf8');
const output = mustache.render(template, apiViewData);
const destPath = path.join(outputDir, 'domain/repositories', `${tag.toLowerCase()}.repository.contract.ts`);
fs.writeFileSync(destPath, output);
generatedCount.repositories++;
}
// Repository Impl
const repoImplPath = path.join(templatesDir, 'api.repository.impl.mustache');
if (fs.existsSync(repoImplPath)) {
const template = fs.readFileSync(repoImplPath, 'utf8');
const output = mustache.render(template, apiViewData);
const destPath = path.join(outputDir, 'data/repositories', `${tag.toLowerCase()}.repository.impl.ts`);
fs.writeFileSync(destPath, output);
generatedCount.repositories++;
}
// Use Case Provider
const ucProviderPath = path.join(templatesDir, 'use-cases.provider.mustache');
if (fs.existsSync(ucProviderPath)) {
const template = fs.readFileSync(ucProviderPath, 'utf8');
const output = mustache.render(template, apiViewData);
const destPath = path.join(outputDir, 'di/use-cases', `${tag.toLowerCase()}.use-cases.provider.ts`);
fs.writeFileSync(destPath, output);
generatedCount.providers++;
}
// Repository Provider
const repoProviderPath = path.join(templatesDir, 'repository.provider.mustache');
if (fs.existsSync(repoProviderPath)) {
const template = fs.readFileSync(repoProviderPath, 'utf8');
const output = mustache.render(template, apiViewData);
const destPath = path.join(outputDir, 'di/repositories', `${tag.toLowerCase()}.repository.provider.ts`);
fs.writeFileSync(destPath, output);
generatedCount.providers++;
}
});
logSuccess(`${generatedCount.models} Models, ${generatedCount.repositories} Repos, ${generatedCount.useCases} Use Cases, ${generatedCount.mappers} Mappers, ${generatedCount.providers} Providers generados con Mustache`);
return generatedCount;
}
// Limpiar directorio temporal
function cleanup(tempDir: string): void {
if (fs.existsSync(tempDir)) {
fs.removeSync(tempDir);
logInfo('Archivos temporales eliminados');
}
}
interface GenerationReport {
timestamp: string;
tags: number;
endpoints: number;
outputDirectory: string;
structure: {
dtos: number;
repositories: number;
mappers: number;
useCases: number;
providers: number;
};
}
// Generar reporte
function generateReport(outputDir: string, analysis: SwaggerAnalysis): GenerationReport {
logStep('Generando reporte de generación...');
const report: GenerationReport = {
timestamp: new Date().toISOString(),
tags: analysis.tags.length,
endpoints: Object.keys(analysis.paths).length,
outputDirectory: outputDir,
structure: {
dtos: fs.readdirSync(path.join(outputDir, 'data/dtos')).length,
repositories: fs.readdirSync(path.join(outputDir, 'data/repositories')).length,
mappers: fs.readdirSync(path.join(outputDir, 'data/mappers')).length,
useCases: fs.readdirSync(path.join(outputDir, 'domain/use-cases')).length,
providers: fs.readdirSync(path.join(outputDir, 'di/repositories')).length + fs.readdirSync(path.join(outputDir, 'di/use-cases')).length
}
};
const reportPath = path.join(process.cwd(), 'generation-report.json');
fs.writeJsonSync(reportPath, report, { spaces: 2 });
logSuccess(`Reporte guardado en: ${reportPath}`);
return report;
}
// Función principal
async function main(): Promise<void> {
console.log('\n' + '='.repeat(60));
log(' OpenAPI Clean Architecture Generator', 'bright');
log(' Angular + Clean Architecture Code Generator', 'cyan');
console.log('='.repeat(60) + '\n');
// Validar archivo de entrada
if (!fs.existsSync(options.input)) {
logError(`Archivo no encontrado: ${options.input}`);
process.exit(1);
}
logInfo(`Archivo de entrada: ${options.input}`);
logInfo(`Directorio de salida: ${options.output}`);
logInfo(`Templates: ${options.templates}`);
if (options.dryRun) {
logWarning('Modo DRY RUN - No se generarán archivos');
}
// Verificar/Instalar OpenAPI Generator
if (!checkOpenApiGenerator()) {
logWarning('OpenAPI Generator CLI no encontrado');
if (!options.skipInstall) {
installOpenApiGenerator();
} else {
logError('Instala openapi-generator-cli con: npm install -g @openapitools/openapi-generator-cli');
process.exit(1);
}
} else {
logSuccess('OpenAPI Generator CLI encontrado');
}
// Analizar Swagger
const analysis = analyzeSwagger(options.input);
if (options.dryRun) {
logInfo('Finalizando en modo DRY RUN');
return;
}
// Crear estructura de directorios
createDirectoryStructure(options.output);
// Generar código
const tempDir = generateCode(options.input, options.templates);
// Organizar archivos
organizeFiles(tempDir, options.output);
// Crear componentes Clean Architecture con nuestro script de Mustache
generateCleanArchitecture(analysis, options.output, options.templates);
// Limpiar
cleanup(tempDir);
// Generar reporte
const report = generateReport(options.output, analysis);
// Resumen final
console.log('\n' + '='.repeat(60));
log(' ✨ Generación completada con éxito', 'green');
console.log('='.repeat(60));
console.log(`\n📊 Resumen:`);
console.log(` - DTOs generados: ${report.structure.dtos}`);
console.log(` - Repositories: ${report.structure.repositories}`);
console.log(` - Mappers: ${report.structure.mappers}`);
console.log(` - Use Cases: ${report.structure.useCases}`);
console.log(` - Providers: ${report.structure.providers}`);
console.log(`\n📁 Archivos generados en: ${colors.cyan}${options.output}${colors.reset}\n`);
}
// Ejecutar
main().catch((error: any) => {
logError(`Error fatal: ${error.message}`);
console.error(error);
process.exit(1);
});