#!/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 { escape: (text: string) => string }).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 ', 'Archivo OpenAPI/Swagger (yaml o json)', 'swagger.yaml') .option('-o, --output ', 'Directorio de salida', './src/app') .option('-t, --templates ', 'Directorio de templates personalizados', './templates') .option('--skip-install', 'No instalar dependencias') .option('--dry-run', 'Simular sin generar archivos') .parse(process.argv); export interface CliOptions { input: string; output: string; templates: string; skipInstall?: boolean; dryRun?: boolean; } const options = program.opts(); // 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: unknown[]; paths: Record; swagger: unknown; } // 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 Record; const tags = Array.isArray(swagger.tags) ? swagger.tags : []; const paths = (swagger.paths as Record) || {}; logInfo(`Encontrados ${tags.length} tags en el API`); logInfo(`Encontrados ${Object.keys(paths).length} endpoints`); tags.forEach((tag: unknown) => { const t = tag as { name: string; description?: string }; logInfo(` - ${t.name}: ${t.description || 'Sin descripción'}`); }); return { tags, paths, swagger }; } catch (error: unknown) { const err = error as Error; logError(`Error al leer el archivo Swagger: ${err.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 { if (!type) return 'unknown'; const typeMap: Record = { integer: 'number', string: 'string', boolean: 'boolean', number: 'number', array: 'Array', object: 'unknown' }; return typeMap[type] || 'unknown'; } interface GeneratedCount { models: number; repositories: number; mappers: number; useCases: number; providers: number; } export interface OpenApiSchema { properties?: Record< string, { type?: string; description?: string; $ref?: string; items?: { $ref?: string }; } >; required?: string[]; description?: string; } export interface OpenApiOperation { tags?: string[]; operationId?: string; summary?: string; description?: string; parameters?: Array<{ name: string; in: string; required: boolean; description?: string; schema?: { type?: string }; }>; requestBody?: { description?: string; content?: Record< string, { schema?: { $ref?: string; type?: string; }; } >; }; responses?: Record< string, { content?: Record< string, { schema?: { $ref?: string; type?: string; items?: { $ref?: string }; }; } >; } >; } export interface TagOperation { nickname: string; summary: string; notes: string; httpMethod: string; path: string; allParams: unknown[]; hasQueryParams: boolean; queryParams: unknown[]; hasBodyParam: boolean; bodyParam: string; returnType: string | boolean; returnBaseType: string | boolean; isListContainer: boolean; vendorExtensions: Record; } // Generar Clean Architecture con Mustache function generateCleanArchitecture( analysis: SwaggerAnalysis, outputDir: string, templatesDir: string ): GeneratedCount { logStep('Generando artefactos de Clean Architecture usando Mustache...'); const generatedCount: GeneratedCount = { models: 0, repositories: 0, mappers: 0, useCases: 0, providers: 0 }; const schemas = (analysis.swagger as { components?: { schemas?: Record } }).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 schemaObj = schemas[schemaName] as OpenApiSchema; const rawProperties = schemaObj.properties || {}; const requiredProps: string[] = schemaObj.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() || 'unknown'; } 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: schemaObj.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 = {}; // Agrupar operaciones por Tag Object.keys(analysis.paths).forEach((pathKey) => { const pathObj = analysis.paths[pathKey] as Record; Object.keys(pathObj).forEach((method) => { const op = pathObj[method] as OpenApiOperation; 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) => ({ 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 = 'unknown'; const content = op.requestBody.content?.['application/json']?.schema; if (content) { if (content.$ref) bodyType = content.$ref.split('/').pop() || 'unknown'; 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() || 'unknown'; returnBaseType = returnType; } else if (responseSchema.type === 'array' && responseSchema.items?.$ref) { returnBaseType = responseSchema.items.$ref.split('/').pop() || 'unknown'; 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, i: number) => ({ ...p, '-last': i === allParams.length - 1 })), hasQueryParams: (op.parameters || []).some((p) => p.in === 'query'), queryParams: (op.parameters || []) .filter((p) => p.in === 'query') .map((p, i: number, arr: unknown[]) => ({ 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: { classname: string; classFilename: string; classVarName: string }[] = []; Object.keys(schemas).forEach((s) => { // Import heurístico burdo if (tagsMap[tag].some((op) => 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 { 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: unknown) => { const err = error as Error; logError(`Error fatal: ${err.message}`); console.error(error); process.exit(1); });