ata-validator 1.52.0 → 1.54.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/dialect.js CHANGED
@@ -28,4 +28,39 @@ function isV1Dialect(schema) {
28
28
  return V1_DIALECTS.has(uri);
29
29
  }
30
30
 
31
- module.exports = { isV1Dialect, V1_DIALECTS };
31
+ // The earlier dialects by their meta-schema URIs. draftCode is asked of every
32
+ // schema, so it lives here, in a file every validator loads, and the
33
+ // normalizers in draft7.js load only for a schema that declares one of these.
34
+ // draft-06 is read through the draft-07 rules (see draft7.js).
35
+ const DRAFT7_SCHEMAS = new Set([
36
+ 'http://json-schema.org/draft-07/schema#',
37
+ 'http://json-schema.org/draft-07/schema',
38
+ 'http://json-schema.org/draft-06/schema#',
39
+ 'http://json-schema.org/draft-06/schema',
40
+ ]);
41
+ const DRAFT6_SCHEMAS = new Set([
42
+ 'http://json-schema.org/draft-06/schema#',
43
+ 'http://json-schema.org/draft-06/schema',
44
+ ]);
45
+ // draft-04 is read through the draft-06 rules plus its own: `id` for `$id`,
46
+ // and no `const`, `contains` or `propertyNames` (see draft7.js).
47
+ const DRAFT4_SCHEMAS = new Set([
48
+ 'http://json-schema.org/draft-04/schema#',
49
+ 'http://json-schema.org/draft-04/schema',
50
+ ]);
51
+ const DRAFT2019_SCHEMAS = new Set([
52
+ 'https://json-schema.org/draft/2019-09/schema',
53
+ 'https://json-schema.org/draft/2019-09/schema#',
54
+ ]);
55
+
56
+ // 0 for 2020-12 and later, 1 draft-07, 2 draft-06, 3 2019-09, 4 draft-04.
57
+ function draftCode(uri) {
58
+ if (typeof uri !== 'string') return 0;
59
+ if (DRAFT4_SCHEMAS.has(uri)) return 4;
60
+ if (DRAFT6_SCHEMAS.has(uri)) return 2;
61
+ if (DRAFT7_SCHEMAS.has(uri)) return 1;
62
+ if (DRAFT2019_SCHEMAS.has(uri)) return 3;
63
+ return 0;
64
+ }
65
+
66
+ module.exports = { isV1Dialect, V1_DIALECTS, DRAFT7_SCHEMAS, DRAFT6_SCHEMAS, DRAFT4_SCHEMAS, DRAFT2019_SCHEMAS, draftCode };
package/lib/draft7.js CHANGED
@@ -6,16 +6,7 @@
6
6
  // this, a draft-06 schema was read under 2020-12 and its tuple `items`,
7
7
  // `additionalItems` and `dependencies` were not applied: 30 cases of the
8
8
  // official draft-06 suite accepted documents they must reject.
9
- const DRAFT7_SCHEMAS = new Set([
10
- 'http://json-schema.org/draft-07/schema#',
11
- 'http://json-schema.org/draft-07/schema',
12
- 'http://json-schema.org/draft-06/schema#',
13
- 'http://json-schema.org/draft-06/schema',
14
- ])
15
- const DRAFT6_SCHEMAS = new Set([
16
- 'http://json-schema.org/draft-06/schema#',
17
- 'http://json-schema.org/draft-06/schema',
18
- ])
9
+ const { DRAFT7_SCHEMAS, DRAFT6_SCHEMAS, DRAFT4_SCHEMAS, DRAFT2019_SCHEMAS, draftCode } = require('./dialect')
19
10
 
