ata-validator 1.52.0 → 1.53.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.
@@ -31,6 +31,8 @@ const REFERENCE_ORDER = [
31
31
  'maxLength', 'minLength', 'pattern', 'format',
32
32
  'maxItems', 'minItems', 'additionalItems', 'prefixItems', 'items', 'contains', 'uniqueItems', 'maxContains', 'minContains', 'unevaluatedItems',
33
33
  'maxProperties', 'minProperties', 'required', 'propertyNames', 'additionalProperties', 'dependencies', 'dependentRequired', 'dependentSchemas', 'properties', 'patternProperties', 'unevaluatedProperties',
34
+ // The reference's `discriminator` option adds the keyword last.
35
+ 'discriminator',
34
36
  ];
35
37
  const REFERENCE_RANK = new Map(REFERENCE_ORDER.map((k, i) => [k, i]));
36
38
 
@@ -78,13 +80,26 @@ function classify(segs) {
78
80
  // segments, and each segment's kind. Everything below used to re-derive these
79
81
  // wherever it needed them, which for one shaped rejection meant splitting the
80
82
  // same pointer three times per error.
83
+ // Memoized by schemaPath: a schema reports a fixed set of paths, and a
84
+ // rejection reshaped every error's path from scratch, which was a third of the
85
+ // compat error path. The key is read, never written, by its users. Bounded so
86
+ // a schema with paths that grow with the data (recursion) cannot fill memory.
87
+ const _keys = new Map();
81
88
  function keyFor(e) {
82
- const rel = pointerOf(e && e.schemaPath ? e.schemaPath : '#');
89
+ const sp = e && e.schemaPath ? e.schemaPath : '#';
90
+ let k = _keys.get(sp);
91
+ if (k !== undefined) return k;
92
+ const rel = pointerOf(sp);
83
93
  const segs = segments(rel);
84
- return { rel, segs, kinds: classify(segs) };
94
+ k = { rel, segs, kinds: classify(segs) };
95
+ if (_keys.size >= 4096) _keys.clear();
96
+ _keys.set(sp, k);
97
+ return k;
85
98
  }
86
99
 
87
- function sortByKeys(errors, keys) {
100
+ // `rankAt(segs, i)`, when given, can rank a keyword by where it sits: a
101
+ // oneOf the discriminator decides is reported where the discriminator is.
102
+ function sortByKeys(errors, keys, rankAt) {
88
103
  if (errors.length < 2) return errors.slice();
89
104
  const keyed = errors.map((e) => ({ e, k: keys.get(e) }));
90
105
  keyed.sort((A, B) => {
@@ -93,8 +108,8 @@ function sortByKeys(errors, keys) {
93
108
  for (let i = 0; i < n; i++) {
94
109
  if (pa[i] === pb[i]) continue;
95
110
  if (ka[i] !== 'kw') return 0;
96
- const ra = REFERENCE_RANK.get(pa[i]);
97
- const rb = REFERENCE_RANK.get(pb[i]);
111
+ const ra = rankAt ? rankAt(pa, i) : REFERENCE_RANK.get(pa[i]);
112
+ const rb = rankAt ? rankAt(pb, i) : REFERENCE_RANK.get(pb[i]);
98
113
  if (ra === undefined || rb === undefined) return 0;
99
114
  return ra - rb;
100
115
  }
@@ -143,7 +158,22 @@ function asReferenceKeyword(e) {
143
158
  return e.keyword === 'not' && e.message === 'boolean schema is false' ? { ...e, keyword: 'false schema' } : e;
144
159
  }
145
160
 
146
- function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors, macroKeywords }) {
161
+ const FRAGMENT = 'ata-compat:fragment:';
162
+
163
+ // Whether a schema holds a reference anywhere, the reference's test for
164
+ // compiling a `$ref` target as a function of its own instead of inlining it.
165
+ const REF_KEYWORDS = new Set(['$ref', '$recursiveRef', '$recursiveAnchor', '$dynamicRef', '$dynamicAnchor']);
166
+ function hasRef(schema) {
167
+ if (schema === null || typeof schema !== 'object') return false;
168
+ for (const key in schema) {
169
+ if (REF_KEYWORDS.has(key)) return true;
170
+ const sch = schema[key];
171
+ if (Array.isArray(sch) ? sch.some(hasRef) : (sch !== null && typeof sch === 'object' && hasRef(sch))) return true;
172
+ }
173
+ return false;
174
+ }
175
+
176
+ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors, macroKeywords, discriminator }) {
147
177
  const branchCache = new Map();
148
178
  const branchValidator = (pointer) => {
149
179
  let v = branchCache.get(pointer);
@@ -156,6 +186,74 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
156
186
  const wrappers = new Set(WRAPPERS);
157
187
  for (const k of macroKeywords) wrappers.add(k);
158
188
 
189
+ // Where a schema path really points. ata reports a keyword reached through
190
+ // a local `$ref` at the referring node (`#/properties/s/maxLength`), the
191
+ // reference at the node the `$ref` names (`#/definitions/S/maxLength`).
192
+ // Walking the document, a segment the node does not have, on a node with a
193
+ // `#/...` `$ref`, continues at the target. Anything else stays as it is.
194
+ // Under the discriminator option a oneOf beside a `discriminator` is the
195
+ // discriminator's: its branch errors come where that keyword runs.
196
+ const discriminated = discriminator === true;
197
+ const rankAt = (segs, i) => {
198
+ if (segs[i] === 'oneOf') {
199
+ const node = walkPointer(rootDoc, canonical(segs.length && i > 0 ? '/' + segs.slice(0, i).join('/') : ''));
200
+ if (node !== null && typeof node === 'object' && node.discriminator && typeof node.discriminator.propertyName === 'string') return REFERENCE_RANK.get('discriminator');
201
+ }
202
+ return REFERENCE_RANK.get(segs[i]);
203
+ };
204
+ const canonCache = new Map();
205
+ const reportCache = new Map();
206
+ // `reported` false: the pointer of the node itself, to read the schema at.
207
+ // `reported` true: the path the reference reports, which differs after a
208
+ // `$ref` it compiles as a function of its own rather than inlining (one
209
+ // whose target holds a `$ref`): errors from there carry the path from
210
+ // that target, `#/...`, as if it were a root.
211
+ function follow(pointer, reported) {
212
+ const cache = reported ? reportCache : canonCache;
213
+ let out = cache.get(pointer);
214
+ if (out !== undefined) return out;
215
+ out = pointer;
216
+ let node = rootDoc, path = '', moved = false, ok = true;
217
+ for (const raw of segments(pointer)) {
218
+ const seg = unescape(raw);
219
+ let first = null, hops = 0;
220
+ for (; hops < 32 && node !== null && typeof node === 'object' && !Array.isArray(node) &&
221
+ !Object.prototype.hasOwnProperty.call(node, seg) && typeof node.$ref === 'string' &&
222
+ (node.$ref.startsWith('#/') || node.$ref.startsWith(FRAGMENT)); hops++) {
223
+ // A schema registered under a fragment-only key (see fragmentKey in
224
+ // compat.js) is named by that key, as the reference names it.
225
+ const registered = node.$ref.startsWith(FRAGMENT);
226
+ if (registered && !reported) { ok = false; break; }
227
+ let target;
228
+ try { target = decodeURIComponent(registered ? node.$ref.slice(FRAGMENT.length) : node.$ref.slice(1)); } catch { target = null; }
229
+ if (target === null) { ok = false; break; }
230
+ node = registered ? (schemas ? schemas[node.$ref] : undefined) : walkPointer(rootDoc, target);
231
+ if (first === null) first = target;
232
+ if (!reported) path = target;
233
+ moved = true;
234
+ }
235
+ if (!ok) break;
236
+ // A `$ref` to a `$ref` is reported at the first target, as the
237
+ // reference reports it, unless the reference compiled the last one on
238
+ // its own.
239
+ if (reported && first !== null) path = hasRef(node) ? '' : first;
240
+ if (node === null || typeof node !== 'object') { ok = false; break; }
241
+ node = Array.isArray(node) ? node[Number(seg)] : node[seg];
242
+ path += '/' + raw;
243
+ }
244
+ if (ok && moved && node !== undefined) out = path;
245
+ if (cache.size >= 4096) cache.clear();
246
+ cache.set(pointer, out);
247
+ return out;
248
+ }
249
+ const canonical = (pointer) => follow(pointer, false);
250
+ function canonicalError(e) {
251
+ const sp = e.schemaPath;
252
+ if (typeof sp !== 'string' || !sp.startsWith('#')) return e;
253
+ const c = follow(sp.slice(1), true);
254
+ return c === sp.slice(1) ? e : { ...e, schemaPath: '#' + c };
255
+ }
256
+
159
257
  // Errors whose schemaPath is already absolute (from the root), as opposed
160
258
  // to relative to the branch validator that produced them.
161
259
  const absolute = new WeakSet();
@@ -163,14 +261,16 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
163
261
  // Returns the shaped errors of `value` against the subschema at `pointer`
164
262
  // (absolute), with schemaPath made absolute and instancePath rebased onto
165
263
  // the caller's.
166
- function subErrors(pointer, value, schemaPrefix, instancePrefix) {
264
+ // `pointer` is where the subschema is; `at`, when given, is the path its
265
+ // errors are reported under, the referring one ata's own errors use.
266
+ function subErrors(pointer, value, schemaPrefix, instancePrefix, at) {
167
267
  const r = branchValidator(pointer).validate(value);
168
268
  if (r.valid) return null;
169
269
  const shaped = shape(r.errors, value, pointer);
170
270
  return shaped.map((e) => {
171
271
  const out = {
172
272
  ...e,
173
- schemaPath: absolute.has(e) ? e.schemaPath : schemaPrefix + pointer + pointerOf(e.schemaPath || '#'),
273
+ schemaPath: absolute.has(e) ? e.schemaPath : schemaPrefix + (at === undefined ? pointer : at) + pointerOf(e.schemaPath || '#'),
174
274
  instancePath: instancePrefix + (e.instancePath || ''),
175
275
  };
176
276
  absolute.add(out);
@@ -187,7 +287,7 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
187
287
  function shape(errors, data, basePointer) {
188
288
  const keys = new Map();
189
289
  for (const e of errors) keys.set(e, keyFor(e));
190
- const sorted = sortByKeys(errors, keys);
290
+ const sorted = sortByKeys(errors, keys, discriminated ? rankAt : undefined);
191
291
  const out = [];
192
292
  const consumed = new Set();
193
293
  let thenGroup = null; // { prefix, which } for the pending `if` wrapper
@@ -208,7 +308,8 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
208
308
  if (consumed.has(e)) continue;
209
309
  const K = keys.get(e);
210
310
  const rel = K.rel;
211
- const abs = basePointer + rel;
311
+ const raw = basePointer + rel;
312
+ const abs = canonical(raw);
212
313
  const segs = K.segs;
213
314
  const kinds = K.kinds;
214
315
  const schemaPrefix = (e.schemaPath || '#').slice(0, (e.schemaPath || '#').indexOf('#') + 1);
@@ -241,14 +342,25 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
241
342
  const value = walkData(data, e.instancePath || '');
242
343
  const branchErrors = [];
243
344
  const passing = [];
345
+ // The validator kept every branch's errors when keywords may have
346
+ // changed the data: validating a branch again would see the change.
347
+ const kept = basePointer === '' && Array.isArray(e._branches) && e._branches.length === branches.length ? e._branches : null;
244
348
  for (let i = 0; i < branches.length; i++) {
245
- const errs = subErrors(abs + '/' + i, value, schemaPrefix, e.instancePath || '');
349
+ let errs;
350
+ if (kept !== null) {
351
+ errs = kept[i].valid ? null : shape(kept[i].errors, data, '');
352
+ if (errs !== null) for (const x of errs) absolute.add(x);
353
+ } else errs = subErrors(abs + '/' + i, value, schemaPrefix, e.instancePath || '', raw + '/' + i);
246
354
  if (errs === null) passing.push(i);
247
355
  else branchErrors.push(allErrors ? errs : errs.slice(0, groupEnd(errs)));
248
356
  }
249
357
  if (passing.length === 0) {
250
358
  for (const errs of branchErrors) out.push(...errs);
251
- out.push(e);
359
+ // The reference's own wording and params for no branch passing;
360
+ // the collapsed error validate() reports names the count instead.
361
+ out.push(kw === 'oneOf'
362
+ ? keywordError('oneOf', e.instancePath || '', e.schemaPath, { passingSchemas: null }, 'must match exactly one schema in oneOf')
363
+ : keywordError('anyOf', e.instancePath || '', e.schemaPath, {}, 'must match a schema in anyOf'));
252
364
  } else if (kw === 'oneOf') {
253
365
  out.push({ ...e, params: { passingSchemas: passing }, message: 'must match exactly one schema in oneOf' });
254
366
  } else {
@@ -291,7 +403,7 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
291
403
  const items = walkData(data, e.instancePath || '');
292
404
  if (Array.isArray(items)) {
293
405
  for (let i = 0; i < items.length; i++) {
294
- const errs = subErrors(abs, items[i], schemaPrefix, (e.instancePath || '') + '/' + i);
406
+ const errs = subErrors(abs, items[i], schemaPrefix, (e.instancePath || '') + '/' + i, raw);
295
407
  if (errs !== null) out.push(...errs);
296
408
  }
297
409
  out.push(e);
@@ -304,7 +416,7 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
304
416
  const depIndex = segs.findIndex((s, i) => kinds[i] === 'kw' && (s === 'dependentRequired' || s === 'dependentSchemas'));
305
417
  if (depIndex >= 0) {
306
418
  const nodePointer = basePointer + (depIndex === 0 ? '' : '/' + segs.slice(0, depIndex).join('/'));
307
- const node = walkPointer(rootDoc, nodePointer);
419
+ const node = walkPointer(rootDoc, canonical(nodePointer));
308
420
  if (node && typeof node === 'object' && node.dependencies && typeof node.dependencies === 'object') {
309
421
  const rewritten = segs.slice();
310
422
  rewritten[depIndex] = 'dependencies';
@@ -379,7 +491,7 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
379
491
  if (e.keyword !== 'type' || typeof e.message !== 'string' || !e.message.startsWith('must be ')) return e;
380
492
  const ptr = pointerOf(e.schemaPath || '');
381
493
  if (!ptr.endsWith('/type')) return e;
382
- const node = walkPointer(rootDoc, ptr.slice(0, -'/type'.length));
494
+ const node = walkPointer(rootDoc, canonical(ptr.slice(0, -'/type'.length)));
383
495
  let t = node && typeof node === 'object' && node.type !== undefined ? node.type : e.params && e.params.type;
384
496
  if (t === undefined) return e;
385
497
  if (node && node.nullable === true && Array.isArray(t) && !t.includes('null')) t = [...t, 'null'];
@@ -387,10 +499,12 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
387
499
  }
388
500
 
389
501
  return function shapeErrors(errors, data) {
502
+ // Paths are made the reference's last: the grouping above reads them as
503
+ // ata reports them, through the referring node.
390
504
  const shaped = shape(errors, data, '').map(asReferenceKeyword).map(asReferenceType);
391
- if (allErrors) return shaped;
505
+ if (allErrors) return shaped.map(canonicalError);
392
506
  const n = groupEnd(shaped);
393
- return shaped.slice(0, n);
507
+ return shaped.slice(0, n).map(canonicalError);
394
508
  };
395
509
  }
396
510
 
package/lib/draft7.js CHANGED
@@ -91,34 +91,38 @@ function _normalize(schema, d6) {
91
91
  }
92
92
 
93
93
  // Recurse into object-valued sub-schemas
94
- const objSubs = ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']
95
- for (const key of objSubs) {
96
- if (schema[key] && typeof schema[key] === 'object') {
97
- for (const v of Object.values(schema[key])) {
94
+ for (const key of D7_OBJ_SUBS) {
95
+ const m = schema[key]
96
+ if (m && typeof m === 'object') {
97
+ for (const k in m) {
98
+ if (!Object.prototype.hasOwnProperty.call(m, k)) continue
99
+ const v = m[k]
98
100
  if (typeof v === 'object' && v !== null) _normalize(v, d6)
99
101
  }
100
102
  }
101
103
  }
102
104
 
103
105
  // Recurse into array-valued sub-schemas
104
- const arrSubs = ['allOf', 'anyOf', 'oneOf', 'prefixItems']
105
- for (const key of arrSubs) {
106
- if (Array.isArray(schema[key])) {
107
- for (const s of schema[key]) {
108
- if (typeof s === 'object' && s !== null) _normalize(s, d6)
106
+ for (const key of D7_ARR_SUBS) {
107
+ const a = schema[key]
108
+ if (Array.isArray(a)) {
109
+ for (let i = 0; i < a.length; i++) {
110
+ const v = a[i]
111
+ if (typeof v === 'object' && v !== null) _normalize(v, d6)
109
112
  }
110
113
  }
111
114
  }
112
115
 
113
116
  // Recurse into single sub-schemas
114
- const singleSubs = ['items', 'contains', 'not', 'if', 'then', 'else',
115
- 'additionalProperties', 'propertyNames']
116
- for (const key of singleSubs) {
117
- if (typeof schema[key] === 'object' && schema[key] !== null) {
118
- _normalize(schema[key], d6)
119
- }
117
+ for (const key of D7_SINGLE_SUBS) {
118
+ const v = schema[key]
119
+ if (typeof v === 'object' && v !== null) _normalize(v, d6)
120
120
  }
121
121
  }
122
+ // The schema-bearing keywords _normalize walks, built once rather than per node.
123
+ const D7_OBJ_SUBS = ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']
124
+ const D7_ARR_SUBS = ['allOf', 'anyOf', 'oneOf', 'prefixItems']
125
+ const D7_SINGLE_SUBS = ['items', 'contains', 'not', 'if', 'then', 'else', 'additionalProperties', 'propertyNames']
122
126
 
123
127
  // OpenAPI `nullable: true` is not JSON Schema. Convert it to a union with
124
128
  // 'null' (`{ type: 'X', nullable: true }` -> `{ type: ['X', 'null'] }`), the
@@ -170,12 +174,36 @@ function _walkExclusive(node, seen) {
170
174
 
171
175
  function normalizeNullable(schema) {
172
176
  if (typeof schema !== 'object' || schema === null) return schema
173
- _normalizeNullable(schema)
177
+ const st = { seen: new Set(), refs: [] }
178
+ _normalizeNullable(schema, st)
179
+ // A local `$ref` can point anywhere in the document, an OpenAPI
180
+ // `components` section among them, where the walk over subschema keywords
181
+ // does not go. A schema reached that way is a schema all the same, and its
182
+ // `nullable` is folded too.
183
+ while (st.refs.length > 0) {
184
+ const target = _localTarget(schema, st.refs.pop())
185
+ if (typeof target === 'object' && target !== null && !Array.isArray(target)) _normalizeNullable(target, st)
186
+ }
174
187
  return schema
175
188
  }
176
189
 
177
- function _normalizeNullable(schema) {
190
+ function _localTarget(root, ref) {
191
+ if (ref === '#') return root
192
+ if (!ref.startsWith('#/')) return undefined
193
+ let node = root
194
+ for (const raw of ref.slice(2).split('/')) {
195
+ let seg = raw.replace(/~1/g, '/').replace(/~0/g, '~')
196
+ if (seg.includes('%')) { try { seg = decodeURIComponent(seg) } catch { return undefined } }
197
+ if (node === null || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, seg)) return undefined
198
+ node = node[seg]
199
+ }
200
+ return node
201
+ }
202
+
203
+ function _normalizeNullable(schema, st) {
178
204
  if (typeof schema !== 'object' || schema === null) return
205
+ if (st.seen.has(schema)) return
206
+ st.seen.add(schema)
179
207
 
180
208
  if (schema.nullable === true && schema.type !== undefined) {
181
209
  if (Array.isArray(schema.type)) {
@@ -195,37 +223,39 @@ function _normalizeNullable(schema) {
195
223
  // alone: the engines decode once more, and `100%` must not be decoded twice.
196
224
  // This walk is the one every normalization runs, so it costs no second pass;
197
225
  // a reference it does not reach keeps the old behaviour, the interpreter.
198
- if (/^#.*%/.test(schema.$ref)) {
226
+ if (typeof schema.$ref === 'string' && schema.$ref.charCodeAt(0) === 35 && schema.$ref.indexOf('%') > 0) {
199
227
  try {
200
228
  const decoded = decodeURIComponent(schema.$ref)
201
229
  if (!decoded.includes('%')) schema.$ref = decoded
202
230
  } catch {}
203
231
  }
204
-
205
- const objSubs = ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']
206
- for (const key of objSubs) {
207
- if (schema[key] && typeof schema[key] === 'object') {
208
- for (const v of Object.values(schema[key])) {
209
- if (typeof v === 'object' && v !== null) _normalizeNullable(v)
232
+ if (typeof schema.$ref === 'string' && schema.$ref.charCodeAt(0) === 35) st.refs.push(schema.$ref)
233
+
234
+ for (const key of D7_OBJ_SUBS) {
235
+ const m = schema[key]
236
+ if (m && typeof m === 'object') {
237
+ for (const k in m) {
238
+ if (!Object.prototype.hasOwnProperty.call(m, k)) continue
239
+ const v = m[k]
240
+ if (typeof v === 'object' && v !== null) _normalizeNullable(v, st)
210
241
  }
211
242
  }
212
243
  }
213
- const arrSubs = ['allOf', 'anyOf', 'oneOf', 'prefixItems']
214
- for (const key of arrSubs) {
215
- if (Array.isArray(schema[key])) {
216
- for (const s of schema[key]) {
217
- if (typeof s === 'object' && s !== null) _normalizeNullable(s)
244
+ for (const key of D7_ARR_SUBS) {
245
+ const a = schema[key]
246
+ if (Array.isArray(a)) {
247
+ for (let i = 0; i < a.length; i++) {
248
+ const v = a[i]
249
+ if (typeof v === 'object' && v !== null) _normalizeNullable(v, st)
218
250
  }
219
251
  }
220
252
  }
221
- const singleSubs = ['items', 'contains', 'not', 'if', 'then', 'else',
222
- 'additionalProperties', 'propertyNames', 'unevaluatedItems', 'unevaluatedProperties']
223
- for (const key of singleSubs) {
224
- if (typeof schema[key] === 'object' && schema[key] !== null) {
225
- _normalizeNullable(schema[key])
226
- }
253
+ for (const key of NULLABLE_SINGLE_SUBS) {
254
+ const v = schema[key]
255
+ if (typeof v === 'object' && v !== null) _normalizeNullable(v, st)
227
256
  }
228
257
  }
258
+ const NULLABLE_SINGLE_SUBS = ['items', 'contains', 'not', 'if', 'then', 'else', 'additionalProperties', 'propertyNames', 'unevaluatedItems', 'unevaluatedProperties']
229
259
 
230
260
  // Draft 2020-12 treats `format` as an annotation unless the assertion
231
261
  // vocabulary is in use. Removing the keyword is exactly that reading, and it