@jarenjs/validate 0.9.2 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,497 @@
1
+ //@ts-check
2
+
3
+ /**
4
+ * Structured error messages & i18n for the validator.
5
+ *
6
+ * The validation hot path never touches this module: message rendering
7
+ * happens only in collect mode, only over the already-failed set, at
8
+ * conversion time (`convertInternalErrors`) or afterwards
9
+ * (`localizeErrors`). Every failure is identified by a stable message key
10
+ * (`msgid`) plus raw structured `params`; human text is produced by a
11
+ * locale catalog - a plain flat object of closures (or template strings
12
+ * that compile into closures). The built-in English catalog lives here;
13
+ * non-English packs live in `@jarenjs/locales`.
14
+ *
15
+ * The catalog contract itself - template syntax, compilation, parameter
16
+ * rendering - is kernel property (`@jarenjs/core/message`), shared with
17
+ * `@jarenjs/forms` without either package depending on the other. Both
18
+ * compilers are re-exported here so the validator's public surface stays
19
+ * self-contained.
20
+ *
21
+ * The normative spec is `packages/validate/docs/ERROR-MESSAGES.md`:
22
+ * the MessageSpec grammar, the message-key registry, the resolution
23
+ * precedence chain and the `errorMessage` matching semantics.
24
+ */
25
+
26
+ import {
27
+ compileMessageTemplate,
28
+ compileMessageCatalog,
29
+ } from '@jarenjs/core/message';
30
+
31
+ export {
32
+ compileMessageTemplate,
33
+ compileMessageCatalog,
34
+ } from '@jarenjs/core/message';
35
+
36
+ //#region English catalog
37
+
38
+ /**
39
+ * The built-in English catalog: one entry per message key the validator
40
+ * produces, rendering the exact strings of the historical `#convertErrors`
41
+ * if/else chain. Keywords without an entry (`const`, `enum`,
42
+ * `dependentRequired`, ...) fall back to the generic
43
+ * `validation failed for keyword '<keyword>'` - as before.
44
+ * @type {Record<string, string | ((params: object, error?: object) => string)>}
45
+ */
46
+ export const messagesEn = {
47
+ type: (p) => p.types
48
+ ? `must be one of the following types: ${p.types.join(', ')}`
49
+ : `must be ${p.type === 'integer' ? 'an' : 'a'} ${p.type}`,
50
+ required: (p) => p.missingProperty
51
+ ? `must have required property '${p.missingProperty}'`
52
+ : 'must have required properties',
53
+ minimum: 'must be {comparison} {limit}',
54
+ maximum: 'must be {comparison} {limit}',
55
+ exclusiveMinimum: 'must be {comparison} {limit}',
56
+ exclusiveMaximum: 'must be {comparison} {limit}',
57
+ multipleOf: 'must be multiple of {multipleOf}',
58
+ minLength: 'must NOT have fewer than {limit} characters',
59
+ maxLength: 'must NOT have more than {limit} characters',
60
+ pattern: 'must match pattern "{pattern}"',
61
+ additionalProperties: (p) => p.additionalProperty
62
+ ? `must NOT have additional property '${p.additionalProperty}'`
63
+ : 'must NOT have additional properties',
64
+ minProperties: 'must NOT have fewer than {limit} properties',
65
+ maxProperties: 'must NOT have more than {limit} properties',
66
+ minItems: 'must NOT have fewer than {limit} items',
67
+ maxItems: 'must NOT have more than {limit} items',
68
+ uniqueItems: 'must NOT have duplicate items',
69
+ contains: 'must contain at least one valid item',
70
+ items: 'array items are invalid',
71
+ allOf: 'must match all of the subschemas',
72
+ anyOf: 'must match a subschema in anyOf',
73
+ oneOf: 'must match exactly one subschema in oneOf',
74
+ not: 'must NOT match the subschema',
75
+ format: 'must match format "{format}"',
76
+ if: 'must match "if" schema',
77
+ then: 'must match "then" schema',
78
+ else: 'must match "else" schema',
79
+ 'false schema': 'boolean schema false is always invalid',
80
+ $query: (p) => p.code
81
+ ? `'$query' assertion raised ${p.code} at '${p.docPath}'`
82
+ : "must satisfy the '$query' assertion",
83
+ // Query runtime codes reachable through '$query' (JQ2001-class operator
84
+ // errors and the JQ2003 multi-item EBV); an uncovered JQ2xxx code falls
85
+ // back to the '$query' entry above, which renders the same string.
86
+ JQ2001: (p) => `'$query' assertion raised ${p.code} at '${p.docPath}'`,
87
+ JQ2003: (p) => `'$query' assertion raised ${p.code} at '${p.docPath}'`,
88
+ };
89
+
90
+ /** The compiled built-in English catalog (module-level singleton). */
91
+ const EN = compileMessageCatalog(messagesEn);
92
+
93
+ //#endregion
94
+
95
+ //#region Public error record & rendering
96
+
97
+ /**
98
+ * JSON Schema Validation Error
99
+ * Represents a validation error according to the JSON Schema specification.
100
+ * @see https://json-schema.org/draft/2020-12/json-schema-core.html#output
101
+ */
102
+ export class ValidationError {
103
+ /**
104
+ * @param {object} options - Error options
105
+ * @param {string} options.keyword - The keyword that failed validation
106
+ * @param {string} options.instancePath - JSON Pointer to the data location
107
+ * @param {string} options.schemaPath - JSON Pointer to the schema location
108
+ * @param {object} options.params - Keyword-specific parameters
109
+ * @param {string} [options.msgid] - Stable message key resolving this error in a catalog
110
+ * @param {string} [options.message] - Human-readable error message
111
+ */
112
+ constructor(options) {
113
+ this.keyword = options.keyword;
114
+ this.instancePath = options.instancePath || '';
115
+ this.schemaPath = options.schemaPath || '';
116
+ this.params = options.params || {};
117
+ this.msgid = options.msgid || options.keyword;
118
+ this.message = options.message || '';
119
+ }
120
+
121
+ /**
122
+ * Convert error to a plain object
123
+ * @returns {object} Plain object representation
124
+ */
125
+ toJSON() {
126
+ return {
127
+ keyword: this.keyword,
128
+ instancePath: this.instancePath,
129
+ schemaPath: this.schemaPath,
130
+ params: this.params,
131
+ msgid: this.msgid,
132
+ message: this.message,
133
+ };
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Mark an error's message as inline schema-authored text (a MessageSpec
139
+ * without `$msgid`): single-language by definition, never re-rendered by
140
+ * `localizeErrors`. Non-enumerable so it stays out of serialization.
141
+ * @param {ValidationError} error - The error to mark
142
+ */
143
+ function markInlineMessage(error) {
144
+ Object.defineProperty(error, 'inlineMessage', {
145
+ value: true,
146
+ enumerable: false,
147
+ configurable: true,
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Render the message of an error through a catalog - the tail of the
153
+ * resolution precedence chain (no `errorMessage` registry involvement):
154
+ * catalog[msgid], built-in English[msgid], catalog[keyword],
155
+ * built-in English[keyword], then the generic fallback text.
156
+ * @param {ValidationError | {keyword: string, msgid?: string, params?: object}} error - The error to render
157
+ * @param {Readonly<Record<string, (params: object, error?: object) => string>>} [catalog] - A compiled catalog (see {@link compileMessageCatalog})
158
+ * @returns {string} The rendered message
159
+ */
160
+ export function renderErrorMessage(error, catalog = undefined) {
161
+ const msgid = error.msgid || error.keyword;
162
+ const params = error.params || {};
163
+ let render = catalog !== undefined ? catalog[msgid] : undefined;
164
+ if (render === undefined) render = EN[msgid];
165
+ if (render === undefined && catalog !== undefined) render = catalog[error.keyword];
166
+ if (render === undefined) render = EN[error.keyword];
167
+ if (render === undefined) return `validation failed for keyword '${error.keyword}'`;
168
+ return render(params, error);
169
+ }
170
+
171
+ /**
172
+ * Re-render the `message` of every error from its `msgid` + `params`
173
+ * through the given catalog, with built-in English fallback. This is the
174
+ * whole post-hoc i18n story:
175
+ * `localizeErrors(validate(data).errors, compileMessageCatalog(nl))`.
176
+ *
177
+ * Inline schema-authored messages (a MessageSpec without `$msgid`) are
178
+ * single-language by definition and are NOT re-rendered - that is why
179
+ * `$msgid` exists. An error whose `msgid` resolves in no catalog keeps
180
+ * its current message (e.g. the spec's inline fallback text).
181
+ * @param {ValidationError[]} errors - Errors from a collect-mode validation
182
+ * @param {Readonly<Record<string, (params: object, error?: object) => string>>} catalog - A compiled catalog (see {@link compileMessageCatalog})
183
+ * @returns {ValidationError[]} The same array, messages re-rendered
184
+ */
185
+ export function localizeErrors(errors, catalog) {
186
+ for (let i = 0; i < errors.length; ++i) {
187
+ const error = errors[i];
188
+ // @ts-ignore - marker property, non-enumerable
189
+ if (error.inlineMessage === true) continue;
190
+ const msgid = error.msgid || error.keyword;
191
+ const params = error.params || {};
192
+ let render = catalog !== undefined ? catalog[msgid] : undefined;
193
+ if (render === undefined) render = EN[msgid];
194
+ if (render === undefined && catalog !== undefined) render = catalog[error.keyword];
195
+ if (render === undefined) render = EN[error.keyword];
196
+ if (render !== undefined) {
197
+ error.message = render(params, error);
198
+ }
199
+ else if (error.message === '') {
200
+ error.message = `validation failed for keyword '${error.keyword}'`;
201
+ }
202
+ // else: keep the existing message (an unresolvable custom msgid whose
203
+ // text came from the spec's inline fallback).
204
+ }
205
+ return errors;
206
+ }
207
+
208
+ //#endregion
209
+
210
+ //#region 'errorMessage' spec compilation (schema compile time)
211
+
212
+ /**
213
+ * A compiled MessageSpec leaf.
214
+ * @typedef {object} CompiledMessageSpec
215
+ * @property {string|null} msgid - Catalog key to resolve at render time
216
+ * @property {((params: object, error?: object) => string)|null} render - Compiled inline template
217
+ * @property {object|null} params - Author params, merged OVER the error's params
218
+ */
219
+
220
+ /**
221
+ * Is this value a MessageSpec (string form, or object form carrying
222
+ * `$msgid`/`message`) rather than a keyword map?
223
+ * @param {unknown} value - The value to test
224
+ * @returns {boolean}
225
+ */
226
+ function isMessageSpecValue(value) {
227
+ if (typeof value === 'string') return true;
228
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
229
+ return typeof (/** @type {any} */ (value).$msgid) === 'string'
230
+ || typeof (/** @type {any} */ (value).message) === 'string';
231
+ }
232
+
233
+ /**
234
+ * Compile one MessageSpec value; throws on a malformed spec, carrying the
235
+ * schema path (the query-keyword compile-time throw idiom).
236
+ * @param {unknown} spec - The MessageSpec value
237
+ * @param {string} path - The schema path, for error messages
238
+ * @returns {CompiledMessageSpec} The compiled spec
239
+ */
240
+ function compileMessageSpec(spec, path) {
241
+ if (typeof spec === 'string')
242
+ return { msgid: null, render: compileMessageTemplate(spec), params: null };
243
+
244
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec))
245
+ throw new Error(`invalid 'errorMessage' spec at '${path}': a MessageSpec must be a string or an object with '$msgid' and/or 'message'`);
246
+
247
+ const obj = /** @type {any} */ (spec);
248
+ const keys = Object.keys(obj);
249
+ for (let i = 0; i < keys.length; ++i) {
250
+ const key = keys[i];
251
+ if (key !== '$msgid' && key !== 'message' && key !== 'params')
252
+ throw new Error(`invalid 'errorMessage' spec at '${path}': unknown MessageSpec member '${key}'`);
253
+ }
254
+ if (obj.$msgid !== undefined && typeof obj.$msgid !== 'string')
255
+ throw new Error(`invalid 'errorMessage' spec at '${path}': '$msgid' must be a string`);
256
+ if (obj.message !== undefined && typeof obj.message !== 'string')
257
+ throw new Error(`invalid 'errorMessage' spec at '${path}': 'message' must be a string`);
258
+ if (obj.$msgid === undefined && obj.message === undefined)
259
+ throw new Error(`invalid 'errorMessage' spec at '${path}': a MessageSpec object needs '$msgid' and/or 'message'`);
260
+ if (obj.params !== undefined && (obj.params === null || typeof obj.params !== 'object' || Array.isArray(obj.params)))
261
+ throw new Error(`invalid 'errorMessage' spec at '${path}': 'params' must be an object`);
262
+
263
+ return {
264
+ msgid: obj.$msgid !== undefined ? obj.$msgid : null,
265
+ render: obj.message !== undefined ? compileMessageTemplate(obj.message) : null,
266
+ params: obj.params !== undefined ? obj.params : null,
267
+ };
268
+ }
269
+
270
+ /**
271
+ * A compiled 'errorMessage' node registered on the ValidationRoot.
272
+ * @typedef {object} CompiledErrorMessageNode
273
+ * @property {CompiledMessageSpec|null} all - String-form spec: covers this node AND its subtree
274
+ * @property {Map<string, CompiledMessageSpec | {perKey: Map<string, CompiledMessageSpec>, fallback: CompiledMessageSpec|null}>|null} keywords - Map-form per-keyword specs (this node only)
275
+ * @property {CompiledMessageSpec|null} catchAll - The '_' entry (this node only)
276
+ */
277
+
278
+ /**
279
+ * Compile the value of an 'errorMessage' keyword into a registry node.
280
+ * Grammar (validated here, at schema compile time):
281
+ * - MessageSpec (string / `$msgid` object): covers the whole subtree;
282
+ * - map form: per-keyword MessageSpecs for this node, where `required`
283
+ * also accepts a per-missing-property map, `$query` a per-runtime-code
284
+ * map (with `default` for the EBV-false failure), and `_` is the
285
+ * node-level catch-all.
286
+ * @param {unknown} errorMessage - The keyword's value
287
+ * @param {string} path - The schema path, for compile error messages
288
+ * @returns {CompiledErrorMessageNode} The compiled node
289
+ */
290
+ export function compileErrorMessageSpec(errorMessage, path) {
291
+ if (isMessageSpecValue(errorMessage))
292
+ return { all: compileMessageSpec(errorMessage, path), keywords: null, catchAll: null };
293
+
294
+ if (errorMessage === null || typeof errorMessage !== 'object' || Array.isArray(errorMessage))
295
+ throw new Error(`invalid 'errorMessage' at '${path}': must be a MessageSpec or a keyword map`);
296
+
297
+ /** @type {CompiledErrorMessageNode} */
298
+ const node = { all: null, keywords: null, catchAll: null };
299
+ const obj = /** @type {Record<string, unknown>} */ (errorMessage);
300
+ const keys = Object.keys(obj);
301
+ for (let i = 0; i < keys.length; ++i) {
302
+ const key = keys[i];
303
+ const value = obj[key];
304
+ if (key === '_') {
305
+ node.catchAll = compileMessageSpec(value, `${path}/errorMessage/_`);
306
+ continue;
307
+ }
308
+ if (node.keywords === null) node.keywords = new Map();
309
+ const keyPath = `${path}/errorMessage/${key}`;
310
+ if ((key === 'required' || key === '$query') && !isMessageSpecValue(value)) {
311
+ // Per-key form: required -> per missing property, $query -> per
312
+ // runtime code with 'default' for the plain EBV-false failure.
313
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
314
+ throw new Error(`invalid 'errorMessage' spec at '${keyPath}': must be a MessageSpec or a map of MessageSpecs`);
315
+ const perKey = new Map();
316
+ /** @type {CompiledMessageSpec|null} */
317
+ let fallback = null;
318
+ const subKeys = Object.keys(value);
319
+ for (let j = 0; j < subKeys.length; ++j) {
320
+ const subKey = subKeys[j];
321
+ const compiled = compileMessageSpec(/** @type {any} */ (value)[subKey], `${keyPath}/${subKey}`);
322
+ if (key === '$query' && subKey === 'default') fallback = compiled;
323
+ else perKey.set(subKey, compiled);
324
+ }
325
+ node.keywords.set(key, { perKey, fallback });
326
+ continue;
327
+ }
328
+ node.keywords.set(key, compileMessageSpec(value, keyPath));
329
+ }
330
+ return node;
331
+ }
332
+
333
+ //#endregion
334
+
335
+ //#region Internal error conversion (report time)
336
+
337
+ /**
338
+ * Find the MessageSpec governing an error: nearest registered ancestor of
339
+ * the error's schema path wins (longest prefix, segment-aware); within one
340
+ * node, keyword-map entry beats '_' beats the string form; map-form
341
+ * entries apply only to errors AT the node, the string form covers the
342
+ * subtree. No match at the nearest node falls through to farther ancestors.
343
+ * @param {Map<string, CompiledErrorMessageNode>} registry - The root's errorMessage registry
344
+ * @param {string} errorPath - The error's schema path
345
+ * @param {string} keyword - The failed keyword
346
+ * @param {object} params - The extracted error params
347
+ * @returns {CompiledMessageSpec|null} The governing spec, or null
348
+ */
349
+ function resolveErrorMessageSpec(registry, errorPath, keyword, params) {
350
+ /** @type {Array<{path: string, node: CompiledErrorMessageNode}>} */
351
+ const candidates = [];
352
+ for (const [path, node] of registry) {
353
+ if (path === errorPath
354
+ || (errorPath.startsWith(path) && errorPath.charCodeAt(path.length) === 0x2f /* / */)) {
355
+ candidates.push({ path, node });
356
+ }
357
+ }
358
+ if (candidates.length === 0) return null;
359
+ candidates.sort((a, b) => b.path.length - a.path.length);
360
+
361
+ for (let i = 0; i < candidates.length; ++i) {
362
+ const { path, node } = candidates[i];
363
+ if (path === errorPath) {
364
+ if (node.keywords !== null) {
365
+ const entry = node.keywords.get(keyword);
366
+ if (entry !== undefined) {
367
+ if ('perKey' in entry) {
368
+ const matchKey = keyword === 'required' ? params.missingProperty : params.code;
369
+ if (matchKey !== undefined && entry.perKey.has(matchKey))
370
+ return entry.perKey.get(matchKey);
371
+ if (keyword === '$query' && params.code === undefined && entry.fallback !== null)
372
+ return entry.fallback;
373
+ // no per-key match: fall through to '_' / string form
374
+ }
375
+ else {
376
+ return entry;
377
+ }
378
+ }
379
+ }
380
+ if (node.catchAll !== null) return node.catchAll;
381
+ }
382
+ if (node.all !== null) return node.all;
383
+ }
384
+ return null;
385
+ }
386
+
387
+ /**
388
+ * Convert internal validation errors to the public ValidationError format.
389
+ * Params extraction is table-driven off the failed keyword; message text
390
+ * goes through the errorMessage registry (if any) and the built-in
391
+ * English catalog. With `options.messages === false` no message is
392
+ * rendered at all (`message: ''`, params and msgid still set).
393
+ * @param {Array<{object: any, key: string|string[], expected: any, dataKey: any, value: any, rest: any[]}>} internalErrors - The root's internal error records
394
+ * @returns {ValidationError[]} The public errors
395
+ */
396
+ export function convertInternalErrors(internalErrors) {
397
+ return internalErrors.map(err => {
398
+ const keyword = Array.isArray(err.key) ? err.key[err.key.length - 1] : err.key;
399
+
400
+ // Build params based on error type
401
+ const params = {};
402
+ if (keyword === 'required') {
403
+ params.missingProperty = err.dataKey;
404
+ } else if (keyword === 'type') {
405
+ if (Array.isArray(err.expected)) {
406
+ params.types = err.expected;
407
+ } else {
408
+ params.type = err.expected;
409
+ }
410
+ } else if (['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength', 'minProperties', 'maxProperties', 'minItems', 'maxItems'].includes(keyword)) {
411
+ params.limit = err.expected;
412
+ if (keyword === 'minimum' || keyword === 'maximum') {
413
+ params.comparison = keyword === 'minimum' ? '>=' : '<=';
414
+ } else if (keyword === 'exclusiveMinimum' || keyword === 'exclusiveMaximum') {
415
+ params.comparison = keyword === 'exclusiveMinimum' ? '>' : '<';
416
+ }
417
+ } else if (keyword === 'multipleOf') {
418
+ params.multipleOf = err.expected;
419
+ } else if (keyword === 'pattern') {
420
+ params.pattern = err.expected?.source || err.expected;
421
+ } else if (keyword === 'additionalProperties') {
422
+ params.additionalProperty = err.dataKey;
423
+ } else if (keyword === 'format') {
424
+ params.format = err.expected || err.value;
425
+ } else if (keyword === '$query') {
426
+ // A '$query' runtime failure passes the JQ2xxx code and the query
427
+ // document pointer as extra meta arguments after the data path;
428
+ // a plain EBV-false failure passes neither.
429
+ if (err.rest != null && err.rest.length > 1) {
430
+ params.code = err.rest[1];
431
+ params.docPath = err.rest[2];
432
+ }
433
+ }
434
+
435
+ // Validators pass the instance data path as the first meta argument to
436
+ // the error handler (the handler-contract invariant); the charCode
437
+ // guard is a safety net that yields '' - never a wrong path.
438
+ const meta0 = err.rest?.[0];
439
+ const instancePath = (typeof meta0 === 'string' && (meta0 === '' || meta0.charCodeAt(0) === 0x2f))
440
+ ? meta0
441
+ : '';
442
+
443
+ const schemaPath = err.object?.path || '';
444
+ const root = err.object?.root;
445
+
446
+ // Nearest-ancestor 'errorMessage' spec, if the schema registered any.
447
+ const registry = root?.errorMessages ?? null;
448
+ const spec = registry !== null
449
+ ? resolveErrorMessageSpec(registry, schemaPath, keyword, params)
450
+ : null;
451
+ if (spec !== null && spec.params !== null) {
452
+ // Author params merge OVER the error's params - into the error
453
+ // record itself, so localizeErrors re-renders with them too.
454
+ Object.assign(params, spec.params);
455
+ }
456
+
457
+ const msgid = (spec !== null && spec.msgid !== null)
458
+ ? spec.msgid
459
+ : (params.code !== undefined ? params.code : keyword);
460
+
461
+ const renderMessages = root?.options?.messages !== false;
462
+ let message = '';
463
+ let inline = false;
464
+ if (renderMessages) {
465
+ if (spec !== null) {
466
+ if (spec.msgid !== null) {
467
+ const render = EN[spec.msgid];
468
+ if (render !== undefined) {
469
+ message = render(params);
470
+ } else if (spec.render !== null) {
471
+ message = spec.render(params);
472
+ } else {
473
+ message = renderErrorMessage({ keyword, msgid, params });
474
+ }
475
+ } else {
476
+ message = /** @type {(params: object) => string} */ (spec.render)(params);
477
+ inline = true;
478
+ }
479
+ } else {
480
+ message = renderErrorMessage({ keyword, msgid, params });
481
+ }
482
+ }
483
+
484
+ const error = new ValidationError({
485
+ keyword,
486
+ instancePath,
487
+ schemaPath,
488
+ params,
489
+ msgid,
490
+ message,
491
+ });
492
+ if (inline) markInlineMessage(error);
493
+ return error;
494
+ });
495
+ }
496
+
497
+ //#endregion