@jarenjs/validate 0.9.2 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools.js CHANGED
@@ -23,6 +23,14 @@ import {
23
23
  isArrayish,
24
24
  } from '@jarenjs/core/array';
25
25
 
26
+ import {
27
+ isJsonObject,
28
+ } from '@jarenjs/core/object';
29
+
30
+ import {
31
+ JSONPOINTER_NOTHING,
32
+ } from '@jarenjs/json';
33
+
26
34
  //#region Object
27
35
  export function isBoolOrObjectClass(obj) {
28
36
  return isBooleanType(obj)
@@ -77,6 +85,32 @@ export function hasSchemaDynamicRef(schema) {
77
85
  && !isStringWhiteSpace(schema.$dynamicRef);
78
86
  }
79
87
 
88
+ /**
89
+ * Whether a sibling keyword of unevaluatedProperties already evaluates every
90
+ * property of the instance. additionalProperties (boolean or schema) applies
91
+ * to each property not matched by properties/patternProperties, so once it
92
+ * has passed no property is left unevaluated.
93
+ * @param {object} schema - The schema holding the unevaluatedProperties keyword
94
+ * @returns {boolean} True when the unevaluatedProperties check can never match
95
+ */
96
+ export function hasUnevaluatedPropertiesCoverage(schema) {
97
+ return isBoolOrObjectClass(schema.additionalProperties);
98
+ }
99
+
100
+ /**
101
+ * Whether a sibling keyword of unevaluatedItems already evaluates every item
102
+ * of the instance: a uniform items schema (boolean or object) covers all
103
+ * items beyond any prefixItems, and a tuple-form items with additionalItems
104
+ * covers the items beyond the tuple.
105
+ * @param {object} schema - The schema holding the unevaluatedItems keyword
106
+ * @returns {boolean} True when the unevaluatedItems check can never match
107
+ */
108
+ export function hasUnevaluatedItemsCoverage(schema) {
109
+ const items = schema.items;
110
+ if (isBoolOrObjectClass(items)) return true;
111
+ return isArrayClass(items) && isBoolOrObjectClass(schema.additionalItems);
112
+ }
113
+
80
114
  export function createIsSchemaTypeHandler(type, isStrict = false) {
81
115
  switch (type) {
82
116
  case 'null': return isNullValue;
@@ -109,6 +143,137 @@ export function createIsSchemaTypeHandler(type, isStrict = false) {
109
143
 
110
144
  //#endregion
111
145
 
146
+ //#region Data references
147
+
148
+ /**
149
+ * The fallback resolver of the data-reference keywords (`data`, `$data`):
150
+ * a ref that fails the strict compile keeps the lax keyword semantics, so
151
+ * it resolves as not-found and the keyword asserts nothing.
152
+ * @returns {any} the JSON Pointer not-found sentinel
153
+ */
154
+ export const resolveNothing = () => JSONPOINTER_NOTHING;
155
+
156
+ const isDefined = (data) => data !== undefined;
157
+ const isJsonString = (data) => typeof data === 'string';
158
+ const isAnyValue = () => true;
159
+
160
+ /**
161
+ * Build the keyword validators the two data-reference keywords share
162
+ * verbatim. The `data` keyword (json-everything, absolute + relative
163
+ * pointers via `compileDataRef`) and the Ajv-style `$data` keyword
164
+ * (relative pointers only) differ ONLY in which pointer compiler
165
+ * resolves a ref, so each module passes its own `compileRefResolver`
166
+ * and gets the same fifteen compilers back.
167
+ *
168
+ * Every validator follows one lax contract: a data instance outside the
169
+ * keyword's type, an unresolvable ref, or a resolved constraint of the
170
+ * wrong type asserts nothing.
171
+ *
172
+ * @param {(ref: string) => (dataRoot: any, dataPath: string) => any} compileRefResolver
173
+ * @returns {Record<string, (schemaObj: object, ref: string) => ((data: any, dataPath: string, dataRoot: any) => boolean) | undefined>}
174
+ */
175
+ export function createDataRefCompilers(compileRefResolver) {
176
+ /**
177
+ * @param {string} keyword
178
+ * @param {(data: any) => boolean} accepts - Instance types the keyword constrains
179
+ * @param {(constraint: any) => boolean} expects - Resolved constraint types that assert
180
+ * @param {(data: any, constraint: any) => boolean} isValid
181
+ */
182
+ const constraint = (keyword, accepts, expects, isValid) =>
183
+ (schemaObj, ref) => {
184
+ const addError = schemaObj.createErrorHandler(ref, keyword);
185
+ const resolveRef = compileRefResolver(ref);
186
+
187
+ return function validateDataRefConstraint(data, dataPath, dataRoot) {
188
+ if (!accepts(data)) return true;
189
+
190
+ const value = resolveRef(dataRoot, dataPath);
191
+ if (value === JSONPOINTER_NOTHING || !expects(value)) return true;
192
+
193
+ return isValid(data, value) || addError(data, dataPath, value);
194
+ };
195
+ };
196
+
197
+ const compileFormat = (schemaObj, ref) => {
198
+ const formats = schemaObj.formats;
199
+ if (!formats) return undefined;
200
+
201
+ const addError = schemaObj.createErrorHandler(ref, 'format');
202
+ const resolveRef = compileRefResolver(ref);
203
+
204
+ // The registry holds format COMPILERS; compile (and cache) a validator
205
+ // per referenced format name at validation time.
206
+ const compiled = new Map();
207
+ const mockSchemaObj = {
208
+ createErrorHandler: () => () => false,
209
+ options: { skipErrors: true },
210
+ };
211
+
212
+ return function validateDataRefFormat(data, dataPath, dataRoot) {
213
+ if (typeof data !== 'string') return true;
214
+
215
+ const formatName = resolveRef(dataRoot, dataPath);
216
+ if (formatName === JSONPOINTER_NOTHING || !isStringType(formatName)) return true;
217
+
218
+ let validator = compiled.get(formatName);
219
+ if (validator === undefined) {
220
+ const formatCompiler = formats[formatName];
221
+ validator = null;
222
+ if (formatCompiler) {
223
+ try {
224
+ const candidate = formatCompiler(mockSchemaObj, { format: formatName });
225
+ if (typeof candidate === 'function') validator = candidate;
226
+ } catch (_e) {
227
+ // An uncompilable format asserts nothing
228
+ }
229
+ }
230
+ compiled.set(formatName, validator);
231
+ }
232
+ if (validator === null) return true;
233
+
234
+ return validator(data, dataPath) || addError(data, dataPath, formatName);
235
+ };
236
+ };
237
+
238
+ return {
239
+ __proto__: null,
240
+ minimum: constraint('minimum', isNumberType, isNumberType,
241
+ (data, min) => data >= min),
242
+ maximum: constraint('maximum', isNumberType, isNumberType,
243
+ (data, max) => data <= max),
244
+ exclusiveMinimum: constraint('exclusiveMinimum', isNumberType, isNumberType,
245
+ (data, min) => data > min),
246
+ exclusiveMaximum: constraint('exclusiveMaximum', isNumberType, isNumberType,
247
+ (data, max) => data < max),
248
+ multipleOf: constraint('multipleOf', isNumberType, isNumberType,
249
+ (data, multipleOf) => {
250
+ const q = data / multipleOf;
251
+ return Math.abs(q - Math.round(q)) < 1e-6;
252
+ }),
253
+ minLength: constraint('minLength', isJsonString, isNumberType,
254
+ (data, min) => data.length >= min),
255
+ maxLength: constraint('maxLength', isJsonString, isNumberType,
256
+ (data, max) => data.length <= max),
257
+ pattern: constraint('pattern', isJsonString, isStringType,
258
+ (data, pattern) => new RegExp(pattern, 'u').test(data)),
259
+ minItems: constraint('minItems', Array.isArray, isNumberType,
260
+ (data, min) => data.length >= min),
261
+ maxItems: constraint('maxItems', Array.isArray, isNumberType,
262
+ (data, max) => data.length <= max),
263
+ minProperties: constraint('minProperties', isJsonObject, isNumberType,
264
+ (data, min) => Object.keys(data).length >= min),
265
+ maxProperties: constraint('maxProperties', isJsonObject, isNumberType,
266
+ (data, max) => Object.keys(data).length <= max),
267
+ enum: constraint('enum', isDefined, Array.isArray,
268
+ (data, values) => values.includes(data)),
269
+ const: constraint('const', isDefined, isAnyValue,
270
+ (data, value) => data === value),
271
+ format: compileFormat,
272
+ };
273
+ }
274
+
275
+ //#endregion
276
+
112
277
  /**
113
278
  * Records which properties (string keys) and items (numeric indexes) of a
114
279
  * data instance were successfully evaluated during validation, so that
@@ -174,6 +339,33 @@ export class EvalLog {
174
339
  }
175
340
  }
176
341
 
342
+ /**
343
+ * Combine INDEPENDENT keyword validators without short-circuiting.
344
+ *
345
+ * `a(...) && b(...)` is the right composition in boolean mode: the answer is
346
+ * known at the first failure and nothing is gained by continuing. When errors
347
+ * are recorded it is wrong, because each validator is the only thing that can
348
+ * report its own fault, so the first failure hides every sibling's. This runs
349
+ * all of them and ANDs the results — the boolean answer is identical, the
350
+ * error list is complete.
351
+ *
352
+ * Only use it where the validators genuinely are independent. A precondition
353
+ * (a type guard before a length check) must keep its short-circuit: running
354
+ * past it is meaningless at best and throws at worst.
355
+ * @param {Function[]} validators - Independent validators, in report order
356
+ * @returns {Function} A validator that runs every one of them
357
+ */
358
+ export function combineIndependent(validators) {
359
+ return function validateIndependent(data, dataPath, dataRoot, dataKey) {
360
+ let valid = true;
361
+ for (let i = 0; i < validators.length; ++i) {
362
+ if (validators[i](data, dataPath, dataRoot, dataKey) === false)
363
+ valid = false;
364
+ }
365
+ return valid;
366
+ };
367
+ }
368
+
177
369
  export class ValidationResult {
178
370
  static undefThat() {
179
371
  return new ValidationResult();
package/src/traverse.js CHANGED
@@ -20,9 +20,14 @@ import {
20
20
  isValidHtmlIdentifier,
21
21
  } from '@jarenjs/core/text';
22
22
 
23
+ import {
24
+ encodeJSONPointerSegment,
25
+ decodeJSONPointerSegment,
26
+ } from '@jarenjs/json/pointer';
27
+
23
28
 
24
29
  function encodeJsonPointerKey(key) {
25
- return encodeURIComponent(key.replace('~', '~0').replace('/', '~1'));
30
+ return encodeURIComponent(encodeJSONPointerSegment(key));
26
31
  }
27
32
 
28
33
  export function encodeJsonPointerPath(path, key, index) {
@@ -32,7 +37,11 @@ export function encodeJsonPointerPath(path, key, index) {
32
37
  }
33
38
 
34
39
  function decodeJsonPointerKey(key) {
35
- return decodeURIComponent(key.replace('~0', '~').replace('~1', '/'));
40
+ // Lenient decode: `$ref` fragments arrive here unvalidated, so a stray
41
+ // `~` must pass through instead of throwing like the strict parser does.
42
+ // The percent-decode is this site's own: these keys arrive from a URI
43
+ // fragment, which the plain reference-token decode knows nothing about.
44
+ return decodeJSONPointerSegment(decodeURIComponent(key));
36
45
  }
37
46
 
38
47
  export function decodeJsonPointerPath(path) {
@@ -80,7 +89,7 @@ export function createJsonPointer(refUri, baseUri, opts = new JsonPointerOptions
80
89
  url = new URL(refUri, 'http://example.com/' + effectiveBase);
81
90
  // Restore the original baseUri in the result
82
91
  const href = url.href.replace('http://example.com/', '');
83
- const [uri, fragment] = href.split('#');
92
+ const [, fragment] = href.split('#');
84
93
  return new JsonPointer(
85
94
  effectiveBase + refUri,
86
95
  undefined,
@@ -364,7 +373,7 @@ export function restoreSchemaRefsInMap(schemas, opts = new JsonPointerOptions())
364
373
  if (finalId !== id && !schemas.has(finalId)) {
365
374
  schemas.set(finalId, finalSchema);
366
375
  }
367
- } catch (e) {
376
+ } catch (_e) {
368
377
  // If deep resolution fails (remote ref not loaded), fall back to shallow resolution
369
378
  // This preserves the original behavior for unresolved refs
370
379
  const { schema } = resolveRefSchemaShallow(schemas, id, null, opts);
@@ -7,6 +7,8 @@ import {
7
7
 
8
8
  import {
9
9
  getBoolOrObjectClass,
10
+ hasUnevaluatedPropertiesCoverage,
11
+ hasUnevaluatedItemsCoverage,
10
12
  } from './tools.js';
11
13
 
12
14
  /**
@@ -22,13 +24,20 @@ function compileUnevaluatedProperties(schemaObj, jsonSchema) {
22
24
  const uneval = getBoolOrObjectClass(jsonSchema.unevaluatedProperties);
23
25
  if (uneval == null) return undefined;
24
26
 
27
+ // In skipErrors mode reaching this final-stage check means every sibling
28
+ // keyword passed; with a sibling additionalProperties every property was
29
+ // then evaluated (and logged), so the check can never match and the
30
+ // sibling already produced the annotations any outer check consumes.
31
+ if (schemaObj.options.skipErrors && hasUnevaluatedPropertiesCoverage(jsonSchema))
32
+ return undefined;
33
+
25
34
  const root = schemaObj.root;
26
35
  const addError = schemaObj.createErrorHandler(uneval, 'unevaluatedProperties');
27
36
 
28
37
  // true: everything left over is valid, but counts as evaluated for
29
38
  // any unevaluatedProperties in an outer schema.
30
39
  if (uneval === true) {
31
- return function validateUnevaluatedPropertiesTrue(data, dataPath, dataRoot, mark) {
40
+ return function validateUnevaluatedPropertiesTrue(data, _dataPath, _dataRoot, _mark) {
32
41
  if (!isObjectType(data)) return true;
33
42
  const log = root.evalLog;
34
43
  const keys = Object.keys(data);
@@ -81,11 +90,19 @@ function compileUnevaluatedItems(schemaObj, jsonSchema) {
81
90
  const uneval = getBoolOrObjectClass(jsonSchema.unevaluatedItems);
82
91
  if (uneval == null) return undefined;
83
92
 
93
+ // In skipErrors mode reaching this final-stage check means every sibling
94
+ // keyword passed; with sibling coverage (uniform items, or tuple items
95
+ // plus additionalItems) every item was then evaluated (and logged), so
96
+ // the check can never match and the covering sibling already produced
97
+ // the annotations any outer check consumes.
98
+ if (schemaObj.options.skipErrors && hasUnevaluatedItemsCoverage(jsonSchema))
99
+ return undefined;
100
+
84
101
  const root = schemaObj.root;
85
102
  const addError = schemaObj.createErrorHandler(uneval, 'unevaluatedItems');
86
103
 
87
104
  if (uneval === true) {
88
- return function validateUnevaluatedItemsTrue(data, dataPath, dataRoot, mark) {
105
+ return function validateUnevaluatedItemsTrue(data, _dataPath, _dataRoot, _mark) {
89
106
  if (!isArrayClass(data)) return true;
90
107
  root.evalLog.add(data, -1);
91
108
  return true;
@@ -140,12 +157,17 @@ export function wrapUnevaluated(schemaObj, jsonSchema, validator) {
140
157
  const unevalItems = compileUnevaluatedItems(schemaObj, jsonSchema);
141
158
  if (unevalProps == null && unevalItems == null) return validator;
142
159
 
160
+ const stopAtFirst = root.options.skipErrors;
143
161
  return function validateUnevaluatedSchema(data, dataPath, dataRoot, dataKey) {
144
162
  const log = root.evalLog;
145
163
  const mark = log.mark();
164
+ // The sibling result IS a precondition: unevaluated* reads annotations
165
+ // that a failed sibling may never have produced. But unevaluatedProperties
166
+ // and unevaluatedItems are independent of each other.
146
167
  if (validator(data, dataPath, dataRoot, dataKey) === false) return false;
147
- if (unevalProps != null && unevalProps(data, dataPath, dataRoot, mark) === false) return false;
148
- if (unevalItems != null && unevalItems(data, dataPath, dataRoot, mark) === false) return false;
149
- return true;
168
+ let valid = unevalProps == null || unevalProps(data, dataPath, dataRoot, mark) !== false;
169
+ if (stopAtFirst && !valid) return false;
170
+ if (unevalItems != null && unevalItems(data, dataPath, dataRoot, mark) === false) valid = false;
171
+ return valid;
150
172
  };
151
173
  }