From 917cc3b9cf45cf25319853ef7da268708b53f745 Mon Sep 17 00:00:00 2001 From: didavila Date: Wed, 25 Mar 2026 11:01:21 +0100 Subject: [PATCH 1/3] feat: add mock generation for DTOs, models, and repositories with corresponding templates --- main.ts | 1 + src/generators/clean-arch.generator.ts | 85 ++++++++++++++++++++- src/generators/report.generator.ts | 18 ++++- src/types/generation.types.ts | 2 + src/types/openapi.types.ts | 5 +- src/utils/mock-value-resolver.ts | 70 +++++++++++++++++ templates/api.repository.impl.mock.mustache | 21 +++++ templates/api.use-cases.mock.mustache | 21 +++++ templates/dto.mock.mustache | 16 ++++ templates/model.mock.mustache | 14 ++++ templates/repository.provider.mock.mustache | 20 +++++ templates/use-cases.provider.mock.mustache | 20 +++++ 12 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 src/utils/mock-value-resolver.ts create mode 100644 templates/api.repository.impl.mock.mustache create mode 100644 templates/api.use-cases.mock.mustache create mode 100644 templates/dto.mock.mustache create mode 100644 templates/model.mock.mustache create mode 100644 templates/repository.provider.mock.mustache create mode 100644 templates/use-cases.provider.mock.mustache diff --git a/main.ts b/main.ts index 92861c7..10b09e4 100755 --- a/main.ts +++ b/main.ts @@ -143,6 +143,7 @@ async function main(): Promise { console.log(` - Mappers: ${report.structure.mappers}`); console.log(` - Use Cases: ${report.structure.useCases}`); console.log(` - Providers: ${report.structure.providers}`); + console.log(` - Mocks: ${report.structure.mocks}`); 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 a9e10c8..c6d1d17 100644 --- a/src/generators/clean-arch.generator.ts +++ b/src/generators/clean-arch.generator.ts @@ -4,6 +4,7 @@ import mustache from 'mustache'; import { logStep, logSuccess, logInfo } from '../utils/logger'; import { mapSwaggerTypeToTs } from '../utils/type-mapper'; import { toCamelCase } from '../utils/name-formatter'; +import { resolveMockValue } from '../utils/mock-value-resolver'; import type { SwaggerAnalysis, OpenApiSchema, @@ -72,7 +73,8 @@ export function generateCleanArchitecture( repositories: 0, mappers: 0, useCases: 0, - providers: 0 + providers: 0, + mocks: 0 }; const schemas = @@ -166,6 +168,48 @@ export function generateCleanArchitecture( 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' + ); }); // 2. Generate Use Cases and Repositories from Paths/Tags @@ -357,10 +401,47 @@ export function generateCleanArchitecture( generatedCount, 'providers' ); + + // Mocks — repository impl, use-cases impl, repository provider, use-cases provider + renderTemplate( + templatesDir, + 'api.repository.impl.mock.mustache', + apiViewData, + path.join(outputDir, 'data/repositories', `${toCamelCase(tag)}.repository.impl.mock.ts`), + generatedCount, + 'mocks' + ); + + renderTemplate( + templatesDir, + 'api.use-cases.mock.mustache', + apiViewData, + path.join(outputDir, 'domain/use-cases', `${toCamelCase(tag)}.use-cases.mock.ts`), + generatedCount, + 'mocks' + ); + + renderTemplate( + templatesDir, + 'repository.provider.mock.mustache', + apiViewData, + path.join(outputDir, 'di/repositories', `${toCamelCase(tag)}.repository.provider.mock.ts`), + generatedCount, + 'mocks' + ); + + renderTemplate( + templatesDir, + 'use-cases.provider.mock.mustache', + apiViewData, + path.join(outputDir, 'di/use-cases', `${toCamelCase(tag)}.use-cases.provider.mock.ts`), + generatedCount, + 'mocks' + ); }); logSuccess( - `${generatedCount.models} Models, ${generatedCount.repositories} Repos, ${generatedCount.useCases} Use Cases, ${generatedCount.mappers} Mappers, ${generatedCount.providers} Providers generados con Mustache` + `${generatedCount.models} Models, ${generatedCount.repositories} Repos, ${generatedCount.useCases} Use Cases, ${generatedCount.mappers} Mappers, ${generatedCount.providers} Providers, ${generatedCount.mocks} Mocks generated` ); return generatedCount; } diff --git a/src/generators/report.generator.ts b/src/generators/report.generator.ts index 166bf0f..f8386f7 100644 --- a/src/generators/report.generator.ts +++ b/src/generators/report.generator.ts @@ -3,6 +3,15 @@ import path from 'path'; import { logStep, logSuccess } from '../utils/logger'; import type { SwaggerAnalysis, GenerationReport } from '../types'; +/** Counts files ending with `.mock.ts` in a directory (returns 0 if directory does not exist). */ +function countMockFiles(dir: string): number { + try { + return fs.readdirSync(dir).filter((f) => f.endsWith('.mock.ts')).length; + } catch { + return 0; + } +} + /** Generates and persists the `generation-report.json` file with process statistics. */ export function generateReport(outputDir: string, analysis: SwaggerAnalysis): GenerationReport { logStep('Generating report...'); @@ -19,7 +28,14 @@ export function generateReport(outputDir: string, analysis: SwaggerAnalysis): Ge useCases: fs.readdirSync(path.join(outputDir, 'domain/use-cases')).length, providers: fs.readdirSync(path.join(outputDir, 'di/repositories')).length + - fs.readdirSync(path.join(outputDir, 'di/use-cases')).length + fs.readdirSync(path.join(outputDir, 'di/use-cases')).length, + mocks: + countMockFiles(path.join(outputDir, 'data/dtos')) + + countMockFiles(path.join(outputDir, 'data/repositories')) + + countMockFiles(path.join(outputDir, 'di/repositories')) + + countMockFiles(path.join(outputDir, 'di/use-cases')) + + countMockFiles(path.join(outputDir, 'domain/use-cases')) + + countMockFiles(path.join(outputDir, 'entities/models')) } }; diff --git a/src/types/generation.types.ts b/src/types/generation.types.ts index 56f8d9d..55f5368 100644 --- a/src/types/generation.types.ts +++ b/src/types/generation.types.ts @@ -7,6 +7,7 @@ export interface GeneratedCount { mappers: number; useCases: number; providers: number; + mocks: number; } /** @@ -23,5 +24,6 @@ export interface GenerationReport { mappers: number; useCases: number; providers: number; + mocks: number; }; } diff --git a/src/types/openapi.types.ts b/src/types/openapi.types.ts index a9a1bb3..02498f2 100644 --- a/src/types/openapi.types.ts +++ b/src/types/openapi.types.ts @@ -30,9 +30,12 @@ export interface OpenApiSchema { string, { type?: string; + format?: string; description?: string; + example?: unknown; + enum?: unknown[]; $ref?: string; - items?: { $ref?: string }; + items?: { $ref?: string; type?: string }; } >; required?: string[]; diff --git a/src/utils/mock-value-resolver.ts b/src/utils/mock-value-resolver.ts new file mode 100644 index 0000000..0112c89 --- /dev/null +++ b/src/utils/mock-value-resolver.ts @@ -0,0 +1,70 @@ +/** + * 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()`. + */ +export function resolveMockValue( + propName: string, + prop: { + type?: string; + format?: string; + example?: unknown; + enum?: unknown[]; + $ref?: string; + items?: { $ref?: string; type?: string }; + }, + context: 'dto' | 'model' = 'dto' +): string { + const suffix = context === 'dto' ? 'Dto' : 'Model'; + + // 1. Direct $ref → call the referenced mock factory + if (prop.$ref) { + const refName = prop.$ref.split('/').pop()!; + return `mock${refName}${suffix}()`; + } + + // 2. Array of $ref → wrap referenced mock in an array + if (prop.type === 'array' && prop.items?.$ref) { + const refName = prop.items.$ref.split('/').pop()!; + return `[mock${refName}${suffix}()]`; + } + + // 3. Array of primitives + if (prop.type === 'array') return '[]'; + + // 4. Enum → first declared value + if (prop.enum?.length) { + const first = prop.enum[0]; + return typeof first === 'string' ? `'${first}'` : String(first); + } + + // 5. Example value from the swagger spec (highest fidelity) + if (prop.example !== undefined) return formatLiteral(prop.example); + + // 6. Format-aware fallbacks (when no example is provided) + if (prop.format === 'date-time') return `'2024-01-01T00:00:00.000Z'`; + if (prop.format === 'date') return `'2024-01-01'`; + if (prop.format === 'uuid') return `'00000000-0000-0000-0000-000000000000'`; + if (prop.format === 'uri') return `'https://example.com'`; + if (prop.format === 'email' || propName.toLowerCase().includes('email')) + return `'user@example.com'`; + + // 7. Type defaults + if (prop.type === 'string') return `'value'`; + if (prop.type === 'integer' || prop.type === 'number') return `0`; + if (prop.type === 'boolean') return `false`; + + return 'undefined'; +} + +function formatLiteral(value: unknown): string { + if (typeof value === 'string') return `'${value}'`; + if (typeof value === 'number') return `${value}`; + if (typeof value === 'boolean') return `${value}`; + return `'${String(value)}'`; +} diff --git a/templates/api.repository.impl.mock.mustache b/templates/api.repository.impl.mock.mustache new file mode 100644 index 0000000..040c7e8 --- /dev/null +++ b/templates/api.repository.impl.mock.mustache @@ -0,0 +1,21 @@ +{{#apiInfo}} +{{#apis}} +{{#operations}} +import { MockService } from 'ng-mocks'; +import { of } from 'rxjs'; + +import { {{classname}}RepositoryImpl } from '@/data/repositories/{{classFilename}}.repository.impl'; +{{#returnImports}} +import { mock{{classname}}Model } from '@/entities/models/{{classFilename}}.model.mock'; +{{/returnImports}} + +export const mock{{classname}}RepositoryImpl = () => + MockService({{classname}}RepositoryImpl, { +{{#operation}} + {{nickname}}: () => of({{#isListContainer}}[mock{{returnBaseType}}Model()]{{/isListContainer}}{{^isListContainer}}{{#returnBaseType}}mock{{returnBaseType}}Model(){{/returnBaseType}}{{^returnBaseType}}undefined{{/returnBaseType}}{{/isListContainer}}), +{{/operation}} + }); + +{{/operations}} +{{/apis}} +{{/apiInfo}} diff --git a/templates/api.use-cases.mock.mustache b/templates/api.use-cases.mock.mustache new file mode 100644 index 0000000..65e1b2d --- /dev/null +++ b/templates/api.use-cases.mock.mustache @@ -0,0 +1,21 @@ +{{#apiInfo}} +{{#apis}} +{{#operations}} +import { MockService } from 'ng-mocks'; +import { of } from 'rxjs'; + +import { {{classname}}UseCasesImpl } from '@/domain/use-cases/{{classFilename}}.use-cases.impl'; +{{#returnImports}} +import { mock{{classname}}Model } from '@/entities/models/{{classFilename}}.model.mock'; +{{/returnImports}} + +export const mock{{classname}}UseCasesImpl = () => + MockService({{classname}}UseCasesImpl, { +{{#operation}} + {{nickname}}: () => of({{#isListContainer}}[mock{{returnBaseType}}Model()]{{/isListContainer}}{{^isListContainer}}{{#returnBaseType}}mock{{returnBaseType}}Model(){{/returnBaseType}}{{^returnBaseType}}undefined{{/returnBaseType}}{{/isListContainer}}), +{{/operation}} + }); + +{{/operations}} +{{/apis}} +{{/apiInfo}} diff --git a/templates/dto.mock.mustache b/templates/dto.mock.mustache new file mode 100644 index 0000000..2c19193 --- /dev/null +++ b/templates/dto.mock.mustache @@ -0,0 +1,16 @@ +{{#models}} +{{#model}} +{{#mockImports}} +import { mock{{classname}}Dto } from './{{classFilename}}.dto.mock'; +{{/mockImports}} +import { {{classname}}Dto } from './{{classFilename}}.dto'; + +export const mock{{classname}}Dto = (overrides: Partial<{{classname}}Dto> = {}): {{classname}}Dto => ({ +{{#vars}} + {{name}}: {{{mockValue}}}, +{{/vars}} + ...overrides +}); + +{{/model}} +{{/models}} diff --git a/templates/model.mock.mustache b/templates/model.mock.mustache new file mode 100644 index 0000000..b9646e7 --- /dev/null +++ b/templates/model.mock.mustache @@ -0,0 +1,14 @@ +{{#models}} +{{#model}} +import { {{classname}} } from './{{classFilename}}.model'; +import { {{classVarName}}Mapper } from '@/mappers/{{classFilename}}.mapper'; +import { mock{{classname}}Dto } from '@/dtos/{{classFilename}}.dto.mock'; + +export const mock{{classname}}Model = (overrides: Partial<{{classname}}> = {}): {{classname}} => + Object.assign(new {{classname}}(), { + ...{{classVarName}}Mapper(mock{{classname}}Dto()), + ...overrides + }); + +{{/model}} +{{/models}} diff --git a/templates/repository.provider.mock.mustache b/templates/repository.provider.mock.mustache new file mode 100644 index 0000000..f889d57 --- /dev/null +++ b/templates/repository.provider.mock.mustache @@ -0,0 +1,20 @@ +{{#apiInfo}} +{{#apis}} +{{#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'; + +export function mock{{classname}}Repository(): Provider[] { + return [ + { + provide: {{constantName}}_REPOSITORY, + useFactory: () => mock{{classname}}RepositoryImpl() + } + ]; +} + +{{/operations}} +{{/apis}} +{{/apiInfo}} diff --git a/templates/use-cases.provider.mock.mustache b/templates/use-cases.provider.mock.mustache new file mode 100644 index 0000000..88abe90 --- /dev/null +++ b/templates/use-cases.provider.mock.mustache @@ -0,0 +1,20 @@ +{{#apiInfo}} +{{#apis}} +{{#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'; + +export function mock{{classname}}UseCases(): Provider[] { + return [ + { + provide: {{constantName}}_USE_CASES, + useFactory: () => mock{{classname}}UseCasesImpl() + } + ]; +} + +{{/operations}} +{{/apis}} +{{/apiInfo}} -- 2.49.1 From 9c14a070c6d94add869f93272fb90f21984516fa Mon Sep 17 00:00:00 2001 From: didavila Date: Wed, 25 Mar 2026 11:10:08 +0100 Subject: [PATCH 2/3] feat: add linting option and implement linting for generated TypeScript files --- main.ts | 6 ++ src/generators/lint.generator.ts | 106 +++++++++++++++++++++++++++++++ src/types/cli.types.ts | 1 + 3 files changed, 113 insertions(+) create mode 100644 src/generators/lint.generator.ts diff --git a/main.ts b/main.ts index 10b09e4..6f5331f 100755 --- a/main.ts +++ b/main.ts @@ -15,6 +15,7 @@ import { extractTagsWithOperations } from './src/generators/clean-arch.generator'; import { generateReport } from './src/generators/report.generator'; +import { lintGeneratedFiles } from './src/generators/lint.generator'; import { findEnvironmentFile, parseApiKeys } from './src/utils/environment-finder'; import { askApiKeysForTags, askSelectionFilter } from './src/utils/prompt'; import type { SelectionFilter } from './src/types'; @@ -37,6 +38,7 @@ program .option('-t, --templates ', 'Custom templates directory', path.join(__dirname, 'templates')) .option('--skip-install', 'Skip dependency installation') .option('--dry-run', 'Simulate without generating files') + .option('--skip-lint', 'Skip post-generation linting and formatting') .option('-s, --select-endpoints', 'Interactively select which tags and endpoints to generate') .parse(process.argv); @@ -132,6 +134,10 @@ async function main(): Promise { ); cleanup(tempDir); + if (!options.skipLint) { + lintGeneratedFiles(options.output); + } + const report = generateReport(options.output, analysis); console.log('\n' + '='.repeat(60)); diff --git a/src/generators/lint.generator.ts b/src/generators/lint.generator.ts new file mode 100644 index 0000000..2ac81f1 --- /dev/null +++ b/src/generators/lint.generator.ts @@ -0,0 +1,106 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { spawnSync } from 'child_process'; +import { logStep, logSuccess, logWarning, logInfo } from '../utils/logger'; + +/** + * Walks up the directory tree from `startDir` to find the nearest + * directory containing a `package.json` (i.e. the project root). + * Returns `null` if none is found before reaching the filesystem root. + */ +function findProjectRoot(startDir: string): string | null { + let current = path.resolve(startDir); + while (true) { + if (fs.existsSync(path.join(current, 'package.json'))) return current; + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + +/** + * Collects all `.ts` files recursively inside a directory. + */ +function collectTsFiles(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + const results: string[] = []; + fs.readdirSync(dir).forEach((entry) => { + const full = path.join(dir, entry); + if (fs.statSync(full).isDirectory()) { + results.push(...collectTsFiles(full)); + } else if (entry.endsWith('.ts')) { + results.push(full); + } + }); + return results; +} + +/** + * Runs a command synchronously and returns whether it succeeded. + * Prints stdout/stderr to the console only on failure. + */ +function run(cmd: string, args: string[], cwd: string): boolean { + const result = spawnSync(cmd, args, { cwd, encoding: 'utf8', shell: true }); + if (result.status !== 0) { + if (result.stderr) process.stderr.write(result.stderr); + if (result.stdout) process.stdout.write(result.stdout); + return false; + } + return true; +} + +/** + * Runs Prettier and ESLint (--fix) on all generated `.ts` files inside `outputDir`. + * Both tools are looked up via `npx` in the nearest project root so that the + * target Angular project's own configuration and plugins are used. + * + * - Prettier: always attempted; logs a warning if not found. + * - ESLint: optional; silently skipped if no config is found in the project root. + */ +export function lintGeneratedFiles(outputDir: string): void { + logStep('Linting generated files...'); + + const projectRoot = findProjectRoot(outputDir); + if (!projectRoot) { + logWarning('Could not locate a project root (package.json). Skipping lint.'); + return; + } + logInfo(` Project root: ${projectRoot}`); + + const files = collectTsFiles(outputDir); + if (files.length === 0) { + logWarning('No TypeScript files found in output directory. Skipping lint.'); + return; + } + + const relativePaths = files.map((f) => path.relative(projectRoot, f)); + + // --- Prettier --- + const prettierOk = run('npx', ['prettier', '--write', ...relativePaths], projectRoot); + if (prettierOk) { + logSuccess(`Prettier formatted ${files.length} files`); + } else { + logWarning('Prettier not available or encountered errors. Skipping formatting.'); + } + + // --- ESLint (only if a config exists in the project root) --- + const hasEslintConfig = + fs.existsSync(path.join(projectRoot, 'eslint.config.js')) || + fs.existsSync(path.join(projectRoot, 'eslint.config.mjs')) || + fs.existsSync(path.join(projectRoot, '.eslintrc.js')) || + fs.existsSync(path.join(projectRoot, '.eslintrc.json')) || + fs.existsSync(path.join(projectRoot, '.eslintrc.yml')) || + fs.existsSync(path.join(projectRoot, '.eslintrc.yaml')); + + if (!hasEslintConfig) { + logWarning('No ESLint config found in project root. Skipping ESLint fix.'); + return; + } + + const eslintOk = run('npx', ['eslint', '--fix', ...relativePaths], projectRoot); + if (eslintOk) { + logSuccess(`ESLint fixed ${files.length} files`); + } else { + logWarning('ESLint reported errors that could not be auto-fixed. Review the output above.'); + } +} diff --git a/src/types/cli.types.ts b/src/types/cli.types.ts index bea1609..bc2605a 100644 --- a/src/types/cli.types.ts +++ b/src/types/cli.types.ts @@ -9,4 +9,5 @@ export interface CliOptions { skipInstall?: boolean; dryRun?: boolean; selectEndpoints?: boolean; + skipLint?: boolean; } -- 2.49.1 From 99658800ed9342c948f55df8bc4fe0b724f588eb Mon Sep 17 00:00:00 2001 From: didavila Date: Wed, 25 Mar 2026 11:15:37 +0100 Subject: [PATCH 3/3] feat: ensure required fields are marked with '!' in generated model entities --- templates/model-entity.mustache | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/model-entity.mustache b/templates/model-entity.mustache index 65fbc8f..34fd5f4 100644 --- a/templates/model-entity.mustache +++ b/templates/model-entity.mustache @@ -16,7 +16,7 @@ export class {{classname}} { * {{description}} */ {{/description}} - {{name}}{{^required}}?{{/required}}: {{{dataType}}}; + {{name}}{{^required}}?{{/required}}{{#required}}!{{/required}}: {{{dataType}}}; {{/vars}} } -- 2.49.1