From 2db6e95b1c08c1372ddd2011b165f8ae6ec40592 Mon Sep 17 00:00:00 2001 From: didavila Date: Fri, 27 Mar 2026 14:34:30 +0100 Subject: [PATCH] feat: enhance DTO generation and organization by tag --- main.ts | 18 +- src/generators/clean-arch.generator.ts | 511 ++++++++++++-------- src/generators/dto.generator.ts | 74 ++- templates/api.repository.contract.mustache | 2 +- templates/api.repository.impl.mock.mustache | 4 +- templates/api.repository.impl.mustache | 10 +- templates/api.repository.impl.spec.mustache | 4 +- templates/api.use-cases.contract.mustache | 2 +- templates/api.use-cases.impl.mustache | 4 +- templates/api.use-cases.impl.spec.mustache | 4 +- templates/api.use-cases.mock.mustache | 4 +- templates/mapper.mustache | 4 +- templates/mapper.spec.mustache | 4 +- templates/model-entity.mustache | 2 +- templates/model.mock.mustache | 4 +- templates/repository.provider.mock.mustache | 4 +- templates/repository.provider.mustache | 4 +- templates/use-cases.provider.mock.mustache | 4 +- templates/use-cases.provider.mustache | 4 +- 19 files changed, 411 insertions(+), 256 deletions(-) diff --git a/main.ts b/main.ts index aa59d93..2e52e68 100755 --- a/main.ts +++ b/main.ts @@ -20,7 +20,9 @@ import { analyzeSwagger } from './src/swagger/analyzer'; import { generateCode, organizeFiles, addDtoImports } from './src/generators/dto.generator'; import { generateCleanArchitecture, - extractTagsWithOperations + extractTagsWithOperations, + buildTagsMapFromAnalysis, + buildSchemaTagMap } from './src/generators/clean-arch.generator'; import { generateReport } from './src/generators/report.generator'; import { lintGeneratedFiles } from './src/generators/lint.generator'; @@ -193,14 +195,24 @@ async function main(): Promise { // ────────────────────────────────────────────────────────────────────────── const tempDir = generateCode(options.input, options.templates); - organizeFiles(tempDir, options.output); + + // Compute schema→tag map before organizeFiles so DTOs land in the right subfolder + const tagsMapForSchema = buildTagsMapFromAnalysis(analysis, selectionFilter); + const schemaTagMap = buildSchemaTagMap( + (analysis.swagger as { components?: { schemas?: Record } }).components + ?.schemas || {}, + tagsMapForSchema + ); + + organizeFiles(tempDir, options.output, schemaTagMap); addDtoImports(options.output); generateCleanArchitecture( analysis, options.output, options.templates, tagApiKeyMap, - selectionFilter + selectionFilter, + schemaTagMap ); cleanup(tempDir); diff --git a/src/generators/clean-arch.generator.ts b/src/generators/clean-arch.generator.ts index d7e8fb3..82a1c8d 100644 --- a/src/generators/clean-arch.generator.ts +++ b/src/generators/clean-arch.generator.ts @@ -59,181 +59,14 @@ export function extractTagsWithOperations(analysis: SwaggerAnalysis): TagSummary return [...map.values()]; } -/** Generates all Clean Architecture artefacts (models, mappers, repos, use cases, providers) using Mustache. */ -export function generateCleanArchitecture( +/** + * Builds and returns the tagsMap from the swagger analysis, applying the optional selection filter. + * Exported so callers (e.g. main.ts) can compute it before organizeFiles runs. + */ +export function buildTagsMapFromAnalysis( analysis: SwaggerAnalysis, - outputDir: string, - templatesDir: string, - tagApiKeyMap: Record = {}, selectionFilter: SelectionFilter = {} -): GeneratedCount { - logStep('Generating Clean Architecture artefacts using Mustache...'); - const generatedCount: GeneratedCount = { - models: 0, - repositories: 0, - mappers: 0, - useCases: 0, - providers: 0, - mocks: 0, - specs: 0 - }; - - const schemas = - (analysis.swagger as { components?: { schemas?: Record } }).components - ?.schemas || {}; - - // 1. Generate Models, Entities and Mappers from 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 = `${rawProperties[k].items.$ref.split('/').pop()}[]`; - } - return { - name: k, - dataType: tsType, - description: rawProperties[k].description || '', - required: requiredProps.includes(k) - }; - }); - - // Collect imports for types referenced via $ref in properties - const referencedTypes = new Set(); - Object.values(rawProperties).forEach((prop) => { - if (prop.$ref) { - referencedTypes.add(prop.$ref.split('/').pop() || ''); - } else if (prop.type === 'array' && prop.items?.$ref) { - referencedTypes.add(prop.items.$ref.split('/').pop() || ''); - } - }); - const modelImports = [...referencedTypes] - .filter(Boolean) - .map((name) => ({ classname: name, classFilename: toCamelCase(name) })); - - const modelViewData = { - models: [ - { - model: { - classname: baseName, - classFilename: toCamelCase(baseName), - classVarName: toCamelCase(baseName), - description: schemaObj.description || '', - imports: modelImports, - vars: varsMap - } - } - ], - allModels: [{ model: { vars: varsMap } }] - }; - - const mapperViewData = { - ...modelViewData, - apiInfo: { - apis: [ - { - operations: { - classname: baseName, - classFilename: toCamelCase(baseName), - classVarName: toCamelCase(baseName) - } - } - ] - } - }; - - // 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', `${toCamelCase(baseName)}.model.ts`); - fs.writeFileSync(destPath, output); - generatedCount.models++; - logDetail('generate', `model-entity → ${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', `${toCamelCase(baseName)}.mapper.ts`); - fs.writeFileSync(destPath, output); - generatedCount.mappers++; - } - - // DTO mock — values resolved from raw schema (example, format, type) - const dtoMockVarsMap = Object.keys(rawProperties).map((k) => ({ - name: k, - mockValue: resolveMockValue(k, rawProperties[k], 'dto') - })); - const dtoMockImports = [...referencedTypes] - .filter(Boolean) - .map((name) => ({ classname: name, classFilename: toCamelCase(name) })); - - const dtoMockViewData = { - models: [ - { - model: { - classname: baseName, - classFilename: toCamelCase(baseName), - classVarName: toCamelCase(baseName), - mockImports: dtoMockImports, - vars: dtoMockVarsMap - } - } - ] - }; - - renderTemplate( - templatesDir, - 'dto.mock.mustache', - dtoMockViewData, - path.join(outputDir, 'data/dtos', `${toCamelCase(baseName)}.dto.mock.ts`), - generatedCount, - 'mocks' - ); - - // Model mock — delegates to mapper + DTO mock (no property values needed) - renderTemplate( - templatesDir, - 'model.mock.mustache', - modelViewData, - path.join(outputDir, 'entities/models', `${toCamelCase(baseName)}.model.mock.ts`), - generatedCount, - 'mocks' - ); - - // Model spec - renderTemplate( - templatesDir, - 'model-entity.spec.mustache', - modelViewData, - path.join(outputDir, 'entities/models', `${toCamelCase(baseName)}.model.spec.ts`), - generatedCount, - 'specs' - ); - - // Mapper spec - renderTemplate( - templatesDir, - 'mapper.spec.mustache', - mapperViewData, - path.join(outputDir, 'data/mappers', `${toCamelCase(baseName)}.mapper.spec.ts`), - generatedCount, - 'specs' - ); - }); - - // 2. Generate Use Cases and Repositories from Paths/Tags +): Record { const tagsMap: Record = {}; Object.keys(analysis.paths).forEach((pathKey) => { @@ -334,10 +167,266 @@ export function generateCleanArchitecture( }); } + return tagsMap; +} + +/** + * Maps each schema basename to the tag subfolder it belongs to. + * Schemas used by exactly one tag → that tag's camelCase name. + * Schemas used by 0 or multiple tags → 'shared'. + */ +export function buildSchemaTagMap( + schemas: Record, + tagsMap: Record +): Record { + const result: Record = {}; + Object.keys(schemas).forEach((schemaName) => { + const baseName = schemaName.replace(/Dto$/, ''); + const tagsUsing: string[] = []; + Object.keys(tagsMap).forEach((tag) => { + const used = tagsMap[tag].some( + (op) => + op.returnType === baseName || + op.returnType === `${baseName}[]` || + op.allParams.some((p) => p.dataType === baseName || p.dataType === `${baseName}[]`) + ); + if (used) tagsUsing.push(tag); + }); + result[baseName] = tagsUsing.length === 1 ? toCamelCase(tagsUsing[0]) : 'shared'; + }); + return result; +} + +/** Generates all Clean Architecture artefacts (models, mappers, repos, use cases, providers) using Mustache. */ +export function generateCleanArchitecture( + analysis: SwaggerAnalysis, + outputDir: string, + templatesDir: string, + tagApiKeyMap: Record = {}, + selectionFilter: SelectionFilter = {}, + precomputedSchemaTagMap: Record = {} +): GeneratedCount { + logStep('Generating Clean Architecture artefacts using Mustache...'); + const generatedCount: GeneratedCount = { + models: 0, + repositories: 0, + mappers: 0, + useCases: 0, + providers: 0, + mocks: 0, + specs: 0 + }; + + const schemas = + (analysis.swagger as { components?: { schemas?: Record } }).components + ?.schemas || {}; + + // Build tagsMap first — needed to compute schemaTagMap before the schema loop + const tagsMap = buildTagsMapFromAnalysis(analysis, selectionFilter); + + // Map each schema basename → tag subfolder ('shared' if used by 0 or >1 tags) + const schemaTagMap = + Object.keys(precomputedSchemaTagMap).length > 0 + ? precomputedSchemaTagMap + : buildSchemaTagMap(schemas, tagsMap); + + // 1. Generate Models, Entities and Mappers from Schemas + Object.keys(schemas).forEach((schemaName) => { + const baseName = schemaName.replace(/Dto$/, ''); + const tagFilename = schemaTagMap[baseName] || 'shared'; + + 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 = `${rawProperties[k].items.$ref.split('/').pop()}[]`; + } + return { + name: k, + dataType: tsType, + description: rawProperties[k].description || '', + required: requiredProps.includes(k) + }; + }); + + // Collect imports for types referenced via $ref in properties + const referencedTypes = new Set(); + Object.values(rawProperties).forEach((prop) => { + if (prop.$ref) { + referencedTypes.add(prop.$ref.split('/').pop() || ''); + } else if (prop.type === 'array' && prop.items?.$ref) { + referencedTypes.add(prop.items.$ref.split('/').pop() || ''); + } + }); + const modelImports = [...referencedTypes].filter(Boolean).map((name) => ({ + classname: name, + classFilename: toCamelCase(name), + tagFilename: schemaTagMap[name] || 'shared' + })); + + const modelViewData = { + tagFilename, + models: [ + { + model: { + classname: baseName, + classFilename: toCamelCase(baseName), + classVarName: toCamelCase(baseName), + description: schemaObj.description || '', + imports: modelImports, + vars: varsMap + } + } + ], + allModels: [{ model: { vars: varsMap } }] + }; + + const mapperViewData = { + ...modelViewData, + apiInfo: { + apis: [ + { + operations: { + classname: baseName, + classFilename: toCamelCase(baseName), + classVarName: toCamelCase(baseName), + tagFilename + } + } + ] + } + }; + + // 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', + tagFilename, + `${toCamelCase(baseName)}.model.ts` + ); + fs.ensureDirSync(path.dirname(destPath)); + fs.writeFileSync(destPath, output); + generatedCount.models++; + logDetail('generate', `model-entity → ${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', + tagFilename, + `${toCamelCase(baseName)}.mapper.ts` + ); + fs.ensureDirSync(path.dirname(destPath)); + fs.writeFileSync(destPath, output); + generatedCount.mappers++; + } + + // DTO mock — values resolved from raw schema (example, format, type) + const dtoMockVarsMap = Object.keys(rawProperties).map((k) => ({ + name: k, + mockValue: resolveMockValue(k, rawProperties[k], 'dto') + })); + const dtoMockImports = [...referencedTypes].filter(Boolean).map((name) => ({ + classname: name, + classFilename: toCamelCase(name), + tagFilename: schemaTagMap[name] || 'shared' + })); + + const dtoMockViewData = { + tagFilename, + models: [ + { + model: { + classname: baseName, + classFilename: toCamelCase(baseName), + classVarName: toCamelCase(baseName), + mockImports: dtoMockImports, + vars: dtoMockVarsMap + } + } + ] + }; + + renderTemplate( + templatesDir, + 'dto.mock.mustache', + dtoMockViewData, + path.join(outputDir, 'data/dtos', tagFilename, `${toCamelCase(baseName)}.dto.mock.ts`), + generatedCount, + 'mocks' + ); + + // Model mock — delegates to mapper + DTO mock (no property values needed) + renderTemplate( + templatesDir, + 'model.mock.mustache', + modelViewData, + path.join( + outputDir, + 'entities/models', + tagFilename, + `${toCamelCase(baseName)}.model.mock.ts` + ), + generatedCount, + 'mocks' + ); + + // Model spec + renderTemplate( + templatesDir, + 'model-entity.spec.mustache', + modelViewData, + path.join( + outputDir, + 'entities/models', + tagFilename, + `${toCamelCase(baseName)}.model.spec.ts` + ), + generatedCount, + 'specs' + ); + + // Mapper spec + renderTemplate( + templatesDir, + 'mapper.spec.mustache', + mapperViewData, + path.join(outputDir, 'data/mappers', tagFilename, `${toCamelCase(baseName)}.mapper.spec.ts`), + generatedCount, + 'specs' + ); + }); + + // 2. Generate Use Cases and Repositories from Paths/Tags // Generate per tag Object.keys(tagsMap).forEach((tag) => { - const returnImports: { classname: string; classFilename: string; classVarName: string }[] = []; - const paramImports: { classname: string; classFilename: string; classVarName: string }[] = []; + const tagFilename = toCamelCase(tag); + const returnImports: { + classname: string; + classFilename: string; + classVarName: string; + tagFilename: string; + }[] = []; + const paramImports: { + classname: string; + classFilename: string; + classVarName: string; + tagFilename: string; + }[] = []; Object.keys(schemas).forEach((s) => { const usedAsReturn = tagsMap[tag].some( @@ -347,7 +436,12 @@ export function generateCleanArchitecture( op.allParams.some((p) => p.dataType === s || p.dataType === `${s}[]`) ); - const entry = { classname: s, classFilename: toCamelCase(s), classVarName: toCamelCase(s) }; + const entry = { + classname: s, + classFilename: toCamelCase(s), + classVarName: toCamelCase(s), + tagFilename: schemaTagMap[s] || 'shared' + }; if (usedAsReturn) { returnImports.push(entry); @@ -363,17 +457,13 @@ export function generateCleanArchitecture( { operations: { classname: toPascalCase(tag), - classFilename: toCamelCase(tag), - classVarName: toCamelCase(tag), + classFilename: tagFilename, + classVarName: tagFilename, constantName: tag.toUpperCase().replace(/[^A-Z0-9]/g, '_'), operation: tagsMap[tag], - // All entity imports (return + param) — for contracts and use-cases imports: [...returnImports, ...paramImports], - // Return-type-only imports — for repo impl (Dto + Entity + Mapper) returnImports, - // Param-only imports — for repo impl (Entity only, no Dto/Mapper) paramImports, - // Environment API key for the repository base URL (e.g. "aprovalmApi") environmentApiKey: tagApiKeyMap[tag] || 'apiUrl' } } @@ -385,7 +475,7 @@ export function generateCleanArchitecture( templatesDir, 'api.use-cases.contract.mustache', apiViewData, - path.join(outputDir, 'domain/use-cases', `${toCamelCase(tag)}.use-cases.contract.ts`), + path.join(outputDir, 'domain/use-cases', tagFilename, `${tagFilename}.use-cases.contract.ts`), generatedCount, 'useCases' ); @@ -394,7 +484,7 @@ export function generateCleanArchitecture( templatesDir, 'api.use-cases.impl.mustache', apiViewData, - path.join(outputDir, 'domain/use-cases', `${toCamelCase(tag)}.use-cases.impl.ts`), + path.join(outputDir, 'domain/use-cases', tagFilename, `${tagFilename}.use-cases.impl.ts`), generatedCount, 'useCases' ); @@ -403,7 +493,12 @@ export function generateCleanArchitecture( templatesDir, 'api.repository.contract.mustache', apiViewData, - path.join(outputDir, 'domain/repositories', `${toCamelCase(tag)}.repository.contract.ts`), + path.join( + outputDir, + 'domain/repositories', + tagFilename, + `${tagFilename}.repository.contract.ts` + ), generatedCount, 'repositories' ); @@ -412,7 +507,7 @@ export function generateCleanArchitecture( templatesDir, 'api.repository.impl.mustache', apiViewData, - path.join(outputDir, 'data/repositories', `${toCamelCase(tag)}.repository.impl.ts`), + path.join(outputDir, 'data/repositories', tagFilename, `${tagFilename}.repository.impl.ts`), generatedCount, 'repositories' ); @@ -421,7 +516,7 @@ export function generateCleanArchitecture( templatesDir, 'use-cases.provider.mustache', apiViewData, - path.join(outputDir, 'di/use-cases', `${toCamelCase(tag)}.use-cases.provider.ts`), + path.join(outputDir, 'di/use-cases', tagFilename, `${tagFilename}.use-cases.provider.ts`), generatedCount, 'providers' ); @@ -430,17 +525,22 @@ export function generateCleanArchitecture( templatesDir, 'repository.provider.mustache', apiViewData, - path.join(outputDir, 'di/repositories', `${toCamelCase(tag)}.repository.provider.ts`), + path.join(outputDir, 'di/repositories', tagFilename, `${tagFilename}.repository.provider.ts`), generatedCount, 'providers' ); - // Mocks — repository impl, use-cases impl, repository provider, use-cases provider + // Mocks renderTemplate( templatesDir, 'api.repository.impl.mock.mustache', apiViewData, - path.join(outputDir, 'data/repositories', `${toCamelCase(tag)}.repository.impl.mock.ts`), + path.join( + outputDir, + 'data/repositories', + tagFilename, + `${tagFilename}.repository.impl.mock.ts` + ), generatedCount, 'mocks' ); @@ -449,7 +549,7 @@ export function generateCleanArchitecture( templatesDir, 'api.use-cases.mock.mustache', apiViewData, - path.join(outputDir, 'domain/use-cases', `${toCamelCase(tag)}.use-cases.mock.ts`), + path.join(outputDir, 'domain/use-cases', tagFilename, `${tagFilename}.use-cases.mock.ts`), generatedCount, 'mocks' ); @@ -458,7 +558,12 @@ export function generateCleanArchitecture( templatesDir, 'repository.provider.mock.mustache', apiViewData, - path.join(outputDir, 'di/repositories', `${toCamelCase(tag)}.repository.provider.mock.ts`), + path.join( + outputDir, + 'di/repositories', + tagFilename, + `${tagFilename}.repository.provider.mock.ts` + ), generatedCount, 'mocks' ); @@ -467,7 +572,12 @@ export function generateCleanArchitecture( templatesDir, 'use-cases.provider.mock.mustache', apiViewData, - path.join(outputDir, 'di/use-cases', `${toCamelCase(tag)}.use-cases.provider.mock.ts`), + path.join( + outputDir, + 'di/use-cases', + tagFilename, + `${tagFilename}.use-cases.provider.mock.ts` + ), generatedCount, 'mocks' ); @@ -477,7 +587,12 @@ export function generateCleanArchitecture( templatesDir, 'api.repository.impl.spec.mustache', apiViewData, - path.join(outputDir, 'data/repositories', `${toCamelCase(tag)}.repository.impl.spec.ts`), + path.join( + outputDir, + 'data/repositories', + tagFilename, + `${tagFilename}.repository.impl.spec.ts` + ), generatedCount, 'specs' ); @@ -487,7 +602,12 @@ export function generateCleanArchitecture( templatesDir, 'api.use-cases.impl.spec.mustache', apiViewData, - path.join(outputDir, 'domain/use-cases', `${toCamelCase(tag)}.use-cases.impl.spec.ts`), + path.join( + outputDir, + 'domain/use-cases', + tagFilename, + `${tagFilename}.use-cases.impl.spec.ts` + ), generatedCount, 'specs' ); @@ -512,6 +632,7 @@ function renderTemplate( if (fs.existsSync(templatePath)) { const template = fs.readFileSync(templatePath, 'utf8'); const output = mustache.render(template, viewData); + fs.ensureDirSync(path.dirname(destPath)); fs.writeFileSync(destPath, output); counter[key]++; logDetail( diff --git a/src/generators/dto.generator.ts b/src/generators/dto.generator.ts index c82819b..7bf68e2 100644 --- a/src/generators/dto.generator.ts +++ b/src/generators/dto.generator.ts @@ -2,6 +2,7 @@ import { execSync } from 'child_process'; import fs from 'fs-extra'; import path from 'path'; import { logStep, logSuccess, logError, logDetail } from '../utils/logger'; +import { toPascalCase } from '../utils/name-formatter'; /** Invokes `openapi-generator-cli` to generate DTOs into a temporary directory. */ export function generateCode(swaggerFile: string, templatesDir: string): string { @@ -35,8 +36,12 @@ export function generateCode(swaggerFile: string, templatesDir: string): string } } -/** Copies the generated DTOs from the temporary directory to the output directory. */ -export function organizeFiles(tempDir: string, outputDir: string): void { +/** Copies the generated DTOs from the temporary directory to the output directory, organised by tag subfolder. */ +export function organizeFiles( + tempDir: string, + outputDir: string, + schemaTagMap: Record = {} +): void { logStep('Organising generated DTO files...'); const sourceDir = path.join(tempDir, 'model'); @@ -49,8 +54,14 @@ export function organizeFiles(tempDir: string, outputDir: string): void { const files = fs.readdirSync(sourceDir).filter((file) => file.endsWith('.dto.ts')); files.forEach((file) => { + // file is like "userResponse.dto.ts" → derive PascalCase schema name to look up tag + const camelName = file.replace('.dto.ts', ''); + const pascalName = toPascalCase(camelName); + const tagFolder = schemaTagMap[pascalName] || 'shared'; + const sourcePath = path.join(sourceDir, file); - const destPath = path.join(destDir, file); + const destPath = path.join(destDir, tagFolder, file); + fs.ensureDirSync(path.dirname(destPath)); fs.copySync(sourcePath, destPath); filesMoved++; logDetail('dto', `${file} → ${path.relative(process.cwd(), destPath)}`); @@ -65,58 +76,69 @@ export function addDtoImports(outputDir: string): void { logStep('Post-processing generated DTOs...'); const dtosDir = path.join(outputDir, 'data/dtos'); - if (!fs.existsSync(dtosDir)) return; - const files = fs.readdirSync(dtosDir).filter((f) => f.endsWith('.dto.ts')); + // Collect all .dto.ts files from all subfolders (1 level deep) + const allFiles: { subfolder: string; file: string; fullPath: string }[] = []; - // Build a map of DTO classname → file base name (without .ts) - const dtoMap: Record = {}; - files.forEach((file) => { - const content = fs.readFileSync(path.join(dtosDir, file), 'utf8'); - const match = content.match(/export interface (\w+)/); - if (match) { - dtoMap[match[1]] = file.replace('.ts', ''); + const entries = fs.readdirSync(dtosDir); + entries.forEach((entry) => { + const entryPath = path.join(dtosDir, entry); + if (fs.statSync(entryPath).isDirectory()) { + fs.readdirSync(entryPath) + .filter((f) => f.endsWith('.dto.ts')) + .forEach((file) => + allFiles.push({ subfolder: entry, file, fullPath: path.join(entryPath, file) }) + ); + } else if (entry.endsWith('.dto.ts')) { + allFiles.push({ subfolder: '', file: entry, fullPath: entryPath }); } }); + // Build map: ClassName → { subfolder, fileBase } + const dtoMap: Record = {}; + allFiles.forEach(({ subfolder, file, fullPath }) => { + const content = fs.readFileSync(fullPath, 'utf8'); + const match = content.match(/export interface (\w+)/); + if (match) dtoMap[match[1]] = { subfolder, fileBase: file.replace('.ts', '') }; + }); + let filesProcessed = 0; - files.forEach((file) => { - const filePath = path.join(dtosDir, file); - const originalContent = fs.readFileSync(filePath, 'utf8'); + allFiles.forEach(({ subfolder, file, fullPath }) => { + const originalContent = fs.readFileSync(fullPath, 'utf8'); let content = originalContent; const selfMatch = content.match(/export interface (\w+)/); const selfName = selfMatch ? selfMatch[1] : ''; - // Normalize Array → T[] (openapi-generator-cli always outputs Array) content = content.replace(/Array<(\w+)>/g, '$1[]'); - // Find all Dto type references in the file body (excluding the interface name itself) const references = new Set(); const typeRegex = /\b(\w+Dto)\b/g; let match; while ((match = typeRegex.exec(content)) !== null) { - if (match[1] !== selfName) { - references.add(match[1]); - } + if (match[1] !== selfName) references.add(match[1]); } - // Build import lines for each referenced type that exists in the dtoMap const imports: string[] = []; references.forEach((ref) => { if (dtoMap[ref]) { - imports.push(`import { ${ref} } from './${dtoMap[ref]}';`); + const { subfolder: refSubfolder, fileBase: refFileBase } = dtoMap[ref]; + const fromDir = subfolder ? path.join(dtosDir, subfolder) : dtosDir; + const toFile = refSubfolder + ? path.join(dtosDir, refSubfolder, refFileBase) + : path.join(dtosDir, refFileBase); + let relPath = path.relative(fromDir, toFile).replace(/\\/g, '/'); + if (!relPath.startsWith('.')) relPath = './' + relPath; + imports.push(`import { ${ref} } from '${relPath}';`); } }); - if (imports.length > 0) { - content = imports.join('\n') + '\n' + content; - } + if (imports.length > 0) content = imports.join('\n') + '\n' + content; if (content !== originalContent) { - fs.writeFileSync(filePath, content); + fs.writeFileSync(fullPath, content); filesProcessed++; logDetail('dto', `Post-processed ${file} (added ${imports.length} import(s))`); } diff --git a/templates/api.repository.contract.mustache b/templates/api.repository.contract.mustache index ee2a2ae..092e9ad 100644 --- a/templates/api.repository.contract.mustache +++ b/templates/api.repository.contract.mustache @@ -4,7 +4,7 @@ import { InjectionToken } from '@angular/core'; import { Observable } from 'rxjs'; {{#imports}} -import { {{classname}} } from '@/entities/models/{{classFilename}}.model'; +import { {{classname}} } from '@/entities/models/{{tagFilename}}/{{classFilename}}.model'; {{/imports}} /** diff --git a/templates/api.repository.impl.mock.mustache b/templates/api.repository.impl.mock.mustache index 040c7e8..c43e2f5 100644 --- a/templates/api.repository.impl.mock.mustache +++ b/templates/api.repository.impl.mock.mustache @@ -4,9 +4,9 @@ import { MockService } from 'ng-mocks'; import { of } from 'rxjs'; -import { {{classname}}RepositoryImpl } from '@/data/repositories/{{classFilename}}.repository.impl'; +import { {{classname}}RepositoryImpl } from '@/data/repositories/{{classFilename}}/{{classFilename}}.repository.impl'; {{#returnImports}} -import { mock{{classname}}Model } from '@/entities/models/{{classFilename}}.model.mock'; +import { mock{{classname}}Model } from '@/entities/models/{{tagFilename}}/{{classFilename}}.model.mock'; {{/returnImports}} export const mock{{classname}}RepositoryImpl = () => diff --git a/templates/api.repository.impl.mustache b/templates/api.repository.impl.mustache index a466595..3fd3db6 100644 --- a/templates/api.repository.impl.mustache +++ b/templates/api.repository.impl.mustache @@ -9,14 +9,14 @@ import { environment } from '@environment'; import { MRepository } from '@mercadona/core/utils/repository'; -import { {{classname}}Repository } from '@/domain/repositories/{{classFilename}}.repository.contract'; +import { {{classname}}Repository } from '@/domain/repositories/{{classFilename}}/{{classFilename}}.repository.contract'; {{#returnImports}} -import { {{classname}}Dto } from '@/dtos/{{classFilename}}.dto'; -import { {{classname}} } from '@/entities/models/{{classFilename}}.model'; -import { {{classVarName}}Mapper } from '@/mappers/{{classFilename}}.mapper'; +import { {{classname}}Dto } from '@/dtos/{{tagFilename}}/{{classFilename}}.dto'; +import { {{classname}} } from '@/entities/models/{{tagFilename}}/{{classFilename}}.model'; +import { {{classVarName}}Mapper } from '@/mappers/{{tagFilename}}/{{classFilename}}.mapper'; {{/returnImports}} {{#paramImports}} -import { {{classname}} } from '@/entities/models/{{classFilename}}.model'; +import { {{classname}} } from '@/entities/models/{{tagFilename}}/{{classFilename}}.model'; {{/paramImports}} /** diff --git a/templates/api.repository.impl.spec.mustache b/templates/api.repository.impl.spec.mustache index 7441c9d..eee29cb 100644 --- a/templates/api.repository.impl.spec.mustache +++ b/templates/api.repository.impl.spec.mustache @@ -6,8 +6,8 @@ import { TestBed } from '@angular/core/testing'; import { {{classname}}RepositoryImpl } from './{{classFilename}}.repository.impl'; {{#returnImports}} -import { mock{{classname}}Dto } from '@/dtos/{{classFilename}}.dto.mock'; -import { mock{{classname}}Model } from '@/entities/models/{{classFilename}}.model.mock'; +import { mock{{classname}}Dto } from '@/dtos/{{tagFilename}}/{{classFilename}}.dto.mock'; +import { mock{{classname}}Model } from '@/entities/models/{{tagFilename}}/{{classFilename}}.model.mock'; {{/returnImports}} describe('{{classname}}RepositoryImpl', () => { diff --git a/templates/api.use-cases.contract.mustache b/templates/api.use-cases.contract.mustache index 7ab4efe..9944662 100644 --- a/templates/api.use-cases.contract.mustache +++ b/templates/api.use-cases.contract.mustache @@ -4,7 +4,7 @@ import { InjectionToken } from '@angular/core'; import { Observable } from 'rxjs'; {{#imports}} -import { {{classname}} } from '@/entities/models/{{classFilename}}.model'; +import { {{classname}} } from '@/entities/models/{{tagFilename}}/{{classFilename}}.model'; {{/imports}} /** diff --git a/templates/api.use-cases.impl.mustache b/templates/api.use-cases.impl.mustache index 140f204..b02ea53 100644 --- a/templates/api.use-cases.impl.mustache +++ b/templates/api.use-cases.impl.mustache @@ -6,9 +6,9 @@ import { Observable } from 'rxjs'; import { {{classname}}UseCases } from './{{classFilename}}.use-cases.contract'; -import { {{constantName}}_REPOSITORY, {{classname}}Repository } from '@/domain/repositories/{{classFilename}}.repository.contract'; +import { {{constantName}}_REPOSITORY, {{classname}}Repository } from '@/domain/repositories/{{classFilename}}/{{classFilename}}.repository.contract'; {{#imports}} -import { {{classname}} } from '@/entities/models/{{classFilename}}.model'; +import { {{classname}} } from '@/entities/models/{{tagFilename}}/{{classFilename}}.model'; {{/imports}} /** diff --git a/templates/api.use-cases.impl.spec.mustache b/templates/api.use-cases.impl.spec.mustache index 14fb9b4..e6f8f82 100644 --- a/templates/api.use-cases.impl.spec.mustache +++ b/templates/api.use-cases.impl.spec.mustache @@ -6,9 +6,9 @@ import { of } from 'rxjs'; import { {{classname}}UseCasesImpl } from './{{classFilename}}.use-cases.impl'; -import { {{constantName}}_REPOSITORY, {{classname}}Repository } from '@/domain/repositories/{{classFilename}}.repository.contract'; +import { {{constantName}}_REPOSITORY, {{classname}}Repository } from '@/domain/repositories/{{classFilename}}/{{classFilename}}.repository.contract'; {{#returnImports}} -import { mock{{classname}}Model } from '@/entities/models/{{classFilename}}.model.mock'; +import { mock{{classname}}Model } from '@/entities/models/{{tagFilename}}/{{classFilename}}.model.mock'; {{/returnImports}} describe('{{classname}}UseCasesImpl', () => { diff --git a/templates/api.use-cases.mock.mustache b/templates/api.use-cases.mock.mustache index 65e1b2d..c36720d 100644 --- a/templates/api.use-cases.mock.mustache +++ b/templates/api.use-cases.mock.mustache @@ -4,9 +4,9 @@ import { MockService } from 'ng-mocks'; import { of } from 'rxjs'; -import { {{classname}}UseCasesImpl } from '@/domain/use-cases/{{classFilename}}.use-cases.impl'; +import { {{classname}}UseCasesImpl } from '@/domain/use-cases/{{classFilename}}/{{classFilename}}.use-cases.impl'; {{#returnImports}} -import { mock{{classname}}Model } from '@/entities/models/{{classFilename}}.model.mock'; +import { mock{{classname}}Model } from '@/entities/models/{{tagFilename}}/{{classFilename}}.model.mock'; {{/returnImports}} export const mock{{classname}}UseCasesImpl = () => diff --git a/templates/mapper.mustache b/templates/mapper.mustache index 418ed34..6400d97 100644 --- a/templates/mapper.mustache +++ b/templates/mapper.mustache @@ -4,8 +4,8 @@ import { MapFromFn } from '@mercadona/common/public'; import { Builder } from '@mercadona/common/utils'; -import { {{classname}}Dto } from '@/dtos/{{classFilename}}.dto'; -import { {{classname}} } from '@/entities/models/{{classFilename}}.model'; +import { {{classname}}Dto } from '@/dtos/{{tagFilename}}/{{classFilename}}.dto'; +import { {{classname}} } from '@/entities/models/{{tagFilename}}/{{classFilename}}.model'; /** * {{classname}} Mapper diff --git a/templates/mapper.spec.mustache b/templates/mapper.spec.mustache index bfe3d69..5821238 100644 --- a/templates/mapper.spec.mustache +++ b/templates/mapper.spec.mustache @@ -2,8 +2,8 @@ {{#model}} import { {{classVarName}}Mapper } from './{{classFilename}}.mapper'; -import { mock{{classname}}Dto } from '@/dtos/{{classFilename}}.dto.mock'; -import { {{classname}} } from '@/entities/models/{{classFilename}}.model'; +import { mock{{classname}}Dto } from '@/dtos/{{tagFilename}}/{{classFilename}}.dto.mock'; +import { {{classname}} } from '@/entities/models/{{tagFilename}}/{{classFilename}}.model'; describe('{{classVarName}}Mapper', () => { {{#vars}} diff --git a/templates/model-entity.mustache b/templates/model-entity.mustache index 34fd5f4..0390722 100644 --- a/templates/model-entity.mustache +++ b/templates/model-entity.mustache @@ -1,7 +1,7 @@ {{#models}} {{#model}} {{#imports}} -import { {{classname}} } from './{{classFilename}}.model'; +import { {{classname}} } from '@/entities/models/{{tagFilename}}/{{classFilename}}.model'; {{/imports}} /** diff --git a/templates/model.mock.mustache b/templates/model.mock.mustache index b9646e7..5e0bcca 100644 --- a/templates/model.mock.mustache +++ b/templates/model.mock.mustache @@ -1,8 +1,8 @@ {{#models}} {{#model}} import { {{classname}} } from './{{classFilename}}.model'; -import { {{classVarName}}Mapper } from '@/mappers/{{classFilename}}.mapper'; -import { mock{{classname}}Dto } from '@/dtos/{{classFilename}}.dto.mock'; +import { {{classVarName}}Mapper } from '@/mappers/{{tagFilename}}/{{classFilename}}.mapper'; +import { mock{{classname}}Dto } from '@/dtos/{{tagFilename}}/{{classFilename}}.dto.mock'; export const mock{{classname}}Model = (overrides: Partial<{{classname}}> = {}): {{classname}} => Object.assign(new {{classname}}(), { diff --git a/templates/repository.provider.mock.mustache b/templates/repository.provider.mock.mustache index f889d57..b1f8569 100644 --- a/templates/repository.provider.mock.mustache +++ b/templates/repository.provider.mock.mustache @@ -3,8 +3,8 @@ {{#operations}} import { Provider } from '@angular/core'; -import { {{constantName}}_REPOSITORY } from '@/domain/repositories/{{classFilename}}.repository.contract'; -import { mock{{classname}}RepositoryImpl } from '@/data/repositories/{{classFilename}}.repository.impl.mock'; +import { {{constantName}}_REPOSITORY } from '@/domain/repositories/{{classFilename}}/{{classFilename}}.repository.contract'; +import { mock{{classname}}RepositoryImpl } from '@/data/repositories/{{classFilename}}/{{classFilename}}.repository.impl.mock'; export function mock{{classname}}Repository(): Provider[] { return [ diff --git a/templates/repository.provider.mustache b/templates/repository.provider.mustache index daae21a..cfb4ded 100644 --- a/templates/repository.provider.mustache +++ b/templates/repository.provider.mustache @@ -3,8 +3,8 @@ {{#operations}} import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core'; -import { {{constantName}}_REPOSITORY } from '@/domain/repositories/{{classFilename}}.repository.contract'; -import { {{classname}}RepositoryImpl } from '@/data/repositories/{{classFilename}}.repository.impl'; +import { {{constantName}}_REPOSITORY } from '@/domain/repositories/{{classFilename}}/{{classFilename}}.repository.contract'; +import { {{classname}}RepositoryImpl } from '@/data/repositories/{{classFilename}}/{{classFilename}}.repository.impl'; /** * {{classname}} Repository Provider diff --git a/templates/use-cases.provider.mock.mustache b/templates/use-cases.provider.mock.mustache index 88abe90..d4f0905 100644 --- a/templates/use-cases.provider.mock.mustache +++ b/templates/use-cases.provider.mock.mustache @@ -3,8 +3,8 @@ {{#operations}} import { Provider } from '@angular/core'; -import { {{constantName}}_USE_CASES } from '@/domain/use-cases/{{classFilename}}.use-cases.contract'; -import { mock{{classname}}UseCasesImpl } from '@/domain/use-cases/{{classFilename}}.use-cases.mock'; +import { {{constantName}}_USE_CASES } from '@/domain/use-cases/{{classFilename}}/{{classFilename}}.use-cases.contract'; +import { mock{{classname}}UseCasesImpl } from '@/domain/use-cases/{{classFilename}}/{{classFilename}}.use-cases.mock'; export function mock{{classname}}UseCases(): Provider[] { return [ diff --git a/templates/use-cases.provider.mustache b/templates/use-cases.provider.mustache index 58a4f0b..19ed974 100644 --- a/templates/use-cases.provider.mustache +++ b/templates/use-cases.provider.mustache @@ -3,8 +3,8 @@ {{#operations}} import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core'; -import { {{constantName}}_USE_CASES } from '@/domain/use-cases/{{classFilename}}.use-cases.contract'; -import { {{classname}}UseCasesImpl } from '@/domain/use-cases/{{classFilename}}.use-cases.impl'; +import { {{constantName}}_USE_CASES } from '@/domain/use-cases/{{classFilename}}/{{classFilename}}.use-cases.contract'; +import { {{classname}}UseCasesImpl } from '@/domain/use-cases/{{classFilename}}/{{classFilename}}.use-cases.impl'; /** * {{classname}} Use Cases Provider