diff --git a/generate.js b/generate.js index 707db06..84e888c 100755 --- a/generate.js +++ b/generate.js @@ -4,8 +4,12 @@ const { execSync } = require('child_process'); const fs = require('fs-extra'); const path = require('path'); const yaml = require('js-yaml'); +const mustache = require('mustache'); const { program } = require('commander'); +// Desactivar escape HTML para que los literales < y > generen tipos genéricos válidos de TS. +mustache.escape = function(text) { return text; }; + // Colores para console (sin dependencias externas) const colors = { reset: '\x1b[0m', @@ -137,9 +141,10 @@ function generateCode(swaggerFile, templatesDir) { const command = `openapi-generator-cli generate \ -i "${swaggerFile}" \ -g typescript-angular \ + --global-property models \ -t "${templatesDir}" \ -o "${tempDir}" \ - --additional-properties=ngVersion=17.0.0,withInterfaces=true,providedInRoot=false,supportsES6=true,modelPropertyNaming=camelCase`; + --additional-properties=ngVersion=17.0.0,modelFileSuffix=.dto`; execSync(command, { stdio: 'inherit' }); logSuccess('Código generado correctamente'); @@ -154,51 +159,287 @@ function generateCode(swaggerFile, templatesDir) { } } -// Organizar archivos según Clean Architecture +// Organizar archivos según Clean Architecture (DTOs) function organizeFiles(tempDir, outputDir) { - logStep('Organizando archivos en estructura Clean Architecture...'); - - const moves = [ - { from: 'model', to: path.join(outputDir, 'data/dtos'), pattern: '*.dto.ts' }, - { from: 'model', to: path.join(outputDir, 'entities/models'), pattern: '*.model.ts' }, - { from: 'api', to: path.join(outputDir, 'domain/repositories'), pattern: '*.repository.contract.ts' }, - { from: 'api', to: path.join(outputDir, 'data/repositories'), pattern: '*.repository.impl.ts' }, - { from: 'api', to: path.join(outputDir, 'data/repositories'), pattern: '*.repository.mock.ts' }, - { from: 'api', to: path.join(outputDir, 'data/mappers'), pattern: '*.mapper.ts' }, - { from: 'api', to: path.join(outputDir, 'domain/use-cases'), pattern: '*.use-cases.contract.ts' }, - { from: 'api', to: path.join(outputDir, 'domain/use-cases'), pattern: '*.use-cases.impl.ts' }, - { from: 'providers', to: path.join(outputDir, 'di/repositories'), pattern: '*.repository.provider.ts' }, - { from: 'providers', to: path.join(outputDir, 'di/use-cases'), pattern: '*.use-cases.provider.ts' } - ]; + logStep('Organizando archivos DTO generados...'); + const sourceDir = path.join(tempDir, 'model'); + const destDir = path.join(outputDir, 'data/dtos'); let filesMoved = 0; - moves.forEach(({ from, to, pattern }) => { - const sourceDir = path.join(tempDir, from); + if (fs.existsSync(sourceDir)) { + fs.ensureDirSync(destDir); - if (fs.existsSync(sourceDir)) { - fs.ensureDirSync(to); + 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); - const files = fs.readdirSync(sourceDir).filter(file => { - if (pattern.includes('*')) { - const regex = new RegExp(pattern.replace('*', '.*')); - return regex.test(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) { + const typeMap = { + 'integer': 'number', + 'string': 'string', + 'boolean': 'boolean', + 'number': 'number', + 'array': 'Array', + 'object': 'any' + }; + return typeMap[type] || 'any'; +} + +// Generar Clean Architecture con Mustache +function generateCleanArchitecture(analysis, outputDir, templatesDir) { + logStep('Generando artefactos de Clean Architecture usando Mustache...'); + let 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 = 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(); + } 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 } - return file.endsWith(pattern); - }); - - files.forEach(file => { - const sourcePath = path.join(sourceDir, file); - const destPath = path.join(to, file); - - fs.copySync(sourcePath, destPath); - filesMoved++; - logInfo(` ${file} → ${path.relative(process.cwd(), destPath)}`); - }); + }], + // 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 = {}; - logSuccess(`${filesMoved} archivos organizados correctamente`); + // 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 => ({ + 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(); + 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(); + returnBaseType = returnType; + } else if (responseSchema.type === 'array' && responseSchema.items?.$ref) { + returnBaseType = responseSchema.items.$ref.split('/').pop(); + 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) => ({ ...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, arr) => ({ 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 = []; + 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 @@ -222,7 +463,8 @@ function generateReport(outputDir, analysis) { 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 + 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 } }; @@ -285,6 +527,9 @@ async function main() { // Organizar archivos organizeFiles(tempDir, options.output); + // Crear componentes Clean Architecture con nuestro script de Mustache + generateCleanArchitecture(analysis, options.output, options.templates); + // Limpiar cleanup(tempDir); @@ -300,6 +545,7 @@ async function main() { 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`); } diff --git a/package.json b/package.json index fed694d..1de7a73 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "chalk": "^4.1.2", "commander": "^11.1.0", "fs-extra": "^11.2.0", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "mustache": "^4.2.0" }, "engines": { "node": ">=14.0.0"