Skip to content

Commit

Permalink
feat(walker): adds a max depth option for refs
Browse files Browse the repository at this point in the history
  • Loading branch information
Daniel A. White committed Nov 15, 2023
1 parent a173300 commit 97c4788
Show file tree
Hide file tree
Showing 6 changed files with 62 additions and 5 deletions.
24 changes: 24 additions & 0 deletions src/__tests__/__fixtures__/recursive-schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"title": "Thing",
"allOf": [
{
"$ref": "#/definitions/User"
}
],
"description": "baz",
"definitions": {
"User": {
"type": "object",
"description": "user",
"properties": {
"manager": {
"$ref": "#/definitions/Boss"
}
}
},
"Boss": {
"$ref": "#/definitions/User",
"description": "xyz"
}
}
}
15 changes: 14 additions & 1 deletion src/__tests__/tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('SchemaTree', () => {
it.each(
fastGlob.sync('**/*.json', {
cwd: path.join(__dirname, '__fixtures__'),
ignore: ['stress-schema.json'],
ignore: ['stress-schema.json', 'recursive-schema.json'],
}),
)('should generate valid tree for %s', async filename => {
const schema = JSON.parse(await fs.promises.readFile(path.resolve(__dirname, '__fixtures__', filename), 'utf8'));
Expand Down Expand Up @@ -985,4 +985,17 @@ describe('SchemaTree', () => {
});
});
});

describe('recursive walking', () => {
it('should load with a max depth', async () => {
const schema = JSON.parse(
await fs.promises.readFile(path.resolve(__dirname, '__fixtures__', 'recursive-schema.json'), 'utf8'),
);

const w = new SchemaTree(schema, {
maxRefDepth: 1000,
});
w.populate();
});
});
});
1 change: 1 addition & 0 deletions src/tree/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class SchemaTree {
this.walker = new Walker(this.root, {
mergeAllOf: this.opts?.mergeAllOf !== false,
resolveRef: opts?.refResolver === null ? null : this.resolveRef,
maxRefDepth: opts?.maxRefDepth,
});
}

Expand Down
3 changes: 3 additions & 0 deletions src/tree/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import type { SchemaFragment } from '../types';

export type SchemaTreeOptions = {
mergeAllOf: boolean;
/** Resolves references to the schemas. If providing a custom implementation, it must return the same object reference for the same reference string. */
refResolver: SchemaTreeRefDereferenceFn | null;
/** Controls the level of recursion of refs. Prevents overly complex trees and running out of stack depth. */
maxRefDepth?: number | null;
};

export type SchemaTreeRefInfo = {
Expand Down
3 changes: 3 additions & 0 deletions src/walker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ export type WalkerRefResolver = (path: string[] | null, $ref: string) => SchemaF

export type WalkingOptions = {
mergeAllOf: boolean;
/** Resolves references to the schemas. If providing a custom implementation, it must return the same object reference for the same reference string. */
resolveRef: WalkerRefResolver | null;
/** Controls the level of recursion of refs. Prevents overly complex trees and running out of stack depth. */
maxRefDepth?: number | null;
};

export type WalkerSnapshot = {
Expand Down
21 changes: 17 additions & 4 deletions src/walker/walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,22 @@ export class Walker extends EventEmitter<WalkerEmitter> {
constructor(protected readonly root: RootNode, protected readonly walkingOptions: WalkingOptions) {
super();

let maxRefDepth = walkingOptions.maxRefDepth ?? null;
if (typeof maxRefDepth === 'number') {
if (maxRefDepth < 1) {
maxRefDepth = null;
} else if (maxRefDepth > 1000) {
// experimented with 1500 and the recursion limit is still lower than that
maxRefDepth = 1000;
}
}
walkingOptions.maxRefDepth = maxRefDepth;

this.path = [];
this.depth = -1;
this.fragment = root.fragment;
this.schemaNode = root;
this.processedFragments = new WeakMap<SchemaFragment, SchemaNode>();
this.processedFragments = new WeakMap();
this.mergedAllOfs = new WeakMap();

this.hooks = {};
Expand All @@ -51,7 +62,7 @@ export class Walker extends EventEmitter<WalkerEmitter> {
this.depth = -1;
this.fragment = this.root.fragment;
this.schemaNode = this.root;
this.processedFragments = new WeakMap<SchemaFragment, RegularNode | ReferenceNode>();
this.processedFragments = new WeakMap();
this.mergedAllOfs = new WeakMap();
}

Expand Down Expand Up @@ -265,7 +276,7 @@ export class Walker extends EventEmitter<WalkerEmitter> {
}

protected processFragment(): [SchemaNode, ProcessedFragment] {
const { walkingOptions, path, fragment: originalFragment } = this;
const { walkingOptions, path, fragment: originalFragment, depth } = this;
let { fragment } = this;

let retrieved = isNonNullable(fragment) ? this.retrieveFromFragment(fragment, originalFragment) : null;
Expand All @@ -275,7 +286,9 @@ export class Walker extends EventEmitter<WalkerEmitter> {
}

if ('$ref' in fragment) {
if (typeof fragment.$ref !== 'string') {
if (typeof walkingOptions.maxRefDepth === 'number' && walkingOptions.maxRefDepth < depth) {
return [new ReferenceNode(fragment, `max $ref depth limit reached`), fragment];
} else if (typeof fragment.$ref !== 'string') {
return [new ReferenceNode(fragment, '$ref is not a string'), fragment];
} else if (walkingOptions.resolveRef !== null) {
try {
Expand Down

0 comments on commit 97c4788

Please sign in to comment.