@hey-api/json-schema-ref-parser 1.0.8 → 1.1.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/__tests__/bundle.test.js +8 -11
- package/dist/lib/__tests__/pointer.test.js +5 -4
- package/dist/lib/bundle.d.ts +1 -0
- package/dist/lib/bundle.js +336 -80
- package/dist/lib/dereference.js +4 -2
- package/dist/lib/index.d.ts +2 -2
- package/dist/lib/index.js +11 -11
- package/lib/__tests__/bundle.test.ts +9 -15
- package/lib/__tests__/pointer.test.ts +5 -4
- package/lib/__tests__/spec/multiple-refs.json +2 -2
- package/lib/__tests__/spec/path-parameter.json +12 -8
- package/lib/bundle.ts +411 -80
- package/lib/dereference.ts +5 -6
- package/lib/index.ts +18 -18
- package/package.json +1 -1
package/lib/bundle.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import isEqual from "lodash/isEqual";
|
|
2
|
-
|
|
3
1
|
import $Ref from "./ref.js";
|
|
4
2
|
import type { ParserOptions } from "./options.js";
|
|
5
3
|
import Pointer from "./pointer.js";
|
|
@@ -8,6 +6,17 @@ import type $Refs from "./refs.js";
|
|
|
8
6
|
import type { $RefParser } from "./index";
|
|
9
7
|
import type { JSONSchema } from "./types/index.js";
|
|
10
8
|
|
|
9
|
+
const DEBUG_PERFORMANCE =
|
|
10
|
+
process.env.DEBUG === "true" ||
|
|
11
|
+
(typeof globalThis !== "undefined" && (globalThis as any).DEBUG_BUNDLE_PERFORMANCE === true);
|
|
12
|
+
|
|
13
|
+
const perf = {
|
|
14
|
+
mark: (name: string) => DEBUG_PERFORMANCE && performance.mark(name),
|
|
15
|
+
measure: (name: string, start: string, end: string) => DEBUG_PERFORMANCE && performance.measure(name, start, end),
|
|
16
|
+
log: (message: string, ...args: any[]) => DEBUG_PERFORMANCE && console.log("[PERF] " + message, ...args),
|
|
17
|
+
warn: (message: string, ...args: any[]) => DEBUG_PERFORMANCE && console.warn("[PERF] " + message, ...args),
|
|
18
|
+
};
|
|
19
|
+
|
|
11
20
|
export interface InventoryEntry {
|
|
12
21
|
$ref: any;
|
|
13
22
|
circular: any;
|
|
@@ -21,22 +30,87 @@ export interface InventoryEntry {
|
|
|
21
30
|
parent: any;
|
|
22
31
|
pathFromRoot: any;
|
|
23
32
|
value: any;
|
|
33
|
+
originalContainerType?: "schemas" | "parameters" | "requestBodies" | "responses" | "headers";
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
/**
|
|
27
|
-
*
|
|
37
|
+
* Fast lookup using Map instead of linear search with deep equality
|
|
28
38
|
*/
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
const createInventoryLookup = () => {
|
|
40
|
+
const lookup = new Map<string, InventoryEntry>();
|
|
41
|
+
const objectIds = new WeakMap<object, string>(); // Use WeakMap to avoid polluting objects
|
|
42
|
+
let idCounter = 0;
|
|
43
|
+
let lookupCount = 0;
|
|
44
|
+
let addCount = 0;
|
|
45
|
+
|
|
46
|
+
const getObjectId = (obj: any) => {
|
|
47
|
+
if (!objectIds.has(obj)) {
|
|
48
|
+
objectIds.set(obj, `obj_${++idCounter}`);
|
|
37
49
|
}
|
|
50
|
+
return objectIds.get(obj)!;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const createInventoryKey = ($refParent: any, $refKey: any) => {
|
|
54
|
+
// Use WeakMap-based lookup to avoid polluting the actual schema objects
|
|
55
|
+
return `${getObjectId($refParent)}_${$refKey}`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
add: (entry: InventoryEntry) => {
|
|
60
|
+
addCount++;
|
|
61
|
+
const key = createInventoryKey(entry.parent, entry.key);
|
|
62
|
+
lookup.set(key, entry);
|
|
63
|
+
if (addCount % 100 === 0) {
|
|
64
|
+
perf.log(`Inventory lookup: Added ${addCount} entries, map size: ${lookup.size}`);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
find: ($refParent: any, $refKey: any) => {
|
|
68
|
+
lookupCount++;
|
|
69
|
+
const key = createInventoryKey($refParent, $refKey);
|
|
70
|
+
const result = lookup.get(key);
|
|
71
|
+
if (lookupCount % 100 === 0) {
|
|
72
|
+
perf.log(`Inventory lookup: ${lookupCount} lookups performed`);
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
},
|
|
76
|
+
remove: (entry: InventoryEntry) => {
|
|
77
|
+
const key = createInventoryKey(entry.parent, entry.key);
|
|
78
|
+
lookup.delete(key);
|
|
79
|
+
},
|
|
80
|
+
getStats: () => ({ lookupCount, addCount, mapSize: lookup.size }),
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Determine the container type from a JSON Pointer path.
|
|
86
|
+
* Analyzes the path tokens to identify the appropriate OpenAPI component container.
|
|
87
|
+
*
|
|
88
|
+
* @param path - The JSON Pointer path to analyze
|
|
89
|
+
* @returns The container type: "schemas", "parameters", "requestBodies", "responses", or "headers"
|
|
90
|
+
*/
|
|
91
|
+
const getContainerTypeFromPath = (
|
|
92
|
+
path: string,
|
|
93
|
+
): "schemas" | "parameters" | "requestBodies" | "responses" | "headers" => {
|
|
94
|
+
const tokens = Pointer.parse(path);
|
|
95
|
+
const has = (t: string) => tokens.includes(t);
|
|
96
|
+
// Prefer more specific containers first
|
|
97
|
+
if (has("parameters")) {
|
|
98
|
+
return "parameters";
|
|
99
|
+
}
|
|
100
|
+
if (has("requestBody")) {
|
|
101
|
+
return "requestBodies";
|
|
102
|
+
}
|
|
103
|
+
if (has("headers")) {
|
|
104
|
+
return "headers";
|
|
38
105
|
}
|
|
39
|
-
|
|
106
|
+
if (has("responses")) {
|
|
107
|
+
return "responses";
|
|
108
|
+
}
|
|
109
|
+
if (has("schema")) {
|
|
110
|
+
return "schemas";
|
|
111
|
+
}
|
|
112
|
+
// default: treat as schema-like
|
|
113
|
+
return "schemas";
|
|
40
114
|
};
|
|
41
115
|
|
|
42
116
|
/**
|
|
@@ -49,9 +123,12 @@ const inventory$Ref = <S extends object = JSONSchema>({
|
|
|
49
123
|
$refs,
|
|
50
124
|
indirections,
|
|
51
125
|
inventory,
|
|
126
|
+
inventoryLookup,
|
|
52
127
|
options,
|
|
53
128
|
path,
|
|
54
129
|
pathFromRoot,
|
|
130
|
+
visitedObjects = new WeakSet(),
|
|
131
|
+
resolvedRefs = new Map(),
|
|
55
132
|
}: {
|
|
56
133
|
/**
|
|
57
134
|
* The key in `$refParent` that is a JSON Reference
|
|
@@ -70,6 +147,10 @@ const inventory$Ref = <S extends object = JSONSchema>({
|
|
|
70
147
|
* An array of already-inventoried $ref pointers
|
|
71
148
|
*/
|
|
72
149
|
inventory: Array<InventoryEntry>;
|
|
150
|
+
/**
|
|
151
|
+
* Fast lookup for inventory entries
|
|
152
|
+
*/
|
|
153
|
+
inventoryLookup: ReturnType<typeof createInventoryLookup>;
|
|
73
154
|
options: ParserOptions;
|
|
74
155
|
/**
|
|
75
156
|
* The full path of the JSON Reference at `$refKey`, possibly with a JSON Pointer in the hash
|
|
@@ -79,13 +160,39 @@ const inventory$Ref = <S extends object = JSONSchema>({
|
|
|
79
160
|
* The path of the JSON Reference at `$refKey`, from the schema root
|
|
80
161
|
*/
|
|
81
162
|
pathFromRoot: string;
|
|
163
|
+
/**
|
|
164
|
+
* Set of already visited objects to avoid infinite loops and redundant processing
|
|
165
|
+
*/
|
|
166
|
+
visitedObjects?: WeakSet<object>;
|
|
167
|
+
/**
|
|
168
|
+
* Cache for resolved $ref targets to avoid redundant resolution
|
|
169
|
+
*/
|
|
170
|
+
resolvedRefs?: Map<string, any>;
|
|
82
171
|
}) => {
|
|
172
|
+
perf.mark("inventory-ref-start");
|
|
83
173
|
const $ref = $refKey === null ? $refParent : $refParent[$refKey];
|
|
84
174
|
const $refPath = url.resolve(path, $ref.$ref);
|
|
85
|
-
|
|
175
|
+
|
|
176
|
+
// Check cache first to avoid redundant resolution
|
|
177
|
+
let pointer = resolvedRefs.get($refPath);
|
|
178
|
+
if (!pointer) {
|
|
179
|
+
perf.mark("resolve-start");
|
|
180
|
+
pointer = $refs._resolve($refPath, pathFromRoot, options);
|
|
181
|
+
perf.mark("resolve-end");
|
|
182
|
+
perf.measure("resolve-time", "resolve-start", "resolve-end");
|
|
183
|
+
|
|
184
|
+
if (pointer) {
|
|
185
|
+
resolvedRefs.set($refPath, pointer);
|
|
186
|
+
perf.log(`Cached resolved $ref: ${$refPath}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
86
190
|
if (pointer === null) {
|
|
191
|
+
perf.mark("inventory-ref-end");
|
|
192
|
+
perf.measure("inventory-ref-time", "inventory-ref-start", "inventory-ref-end");
|
|
87
193
|
return;
|
|
88
194
|
}
|
|
195
|
+
|
|
89
196
|
const parsed = Pointer.parse(pathFromRoot);
|
|
90
197
|
const depth = parsed.length;
|
|
91
198
|
const file = url.stripHash(pointer.path);
|
|
@@ -95,17 +202,24 @@ const inventory$Ref = <S extends object = JSONSchema>({
|
|
|
95
202
|
indirections += pointer.indirections;
|
|
96
203
|
|
|
97
204
|
// Check if this exact location (parent + key + pathFromRoot) has already been inventoried
|
|
98
|
-
|
|
205
|
+
perf.mark("lookup-start");
|
|
206
|
+
const existingEntry = inventoryLookup.find($refParent, $refKey);
|
|
207
|
+
perf.mark("lookup-end");
|
|
208
|
+
perf.measure("lookup-time", "lookup-start", "lookup-end");
|
|
209
|
+
|
|
99
210
|
if (existingEntry && existingEntry.pathFromRoot === pathFromRoot) {
|
|
100
211
|
// This exact location has already been inventoried, so we don't need to process it again
|
|
101
212
|
if (depth < existingEntry.depth || indirections < existingEntry.indirections) {
|
|
102
213
|
removeFromInventory(inventory, existingEntry);
|
|
214
|
+
inventoryLookup.remove(existingEntry);
|
|
103
215
|
} else {
|
|
216
|
+
perf.mark("inventory-ref-end");
|
|
217
|
+
perf.measure("inventory-ref-time", "inventory-ref-start", "inventory-ref-end");
|
|
104
218
|
return;
|
|
105
219
|
}
|
|
106
220
|
}
|
|
107
221
|
|
|
108
|
-
|
|
222
|
+
const newEntry: InventoryEntry = {
|
|
109
223
|
$ref, // The JSON Reference (e.g. {$ref: string})
|
|
110
224
|
circular: pointer.circular, // Is this $ref pointer DIRECTLY circular? (i.e. it references itself)
|
|
111
225
|
depth, // How far from the JSON Schema root is this $ref pointer?
|
|
@@ -118,10 +232,17 @@ const inventory$Ref = <S extends object = JSONSchema>({
|
|
|
118
232
|
parent: $refParent, // The object that contains this $ref pointer
|
|
119
233
|
pathFromRoot, // The path to the $ref pointer, from the JSON Schema root
|
|
120
234
|
value: pointer.value, // The resolved value of the $ref pointer
|
|
121
|
-
|
|
235
|
+
originalContainerType: external ? getContainerTypeFromPath(pointer.path) : undefined, // The original container type in the external file
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
inventory.push(newEntry);
|
|
239
|
+
inventoryLookup.add(newEntry);
|
|
240
|
+
|
|
241
|
+
perf.log(`Inventoried $ref: ${$ref.$ref} -> ${file}${hash} (external: ${external}, depth: ${depth})`);
|
|
122
242
|
|
|
123
243
|
// Recursively crawl the resolved value
|
|
124
244
|
if (!existingEntry || external) {
|
|
245
|
+
perf.mark("crawl-recursive-start");
|
|
125
246
|
crawl({
|
|
126
247
|
parent: pointer.value,
|
|
127
248
|
key: null,
|
|
@@ -129,10 +250,18 @@ const inventory$Ref = <S extends object = JSONSchema>({
|
|
|
129
250
|
pathFromRoot,
|
|
130
251
|
indirections: indirections + 1,
|
|
131
252
|
inventory,
|
|
253
|
+
inventoryLookup,
|
|
132
254
|
$refs,
|
|
133
255
|
options,
|
|
256
|
+
visitedObjects,
|
|
257
|
+
resolvedRefs,
|
|
134
258
|
});
|
|
259
|
+
perf.mark("crawl-recursive-end");
|
|
260
|
+
perf.measure("crawl-recursive-time", "crawl-recursive-start", "crawl-recursive-end");
|
|
135
261
|
}
|
|
262
|
+
|
|
263
|
+
perf.mark("inventory-ref-end");
|
|
264
|
+
perf.measure("inventory-ref-time", "inventory-ref-start", "inventory-ref-end");
|
|
136
265
|
};
|
|
137
266
|
|
|
138
267
|
/**
|
|
@@ -142,11 +271,14 @@ const crawl = <S extends object = JSONSchema>({
|
|
|
142
271
|
$refs,
|
|
143
272
|
indirections,
|
|
144
273
|
inventory,
|
|
274
|
+
inventoryLookup,
|
|
145
275
|
key,
|
|
146
276
|
options,
|
|
147
277
|
parent,
|
|
148
278
|
path,
|
|
149
279
|
pathFromRoot,
|
|
280
|
+
visitedObjects = new WeakSet(),
|
|
281
|
+
resolvedRefs = new Map(),
|
|
150
282
|
}: {
|
|
151
283
|
$refs: $Refs<S>;
|
|
152
284
|
indirections: number;
|
|
@@ -154,6 +286,10 @@ const crawl = <S extends object = JSONSchema>({
|
|
|
154
286
|
* An array of already-inventoried $ref pointers
|
|
155
287
|
*/
|
|
156
288
|
inventory: Array<InventoryEntry>;
|
|
289
|
+
/**
|
|
290
|
+
* Fast lookup for inventory entries
|
|
291
|
+
*/
|
|
292
|
+
inventoryLookup: ReturnType<typeof createInventoryLookup>;
|
|
157
293
|
/**
|
|
158
294
|
* The property key of `parent` to be crawled
|
|
159
295
|
*/
|
|
@@ -171,11 +307,26 @@ const crawl = <S extends object = JSONSchema>({
|
|
|
171
307
|
* The path of the property being crawled, from the schema root
|
|
172
308
|
*/
|
|
173
309
|
pathFromRoot: string;
|
|
310
|
+
/**
|
|
311
|
+
* Set of already visited objects to avoid infinite loops and redundant processing
|
|
312
|
+
*/
|
|
313
|
+
visitedObjects?: WeakSet<object>;
|
|
314
|
+
/**
|
|
315
|
+
* Cache for resolved $ref targets to avoid redundant resolution
|
|
316
|
+
*/
|
|
317
|
+
resolvedRefs?: Map<string, any>;
|
|
174
318
|
}) => {
|
|
175
319
|
const obj = key === null ? parent : parent[key as keyof typeof parent];
|
|
176
320
|
|
|
177
321
|
if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj)) {
|
|
322
|
+
// Early exit if we've already processed this exact object
|
|
323
|
+
if (visitedObjects.has(obj)) {
|
|
324
|
+
perf.log(`Skipping already visited object at ${pathFromRoot}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
178
328
|
if ($Ref.isAllowed$Ref(obj)) {
|
|
329
|
+
perf.log(`Found $ref at ${pathFromRoot}: ${(obj as any).$ref}`);
|
|
179
330
|
inventory$Ref({
|
|
180
331
|
$refParent: parent,
|
|
181
332
|
$refKey: key,
|
|
@@ -183,10 +334,16 @@ const crawl = <S extends object = JSONSchema>({
|
|
|
183
334
|
pathFromRoot,
|
|
184
335
|
indirections,
|
|
185
336
|
inventory,
|
|
337
|
+
inventoryLookup,
|
|
186
338
|
$refs,
|
|
187
339
|
options,
|
|
340
|
+
visitedObjects,
|
|
341
|
+
resolvedRefs,
|
|
188
342
|
});
|
|
189
343
|
} else {
|
|
344
|
+
// Mark this object as visited BEFORE processing its children
|
|
345
|
+
visitedObjects.add(obj);
|
|
346
|
+
|
|
190
347
|
// Crawl the object in a specific order that's optimized for bundling.
|
|
191
348
|
// This is important because it determines how `pathFromRoot` gets built,
|
|
192
349
|
// which later determines which keys get dereferenced and which ones get remapped
|
|
@@ -217,8 +374,11 @@ const crawl = <S extends object = JSONSchema>({
|
|
|
217
374
|
pathFromRoot: keyPathFromRoot,
|
|
218
375
|
indirections,
|
|
219
376
|
inventory,
|
|
377
|
+
inventoryLookup,
|
|
220
378
|
$refs,
|
|
221
379
|
options,
|
|
380
|
+
visitedObjects,
|
|
381
|
+
resolvedRefs,
|
|
222
382
|
});
|
|
223
383
|
} else {
|
|
224
384
|
crawl({
|
|
@@ -228,8 +388,11 @@ const crawl = <S extends object = JSONSchema>({
|
|
|
228
388
|
pathFromRoot: keyPathFromRoot,
|
|
229
389
|
indirections,
|
|
230
390
|
inventory,
|
|
391
|
+
inventoryLookup,
|
|
231
392
|
$refs,
|
|
232
393
|
options,
|
|
394
|
+
visitedObjects,
|
|
395
|
+
resolvedRefs,
|
|
233
396
|
});
|
|
234
397
|
}
|
|
235
398
|
}
|
|
@@ -238,29 +401,16 @@ const crawl = <S extends object = JSONSchema>({
|
|
|
238
401
|
};
|
|
239
402
|
|
|
240
403
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* value are re-mapped to point to the first reference.
|
|
244
|
-
*
|
|
245
|
-
* @example: {
|
|
246
|
-
* first: { $ref: somefile.json#/some/part },
|
|
247
|
-
* second: { $ref: somefile.json#/another/part },
|
|
248
|
-
* third: { $ref: somefile.json },
|
|
249
|
-
* fourth: { $ref: somefile.json#/some/part/sub/part }
|
|
250
|
-
* }
|
|
251
|
-
*
|
|
252
|
-
* In this example, there are four references to the same file, but since the third reference points
|
|
253
|
-
* to the ENTIRE file, that's the only one we need to dereference. The other three can just be
|
|
254
|
-
* remapped to point inside the third one.
|
|
255
|
-
*
|
|
256
|
-
* On the other hand, if the third reference DIDN'T exist, then the first and second would both need
|
|
257
|
-
* to be dereferenced, since they point to different parts of the file. The fourth reference does NOT
|
|
258
|
-
* need to be dereferenced, because it can be remapped to point inside the first one.
|
|
259
|
-
*
|
|
260
|
-
* @param inventory
|
|
404
|
+
* Remap external refs by hoisting resolved values into a shared container in the root schema
|
|
405
|
+
* and pointing all occurrences to those internal definitions. Internal refs remain internal.
|
|
261
406
|
*/
|
|
262
|
-
function remap(inventory: InventoryEntry[]) {
|
|
407
|
+
function remap(parser: $RefParser, inventory: InventoryEntry[]) {
|
|
408
|
+
perf.log(`Starting remap with ${inventory.length} inventory entries`);
|
|
409
|
+
perf.mark("remap-start");
|
|
410
|
+
const root = parser.schema as any;
|
|
411
|
+
|
|
263
412
|
// Group & sort all the $ref pointers, so they're in the order that we need to dereference/remap them
|
|
413
|
+
perf.mark("sort-inventory-start");
|
|
264
414
|
inventory.sort((a: InventoryEntry, b: InventoryEntry) => {
|
|
265
415
|
if (a.file !== b.file) {
|
|
266
416
|
// Group all the $refs that point to the same file
|
|
@@ -285,7 +435,6 @@ function remap(inventory: InventoryEntry[]) {
|
|
|
285
435
|
// Most people will expect references to be bundled into the the "definitions" property if possible.
|
|
286
436
|
const aDefinitionsIndex = a.pathFromRoot.lastIndexOf("/definitions");
|
|
287
437
|
const bDefinitionsIndex = b.pathFromRoot.lastIndexOf("/definitions");
|
|
288
|
-
|
|
289
438
|
if (aDefinitionsIndex !== bDefinitionsIndex) {
|
|
290
439
|
// Give higher priority to the $ref that's closer to the "definitions" property
|
|
291
440
|
return bDefinitionsIndex - aDefinitionsIndex;
|
|
@@ -296,55 +445,190 @@ function remap(inventory: InventoryEntry[]) {
|
|
|
296
445
|
}
|
|
297
446
|
});
|
|
298
447
|
|
|
299
|
-
|
|
448
|
+
perf.mark("sort-inventory-end");
|
|
449
|
+
perf.measure("sort-inventory-time", "sort-inventory-start", "sort-inventory-end");
|
|
450
|
+
|
|
451
|
+
perf.log(`Sorted ${inventory.length} inventory entries`);
|
|
452
|
+
|
|
453
|
+
// Ensure or return a container by component type. Prefer OpenAPI-aware placement;
|
|
454
|
+
// otherwise use existing root containers; otherwise create components/*.
|
|
455
|
+
const ensureContainer = (type: "schemas" | "parameters" | "requestBodies" | "responses" | "headers") => {
|
|
456
|
+
const isOas3 = !!(root && typeof root === "object" && typeof root.openapi === "string");
|
|
457
|
+
const isOas2 = !!(root && typeof root === "object" && typeof root.swagger === "string");
|
|
458
|
+
|
|
459
|
+
if (isOas3) {
|
|
460
|
+
if (!root.components || typeof root.components !== "object") {
|
|
461
|
+
root.components = {};
|
|
462
|
+
}
|
|
463
|
+
if (!root.components[type] || typeof root.components[type] !== "object") {
|
|
464
|
+
root.components[type] = {};
|
|
465
|
+
}
|
|
466
|
+
return { obj: root.components[type], prefix: `#/components/${type}` } as const;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (isOas2) {
|
|
470
|
+
if (type === "schemas") {
|
|
471
|
+
if (!root.definitions || typeof root.definitions !== "object") {
|
|
472
|
+
root.definitions = {};
|
|
473
|
+
}
|
|
474
|
+
return { obj: root.definitions, prefix: "#/definitions" } as const;
|
|
475
|
+
}
|
|
476
|
+
if (type === "parameters") {
|
|
477
|
+
if (!root.parameters || typeof root.parameters !== "object") {
|
|
478
|
+
root.parameters = {};
|
|
479
|
+
}
|
|
480
|
+
return { obj: root.parameters, prefix: "#/parameters" } as const;
|
|
481
|
+
}
|
|
482
|
+
if (type === "responses") {
|
|
483
|
+
if (!root.responses || typeof root.responses !== "object") {
|
|
484
|
+
root.responses = {};
|
|
485
|
+
}
|
|
486
|
+
return { obj: root.responses, prefix: "#/responses" } as const;
|
|
487
|
+
}
|
|
488
|
+
// requestBodies/headers don't exist as reusable containers in OAS2; fallback to definitions
|
|
489
|
+
if (!root.definitions || typeof root.definitions !== "object") {
|
|
490
|
+
root.definitions = {};
|
|
491
|
+
}
|
|
492
|
+
return { obj: root.definitions, prefix: "#/definitions" } as const;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// No explicit version: prefer existing containers
|
|
496
|
+
if (root && typeof root === "object") {
|
|
497
|
+
if (root.components && typeof root.components === "object") {
|
|
498
|
+
if (!root.components[type] || typeof root.components[type] !== "object") {
|
|
499
|
+
root.components[type] = {};
|
|
500
|
+
}
|
|
501
|
+
return { obj: root.components[type], prefix: `#/components/${type}` } as const;
|
|
502
|
+
}
|
|
503
|
+
if (root.definitions && typeof root.definitions === "object") {
|
|
504
|
+
return { obj: root.definitions, prefix: "#/definitions" } as const;
|
|
505
|
+
}
|
|
506
|
+
// Create components/* by default if nothing exists
|
|
507
|
+
if (!root.components || typeof root.components !== "object") {
|
|
508
|
+
root.components = {};
|
|
509
|
+
}
|
|
510
|
+
if (!root.components[type] || typeof root.components[type] !== "object") {
|
|
511
|
+
root.components[type] = {};
|
|
512
|
+
}
|
|
513
|
+
return { obj: root.components[type], prefix: `#/components/${type}` } as const;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Fallback
|
|
517
|
+
root.definitions = root.definitions || {};
|
|
518
|
+
return { obj: root.definitions, prefix: "#/definitions" } as const;
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Choose the appropriate component container for bundling.
|
|
523
|
+
* Prioritizes the original container type from external files over usage location.
|
|
524
|
+
*
|
|
525
|
+
* @param entry - The inventory entry containing reference information
|
|
526
|
+
* @returns The container type to use for bundling
|
|
527
|
+
*/
|
|
528
|
+
const chooseComponent = (entry: InventoryEntry) => {
|
|
529
|
+
// If we have the original container type from the external file, use it
|
|
530
|
+
if (entry.originalContainerType) {
|
|
531
|
+
return entry.originalContainerType;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Fallback to usage path for internal references or when original type is not available
|
|
535
|
+
return getContainerTypeFromPath(entry.pathFromRoot);
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// Track names per (container prefix) and per target
|
|
539
|
+
const targetToNameByPrefix = new Map<string, Map<string, string>>();
|
|
540
|
+
const usedNamesByObj = new Map<any, Set<string>>();
|
|
541
|
+
|
|
542
|
+
const sanitize = (name: string) => name.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
543
|
+
const baseName = (filePath: string) => {
|
|
544
|
+
try {
|
|
545
|
+
const withoutHash = filePath.split("#")[0];
|
|
546
|
+
const parts = withoutHash.split("/");
|
|
547
|
+
const filename = parts[parts.length - 1] || "schema";
|
|
548
|
+
const dot = filename.lastIndexOf(".");
|
|
549
|
+
return sanitize(dot > 0 ? filename.substring(0, dot) : filename);
|
|
550
|
+
} catch {
|
|
551
|
+
return "schema";
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
const lastToken = (hash: string) => {
|
|
555
|
+
if (!hash || hash === "#") {
|
|
556
|
+
return "root";
|
|
557
|
+
}
|
|
558
|
+
const tokens = hash.replace(/^#\//, "").split("/");
|
|
559
|
+
return sanitize(tokens[tokens.length - 1] || "root");
|
|
560
|
+
};
|
|
561
|
+
const uniqueName = (containerObj: any, proposed: string) => {
|
|
562
|
+
if (!usedNamesByObj.has(containerObj)) {
|
|
563
|
+
usedNamesByObj.set(containerObj, new Set<string>(Object.keys(containerObj || {})));
|
|
564
|
+
}
|
|
565
|
+
const used = usedNamesByObj.get(containerObj)!;
|
|
566
|
+
let name = proposed;
|
|
567
|
+
let i = 2;
|
|
568
|
+
while (used.has(name)) {
|
|
569
|
+
name = `${proposed}_${i++}`;
|
|
570
|
+
}
|
|
571
|
+
used.add(name);
|
|
572
|
+
return name;
|
|
573
|
+
};
|
|
574
|
+
perf.mark("remap-loop-start");
|
|
300
575
|
for (const entry of inventory) {
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
} else if (entry.file === file && entry.hash === hash) {
|
|
307
|
-
// This $ref points to the same value as the prevous $ref, so remap it to the same path
|
|
308
|
-
entry.$ref.$ref = pathFromRoot;
|
|
309
|
-
} else if (entry.file === file && entry.hash.indexOf(hash + "/") === 0) {
|
|
310
|
-
// This $ref points to a sub-value of the prevous $ref, so remap it beneath that path
|
|
311
|
-
entry.$ref.$ref = Pointer.join(pathFromRoot, Pointer.parse(entry.hash.replace(hash, "#")));
|
|
312
|
-
} else {
|
|
313
|
-
// We've moved to a new file or new hash
|
|
314
|
-
file = entry.file;
|
|
315
|
-
hash = entry.hash;
|
|
316
|
-
pathFromRoot = entry.pathFromRoot;
|
|
576
|
+
// Safety check: ensure entry and entry.$ref are valid objects
|
|
577
|
+
if (!entry || !entry.$ref || typeof entry.$ref !== "object") {
|
|
578
|
+
perf.warn(`Skipping invalid inventory entry:`, entry);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
317
581
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
entry.$ref
|
|
582
|
+
// Keep internal refs internal
|
|
583
|
+
if (!entry.external) {
|
|
584
|
+
if (entry.$ref && typeof entry.$ref === "object") {
|
|
585
|
+
entry.$ref.$ref = entry.hash;
|
|
586
|
+
}
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
321
589
|
|
|
322
|
-
|
|
323
|
-
|
|
590
|
+
// Avoid changing direct self-references; keep them internal
|
|
591
|
+
if (entry.circular) {
|
|
592
|
+
if (entry.$ref && typeof entry.$ref === "object") {
|
|
324
593
|
entry.$ref.$ref = entry.pathFromRoot;
|
|
325
594
|
}
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Choose appropriate container based on original location in external file
|
|
599
|
+
const component = chooseComponent(entry);
|
|
600
|
+
const { obj: container, prefix } = ensureContainer(component);
|
|
601
|
+
|
|
602
|
+
const targetKey = `${entry.file}::${entry.hash}`;
|
|
603
|
+
if (!targetToNameByPrefix.has(prefix)) {
|
|
604
|
+
targetToNameByPrefix.set(prefix, new Map<string, string>());
|
|
605
|
+
}
|
|
606
|
+
const namesForPrefix = targetToNameByPrefix.get(prefix)!;
|
|
607
|
+
|
|
608
|
+
let defName = namesForPrefix.get(targetKey);
|
|
609
|
+
if (!defName) {
|
|
610
|
+
const proposed = `${baseName(entry.file)}_${lastToken(entry.hash)}`;
|
|
611
|
+
defName = uniqueName(container, proposed);
|
|
612
|
+
namesForPrefix.set(targetKey, defName);
|
|
613
|
+
// Store the resolved value under the container
|
|
614
|
+
container[defName] = entry.value;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Point the occurrence to the internal definition, preserving extensions
|
|
618
|
+
const refPath = `${prefix}/${defName}`;
|
|
619
|
+
if (entry.extended && entry.$ref && typeof entry.$ref === "object") {
|
|
620
|
+
entry.$ref.$ref = refPath;
|
|
621
|
+
} else {
|
|
622
|
+
entry.parent[entry.key] = { $ref: refPath };
|
|
326
623
|
}
|
|
327
624
|
}
|
|
625
|
+
perf.mark("remap-loop-end");
|
|
626
|
+
perf.measure("remap-loop-time", "remap-loop-start", "remap-loop-end");
|
|
627
|
+
|
|
628
|
+
perf.mark("remap-end");
|
|
629
|
+
perf.measure("remap-total-time", "remap-start", "remap-end");
|
|
328
630
|
|
|
329
|
-
|
|
330
|
-
// let hadChange = true;
|
|
331
|
-
// while (hadChange) {
|
|
332
|
-
// hadChange = false;
|
|
333
|
-
// for (const entry of inventory) {
|
|
334
|
-
// if (entry.$ref && typeof entry.$ref === "object" && "$ref" in entry.$ref) {
|
|
335
|
-
// const resolved = inventory.find((e: InventoryEntry) => e.pathFromRoot === entry.$ref.$ref);
|
|
336
|
-
// if (resolved) {
|
|
337
|
-
// const resolvedPointsToAnotherRef =
|
|
338
|
-
// resolved.$ref && typeof resolved.$ref === "object" && "$ref" in resolved.$ref;
|
|
339
|
-
// if (resolvedPointsToAnotherRef && entry.$ref.$ref !== resolved.$ref.$ref) {
|
|
340
|
-
// // console.log('Re-mapping $ref pointer "%s" at %s', entry.$ref.$ref, entry.pathFromRoot);
|
|
341
|
-
// entry.$ref.$ref = resolved.$ref.$ref;
|
|
342
|
-
// hadChange = true;
|
|
343
|
-
// }
|
|
344
|
-
// }
|
|
345
|
-
// }
|
|
346
|
-
// }
|
|
347
|
-
// }
|
|
631
|
+
perf.log(`Completed remap of ${inventory.length} entries`);
|
|
348
632
|
}
|
|
349
633
|
|
|
350
634
|
function removeFromInventory(inventory: InventoryEntry[], entry: any) {
|
|
@@ -362,8 +646,18 @@ function removeFromInventory(inventory: InventoryEntry[], entry: any) {
|
|
|
362
646
|
*/
|
|
363
647
|
export const bundle = (parser: $RefParser, options: ParserOptions) => {
|
|
364
648
|
// console.log('Bundling $ref pointers in %s', parser.$refs._root$Ref.path);
|
|
649
|
+
perf.mark("bundle-start");
|
|
650
|
+
|
|
365
651
|
// Build an inventory of all $ref pointers in the JSON Schema
|
|
366
652
|
const inventory: InventoryEntry[] = [];
|
|
653
|
+
const inventoryLookup = createInventoryLookup();
|
|
654
|
+
|
|
655
|
+
perf.log("Starting crawl phase");
|
|
656
|
+
perf.mark("crawl-phase-start");
|
|
657
|
+
|
|
658
|
+
const visitedObjects = new WeakSet<object>();
|
|
659
|
+
const resolvedRefs = new Map<string, any>(); // Cache for resolved $ref targets
|
|
660
|
+
|
|
367
661
|
crawl<JSONSchema>({
|
|
368
662
|
parent: parser,
|
|
369
663
|
key: "schema",
|
|
@@ -371,10 +665,47 @@ export const bundle = (parser: $RefParser, options: ParserOptions) => {
|
|
|
371
665
|
pathFromRoot: "#",
|
|
372
666
|
indirections: 0,
|
|
373
667
|
inventory,
|
|
668
|
+
inventoryLookup,
|
|
374
669
|
$refs: parser.$refs,
|
|
375
670
|
options,
|
|
671
|
+
visitedObjects,
|
|
672
|
+
resolvedRefs,
|
|
376
673
|
});
|
|
377
674
|
|
|
675
|
+
perf.mark("crawl-phase-end");
|
|
676
|
+
perf.measure("crawl-phase-time", "crawl-phase-start", "crawl-phase-end");
|
|
677
|
+
|
|
678
|
+
const stats = inventoryLookup.getStats();
|
|
679
|
+
perf.log(`Crawl phase complete. Found ${inventory.length} $refs. Lookup stats:`, stats);
|
|
680
|
+
|
|
378
681
|
// Remap all $ref pointers
|
|
379
|
-
remap
|
|
682
|
+
perf.log("Starting remap phase");
|
|
683
|
+
perf.mark("remap-phase-start");
|
|
684
|
+
remap(parser, inventory);
|
|
685
|
+
perf.mark("remap-phase-end");
|
|
686
|
+
perf.measure("remap-phase-time", "remap-phase-start", "remap-phase-end");
|
|
687
|
+
|
|
688
|
+
perf.mark("bundle-end");
|
|
689
|
+
perf.measure("bundle-total-time", "bundle-start", "bundle-end");
|
|
690
|
+
|
|
691
|
+
perf.log("Bundle complete. Performance summary:");
|
|
692
|
+
|
|
693
|
+
// Log final stats
|
|
694
|
+
const finalStats = inventoryLookup.getStats();
|
|
695
|
+
perf.log(`Final inventory stats:`, finalStats);
|
|
696
|
+
perf.log(`Resolved refs cache size: ${resolvedRefs.size}`);
|
|
697
|
+
|
|
698
|
+
if (DEBUG_PERFORMANCE) {
|
|
699
|
+
// Log all performance measures
|
|
700
|
+
const measures = performance.getEntriesByType("measure");
|
|
701
|
+
measures.forEach((measure) => {
|
|
702
|
+
if (measure.name.includes("time")) {
|
|
703
|
+
console.log(`${measure.name}: ${measure.duration.toFixed(2)}ms`);
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// Clear performance marks and measures for next run
|
|
708
|
+
performance.clearMarks();
|
|
709
|
+
performance.clearMeasures();
|
|
710
|
+
}
|
|
380
711
|
};
|