20
11
  function isDraft7(schema) {
21
12
  return !!(schema && typeof schema.$schema === 'string' && DRAFT7_SCHEMAS.has(schema.$schema))
@@ -28,9 +19,11 @@ function isDraft6(schema) {
28
19
  // `force` applies the draft-07 rules to a document that declares no dialect
29
20
  // of its own, which is how a retrieved schema inherits the root's draft.
30
21
  // `draft6` says the document, or the root it inherits from, is draft-06.
31
- function normalizeDraft7(schema, force, draft6) {
22
+ // `draft4`: draft-04, read as draft-06 plus its own differences (below).
23
+ function normalizeDraft7(schema, force, draft6, draft4) {
32
24
  if (!force && !isDraft7(schema)) return schema
33
- _normalize(schema, draft6 === true || isDraft6(schema))
25
+ const d4 = draft4 === true || (typeof schema.$schema === 'string' && DRAFT4_SCHEMAS.has(schema.$schema))
26
+ _normalize(schema, d4 || draft6 === true || isDraft6(schema), d4)
34
27
  return schema
35
28
  }
36
29
 
@@ -38,13 +31,21 @@ function normalizeDraft7(schema, force, draft6) {
38
31
  // definitions are addressable by pointer, annotations are inert.
39
32
  const REF_SIBLINGS_KEPT = new Set(['$ref', '$defs', 'definitions', '$schema', '$comment', 'title', 'description', 'examples', 'default', 'readOnly', 'writeOnly'])
40
33
 
41
- function _normalize(schema, d6) {
34
+ function _normalize(schema, d6, d4) {
42
35
  if (typeof schema !== 'object' || schema === null) return
43
36
 
44
37
  // draft-06 has no conditional: `if`, `then` and `else` are unknown keywords
45
38
  // there, so they are dropped rather than applied.
46
39
  if (d6) { delete schema.if; delete schema.then; delete schema.else }
47
40
 
41
+ // draft-04 names a schema's identifier `id`, and has none of draft-06's
42
+ // `const`, `contains` or `propertyNames`: unknown keywords there.
43
+ if (d4) {
44
+ delete schema.const; delete schema.contains; delete schema.propertyNames
45
+ if (typeof schema.id === 'string' && schema.$id === undefined && typeof schema.$ref !== 'string') schema.$id = schema.id
46
+ delete schema.id
47
+ }
48
+
48
49
  // In draft-07 an object with `$ref` is that reference and nothing else:
49
50
  // sibling keywords, `$id` included, are ignored. Dropping them here gives
50
51
  // every engine the same reading without each having to know the draft.
@@ -91,34 +92,38 @@ function _normalize(schema, d6) {
91
92
  }
92
93
 
93
94
  // 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])) {
98
- if (typeof v === 'object' && v !== null) _normalize(v, d6)
95
+ for (const key of D7_OBJ_SUBS) {
96
+ const m = schema[key]
97
+ if (m && typeof m === 'object') {
98
+ for (const k in m) {
99
+ if (!Object.prototype.hasOwnProperty.call(m, k)) continue
100
+ const v = m[k]
101
+ if (typeof v === 'object' && v !== null) _normalize(v, d6, d4)
99
102
  }
100
103
  }
101
104
  }
102
105
 
103
106
  // 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)
107
+ for (const key of D7_ARR_SUBS) {
108
+ const a = schema[key]
109
+ if (Array.isArray(a)) {
110
+ for (let i = 0; i < a.length; i++) {
111
+ const v = a[i]
112
+ if (typeof v === 'object' && v !== null) _normalize(v, d6, d4)
109
113
  }
110
114
  }
111
115
  }
112
116
 
113
117
  // 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
- }
118
+ for (const key of D7_SINGLE_SUBS) {
119
+ const v = schema[key]
120
+ if (typeof v === 'object' && v !== null) _normalize(v, d6, d4)
120
121
  }
121
122
  }
123
+ // The schema-bearing keywords _normalize walks, built once rather than per node.
124
+ const D7_OBJ_SUBS = ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']
125
+ const D7_ARR_SUBS = ['allOf', 'anyOf', 'oneOf', 'prefixItems']
126
+ const D7_SINGLE_SUBS = ['items', 'contains', 'not', 'if', 'then', 'else', 'additionalProperties', 'propertyNames']
122
127
 
123
128
  // OpenAPI `nullable: true` is not JSON Schema. Convert it to a union with
124
129
  // 'null' (`{ type: 'X', nullable: true }` -> `{ type: ['X', 'null'] }`), the
@@ -170,12 +175,36 @@ function _walkExclusive(node, seen) {
170
175
 
171
176
  function normalizeNullable(schema) {
172
177
  if (typeof schema !== 'object' || schema === null) return schema
173
- _normalizeNullable(schema)
178
+ const st = { seen: new Set(), refs: [] }
179
+ _normalizeNullable(schema, st)
180
+ // A local `$ref` can point anywhere in the document, an OpenAPI
181
+ // `components` section among them, where the walk over subschema keywords
182
+ // does not go. A schema reached that way is a schema all the same, and its
183
+ // `nullable` is folded too.
184
+ while (st.refs.length > 0) {
185
+ const target = _localTarget(schema, st.refs.pop())
186
+ if (typeof target === 'object' && target !== null && !Array.isArray(target)) _normalizeNullable(target, st)
187
+ }
174
188
  return schema
175
189
  }
176
190
 
177
- function _normalizeNullable(schema) {
191
+ function _localTarget(root, ref) {
192
+ if (ref === '#') return root
193
+ if (!ref.startsWith('#/')) return undefined
194
+ let node = root
195
+ for (const raw of ref.slice(2).split('/')) {
196
+ let seg = raw.replace(/~1/g, '/').replace(/~0/g, '~')
197
+ if (seg.includes('%')) { try { seg = decodeURIComponent(seg) } catch { return undefined } }
198
+ if (node === null || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, seg)) return undefined
199
+ node = node[seg]
200
+ }
201
+ return node
202
+ }
203
+
204
+ function _normalizeNullable(schema, st) {
178
205
  if (typeof schema !== 'object' || schema === null) return
206
+ if (st.seen.has(schema)) return
207
+ st.seen.add(schema)
179
208
 
180
209
  if (schema.nullable === true && schema.type !== undefined) {
181
210
  if (Array.isArray(schema.type)) {
@@ -195,37 +224,39 @@ function _normalizeNullable(schema) {
195
224
  // alone: the engines decode once more, and `100%` must not be decoded twice.
196
225
  // This walk is the one every normalization runs, so it costs no second pass;
197
226
  // a reference it does not reach keeps the old behaviour, the interpreter.
198
- if (/^#.*%/.test(schema.$ref)) {
227
+ if (typeof schema.$ref === 'string' && schema.$ref.charCodeAt(0) === 35 && schema.$ref.indexOf('%') > 0) {
199
228
  try {
200
229
  const decoded = decodeURIComponent(schema.$ref)
201
230
  if (!decoded.includes('%')) schema.$ref = decoded
202
231
  } catch {}
203
232
  }
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)
233
+ if (typeof schema.$ref === 'string' && schema.$ref.charCodeAt(0) === 35) st.refs.push(schema.$ref)
234
+
235
+ for (const key of D7_OBJ_SUBS) {
236
+ const m = schema[key]
237
+ if (m && typeof m === 'object') {
238
+ for (const k in m) {
239
+ if (!Object.prototype.hasOwnProperty.call(m, k)) continue
240
+ const v = m[k]
241
+ if (typeof v === 'object' && v !== null) _normalizeNullable(v, st)
210
242
  }
211
243
  }
212
244
  }
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)
245
+ for (const key of D7_ARR_SUBS) {
246
+ const a = schema[key]
247
+ if (Array.isArray(a)) {
248
+ for (let i = 0; i < a.length; i++) {
249
+ const v = a[i]
250
+ if (typeof v === 'object' && v !== null) _normalizeNullable(v, st)
218
251
  }
219
252
  }
220
253
  }
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
- }
254
+ for (const key of NULLABLE_SINGLE_SUBS) {
255
+ const v = schema[key]
256
+ if (typeof v === 'object' && v !== null) _normalizeNullable(v, st)
227
257
  }
228
258
  }
259
+ const NULLABLE_SINGLE_SUBS = ['items', 'contains', 'not', 'if', 'then', 'else', 'additionalProperties', 'propertyNames', 'unevaluatedItems', 'unevaluatedProperties']
229
260
 
230
261
  // Draft 2020-12 treats `format` as an annotation unless the assertion
231
262
  // vocabulary is in use. Removing the keyword is exactly that reading, and it
@@ -283,10 +314,6 @@ function _stripFormat(schema, seen) {
283
314
  // carries the anchor, and a plain `$ref: "#"` when it does not, which is what
284
315
  // 2019-09 says it means then. The outward search for the outermost anchored
285
316
  // resource is the same rule in both drafts.
286
- const DRAFT2019_SCHEMAS = new Set([
287
- 'https://json-schema.org/draft/2019-09/schema',
288
- 'https://json-schema.org/draft/2019-09/schema#',
289
- ])
290
317
  const RECURSIVE_ANCHOR = '__ata_recursive_anchor'
291
318
 
292
319
  function isDraft2019(schema) {
@@ -338,12 +365,4 @@ function _normalize2019(node, resourceRoot, isRoot) {
338
365
  // The draft a `$schema` value names, for the normalizers: 2 draft-06,
339
366
  // 1 draft-07, 3 2019-09, 0 anything else. Takes the string so the caller
340
367
  // reads the property once.
341
- function draftCode(uri) {
342
- if (typeof uri !== 'string') return 0
343
- if (DRAFT6_SCHEMAS.has(uri)) return 2
344
- if (DRAFT7_SCHEMAS.has(uri)) return 1
345
- if (DRAFT2019_SCHEMAS.has(uri)) return 3
346
- return 0
347
- }
348
-
349
368
  module.exports = { draftCode, isDraft7, isDraft6, isDraft2019, normalizeDraft2019, normalizeDraft7, normalizeNullable, normalizeExclusiveBounds, stripFormatAssertions }
@@ -16,9 +16,31 @@ const { reprValue, expectedFor, detailFor, rankFor, MISSING, DOC_BASE } = requir
16
16
  // output. A module of its own because only the code generator reaches it, and
17
17
  // ata-validator/lite, which enriches through `enrich`, should not carry it.
18
18
  const NO_EXPECTED = {};
19
+ // `detail` (see detailFor in lib/enrich-error.js), worked out at the site as
20
+ // far as the site allows: the whole string when it depends only on the
21
+ // params, its fixed head when only the received value is left to append, and
22
+ // neither when it depends on the value (type) or the params are not fixed.
23
+ const DETAIL_AT_RUN = 0;
24
+ const DETAIL_FIXED = 1;
25
+ const DETAIL_HEAD = 2;
26
+ const RANGE_KEYWORDS = new Set(['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength']);
27
+ function siteDetail (keyword, params, expected) {
28
+ if (params === undefined || keyword === 'type') return [DETAIL_AT_RUN, undefined];
29
+ switch (keyword) {
30
+ case 'required': return [DETAIL_FIXED, `missing required property "${params.missingProperty}"`];
31
+ case 'additionalProperties': return [DETAIL_FIXED, `unknown property "${params.additionalProperty}"`];
32
+ case 'unevaluatedProperties': return [DETAIL_FIXED, `unevaluated property "${params.unevaluatedProperty}"`];
33
+ case 'format': return [DETAIL_HEAD, `not a valid ${params.format}: `];
34
+ }
35
+ if (RANGE_KEYWORDS.has(keyword) || expected) return [DETAIL_HEAD, `expected ${expected}, found `];
36
+ return [DETAIL_FIXED, undefined];
37
+ }
38
+
19
39
  function errorSite (keyword, schemaPath, params, format) {
20
40
  const dependent = keyword === 'required' && typeof schemaPath === 'string' && schemaPath.endsWith('/dependentRequired');
21
41
  const code = (dependent && 'ATA7005') || codeFor(keyword, format) || 'ATA9001';
42
+ const expected = params === undefined ? NO_EXPECTED : expectedFor({ keyword, params });
43
+ const [detailKind, detail] = siteDetail(keyword, params, expected);
22
44
  return {
23
45
  code,
24
46
  keyword,
@@ -26,7 +48,9 @@ function errorSite (keyword, schemaPath, params, format) {
26
48
  docUrl: DOC_BASE + code,
27
49
  rank: rankFor(keyword),
28
50
  headline: (CODES[code] && CODES[code].headline) || 'validation failed',
29
- expected: params === undefined ? NO_EXPECTED : expectedFor({ keyword, params }),
51
+ expected,
52
+ detailKind,
53
+ detail,
30
54
  };
31
55
  }
32
56
 
@@ -49,7 +73,10 @@ function makeRich (site, path, params, message, value, root, spPrefix) {
49
73
  };
50
74
  const sugg = suggestFor(out, root, at === MISSING ? undefined : at);
51
75
  if (sugg) out.suggestion = sugg;
52
- const detail = detailFor({ keyword: site.keyword, params }, out, at);
76
+ const kind = site.detailKind;
77
+ const detail = kind === DETAIL_FIXED ? site.detail
78
+ : kind === DETAIL_HEAD ? site.detail + out.received
79
+ : detailFor({ keyword: site.keyword, params }, out, at);
53
80
  if (detail !== undefined) out.detail = detail;
54
81
  out.rank = site.rank;
55
82
  return out;
@@ -23,6 +23,8 @@ const FILES = [
23
23
  'safe-regex',
24
24
  'unique-items',
25
25
  'formats',
26
+ // The URI walk formats.js loads; left out with formats (see OPTIONAL).
27
+ 'uri-core',
26
28
  ];
27
29
  // The parts a build can leave out when none of its schemas needs them. The
28
30
  // decision is made on the schemas the runtime walks (as embedded, see
@@ -91,8 +93,10 @@ function errorRuntimeSource(format, version, features, schemaMode) {
91
93
  if (hit !== undefined) return hit;
92
94
  const defs = [];
93
95
  for (const name of schemaMode ? FILES.concat(SCHEMA_FILES) : FILES) {
94
- const src = OPTIONAL[name] !== undefined && !want(name)
95
- ? OPTIONAL[name].stub
96
+ // uri-core goes with formats: only formats.js requires it.
97
+ const part = name === 'uri-core' ? 'formats' : name;
98
+ const src = OPTIONAL[part] !== undefined && !want(part)
99
+ ? (name === 'uri-core' ? 'module.exports = {};' : OPTIONAL[part].stub)
96
100
  : dropCommentLines(fs.readFileSync(path.join(__dirname, name + '.js'), 'utf8'), name);
97
101
  // The files' own require('./x') calls go to the table under another name:
98
102
  // a tool that reads the runtime without scope (knip, a lint rule) took a