From 603feda26d7f55fc52a8b72d7785d2f65315bdf3 Mon Sep 17 00:00:00 2001 From: didavila Date: Fri, 27 Mar 2026 14:09:30 +0100 Subject: [PATCH 1/5] feat: enhance name formatting functions to include PascalCase conversion --- src/generators/clean-arch.generator.ts | 4 ++-- src/utils/name-formatter.ts | 23 +++++++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/generators/clean-arch.generator.ts b/src/generators/clean-arch.generator.ts index 747dd2d..d7e8fb3 100644 --- a/src/generators/clean-arch.generator.ts +++ b/src/generators/clean-arch.generator.ts @@ -3,7 +3,7 @@ import path from 'path'; import mustache from 'mustache'; import { logStep, logSuccess, logDetail } from '../utils/logger'; import { mapSwaggerTypeToTs } from '../utils/type-mapper'; -import { toCamelCase } from '../utils/name-formatter'; +import { toCamelCase, toPascalCase } from '../utils/name-formatter'; import { resolveMockValue } from '../utils/mock-value-resolver'; import type { SwaggerAnalysis, @@ -362,7 +362,7 @@ export function generateCleanArchitecture( apis: [ { operations: { - classname: tag, + classname: toPascalCase(tag), classFilename: toCamelCase(tag), classVarName: toCamelCase(tag), constantName: tag.toUpperCase().replace(/[^A-Z0-9]/g, '_'), diff --git a/src/utils/name-formatter.ts b/src/utils/name-formatter.ts index c6ff94b..32ad139 100644 --- a/src/utils/name-formatter.ts +++ b/src/utils/name-formatter.ts @@ -1,12 +1,31 @@ /** - * Converts a PascalCase name to camelCase by lowercasing the first character. + * Converts a string to PascalCase, handling spaces, hyphens and underscores. + * Used to derive class names from schema/tag names. + * + * @example + * toPascalCase('Product Format') // 'ProductFormat' + * toPascalCase('user-response') // 'UserResponse' + * toPascalCase('UserSchema') // 'UserSchema' + */ +export function toPascalCase(name: string): string { + if (!name) return name; + return name + .split(/[\s\-_]+/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); +} + +/** + * Converts a string to camelCase, handling spaces, hyphens and underscores. * Used to derive class filenames and variable names from schema/tag names. * * @example + * toCamelCase('Product Format') // 'productFormat' * toCamelCase('ProductResponse') // 'productResponse' * toCamelCase('UserSchema') // 'userSchema' */ export function toCamelCase(name: string): string { if (!name) return name; - return name.charAt(0).toLowerCase() + name.slice(1); + const pascal = toPascalCase(name); + return pascal.charAt(0).toLowerCase() + pascal.slice(1); } From 2db6e95b1c08c1372ddd2011b165f8ae6ec40592 Mon Sep 17 00:00:00 2001 From: didavila Date: Fri, 27 Mar 2026 14:34:30 +0100 Subject: [PATCH 2/5] 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 From aab9bf01bb562228f1f532a4309bafcf1cc917dd Mon Sep 17 00:00:00 2001 From: didavila Date: Fri, 27 Mar 2026 14:46:06 +0100 Subject: [PATCH 3/5] feat: sort parameters in buildTagsMapFromAnalysis by required status --- src/generators/clean-arch.generator.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/generators/clean-arch.generator.ts b/src/generators/clean-arch.generator.ts index 82a1c8d..25bc013 100644 --- a/src/generators/clean-arch.generator.ts +++ b/src/generators/clean-arch.generator.ts @@ -123,6 +123,12 @@ export function buildTagsMapFromAnalysis( const hasQueryParams = (op.parameters || []).some((p) => p.in === 'query'); const hasBodyParam = !!op.requestBody; + // Sort: required params first, optional params last (TypeScript requirement) + allParams.sort((a, b) => { + if (a.required === b.required) return 0; + return a.required ? -1 : 1; + }); + tagsMap[tag].push({ nickname: op.operationId || `${method}${pathKey.replace(/\//g, '_')}`, summary: op.summary || '', From e0446d4939f0fd567ea8eeb61e8d9c3edaa0e66a Mon Sep 17 00:00:00 2001 From: didavila Date: Fri, 27 Mar 2026 15:27:03 +0100 Subject: [PATCH 4/5] feat: add example validation and mismatch reporting for OpenAPI schemas --- main.ts | 27 +++++++ src/generators/clean-arch.generator.ts | 9 ++- src/generators/report.generator.ts | 7 ++ src/types/generation.types.ts | 17 ++++ src/utils/example-validator.ts | 103 +++++++++++++++++++++++++ src/utils/mock-value-resolver.ts | 30 +++++-- src/utils/name-formatter.ts | 78 +++++++++++++++++++ 7 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 src/utils/example-validator.ts diff --git a/main.ts b/main.ts index 2e52e68..2f39230 100755 --- a/main.ts +++ b/main.ts @@ -27,6 +27,7 @@ import { import { generateReport } from './src/generators/report.generator'; import { lintGeneratedFiles } from './src/generators/lint.generator'; import { findEnvironmentFile, parseApiKeys } from './src/utils/environment-finder'; +import { getExampleMismatches, clearExampleMismatches } from './src/utils/example-validator'; import { askApiKeysForTags, askSelectionFilter } from './src/utils/prompt'; import { loadConfig, @@ -146,6 +147,7 @@ async function main(): Promise { } createDirectoryStructure(options.output); + clearExampleMismatches(); // ── SELECTION: tags and endpoints ───────────────────────────────────────── let selectionFilter: SelectionFilter = {}; @@ -216,6 +218,26 @@ async function main(): Promise { ); cleanup(tempDir); + // ── EXAMPLE/TYPE MISMATCH WARNINGS ───────────────────────────────────────── + const mismatches = getExampleMismatches(); + if (mismatches.length > 0) { + console.log(''); + logWarning(`${mismatches.length} example/type mismatch(es) detected in OpenAPI schemas:`); + for (const m of mismatches) { + const action = + m.action === 'coerced' + ? `→ coerced to ${JSON.stringify(m.coercedValue)}` + : '→ example ignored, using type default'; + logWarning( + ` ${m.schemaName}.${m.propertyName}: type '${m.declaredType}' but example is ${m.exampleJsType} (${JSON.stringify(m.exampleValue)}) ${action}` + ); + logDetail( + 'VALIDATE', + `${m.schemaName}.${m.propertyName}: declared=${m.declaredType} example=${JSON.stringify(m.exampleValue)} (${m.exampleJsType}) action=${m.action}` + ); + } + } + const noLintResult: LintResult = { prettier: { ran: false, filesFormatted: 0 }, eslint: { ran: false, filesFixed: 0 } @@ -234,6 +256,11 @@ async function main(): Promise { console.log(` - Use Cases: ${report.structure.useCases}`); console.log(` - Providers: ${report.structure.providers}`); console.log(` - Mocks: ${report.structure.mocks}`); + if (report.warnings.total > 0) { + console.log( + `\n ${colors.yellow}⚠️ ${report.warnings.total} example/type mismatch(es) (see above)${colors.reset}` + ); + } console.log(`\n📁 Files generated in: ${colors.cyan}${options.output}${colors.reset}\n`); } diff --git a/src/generators/clean-arch.generator.ts b/src/generators/clean-arch.generator.ts index 25bc013..f864bbe 100644 --- a/src/generators/clean-arch.generator.ts +++ b/src/generators/clean-arch.generator.ts @@ -3,7 +3,7 @@ import path from 'path'; import mustache from 'mustache'; import { logStep, logSuccess, logDetail } from '../utils/logger'; import { mapSwaggerTypeToTs } from '../utils/type-mapper'; -import { toCamelCase, toPascalCase } from '../utils/name-formatter'; +import { toCamelCase, toPascalCase, safePropertyName } from '../utils/name-formatter'; import { resolveMockValue } from '../utils/mock-value-resolver'; import type { SwaggerAnalysis, @@ -253,7 +253,8 @@ export function generateCleanArchitecture( tsType = `${rawProperties[k].items.$ref.split('/').pop()}[]`; } return { - name: k, + name: safePropertyName(k), + originalName: k, dataType: tsType, description: rawProperties[k].description || '', required: requiredProps.includes(k) @@ -343,8 +344,8 @@ export function generateCleanArchitecture( // 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') + name: safePropertyName(k), + mockValue: resolveMockValue(k, rawProperties[k], 'dto', schemaName) })); const dtoMockImports = [...referencedTypes].filter(Boolean).map((name) => ({ classname: name, diff --git a/src/generators/report.generator.ts b/src/generators/report.generator.ts index 9d3f086..87a3597 100644 --- a/src/generators/report.generator.ts +++ b/src/generators/report.generator.ts @@ -1,6 +1,7 @@ import fs from 'fs-extra'; import path from 'path'; import { logStep, logSuccess } from '../utils/logger'; +import { getExampleMismatches } from '../utils/example-validator'; import type { SwaggerAnalysis, GenerationReport, LintResult } from '../types'; /** Counts files ending with `.mock.ts` in a directory (returns 0 if directory does not exist). */ @@ -41,6 +42,8 @@ export function generateReport( return { name: t.name, description: t.description || '', endpoints: endpointCount }; }); + const exampleMismatches = getExampleMismatches(); + const report: GenerationReport = { timestamp: new Date().toISOString(), tags: analysis.tags.length, @@ -48,6 +51,10 @@ export function generateReport( tagDetails, outputDirectory: outputDir, linting: lintResult, + warnings: { + exampleMismatches: exampleMismatches.map((m) => ({ ...m })), + total: exampleMismatches.length + }, structure: { dtos: fs.readdirSync(path.join(outputDir, 'data/dtos')).length, repositories: fs.readdirSync(path.join(outputDir, 'data/repositories')).length, diff --git a/src/types/generation.types.ts b/src/types/generation.types.ts index ee02c5a..23c8c31 100644 --- a/src/types/generation.types.ts +++ b/src/types/generation.types.ts @@ -28,6 +28,19 @@ export interface TagDetail { endpoints: number; } +/** + * A single example/type mismatch detected during mock generation. + */ +export interface ExampleMismatchEntry { + schemaName: string; + propertyName: string; + declaredType: string; + exampleValue: unknown; + exampleJsType: string; + action: 'coerced' | 'ignored'; + coercedValue?: unknown; +} + /** * Final generation report persisted as `generation-report.json`. */ @@ -38,6 +51,10 @@ export interface GenerationReport { tagDetails: TagDetail[]; outputDirectory: string; linting: LintResult; + warnings: { + exampleMismatches: ExampleMismatchEntry[]; + total: number; + }; structure: { dtos: number; repositories: number; diff --git a/src/utils/example-validator.ts b/src/utils/example-validator.ts new file mode 100644 index 0000000..8655266 --- /dev/null +++ b/src/utils/example-validator.ts @@ -0,0 +1,103 @@ +/** + * Validates that OpenAPI `example` values match their declared `type`. + * + * YAML parses unquoted values by native type (e.g. `example: 68131` becomes a JS number + * even when the schema declares `type: string`). This module detects such mismatches, + * coerces them when possible, and accumulates warnings for the generation report. + */ + +export interface ExampleMismatch { + schemaName: string; + propertyName: string; + declaredType: string; + exampleValue: unknown; + exampleJsType: string; + action: 'coerced' | 'ignored'; + coercedValue?: unknown; +} + +// Module-level accumulator — reset between runs via `clearExampleMismatches()`. +let mismatches: ExampleMismatch[] = []; + +/** + * Validates an `example` value against a declared OpenAPI `type`. + * + * @returns `{ valid: true }` when types already match, or + * `{ valid: false, coerced: }` when the value was coerced, or + * `{ valid: false }` when coercion is not possible (caller should ignore the example). + */ +export function validateExample( + declaredType: string | undefined, + example: unknown +): { valid: boolean; coerced?: unknown } { + if (declaredType === undefined) return { valid: true }; + + const jsType = typeof example; + + // ── string declared ────────────────────────────────────────────────────── + if (declaredType === 'string') { + if (jsType === 'string') return { valid: true }; + // number or boolean → coerce to string + if (jsType === 'number' || jsType === 'boolean') { + return { valid: false, coerced: String(example) }; + } + return { valid: false }; + } + + // ── integer / number declared ──────────────────────────────────────────── + if (declaredType === 'integer' || declaredType === 'number') { + if (jsType === 'number') return { valid: true }; + if (jsType === 'string') { + const parsed = Number(example); + if (!Number.isNaN(parsed)) return { valid: false, coerced: parsed }; + return { valid: false }; // unparseable → ignore + } + return { valid: false }; + } + + // ── boolean declared ───────────────────────────────────────────────────── + if (declaredType === 'boolean') { + if (jsType === 'boolean') return { valid: true }; + if (jsType === 'string') { + const lower = (example as string).toLowerCase(); + if (lower === 'true') return { valid: false, coerced: true }; + if (lower === 'false') return { valid: false, coerced: false }; + } + return { valid: false }; // cannot coerce + } + + // Other types (object, array, etc.) — no validation + return { valid: true }; +} + +/** + * Records a mismatch so it can be retrieved later for console warnings and the report. + */ +export function registerMismatch( + schemaName: string, + propertyName: string, + declaredType: string, + exampleValue: unknown, + action: 'coerced' | 'ignored', + coercedValue?: unknown +): void { + mismatches.push({ + schemaName, + propertyName, + declaredType, + exampleValue, + exampleJsType: typeof exampleValue, + action, + coercedValue + }); +} + +/** Returns all recorded mismatches. */ +export function getExampleMismatches(): ReadonlyArray { + return mismatches; +} + +/** Clears all recorded mismatches (call before each generation run). */ +export function clearExampleMismatches(): void { + mismatches = []; +} diff --git a/src/utils/mock-value-resolver.ts b/src/utils/mock-value-resolver.ts index 0112c89..84070aa 100644 --- a/src/utils/mock-value-resolver.ts +++ b/src/utils/mock-value-resolver.ts @@ -1,12 +1,15 @@ +import { validateExample, registerMismatch } from './example-validator'; + /** * Resolves a TypeScript literal string to use as a mock value for a single schema property. * * Priority chain: * $ref mock call → array $ref mock call → enum[0] → example → format fallback → type default * - * @param propName Property name (used for format heuristics such as "email"). - * @param prop Raw OpenAPI property definition. - * @param context 'dto' generates `mockFooDto()`, 'model' generates `mockFooModel()`. + * @param propName Property name (used for format heuristics such as "email"). + * @param prop Raw OpenAPI property definition. + * @param context 'dto' generates `mockFooDto()`, 'model' generates `mockFooModel()`. + * @param schemaName Parent schema name (used for mismatch reporting). */ export function resolveMockValue( propName: string, @@ -18,7 +21,8 @@ export function resolveMockValue( $ref?: string; items?: { $ref?: string; type?: string }; }, - context: 'dto' | 'model' = 'dto' + context: 'dto' | 'model' = 'dto', + schemaName = 'unknown' ): string { const suffix = context === 'dto' ? 'Dto' : 'Model'; @@ -43,8 +47,22 @@ export function resolveMockValue( return typeof first === 'string' ? `'${first}'` : String(first); } - // 5. Example value from the swagger spec (highest fidelity) - if (prop.example !== undefined) return formatLiteral(prop.example); + // 5. Example value — validated and coerced if needed + if (prop.example !== undefined) { + const result = validateExample(prop.type, prop.example); + + if (result.valid) { + return formatLiteral(prop.example); + } + + if (result.coerced !== undefined) { + registerMismatch(schemaName, propName, prop.type!, prop.example, 'coerced', result.coerced); + return formatLiteral(result.coerced); + } + + // Cannot coerce — register and fall through to defaults + registerMismatch(schemaName, propName, prop.type!, prop.example, 'ignored'); + } // 6. Format-aware fallbacks (when no example is provided) if (prop.format === 'date-time') return `'2024-01-01T00:00:00.000Z'`; diff --git a/src/utils/name-formatter.ts b/src/utils/name-formatter.ts index 32ad139..3115681 100644 --- a/src/utils/name-formatter.ts +++ b/src/utils/name-formatter.ts @@ -29,3 +29,81 @@ export function toCamelCase(name: string): string { const pascal = toPascalCase(name); return pascal.charAt(0).toLowerCase() + pascal.slice(1); } + +const JS_RESERVED_WORDS = new Set([ + 'abstract', + 'arguments', + 'await', + 'boolean', + 'break', + 'byte', + 'case', + 'catch', + 'char', + 'class', + 'const', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'double', + 'else', + 'enum', + 'eval', + 'export', + 'extends', + 'false', + 'final', + 'finally', + 'float', + 'for', + 'function', + 'goto', + 'if', + 'implements', + 'import', + 'in', + 'instanceof', + 'int', + 'interface', + 'let', + 'long', + 'native', + 'new', + 'null', + 'package', + 'private', + 'protected', + 'public', + 'return', + 'short', + 'static', + 'super', + 'switch', + 'synchronized', + 'this', + 'throw', + 'throws', + 'transient', + 'true', + 'try', + 'typeof', + 'undefined', + 'var', + 'void', + 'volatile', + 'while', + 'with', + 'yield' +]); + +/** Returns true if the given name is a JS/TS reserved word. */ +export function isReservedWord(name: string): boolean { + return JS_RESERVED_WORDS.has(name); +} + +/** Prefixes reserved words with `_` to produce a safe identifier. */ +export function safePropertyName(name: string): string { + return isReservedWord(name) ? `_${name}` : name; +} From 16ef1ce3e3a9cd64842624844d4f851d6cddf943 Mon Sep 17 00:00:00 2001 From: didavila Date: Fri, 27 Mar 2026 15:40:33 +0100 Subject: [PATCH 5/5] feat: enhance DTO mock generation with dynamic import paths and mock value checks --- src/generators/clean-arch.generator.ts | 21 +++++++++++++++------ templates/dto.mock.mustache | 2 +- templates/model-entity.spec.mustache | 4 ++++ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/generators/clean-arch.generator.ts b/src/generators/clean-arch.generator.ts index f864bbe..af87f78 100644 --- a/src/generators/clean-arch.generator.ts +++ b/src/generators/clean-arch.generator.ts @@ -247,6 +247,7 @@ export function generateCleanArchitecture( const varsMap = Object.keys(rawProperties).map((k) => { let tsType = mapSwaggerTypeToTs(rawProperties[k].type); + const isInlineObject = rawProperties[k].type === 'object' && !rawProperties[k].$ref; if (rawProperties[k].$ref) { tsType = rawProperties[k].$ref.split('/').pop() || 'unknown'; } else if (rawProperties[k].type === 'array' && rawProperties[k].items?.$ref) { @@ -257,7 +258,8 @@ export function generateCleanArchitecture( originalName: k, dataType: tsType, description: rawProperties[k].description || '', - required: requiredProps.includes(k) + required: requiredProps.includes(k), + hasMockValue: !isInlineObject }; }); @@ -347,11 +349,18 @@ export function generateCleanArchitecture( name: safePropertyName(k), mockValue: resolveMockValue(k, rawProperties[k], 'dto', schemaName) })); - const dtoMockImports = [...referencedTypes].filter(Boolean).map((name) => ({ - classname: name, - classFilename: toCamelCase(name), - tagFilename: schemaTagMap[name] || 'shared' - })); + const dtoMockImports = [...referencedTypes].filter(Boolean).map((name) => { + const targetTag = schemaTagMap[name] || 'shared'; + const targetFile = `${toCamelCase(name)}.dto.mock`; + const importPath = + targetTag === tagFilename ? `./${targetFile}` : `../${targetTag}/${targetFile}`; + return { + classname: name, + classFilename: toCamelCase(name), + tagFilename: targetTag, + importPath + }; + }); const dtoMockViewData = { tagFilename, diff --git a/templates/dto.mock.mustache b/templates/dto.mock.mustache index 2c19193..401250a 100644 --- a/templates/dto.mock.mustache +++ b/templates/dto.mock.mustache @@ -1,7 +1,7 @@ {{#models}} {{#model}} {{#mockImports}} -import { mock{{classname}}Dto } from './{{classFilename}}.dto.mock'; +import { mock{{classname}}Dto } from '{{{importPath}}}'; {{/mockImports}} import { {{classname}}Dto } from './{{classFilename}}.dto'; diff --git a/templates/model-entity.spec.mustache b/templates/model-entity.spec.mustache index 2dc0860..e3edd77 100644 --- a/templates/model-entity.spec.mustache +++ b/templates/model-entity.spec.mustache @@ -11,6 +11,7 @@ describe('{{classname}}', () => { }); {{#vars}} +{{#hasMockValue}} it('should allow setting {{name}}', () => { const model = new {{classname}}(); const expected = mock{{classname}}Model(); @@ -19,13 +20,16 @@ describe('{{classname}}', () => { expect(model.{{name}}).toBe(expected.{{name}}); }); +{{/hasMockValue}} {{/vars}} it('should build a valid model from mock', () => { const model = mock{{classname}}Model(); expect(model).toBeInstanceOf({{classname}}); {{#vars}} +{{#hasMockValue}} expect(model.{{name}}).toBeDefined(); +{{/hasMockValue}} {{/vars}} }); });