feat: add example validation and mismatch reporting for OpenAPI schemas
This commit is contained in:
27
main.ts
27
main.ts
@@ -27,6 +27,7 @@ import {
|
|||||||
import { generateReport } from './src/generators/report.generator';
|
import { generateReport } from './src/generators/report.generator';
|
||||||
import { lintGeneratedFiles } from './src/generators/lint.generator';
|
import { lintGeneratedFiles } from './src/generators/lint.generator';
|
||||||
import { findEnvironmentFile, parseApiKeys } from './src/utils/environment-finder';
|
import { findEnvironmentFile, parseApiKeys } from './src/utils/environment-finder';
|
||||||
|
import { getExampleMismatches, clearExampleMismatches } from './src/utils/example-validator';
|
||||||
import { askApiKeysForTags, askSelectionFilter } from './src/utils/prompt';
|
import { askApiKeysForTags, askSelectionFilter } from './src/utils/prompt';
|
||||||
import {
|
import {
|
||||||
loadConfig,
|
loadConfig,
|
||||||
@@ -146,6 +147,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createDirectoryStructure(options.output);
|
createDirectoryStructure(options.output);
|
||||||
|
clearExampleMismatches();
|
||||||
|
|
||||||
// ── SELECTION: tags and endpoints ─────────────────────────────────────────
|
// ── SELECTION: tags and endpoints ─────────────────────────────────────────
|
||||||
let selectionFilter: SelectionFilter = {};
|
let selectionFilter: SelectionFilter = {};
|
||||||
@@ -216,6 +218,26 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
cleanup(tempDir);
|
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 = {
|
const noLintResult: LintResult = {
|
||||||
prettier: { ran: false, filesFormatted: 0 },
|
prettier: { ran: false, filesFormatted: 0 },
|
||||||
eslint: { ran: false, filesFixed: 0 }
|
eslint: { ran: false, filesFixed: 0 }
|
||||||
@@ -234,6 +256,11 @@ async function main(): Promise<void> {
|
|||||||
console.log(` - Use Cases: ${report.structure.useCases}`);
|
console.log(` - Use Cases: ${report.structure.useCases}`);
|
||||||
console.log(` - Providers: ${report.structure.providers}`);
|
console.log(` - Providers: ${report.structure.providers}`);
|
||||||
console.log(` - Mocks: ${report.structure.mocks}`);
|
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`);
|
console.log(`\n📁 Files generated in: ${colors.cyan}${options.output}${colors.reset}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from 'path';
|
|||||||
import mustache from 'mustache';
|
import mustache from 'mustache';
|
||||||
import { logStep, logSuccess, logDetail } from '../utils/logger';
|
import { logStep, logSuccess, logDetail } from '../utils/logger';
|
||||||
import { mapSwaggerTypeToTs } from '../utils/type-mapper';
|
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 { resolveMockValue } from '../utils/mock-value-resolver';
|
||||||
import type {
|
import type {
|
||||||
SwaggerAnalysis,
|
SwaggerAnalysis,
|
||||||
@@ -253,7 +253,8 @@ export function generateCleanArchitecture(
|
|||||||
tsType = `${rawProperties[k].items.$ref.split('/').pop()}[]`;
|
tsType = `${rawProperties[k].items.$ref.split('/').pop()}[]`;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name: k,
|
name: safePropertyName(k),
|
||||||
|
originalName: k,
|
||||||
dataType: tsType,
|
dataType: tsType,
|
||||||
description: rawProperties[k].description || '',
|
description: rawProperties[k].description || '',
|
||||||
required: requiredProps.includes(k)
|
required: requiredProps.includes(k)
|
||||||
@@ -343,8 +344,8 @@ export function generateCleanArchitecture(
|
|||||||
|
|
||||||
// DTO mock — values resolved from raw schema (example, format, type)
|
// DTO mock — values resolved from raw schema (example, format, type)
|
||||||
const dtoMockVarsMap = Object.keys(rawProperties).map((k) => ({
|
const dtoMockVarsMap = Object.keys(rawProperties).map((k) => ({
|
||||||
name: k,
|
name: safePropertyName(k),
|
||||||
mockValue: resolveMockValue(k, rawProperties[k], 'dto')
|
mockValue: resolveMockValue(k, rawProperties[k], 'dto', schemaName)
|
||||||
}));
|
}));
|
||||||
const dtoMockImports = [...referencedTypes].filter(Boolean).map((name) => ({
|
const dtoMockImports = [...referencedTypes].filter(Boolean).map((name) => ({
|
||||||
classname: name,
|
classname: name,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { logStep, logSuccess } from '../utils/logger';
|
import { logStep, logSuccess } from '../utils/logger';
|
||||||
|
import { getExampleMismatches } from '../utils/example-validator';
|
||||||
import type { SwaggerAnalysis, GenerationReport, LintResult } from '../types';
|
import type { SwaggerAnalysis, GenerationReport, LintResult } from '../types';
|
||||||
|
|
||||||
/** Counts files ending with `.mock.ts` in a directory (returns 0 if directory does not exist). */
|
/** 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 };
|
return { name: t.name, description: t.description || '', endpoints: endpointCount };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exampleMismatches = getExampleMismatches();
|
||||||
|
|
||||||
const report: GenerationReport = {
|
const report: GenerationReport = {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
tags: analysis.tags.length,
|
tags: analysis.tags.length,
|
||||||
@@ -48,6 +51,10 @@ export function generateReport(
|
|||||||
tagDetails,
|
tagDetails,
|
||||||
outputDirectory: outputDir,
|
outputDirectory: outputDir,
|
||||||
linting: lintResult,
|
linting: lintResult,
|
||||||
|
warnings: {
|
||||||
|
exampleMismatches: exampleMismatches.map((m) => ({ ...m })),
|
||||||
|
total: exampleMismatches.length
|
||||||
|
},
|
||||||
structure: {
|
structure: {
|
||||||
dtos: fs.readdirSync(path.join(outputDir, 'data/dtos')).length,
|
dtos: fs.readdirSync(path.join(outputDir, 'data/dtos')).length,
|
||||||
repositories: fs.readdirSync(path.join(outputDir, 'data/repositories')).length,
|
repositories: fs.readdirSync(path.join(outputDir, 'data/repositories')).length,
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ export interface TagDetail {
|
|||||||
endpoints: number;
|
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`.
|
* Final generation report persisted as `generation-report.json`.
|
||||||
*/
|
*/
|
||||||
@@ -38,6 +51,10 @@ export interface GenerationReport {
|
|||||||
tagDetails: TagDetail[];
|
tagDetails: TagDetail[];
|
||||||
outputDirectory: string;
|
outputDirectory: string;
|
||||||
linting: LintResult;
|
linting: LintResult;
|
||||||
|
warnings: {
|
||||||
|
exampleMismatches: ExampleMismatchEntry[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
structure: {
|
structure: {
|
||||||
dtos: number;
|
dtos: number;
|
||||||
repositories: number;
|
repositories: number;
|
||||||
|
|||||||
103
src/utils/example-validator.ts
Normal file
103
src/utils/example-validator.ts
Normal file
@@ -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: <value> }` 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<ExampleMismatch> {
|
||||||
|
return mismatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears all recorded mismatches (call before each generation run). */
|
||||||
|
export function clearExampleMismatches(): void {
|
||||||
|
mismatches = [];
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { validateExample, registerMismatch } from './example-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a TypeScript literal string to use as a mock value for a single schema property.
|
* Resolves a TypeScript literal string to use as a mock value for a single schema property.
|
||||||
*
|
*
|
||||||
@@ -7,6 +9,7 @@
|
|||||||
* @param propName Property name (used for format heuristics such as "email").
|
* @param propName Property name (used for format heuristics such as "email").
|
||||||
* @param prop Raw OpenAPI property definition.
|
* @param prop Raw OpenAPI property definition.
|
||||||
* @param context 'dto' generates `mockFooDto()`, 'model' generates `mockFooModel()`.
|
* @param context 'dto' generates `mockFooDto()`, 'model' generates `mockFooModel()`.
|
||||||
|
* @param schemaName Parent schema name (used for mismatch reporting).
|
||||||
*/
|
*/
|
||||||
export function resolveMockValue(
|
export function resolveMockValue(
|
||||||
propName: string,
|
propName: string,
|
||||||
@@ -18,7 +21,8 @@ export function resolveMockValue(
|
|||||||
$ref?: string;
|
$ref?: string;
|
||||||
items?: { $ref?: string; type?: string };
|
items?: { $ref?: string; type?: string };
|
||||||
},
|
},
|
||||||
context: 'dto' | 'model' = 'dto'
|
context: 'dto' | 'model' = 'dto',
|
||||||
|
schemaName = 'unknown'
|
||||||
): string {
|
): string {
|
||||||
const suffix = context === 'dto' ? 'Dto' : 'Model';
|
const suffix = context === 'dto' ? 'Dto' : 'Model';
|
||||||
|
|
||||||
@@ -43,8 +47,22 @@ export function resolveMockValue(
|
|||||||
return typeof first === 'string' ? `'${first}'` : String(first);
|
return typeof first === 'string' ? `'${first}'` : String(first);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Example value from the swagger spec (highest fidelity)
|
// 5. Example value — validated and coerced if needed
|
||||||
if (prop.example !== undefined) return formatLiteral(prop.example);
|
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)
|
// 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-time') return `'2024-01-01T00:00:00.000Z'`;
|
||||||
|
|||||||
@@ -29,3 +29,81 @@ export function toCamelCase(name: string): string {
|
|||||||
const pascal = toPascalCase(name);
|
const pascal = toPascalCase(name);
|
||||||
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user