Skip to content

Commit 6876b64

Browse files
kevinccbsgclaude
andcommitted
feat: humanize validation error messages
Use dot notation paths (response.address.city) instead of JSON pointers, rewrite messages for required/type/enum/additionalProperties keywords, and collapse oneOf/anyOf sub-errors into a single summary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7bd193a commit 6876b64

2 files changed

Lines changed: 57 additions & 7 deletions

File tree

src/validator.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,32 +120,58 @@ export class OpenAPIMockValidator {
120120
return { valid: true, errors: [], warnings: existingWarnings };
121121
}
122122

123-
const errors: ValidationError[] = (ajv.errors || []).map((err: Record<string, unknown>) => {
123+
const rawErrors: ValidationError[] = (ajv.errors || []).map((err: Record<string, unknown>) => {
124124
const params = err.params as Record<string, unknown> | undefined;
125+
const instancePath = (err.instancePath as string) || '';
126+
const dotPath = toDotPath(instancePath);
127+
125128
const error: ValidationError = {
126-
path: (err.instancePath as string) || '/',
129+
path: dotPath,
127130
message: (err.message as string) || 'validation failed',
128131
keyword: err.keyword as string,
129132
};
130133

134+
if (err.keyword === 'required') {
135+
const missingProp = params?.missingProperty as string;
136+
error.path = dotPath ? `${dotPath}.${missingProp}` : missingProp;
137+
error.message = 'missing required property';
138+
}
139+
131140
if (err.keyword === 'type') {
132141
error.expected = String(params?.type);
133142
error.received = typeof payload === 'object' && payload !== null
134-
? typeof getValueAtPath(payload, err.instancePath as string)
143+
? typeof getValueAtPath(payload, instancePath)
135144
: typeof payload;
145+
error.message = `expected ${error.expected}, got ${error.received}`;
136146
}
137147

138148
if (err.keyword === 'enum') {
139-
error.expected = (params?.allowedValues as unknown[])?.join(', ');
149+
const allowed = (params?.allowedValues as unknown[]);
150+
error.expected = allowed?.join(', ');
151+
error.message = `must be one of: ${allowed?.map(v => `"${v}"`).join(', ')}`;
140152
}
141153

142154
if (err.keyword === 'additionalProperties') {
143-
error.path = `${err.instancePath}/${params?.additionalProperty}`;
155+
const extra = params?.additionalProperty as string;
156+
error.path = dotPath ? `${dotPath}.${extra}` : extra;
157+
error.message = 'unexpected property';
158+
}
159+
160+
if (err.keyword === 'oneOf') {
161+
error.message = 'does not match any allowed schema (oneOf)';
162+
}
163+
164+
if (err.keyword === 'anyOf') {
165+
error.message = 'does not match any allowed schema (anyOf)';
144166
}
145167

146168
return error;
147169
});
148170

171+
// Collapse oneOf/anyOf: if the final error is a oneOf/anyOf keyword,
172+
// keep only that summary and drop the per-branch sub-errors
173+
const errors = collapseCompositionErrors(rawErrors);
174+
149175
return { valid: false, errors, warnings: existingWarnings };
150176
}
151177

@@ -234,3 +260,27 @@ function getValueAtPath(obj: unknown, path: string): unknown {
234260
}
235261
return current;
236262
}
263+
264+
function toDotPath(instancePath: string): string {
265+
if (!instancePath || instancePath === '/') return 'response';
266+
const parts = instancePath.split('/').filter(Boolean);
267+
const segments = parts.map((p) => /^\d+$/.test(p) ? `[${p}]` : `.${p}`);
268+
return `response${segments.join('')}`;
269+
}
270+
271+
function collapseCompositionErrors(errors: ValidationError[]): ValidationError[] {
272+
if (errors.length <= 1) return errors;
273+
274+
const last = errors[errors.length - 1];
275+
if (last.keyword === 'oneOf' || last.keyword === 'anyOf') {
276+
const prefix = last.path;
277+
// Keep the composition error itself and any errors NOT under that path
278+
return errors.filter((e) => {
279+
if (e === last) return true;
280+
// Drop sub-errors that are children of the composition path
281+
return !e.path.startsWith(prefix);
282+
});
283+
}
284+
285+
return errors;
286+
}

tests/validateResponse.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ describe('validateResponse', () => {
4040
expect(result.valid).toBe(false);
4141
expect(result.errors).toHaveLength(1);
4242
expect(result.errors[0].keyword).toBe('type');
43-
expect(result.errors[0].path).toBe('/id');
43+
expect(result.errors[0].path).toBe('response.id');
4444
});
4545

4646
it('catches wrong type for name (number instead of string)', () => {
@@ -122,7 +122,7 @@ describe('validateResponse', () => {
122122
{ id: 'bad', name: 'Fido' },
123123
]);
124124
expect(result.valid).toBe(false);
125-
expect(result.errors[0].path).toContain('/0');
125+
expect(result.errors[0].path).toContain('[0]');
126126
});
127127
});
128128
});

0 commit comments

Comments
 (0)