fork of hey-api/openapi-ts because I need some additional things
at feat/skip-token 687 lines 23 kB view raw
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}