From e0446d4939f0fd567ea8eeb61e8d9c3edaa0e66a Mon Sep 17 00:00:00 2001 From: didavila Date: Fri, 27 Mar 2026 15:27:03 +0100 Subject: [PATCH] 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; +}