@apidevtools/json-schema-ref-parser 15.2.1 → 15.3.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/dist/lib/bundle.js +78 -0
- package/dist/lib/dereference.js +19 -9
- package/dist/lib/options.d.ts +8 -0
- package/dist/lib/parse.js +2 -2
- package/dist/lib/pointer.js +14 -0
- package/lib/bundle.ts +93 -0
- package/lib/dereference.ts +21 -2
- package/lib/options.ts +9 -0
- package/lib/parse.ts +2 -2
- package/lib/pointer.ts +13 -0
- package/package.json +14 -14
package/dist/lib/bundle.js
CHANGED
|
@@ -16,6 +16,8 @@ function bundle(parser, options) {
|
|
|
16
16
|
crawl(parser, "schema", parser.$refs._root$Ref.path + "#", "#", 0, inventory, parser.$refs, options);
|
|
17
17
|
// Remap all $ref pointers
|
|
18
18
|
remap(inventory, options);
|
|
19
|
+
// Fix any $ref paths that traverse through other $refs (which is invalid per JSON Schema spec)
|
|
20
|
+
fixRefsThroughRefs(inventory, parser.schema);
|
|
19
21
|
}
|
|
20
22
|
/**
|
|
21
23
|
* Recursively crawls the given value, and inventories all JSON references.
|
|
@@ -261,4 +263,80 @@ function removeFromInventory(inventory, entry) {
|
|
|
261
263
|
const index = inventory.indexOf(entry);
|
|
262
264
|
inventory.splice(index, 1);
|
|
263
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* After remapping, some $ref paths may traverse through other $ref nodes.
|
|
268
|
+
* JSON pointer resolution does not follow $ref indirection, so these paths are invalid.
|
|
269
|
+
* This function detects and fixes such paths by following any intermediate $refs
|
|
270
|
+
* to compute a valid direct path.
|
|
271
|
+
*/
|
|
272
|
+
function fixRefsThroughRefs(inventory, schema) {
|
|
273
|
+
for (const entry of inventory) {
|
|
274
|
+
if (!entry.$ref || typeof entry.$ref !== "object" || !("$ref" in entry.$ref)) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const refValue = entry.$ref.$ref;
|
|
278
|
+
if (typeof refValue !== "string" || !refValue.startsWith("#/")) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const fixedPath = resolvePathThroughRefs(schema, refValue);
|
|
282
|
+
if (fixedPath !== refValue) {
|
|
283
|
+
entry.$ref.$ref = fixedPath;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Walks a JSON pointer path through the schema. If any intermediate value
|
|
289
|
+
* is a $ref, follows it and adjusts the path accordingly.
|
|
290
|
+
* Returns the corrected path that doesn't traverse through any $ref.
|
|
291
|
+
*/
|
|
292
|
+
function resolvePathThroughRefs(schema, refPath) {
|
|
293
|
+
if (!refPath.startsWith("#/")) {
|
|
294
|
+
return refPath;
|
|
295
|
+
}
|
|
296
|
+
const segments = refPath.slice(2).split("/");
|
|
297
|
+
let current = schema;
|
|
298
|
+
const resolvedSegments = [];
|
|
299
|
+
for (const seg of segments) {
|
|
300
|
+
if (current === null || current === undefined || typeof current !== "object") {
|
|
301
|
+
// Can't walk further, return original path
|
|
302
|
+
return refPath;
|
|
303
|
+
}
|
|
304
|
+
// If the current value is a $ref, follow it
|
|
305
|
+
if ("$ref" in current && typeof current.$ref === "string" && current.$ref.startsWith("#/")) {
|
|
306
|
+
// Follow the $ref and restart the path from its target
|
|
307
|
+
const targetSegments = current.$ref.slice(2).split("/");
|
|
308
|
+
resolvedSegments.length = 0;
|
|
309
|
+
resolvedSegments.push(...targetSegments);
|
|
310
|
+
current = walkPath(schema, current.$ref);
|
|
311
|
+
if (current === null || current === undefined || typeof current !== "object") {
|
|
312
|
+
return refPath;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const decoded = seg.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
316
|
+
const idx = Array.isArray(current) ? parseInt(decoded) : decoded;
|
|
317
|
+
current = current[idx];
|
|
318
|
+
resolvedSegments.push(seg);
|
|
319
|
+
}
|
|
320
|
+
const result = "#/" + resolvedSegments.join("/");
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Walks a JSON pointer path through a schema object, returning the value at that path.
|
|
325
|
+
*/
|
|
326
|
+
function walkPath(schema, path) {
|
|
327
|
+
if (!path.startsWith("#/")) {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
const segments = path.slice(2).split("/");
|
|
331
|
+
let current = schema;
|
|
332
|
+
for (const seg of segments) {
|
|
333
|
+
if (current === null || current === undefined || typeof current !== "object") {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
const decoded = seg.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
337
|
+
const idx = Array.isArray(current) ? parseInt(decoded) : decoded;
|
|
338
|
+
current = current[idx];
|
|
339
|
+
}
|
|
340
|
+
return current;
|
|
341
|
+
}
|
|
264
342
|
export default bundle;
|
package/dist/lib/dereference.js
CHANGED
|
@@ -13,7 +13,7 @@ export default dereference;
|
|
|
13
13
|
function dereference(parser, options) {
|
|
14
14
|
const start = Date.now();
|
|
15
15
|
// console.log('Dereferencing $ref pointers in %s', parser.$refs._root$Ref.path);
|
|
16
|
-
const dereferenced = crawl(parser.schema, parser.$refs._root$Ref.path, "#", new Set(), new Set(), new Map(), parser.$refs, options, start);
|
|
16
|
+
const dereferenced = crawl(parser.schema, parser.$refs._root$Ref.path, "#", new Set(), new Set(), new Map(), parser.$refs, options, start, 0);
|
|
17
17
|
parser.$refs.circular = dereferenced.circular;
|
|
18
18
|
parser.schema = dereferenced.value;
|
|
19
19
|
}
|
|
@@ -29,9 +29,10 @@ function dereference(parser, options) {
|
|
|
29
29
|
* @param $refs
|
|
30
30
|
* @param options
|
|
31
31
|
* @param startTime - The time when the dereferencing started
|
|
32
|
+
* @param depth - The current recursion depth
|
|
32
33
|
* @returns
|
|
33
34
|
*/
|
|
34
|
-
function crawl(obj, path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime) {
|
|
35
|
+
function crawl(obj, path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth) {
|
|
35
36
|
let dereferenced;
|
|
36
37
|
const result = {
|
|
37
38
|
value: obj,
|
|
@@ -39,13 +40,19 @@ function crawl(obj, path, pathFromRoot, parents, processedObjects, dereferencedC
|
|
|
39
40
|
};
|
|
40
41
|
checkDereferenceTimeout(startTime, options);
|
|
41
42
|
const derefOptions = (options.dereference || {});
|
|
43
|
+
const maxDepth = derefOptions.maxDepth ?? 500;
|
|
44
|
+
if (depth > maxDepth) {
|
|
45
|
+
throw new RangeError(`Maximum dereference depth (${maxDepth}) exceeded at ${pathFromRoot}. ` +
|
|
46
|
+
`This likely indicates an extremely deep or recursive schema. ` +
|
|
47
|
+
`You can increase this limit with the dereference.maxDepth option.`);
|
|
48
|
+
}
|
|
42
49
|
const isExcludedPath = derefOptions.excludedPathMatcher || (() => false);
|
|
43
50
|
if (derefOptions?.circular === "ignore" || !processedObjects.has(obj)) {
|
|
44
51
|
if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(pathFromRoot)) {
|
|
45
52
|
parents.add(obj);
|
|
46
53
|
processedObjects.add(obj);
|
|
47
54
|
if ($Ref.isAllowed$Ref(obj, options)) {
|
|
48
|
-
dereferenced = dereference$Ref(obj, path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime);
|
|
55
|
+
dereferenced = dereference$Ref(obj, path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth);
|
|
49
56
|
result.circular = dereferenced.circular;
|
|
50
57
|
result.value = dereferenced.value;
|
|
51
58
|
}
|
|
@@ -58,9 +65,9 @@ function crawl(obj, path, pathFromRoot, parents, processedObjects, dereferencedC
|
|
|
58
65
|
continue;
|
|
59
66
|
}
|
|
60
67
|
const value = obj[key];
|
|
61
|
-
let circular
|
|
68
|
+
let circular;
|
|
62
69
|
if ($Ref.isAllowed$Ref(value, options)) {
|
|
63
|
-
dereferenced = dereference$Ref(value, keyPath, keyPathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime);
|
|
70
|
+
dereferenced = dereference$Ref(value, keyPath, keyPathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth);
|
|
64
71
|
circular = dereferenced.circular;
|
|
65
72
|
// Avoid pointless mutations; breaks frozen objects to no profit
|
|
66
73
|
if (obj[key] !== dereferenced.value) {
|
|
@@ -91,7 +98,7 @@ function crawl(obj, path, pathFromRoot, parents, processedObjects, dereferencedC
|
|
|
91
98
|
}
|
|
92
99
|
else {
|
|
93
100
|
if (!parents.has(value)) {
|
|
94
|
-
dereferenced = crawl(value, keyPath, keyPathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime);
|
|
101
|
+
dereferenced = crawl(value, keyPath, keyPathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth + 1);
|
|
95
102
|
circular = dereferenced.circular;
|
|
96
103
|
// Avoid pointless mutations; breaks frozen objects to no profit
|
|
97
104
|
if (obj[key] !== dereferenced.value) {
|
|
@@ -124,7 +131,7 @@ function crawl(obj, path, pathFromRoot, parents, processedObjects, dereferencedC
|
|
|
124
131
|
* @param options
|
|
125
132
|
* @returns
|
|
126
133
|
*/
|
|
127
|
-
function dereference$Ref($ref, path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime) {
|
|
134
|
+
function dereference$Ref($ref, path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth) {
|
|
128
135
|
const isExternalRef = $Ref.isExternal$Ref($ref);
|
|
129
136
|
const shouldResolveOnCwd = isExternalRef && options?.dereference?.externalReferenceResolution === "root";
|
|
130
137
|
const $refPath = url.resolve(shouldResolveOnCwd ? url.cwd() : path, $ref.$ref);
|
|
@@ -166,13 +173,16 @@ function dereference$Ref($ref, path, pathFromRoot, parents, processedObjects, de
|
|
|
166
173
|
// overhaul.
|
|
167
174
|
if (typeof cache.value === "object" && "$ref" in cache.value && "$ref" in $ref) {
|
|
168
175
|
if (cache.value.$ref === $ref.$ref) {
|
|
176
|
+
// Fire onCircular for cached circular refs so callers are notified of every occurrence
|
|
177
|
+
foundCircularReference(path, $refs, options);
|
|
169
178
|
return cache;
|
|
170
179
|
}
|
|
171
180
|
else {
|
|
172
|
-
// no-op
|
|
181
|
+
// no-op - fall through to re-process (handles external ref edge case)
|
|
173
182
|
}
|
|
174
183
|
}
|
|
175
184
|
else {
|
|
185
|
+
foundCircularReference(path, $refs, options);
|
|
176
186
|
return cache;
|
|
177
187
|
}
|
|
178
188
|
}
|
|
@@ -194,7 +204,7 @@ function dereference$Ref($ref, path, pathFromRoot, parents, processedObjects, de
|
|
|
194
204
|
// Crawl the dereferenced value (unless it's circular)
|
|
195
205
|
if (!circular) {
|
|
196
206
|
// Determine if the dereferenced value is circular
|
|
197
|
-
const dereferenced = crawl(dereferencedValue, pointer.path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime);
|
|
207
|
+
const dereferenced = crawl(dereferencedValue, pointer.path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth + 1);
|
|
198
208
|
circular = dereferenced.circular;
|
|
199
209
|
dereferencedValue = dereferenced.value;
|
|
200
210
|
}
|
package/dist/lib/options.d.ts
CHANGED
|
@@ -70,6 +70,14 @@ export interface DereferenceOptions {
|
|
|
70
70
|
* Default: `true`
|
|
71
71
|
*/
|
|
72
72
|
mergeKeys?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* The maximum recursion depth for dereferencing nested schemas.
|
|
75
|
+
* If the schema nesting exceeds this depth, a RangeError will be thrown
|
|
76
|
+
* with a descriptive message instead of crashing with a stack overflow.
|
|
77
|
+
*
|
|
78
|
+
* Default: 500
|
|
79
|
+
*/
|
|
80
|
+
maxDepth?: number;
|
|
73
81
|
}
|
|
74
82
|
/**
|
|
75
83
|
* Options that determine how JSON schemas are parsed, resolved, and dereferenced.
|
package/dist/lib/parse.js
CHANGED
|
@@ -67,7 +67,7 @@ async function readFile(file, options, $refs) {
|
|
|
67
67
|
}
|
|
68
68
|
else if (!err || !("error" in err)) {
|
|
69
69
|
// Throw a generic, friendly error.
|
|
70
|
-
throw new SyntaxError(`Unable to resolve $ref pointer "${file.url}"
|
|
70
|
+
throw new SyntaxError(`Unable to resolve $ref pointer "${file.url}"`, { cause: err });
|
|
71
71
|
}
|
|
72
72
|
// Throw the original error, if it's one of our own (user-friendly) errors.
|
|
73
73
|
else if (err.error instanceof ResolverError) {
|
|
@@ -118,7 +118,7 @@ async function parseFile(file, options, $refs) {
|
|
|
118
118
|
throw err;
|
|
119
119
|
}
|
|
120
120
|
else if (!err || !("error" in err)) {
|
|
121
|
-
throw new SyntaxError(`Unable to parse ${file.url}
|
|
121
|
+
throw new SyntaxError(`Unable to parse ${file.url}`, { cause: err });
|
|
122
122
|
}
|
|
123
123
|
else if (err.error instanceof ParserError) {
|
|
124
124
|
throw err.error;
|
package/dist/lib/pointer.js
CHANGED
|
@@ -69,10 +69,24 @@ class Pointer {
|
|
|
69
69
|
// Crawl the object, one token at a time
|
|
70
70
|
this.value = unwrapOrThrow(obj);
|
|
71
71
|
for (let i = 0; i < tokens.length; i++) {
|
|
72
|
+
// During token walking, if the current value is an extended $ref (has sibling keys
|
|
73
|
+
// alongside $ref, as allowed by JSON Schema 2019-09+), and resolveIf$Ref marks it
|
|
74
|
+
// as circular because the $ref resolves to the same path we're walking, we should
|
|
75
|
+
// reset the circular flag and continue walking the object's own properties.
|
|
76
|
+
// This prevents false circular detection when e.g. a root schema has both
|
|
77
|
+
// $ref: "#/$defs/Foo" and $defs: { Foo: {...} } as siblings.
|
|
78
|
+
const wasCircular = this.circular;
|
|
79
|
+
const isExtendedRef = $Ref.isExtended$Ref(this.value);
|
|
72
80
|
if (resolveIf$Ref(this, options, pathFromRoot)) {
|
|
73
81
|
// The $ref path has changed, so append the remaining tokens to the path
|
|
74
82
|
this.path = Pointer.join(this.path, tokens.slice(i));
|
|
75
83
|
}
|
|
84
|
+
else if (!wasCircular && this.circular && isExtendedRef) {
|
|
85
|
+
// resolveIf$Ref set circular=true on an extended $ref during token walking.
|
|
86
|
+
// Since we still have tokens to process, the object should be walked by its
|
|
87
|
+
// properties, not treated as a circular self-reference.
|
|
88
|
+
this.circular = false;
|
|
89
|
+
}
|
|
76
90
|
const token = tokens[i];
|
|
77
91
|
if (this.value[token] === undefined || (this.value[token] === null && i === tokens.length - 1)) {
|
|
78
92
|
// one final case is if the entry itself includes slashes, and was parsed out as a token - we can join the remaining tokens and try again
|
package/lib/bundle.ts
CHANGED
|
@@ -41,6 +41,9 @@ function bundle<S extends object = JSONSchema, O extends ParserOptions<S> = Pars
|
|
|
41
41
|
|
|
42
42
|
// Remap all $ref pointers
|
|
43
43
|
remap<S, O>(inventory, options);
|
|
44
|
+
|
|
45
|
+
// Fix any $ref paths that traverse through other $refs (which is invalid per JSON Schema spec)
|
|
46
|
+
fixRefsThroughRefs(inventory, parser.schema as any);
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/**
|
|
@@ -316,4 +319,94 @@ function removeFromInventory(inventory: InventoryEntry[], entry: any) {
|
|
|
316
319
|
const index = inventory.indexOf(entry);
|
|
317
320
|
inventory.splice(index, 1);
|
|
318
321
|
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* After remapping, some $ref paths may traverse through other $ref nodes.
|
|
325
|
+
* JSON pointer resolution does not follow $ref indirection, so these paths are invalid.
|
|
326
|
+
* This function detects and fixes such paths by following any intermediate $refs
|
|
327
|
+
* to compute a valid direct path.
|
|
328
|
+
*/
|
|
329
|
+
function fixRefsThroughRefs(inventory: InventoryEntry[], schema: any) {
|
|
330
|
+
for (const entry of inventory) {
|
|
331
|
+
if (!entry.$ref || typeof entry.$ref !== "object" || !("$ref" in entry.$ref)) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const refValue = entry.$ref.$ref;
|
|
336
|
+
if (typeof refValue !== "string" || !refValue.startsWith("#/")) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const fixedPath = resolvePathThroughRefs(schema, refValue);
|
|
341
|
+
if (fixedPath !== refValue) {
|
|
342
|
+
entry.$ref.$ref = fixedPath;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Walks a JSON pointer path through the schema. If any intermediate value
|
|
349
|
+
* is a $ref, follows it and adjusts the path accordingly.
|
|
350
|
+
* Returns the corrected path that doesn't traverse through any $ref.
|
|
351
|
+
*/
|
|
352
|
+
function resolvePathThroughRefs(schema: any, refPath: string): string {
|
|
353
|
+
if (!refPath.startsWith("#/")) {
|
|
354
|
+
return refPath;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const segments = refPath.slice(2).split("/");
|
|
358
|
+
let current = schema;
|
|
359
|
+
const resolvedSegments: string[] = [];
|
|
360
|
+
|
|
361
|
+
for (const seg of segments) {
|
|
362
|
+
if (current === null || current === undefined || typeof current !== "object") {
|
|
363
|
+
// Can't walk further, return original path
|
|
364
|
+
return refPath;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// If the current value is a $ref, follow it
|
|
368
|
+
if ("$ref" in current && typeof current.$ref === "string" && current.$ref.startsWith("#/")) {
|
|
369
|
+
// Follow the $ref and restart the path from its target
|
|
370
|
+
const targetSegments = current.$ref.slice(2).split("/");
|
|
371
|
+
resolvedSegments.length = 0;
|
|
372
|
+
resolvedSegments.push(...targetSegments);
|
|
373
|
+
current = walkPath(schema, current.$ref);
|
|
374
|
+
if (current === null || current === undefined || typeof current !== "object") {
|
|
375
|
+
return refPath;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const decoded = seg.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
380
|
+
const idx = Array.isArray(current) ? parseInt(decoded) : decoded;
|
|
381
|
+
current = current[idx];
|
|
382
|
+
resolvedSegments.push(seg);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const result = "#/" + resolvedSegments.join("/");
|
|
386
|
+
return result;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Walks a JSON pointer path through a schema object, returning the value at that path.
|
|
391
|
+
*/
|
|
392
|
+
function walkPath(schema: any, path: string): any {
|
|
393
|
+
if (!path.startsWith("#/")) {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const segments = path.slice(2).split("/");
|
|
398
|
+
let current = schema;
|
|
399
|
+
|
|
400
|
+
for (const seg of segments) {
|
|
401
|
+
if (current === null || current === undefined || typeof current !== "object") {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
const decoded = seg.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
405
|
+
const idx = Array.isArray(current) ? parseInt(decoded) : decoded;
|
|
406
|
+
current = current[idx];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return current;
|
|
410
|
+
}
|
|
411
|
+
|
|
319
412
|
export default bundle;
|
package/lib/dereference.ts
CHANGED
|
@@ -31,6 +31,7 @@ function dereference<S extends object = JSONSchema, O extends ParserOptions<S> =
|
|
|
31
31
|
parser.$refs,
|
|
32
32
|
options,
|
|
33
33
|
start,
|
|
34
|
+
0,
|
|
34
35
|
);
|
|
35
36
|
parser.$refs.circular = dereferenced.circular;
|
|
36
37
|
parser.schema = dereferenced.value;
|
|
@@ -48,6 +49,7 @@ function dereference<S extends object = JSONSchema, O extends ParserOptions<S> =
|
|
|
48
49
|
* @param $refs
|
|
49
50
|
* @param options
|
|
50
51
|
* @param startTime - The time when the dereferencing started
|
|
52
|
+
* @param depth - The current recursion depth
|
|
51
53
|
* @returns
|
|
52
54
|
*/
|
|
53
55
|
function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOptions<S>>(
|
|
@@ -60,6 +62,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
|
|
|
60
62
|
$refs: $Refs<S, O>,
|
|
61
63
|
options: O,
|
|
62
64
|
startTime: number,
|
|
65
|
+
depth: number,
|
|
63
66
|
) {
|
|
64
67
|
let dereferenced;
|
|
65
68
|
const result = {
|
|
@@ -70,6 +73,14 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
|
|
|
70
73
|
checkDereferenceTimeout<S, O>(startTime, options);
|
|
71
74
|
|
|
72
75
|
const derefOptions = (options.dereference || {}) as DereferenceOptions;
|
|
76
|
+
const maxDepth = derefOptions.maxDepth ?? 500;
|
|
77
|
+
if (depth > maxDepth) {
|
|
78
|
+
throw new RangeError(
|
|
79
|
+
`Maximum dereference depth (${maxDepth}) exceeded at ${pathFromRoot}. ` +
|
|
80
|
+
`This likely indicates an extremely deep or recursive schema. ` +
|
|
81
|
+
`You can increase this limit with the dereference.maxDepth option.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
73
84
|
const isExcludedPath = derefOptions.excludedPathMatcher || (() => false);
|
|
74
85
|
|
|
75
86
|
if (derefOptions?.circular === "ignore" || !processedObjects.has(obj)) {
|
|
@@ -88,6 +99,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
|
|
|
88
99
|
$refs,
|
|
89
100
|
options,
|
|
90
101
|
startTime,
|
|
102
|
+
depth,
|
|
91
103
|
);
|
|
92
104
|
result.circular = dereferenced.circular;
|
|
93
105
|
result.value = dereferenced.value;
|
|
@@ -103,7 +115,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
|
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
const value = obj[key];
|
|
106
|
-
let circular
|
|
118
|
+
let circular;
|
|
107
119
|
|
|
108
120
|
if ($Ref.isAllowed$Ref(value, options)) {
|
|
109
121
|
dereferenced = dereference$Ref(
|
|
@@ -116,6 +128,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
|
|
|
116
128
|
$refs,
|
|
117
129
|
options,
|
|
118
130
|
startTime,
|
|
131
|
+
depth,
|
|
119
132
|
);
|
|
120
133
|
circular = dereferenced.circular;
|
|
121
134
|
// Avoid pointless mutations; breaks frozen objects to no profit
|
|
@@ -159,6 +172,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
|
|
|
159
172
|
$refs,
|
|
160
173
|
options,
|
|
161
174
|
startTime,
|
|
175
|
+
depth + 1,
|
|
162
176
|
);
|
|
163
177
|
circular = dereferenced.circular;
|
|
164
178
|
// Avoid pointless mutations; breaks frozen objects to no profit
|
|
@@ -205,6 +219,7 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
|
|
|
205
219
|
$refs: $Refs<S, O>,
|
|
206
220
|
options: O,
|
|
207
221
|
startTime: number,
|
|
222
|
+
depth: number,
|
|
208
223
|
) {
|
|
209
224
|
const isExternalRef = $Ref.isExternal$Ref($ref);
|
|
210
225
|
const shouldResolveOnCwd = isExternalRef && options?.dereference?.externalReferenceResolution === "root";
|
|
@@ -251,11 +266,14 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
|
|
|
251
266
|
// overhaul.
|
|
252
267
|
if (typeof cache.value === "object" && "$ref" in cache.value && "$ref" in $ref) {
|
|
253
268
|
if (cache.value.$ref === $ref.$ref) {
|
|
269
|
+
// Fire onCircular for cached circular refs so callers are notified of every occurrence
|
|
270
|
+
foundCircularReference(path, $refs, options);
|
|
254
271
|
return cache;
|
|
255
272
|
} else {
|
|
256
|
-
// no-op
|
|
273
|
+
// no-op - fall through to re-process (handles external ref edge case)
|
|
257
274
|
}
|
|
258
275
|
} else {
|
|
276
|
+
foundCircularReference(path, $refs, options);
|
|
259
277
|
return cache;
|
|
260
278
|
}
|
|
261
279
|
}
|
|
@@ -292,6 +310,7 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
|
|
|
292
310
|
$refs,
|
|
293
311
|
options,
|
|
294
312
|
startTime,
|
|
313
|
+
depth + 1,
|
|
295
314
|
);
|
|
296
315
|
circular = dereferenced.circular;
|
|
297
316
|
dereferencedValue = dereferenced.value;
|
package/lib/options.ts
CHANGED
|
@@ -89,6 +89,15 @@ export interface DereferenceOptions {
|
|
|
89
89
|
* Default: `true`
|
|
90
90
|
*/
|
|
91
91
|
mergeKeys?: boolean;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The maximum recursion depth for dereferencing nested schemas.
|
|
95
|
+
* If the schema nesting exceeds this depth, a RangeError will be thrown
|
|
96
|
+
* with a descriptive message instead of crashing with a stack overflow.
|
|
97
|
+
*
|
|
98
|
+
* Default: 500
|
|
99
|
+
*/
|
|
100
|
+
maxDepth?: number;
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
/**
|
package/lib/parse.ts
CHANGED
|
@@ -91,7 +91,7 @@ async function readFile<S extends object = JSONSchema, O extends ParserOptions<S
|
|
|
91
91
|
throw new UnmatchedResolverError(file.url);
|
|
92
92
|
} else if (!err || !("error" in err)) {
|
|
93
93
|
// Throw a generic, friendly error.
|
|
94
|
-
throw new SyntaxError(`Unable to resolve $ref pointer "${file.url}"
|
|
94
|
+
throw new SyntaxError(`Unable to resolve $ref pointer "${file.url}"`, { cause: err });
|
|
95
95
|
}
|
|
96
96
|
// Throw the original error, if it's one of our own (user-friendly) errors.
|
|
97
97
|
else if (err.error instanceof ResolverError) {
|
|
@@ -143,7 +143,7 @@ async function parseFile<S extends object = JSONSchema, O extends ParserOptions<
|
|
|
143
143
|
} else if (err && err.message && err.message.startsWith("Error parsing")) {
|
|
144
144
|
throw err;
|
|
145
145
|
} else if (!err || !("error" in err)) {
|
|
146
|
-
throw new SyntaxError(`Unable to parse ${file.url}
|
|
146
|
+
throw new SyntaxError(`Unable to parse ${file.url}`, { cause: err });
|
|
147
147
|
} else if (err.error instanceof ParserError) {
|
|
148
148
|
throw err.error;
|
|
149
149
|
} else {
|
package/lib/pointer.ts
CHANGED
|
@@ -89,9 +89,22 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
|
|
|
89
89
|
this.value = unwrapOrThrow(obj);
|
|
90
90
|
|
|
91
91
|
for (let i = 0; i < tokens.length; i++) {
|
|
92
|
+
// During token walking, if the current value is an extended $ref (has sibling keys
|
|
93
|
+
// alongside $ref, as allowed by JSON Schema 2019-09+), and resolveIf$Ref marks it
|
|
94
|
+
// as circular because the $ref resolves to the same path we're walking, we should
|
|
95
|
+
// reset the circular flag and continue walking the object's own properties.
|
|
96
|
+
// This prevents false circular detection when e.g. a root schema has both
|
|
97
|
+
// $ref: "#/$defs/Foo" and $defs: { Foo: {...} } as siblings.
|
|
98
|
+
const wasCircular = this.circular;
|
|
99
|
+
const isExtendedRef = $Ref.isExtended$Ref(this.value);
|
|
92
100
|
if (resolveIf$Ref(this, options, pathFromRoot)) {
|
|
93
101
|
// The $ref path has changed, so append the remaining tokens to the path
|
|
94
102
|
this.path = Pointer.join(this.path, tokens.slice(i));
|
|
103
|
+
} else if (!wasCircular && this.circular && isExtendedRef) {
|
|
104
|
+
// resolveIf$Ref set circular=true on an extended $ref during token walking.
|
|
105
|
+
// Since we still have tokens to process, the object should be walked by its
|
|
106
|
+
// properties, not treated as a circular self-reference.
|
|
107
|
+
this.circular = false;
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
const token = tokens[i];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apidevtools/json-schema-ref-parser",
|
|
3
|
-
"version": "15.
|
|
3
|
+
"version": "15.3.0",
|
|
4
4
|
"description": "Parse, Resolve, and Dereference JSON Schema $ref pointers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "dist/lib/index.d.ts",
|
|
@@ -75,29 +75,29 @@
|
|
|
75
75
|
"js-yaml": "^4.1.1"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"@eslint/compat": "^2.0.
|
|
79
|
-
"@eslint/js": "^
|
|
78
|
+
"@eslint/compat": "^2.0.2",
|
|
79
|
+
"@eslint/js": "^10.0.1",
|
|
80
80
|
"@types/eslint": "^9.6.1",
|
|
81
81
|
"@types/js-yaml": "^4.0.9",
|
|
82
82
|
"@types/json-schema": "^7.0.15",
|
|
83
83
|
"@types/node": "^25",
|
|
84
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
85
|
-
"@typescript-eslint/parser": "^8.
|
|
86
|
-
"@vitest/coverage-v8": "^4.0.
|
|
84
|
+
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
|
85
|
+
"@typescript-eslint/parser": "^8.56.1",
|
|
86
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
87
87
|
"cross-env": "^10.1.0",
|
|
88
|
-
"eslint": "^
|
|
88
|
+
"eslint": "^10.0.2",
|
|
89
89
|
"eslint-config-prettier": "^10.1.8",
|
|
90
90
|
"eslint-plugin-import": "^2.32.0",
|
|
91
91
|
"eslint-plugin-prettier": "^5.5.5",
|
|
92
92
|
"eslint-plugin-promise": "^7.2.1",
|
|
93
|
-
"eslint-plugin-unused-imports": "^4.
|
|
94
|
-
"globals": "^17.
|
|
95
|
-
"jsdom": "^
|
|
96
|
-
"prettier": "^3.8.
|
|
97
|
-
"rimraf": "^6.1.
|
|
93
|
+
"eslint-plugin-unused-imports": "^4.4.1",
|
|
94
|
+
"globals": "^17.3.0",
|
|
95
|
+
"jsdom": "^28.1.0",
|
|
96
|
+
"prettier": "^3.8.1",
|
|
97
|
+
"rimraf": "^6.1.3",
|
|
98
98
|
"typescript": "^5.9.3",
|
|
99
|
-
"typescript-eslint": "^8.
|
|
100
|
-
"vitest": "^4.0.
|
|
99
|
+
"typescript-eslint": "^8.56.1",
|
|
100
|
+
"vitest": "^4.0.18"
|
|
101
101
|
},
|
|
102
102
|
"release": {
|
|
103
103
|
"branches": [
|