diff --git a/main.ts b/main.ts index 4bcfc0e..d44197f 100755 --- a/main.ts +++ b/main.ts @@ -1,55 +1,25 @@ #!/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'; +import { log, logSuccess, logInfo, logWarning, logError, colors } from './src/utils/logger'; +import { checkOpenApiGenerator, installOpenApiGenerator } from './src/utils/openapi-generator'; +import { createDirectoryStructure, cleanup } from './src/utils/filesystem'; +import { analyzeSwagger } from './src/swagger/analyzer'; +import { generateCode, organizeFiles } from './src/generators/dto.generator'; +import { generateCleanArchitecture } from './src/generators/clean-arch.generator'; +import { generateReport } from './src/generators/report.generator'; +import type { CliOptions } from './src/types'; + // 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' -}; +// ── CLI CONFIGURATION ──────────────────────────────────────────────────────── -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') @@ -61,609 +31,16 @@ program .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(); -const options = program.opts(); +// ── MAIN ORCHESTRATOR ──────────────────────────────────────────────────────── -// 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); @@ -677,7 +54,6 @@ async function main(): Promise { logWarning('Modo DRY RUN - No se generarán archivos'); } - // Verificar/Instalar OpenAPI Generator if (!checkOpenApiGenerator()) { logWarning('OpenAPI Generator CLI no encontrado'); if (!options.skipInstall) { @@ -692,7 +68,6 @@ async function main(): Promise { logSuccess('OpenAPI Generator CLI encontrado'); } - // Analizar Swagger const analysis = analyzeSwagger(options.input); if (options.dryRun) { @@ -700,25 +75,15 @@ async function main(): Promise { 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)); @@ -731,7 +96,6 @@ async function main(): Promise { 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}`); diff --git a/package.json b/package.json index a77a95c..4d63162 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "prepublishOnly": "npm run build", "generate": "node dist/generate.js", "generate:dev": "ts-node main.ts", - "lint": "eslint 'main.ts' -f unix", - "lint:fix": "eslint 'main.ts' --fix -f unix", + "lint": "eslint 'main.ts' 'src/**/*.ts' -f unix", + "lint:fix": "eslint 'main.ts' 'src/**/*.ts' --fix -f unix", "format": "prettier --write .", "setup": "npm install -g @openapitools/openapi-generator-cli" }, diff --git a/src/generators/clean-arch.generator.ts b/src/generators/clean-arch.generator.ts new file mode 100644 index 0000000..b4b34f7 --- /dev/null +++ b/src/generators/clean-arch.generator.ts @@ -0,0 +1,294 @@ +import fs from 'fs-extra'; +import path from 'path'; +import mustache from 'mustache'; +import { logStep, logSuccess, logInfo } from '../utils/logger'; +import { mapSwaggerTypeToTs } from '../utils/type-mapper'; +import type { + SwaggerAnalysis, + OpenApiSchema, + OpenApiOperation, + TagOperation, + GeneratedCount +} from '../types'; + +/** Genera todos los artefactos de Clean Architecture (modelos, mappers, repos, use cases, providers) usando Mustache. */ +export 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) => { + const baseName = schemaName.replace(/Dto$/, ''); + + 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) { + 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 + } + } + ], + allModels: [{ model: { vars: varsMap } }] + }; + + 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 = {}; + + 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]; + if (!tagsMap[tag]) tagsMap[tag] = []; + + const allParams = (op.parameters || []).map((p) => ({ + paramName: p.name, + dataType: mapSwaggerTypeToTs(p.schema?.type || ''), + description: p.description || '', + required: p.required + })); + + 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 + }); + } + + 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) => { + const imports: { classname: string; classFilename: string; classVarName: string }[] = []; + Object.keys(schemas).forEach((s) => { + 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 + } + } + ] + } + }; + + renderTemplate( + templatesDir, + 'api.use-cases.contract.mustache', + apiViewData, + path.join(outputDir, 'domain/use-cases', `${tag.toLowerCase()}.use-cases.contract.ts`), + generatedCount, + 'useCases' + ); + + renderTemplate( + templatesDir, + 'api.use-cases.impl.mustache', + apiViewData, + path.join(outputDir, 'domain/use-cases', `${tag.toLowerCase()}.use-cases.impl.ts`), + generatedCount, + 'useCases' + ); + + renderTemplate( + templatesDir, + 'api.repository.contract.mustache', + apiViewData, + path.join(outputDir, 'domain/repositories', `${tag.toLowerCase()}.repository.contract.ts`), + generatedCount, + 'repositories' + ); + + renderTemplate( + templatesDir, + 'api.repository.impl.mustache', + apiViewData, + path.join(outputDir, 'data/repositories', `${tag.toLowerCase()}.repository.impl.ts`), + generatedCount, + 'repositories' + ); + + renderTemplate( + templatesDir, + 'use-cases.provider.mustache', + apiViewData, + path.join(outputDir, 'di/use-cases', `${tag.toLowerCase()}.use-cases.provider.ts`), + generatedCount, + 'providers' + ); + + renderTemplate( + templatesDir, + 'repository.provider.mustache', + apiViewData, + path.join(outputDir, 'di/repositories', `${tag.toLowerCase()}.repository.provider.ts`), + generatedCount, + 'providers' + ); + }); + + logSuccess( + `${generatedCount.models} Models, ${generatedCount.repositories} Repos, ${generatedCount.useCases} Use Cases, ${generatedCount.mappers} Mappers, ${generatedCount.providers} Providers generados con Mustache` + ); + return generatedCount; +} + +/** Renderiza un template Mustache e incrementa el contador correspondiente. */ +function renderTemplate( + templatesDir: string, + templateName: string, + viewData: unknown, + destPath: string, + counter: GeneratedCount, + key: keyof GeneratedCount +): void { + const templatePath = path.join(templatesDir, templateName); + if (fs.existsSync(templatePath)) { + const template = fs.readFileSync(templatePath, 'utf8'); + const output = mustache.render(template, viewData); + fs.writeFileSync(destPath, output); + counter[key]++; + } +} diff --git a/src/generators/dto.generator.ts b/src/generators/dto.generator.ts new file mode 100644 index 0000000..fce38e2 --- /dev/null +++ b/src/generators/dto.generator.ts @@ -0,0 +1,62 @@ +import { execSync } from 'child_process'; +import fs from 'fs-extra'; +import path from 'path'; +import { logStep, logSuccess, logError, logInfo } from '../utils/logger'; + +/** Invoca `openapi-generator-cli` para generar DTOs en un directorio temporal. */ +export function generateCode(swaggerFile: string, templatesDir: string): string { + logStep('Generando código desde OpenAPI...'); + + const tempDir = path.join(process.cwd(), '.temp-generated'); + + 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); + } +} + +/** Copia los DTOs generados desde el directorio temporal al directorio de salida. */ +export 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`); +} diff --git a/src/generators/report.generator.ts b/src/generators/report.generator.ts new file mode 100644 index 0000000..5a786d0 --- /dev/null +++ b/src/generators/report.generator.ts @@ -0,0 +1,32 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { logStep, logSuccess } from '../utils/logger'; +import type { SwaggerAnalysis, GenerationReport } from '../types'; + +/** Genera y persiste el reporte `generation-report.json` con las estadísticas del proceso. */ +export 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; +} diff --git a/src/swagger/analyzer.ts b/src/swagger/analyzer.ts new file mode 100644 index 0000000..98764b4 --- /dev/null +++ b/src/swagger/analyzer.ts @@ -0,0 +1,31 @@ +import fs from 'fs-extra'; +import yaml from 'js-yaml'; +import { logStep, logInfo, logError } from '../utils/logger'; +import type { SwaggerAnalysis } from '../types'; + +/** Parsea un archivo OpenAPI/Swagger y extrae tags, paths y el documento completo. */ +export 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); + } +} diff --git a/src/types/cli.types.ts b/src/types/cli.types.ts new file mode 100644 index 0000000..be76cb3 --- /dev/null +++ b/src/types/cli.types.ts @@ -0,0 +1,11 @@ +/** + * Opciones recibidas desde la línea de comandos (Commander). + * Desacoplada del framework CLI para permitir su uso desde un backend u otro entrypoint. + */ +export interface CliOptions { + input: string; + output: string; + templates: string; + skipInstall?: boolean; + dryRun?: boolean; +} diff --git a/src/types/generation.types.ts b/src/types/generation.types.ts new file mode 100644 index 0000000..5b88149 --- /dev/null +++ b/src/types/generation.types.ts @@ -0,0 +1,27 @@ +/** + * Contadores acumulativos de artefactos generados durante el proceso. + */ +export interface GeneratedCount { + models: number; + repositories: number; + mappers: number; + useCases: number; + providers: number; +} + +/** + * Reporte final de generación que se persiste como `generation-report.json`. + */ +export interface GenerationReport { + timestamp: string; + tags: number; + endpoints: number; + outputDirectory: string; + structure: { + dtos: number; + repositories: number; + mappers: number; + useCases: number; + providers: number; + }; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..7b54850 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,8 @@ +/** + * @module types + * @description Barrel que re-exporta todos los tipos e interfaces compartidos del proyecto. + */ +export * from './cli.types'; +export * from './swagger.types'; +export * from './openapi.types'; +export * from './generation.types'; diff --git a/src/types/openapi.types.ts b/src/types/openapi.types.ts new file mode 100644 index 0000000..9b59ce6 --- /dev/null +++ b/src/types/openapi.types.ts @@ -0,0 +1,83 @@ +/** + * Representación simplificada de un schema de componente OpenAPI. + * Se utiliza para generar modelos (entidades) y mappers. + */ +export interface OpenApiSchema { + properties?: Record< + string, + { + type?: string; + description?: string; + $ref?: string; + items?: { $ref?: string }; + } + >; + required?: string[]; + description?: string; +} + +/** + * Representación de una operación OpenAPI (GET, POST, etc.) dentro de un path. + * Contiene la información necesaria para generar repositorios y casos de uso. + */ +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 }; + }; + } + >; + } + >; +} + +/** + * Operación normalizada y lista para ser consumida por los templates Mustache. + * Cada instancia representa un endpoint agrupado bajo un tag del API. + */ +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; +} diff --git a/src/types/swagger.types.ts b/src/types/swagger.types.ts new file mode 100644 index 0000000..95d7e2b --- /dev/null +++ b/src/types/swagger.types.ts @@ -0,0 +1,9 @@ +/** + * Resultado del análisis de un archivo OpenAPI/Swagger. + * Contiene las estructuras crudas extraídas del spec para su posterior procesamiento. + */ +export interface SwaggerAnalysis { + tags: unknown[]; + paths: Record; + swagger: unknown; +} diff --git a/src/utils/filesystem.ts b/src/utils/filesystem.ts new file mode 100644 index 0000000..959b8be --- /dev/null +++ b/src/utils/filesystem.ts @@ -0,0 +1,31 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { logSuccess, logInfo } from './logger'; + +/** Crea la estructura de directorios necesaria para Clean Architecture (idempotente). */ +export 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'); +} + +/** Elimina un directorio temporal y todo su contenido. */ +export function cleanup(tempDir: string): void { + if (fs.existsSync(tempDir)) { + fs.removeSync(tempDir); + logInfo('Archivos temporales eliminados'); + } +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..04e211a --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,43 @@ +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + green: '\x1b[32m', + blue: '\x1b[34m', + yellow: '\x1b[33m', + red: '\x1b[31m', + cyan: '\x1b[36m' +} as const; + +type Color = keyof typeof colors; + +/** Imprime un mensaje en consola con el color ANSI indicado. */ +export function log(message: string, color: Color = 'reset'): void { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +/** Imprime un mensaje de éxito (verde). */ +export function logSuccess(message: string): void { + log(`✅ ${message}`, 'green'); +} + +/** Imprime un mensaje informativo (azul). */ +export function logInfo(message: string): void { + log(`ℹ️ ${message}`, 'blue'); +} + +/** Imprime un mensaje de advertencia (amarillo). */ +export function logWarning(message: string): void { + log(`⚠️ ${message}`, 'yellow'); +} + +/** Imprime un mensaje de error (rojo). */ +export function logError(message: string): void { + log(`❌ ${message}`, 'red'); +} + +/** Imprime un encabezado de paso/etapa (cian). */ +export function logStep(message: string): void { + log(`\n🚀 ${message}`, 'cyan'); +} + +export { colors }; diff --git a/src/utils/openapi-generator.ts b/src/utils/openapi-generator.ts new file mode 100644 index 0000000..2233f31 --- /dev/null +++ b/src/utils/openapi-generator.ts @@ -0,0 +1,24 @@ +import { execSync } from 'child_process'; +import { logStep, logSuccess, logError } from './logger'; + +/** Verifica si `openapi-generator-cli` está disponible en el PATH. */ +export function checkOpenApiGenerator(): boolean { + try { + execSync('openapi-generator-cli version', { stdio: 'ignore' }); + return true; + } catch (_error) { + return false; + } +} + +/** Instala `@openapitools/openapi-generator-cli` de forma global vía npm. */ +export 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); + } +} diff --git a/src/utils/type-mapper.ts b/src/utils/type-mapper.ts new file mode 100644 index 0000000..f128eb2 --- /dev/null +++ b/src/utils/type-mapper.ts @@ -0,0 +1,14 @@ +/** Traduce un tipo primitivo de OpenAPI/Swagger al equivalente TypeScript. */ +export 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'; +} diff --git a/tsconfig.json b/tsconfig.json index e413ed6..6573c44 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "moduleResolution": "node", "resolveJsonModule": true }, - "include": ["main.ts"], + "include": ["main.ts", "src/**/*.ts"], "exclude": ["node_modules", "dist"] }