fork of hey-api/openapi-ts because I need some additional things
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix cross-file reference bug in bundle()

When an external file references a schema from another external file using a local-looking ref (e.g., #/components/schemas/SchemaB), the bundle() function would:
1. Try to resolve it relative to the current file
2. Fail and log "Skipping unresolvable $ref"
3. Leave the ref as-is in the bundled output
4. Create a dangling reference since SchemaB is hoisted with a different name

Added fixDanglingRefs() post-processing step that:
- Identifies dangling internal $refs
- Matches them to hoisted schemas from other files
- Rewrites them to the correct hoisted names

Test case added: cross-file reference scenario

Co-authored-by: mrlubos <12529395+mrlubos@users.noreply.github.com>

+231
+57
packages/json-schema-ref-parser/src/__tests__/bundle.test.ts
··· 161 161 expect(actionParams.properties.ActionType.$ref).toContain('ResolutionType'); 162 162 expect(actionParams.properties.ActionType.$ref).toMatch(/^#\/components\/schemas\//); 163 163 }); 164 + 165 + it('fixes cross-file references (schemas in different external files)', async () => { 166 + const refParser = new $RefParser(); 167 + const pathOrUrlOrSchema = path.join( 168 + getSpecsPath(), 169 + 'json-schema-ref-parser', 170 + 'cross-file-ref-main.json', 171 + ); 172 + const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; 173 + 174 + // Both schemas should be hoisted 175 + expect(schema.components).toBeDefined(); 176 + expect(schema.components.schemas).toBeDefined(); 177 + 178 + const schemaKeys = Object.keys(schema.components.schemas); 179 + expect(schemaKeys.length).toBe(2); 180 + 181 + // Find the hoisted schemas 182 + const schemaAKey = schemaKeys.find((k) => k.includes('SchemaA')); 183 + const schemaBKey = schemaKeys.find((k) => k.includes('SchemaB')); 184 + 185 + expect(schemaAKey).toBeDefined(); 186 + expect(schemaBKey).toBeDefined(); 187 + 188 + // SchemaA should have a reference to SchemaB 189 + const schemaA = schema.components.schemas[schemaAKey!]; 190 + expect(schemaA.properties.typeField.$ref).toBe(`#/components/schemas/${schemaBKey}`); 191 + 192 + // SchemaB should be the enum type 193 + const schemaB = schema.components.schemas[schemaBKey!]; 194 + expect(schemaB).toEqual({ 195 + enum: ['TypeA', 'TypeB', 'TypeC'], 196 + type: 'string', 197 + }); 198 + 199 + // Verify no dangling refs exist 200 + const findDanglingRefs = (obj: any, schemas: any): string[] => { 201 + const dangling: string[] = []; 202 + const check = (o: any) => { 203 + if (!o || typeof o !== 'object') return; 204 + if (o.$ref && typeof o.$ref === 'string' && o.$ref.startsWith('#/components/schemas/')) { 205 + const schemaName = o.$ref.replace('#/components/schemas/', ''); 206 + if (!schemas[schemaName]) { 207 + dangling.push(o.$ref); 208 + } 209 + } 210 + for (const value of Object.values(o)) { 211 + check(value); 212 + } 213 + }; 214 + check(obj); 215 + return dangling; 216 + }; 217 + 218 + const danglingRefs = findDanglingRefs(schema, schema.components.schemas); 219 + expect(danglingRefs).toEqual([]); 220 + }); 164 221 });
+106
packages/json-schema-ref-parser/src/bundle.ts
··· 609 609 } 610 610 611 611 /** 612 + * Fix dangling $refs that point to schemas in the root spec but don't exist. 613 + * This can happen when an external file references another external file's schema 614 + * using a local-looking ref like #/components/schemas/SchemaName. 615 + * 616 + * @param parser 617 + */ 618 + function fixDanglingRefs(parser: $RefParser): void { 619 + const root = parser.schema as any; 620 + if (!root || typeof root !== 'object') { 621 + return; 622 + } 623 + 624 + // Get all hoisted schemas from components 625 + const containers = [ 626 + { obj: root.components?.schemas, prefix: '#/components/schemas/' }, 627 + { obj: root.components?.parameters, prefix: '#/components/parameters/' }, 628 + { obj: root.components?.requestBodies, prefix: '#/components/requestBodies/' }, 629 + { obj: root.components?.responses, prefix: '#/components/responses/' }, 630 + { obj: root.components?.headers, prefix: '#/components/headers/' }, 631 + { obj: root.definitions, prefix: '#/definitions/' }, 632 + { obj: root.parameters, prefix: '#/parameters/' }, 633 + { obj: root.responses, prefix: '#/responses/' }, 634 + ].filter((c) => c.obj && typeof c.obj === 'object'); 635 + 636 + // Build a map of simple schema names to their hoisted full names 637 + // E.g., "SchemaB" -> "file2_SchemaB" 638 + const schemaNameMap = new Map<string, Array<{ fullName: string; prefix: string }>>(); 639 + 640 + for (const container of containers) { 641 + for (const fullName of Object.keys(container.obj)) { 642 + // Extract the original schema name from the hoisted name 643 + // Hoisted names are typically "filename_SchemaName" 644 + // Try to match the pattern and extract SchemaName 645 + const parts = fullName.split('_'); 646 + if (parts.length >= 2) { 647 + // The last part(s) might be the original schema name 648 + // Try progressively longer suffixes 649 + for (let i = 1; i < parts.length; i++) { 650 + const schemaName = parts.slice(i).join('_'); 651 + if (!schemaNameMap.has(schemaName)) { 652 + schemaNameMap.set(schemaName, []); 653 + } 654 + schemaNameMap.get(schemaName)!.push({ 655 + fullName, 656 + prefix: container.prefix, 657 + }); 658 + } 659 + } 660 + } 661 + } 662 + 663 + // Find and fix all dangling $refs 664 + const fixRefs = (obj: any, visited = new WeakSet<object>()): void => { 665 + if (!obj || typeof obj !== 'object' || ArrayBuffer.isView(obj)) { 666 + return; 667 + } 668 + 669 + if (visited.has(obj)) { 670 + return; 671 + } 672 + visited.add(obj); 673 + 674 + if ($Ref.is$Ref(obj)) { 675 + const ref = obj.$ref; 676 + if (typeof ref === 'string') { 677 + // Check if this is a dangling internal ref 678 + for (const container of containers) { 679 + if (ref.startsWith(container.prefix)) { 680 + const schemaName = ref.substring(container.prefix.length); 681 + 682 + // Check if the exact name exists 683 + if (container.obj[schemaName]) { 684 + continue; // Not dangling 685 + } 686 + 687 + // Try to find a hoisted schema that matches this name 688 + const candidates = schemaNameMap.get(schemaName) || []; 689 + 690 + if (candidates.length === 1) { 691 + // Unambiguous match - fix the ref 692 + const candidate = candidates[0]!; 693 + obj.$ref = `${candidate.prefix}${candidate.fullName}`; 694 + console.warn(`Fixed dangling $ref: ${ref} -> ${obj.$ref}`); 695 + } else if (candidates.length > 1) { 696 + // Multiple matches - log warning but don't change 697 + console.warn( 698 + `Ambiguous dangling $ref: ${ref} could refer to: ${candidates.map((c) => `${c.prefix}${c.fullName}`).join(', ')}`, 699 + ); 700 + } 701 + // If no candidates, leave as-is (will remain dangling) 702 + } 703 + } 704 + } 705 + } 706 + 707 + // Recursively fix refs in nested objects 708 + for (const value of Object.values(obj)) { 709 + fixRefs(value, visited); 710 + } 711 + }; 712 + 713 + fixRefs(root); 714 + } 715 + 716 + /** 612 717 * Bundles all external JSON references into the main JSON schema, thus resulting in a schema that 613 718 * only has *internal* references, not any *external* references. 614 719 * This method mutates the JSON schema object, adding new references and re-mapping existing ones. ··· 638 743 }); 639 744 640 745 remap(parser, inventory); 746 + fixDanglingRefs(parser); 641 747 }
+17
specs/json-schema-ref-parser/cross-file-ref-file1.json
··· 1 + { 2 + "components": { 3 + "schemas": { 4 + "SchemaA": { 5 + "type": "object", 6 + "properties": { 7 + "typeField": { 8 + "$ref": "#/components/schemas/SchemaB" 9 + }, 10 + "name": { 11 + "type": "string" 12 + } 13 + } 14 + } 15 + } 16 + } 17 + }
+10
specs/json-schema-ref-parser/cross-file-ref-file2.json
··· 1 + { 2 + "components": { 3 + "schemas": { 4 + "SchemaB": { 5 + "type": "string", 6 + "enum": ["TypeA", "TypeB", "TypeC"] 7 + } 8 + } 9 + } 10 + }
+41
specs/json-schema-ref-parser/cross-file-ref-main.json
··· 1 + { 2 + "openapi": "3.0.0", 3 + "info": { 4 + "title": "Cross-file Reference Test", 5 + "version": "1.0.0" 6 + }, 7 + "paths": { 8 + "/resource-a": { 9 + "get": { 10 + "responses": { 11 + "200": { 12 + "description": "Returns SchemaA", 13 + "content": { 14 + "application/json": { 15 + "schema": { 16 + "$ref": "cross-file-ref-file1.json#/components/schemas/SchemaA" 17 + } 18 + } 19 + } 20 + } 21 + } 22 + } 23 + }, 24 + "/resource-b": { 25 + "get": { 26 + "responses": { 27 + "200": { 28 + "description": "Returns SchemaB", 29 + "content": { 30 + "application/json": { 31 + "schema": { 32 + "$ref": "cross-file-ref-file2.json#/components/schemas/SchemaB" 33 + } 34 + } 35 + } 36 + } 37 + } 38 + } 39 + } 40 + } 41 + }