diff --git a/packages/parser/src/custom-operations/resolve-circular-refs.ts b/packages/parser/src/custom-operations/resolve-circular-refs.ts index 388c2d811..a0540a7f0 100644 --- a/packages/parser/src/custom-operations/resolve-circular-refs.ts +++ b/packages/parser/src/custom-operations/resolve-circular-refs.ts @@ -9,39 +9,68 @@ interface Context { document: AsyncAPIObject; hasCircular: boolean; inventory: RulesetFunctionContext['documentInventory']; - visited: Set; + active: WeakSet; + completed: WeakSet; } export function resolveCircularRefs(document: AsyncAPIDocumentInterface, inventory: RulesetFunctionContext['documentInventory']) { const documentJson = document.json(); - const ctx: Context = { document: documentJson, hasCircular: false, inventory, visited: new Set() }; + const ctx: Context = { + document: documentJson, + hasCircular: false, + inventory, + active: new WeakSet(), + completed: new WeakSet(), + }; traverse(documentJson, [], null, '', ctx); if (ctx.hasCircular) { setExtension(xParserCircular, true, document); } } -function traverse(data: any, path: Array, parent: any, property: string | number, ctx: Context) { - if (typeof data !== 'object' || !data || ctx.visited.has(data)) { - return; +function traverse(data: any, path: Array, parent: any, property: string | number, ctx: Context): boolean { + if (typeof data !== 'object' || !data) { + return true; } - ctx.visited.add(data); - if (Array.isArray(data)) { - data.forEach((item, idx) => traverse(item, [...path, idx], data, idx, ctx)); + if (ctx.completed.has(data)) { + return true; + } + + if (ctx.active.has(data)) { + return !('$ref' in data); } + if ('$ref' in data) { ctx.hasCircular = true; + ctx.active.add(data); const resolvedRef = retrieveCircularRef(data, path, ctx); if (resolvedRef) { parent[property] = resolvedRef; + const completed = traverse(resolvedRef, path, parent, property, ctx); + ctx.active.delete(data); + return completed; } + ctx.active.delete(data); + return false; + } + + ctx.active.add(data); + let completed = true; + if (Array.isArray(data)) { + data.forEach((item, idx) => { + completed = traverse(item, [...path, idx], data, idx, ctx) && completed; + }); } else { for (const p in data) { - traverse(data[p], [...path, p], data, p, ctx); + completed = traverse(data[p], [...path, p], data, p, ctx) && completed; } } - ctx.visited.delete(data); + ctx.active.delete(data); + if (completed) { + ctx.completed.add(data); + } + return completed; } function retrieveCircularRef(data: { $ref: string }, path: Array, ctx: Context): any { diff --git a/packages/parser/test/custom-operations/resolve-circular-refs.spec.ts b/packages/parser/test/custom-operations/resolve-circular-refs.spec.ts index a1005a0e4..a2f809f0f 100644 --- a/packages/parser/test/custom-operations/resolve-circular-refs.spec.ts +++ b/packages/parser/test/custom-operations/resolve-circular-refs.spec.ts @@ -1,5 +1,6 @@ import { Parser } from '../../src/parser'; import { xParserCircular } from '../../src/constants'; +import { resolveCircularRefs } from '../../src/custom-operations/resolve-circular-refs'; describe('custom operations - check circular references', function() { const parser = new Parser(); @@ -35,6 +36,28 @@ describe('custom operations - check circular references', function() { expect(document?.extensions().get(xParserCircular)?.value()).toEqual(undefined); }); + it('should traverse a shared acyclic object only once', function() { + let leafVisits = 0; + const leaf = {}; + Object.defineProperty(leaf, 'value', { + enumerable: true, + get() { + leafVisits += 1; + return 'value'; + }, + }); + + let graph: Record = leaf; + for (let level = 0; level < 12; level += 1) { + graph = { left: graph, right: graph }; + } + + const document = { json: () => graph } as any; + resolveCircularRefs(document, {} as any); + + expect(leafVisits).toEqual(1); + }); + it('should assign x-parser-circular extension when document has circular schemas', async function() { const { document } = await parser.parse({ asyncapi: '2.0.0',