Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 39 additions & 10 deletions packages/parser/src/custom-operations/resolve-circular-refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,39 +9,68 @@ interface Context {
document: AsyncAPIObject;
hasCircular: boolean;
inventory: RulesetFunctionContext['documentInventory'];
visited: Set<any>;
active: WeakSet<object>;
completed: WeakSet<object>;
}

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<string | number>, parent: any, property: string | number, ctx: Context) {
if (typeof data !== 'object' || !data || ctx.visited.has(data)) {
return;
function traverse(data: any, path: Array<string | number>, 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<string | number>, ctx: Context): any {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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<string, any> = 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',
Expand Down
Loading