@jarenjs/validate 0.8.4 → 0.9.2
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.
- package/ARCHITECTURE.md +1067 -0
- package/LICENSE +21 -0
- package/README.md +339 -2
- package/dist/types/array.d.ts +2 -0
- package/dist/types/bigint.d.ts +1 -0
- package/dist/types/combine.d.ts +1 -0
- package/dist/types/condition.d.ts +1 -0
- package/dist/types/content.d.ts +3 -0
- package/dist/types/data.d.ts +7 -0
- package/dist/types/dollar-data.d.ts +20 -0
- package/dist/types/dynamic-ref.d.ts +44 -0
- package/dist/types/enum.d.ts +1 -0
- package/dist/types/format.d.ts +21 -0
- package/dist/types/index.d.ts +874 -0
- package/dist/types/number.d.ts +1 -0
- package/dist/types/object.d.ts +3 -0
- package/dist/types/query-keyword.d.ts +19 -0
- package/dist/types/query.d.ts +29 -0
- package/dist/types/schema.d.ts +1 -0
- package/dist/types/string.d.ts +1 -0
- package/dist/types/tools.d.ts +51 -0
- package/dist/types/traverse.d.ts +32 -0
- package/dist/types/unevaluated.d.ts +12 -0
- package/package.json +32 -7
- package/src/array.js +565 -0
- package/src/bigint.js +97 -0
- package/src/combine.js +226 -0
- package/src/condition.js +109 -0
- package/src/content.js +83 -0
- package/src/data.js +477 -0
- package/src/dollar-data.js +629 -0
- package/src/dynamic-ref.js +121 -0
- package/src/enum.js +148 -0
- package/src/format.js +66 -0
- package/src/index.js +1854 -0
- package/src/number.js +159 -0
- package/src/object.js +755 -0
- package/src/query-keyword.js +99 -0
- package/src/query.js +59 -0
- package/src/schema.js +645 -0
- package/src/string.js +152 -0
- package/src/tools.js +205 -0
- package/src/traverse.js +433 -0
- package/src/unevaluated.js +151 -0
- package/dist/index.js +0 -1998
- package/dist/index.js.map +0 -7
- package/dist/index.min.js +0 -2
- package/dist/index.min.js.map +0 -7
package/src/index.js
ADDED
|
@@ -0,0 +1,1854 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isObjectClass,
|
|
5
|
+
isStringType,
|
|
6
|
+
} from '@jarenjs/core';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
compileSchemaObject,
|
|
10
|
+
} from './schema.js';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
storeSchemaIdsInMap,
|
|
14
|
+
restoreSchemaRefsInMap,
|
|
15
|
+
resolveRefSchemaDeep,
|
|
16
|
+
encodeJsonPointerPath,
|
|
17
|
+
TraverseOptions,
|
|
18
|
+
createJsonPointer,
|
|
19
|
+
} from './traverse.js';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
isBoolOrObjectClass,
|
|
23
|
+
hasSchemaRef,
|
|
24
|
+
EvalLog,
|
|
25
|
+
} from './tools.js';
|
|
26
|
+
import { wrapUnevaluated } from './unevaluated.js';
|
|
27
|
+
import { registerFormatCompiler, registerFormatCompilers } from './format.js';
|
|
28
|
+
import { mergeMap } from '@jarenjs/core/object';
|
|
29
|
+
import { hasRecursiveAnchor, getDynamicAnchorName, collectDynamicAnchors, collectDynamicAnchorsDeep } from './dynamic-ref.js';
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
registerFormatCompilers
|
|
33
|
+
} from './format.js';
|
|
34
|
+
|
|
35
|
+
export { TraverseOptions };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ajv-style $data reference object.
|
|
39
|
+
* The value is a Relative JSON Pointer that resolves from the current data location.
|
|
40
|
+
* Format: `<non-negative-integer>("#"|<json-pointer>)`
|
|
41
|
+
* - "0" - The current value itself
|
|
42
|
+
* - "0#" - The property name/index of the current value
|
|
43
|
+
* - "0/foo" - The "foo" property of the current value
|
|
44
|
+
* - "1" - The parent value
|
|
45
|
+
* - "1/foo" - The "foo" property of the parent value
|
|
46
|
+
* @see https://github.com/ajv-validator/ajv/tree/master/spec/extras/%24data
|
|
47
|
+
* @typedef {Object} DollarDataRef
|
|
48
|
+
* @property {string} $data - Relative JSON Pointer resolved from the current data location
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Data keyword schema for referencing instance data (json-everything style).
|
|
53
|
+
* Allows constraints to reference values from other parts of the instance;
|
|
54
|
+
* every property value is a (relative) JSON Pointer to the constraint's value.
|
|
55
|
+
* @see https://docs.json-everything.net/schema/examples/data-ref/
|
|
56
|
+
* @typedef {Object} DataKeywordSchema
|
|
57
|
+
* @property {string} [minimum] - JSON Pointer to the minimum value
|
|
58
|
+
* @property {string} [maximum] - JSON Pointer to the maximum value
|
|
59
|
+
* @property {string} [exclusiveMinimum] - JSON Pointer to the exclusive minimum value
|
|
60
|
+
* @property {string} [exclusiveMaximum] - JSON Pointer to the exclusive maximum value
|
|
61
|
+
* @property {string} [multipleOf] - JSON Pointer to the multipleOf value
|
|
62
|
+
* @property {string} [minLength] - JSON Pointer to the minLength value
|
|
63
|
+
* @property {string} [maxLength] - JSON Pointer to the maxLength value
|
|
64
|
+
* @property {string} [pattern] - JSON Pointer to the pattern string
|
|
65
|
+
* @property {string} [format] - JSON Pointer to the format name
|
|
66
|
+
* @property {string} [enum] - JSON Pointer to an array of valid values
|
|
67
|
+
* @property {string} [const] - JSON Pointer to the constant value
|
|
68
|
+
* @property {string} [minItems] - JSON Pointer to the minItems value
|
|
69
|
+
* @property {string} [maxItems] - JSON Pointer to the maxItems value
|
|
70
|
+
* @property {string} [minProperties] - JSON Pointer to the minProperties value
|
|
71
|
+
* @property {string} [maxProperties] - JSON Pointer to the maxProperties value
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The standard JSON Schema keywords understood by Jaren
|
|
76
|
+
* (draft-06 through draft 2020-12). See {@link JSONSchema} for the full
|
|
77
|
+
* schema object type that also permits custom keywords.
|
|
78
|
+
* @typedef {Object} JSONSchemaKeywords
|
|
79
|
+
* @property {string} [$id] - Schema resource identifier (URI)
|
|
80
|
+
* @property {string} [$schema] - Meta-schema URI declaring the draft dialect
|
|
81
|
+
* @property {string} [$ref] - Reference to another schema (URI reference)
|
|
82
|
+
* @property {string} [$anchor] - Plain-name fragment identifier (2019-09+)
|
|
83
|
+
* @property {string} [$dynamicRef] - Dynamic reference (2020-12)
|
|
84
|
+
* @property {string} [$dynamicAnchor] - Dynamic anchor (2020-12)
|
|
85
|
+
* @property {Record<string, boolean>} [$vocabulary] - Vocabulary declarations of a meta-schema
|
|
86
|
+
* @property {string} [$comment] - Comment for schema maintainers; not used in validation
|
|
87
|
+
* @property {Record<string, JSONSchema>} [$defs] - Reusable subschema definitions (2019-09+)
|
|
88
|
+
* @property {Record<string, JSONSchema>} [definitions] - Reusable subschema definitions (draft-07 and earlier)
|
|
89
|
+
* @property {string | string[]} [type] - Expected JSON type(s): 'null', 'boolean', 'object', 'array', 'number', 'string' or 'integer'
|
|
90
|
+
* @property {unknown[] | DollarDataRef} [enum] - Exhaustive list of valid values
|
|
91
|
+
* @property {unknown | DollarDataRef} [const] - Single valid value
|
|
92
|
+
* @property {number | DollarDataRef} [minLength] - Minimum string length (in graphemes by default)
|
|
93
|
+
* @property {number | DollarDataRef} [maxLength] - Maximum string length (in graphemes by default)
|
|
94
|
+
* @property {string | DollarDataRef} [pattern] - ECMA-262 regular expression the string must match
|
|
95
|
+
* @property {string} [contentEncoding] - Encoding of a string-embedded document (e.g. 'base64')
|
|
96
|
+
* @property {string} [contentMediaType] - Media type of a string-embedded document
|
|
97
|
+
* @property {JSONSchema} [contentSchema] - Schema for the decoded string-embedded document
|
|
98
|
+
* @property {number | DollarDataRef} [multipleOf] - Number must be a multiple of this value
|
|
99
|
+
* @property {number | DollarDataRef} [minimum] - Inclusive lower bound
|
|
100
|
+
* @property {number | DollarDataRef} [maximum] - Inclusive upper bound
|
|
101
|
+
* @property {number | boolean | DollarDataRef} [exclusiveMinimum] - Exclusive lower bound (boolean form in draft-04 style schemas)
|
|
102
|
+
* @property {number | boolean | DollarDataRef} [exclusiveMaximum] - Exclusive upper bound (boolean form in draft-04 style schemas)
|
|
103
|
+
* @property {Record<string, JSONSchema>} [properties] - Schemas for named object members
|
|
104
|
+
* @property {Record<string, JSONSchema>} [patternProperties] - Schemas for members whose name matches a regular expression
|
|
105
|
+
* @property {boolean | JSONSchema} [additionalProperties] - Schema for members not matched by properties/patternProperties
|
|
106
|
+
* @property {boolean | JSONSchema} [unevaluatedProperties] - Schema for members not evaluated by any subschema (2019-09+)
|
|
107
|
+
* @property {string[] | DollarDataRef} [required] - Member names that must be present
|
|
108
|
+
* @property {JSONSchema} [propertyNames] - Schema every member name must validate against
|
|
109
|
+
* @property {number | DollarDataRef} [minProperties] - Minimum number of members
|
|
110
|
+
* @property {number | DollarDataRef} [maxProperties] - Maximum number of members
|
|
111
|
+
* @property {JSONSchema | JSONSchema[]} [items] - Schema for array elements (array form is the draft-07 tuple syntax)
|
|
112
|
+
* @property {JSONSchema[]} [prefixItems] - Tuple element schemas (2020-12)
|
|
113
|
+
* @property {boolean | JSONSchema} [additionalItems] - Schema for elements beyond the tuple prefix (draft-07 and earlier)
|
|
114
|
+
* @property {boolean | JSONSchema} [unevaluatedItems] - Schema for elements not evaluated by any subschema (2019-09+)
|
|
115
|
+
* @property {JSONSchema} [contains] - At least one element must validate against this schema
|
|
116
|
+
* @property {number | DollarDataRef} [minItems] - Minimum number of elements
|
|
117
|
+
* @property {number | DollarDataRef} [maxItems] - Maximum number of elements
|
|
118
|
+
* @property {boolean | DollarDataRef} [uniqueItems] - Whether all elements must be unique
|
|
119
|
+
* @property {number} [minContains] - Minimum number of elements matching 'contains' (2019-09+)
|
|
120
|
+
* @property {number} [maxContains] - Maximum number of elements matching 'contains' (2019-09+)
|
|
121
|
+
* @property {JSONSchema[]} [allOf] - Value must validate against all of these schemas
|
|
122
|
+
* @property {JSONSchema[]} [anyOf] - Value must validate against at least one of these schemas
|
|
123
|
+
* @property {JSONSchema[]} [oneOf] - Value must validate against exactly one of these schemas
|
|
124
|
+
* @property {JSONSchema} [not] - Value must NOT validate against this schema
|
|
125
|
+
* @property {JSONSchema} [if] - Condition schema selecting between 'then' and 'else'
|
|
126
|
+
* @property {JSONSchema} [then] - Applied when 'if' validates
|
|
127
|
+
* @property {JSONSchema} [else] - Applied when 'if' does not validate
|
|
128
|
+
* @property {Record<string, JSONSchema>} [dependentSchemas] - Schemas applied when a member is present (2019-09+)
|
|
129
|
+
* @property {Record<string, string[]>} [dependentRequired] - Members required when a member is present (2019-09+)
|
|
130
|
+
* @property {string} [title] - Short descriptive title
|
|
131
|
+
* @property {string} [description] - Explanation of the schema's purpose
|
|
132
|
+
* @property {unknown} [default] - Default value annotation
|
|
133
|
+
* @property {unknown[]} [examples] - Example values annotation
|
|
134
|
+
* @property {boolean} [readOnly] - Value is managed by the receiving authority
|
|
135
|
+
* @property {boolean} [writeOnly] - Value is never returned by the receiving authority
|
|
136
|
+
* @property {boolean} [deprecated] - Value is deprecated
|
|
137
|
+
* @property {string | DollarDataRef} [format] - Named semantic format (e.g. 'email', 'uri', 'date-time')
|
|
138
|
+
* @property {string} [formatMinimum] - Format-aware inclusive lower bound (non-standard, Ajv-style)
|
|
139
|
+
* @property {string} [formatMaximum] - Format-aware inclusive upper bound (non-standard, Ajv-style)
|
|
140
|
+
* @property {string} [formatExclusiveMinimum] - Format-aware exclusive lower bound (non-standard, Ajv-style)
|
|
141
|
+
* @property {string} [formatExclusiveMaximum] - Format-aware exclusive upper bound (non-standard, Ajv-style)
|
|
142
|
+
* @property {DataKeywordSchema} [data] - Data keyword referencing instance data (json-everything style)
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Represents a JSON Schema object.
|
|
147
|
+
* Covers the standard keywords of drafts 06, 07, 2019-09 and 2020-12
|
|
148
|
+
* (see {@link JSONSchemaKeywords}) while remaining open for custom
|
|
149
|
+
* keywords: any property outside the standard set is permitted.
|
|
150
|
+
* Note that a complete schema is `JSONSchema | boolean` - the boolean
|
|
151
|
+
* forms accept everything (`true`) or nothing (`false`).
|
|
152
|
+
* @typedef {JSONSchemaKeywords & Record<string, unknown>} JSONSchema
|
|
153
|
+
*/
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A format compiler function.
|
|
157
|
+
* Called once per schema location at compile time with the compiling
|
|
158
|
+
* ValidationObject and the schema that declares the format; returns the
|
|
159
|
+
* format validator that is invoked for each instance value, or undefined
|
|
160
|
+
* when the format does not apply to the schema location. Compilers are
|
|
161
|
+
* only invoked for schemas whose `format` member is a plain string.
|
|
162
|
+
* @typedef {(schemaObj: ValidationObject, jsonSchema: JSONSchema & {format?: string}) => ((data: unknown, dataPath?: string) => boolean) | undefined} FormatCompiler
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
export const DEFAULT_SCHEMA_DRAFT = 'http://json-schema.org/draft-06/schema#'
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Detects the JSON Schema draft version from the schema's $schema property
|
|
169
|
+
* @param {object} schema - The JSON schema
|
|
170
|
+
* @returns {number} - The draft version (6, 7, 2019, or 2020)
|
|
171
|
+
*/
|
|
172
|
+
export function detectSchemaDraft(schema) {
|
|
173
|
+
if (!schema || typeof schema !== 'object') return 7; // default to draft7
|
|
174
|
+
const schemaUrl = schema.$schema || '';
|
|
175
|
+
if (schemaUrl.includes('2020-12')) return 2020;
|
|
176
|
+
if (schemaUrl.includes('2019-09')) return 2019;
|
|
177
|
+
if (schemaUrl.includes('draft-07') || schemaUrl.includes('draft/07')) return 7;
|
|
178
|
+
if (schemaUrl.includes('draft-06') || schemaUrl.includes('draft/06')) return 6;
|
|
179
|
+
return 7; // default to draft7 behavior
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const isBrowser = typeof window !== 'undefined';
|
|
183
|
+
|
|
184
|
+
const performance = (() => isBrowser
|
|
185
|
+
// eslint-disable-next-line no-undef
|
|
186
|
+
? window.performance
|
|
187
|
+
: {
|
|
188
|
+
now: function performanceNow(start) {
|
|
189
|
+
// @ts-ignore
|
|
190
|
+
const ps = process;
|
|
191
|
+
if (!start) return ps.hrtime();
|
|
192
|
+
const end = ps.hrtime(start);
|
|
193
|
+
return Math.round((end[0] * 1000) + (end[1] / 1000000));
|
|
194
|
+
},
|
|
195
|
+
})();
|
|
196
|
+
|
|
197
|
+
class InternalValidationError {
|
|
198
|
+
constructor(obj, key, expected, dataKey, value, rest) {
|
|
199
|
+
this.timeStamp = performance.now();
|
|
200
|
+
this.object = obj;
|
|
201
|
+
this.key = key;
|
|
202
|
+
this.expected = expected;
|
|
203
|
+
this.dataKey = dataKey;
|
|
204
|
+
this.value = value;
|
|
205
|
+
this.rest = rest;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* JSON Schema Validation Error
|
|
211
|
+
* Represents a validation error according to the JSON Schema specification.
|
|
212
|
+
* @see https://json-schema.org/draft/2020-12/json-schema-core.html#output
|
|
213
|
+
*/
|
|
214
|
+
export class ValidationError {
|
|
215
|
+
/**
|
|
216
|
+
* @param {object} options - Error options
|
|
217
|
+
* @param {string} options.keyword - The keyword that failed validation
|
|
218
|
+
* @param {string} options.instancePath - JSON Pointer to the data location
|
|
219
|
+
* @param {string} options.schemaPath - JSON Pointer to the schema location
|
|
220
|
+
* @param {object} options.params - Keyword-specific parameters
|
|
221
|
+
* @param {string} [options.message] - Human-readable error message
|
|
222
|
+
*/
|
|
223
|
+
constructor(options) {
|
|
224
|
+
this.keyword = options.keyword;
|
|
225
|
+
this.instancePath = options.instancePath || '';
|
|
226
|
+
this.schemaPath = options.schemaPath || '';
|
|
227
|
+
this.params = options.params || {};
|
|
228
|
+
this.message = options.message || '';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Convert error to a plain object
|
|
233
|
+
* @returns {object} Plain object representation
|
|
234
|
+
*/
|
|
235
|
+
toJSON() {
|
|
236
|
+
return {
|
|
237
|
+
keyword: this.keyword,
|
|
238
|
+
instancePath: this.instancePath,
|
|
239
|
+
schemaPath: this.schemaPath,
|
|
240
|
+
params: this.params,
|
|
241
|
+
message: this.message,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* ValidationOptions configures the behavior of the validation process.
|
|
248
|
+
* @class
|
|
249
|
+
*/
|
|
250
|
+
export class ValidationOptions {
|
|
251
|
+
/**
|
|
252
|
+
* Creates validation options.
|
|
253
|
+
* @param {boolean} [skipErrors=true] - Whether to stop at first error or continue
|
|
254
|
+
* @param {boolean} [useGrapheme=true] - Whether to use grapheme cluster counting for strings
|
|
255
|
+
* @param {boolean} [collectErrors=false] - Whether to collect all errors or just return boolean
|
|
256
|
+
* @param {boolean|null} [contentValidation=null] - Whether to validate contentEncoding/contentMediaType (null = auto based on draft)
|
|
257
|
+
* @param {number} [draftVersion=7] - The JSON Schema draft version (6, 7, 2019, or 2020)
|
|
258
|
+
* @param {boolean} [vocabValidation=true] - Whether the validation vocabulary is enabled (false when the schema's metaschema omits it via $vocabulary)
|
|
259
|
+
* @param {boolean|null} [formatAssertion=null] - Whether format asserts (null = auto: asserts below draft 2020-12, annotation-only from 2020-12 on)
|
|
260
|
+
*/
|
|
261
|
+
constructor(
|
|
262
|
+
skipErrors = true,
|
|
263
|
+
useGrapheme = true,
|
|
264
|
+
collectErrors = false,
|
|
265
|
+
contentValidation = null,
|
|
266
|
+
draftVersion = 7,
|
|
267
|
+
vocabValidation = true,
|
|
268
|
+
formatAssertion = null
|
|
269
|
+
) {
|
|
270
|
+
/** @type {boolean} Whether to stop at first error or continue */
|
|
271
|
+
this.skipErrors = skipErrors;
|
|
272
|
+
/** @type {boolean} Whether to use grapheme cluster counting for string length */
|
|
273
|
+
this.useGrapheme = useGrapheme;
|
|
274
|
+
/** @type {boolean} Whether to collect and return detailed errors */
|
|
275
|
+
this.collectErrors = collectErrors;
|
|
276
|
+
/** @type {boolean|null} Whether to validate contentEncoding/contentMediaType (null = auto based on draft) */
|
|
277
|
+
this.contentValidation = contentValidation;
|
|
278
|
+
/** @type {number} The JSON Schema draft version (6, 7, 2019, or 2020) */
|
|
279
|
+
this.draftVersion = draftVersion;
|
|
280
|
+
/** @type {boolean} Whether validation vocabulary keywords (type, minimum, ...) are asserted */
|
|
281
|
+
this.vocabValidation = vocabValidation;
|
|
282
|
+
/** @type {boolean|null} Whether the format keyword asserts (null = auto by draft) */
|
|
283
|
+
this.formatAssertion = formatAssertion;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* ValidationRoot manages the compilation and validation context for a schema.
|
|
289
|
+
* It holds references to all schemas, formats, options, and compiled ValidationObjects.
|
|
290
|
+
* @class
|
|
291
|
+
*/
|
|
292
|
+
export class ValidationRoot {
|
|
293
|
+
/**
|
|
294
|
+
* Creates a ValidationObject and stores it in the root's object map.
|
|
295
|
+
* @param {ValidationRoot} self - The ValidationRoot instance
|
|
296
|
+
* @param {string} path - The URI path for this schema object
|
|
297
|
+
* @param {object|boolean} schema - The JSON schema
|
|
298
|
+
* @param {string} baseUri - The base URI for resolving relative refs
|
|
299
|
+
* @returns {ValidationObject} The created ValidationObject
|
|
300
|
+
*/
|
|
301
|
+
static #createObject(self, path, schema, baseUri, parentDeclaredDraft = null) {
|
|
302
|
+
const objects = self.#objects;
|
|
303
|
+
if (objects.has(path)) {
|
|
304
|
+
const p = objects.get(path);
|
|
305
|
+
if (p != null)
|
|
306
|
+
throw new Error(`Object at '${path}' is already created`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const obj = new ValidationObject(self, path, schema, baseUri, parentDeclaredDraft);
|
|
310
|
+
objects.set(path, obj);
|
|
311
|
+
return obj;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** @type {string|null} The root schema origin/URI */
|
|
315
|
+
#rootOrigin = null;
|
|
316
|
+
/** @type {Map|null} Map of schema paths to schema objects */
|
|
317
|
+
#schemas = null;
|
|
318
|
+
/** @type {object|null} Registered format validators */
|
|
319
|
+
#formats = null;
|
|
320
|
+
/** @type {ValidationOptions|null} Validation options */
|
|
321
|
+
#options = null;
|
|
322
|
+
/** @type {TraverseOptions|null} Schema traversal options */
|
|
323
|
+
#traverse = null;
|
|
324
|
+
/** @type {Map|null} Map of paths to ValidationObjects */
|
|
325
|
+
#objects = null;
|
|
326
|
+
/** @type {Array} Array of validation errors */
|
|
327
|
+
#errors = null;
|
|
328
|
+
/** @type {ValidationObject|null} The root schema's ValidationObject */
|
|
329
|
+
#firstSchema = null;
|
|
330
|
+
/** @type {Map<string, Function[]>} Map of anchor names to stacks of validator functions */
|
|
331
|
+
#dynamicAnchors = null;
|
|
332
|
+
/** @type {string|null} Anchor name to register for the root schema on each validation, or null when not needed */
|
|
333
|
+
#rootAnchorName = null;
|
|
334
|
+
/** @type {function|null} Cached compiled validator of the root schema */
|
|
335
|
+
#rootValidator = null;
|
|
336
|
+
/** @type {Array<{name: string, schema: object, validator: (function|null)}>} $dynamicAnchors of the root resource (excluding the root's own), registered on each validation */
|
|
337
|
+
#rootDynamicAnchors = [];
|
|
338
|
+
/** @type {boolean} Whether any schema in this compilation contains a $data reference */
|
|
339
|
+
#usesDollarData = false;
|
|
340
|
+
/** @type {boolean} Whether any schema in this compilation contains unevaluatedProperties/unevaluatedItems */
|
|
341
|
+
#usesUnevaluated = false;
|
|
342
|
+
/** @type {EvalLog} Log of evaluated properties/items for unevaluated* support */
|
|
343
|
+
#evalLog = new EvalLog();
|
|
344
|
+
/** @type {object|null} The JarenValidator instance this compilation belongs to, or null when constructed standalone */
|
|
345
|
+
#owner = null;
|
|
346
|
+
|
|
347
|
+
/** Keywords whose value is a map of arbitrary names to schemas; those
|
|
348
|
+
* names must not be mistaken for keywords (e.g. a metaschema declaring
|
|
349
|
+
* a property named 'unevaluatedProperties'). */
|
|
350
|
+
static #SCAN_MAP_KEYWORDS = new Set([
|
|
351
|
+
'properties', 'patternProperties', 'dependentSchemas',
|
|
352
|
+
'$defs', 'definitions',
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Recursively scans a schema (sub)tree for keys that require special
|
|
357
|
+
* runtime support: '$data' references and 'unevaluatedProperties'/
|
|
358
|
+
* 'unevaluatedItems'. Keys inside name->schema maps (properties, $defs,
|
|
359
|
+
* ...) are property/definition names and are not treated as keywords.
|
|
360
|
+
* @param {any} node - The schema node to scan
|
|
361
|
+
* @param {Set<object>} seen - Cycle guard
|
|
362
|
+
* @param {{dollarData: boolean, unevaluated: boolean}} flags - Output flags
|
|
363
|
+
* @param {boolean} [isSchema=true] - Whether node's keys are schema keywords
|
|
364
|
+
*/
|
|
365
|
+
static #scanSchemaFeatures(node, seen, flags, isSchema = true) {
|
|
366
|
+
if (node == null || typeof node !== 'object') return;
|
|
367
|
+
if (flags.dollarData && flags.unevaluated) return;
|
|
368
|
+
if (seen.has(node)) return;
|
|
369
|
+
seen.add(node);
|
|
370
|
+
if (Array.isArray(node)) {
|
|
371
|
+
for (let i = 0; i < node.length; ++i) {
|
|
372
|
+
ValidationRoot.#scanSchemaFeatures(node[i], seen, flags, isSchema);
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const keys = Object.keys(node);
|
|
377
|
+
for (let i = 0; i < keys.length; ++i) {
|
|
378
|
+
const key = keys[i];
|
|
379
|
+
if (isSchema) {
|
|
380
|
+
// The json-everything 'data' keyword resolves relative pointers
|
|
381
|
+
// against the data path at validation time, just like '$data'.
|
|
382
|
+
if (key === '$data'
|
|
383
|
+
|| (key === 'data' && node[key] !== null && typeof node[key] === 'object')) flags.dollarData = true;
|
|
384
|
+
else if (key === '$query') {
|
|
385
|
+
// The '$query' keyword binds the instance path to its 'path'
|
|
386
|
+
// external at validation time, so it consumes data paths like
|
|
387
|
+
// '$data'. Its value is a query document, not a schema - the
|
|
388
|
+
// keys inside (operators, embedded schema literals) must not
|
|
389
|
+
// register as keywords of this compilation.
|
|
390
|
+
flags.dollarData = true;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
else if (key === 'unevaluatedProperties' || key === 'unevaluatedItems') flags.unevaluated = true;
|
|
394
|
+
if (ValidationRoot.#SCAN_MAP_KEYWORDS.has(key)) {
|
|
395
|
+
// The value is a name->schema map: its keys are names, its values schemas.
|
|
396
|
+
ValidationRoot.#scanSchemaFeatures(node[key], seen, flags, false);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
ValidationRoot.#scanSchemaFeatures(node[key], seen, flags, true);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Creates a new ValidationRoot.
|
|
406
|
+
* @param {string} origin - The root schema origin/URI
|
|
407
|
+
* @param {Map} schemas - Map of schema paths to schema objects
|
|
408
|
+
* @param {Record<string, FormatCompiler>} formats - Registered format validators
|
|
409
|
+
* @param {ValidationOptions} [opts] - Validation options
|
|
410
|
+
* @param {TraverseOptions} [traverse] - Schema traversal options
|
|
411
|
+
* @param {object|null} [owner] - The owning JarenValidator instance; extension
|
|
412
|
+
* keywords ('$query') compile embedded schema literals against it so their
|
|
413
|
+
* `$ref`s resolve to the owner's `addSchema` registrations
|
|
414
|
+
*/
|
|
415
|
+
constructor(origin, schemas, formats, opts = new ValidationOptions(), traverse = new TraverseOptions, owner = null) {
|
|
416
|
+
const schema = schemas.get(origin);
|
|
417
|
+
this.#rootOrigin = origin;
|
|
418
|
+
this.#schemas = schemas;
|
|
419
|
+
this.#formats = formats;
|
|
420
|
+
|
|
421
|
+
this.#options = opts;
|
|
422
|
+
this.#traverse = traverse;
|
|
423
|
+
this.#owner = owner;
|
|
424
|
+
|
|
425
|
+
this.#objects = new Map();
|
|
426
|
+
this.#errors = [];
|
|
427
|
+
this.#dynamicAnchors = new Map();
|
|
428
|
+
|
|
429
|
+
// Detect $data references and unevaluated* keywords once, so fast paths
|
|
430
|
+
// can skip path building / annotation logging when nothing consumes them.
|
|
431
|
+
// Must run before validators are compiled below.
|
|
432
|
+
const flags = { dollarData: false, unevaluated: false };
|
|
433
|
+
const seen = new Set();
|
|
434
|
+
for (const value of schemas.values()) {
|
|
435
|
+
ValidationRoot.#scanSchemaFeatures(value, seen, flags);
|
|
436
|
+
if (flags.dollarData && flags.unevaluated) break;
|
|
437
|
+
}
|
|
438
|
+
this.#usesDollarData = flags.dollarData;
|
|
439
|
+
this.#usesUnevaluated = flags.unevaluated;
|
|
440
|
+
|
|
441
|
+
// For the root schema, baseUri is the origin
|
|
442
|
+
this.#firstSchema = ValidationRoot.#createObject(this, origin, schema, origin);
|
|
443
|
+
|
|
444
|
+
// Precompute per-validation constants so validate() stays allocation-free.
|
|
445
|
+
// The root schema never changes after compilation.
|
|
446
|
+
const rootSchema = this.#firstSchema.schema;
|
|
447
|
+
const hasRecAnchor = isObjectClass(rootSchema) && hasRecursiveAnchor(rootSchema);
|
|
448
|
+
const dynAnchorName = isObjectClass(rootSchema) ? getDynamicAnchorName(rootSchema) : null;
|
|
449
|
+
this.#rootAnchorName = (hasRecAnchor || dynAnchorName) ? (dynAnchorName || '') : null;
|
|
450
|
+
this.#rootValidator = this.#firstSchema.validate;
|
|
451
|
+
|
|
452
|
+
// Entering the root resource brings ALL of its $dynamicAnchors into the
|
|
453
|
+
// dynamic scope (the root's own anchor is handled via #rootAnchorName).
|
|
454
|
+
this.#rootDynamicAnchors = collectDynamicAnchorsDeep(rootSchema)
|
|
455
|
+
.filter(anchor => anchor.schema !== rootSchema);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** @returns {string} The root schema origin/URI */
|
|
459
|
+
get rootOrigin() { return this.#rootOrigin; }
|
|
460
|
+
|
|
461
|
+
/** @returns {TraverseOptions} Schema traversal options */
|
|
462
|
+
get traverse() { return this.#traverse; }
|
|
463
|
+
|
|
464
|
+
/** @returns {ValidationOptions} Validation options */
|
|
465
|
+
get options() { return this.#options; }
|
|
466
|
+
|
|
467
|
+
/** @returns {object} Registered format validators */
|
|
468
|
+
get formats() { return this.#formats; }
|
|
469
|
+
|
|
470
|
+
/** @returns {Array} Array of validation errors */
|
|
471
|
+
get errors() { return this.#errors; }
|
|
472
|
+
|
|
473
|
+
/** @returns {ValidationObject} The root schema's ValidationObject */
|
|
474
|
+
get firstSchema() { return this.#firstSchema; }
|
|
475
|
+
|
|
476
|
+
/** @returns {boolean} Whether any schema in this compilation contains a $data reference */
|
|
477
|
+
get usesDollarData() { return this.#usesDollarData; }
|
|
478
|
+
|
|
479
|
+
/** @returns {boolean} Whether any schema in this compilation contains unevaluatedProperties/unevaluatedItems */
|
|
480
|
+
get usesUnevaluated() { return this.#usesUnevaluated; }
|
|
481
|
+
|
|
482
|
+
/** @returns {EvalLog} The evaluation log for unevaluated* annotation tracking */
|
|
483
|
+
get evalLog() { return this.#evalLog; }
|
|
484
|
+
|
|
485
|
+
/** @returns {object|null} The owning JarenValidator instance, or null when constructed standalone */
|
|
486
|
+
get owner() { return this.#owner; }
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Creates a new ValidationObject for the given path and schema.
|
|
490
|
+
* @param {string} path - The URI path for this schema object
|
|
491
|
+
* @param {object|boolean} schema - The JSON schema
|
|
492
|
+
* @param {string} baseUri - The base URI for resolving relative refs
|
|
493
|
+
* @returns {ValidationObject} The created ValidationObject
|
|
494
|
+
*/
|
|
495
|
+
createObject(path, schema, baseUri, parentDeclaredDraft = null) {
|
|
496
|
+
return ValidationRoot.#createObject(this, path, schema, baseUri, parentDeclaredDraft);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Checks if an object exists at the given path without creating it.
|
|
501
|
+
* @param {string} path - The URI path to check
|
|
502
|
+
* @returns {ValidationObject|null|undefined} The existing object, null if marked unresolved, or undefined if not known
|
|
503
|
+
*/
|
|
504
|
+
unresolvedObject(path) {
|
|
505
|
+
const objects = this.#objects;
|
|
506
|
+
if (objects.has(path))
|
|
507
|
+
return objects.get(path);
|
|
508
|
+
|
|
509
|
+
objects.set(path, null);
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Gets the raw schema object for a given reference without compiling it.
|
|
515
|
+
* Used to check schema properties (like $recursiveAnchor) at compile time.
|
|
516
|
+
* @param {string} ref - The reference URI to resolve
|
|
517
|
+
* @param {string} path - The current path (for error messages)
|
|
518
|
+
* @param {object} schema - The schema containing the $ref
|
|
519
|
+
* @returns {{id: string, schema: object}|null} The resolved schema info or null
|
|
520
|
+
*/
|
|
521
|
+
getRawSchema(ref, path, schema) {
|
|
522
|
+
try {
|
|
523
|
+
const schemas = this.#schemas;
|
|
524
|
+
const traverse = this.#traverse;
|
|
525
|
+
return resolveRefSchemaDeep(schemas, path, schema, traverse);
|
|
526
|
+
} catch (e) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Gets the raw schema object by its URI/ID directly from the schemas map.
|
|
533
|
+
* This performs a direct lookup without following references.
|
|
534
|
+
* @param {string} uri - The schema URI to look up
|
|
535
|
+
* @returns {object|undefined} The raw schema object or undefined
|
|
536
|
+
*/
|
|
537
|
+
getSchemaByUri(uri) {
|
|
538
|
+
return this.#schemas.get(uri);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Resolves a $ref to a ValidationObject, creating it if necessary.
|
|
543
|
+
* @param {string} ref - The reference URI to resolve
|
|
544
|
+
* @param {string} path - The current path (for error messages)
|
|
545
|
+
* @param {object} schema - The schema containing the $ref
|
|
546
|
+
* @returns {ValidationObject} The resolved ValidationObject
|
|
547
|
+
*/
|
|
548
|
+
resolveObject(ref, path, schema) {
|
|
549
|
+
// Fast path - check if already compiled first
|
|
550
|
+
const objects = this.#objects;
|
|
551
|
+
const cached = objects.get(ref);
|
|
552
|
+
if (cached != null) return cached;
|
|
553
|
+
|
|
554
|
+
// Resolve ref chain and check final ID cache
|
|
555
|
+
const schemas = this.#schemas;
|
|
556
|
+
const traverse = this.#traverse;
|
|
557
|
+
const { id, schema: root } = resolveRefSchemaDeep(schemas, path, schema, traverse);
|
|
558
|
+
|
|
559
|
+
// Check if final ID is already compiled
|
|
560
|
+
const finalCached = objects.get(id);
|
|
561
|
+
if (finalCached != null) return finalCached;
|
|
562
|
+
|
|
563
|
+
// Create and cache the validation object
|
|
564
|
+
return ValidationRoot.#createObject(this, id, root);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Adds an error to the validation errors list.
|
|
569
|
+
* @param {InternalValidationError} error - The error to add
|
|
570
|
+
* @returns {boolean} Always returns false for convenience in validators
|
|
571
|
+
*/
|
|
572
|
+
addError(error /*:InternalValidationError*/) {
|
|
573
|
+
this.#errors.push(error);
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Validates data against the root schema.
|
|
579
|
+
* @param {unknown} data - The data to validate
|
|
580
|
+
* @returns {boolean} True if valid, false otherwise
|
|
581
|
+
*/
|
|
582
|
+
validate(data /*:unknown*/) {
|
|
583
|
+
// only clear errors array when we need to collect errors
|
|
584
|
+
// When skipErrors=true (default), we don't use the errors array
|
|
585
|
+
if (!this.#options.skipErrors) {
|
|
586
|
+
this.#errors = [];
|
|
587
|
+
}
|
|
588
|
+
// Push/pop pairs are balanced (try/finally), so the anchors map is
|
|
589
|
+
// normally empty here already; clear defensively without reallocating.
|
|
590
|
+
if (this.#dynamicAnchors.size !== 0) {
|
|
591
|
+
this.#dynamicAnchors.clear();
|
|
592
|
+
}
|
|
593
|
+
// Clear evaluation annotations from the previous validation
|
|
594
|
+
if (this.#usesUnevaluated) {
|
|
595
|
+
this.#evalLog.reset();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const rootValidator = this.#rootValidator;
|
|
599
|
+
const anchorName = this.#rootAnchorName;
|
|
600
|
+
const rootAnchors = this.#rootDynamicAnchors;
|
|
601
|
+
if (anchorName !== null || rootAnchors.length !== 0) {
|
|
602
|
+
if (anchorName !== null)
|
|
603
|
+
this.pushDynamicAnchorValidator(anchorName, rootValidator);
|
|
604
|
+
for (let i = 0; i < rootAnchors.length; ++i) {
|
|
605
|
+
const anchor = rootAnchors[i];
|
|
606
|
+
if (anchor.validator === null)
|
|
607
|
+
anchor.validator = this.getOrCreateValidator(anchor.schema, this.#rootOrigin, this.#rootOrigin);
|
|
608
|
+
this.pushDynamicAnchorValidator(anchor.name, anchor.validator);
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
// call compiled validator with dataRoot as third argument
|
|
612
|
+
return rootValidator(data, '', data);
|
|
613
|
+
} finally {
|
|
614
|
+
for (let i = rootAnchors.length - 1; i >= 0; --i) {
|
|
615
|
+
this.popDynamicAnchorValidator(rootAnchors[i].name);
|
|
616
|
+
}
|
|
617
|
+
if (anchorName !== null)
|
|
618
|
+
this.popDynamicAnchorValidator(anchorName);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// call compiled validator
|
|
623
|
+
// Pass dataRoot as the third argument for data keyword support
|
|
624
|
+
return rootValidator(data, '', data);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Get the stored validator for a dynamic anchor.
|
|
629
|
+
* Used by $dynamicRef for runtime resolution.
|
|
630
|
+
* Per draft 2020-12, $dynamicRef resolves to the FIRST (outermost)
|
|
631
|
+
* resource in the dynamic scope that defines the anchor.
|
|
632
|
+
* @param {string} anchorName - The anchor name
|
|
633
|
+
* @returns {Function|null} The validator function or null if not set
|
|
634
|
+
*/
|
|
635
|
+
getDynamicAnchorValidator(anchorName) {
|
|
636
|
+
const stack = this.#dynamicAnchors.get(anchorName);
|
|
637
|
+
if (!stack || stack.length === 0) return null;
|
|
638
|
+
return stack[0];
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Get the outermost validator for a recursive anchor.
|
|
643
|
+
* Used by $recursiveRef for runtime resolution.
|
|
644
|
+
* Returns the bottom of the stack (first/outermost registered validator).
|
|
645
|
+
* @param {string} anchorName - The anchor name (empty string for $recursiveRef)
|
|
646
|
+
* @returns {Function|null} The validator function or null if not set
|
|
647
|
+
*/
|
|
648
|
+
getOutermostDynamicAnchorValidator(anchorName) {
|
|
649
|
+
const stack = this.#dynamicAnchors.get(anchorName);
|
|
650
|
+
if (!stack || stack.length === 0) return null;
|
|
651
|
+
return stack[0];
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Push a validator onto the stack for a dynamic anchor.
|
|
656
|
+
* Called when entering a schema with $recursiveAnchor or $dynamicAnchor.
|
|
657
|
+
* @param {string} anchorName - The anchor name
|
|
658
|
+
* @param {Function} validator - The validator function
|
|
659
|
+
*/
|
|
660
|
+
pushDynamicAnchorValidator(anchorName, validator) {
|
|
661
|
+
if (!this.#dynamicAnchors.has(anchorName)) {
|
|
662
|
+
this.#dynamicAnchors.set(anchorName, []);
|
|
663
|
+
}
|
|
664
|
+
this.#dynamicAnchors.get(anchorName).push(validator);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Pop a validator from the stack for a dynamic anchor.
|
|
669
|
+
* Called when exiting a schema with $recursiveAnchor or $dynamicAnchor.
|
|
670
|
+
* @param {string} anchorName - The anchor name
|
|
671
|
+
*/
|
|
672
|
+
popDynamicAnchorValidator(anchorName) {
|
|
673
|
+
const stack = this.#dynamicAnchors.get(anchorName);
|
|
674
|
+
if (stack && stack.length > 0) {
|
|
675
|
+
stack.pop();
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Get or create a validator for a given schema.
|
|
681
|
+
* This is used when we need a validator for a schema at validation time
|
|
682
|
+
* (e.g., for dynamic anchors collected from $defs).
|
|
683
|
+
* @param {object} schema - The schema to create a validator for
|
|
684
|
+
* @param {string} basePath - The base path for the schema
|
|
685
|
+
* @param {string} baseUri - The base URI for the schema
|
|
686
|
+
* @returns {Function} The validator function
|
|
687
|
+
*/
|
|
688
|
+
getOrCreateValidator(schema, basePath, baseUri) {
|
|
689
|
+
// Create a unique path for this schema based on its content
|
|
690
|
+
// We use a simple JSON stringify for now, but this could be improved
|
|
691
|
+
const path = basePath + '/$def-anchor/' + JSON.stringify(schema).slice(0, 50);
|
|
692
|
+
|
|
693
|
+
// Check if we already have an object for this path
|
|
694
|
+
let obj = this.#objects.get(path);
|
|
695
|
+
if (obj != null) {
|
|
696
|
+
return obj.validate;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Create a new validation object for this schema
|
|
700
|
+
obj = ValidationRoot.#createObject(this, path, schema, baseUri);
|
|
701
|
+
return obj.validate;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* ValidationObject represents a single schema location with its compiled validator.
|
|
707
|
+
* It handles the compilation of schema validation logic and provides methods for
|
|
708
|
+
* creating child validators and error handlers.
|
|
709
|
+
* @class
|
|
710
|
+
*/
|
|
711
|
+
export class ValidationObject {
|
|
712
|
+
/**
|
|
713
|
+
* Compiles a validator function for the given schema.
|
|
714
|
+
* This is the main entry point for schema compilation. It handles:
|
|
715
|
+
* - Simple schemas (type-only, required-only) via fast paths
|
|
716
|
+
* - Schemas with $ref by resolving to target validators
|
|
717
|
+
* - Complex schemas by delegating to compileSchemaObject
|
|
718
|
+
* @param {ValidationObject} self - The validation object that is compiling this validator
|
|
719
|
+
* @param {string} path - The path to this schema object (its URI identifier)
|
|
720
|
+
* @param {any} schema - The schema object to compile
|
|
721
|
+
* @param {string} baseUri - The base URI for resolving $ref (parent's base, before any sibling $id)
|
|
722
|
+
* @returns {function(any, any):boolean} A function that validates data against the compiled schema and returns a boolean.
|
|
723
|
+
*/
|
|
724
|
+
static compileValidator(self, path, schema, baseUri) {
|
|
725
|
+
if (!hasSchemaRef(schema))
|
|
726
|
+
return compileSchemaObject(self, schema);
|
|
727
|
+
|
|
728
|
+
const root = self.#root;
|
|
729
|
+
|
|
730
|
+
// In draft 2019-09+, $ref can have sibling keywords that are applied together.
|
|
731
|
+
// In draft 7 and earlier, $ref overrides siblings.
|
|
732
|
+
const draftVersion = root.options.draftVersion || 7;
|
|
733
|
+
|
|
734
|
+
// Base URI for resolving $ref:
|
|
735
|
+
// - Draft 7 and earlier: $ref replaces the schema entirely, so a sibling
|
|
736
|
+
// $id does not change the base URI - resolve against the parent's base.
|
|
737
|
+
// - Draft 2019-09+: $id establishes the base URI for the schema object it
|
|
738
|
+
// appears in, INCLUDING a sibling $ref (self.baseUri accounts for $id).
|
|
739
|
+
const refBase = draftVersion >= 2019
|
|
740
|
+
? (self.baseUri || baseUri || path)
|
|
741
|
+
: (baseUri || path);
|
|
742
|
+
const { id: ref } = createJsonPointer(schema.$ref, refBase, root.traverse);
|
|
743
|
+
// Only check for sibling validators in draft 2019-09+
|
|
744
|
+
const siblingValidator = draftVersion >= 2019 ? compileSchemaObject(self, schema) : null;
|
|
745
|
+
|
|
746
|
+
// Collect dynamic anchors from the current schema's $defs/definitions.
|
|
747
|
+
// When following a $ref, dynamic anchors defined in the source schema's $defs
|
|
748
|
+
// should be in scope for $dynamicRef in the target schema.
|
|
749
|
+
// This handles cases like: root has $defs.foo with $dynamicAnchor, root.$ref points to list,
|
|
750
|
+
// and list.items has $dynamicRef that should resolve to root.$defs.foo's anchor.
|
|
751
|
+
const sourceDynamicAnchors = draftVersion >= 2020 ? collectDynamicAnchors(schema) : [];
|
|
752
|
+
|
|
753
|
+
// Since refs are now pre-compiled at compile time,
|
|
754
|
+
// we should always find the target immediately
|
|
755
|
+
const resolved = root.unresolvedObject(ref);
|
|
756
|
+
if (resolved != null) {
|
|
757
|
+
const refValidator = resolved.validate;
|
|
758
|
+
const resolvedSchema = resolved.schema;
|
|
759
|
+
|
|
760
|
+
// Check if the target schema has a dynamic anchor ($recursiveAnchor or $dynamicAnchor)
|
|
761
|
+
// If so, we need to wrap the call to register the anchor at validation time
|
|
762
|
+
const hasRecAnchor = isObjectClass(resolvedSchema) && hasRecursiveAnchor(resolvedSchema);
|
|
763
|
+
const dynAnchorName = isObjectClass(resolvedSchema) ? getDynamicAnchorName(resolvedSchema) : null;
|
|
764
|
+
|
|
765
|
+
// Entering the target resource brings ALL of its $dynamicAnchors into
|
|
766
|
+
// the dynamic scope (the target root's own anchor is pushed separately
|
|
767
|
+
// as refValidator below). Source $defs anchors are kept for schemas
|
|
768
|
+
// whose resource entry point is the $ref itself.
|
|
769
|
+
const enterAnchors = sourceDynamicAnchors.slice();
|
|
770
|
+
const targetDeepAnchors = collectDynamicAnchorsDeep(resolvedSchema);
|
|
771
|
+
for (let i = 0; i < targetDeepAnchors.length; i++) {
|
|
772
|
+
const anchor = targetDeepAnchors[i];
|
|
773
|
+
if (anchor.schema === resolvedSchema) continue;
|
|
774
|
+
anchor.base = resolved.baseUri;
|
|
775
|
+
enterAnchors.push(anchor);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (hasRecAnchor || dynAnchorName || enterAnchors.length > 0) {
|
|
779
|
+
const anchorName = dynAnchorName || ''; // empty string for $recursiveAnchor
|
|
780
|
+
|
|
781
|
+
// Wrap the ref validator to register dynamic anchor at call time (validation time)
|
|
782
|
+
if (siblingValidator) {
|
|
783
|
+
// unevaluated* keywords must see annotations produced by the $ref
|
|
784
|
+
// target, so the wrapper goes around the combined validator.
|
|
785
|
+
return wrapUnevaluated(self, schema, function validateRefWithDynamicAnchorAndSiblings(data, dataPath, dataRoot) {
|
|
786
|
+
// Register the entered resource's dynamic anchors first
|
|
787
|
+
for (let i = 0; i < enterAnchors.length; i++) {
|
|
788
|
+
const anchor = enterAnchors[i];
|
|
789
|
+
if (anchor.validator == null)
|
|
790
|
+
anchor.validator = root.getOrCreateValidator(anchor.schema, path, anchor.base || baseUri);
|
|
791
|
+
root.pushDynamicAnchorValidator(anchor.name, anchor.validator);
|
|
792
|
+
}
|
|
793
|
+
// Then register target's dynamic anchor if any
|
|
794
|
+
if (hasRecAnchor || dynAnchorName) {
|
|
795
|
+
root.pushDynamicAnchorValidator(anchorName, refValidator);
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
return refValidator(data, dataPath, dataRoot) && siblingValidator(data, dataPath, dataRoot);
|
|
799
|
+
} finally {
|
|
800
|
+
// Pop in reverse order
|
|
801
|
+
if (hasRecAnchor || dynAnchorName) {
|
|
802
|
+
root.popDynamicAnchorValidator(anchorName);
|
|
803
|
+
}
|
|
804
|
+
for (let i = enterAnchors.length - 1; i >= 0; i--) {
|
|
805
|
+
root.popDynamicAnchorValidator(enterAnchors[i].name);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
} else {
|
|
810
|
+
return function validateRefWithDynamicAnchor(data, dataPath, dataRoot) {
|
|
811
|
+
// Register the entered resource's dynamic anchors first
|
|
812
|
+
for (let i = 0; i < enterAnchors.length; i++) {
|
|
813
|
+
const anchor = enterAnchors[i];
|
|
814
|
+
if (anchor.validator == null)
|
|
815
|
+
anchor.validator = root.getOrCreateValidator(anchor.schema, path, anchor.base || baseUri);
|
|
816
|
+
root.pushDynamicAnchorValidator(anchor.name, anchor.validator);
|
|
817
|
+
}
|
|
818
|
+
// Then register target's dynamic anchor if any
|
|
819
|
+
if (hasRecAnchor || dynAnchorName) {
|
|
820
|
+
root.pushDynamicAnchorValidator(anchorName, refValidator);
|
|
821
|
+
}
|
|
822
|
+
try {
|
|
823
|
+
return refValidator(data, dataPath, dataRoot);
|
|
824
|
+
} finally {
|
|
825
|
+
// Pop in reverse order
|
|
826
|
+
if (hasRecAnchor || dynAnchorName) {
|
|
827
|
+
root.popDynamicAnchorValidator(anchorName);
|
|
828
|
+
}
|
|
829
|
+
for (let i = enterAnchors.length - 1; i >= 0; i--) {
|
|
830
|
+
root.popDynamicAnchorValidator(enterAnchors[i].name);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// If there are sibling validators (draft 2019-09+), combine them with the ref validator
|
|
838
|
+
if (siblingValidator) {
|
|
839
|
+
// unevaluated* keywords must see annotations produced by the $ref
|
|
840
|
+
// target, so the wrapper goes around the combined validator.
|
|
841
|
+
return wrapUnevaluated(self, schema, function validateRefWithSiblings(data, dataPath, dataRoot) {
|
|
842
|
+
return refValidator(data, dataPath, dataRoot) && siblingValidator(data, dataPath, dataRoot);
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
self.#validator = refValidator;
|
|
847
|
+
return refValidator;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Fallback for edge cases (e.g., recursive refs that weren't pre-compiled)
|
|
851
|
+
// Pre-bind values to avoid closure overhead in hot path
|
|
852
|
+
const rootRef = root;
|
|
853
|
+
const boundRef = ref;
|
|
854
|
+
const boundRefBase = refBase;
|
|
855
|
+
const boundSchema = schema;
|
|
856
|
+
const boundSiblingValidator = siblingValidator;
|
|
857
|
+
const boundSourceAnchors = sourceDynamicAnchors;
|
|
858
|
+
|
|
859
|
+
// Memoized state of the first resolution - the target never changes.
|
|
860
|
+
let resolvedState = null;
|
|
861
|
+
|
|
862
|
+
return wrapUnevaluated(self, schema, function resolveSchemaCompiler(data, dataPath, dataRoot) {
|
|
863
|
+
if (resolvedState === null) {
|
|
864
|
+
const obj = rootRef.resolveObject(boundRef, boundRefBase, boundSchema);
|
|
865
|
+
const resolvedSchema = obj.schema;
|
|
866
|
+
|
|
867
|
+
// Entering the target resource brings ALL of its $dynamicAnchors into
|
|
868
|
+
// the dynamic scope, exactly like the pre-compiled path above.
|
|
869
|
+
const enterAnchors = boundSourceAnchors.slice();
|
|
870
|
+
const targetDeepAnchors = collectDynamicAnchorsDeep(resolvedSchema);
|
|
871
|
+
for (let i = 0; i < targetDeepAnchors.length; i++) {
|
|
872
|
+
const anchor = targetDeepAnchors[i];
|
|
873
|
+
if (anchor.schema === resolvedSchema) continue;
|
|
874
|
+
anchor.base = obj.baseUri;
|
|
875
|
+
enterAnchors.push(anchor);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
resolvedState = {
|
|
879
|
+
refValidator: obj.validate,
|
|
880
|
+
enterAnchors,
|
|
881
|
+
hasRecAnchor: isObjectClass(resolvedSchema) && hasRecursiveAnchor(resolvedSchema),
|
|
882
|
+
dynAnchorName: isObjectClass(resolvedSchema) ? getDynamicAnchorName(resolvedSchema) : null,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
const { refValidator, enterAnchors, hasRecAnchor, dynAnchorName } = resolvedState;
|
|
886
|
+
|
|
887
|
+
for (let i = 0; i < enterAnchors.length; i++) {
|
|
888
|
+
const anchor = enterAnchors[i];
|
|
889
|
+
if (anchor.validator == null)
|
|
890
|
+
anchor.validator = rootRef.getOrCreateValidator(anchor.schema, path, anchor.base || baseUri);
|
|
891
|
+
rootRef.pushDynamicAnchorValidator(anchor.name, anchor.validator);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
try {
|
|
895
|
+
if (hasRecAnchor || dynAnchorName) {
|
|
896
|
+
const anchorName = dynAnchorName || '';
|
|
897
|
+
rootRef.pushDynamicAnchorValidator(anchorName, refValidator);
|
|
898
|
+
try {
|
|
899
|
+
// If there are sibling validators (draft 2019-09+), combine them with the ref validator
|
|
900
|
+
if (boundSiblingValidator) {
|
|
901
|
+
return refValidator(data, dataPath, dataRoot) && boundSiblingValidator(data, dataPath, dataRoot);
|
|
902
|
+
}
|
|
903
|
+
return refValidator(data, dataPath, dataRoot);
|
|
904
|
+
} finally {
|
|
905
|
+
rootRef.popDynamicAnchorValidator(anchorName);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// If there are sibling validators (draft 2019-09+), combine them with the ref validator
|
|
910
|
+
if (boundSiblingValidator) {
|
|
911
|
+
return refValidator(data, dataPath, dataRoot) && boundSiblingValidator(data, dataPath, dataRoot);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Cache the validator directly (skipping this resolver) only when
|
|
915
|
+
// this ref registers no dynamic anchors - otherwise later calls
|
|
916
|
+
// would lose the registrations.
|
|
917
|
+
if (enterAnchors.length === 0) {
|
|
918
|
+
self.#validator = refValidator;
|
|
919
|
+
}
|
|
920
|
+
return refValidator(data, dataPath, dataRoot);
|
|
921
|
+
} finally {
|
|
922
|
+
// Pop anchors in reverse order
|
|
923
|
+
for (let i = enterAnchors.length - 1; i >= 0; i--) {
|
|
924
|
+
rootRef.popDynamicAnchorValidator(enterAnchors[i].name);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** @type {ValidationRoot} The root validation context */
|
|
931
|
+
#root = null;
|
|
932
|
+
/** @type {string} The URI path identifying this schema object */
|
|
933
|
+
#path = null;
|
|
934
|
+
/** @type {ValidationObject[]} Child validation objects created by this object */
|
|
935
|
+
#members = null;
|
|
936
|
+
/** @type {any} The schema object being validated */
|
|
937
|
+
#schema = null;
|
|
938
|
+
/** @type {function|null} The compiled validator function */
|
|
939
|
+
#validator = null;
|
|
940
|
+
/** @type {string} The base URI passed during construction */
|
|
941
|
+
#baseUri = null;
|
|
942
|
+
/** @type {string} The effective base URI for child $ref resolution */
|
|
943
|
+
#effectiveBaseUri = null;
|
|
944
|
+
/** @type {number|null} Draft version declared by this schema's document ($schema), inherited by subschemas; null when never declared */
|
|
945
|
+
#declaredDraft = null;
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Creates a new ValidationObject.
|
|
949
|
+
* @param {ValidationRoot} root - The root validation context
|
|
950
|
+
* @param {string} path - The URI path identifying this schema object
|
|
951
|
+
* @param {any} schema - The schema object to compile
|
|
952
|
+
* @param {string} baseUri - The base URI for resolving $ref
|
|
953
|
+
* @param {number|null} [parentDeclaredDraft] - The declared draft inherited from the parent schema object
|
|
954
|
+
*/
|
|
955
|
+
constructor(root, path, schema, baseUri, parentDeclaredDraft = null) {
|
|
956
|
+
this.#root = root;
|
|
957
|
+
this.#path = path;
|
|
958
|
+
this.#members = [];
|
|
959
|
+
this.#schema = schema;
|
|
960
|
+
this.#validator = null;
|
|
961
|
+
|
|
962
|
+
// A document that declares its own $schema is processed per that draft
|
|
963
|
+
// (cross-draft references); subschemas inherit the document's draft.
|
|
964
|
+
this.#declaredDraft = (isObjectClass(schema) && isStringType(schema.$schema))
|
|
965
|
+
? detectSchemaDraft(schema)
|
|
966
|
+
: parentDeclaredDraft;
|
|
967
|
+
|
|
968
|
+
// Calculate the effective base URI for this schema.
|
|
969
|
+
// The effective base is what children should use for resolving relative $refs.
|
|
970
|
+
// If this schema has an $id, it becomes the new base for children.
|
|
971
|
+
if (isObjectClass(schema) && schema.$id) {
|
|
972
|
+
// This schema has its own $id - resolve it against the parent's baseUri
|
|
973
|
+
// to get the absolute base for children
|
|
974
|
+
const { id: resolvedId } = createJsonPointer(schema.$id, baseUri);
|
|
975
|
+
this.#effectiveBaseUri = resolvedId.endsWith('#') ? resolvedId.slice(0, -1) : resolvedId;
|
|
976
|
+
} else {
|
|
977
|
+
// No $id - inherit parent's base
|
|
978
|
+
this.#effectiveBaseUri = baseUri;
|
|
979
|
+
}
|
|
980
|
+
this.#baseUri = baseUri;
|
|
981
|
+
|
|
982
|
+
this.#validator = ValidationObject.compileValidator(this, path, schema, baseUri);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** @returns {string} The URI path identifying this schema object */
|
|
986
|
+
get path() {
|
|
987
|
+
return this.#path;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** @returns {string} The effective base URI for resolving relative $refs */
|
|
991
|
+
get baseUri() {
|
|
992
|
+
return this.#effectiveBaseUri;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** @returns {Array} The current validation errors from the root */
|
|
996
|
+
get errors() {
|
|
997
|
+
return this.#root.errors;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** @returns {function} The compiled validator function */
|
|
1001
|
+
get validate() {
|
|
1002
|
+
return this.#validator;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** @returns {ValidationOptions} The validation options */
|
|
1006
|
+
get options() {
|
|
1007
|
+
return this.#root.options;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** @returns {object} The registered format validators */
|
|
1011
|
+
get formats() {
|
|
1012
|
+
return this.#root.formats;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** @returns {ValidationRoot} The root validation context */
|
|
1016
|
+
get root() {
|
|
1017
|
+
return this.#root;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** @returns {object} The schema object */
|
|
1021
|
+
get schema() {
|
|
1022
|
+
return this.#schema;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** @returns {number|null} Draft version declared by this schema's document via $schema, or null when never declared */
|
|
1026
|
+
get declaredDraft() {
|
|
1027
|
+
return this.#declaredDraft;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Creates an error handler function for validation failures.
|
|
1032
|
+
* @param {any} expected - The expected value that failed validation
|
|
1033
|
+
* @param {string | string[]} key - The keyword or keywords that failed
|
|
1034
|
+
* @returns {(data: unknown, ...meta: any[]) => boolean} A function that adds an error and returns false
|
|
1035
|
+
*/
|
|
1036
|
+
createErrorHandler(expected, key) {
|
|
1037
|
+
const self = this;
|
|
1038
|
+
|
|
1039
|
+
// when skipErrors is true, we don't need to create error objects
|
|
1040
|
+
// Just return false immediately to avoid the overhead of error creation
|
|
1041
|
+
if (self.#root.options.skipErrors) {
|
|
1042
|
+
if (!Array.isArray(key)) {
|
|
1043
|
+
return function addNormalErrorFast(data, ...meta) {
|
|
1044
|
+
// Just return false without creating error object
|
|
1045
|
+
return false;
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
return function addKeyedErrorFast(dataKey, data, ...meta) {
|
|
1050
|
+
// Just return false without creating error object
|
|
1051
|
+
return false;
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (!Array.isArray(key)) {
|
|
1057
|
+
return function addNormalError(data, ...meta) {
|
|
1058
|
+
const error = new InternalValidationError(self, key, expected, null, data, meta);
|
|
1059
|
+
return self.#root.addError(error);
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
return function addKeyedError(dataKey, data, ...meta) {
|
|
1064
|
+
const error = new InternalValidationError(self, key, expected, dataKey, data, meta);
|
|
1065
|
+
return self.#root.addError(error);
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Creates a validator function for a child schema.
|
|
1072
|
+
* This is used when compiling nested schemas (e.g., array items, object properties).
|
|
1073
|
+
* @param {JSONSchema | boolean} schema - The child schema to compile
|
|
1074
|
+
* @param {string} key - The property key where the schema is located
|
|
1075
|
+
* @param {number} [index] - Optional array index for tuple items
|
|
1076
|
+
* @returns {function|undefined} The compiled validator function, or undefined if schema is invalid
|
|
1077
|
+
*/
|
|
1078
|
+
createValidator(schema, key, index) {
|
|
1079
|
+
if (!isBoolOrObjectClass(schema))
|
|
1080
|
+
return undefined;
|
|
1081
|
+
|
|
1082
|
+
const root = this.#root;
|
|
1083
|
+
// Use the effective base URI for resolving relative $refs
|
|
1084
|
+
// This is either: (a) the resolved $id of this schema, or (b) the inherited base from parent
|
|
1085
|
+
let basePath = this.#effectiveBaseUri;
|
|
1086
|
+
// Strip trailing '#' for URL resolution - a base URL ending with '#' breaks relative ref resolution
|
|
1087
|
+
if (basePath && basePath.endsWith('#')) {
|
|
1088
|
+
basePath = basePath.slice(0, -1);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Check if schema has $id - this affects how we calculate the path
|
|
1092
|
+
const hasId = isObjectClass(schema) && schema.$id;
|
|
1093
|
+
|
|
1094
|
+
// If schema has $id, resolve it against basePath to get the new base URI
|
|
1095
|
+
// Otherwise, use the current path
|
|
1096
|
+
const id = hasId
|
|
1097
|
+
? createJsonPointer(schema.$id, basePath || this.#path).id
|
|
1098
|
+
: this.#path;
|
|
1099
|
+
|
|
1100
|
+
// If schema has $id, use the resolved ID as the path (it defines the schema's location)
|
|
1101
|
+
// Otherwise, append key/index to create a JSON pointer path
|
|
1102
|
+
const path = hasId
|
|
1103
|
+
? id
|
|
1104
|
+
: index == null
|
|
1105
|
+
? encodeJsonPointerPath(id, key)
|
|
1106
|
+
: encodeJsonPointerPath(id, key, String(index));
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
// Pass basePath as the baseUri for the child object.
|
|
1111
|
+
// This ensures that $ref in the child will be resolved against basePath,
|
|
1112
|
+
// not against any sibling $id that the child might have.
|
|
1113
|
+
// The child inherits this document's declared draft version.
|
|
1114
|
+
const child = root.createObject(path, schema, basePath, this.#declaredDraft);
|
|
1115
|
+
this.#members.push(child);
|
|
1116
|
+
|
|
1117
|
+
return child.#validator;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* ValidatorOptions configures the JarenValidator instance.
|
|
1123
|
+
* Can be created with positional arguments or an options object.
|
|
1124
|
+
* @class
|
|
1125
|
+
* @example
|
|
1126
|
+
* // Positional arguments
|
|
1127
|
+
* const options = new ValidatorOptions(formats, schemas, validation, traverse);
|
|
1128
|
+
*
|
|
1129
|
+
* // Options object (recommended)
|
|
1130
|
+
* const options = new ValidatorOptions({
|
|
1131
|
+
* formats: { custom: validator },
|
|
1132
|
+
* collectErrors: true,
|
|
1133
|
+
* useGrapheme: false
|
|
1134
|
+
* });
|
|
1135
|
+
*/
|
|
1136
|
+
export class ValidatorOptions {
|
|
1137
|
+
/**
|
|
1138
|
+
* Creates validator options.
|
|
1139
|
+
* @param {object|object[]} [formats={}] - Format validators or options object
|
|
1140
|
+
* @param {object[]} [schemas=[]] - Initial schemas to register
|
|
1141
|
+
* @param {ValidationOptions} [validation] - Validation behavior options
|
|
1142
|
+
* @param {TraverseOptions} [traverse] - Schema traversal options
|
|
1143
|
+
*/
|
|
1144
|
+
constructor(
|
|
1145
|
+
formats = {},
|
|
1146
|
+
schemas = [],
|
|
1147
|
+
validation = new ValidationOptions(),
|
|
1148
|
+
traverse = new TraverseOptions(),
|
|
1149
|
+
) {
|
|
1150
|
+
// Support object destructuring: new ValidatorOptions({ collectErrors: true })
|
|
1151
|
+
if (formats && typeof formats === 'object' && !Array.isArray(formats) &&
|
|
1152
|
+
!(formats instanceof Map)) {
|
|
1153
|
+
const opts = formats;
|
|
1154
|
+
/** @type {object} Registered format validators */
|
|
1155
|
+
this.formats = opts.formats || {};
|
|
1156
|
+
/** @type {object[]} Initial schemas to register */
|
|
1157
|
+
this.schemas = opts.schemas || [];
|
|
1158
|
+
// If collectErrors is passed directly, create ValidationOptions with it
|
|
1159
|
+
if (opts.collectErrors != null || opts.skipErrors != null || opts.useGrapheme != null || opts.contentValidation != null || opts.draftVersion != null || opts.formatAssertion != null) {
|
|
1160
|
+
const collectErrors = opts.collectErrors ?? false;
|
|
1161
|
+
this.validation = new ValidationOptions(
|
|
1162
|
+
// collecting errors implies actually recording them
|
|
1163
|
+
opts.skipErrors ?? !collectErrors,
|
|
1164
|
+
opts.useGrapheme ?? true,
|
|
1165
|
+
collectErrors,
|
|
1166
|
+
opts.contentValidation ?? false,
|
|
1167
|
+
opts.draftVersion ?? 7,
|
|
1168
|
+
true,
|
|
1169
|
+
opts.formatAssertion ?? null
|
|
1170
|
+
);
|
|
1171
|
+
} else {
|
|
1172
|
+
/** @type {ValidationOptions} Validation behavior options */
|
|
1173
|
+
this.validation = opts.validation || new ValidationOptions();
|
|
1174
|
+
}
|
|
1175
|
+
/** @type {TraverseOptions} Schema traversal options */
|
|
1176
|
+
this.traverse = opts.traverse || new TraverseOptions();
|
|
1177
|
+
} else {
|
|
1178
|
+
this.formats = formats;
|
|
1179
|
+
this.schemas = schemas;
|
|
1180
|
+
this.validation = validation;
|
|
1181
|
+
this.traverse = traverse;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* JarenValidator is the main entry point for JSON Schema validation.
|
|
1188
|
+
* It manages schema registration, format registration, and compilation.
|
|
1189
|
+
* @class
|
|
1190
|
+
* @example
|
|
1191
|
+
* const validator = new JarenValidator();
|
|
1192
|
+
* validator.addSchema({ $id: 'http://example.com/schema', type: 'object' });
|
|
1193
|
+
* const validate = validator.compile({ $ref: 'http://example.com/schema' });
|
|
1194
|
+
* const valid = validate({ foo: 'bar' }); // true
|
|
1195
|
+
*/
|
|
1196
|
+
export class JarenValidator {
|
|
1197
|
+
/** @type {object} Registered format validators */
|
|
1198
|
+
#formats = {}
|
|
1199
|
+
/** @type {Map} Map of schema URIs to schema objects */
|
|
1200
|
+
#schemas = new Map();
|
|
1201
|
+
/** @type {Map} Map of meta-schema URIs to compiled validators */
|
|
1202
|
+
#metaSchemas = new Map();
|
|
1203
|
+
/** @type {ValidatorOptions} Validator options */
|
|
1204
|
+
#options = new ValidatorOptions();
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Creates a new JarenValidator instance.
|
|
1208
|
+
* @param {ValidatorOptions} [options] - Validator options including formats, schemas, validation options, and traverse options
|
|
1209
|
+
*/
|
|
1210
|
+
constructor(options = new ValidatorOptions()) {
|
|
1211
|
+
// Accept a plain options object ({ skipErrors, collectErrors, ... })
|
|
1212
|
+
// as well as a ValidatorOptions instance.
|
|
1213
|
+
if (!(options instanceof ValidatorOptions)) {
|
|
1214
|
+
options = new ValidatorOptions(options);
|
|
1215
|
+
}
|
|
1216
|
+
this.#formats = options.formats || {};
|
|
1217
|
+
this.#schemas = new Map();
|
|
1218
|
+
this.#metaSchemas = new Map();
|
|
1219
|
+
this.#options = options;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Adds a format validator.
|
|
1224
|
+
* @param {string} name - The format name (e.g., 'email', 'uri', 'date-time')
|
|
1225
|
+
* @param {FormatCompiler} formatCompiler - A function that compiles format validators
|
|
1226
|
+
* @returns {JarenValidator} This validator instance for chaining
|
|
1227
|
+
* @example
|
|
1228
|
+
* validator.addFormat('custom', (schemaObj, schema) => {
|
|
1229
|
+
* return (data) => data.startsWith('custom:');
|
|
1230
|
+
* });
|
|
1231
|
+
*/
|
|
1232
|
+
addFormat(name, formatCompiler) {
|
|
1233
|
+
registerFormatCompiler(
|
|
1234
|
+
this.#formats,
|
|
1235
|
+
name,
|
|
1236
|
+
formatCompiler);
|
|
1237
|
+
return this;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Adds multiple format validators at once.
|
|
1242
|
+
* @param {Record<string, FormatCompiler>} formatCompilers - Object mapping format names to compiler functions
|
|
1243
|
+
* @returns {JarenValidator} This validator instance for chaining
|
|
1244
|
+
*/
|
|
1245
|
+
addFormats(formatCompilers) {
|
|
1246
|
+
registerFormatCompilers(
|
|
1247
|
+
this.#formats,
|
|
1248
|
+
formatCompilers);
|
|
1249
|
+
return this;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
*
|
|
1254
|
+
* @param {boolean | object} schema
|
|
1255
|
+
* @param {object[] | undefined} schemas
|
|
1256
|
+
* @param {TraverseOptions} opts
|
|
1257
|
+
* @returns {{origin:string, map: Map}}
|
|
1258
|
+
*/
|
|
1259
|
+
static #traverseSchema(schema, schemas = undefined, fromMap = undefined, opts = new TraverseOptions()) {
|
|
1260
|
+
// initialize schema map for all ids and refs
|
|
1261
|
+
const schemaMap = new Map(fromMap);
|
|
1262
|
+
const origin = storeSchemaIdsInMap(
|
|
1263
|
+
schemaMap,
|
|
1264
|
+
opts.origin,
|
|
1265
|
+
schema,
|
|
1266
|
+
opts);
|
|
1267
|
+
|
|
1268
|
+
// Then add the other reference schemas
|
|
1269
|
+
if (Array.isArray(schemas) && schemas.length > 0) {
|
|
1270
|
+
schemas.forEach(ref => storeSchemaIdsInMap(
|
|
1271
|
+
schemaMap,
|
|
1272
|
+
origin,
|
|
1273
|
+
ref,
|
|
1274
|
+
opts));
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// make sure all schemas are connected
|
|
1278
|
+
restoreSchemaRefsInMap(schemaMap, opts);
|
|
1279
|
+
|
|
1280
|
+
return { origin: origin, map: schemaMap };
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Adds schema(s) to the validator instance.
|
|
1285
|
+
* This method does not compile schemas - it only registers them for reference.
|
|
1286
|
+
* Dependencies can be added in any order, and circular dependencies are supported.
|
|
1287
|
+
* @param {JSONSchema | boolean | (JSONSchema | boolean)[]} schema - The schema(s) to add
|
|
1288
|
+
* @param {string} [key] - Optional key/URI to register the schema under
|
|
1289
|
+
* @returns {JarenValidator} This validator instance for chaining
|
|
1290
|
+
* @example
|
|
1291
|
+
* // Add a single schema
|
|
1292
|
+
* validator.addSchema({ $id: 'http://example.com/user', type: 'object' });
|
|
1293
|
+
*
|
|
1294
|
+
* // Add multiple schemas
|
|
1295
|
+
* validator.addSchema([schema1, schema2]);
|
|
1296
|
+
*
|
|
1297
|
+
* // Add with explicit key
|
|
1298
|
+
* validator.addSchema({ type: 'string' }, 'http://example.com/name');
|
|
1299
|
+
*/
|
|
1300
|
+
addSchema(schema, key = undefined) {
|
|
1301
|
+
if (Array.isArray(schema)) {
|
|
1302
|
+
schema.forEach((s, index) => this.addSchema(s, key ? `${key}[${index}]` : undefined));
|
|
1303
|
+
}
|
|
1304
|
+
else if (typeof schema === 'object') {
|
|
1305
|
+
const schemaKey = key || schema.$id;
|
|
1306
|
+
if (schemaKey) {
|
|
1307
|
+
this.#schemas.set(schemaKey, schema);
|
|
1308
|
+
// CHECK: Also store with alternate key (with/without #) for absolute URIs
|
|
1309
|
+
if (!schemaKey.startsWith('#')) {
|
|
1310
|
+
const altKey = schemaKey.endsWith('#') ? schemaKey.slice(0, -1) : schemaKey + '#';
|
|
1311
|
+
if (!this.#schemas.has(altKey)) {
|
|
1312
|
+
this.#schemas.set(altKey, schema);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// Also traverse the schema to find and store all internal $id anchors
|
|
1317
|
+
// This is important for remote schemas that may have location-independent identifiers
|
|
1318
|
+
// We use a wrapper that skips already-existing keys instead of throwing
|
|
1319
|
+
this.#traverseAndStoreIds(schemaKey, schema);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return this;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Traverse a schema and store all $id anchors in the schemas map.
|
|
1327
|
+
* This is a wrapper around storeSchemaIdsInMap that skips already-existing keys.
|
|
1328
|
+
* Anchors are scoped to their document context (anchorsGlobal: false) to prevent
|
|
1329
|
+
* conflicts between different schemas that may use the same anchor names.
|
|
1330
|
+
* @param {string} baseUri - The base URI for the schema
|
|
1331
|
+
* @param {object} schema - The schema to traverse
|
|
1332
|
+
*/
|
|
1333
|
+
#traverseAndStoreIds(baseUri, schema) {
|
|
1334
|
+
const traverseOpts = this.#options.traverse;
|
|
1335
|
+
|
|
1336
|
+
// Create options with anchorsGlobal: false to scope anchors to their document.
|
|
1337
|
+
// This prevents conflicts when multiple schemas use the same anchor names (e.g., '#foo').
|
|
1338
|
+
// Anchors will be stored as 'baseUri#anchor' instead of just '#anchor'.
|
|
1339
|
+
const scopedOpts = new TraverseOptions(
|
|
1340
|
+
traverseOpts.origin,
|
|
1341
|
+
traverseOpts.mergeSchemas,
|
|
1342
|
+
false, // anchorsGlobal: false - scope anchors to document
|
|
1343
|
+
traverseOpts.anchorsAllowed,
|
|
1344
|
+
traverseOpts.skipErrors
|
|
1345
|
+
);
|
|
1346
|
+
|
|
1347
|
+
// Use a wrapper map to collect new entries, then merge them
|
|
1348
|
+
const newSchemas = new Map();
|
|
1349
|
+
try {
|
|
1350
|
+
storeSchemaIdsInMap(newSchemas, baseUri, schema, scopedOpts);
|
|
1351
|
+
} catch (e) {
|
|
1352
|
+
// Ignore errors for already-existing schemas at the root level
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// Merge new entries into the main schemas map, skipping existing keys
|
|
1356
|
+
for (const [id, value] of newSchemas.entries()) {
|
|
1357
|
+
if (!this.#schemas.has(id)) {
|
|
1358
|
+
this.#schemas.set(id, value);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Convert internal validation errors to public ValidationError format
|
|
1365
|
+
* @param {InternalValidationError[]} internalErrors
|
|
1366
|
+
* @returns {ValidationError[]}
|
|
1367
|
+
*/
|
|
1368
|
+
static #convertErrors(internalErrors) {
|
|
1369
|
+
return internalErrors.map(err => {
|
|
1370
|
+
const keyword = Array.isArray(err.key) ? err.key[err.key.length - 1] : err.key;
|
|
1371
|
+
|
|
1372
|
+
// Build params based on error type
|
|
1373
|
+
const params = {};
|
|
1374
|
+
if (keyword === 'required') {
|
|
1375
|
+
params.missingProperty = err.dataKey;
|
|
1376
|
+
} else if (keyword === 'type') {
|
|
1377
|
+
if (Array.isArray(err.expected)) {
|
|
1378
|
+
params.types = err.expected;
|
|
1379
|
+
} else {
|
|
1380
|
+
params.type = err.expected;
|
|
1381
|
+
}
|
|
1382
|
+
} else if (['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength', 'minProperties', 'maxProperties', 'minItems', 'maxItems'].includes(keyword)) {
|
|
1383
|
+
params.limit = err.expected;
|
|
1384
|
+
if (keyword === 'minimum' || keyword === 'maximum') {
|
|
1385
|
+
params.comparison = keyword === 'minimum' ? '>=' : '<=';
|
|
1386
|
+
} else if (keyword === 'exclusiveMinimum' || keyword === 'exclusiveMaximum') {
|
|
1387
|
+
params.comparison = keyword === 'exclusiveMinimum' ? '>' : '<';
|
|
1388
|
+
}
|
|
1389
|
+
} else if (keyword === 'multipleOf') {
|
|
1390
|
+
params.multipleOf = err.expected;
|
|
1391
|
+
} else if (keyword === 'pattern') {
|
|
1392
|
+
params.pattern = err.expected?.source || err.expected;
|
|
1393
|
+
} else if (keyword === 'additionalProperties') {
|
|
1394
|
+
params.additionalProperty = err.dataKey;
|
|
1395
|
+
} else if (keyword === '$query') {
|
|
1396
|
+
// A '$query' runtime failure passes the JQ2xxx code and the query
|
|
1397
|
+
// document pointer as extra meta arguments after the data path;
|
|
1398
|
+
// a plain EBV-false failure passes neither.
|
|
1399
|
+
if (err.rest != null && err.rest.length > 1) {
|
|
1400
|
+
params.code = err.rest[1];
|
|
1401
|
+
params.docPath = err.rest[2];
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// Generate message
|
|
1406
|
+
let message = `validation failed for keyword '${keyword}'`;
|
|
1407
|
+
if (keyword === 'required') {
|
|
1408
|
+
message = params.missingProperty
|
|
1409
|
+
? `must have required property '${params.missingProperty}'`
|
|
1410
|
+
: 'must have required properties';
|
|
1411
|
+
} else if (keyword === 'type') {
|
|
1412
|
+
message = params.types
|
|
1413
|
+
? `must be one of the following types: ${params.types.join(', ')}`
|
|
1414
|
+
: `must be ${params.type === 'integer' ? 'an' : 'a'} ${params.type}`;
|
|
1415
|
+
} else if (keyword === 'minimum' || keyword === 'maximum') {
|
|
1416
|
+
message = `must be ${params.comparison} ${params.limit}`;
|
|
1417
|
+
} else if (keyword === 'exclusiveMinimum' || keyword === 'exclusiveMaximum') {
|
|
1418
|
+
message = `must be ${params.comparison} ${params.limit}`;
|
|
1419
|
+
} else if (keyword === 'multipleOf') {
|
|
1420
|
+
message = `must be multiple of ${params.multipleOf}`;
|
|
1421
|
+
} else if (keyword === 'minLength') {
|
|
1422
|
+
message = `must NOT have fewer than ${params.limit} characters`;
|
|
1423
|
+
} else if (keyword === 'maxLength') {
|
|
1424
|
+
message = `must NOT have more than ${params.limit} characters`;
|
|
1425
|
+
} else if (keyword === 'pattern') {
|
|
1426
|
+
message = `must match pattern "${params.pattern}"`;
|
|
1427
|
+
} else if (keyword === 'additionalProperties') {
|
|
1428
|
+
message = params.additionalProperty
|
|
1429
|
+
? `must NOT have additional property '${params.additionalProperty}'`
|
|
1430
|
+
: 'must NOT have additional properties';
|
|
1431
|
+
} else if (keyword === 'minProperties') {
|
|
1432
|
+
message = `must NOT have fewer than ${params.limit} properties`;
|
|
1433
|
+
} else if (keyword === 'maxProperties') {
|
|
1434
|
+
message = `must NOT have more than ${params.limit} properties`;
|
|
1435
|
+
} else if (keyword === 'minItems') {
|
|
1436
|
+
message = `must NOT have fewer than ${params.limit} items`;
|
|
1437
|
+
} else if (keyword === 'maxItems') {
|
|
1438
|
+
message = `must NOT have more than ${params.limit} items`;
|
|
1439
|
+
} else if (keyword === 'uniqueItems') {
|
|
1440
|
+
message = 'must NOT have duplicate items';
|
|
1441
|
+
} else if (keyword === 'contains') {
|
|
1442
|
+
message = 'must contain at least one valid item';
|
|
1443
|
+
} else if (keyword === 'items') {
|
|
1444
|
+
message = 'array items are invalid';
|
|
1445
|
+
} else if (keyword === 'allOf') {
|
|
1446
|
+
message = 'must match all of the subschemas';
|
|
1447
|
+
} else if (keyword === 'anyOf') {
|
|
1448
|
+
message = 'must match a subschema in anyOf';
|
|
1449
|
+
} else if (keyword === 'oneOf') {
|
|
1450
|
+
message = 'must match exactly one subschema in oneOf';
|
|
1451
|
+
} else if (keyword === 'not') {
|
|
1452
|
+
message = 'must NOT match the subschema';
|
|
1453
|
+
} else if (keyword === 'format') {
|
|
1454
|
+
const formatName = err.expected || err.value;
|
|
1455
|
+
params.format = formatName;
|
|
1456
|
+
message = `must match format "${formatName}"`;
|
|
1457
|
+
} else if (keyword === 'if') {
|
|
1458
|
+
message = 'must match "if" schema';
|
|
1459
|
+
} else if (keyword === 'then') {
|
|
1460
|
+
message = 'must match "then" schema';
|
|
1461
|
+
} else if (keyword === 'else') {
|
|
1462
|
+
message = 'must match "else" schema';
|
|
1463
|
+
} else if (keyword === 'false schema') {
|
|
1464
|
+
message = 'boolean schema false is always invalid';
|
|
1465
|
+
} else if (keyword === '$query') {
|
|
1466
|
+
message = params.code
|
|
1467
|
+
? `'$query' assertion raised ${params.code} at '${params.docPath}'`
|
|
1468
|
+
: "must satisfy the '$query' assertion";
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Validators pass the data path as the first meta argument to the
|
|
1472
|
+
// error handler; use it when it looks like a JSON pointer.
|
|
1473
|
+
const meta0 = err.rest?.[0];
|
|
1474
|
+
const instancePath = (typeof meta0 === 'string' && (meta0 === '' || meta0.charCodeAt(0) === 0x2f))
|
|
1475
|
+
? meta0
|
|
1476
|
+
: '';
|
|
1477
|
+
|
|
1478
|
+
return new ValidationError({
|
|
1479
|
+
keyword,
|
|
1480
|
+
instancePath,
|
|
1481
|
+
schemaPath: err.object?.path || '',
|
|
1482
|
+
params,
|
|
1483
|
+
message,
|
|
1484
|
+
});
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
*
|
|
1490
|
+
* @param {JarenValidator} self
|
|
1491
|
+
* @param {string} origin
|
|
1492
|
+
* @param {Map} schemas
|
|
1493
|
+
* @returns {(data) => boolean | {valid: boolean, errors: ValidationError[]}}
|
|
1494
|
+
*/
|
|
1495
|
+
static #compileSchema(self, origin, schemas) {
|
|
1496
|
+
const root = new ValidationRoot(
|
|
1497
|
+
origin,
|
|
1498
|
+
schemas,
|
|
1499
|
+
self.#formats,
|
|
1500
|
+
self.#options.validation,
|
|
1501
|
+
self.#options.traverse,
|
|
1502
|
+
self);
|
|
1503
|
+
|
|
1504
|
+
const collectErrors = self.#options.validation?.collectErrors || false;
|
|
1505
|
+
|
|
1506
|
+
function jarenValidateSchema(data) {
|
|
1507
|
+
const valid = root.validate(data);
|
|
1508
|
+
if (collectErrors) {
|
|
1509
|
+
return {
|
|
1510
|
+
valid,
|
|
1511
|
+
errors: valid ? [] : JarenValidator.#convertErrors(root.errors)
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
return valid;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
Object.defineProperty(jarenValidateSchema, "errors", {
|
|
1518
|
+
get: function () { return root.errors }
|
|
1519
|
+
})
|
|
1520
|
+
|
|
1521
|
+
return jarenValidateSchema;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Compile schema using a pre-created ValidationRoot (with pre-compiled refs).
|
|
1526
|
+
* @param {JarenValidator} self
|
|
1527
|
+
* @param {string} origin
|
|
1528
|
+
* @param {Map} schemas
|
|
1529
|
+
* @param {ValidationRoot} root - Pre-created root with pre-compiled refs
|
|
1530
|
+
* @returns {(data) => boolean | {valid: boolean, errors: ValidationError[]}}
|
|
1531
|
+
*/
|
|
1532
|
+
static #compileSchemaWithRoot(self, origin, schemas, root) {
|
|
1533
|
+
const collectErrors = self.#options.validation?.collectErrors || false;
|
|
1534
|
+
|
|
1535
|
+
function jarenValidateSchema(data) {
|
|
1536
|
+
const valid = root.validate(data);
|
|
1537
|
+
if (collectErrors) {
|
|
1538
|
+
return {
|
|
1539
|
+
valid,
|
|
1540
|
+
errors: valid ? [] : JarenValidator.#convertErrors(root.errors)
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
return valid;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
Object.defineProperty(jarenValidateSchema, "errors", {
|
|
1547
|
+
get: function () { return root.errors }
|
|
1548
|
+
})
|
|
1549
|
+
|
|
1550
|
+
return jarenValidateSchema;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
static normalizeUriKey(key) {
|
|
1554
|
+
return key;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Adds meta-schema(s) that can be used to validate schemas.
|
|
1559
|
+
* Meta-schemas are schemas that describe the structure of valid JSON schemas.
|
|
1560
|
+
* @param {JSONSchema | boolean | (JSONSchema | boolean)[]} schema - The meta-schema(s) to add
|
|
1561
|
+
* @param {string} [key] - Optional key/URI for the meta-schema
|
|
1562
|
+
* @returns {JarenValidator} This validator instance for chaining
|
|
1563
|
+
* @example
|
|
1564
|
+
* validator.addMetaSchema(draft7MetaSchema, 'http://json-schema.org/draft-07/schema');
|
|
1565
|
+
*/
|
|
1566
|
+
addMetaSchema(schema, key = undefined) {
|
|
1567
|
+
key = JarenValidator.normalizeUriKey(key)
|
|
1568
|
+
if (Array.isArray(schema)) {
|
|
1569
|
+
const first = schema.shift();
|
|
1570
|
+
const { origin, map } = JarenValidator.#traverseSchema(first, schema, undefined, new TraverseOptions(key));
|
|
1571
|
+
const compiled = JarenValidator.#compileSchema(this, origin, map);
|
|
1572
|
+
this.#metaSchemas.set(origin, compiled);
|
|
1573
|
+
mergeMap(this.#schemas, map);
|
|
1574
|
+
}
|
|
1575
|
+
else if (isBoolOrObjectClass(schema)) {
|
|
1576
|
+
const { origin, map } = JarenValidator.#traverseSchema(schema, undefined, undefined, new TraverseOptions(key));
|
|
1577
|
+
const compiled = JarenValidator.#compileSchema(this, origin, map);
|
|
1578
|
+
this.#metaSchemas.set(origin, compiled);
|
|
1579
|
+
mergeMap(this.#schemas, map);
|
|
1580
|
+
}
|
|
1581
|
+
return this;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* Retrieves a registered schema by its key/URI.
|
|
1586
|
+
* @param {string} key - The schema URI/key
|
|
1587
|
+
* @returns {JSONSchema | boolean | null} The registered schema, or null if not found
|
|
1588
|
+
*/
|
|
1589
|
+
getSchema(key) {
|
|
1590
|
+
key = JarenValidator.normalizeUriKey(key)
|
|
1591
|
+
return this.#schemas.get(key) || null;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/**
|
|
1595
|
+
* Validates a schema against a registered meta-schema.
|
|
1596
|
+
* This is used to ensure schemas are valid according to the JSON Schema specification.
|
|
1597
|
+
* @param {JSONSchema | boolean} schema - The schema to validate
|
|
1598
|
+
* @returns {boolean} True if the schema is valid
|
|
1599
|
+
* @example
|
|
1600
|
+
* validator.addMetaSchema(draft7MetaSchema);
|
|
1601
|
+
* const isValid = validator.validateSchema({ type: 'string' }); // true
|
|
1602
|
+
*/
|
|
1603
|
+
validateSchema(schema) {
|
|
1604
|
+
// if no meta schema is present, just return true;
|
|
1605
|
+
if (this.#metaSchemas.size == 0)
|
|
1606
|
+
return true;
|
|
1607
|
+
|
|
1608
|
+
const schemaId = JarenValidator.normalizeUriKey(schema.$schema || DEFAULT_SCHEMA_DRAFT);
|
|
1609
|
+
const metaSchema = this.#metaSchemas.get(schemaId);
|
|
1610
|
+
// if the metaSchema is not present, we fail the validation
|
|
1611
|
+
if (!metaSchema)
|
|
1612
|
+
return false;
|
|
1613
|
+
|
|
1614
|
+
// otherwise, validate the schema
|
|
1615
|
+
return metaSchema(schema);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* Pre-compile refs to eliminate validation-time overhead.
|
|
1620
|
+
* Creates ValidationObjects for all refs in the schemas map during compile time.
|
|
1621
|
+
* @param {ValidationRoot} root - The validation root
|
|
1622
|
+
* @param {Map} schemas - The schemas map
|
|
1623
|
+
* @param {string} origin - The origin schema ID
|
|
1624
|
+
*/
|
|
1625
|
+
static #precompileRefs(root, schemas, origin) {
|
|
1626
|
+
// Pre-create validation objects for all refs in the schemas map
|
|
1627
|
+
// This moves ref resolution from validation time to compile time
|
|
1628
|
+
|
|
1629
|
+
// First pass: Create objects for schemas with $id (canonical paths)
|
|
1630
|
+
// These establish the base URIs for their descendants
|
|
1631
|
+
for (const [id, schema] of schemas.entries()) {
|
|
1632
|
+
// Skip if already compiled
|
|
1633
|
+
if (root.unresolvedObject(id) !== null) continue;
|
|
1634
|
+
|
|
1635
|
+
// Skip null placeholders
|
|
1636
|
+
if (schema == null) continue;
|
|
1637
|
+
|
|
1638
|
+
// Only process schemas with $id that are stored under their $id path
|
|
1639
|
+
// (not JSON pointer paths)
|
|
1640
|
+
if (!isObjectClass(schema) || !schema.$id) continue;
|
|
1641
|
+
|
|
1642
|
+
// Check if this id matches the resolved $id
|
|
1643
|
+
const { id: resolvedId } = createJsonPointer(schema.$id, origin);
|
|
1644
|
+
if (id === resolvedId || id === resolvedId + '#') {
|
|
1645
|
+
// This is a canonical $id path - create with origin as base
|
|
1646
|
+
try {
|
|
1647
|
+
root.createObject(id, schema, origin);
|
|
1648
|
+
} catch (e) {
|
|
1649
|
+
// May fail if dependencies not resolved yet
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// Second pass: Create objects for remaining schemas
|
|
1655
|
+
// This includes:
|
|
1656
|
+
// 1. JSON pointer paths (e.g., #/definitions/x) - calculate baseUri by finding nearest ancestor $id
|
|
1657
|
+
// 2. Canonical $id paths with relative $ids that weren't matched in first pass
|
|
1658
|
+
for (const [id, schema] of schemas.entries()) {
|
|
1659
|
+
// Skip if already compiled
|
|
1660
|
+
if (root.unresolvedObject(id) !== null) continue;
|
|
1661
|
+
|
|
1662
|
+
// Skip null placeholders
|
|
1663
|
+
if (schema == null) continue;
|
|
1664
|
+
|
|
1665
|
+
// Skip schemas without $id that aren't refs - they're subschemas
|
|
1666
|
+
// that will be reached through traversal from a parent
|
|
1667
|
+
const hasId = isObjectClass(schema) && schema.$id;
|
|
1668
|
+
const isJsonPointerPath = id.includes('#/');
|
|
1669
|
+
const isCanonicalPath = !isJsonPointerPath && id.endsWith('#');
|
|
1670
|
+
|
|
1671
|
+
if (!hasId && !isJsonPointerPath) continue;
|
|
1672
|
+
|
|
1673
|
+
// Calculate baseUri for this schema
|
|
1674
|
+
let baseUri = origin;
|
|
1675
|
+
|
|
1676
|
+
if (isJsonPointerPath) {
|
|
1677
|
+
// JSON pointer path - traverse from root to find nearest $id ancestor
|
|
1678
|
+
const hashIndex = id.indexOf('#/');
|
|
1679
|
+
const baseDoc = id.substring(0, hashIndex);
|
|
1680
|
+
const pointer = id.substring(hashIndex + 1);
|
|
1681
|
+
const pointerParts = pointer.split('/').filter(p => p);
|
|
1682
|
+
|
|
1683
|
+
const rootId = baseDoc + '#';
|
|
1684
|
+
const rootSchema = schemas.get(rootId);
|
|
1685
|
+
|
|
1686
|
+
// Check if the root schema has an $id that matches the baseDoc.
|
|
1687
|
+
// If so, the baseDoc is already the resolved $id and we shouldn't
|
|
1688
|
+
// apply $id resolution during traversal (that would double-resolve).
|
|
1689
|
+
let rootIdMatchesBaseDoc = false;
|
|
1690
|
+
if (rootSchema && isObjectClass(rootSchema) && rootSchema.$id) {
|
|
1691
|
+
const { id: resolvedRootId } = createJsonPointer(rootSchema.$id, origin);
|
|
1692
|
+
const resolvedRootBase = resolvedRootId.endsWith('#') ? resolvedRootId.slice(0, -1) : resolvedRootId;
|
|
1693
|
+
if (baseDoc === resolvedRootBase) {
|
|
1694
|
+
rootIdMatchesBaseDoc = true;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
let currentSchema = rootSchema;
|
|
1699
|
+
let currentBaseUri = baseDoc;
|
|
1700
|
+
|
|
1701
|
+
// Traverse and find the nearest $id ancestor
|
|
1702
|
+
for (let i = 0; i < pointerParts.length && currentSchema; i++) {
|
|
1703
|
+
const part = pointerParts[i];
|
|
1704
|
+
|
|
1705
|
+
// Check if current schema has $id (before moving to child)
|
|
1706
|
+
if (isObjectClass(currentSchema) && currentSchema.$id) {
|
|
1707
|
+
const isRootSchema = (i === 0);
|
|
1708
|
+
const shouldApplyId = !isRootSchema || !rootIdMatchesBaseDoc;
|
|
1709
|
+
|
|
1710
|
+
if (shouldApplyId) {
|
|
1711
|
+
const { id: resolvedId } = createJsonPointer(currentSchema.$id, currentBaseUri);
|
|
1712
|
+
currentBaseUri = resolvedId.endsWith('#') ? resolvedId.slice(0, -1) : resolvedId;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Move to next level - handle both direct properties and definitions/$defs
|
|
1717
|
+
const nextSchema = currentSchema[part] ||
|
|
1718
|
+
currentSchema.$defs?.[part] ||
|
|
1719
|
+
currentSchema.definitions?.[part];
|
|
1720
|
+
currentSchema = nextSchema;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
baseUri = currentBaseUri;
|
|
1724
|
+
} else if (hasId && isCanonicalPath) {
|
|
1725
|
+
// Canonical $id path that wasn't handled in first pass
|
|
1726
|
+
// This happens when the $id is relative and resolves differently
|
|
1727
|
+
// than against the origin. We need to find the correct base URI.
|
|
1728
|
+
|
|
1729
|
+
// Find any schema in the map that has this schema as a descendant
|
|
1730
|
+
// and use its $id as the base
|
|
1731
|
+
for (const [candidateId, candidateSchema] of schemas.entries()) {
|
|
1732
|
+
if (!candidateSchema || candidateSchema === schema) continue;
|
|
1733
|
+
|
|
1734
|
+
// Check if candidate is an ancestor by checking if our id starts with candidate's path
|
|
1735
|
+
if (isJsonPointerPath && id.startsWith(candidateId.replace('#', '#/') + '/')) {
|
|
1736
|
+
// This is a descendant of a JSON pointer path - skip for now
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// If candidate has an $id, it could be our base
|
|
1741
|
+
if (isObjectClass(candidateSchema) && candidateSchema.$id) {
|
|
1742
|
+
// Try resolving our $id against this candidate's resolved $id
|
|
1743
|
+
const { id: candidateResolvedId } = createJsonPointer(candidateSchema.$id, origin);
|
|
1744
|
+
const candidateBase = candidateResolvedId.endsWith('#') ? candidateResolvedId.slice(0, -1) : candidateResolvedId;
|
|
1745
|
+
|
|
1746
|
+
try {
|
|
1747
|
+
const { id: testResolvedId } = createJsonPointer(schema.$id, candidateBase);
|
|
1748
|
+
const testResolvedIdWithHash = testResolvedId.endsWith('#') ? testResolvedId : testResolvedId + '#';
|
|
1749
|
+
|
|
1750
|
+
if (id === testResolvedIdWithHash || id === testResolvedId) {
|
|
1751
|
+
baseUri = candidateBase;
|
|
1752
|
+
break;
|
|
1753
|
+
}
|
|
1754
|
+
} catch (e) {
|
|
1755
|
+
// Invalid URL, skip this candidate
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
try {
|
|
1762
|
+
root.createObject(id, schema, baseUri);
|
|
1763
|
+
} catch (e) {
|
|
1764
|
+
// Ref may not be resolvable yet, that's ok
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* Compiles a schema into a validation function.
|
|
1771
|
+
* This is the main method for creating validators. It resolves all $ref references,
|
|
1772
|
+
* compiles the schema structure, and returns a function that validates data.
|
|
1773
|
+
* @param {JSONSchema | boolean} schema - The schema to compile
|
|
1774
|
+
* @param {(JSONSchema | boolean)[]} [schemas] - Additional schemas to reference during compilation
|
|
1775
|
+
* @returns {(data: any) => boolean | {valid: boolean, errors: ValidationError[]}} A validation function
|
|
1776
|
+
* @example
|
|
1777
|
+
* const validate = validator.compile({
|
|
1778
|
+
* type: 'object',
|
|
1779
|
+
* properties: {
|
|
1780
|
+
* name: { type: 'string' }
|
|
1781
|
+
* }
|
|
1782
|
+
* });
|
|
1783
|
+
*
|
|
1784
|
+
* const valid = validate({ name: 'John' }); // true
|
|
1785
|
+
* const invalid = validate({ name: 123 }); // false
|
|
1786
|
+
*
|
|
1787
|
+
* // With error collection
|
|
1788
|
+
* validator = new JarenValidator({ collectErrors: true });
|
|
1789
|
+
* const result = validate({ name: 123 });
|
|
1790
|
+
* // result = { valid: false, errors: [...] }
|
|
1791
|
+
*/
|
|
1792
|
+
compile(schema, schemas = undefined) {
|
|
1793
|
+
const { origin, map } = JarenValidator.#traverseSchema(schema, schemas, this.#schemas, this.#options.traverse);
|
|
1794
|
+
|
|
1795
|
+
// Detect draft version from schema and update validation options
|
|
1796
|
+
const draftVersion = detectSchemaDraft(schema);
|
|
1797
|
+
const existingValidation = this.#options.validation || new ValidationOptions();
|
|
1798
|
+
// For draft7, contentValidation defaults to true; for 2019-09+, defaults to false
|
|
1799
|
+
const contentValidationDefault = draftVersion < 2019;
|
|
1800
|
+
|
|
1801
|
+
// When the schema declares a custom metaschema via $schema, its
|
|
1802
|
+
// $vocabulary decides which keyword vocabularies are asserted. A
|
|
1803
|
+
// metaschema that omits the validation vocabulary turns keywords like
|
|
1804
|
+
// 'type' and 'minimum' into annotations that assert nothing.
|
|
1805
|
+
let vocabValidation = true;
|
|
1806
|
+
// In draft 2020-12 the format keyword is annotation-only unless the
|
|
1807
|
+
// metaschema opts into the format-assertion vocabulary (or the user
|
|
1808
|
+
// sets the formatAssertion option explicitly).
|
|
1809
|
+
let formatAssertion = existingValidation.formatAssertion ?? null;
|
|
1810
|
+
if (isObjectClass(schema) && isStringType(schema.$schema)) {
|
|
1811
|
+
const metaKey = JarenValidator.normalizeUriKey(schema.$schema);
|
|
1812
|
+
const metaSchema = map.get(metaKey)
|
|
1813
|
+
|| map.get(metaKey.endsWith('#') ? metaKey.slice(0, -1) : metaKey + '#');
|
|
1814
|
+
if (isObjectClass(metaSchema) && isObjectClass(metaSchema.$vocabulary)) {
|
|
1815
|
+
vocabValidation = Object.keys(metaSchema.$vocabulary)
|
|
1816
|
+
.some(uri => uri.includes('/vocab/validation'));
|
|
1817
|
+
if (formatAssertion == null
|
|
1818
|
+
&& Object.keys(metaSchema.$vocabulary).some(uri => uri.includes('/vocab/format-assertion'))) {
|
|
1819
|
+
formatAssertion = true;
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
if (formatAssertion == null) {
|
|
1824
|
+
formatAssertion = draftVersion < 2020;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
const validationOptions = new ValidationOptions(
|
|
1828
|
+
existingValidation.skipErrors ?? true,
|
|
1829
|
+
existingValidation.useGrapheme ?? true,
|
|
1830
|
+
existingValidation.collectErrors ?? false,
|
|
1831
|
+
existingValidation.contentValidation ?? contentValidationDefault,
|
|
1832
|
+
draftVersion,
|
|
1833
|
+
vocabValidation,
|
|
1834
|
+
formatAssertion
|
|
1835
|
+
);
|
|
1836
|
+
|
|
1837
|
+
// Pre-compile all refs before returning the validator
|
|
1838
|
+
// This ensures all ref chains are resolved at compile time
|
|
1839
|
+
const root = new ValidationRoot(
|
|
1840
|
+
origin,
|
|
1841
|
+
map,
|
|
1842
|
+
this.#formats,
|
|
1843
|
+
validationOptions,
|
|
1844
|
+
this.#options.traverse,
|
|
1845
|
+
this
|
|
1846
|
+
);
|
|
1847
|
+
|
|
1848
|
+
// Pre-create validation objects for all refs
|
|
1849
|
+
JarenValidator.#precompileRefs(root, map, origin);
|
|
1850
|
+
|
|
1851
|
+
// Re-compile with the pre-populated root
|
|
1852
|
+
return JarenValidator.#compileSchemaWithRoot(this, origin, map, root);
|
|
1853
|
+
}
|
|
1854
|
+
}
|