diff --git a/packages/json-schema-ref-parser/src/__tests__/bundle.test.ts b/packages/json-schema-ref-parser/src/__tests__/bundle.test.ts index 5f712ce75..513b076ee 100644 --- a/packages/json-schema-ref-parser/src/__tests__/bundle.test.ts +++ b/packages/json-schema-ref-parser/src/__tests__/bundle.test.ts @@ -161,4 +161,61 @@ describe('bundle', () => { expect(actionParams.properties.ActionType.$ref).toContain('ResolutionType'); expect(actionParams.properties.ActionType.$ref).toMatch(/^#\/components\/schemas\//); }); + + it('fixes cross-file references (schemas in different external files)', async () => { + const refParser = new $RefParser(); + const pathOrUrlOrSchema = path.join( + getSpecsPath(), + 'json-schema-ref-parser', + 'cross-file-ref-main.json', + ); + const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; + + // Both schemas should be hoisted + expect(schema.components).toBeDefined(); + expect(schema.components.schemas).toBeDefined(); + + const schemaKeys = Object.keys(schema.components.schemas); + expect(schemaKeys.length).toBe(2); + + // Find the hoisted schemas + const schemaAKey = schemaKeys.find((k) => k.includes('SchemaA')); + const schemaBKey = schemaKeys.find((k) => k.includes('SchemaB')); + + expect(schemaAKey).toBeDefined(); + expect(schemaBKey).toBeDefined(); + + // SchemaA should have a reference to SchemaB + const schemaA = schema.components.schemas[schemaAKey!]; + expect(schemaA.properties.typeField.$ref).toBe(`#/components/schemas/${schemaBKey}`); + + // SchemaB should be the enum type + const schemaB = schema.components.schemas[schemaBKey!]; + expect(schemaB).toEqual({ + enum: ['TypeA', 'TypeB', 'TypeC'], + type: 'string', + }); + + // Verify no dangling refs exist + const findDanglingRefs = (obj: any, schemas: any): string[] => { + const dangling: string[] = []; + const check = (o: any) => { + if (!o || typeof o !== 'object') return; + if (o.$ref && typeof o.$ref === 'string' && o.$ref.startsWith('#/components/schemas/')) { + const schemaName = o.$ref.replace('#/components/schemas/', ''); + if (!schemas[schemaName]) { + dangling.push(o.$ref); + } + } + for (const value of Object.values(o)) { + check(value); + } + }; + check(obj); + return dangling; + }; + + const danglingRefs = findDanglingRefs(schema, schema.components.schemas); + expect(danglingRefs).toEqual([]); + }); }); diff --git a/packages/json-schema-ref-parser/src/bundle.ts b/packages/json-schema-ref-parser/src/bundle.ts index 7293f917d..da9739388 100644 --- a/packages/json-schema-ref-parser/src/bundle.ts +++ b/packages/json-schema-ref-parser/src/bundle.ts @@ -608,6 +608,111 @@ function removeFromInventory(inventory: Array, entry: any) { inventory.splice(index, 1); } +/** + * Fix dangling $refs that point to schemas in the root spec but don't exist. + * This can happen when an external file references another external file's schema + * using a local-looking ref like #/components/schemas/SchemaName. + * + * @param parser + */ +function fixDanglingRefs(parser: $RefParser): void { + const root = parser.schema as any; + if (!root || typeof root !== 'object') { + return; + } + + // Get all hoisted schemas from components + const containers = [ + { obj: root.components?.schemas, prefix: '#/components/schemas/' }, + { obj: root.components?.parameters, prefix: '#/components/parameters/' }, + { obj: root.components?.requestBodies, prefix: '#/components/requestBodies/' }, + { obj: root.components?.responses, prefix: '#/components/responses/' }, + { obj: root.components?.headers, prefix: '#/components/headers/' }, + { obj: root.definitions, prefix: '#/definitions/' }, + { obj: root.parameters, prefix: '#/parameters/' }, + { obj: root.responses, prefix: '#/responses/' }, + ].filter((c) => c.obj && typeof c.obj === 'object'); + + // Build a map of simple schema names to their hoisted full names + // E.g., "SchemaB" -> "file2_SchemaB" + const schemaNameMap = new Map>(); + + for (const container of containers) { + for (const fullName of Object.keys(container.obj)) { + // Extract the original schema name from the hoisted name + // Hoisted names are typically "filename_SchemaName" + // Try to match the pattern and extract SchemaName + const parts = fullName.split('_'); + if (parts.length >= 2) { + // The last part(s) might be the original schema name + // Try progressively longer suffixes + for (let i = 1; i < parts.length; i++) { + const schemaName = parts.slice(i).join('_'); + if (!schemaNameMap.has(schemaName)) { + schemaNameMap.set(schemaName, []); + } + schemaNameMap.get(schemaName)!.push({ + fullName, + prefix: container.prefix, + }); + } + } + } + } + + // Find and fix all dangling $refs + const fixRefs = (obj: any, visited = new WeakSet()): void => { + if (!obj || typeof obj !== 'object' || ArrayBuffer.isView(obj)) { + return; + } + + if (visited.has(obj)) { + return; + } + visited.add(obj); + + if ($Ref.is$Ref(obj)) { + const ref = obj.$ref; + if (typeof ref === 'string') { + // Check if this is a dangling internal ref + for (const container of containers) { + if (ref.startsWith(container.prefix)) { + const schemaName = ref.substring(container.prefix.length); + + // Check if the exact name exists + if (container.obj[schemaName]) { + continue; // Not dangling + } + + // Try to find a hoisted schema that matches this name + const candidates = schemaNameMap.get(schemaName) || []; + + if (candidates.length === 1) { + // Unambiguous match - fix the ref + const candidate = candidates[0]!; + obj.$ref = `${candidate.prefix}${candidate.fullName}`; + console.warn(`Fixed dangling $ref: ${ref} -> ${obj.$ref}`); + } else if (candidates.length > 1) { + // Multiple matches - log warning but don't change + console.warn( + `Ambiguous dangling $ref: ${ref} could refer to: ${candidates.map((c) => `${c.prefix}${c.fullName}`).join(', ')}`, + ); + } + // If no candidates, leave as-is (will remain dangling) + } + } + } + } + + // Recursively fix refs in nested objects + for (const value of Object.values(obj)) { + fixRefs(value, visited); + } + }; + + fixRefs(root); +} + /** * Bundles all external JSON references into the main JSON schema, thus resulting in a schema that * only has *internal* references, not any *external* references. @@ -638,4 +743,5 @@ export function bundle(parser: $RefParser, options: ParserOptions): void { }); remap(parser, inventory); + fixDanglingRefs(parser); } diff --git a/specs/json-schema-ref-parser/cross-file-ref-file1.json b/specs/json-schema-ref-parser/cross-file-ref-file1.json new file mode 100644 index 000000000..2aae6f78f --- /dev/null +++ b/specs/json-schema-ref-parser/cross-file-ref-file1.json @@ -0,0 +1,17 @@ +{ + "components": { + "schemas": { + "SchemaA": { + "type": "object", + "properties": { + "typeField": { + "$ref": "#/components/schemas/SchemaB" + }, + "name": { + "type": "string" + } + } + } + } + } +} diff --git a/specs/json-schema-ref-parser/cross-file-ref-file2.json b/specs/json-schema-ref-parser/cross-file-ref-file2.json new file mode 100644 index 000000000..da4d572ff --- /dev/null +++ b/specs/json-schema-ref-parser/cross-file-ref-file2.json @@ -0,0 +1,10 @@ +{ + "components": { + "schemas": { + "SchemaB": { + "type": "string", + "enum": ["TypeA", "TypeB", "TypeC"] + } + } + } +} diff --git a/specs/json-schema-ref-parser/cross-file-ref-main.json b/specs/json-schema-ref-parser/cross-file-ref-main.json new file mode 100644 index 000000000..d48af53a2 --- /dev/null +++ b/specs/json-schema-ref-parser/cross-file-ref-main.json @@ -0,0 +1,41 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Cross-file Reference Test", + "version": "1.0.0" + }, + "paths": { + "/resource-a": { + "get": { + "responses": { + "200": { + "description": "Returns SchemaA", + "content": { + "application/json": { + "schema": { + "$ref": "cross-file-ref-file1.json#/components/schemas/SchemaA" + } + } + } + } + } + } + }, + "/resource-b": { + "get": { + "responses": { + "200": { + "description": "Returns SchemaB", + "content": { + "application/json": { + "schema": { + "$ref": "cross-file-ref-file2.json#/components/schemas/SchemaB" + } + } + } + } + } + } + } + } +}