fork of hey-api/openapi-ts because I need some additional things
1import type { $RefParser } from '.';
2import type { ParserOptions } from './options';
3import Pointer from './pointer';
4import $Ref from './ref';
5import type $Refs from './refs';
6import type { JSONSchema } from './types';
7import { MissingPointerError } from './util/errors';
8import * as url from './util/url';
9
10export interface InventoryEntry {
11 $ref: any;
12 circular: any;
13 depth: any;
14 extended: any;
15 external: any;
16 file: any;
17 hash: any;
18 indirections: any;
19 key: any;
20 originalContainerType?: 'schemas' | 'parameters' | 'requestBodies' | 'responses' | 'headers';
21 parent: any;
22 pathFromRoot: any;
23 value: any;
24}
25
26/**
27 * Fast lookup using Map instead of linear search with deep equality
28 */
29const createInventoryLookup = () => {
30 const lookup = new Map<string, InventoryEntry>();
31 const objectIds = new WeakMap<object, string>(); // Use WeakMap to avoid polluting objects
32 let idCounter = 0;
33
34 const getObjectId = (obj: any) => {
35 if (!objectIds.has(obj)) {
36 objectIds.set(obj, `obj_${++idCounter}`);
37 }
38 return objectIds.get(obj)!;
39 };
40
41 const createInventoryKey = ($refParent: any, $refKey: any) =>
42 // Use WeakMap-based lookup to avoid polluting the actual schema objects
43 `${getObjectId($refParent)}_${$refKey}`;
44
45 return {
46 add: (entry: InventoryEntry) => {
47 const key = createInventoryKey(entry.parent, entry.key);
48 lookup.set(key, entry);
49 },
50 find: ($refParent: any, $refKey: any) => {
51 const key = createInventoryKey($refParent, $refKey);
52 const result = lookup.get(key);
53 return result;
54 },
55 remove: (entry: InventoryEntry) => {
56 const key = createInventoryKey(entry.parent, entry.key);
57 lookup.delete(key);
58 },
59 };
60};
61
62/**
63 * Determine the container type from a JSON Pointer path.
64 * Analyzes the path tokens to identify the appropriate OpenAPI component container.
65 *
66 * @param path - The JSON Pointer path to analyze
67 * @returns The container type: "schemas", "parameters", "requestBodies", "responses", or "headers"
68 */
69const getContainerTypeFromPath = (
70 path: string,
71): 'schemas' | 'parameters' | 'requestBodies' | 'responses' | 'headers' => {
72 const tokens = Pointer.parse(path);
73 const has = (t: string) => tokens.includes(t);
74 // Prefer more specific containers first
75 if (has('parameters')) {
76 return 'parameters';
77 }
78 if (has('requestBody')) {
79 return 'requestBodies';
80 }
81 if (has('headers')) {
82 return 'headers';
83 }
84 if (has('responses')) {
85 return 'responses';
86 }
87 if (has('schema')) {
88 return 'schemas';
89 }
90 // default: treat as schema-like
91 return 'schemas';
92};
93
94/**
95 * Inventories the given JSON Reference (i.e. records detailed information about it so we can
96 * optimize all $refs in the schema), and then crawls the resolved value.
97 */
98const inventory$Ref = <S extends object = JSONSchema>({
99 $refKey,
100 $refParent,
101 $refs,
102 indirections,
103 inventory,
104 inventoryLookup,
105 options,
106 path,
107 pathFromRoot,
108 resolvedRefs = new Map(),
109 visitedObjects = new WeakSet(),
110}: {
111 /**
112 * The key in `$refParent` that is a JSON Reference
113 */
114 $refKey: string | null;
115 /**
116 * The object that contains a JSON Reference as one of its keys
117 */
118 $refParent: any;
119 $refs: $Refs<S>;
120 /**
121 * unknown
122 */
123 indirections: number;
124 /**
125 * An array of already-inventoried $ref pointers
126 */
127 inventory: Array<InventoryEntry>;
128 /**
129 * Fast lookup for inventory entries
130 */
131 inventoryLookup: ReturnType<typeof createInventoryLookup>;
132 options: ParserOptions;
133 /**
134 * The full path of the JSON Reference at `$refKey`, possibly with a JSON Pointer in the hash
135 */
136 path: string;
137 /**
138 * The path of the JSON Reference at `$refKey`, from the schema root
139 */
140 pathFromRoot: string;
141 /**
142 * Cache for resolved $ref targets to avoid redundant resolution
143 */
144 resolvedRefs?: Map<string, any>;
145 /**
146 * Set of already visited objects to avoid infinite loops and redundant processing
147 */
148 visitedObjects?: WeakSet<object>;
149}) => {
150 const $ref = $refKey === null ? $refParent : $refParent[$refKey];
151 const $refPath = url.resolve(path, $ref.$ref);
152
153 // Check cache first to avoid redundant resolution
154 let pointer = resolvedRefs.get($refPath);
155 if (!pointer) {
156 try {
157 pointer = $refs._resolve($refPath, pathFromRoot, options);
158 } catch (error) {
159 if (error instanceof MissingPointerError) {
160 // The ref couldn't be resolved in the target file. This commonly
161 // happens when a wrapper file redirects via $ref to a versioned
162 // file, and the bundler's crawl path retains the wrapper URL.
163 // Try resolving the hash fragment against other files in $refs
164 // that might contain the target schema.
165 const hash = url.getHash($refPath);
166 if (hash) {
167 const baseFile = url.stripHash($refPath);
168 for (const filePath of Object.keys($refs._$refs)) {
169 if (filePath === baseFile) continue;
170 try {
171 pointer = $refs._resolve(filePath + hash, pathFromRoot, options);
172 if (pointer) break;
173 } catch {
174 // try next file
175 }
176 }
177 }
178 if (!pointer) {
179 console.warn(`Skipping unresolvable $ref: ${$refPath}`);
180 return;
181 }
182 } else {
183 throw error;
184 }
185 }
186
187 if (pointer) {
188 resolvedRefs.set($refPath, pointer);
189 }
190 }
191
192 if (pointer === null) return;
193
194 const parsed = Pointer.parse(pathFromRoot);
195 const depth = parsed.length;
196 const file = url.stripHash(pointer.path);
197 const hash = url.getHash(pointer.path);
198 const external = file !== $refs._root$Ref.path;
199 const extended = $Ref.isExtended$Ref($ref);
200 indirections += pointer.indirections;
201
202 // Check if this exact location (parent + key + pathFromRoot) has already been inventoried
203 const existingEntry = inventoryLookup.find($refParent, $refKey);
204
205 if (existingEntry && existingEntry.pathFromRoot === pathFromRoot) {
206 // This exact location has already been inventoried, so we don't need to process it again
207 if (depth < existingEntry.depth || indirections < existingEntry.indirections) {
208 removeFromInventory(inventory, existingEntry);
209 inventoryLookup.remove(existingEntry);
210 } else {
211 return;
212 }
213 }
214
215 const newEntry: InventoryEntry = {
216 $ref, // The JSON Reference (e.g. {$ref: string})
217 circular: pointer.circular, // Is this $ref pointer DIRECTLY circular? (i.e. it references itself)
218 depth, // How far from the JSON Schema root is this $ref pointer?
219 extended, // Does this $ref extend its resolved value? (i.e. it has extra properties, in addition to "$ref")
220 external, // Does this $ref pointer point to a file other than the main JSON Schema file?
221 file, // The file that the $ref pointer resolves to
222 hash, // The hash within `file` that the $ref pointer resolves to
223 indirections, // The number of indirect references that were traversed to resolve the value
224 key: $refKey,
225 // The resolved value of the $ref pointer
226 originalContainerType: external ? getContainerTypeFromPath(pointer.path) : undefined,
227
228 // The key in `parent` that is the $ref pointer
229 parent: $refParent,
230
231 // The object that contains this $ref pointer
232 pathFromRoot,
233 // The path to the $ref pointer, from the JSON Schema root
234 value: pointer.value, // The original container type in the external file
235 };
236
237 inventory.push(newEntry);
238 inventoryLookup.add(newEntry);
239
240 // Recursively crawl the resolved value.
241 // When the resolution followed a $ref chain to a different file,
242 // use the resolved file as the base path so that local $ref values
243 // (e.g. #/components/schemas/SiblingSchema) inside the resolved
244 // value resolve against the correct file.
245 if (!existingEntry || external) {
246 let crawlPath = pointer.path;
247
248 const originalFile = url.stripHash($refPath);
249 if (file !== originalFile) {
250 crawlPath = file + url.getHash(pointer.path);
251 }
252
253 crawl({
254 $refs,
255 indirections: indirections + 1,
256 inventory,
257 inventoryLookup,
258 key: null,
259 options,
260 parent: pointer.value,
261 path: crawlPath,
262 pathFromRoot,
263 resolvedRefs,
264 visitedObjects,
265 });
266 }
267};
268
269/**
270 * Recursively crawls the given value, and inventories all JSON references.
271 */
272const crawl = <S extends object = JSONSchema>({
273 $refs,
274 indirections,
275 inventory,
276 inventoryLookup,
277 key,
278 options,
279 parent,
280 path,
281 pathFromRoot,
282 resolvedRefs = new Map(),
283 visitedObjects = new WeakSet(),
284}: {
285 $refs: $Refs<S>;
286 indirections: number;
287 /**
288 * An array of already-inventoried $ref pointers
289 */
290 inventory: Array<InventoryEntry>;
291 /**
292 * Fast lookup for inventory entries
293 */
294 inventoryLookup: ReturnType<typeof createInventoryLookup>;
295 /**
296 * The property key of `parent` to be crawled
297 */
298 key: string | null;
299 options: ParserOptions;
300 /**
301 * The object containing the value to crawl. If the value is not an object or array, it will be ignored.
302 */
303 parent: object | $RefParser;
304 /**
305 * The full path of the property being crawled, possibly with a JSON Pointer in the hash
306 */
307 path: string;
308 /**
309 * The path of the property being crawled, from the schema root
310 */
311 pathFromRoot: string;
312 /**
313 * Cache for resolved $ref targets to avoid redundant resolution
314 */
315 resolvedRefs?: Map<string, any>;
316 /**
317 * Set of already visited objects to avoid infinite loops and redundant processing
318 */
319 visitedObjects?: WeakSet<object>;
320}) => {
321 const obj = key === null ? parent : parent[key as keyof typeof parent];
322
323 if (obj && typeof obj === 'object' && !ArrayBuffer.isView(obj)) {
324 // Early exit if we've already processed this exact object
325 if (visitedObjects.has(obj)) return;
326
327 if ($Ref.isAllowed$Ref(obj)) {
328 inventory$Ref({
329 $refKey: key,
330 $refParent: parent,
331 $refs,
332 indirections,
333 inventory,
334 inventoryLookup,
335 options,
336 path,
337 pathFromRoot,
338 resolvedRefs,
339 visitedObjects,
340 });
341 } else {
342 // Mark this object as visited BEFORE processing its children
343 visitedObjects.add(obj);
344
345 // Crawl the object in a specific order that's optimized for bundling.
346 // This is important because it determines how `pathFromRoot` gets built,
347 // which later determines which keys get dereferenced and which ones get remapped
348 const keys = Object.keys(obj).sort((a, b) => {
349 // Most people will expect references to be bundled into the "definitions" property,
350 // so we always crawl that property first, if it exists.
351 if (a === 'definitions') {
352 return -1;
353 } else if (b === 'definitions') {
354 return 1;
355 } else {
356 // Otherwise, crawl the keys based on their length.
357 // This produces the shortest possible bundled references
358 return a.length - b.length;
359 }
360 }) as Array<keyof typeof obj>;
361
362 for (const key of keys) {
363 const keyPath = Pointer.join(path, key);
364 const keyPathFromRoot = Pointer.join(pathFromRoot, key);
365 const value = obj[key];
366
367 if ($Ref.isAllowed$Ref(value)) {
368 inventory$Ref({
369 $refKey: key,
370 $refParent: obj,
371 $refs,
372 indirections,
373 inventory,
374 inventoryLookup,
375 options,
376 path,
377 pathFromRoot: keyPathFromRoot,
378 resolvedRefs,
379 visitedObjects,
380 });
381 } else {
382 crawl({
383 $refs,
384 indirections,
385 inventory,
386 inventoryLookup,
387 key,
388 options,
389 parent: obj,
390 path: keyPath,
391 pathFromRoot: keyPathFromRoot,
392 resolvedRefs,
393 visitedObjects,
394 });
395 }
396 }
397 }
398 }
399};
400
401/**
402 * Remap external refs by hoisting resolved values into a shared container in the root schema
403 * and pointing all occurrences to those internal definitions. Internal refs remain internal.
404 */
405function remap(parser: $RefParser, inventory: Array<InventoryEntry>) {
406 const root = parser.schema as any;
407
408 // Group & sort all the $ref pointers, so they're in the order that we need to dereference/remap them
409 inventory.sort((a: InventoryEntry, b: InventoryEntry) => {
410 if (a.file !== b.file) {
411 // Group all the $refs that point to the same file
412 return a.file < b.file ? -1 : +1;
413 } else if (a.hash !== b.hash) {
414 // Group all the $refs that point to the same part of the file
415 return a.hash < b.hash ? -1 : +1;
416 } else if (a.circular !== b.circular) {
417 // If the $ref points to itself, then sort it higher than other $refs that point to this $ref
418 return a.circular ? -1 : +1;
419 } else if (a.extended !== b.extended) {
420 // If the $ref extends the resolved value, then sort it lower than other $refs that don't extend the value
421 return a.extended ? +1 : -1;
422 } else if (a.indirections !== b.indirections) {
423 // Sort direct references higher than indirect references
424 return a.indirections - b.indirections;
425 } else if (a.depth !== b.depth) {
426 // Sort $refs by how close they are to the JSON Schema root
427 return a.depth - b.depth;
428 } else {
429 // Determine how far each $ref is from the "definitions" property.
430 // Most people will expect references to be bundled into the the "definitions" property if possible.
431 const aDefinitionsIndex = a.pathFromRoot.lastIndexOf('/definitions');
432 const bDefinitionsIndex = b.pathFromRoot.lastIndexOf('/definitions');
433 if (aDefinitionsIndex !== bDefinitionsIndex) {
434 // Give higher priority to the $ref that's closer to the "definitions" property
435 return bDefinitionsIndex - aDefinitionsIndex;
436 } else {
437 // All else is equal, so use the shorter path, which will produce the shortest possible reference
438 return a.pathFromRoot.length - b.pathFromRoot.length;
439 }
440 }
441 });
442
443 // Ensure or return a container by component type. Prefer OpenAPI-aware placement;
444 // otherwise use existing root containers; otherwise create components/*.
445 const ensureContainer = (
446 type: 'schemas' | 'parameters' | 'requestBodies' | 'responses' | 'headers',
447 ) => {
448 const isOas3 = !!(root && typeof root === 'object' && typeof root.openapi === 'string');
449 const isOas2 = !!(root && typeof root === 'object' && typeof root.swagger === 'string');
450
451 if (isOas3) {
452 if (!root.components || typeof root.components !== 'object') {
453 root.components = {};
454 }
455 if (!root.components[type] || typeof root.components[type] !== 'object') {
456 root.components[type] = {};
457 }
458 return { obj: root.components[type], prefix: `#/components/${type}` } as const;
459 }
460
461 if (isOas2) {
462 if (type === 'schemas') {
463 if (!root.definitions || typeof root.definitions !== 'object') {
464 root.definitions = {};
465 }
466 return { obj: root.definitions, prefix: '#/definitions' } as const;
467 }
468 if (type === 'parameters') {
469 if (!root.parameters || typeof root.parameters !== 'object') {
470 root.parameters = {};
471 }
472 return { obj: root.parameters, prefix: '#/parameters' } as const;
473 }
474 if (type === 'responses') {
475 if (!root.responses || typeof root.responses !== 'object') {
476 root.responses = {};
477 }
478 return { obj: root.responses, prefix: '#/responses' } as const;
479 }
480 // requestBodies/headers don't exist as reusable containers in OAS2; fallback to definitions
481 if (!root.definitions || typeof root.definitions !== 'object') {
482 root.definitions = {};
483 }
484 return { obj: root.definitions, prefix: '#/definitions' } as const;
485 }
486
487 // No explicit version: prefer existing containers
488 if (root && typeof root === 'object') {
489 if (root.components && typeof root.components === 'object') {
490 if (!root.components[type] || typeof root.components[type] !== 'object') {
491 root.components[type] = {};
492 }
493 return { obj: root.components[type], prefix: `#/components/${type}` } as const;
494 }
495 if (root.definitions && typeof root.definitions === 'object') {
496 return { obj: root.definitions, prefix: '#/definitions' } as const;
497 }
498 // Create components/* by default if nothing exists
499 if (!root.components || typeof root.components !== 'object') {
500 root.components = {};
501 }
502 if (!root.components[type] || typeof root.components[type] !== 'object') {
503 root.components[type] = {};
504 }
505 return { obj: root.components[type], prefix: `#/components/${type}` } as const;
506 }
507
508 // Fallback
509 root.definitions = root.definitions || {};
510 return { obj: root.definitions, prefix: '#/definitions' } as const;
511 };
512
513 /**
514 * Choose the appropriate component container for bundling.
515 * Prioritizes the original container type from external files over usage location.
516 *
517 * @param entry - The inventory entry containing reference information
518 * @returns The container type to use for bundling
519 */
520 const chooseComponent = (entry: InventoryEntry) => {
521 // If we have the original container type from the external file, use it
522 if (entry.originalContainerType) {
523 return entry.originalContainerType;
524 }
525
526 // Fallback to usage path for internal references or when original type is not available
527 return getContainerTypeFromPath(entry.pathFromRoot);
528 };
529
530 // Track names per (container prefix) and per target
531 const targetToNameByPrefix = new Map<string, Map<string, string>>();
532 const usedNamesByObj = new Map<any, Set<string>>();
533
534 const sanitize = (name: string) => name.replace(/[^A-Za-z0-9_-]/g, '_');
535 const baseName = (filePath: string) => {
536 try {
537 const withoutHash = filePath.split('#')[0]!;
538 const parts = withoutHash.split('/');
539 const filename = parts[parts.length - 1] || 'schema';
540 const dot = filename.lastIndexOf('.');
541 return sanitize(dot > 0 ? filename.substring(0, dot) : filename);
542 } catch {
543 return 'schema';
544 }
545 };
546 const lastToken = (hash: string) => {
547 if (!hash || hash === '#') {
548 return 'root';
549 }
550 const tokens = hash.replace(/^#\//, '').split('/');
551 return sanitize(tokens[tokens.length - 1] || 'root');
552 };
553 const uniqueName = (containerObj: any, proposed: string) => {
554 if (!usedNamesByObj.has(containerObj)) {
555 usedNamesByObj.set(containerObj, new Set<string>(Object.keys(containerObj || {})));
556 }
557 const used = usedNamesByObj.get(containerObj)!;
558 let name = proposed;
559 let i = 2;
560 while (used.has(name)) {
561 name = `${proposed}_${i++}`;
562 }
563 used.add(name);
564 return name;
565 };
566 for (const entry of inventory) {
567 // Safety check: ensure entry and entry.$ref are valid objects
568 if (!entry || !entry.$ref || typeof entry.$ref !== 'object') {
569 continue;
570 }
571
572 // Keep internal refs internal. However, if the $ref extends the resolved value
573 // (i.e. it has additional properties in addition to "$ref"), then we must
574 // preserve the original $ref rather than rewriting it to the resolved hash.
575 if (!entry.external) {
576 if (!entry.extended && entry.$ref && typeof entry.$ref === 'object') {
577 entry.$ref.$ref = entry.hash;
578 }
579 continue;
580 }
581
582 // Avoid changing direct self-references; keep them internal
583 if (entry.circular) {
584 if (entry.$ref && typeof entry.$ref === 'object') {
585 entry.$ref.$ref = entry.pathFromRoot;
586 }
587 continue;
588 }
589
590 // Choose appropriate container based on original location in external file
591 const component = chooseComponent(entry);
592 const { obj: container, prefix } = ensureContainer(component);
593
594 const targetKey = `${entry.file}::${entry.hash}`;
595 if (!targetToNameByPrefix.has(prefix)) {
596 targetToNameByPrefix.set(prefix, new Map<string, string>());
597 }
598 const namesForPrefix = targetToNameByPrefix.get(prefix)!;
599
600 let defName = namesForPrefix.get(targetKey);
601 if (!defName) {
602 // If the external file is one of the original input sources, prefer its assigned prefix
603 let proposedBase = baseName(entry.file);
604 try {
605 const parserAny: any = parser as any;
606 if (
607 parserAny &&
608 parserAny.sourcePathToPrefix &&
609 typeof parserAny.sourcePathToPrefix.get === 'function'
610 ) {
611 const withoutHash = (entry.file || '').split('#')[0];
612 const mapped = parserAny.sourcePathToPrefix.get(withoutHash);
613 if (mapped && typeof mapped === 'string') {
614 proposedBase = mapped;
615 }
616 }
617 } catch {
618 // Ignore errors
619 }
620
621 // Try without prefix first (cleaner names)
622 const schemaName = lastToken(entry.hash);
623 let proposed = schemaName;
624
625 // Check if this name would conflict with existing schemas from other files
626 if (!usedNamesByObj.has(container)) {
627 usedNamesByObj.set(container, new Set<string>(Object.keys(container || {})));
628 }
629 const used = usedNamesByObj.get(container)!;
630
631 // If the name is already used, add the file prefix
632 if (used.has(proposed)) {
633 proposed = `${proposedBase}_${schemaName}`;
634 }
635
636 defName = uniqueName(container, proposed);
637 namesForPrefix.set(targetKey, defName);
638 // Store the resolved value under the container
639 container[defName] = entry.value;
640 }
641
642 // Point the occurrence to the internal definition, preserving extensions
643 const refPath = `${prefix}/${defName}`;
644 if (entry.extended && entry.$ref && typeof entry.$ref === 'object') {
645 entry.$ref.$ref = refPath;
646 } else {
647 entry.parent[entry.key] = { $ref: refPath };
648 }
649 }
650}
651
652function removeFromInventory(inventory: Array<InventoryEntry>, entry: any) {
653 const index = inventory.indexOf(entry);
654 inventory.splice(index, 1);
655}
656
657/**
658 * Bundles all external JSON references into the main JSON schema, thus resulting in a schema that
659 * only has *internal* references, not any *external* references.
660 * This method mutates the JSON schema object, adding new references and re-mapping existing ones.
661 *
662 * @param parser
663 * @param options
664 */
665export function bundle(parser: $RefParser, options: ParserOptions): void {
666 const inventory: Array<InventoryEntry> = [];
667 const inventoryLookup = createInventoryLookup();
668
669 const visitedObjects = new WeakSet<object>();
670 const resolvedRefs = new Map<string, any>();
671
672 crawl<JSONSchema>({
673 $refs: parser.$refs,
674 indirections: 0,
675 inventory,
676 inventoryLookup,
677 key: 'schema',
678 options,
679 parent: parser,
680 path: parser.$refs._root$Ref.path + '#',
681 pathFromRoot: '#',
682 resolvedRefs,
683 visitedObjects,
684 });
685
686 remap(parser, inventory);
687}