Skip to content

Commit 8974294

Browse files
committed
feat: hide regex in error when msg is used with the j rule
1 parent 4f90bb9 commit 8974294

2 files changed

Lines changed: 175 additions & 17 deletions

File tree

packages/nodejs-lib/src/validation/ajv/ajv.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,22 @@ describe('fingerprint for different keywords', () => {
363363
data: { email: 'INVALID@EMAIL.COM' },
364364
fingerprint: 'Contact.email pattern:^[a-z]+$',
365365
},
366+
{
367+
inputName: 'Contact',
368+
schema: {
369+
type: 'object',
370+
properties: {
371+
email: {
372+
type: 'string',
373+
pattern: '^[a-z]+$',
374+
errorMessages: { pattern: 'must be lowercase' },
375+
},
376+
},
377+
required: ['email'],
378+
},
379+
data: { email: 'INVALID@EMAIL.COM' },
380+
fingerprint: 'Contact.email pattern:must be lowercase',
381+
},
366382
{
367383
inputName: 'Record',
368384
schema: {
@@ -373,6 +389,22 @@ describe('fingerprint for different keywords', () => {
373389
data: { status: 'unknown' },
374390
fingerprint: 'Record.status enum:active,inactive',
375391
},
392+
{
393+
inputName: 'Record',
394+
schema: {
395+
type: 'object',
396+
properties: {
397+
status: {
398+
type: 'string',
399+
enum: ['active', 'inactive'],
400+
errorMessages: { enum: 'must be active or inactive' },
401+
},
402+
},
403+
required: ['status'],
404+
},
405+
data: { status: 'unknown' },
406+
fingerprint: 'Record.status enum:must be active or inactive',
407+
},
376408
{
377409
inputName: 'Person',
378410
schema: {
@@ -437,3 +469,108 @@ describe('fingerprint for different keywords', () => {
437469
},
438470
)
439471
})
472+
473+
describe('error params with custom msg', () => {
474+
test('should drop params when a custom msg overrides the rule', () => {
475+
const schemaWithMsg = j.string().regex(/^[0-9]{2}$/, { msg: 'is not a valid Oompa-loompa' })
476+
const [err] = schemaWithMsg.getValidationResult('abc')
477+
expect(err!.data).toMatchInlineSnapshot(`
478+
{
479+
"errors": [
480+
{
481+
"instancePath": "",
482+
"keyword": "pattern",
483+
"message": "is not a valid Oompa-loompa",
484+
"params": {},
485+
"schemaPath": "#/pattern",
486+
},
487+
],
488+
"fingerprint": "Object pattern:is not a valid Oompa-loompa",
489+
"inputName": "Object",
490+
}
491+
`)
492+
expect(err!.data.errors[0]!.params).toEqual({})
493+
})
494+
495+
test('should preserve params when no custom msg is provided', () => {
496+
const plainSchema = j.string().regex(/^[0-9]{2}$/)
497+
498+
const [err] = plainSchema.getValidationResult('abc')
499+
500+
expect(err).toMatchInlineSnapshot(`
501+
[AjvValidationError: Object must match pattern "^[0-9]{2}$"
502+
Input: abc]
503+
`)
504+
expect(err!.data).toMatchInlineSnapshot(`
505+
{
506+
"errors": [
507+
{
508+
"instancePath": "",
509+
"keyword": "pattern",
510+
"message": "must match pattern "^[0-9]{2}$"",
511+
"params": {
512+
"pattern": "^[0-9]{2}$",
513+
},
514+
"schemaPath": "#/pattern",
515+
},
516+
],
517+
"fingerprint": "Object pattern:^[0-9]{2}$",
518+
"inputName": "Object",
519+
}
520+
`)
521+
})
522+
523+
test('should drop params for nested property with custom msg', () => {
524+
const schema = j.object<{ foo: string }>({
525+
foo: j.string().pattern('^abc$', { msg: 'must equal "abc"' }),
526+
})
527+
528+
const [err] = schema.getValidationResult({ foo: 'def' })
529+
530+
expect(err).toMatchInlineSnapshot(`
531+
[AjvValidationError: Object.foo must equal "abc"
532+
Input: { foo: 'def' }]
533+
`)
534+
expect(err!.data).toMatchInlineSnapshot(`
535+
{
536+
"errors": [
537+
{
538+
"instancePath": ".foo",
539+
"keyword": "pattern",
540+
"message": "must equal "abc"",
541+
"params": {},
542+
"schemaPath": "#/properties/foo/pattern",
543+
},
544+
],
545+
"fingerprint": "Object.foo pattern:must equal "abc"",
546+
"inputName": "Object",
547+
}
548+
`)
549+
})
550+
551+
test('should drop params for enum with custom msg', () => {
552+
const schema = j.enum(['foo', 'bar'], { msg: 'must be foo or bar' })
553+
554+
const [err] = schema.getValidationResult('baz')
555+
556+
expect(err).toMatchInlineSnapshot(`
557+
[AjvValidationError: Object must be foo or bar
558+
Input: baz]
559+
`)
560+
expect(err!.data).toMatchInlineSnapshot(`
561+
{
562+
"errors": [
563+
{
564+
"instancePath": "",
565+
"keyword": "enum",
566+
"message": "must be foo or bar",
567+
"params": {},
568+
"schemaPath": "#/enum",
569+
},
570+
],
571+
"fingerprint": "Object enum:must be foo or bar",
572+
"inputName": "Object",
573+
}
574+
`)
575+
})
576+
})

packages/nodejs-lib/src/validation/ajv/jSchema.ts

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1694,7 +1694,11 @@ function executeValidation<OUT>(
16941694

16951695
// Build fingerprint before applyImprovementsOnErrorMessages: after it, /items/0/name becomes
16961696
// .items[0].name, embedding the index into the segment and making it harder to strip without regex
1697-
const fingerprint = buildAjvErrorFingerprint(errors[0], inputName)
1697+
const fingerprint = buildAjvErrorFingerprint(
1698+
errors[0],
1699+
inputName,
1700+
resolveCustomErrorMessage(builtSchema, errors[0]),
1701+
)
16981702

16991703
applyImprovementsOnErrorMessages(errors, builtSchema)
17001704

@@ -1731,34 +1735,51 @@ function applyImprovementsOnErrorMessages(
17311735

17321736
filterNullableAnyOfErrors(errors, schema)
17331737

1734-
const { errorMessages } = schema
1735-
17361738
for (const error of errors) {
1737-
const errorMessage = getErrorMessageForInstancePath(schema, error.instancePath, error.keyword)
1738-
1739-
if (errorMessage) {
1740-
error.message = errorMessage
1741-
} else if (errorMessages?.[error.keyword]) {
1742-
error.message = errorMessages[error.keyword]
1743-
} else {
1744-
const unwrapped = unwrapNullableAnyOf(schema)
1745-
if (unwrapped?.errorMessages?.[error.keyword]) {
1746-
error.message = unwrapped.errorMessages[error.keyword]
1747-
}
1739+
const customMessage = resolveCustomErrorMessage(schema, error)
1740+
1741+
if (customMessage) {
1742+
error.message = customMessage
1743+
// A custom `msg` signals "the underlying rule param is an implementation detail".
1744+
// Drop params so consumers (e.g. HTTP responses, Sentry payloads) don't surface
1745+
// the raw regex/limit/etc. — the custom message is now the canonical rule label.
1746+
error.params = {}
17481747
}
17491748

17501749
error.instancePath = error.instancePath.replaceAll(/\/(\d+)/g, `[$1]`).replaceAll('/', '.')
17511750
}
17521751
}
17531752

1753+
/**
1754+
* Looks up the user-provided custom error message (set via `{ msg }` / `{ name }`)
1755+
* for a given AJV error, walking the schema along the error's instancePath and
1756+
* falling back to top-level `errorMessages` (including through nullable wrappers).
1757+
* Returns undefined if no custom message was registered for the failing keyword.
1758+
*/
1759+
function resolveCustomErrorMessage(schema: JsonSchema, error: ErrorObject): string | undefined {
1760+
const byPath = getErrorMessageForInstancePath(schema, error.instancePath, error.keyword)
1761+
if (byPath) return byPath
1762+
if (schema.errorMessages?.[error.keyword]) return schema.errorMessages[error.keyword]
1763+
const unwrapped = unwrapNullableAnyOf(schema)
1764+
return unwrapped?.errorMessages?.[error.keyword]
1765+
}
1766+
17541767
/**
17551768
* Groups repeated validation errors by rule rather than by unique request content.
17561769
* Excludes instance-specific data like record IDs and array indices.
1770+
*
1771+
* When a custom error message is registered for the failing rule, prefer it over
1772+
* the raw param value (e.g. regex source) — it is both stable across regex tweaks
1773+
* and avoids leaking the underlying pattern into Sentry fingerprints.
17571774
*/
1758-
function buildAjvErrorFingerprint(e: ErrorObject, inputName: string): string {
1759-
const value = Object.values(e.params || {})[0]
1775+
function buildAjvErrorFingerprint(
1776+
e: ErrorObject,
1777+
inputName: string,
1778+
customMessage: string | undefined,
1779+
): string {
1780+
const ruleValue = customMessage ?? Object.values(e.params || {})[0]
17601781
let rule = e.keyword
1761-
if (value !== undefined) rule += `:${value}`
1782+
if (ruleValue !== undefined) rule += `:${ruleValue}`
17621783
const path = e.instancePath
17631784
.split('/')
17641785
.filter(s => s && isNaN(Number(s)))

0 commit comments

Comments
 (0)