Skip to content

Commit a480185

Browse files
committed
fix: graceful allOf merging handling
1 parent 242ad30 commit a480185

File tree

6 files changed

+167
-22
lines changed

6 files changed

+167
-22
lines changed

src/errors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export class ResolvingError extends ReferenceError {
2+
public readonly name = 'ResolvingError';
3+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export { JsonSchemaViewer, SchemaRow, SchemaTree as SchemaTreeComponent, Propert
22
export * from './tree';
33
export * from './types';
44
export * from './utils';
5+
export * from './errors';

src/tree/__tests__/tree.spec.ts

Lines changed: 133 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { TreeListParentNode } from '@stoplight/tree-list';
22
import { JSONSchema4 } from 'json-schema';
3+
import { ResolvingError } from '../../errors';
34
import { getNodeMetadata } from '../metadata';
45
import { SchemaTree, SchemaTreeState } from '../tree';
56
import { printTree } from './utils/printTree';
@@ -62,7 +63,7 @@ describe('SchemaTree', () => {
6263
},
6364
},
6465
],
65-
})
66+
}),
6667
);
6768
});
6869

@@ -83,7 +84,7 @@ describe('SchemaTree', () => {
8384
type: 'string',
8485
},
8586
},
86-
})
87+
}),
8788
);
8889
});
8990

@@ -103,7 +104,7 @@ describe('SchemaTree', () => {
103104
type: 'string',
104105
},
105106
},
106-
})
107+
}),
107108
);
108109
});
109110
});
@@ -159,7 +160,7 @@ describe('SchemaTree', () => {
159160
type: 'string',
160161
},
161162
},
162-
})
163+
}),
163164
);
164165
});
165166
});
@@ -196,7 +197,7 @@ describe('SchemaTree', () => {
196197
expandedDepth: 0,
197198
mergeAllOf: false,
198199
resolveRef() {
199-
throw new ReferenceError('Seems like you do not want this to be empty.');
200+
throw new ResolvingError('Seems like you do not want this to be empty.');
200201
},
201202
});
202203

@@ -205,7 +206,7 @@ describe('SchemaTree', () => {
205206
tree.unwrap(tree.itemAt(1) as TreeListParentNode);
206207
expect(getNodeMetadata(tree.itemAt(2) as TreeListParentNode)).toHaveProperty(
207208
'error',
208-
'Seems like you do not want this to be empty.'
209+
'Seems like you do not want this to be empty.',
209210
);
210211
});
211212
});
@@ -242,13 +243,13 @@ describe('SchemaTree', () => {
242243
tree.unwrap(tree.itemAt(1) as TreeListParentNode);
243244
expect(getNodeMetadata(tree.itemAt(2) as TreeListParentNode)).toHaveProperty(
244245
'error',
245-
'Cannot dereference external references'
246+
'Cannot dereference external references',
246247
);
247248

248249
tree.unwrap(tree.itemAt(3) as TreeListParentNode);
249250
expect(getNodeMetadata(tree.itemAt(4) as TreeListParentNode)).toHaveProperty(
250251
'error',
251-
'Cannot dereference external references'
252+
'Cannot dereference external references',
252253
);
253254
});
254255

@@ -258,10 +259,10 @@ describe('SchemaTree', () => {
258259
mergeAllOf: false,
259260
resolveRef({ source, pointer }) {
260261
if (source === '../test') {
261-
throw new ReferenceError(`Could not read "${source}"`);
262+
throw new ResolvingError(`Could not read "${source}"`);
262263
}
263264

264-
throw new ReferenceError(`Pointer "${pointer}" is missing`);
265+
throw new ResolvingError(`Pointer "${pointer}" is missing`);
265266
},
266267
});
267268

@@ -270,13 +271,13 @@ describe('SchemaTree', () => {
270271
tree.unwrap(tree.itemAt(1) as TreeListParentNode);
271272
expect(getNodeMetadata(tree.itemAt(2) as TreeListParentNode)).toHaveProperty(
272273
'error',
273-
'Could not read "../test"'
274+
'Could not read "../test"',
274275
);
275276

276277
tree.unwrap(tree.itemAt(3) as TreeListParentNode);
277278
expect(getNodeMetadata(tree.itemAt(4) as TreeListParentNode)).toHaveProperty(
278279
'error',
279-
'Pointer "#id" is missing'
280+
'Pointer "#id" is missing',
280281
);
281282
});
282283
});
@@ -437,7 +438,7 @@ describe('SchemaTree', () => {
437438
`);
438439
});
439440

440-
test('given direct circular reference pointing at allOf, should throw', () => {
441+
test('given direct circular reference pointing at allOf, should bail out and display unmerged allOf', () => {
441442
const schema: JSONSchema4 = {
442443
type: 'object',
443444
properties: {
@@ -472,10 +473,39 @@ describe('SchemaTree', () => {
472473
resolveRef: void 0,
473474
});
474475

475-
expect(tree.populate.bind(tree)).toThrowError('Circular reference detected');
476+
expect(tree.populate.bind(tree)).not.toThrow();
477+
expect(printTree(tree)).toMatchInlineSnapshot(`
478+
"└─ #
479+
├─ type: object
480+
└─ children
481+
├─ 0
482+
│ └─ #/properties/foo
483+
│ ├─ type: undefined
484+
│ └─ children
485+
│ └─ 0
486+
│ └─ #/properties/foo/allOf/0
487+
│ ├─ type: undefined
488+
│ └─ children
489+
└─ 1
490+
└─ #/properties/bar
491+
├─ type: undefined
492+
└─ children
493+
├─ 0
494+
│ └─ #/properties/bar/allOf/0
495+
│ ├─ type: undefined
496+
│ └─ children
497+
└─ 1
498+
└─ #/properties/bar/allOf/1
499+
├─ type: object
500+
└─ children
501+
└─ 0
502+
└─ #/properties/bar/allOf/1/properties/name
503+
└─ type: string
504+
"
505+
`);
476506
});
477507

478-
test('given indirect circular reference pointing at allOf, should throw', () => {
508+
test('given indirect circular reference pointing at allOf, should bail out and display unmerged allOf', () => {
479509
const schema: JSONSchema4 = {
480510
type: 'object',
481511
properties: {
@@ -517,11 +547,98 @@ describe('SchemaTree', () => {
517547
resolveRef: void 0,
518548
});
519549

520-
expect(tree.populate.bind(tree)).toThrowError('Circular reference detected');
550+
expect(tree.populate.bind(tree)).not.toThrow();
551+
expect(printTree(tree)).toMatchInlineSnapshot(`
552+
"└─ #
553+
├─ type: object
554+
└─ children
555+
├─ 0
556+
│ └─ #/properties/baz
557+
│ ├─ type: undefined
558+
│ └─ children
559+
│ └─ 0
560+
│ └─ #/properties/baz/allOf/0
561+
│ ├─ type: undefined
562+
│ └─ children
563+
├─ 1
564+
│ └─ #/properties/foo
565+
│ ├─ type: undefined
566+
│ └─ children
567+
│ └─ 0
568+
│ └─ #/properties/foo/allOf/0
569+
│ ├─ type: undefined
570+
│ └─ children
571+
└─ 2
572+
└─ #/properties/bar
573+
├─ type: undefined
574+
└─ children
575+
├─ 0
576+
│ └─ #/properties/bar/allOf/0
577+
│ ├─ type: undefined
578+
│ └─ children
579+
└─ 1
580+
└─ #/properties/bar/allOf/1
581+
├─ type: object
582+
└─ children
583+
└─ 0
584+
└─ #/properties/bar/allOf/1/properties/name
585+
└─ type: string
586+
"
587+
`);
521588
});
522589
});
523590
});
524591

592+
describe('allOf failures', () => {
593+
test('given incompatible values, should bail out and display unmerged allOf', () => {
594+
const schema: JSONSchema4 = {
595+
allOf: [
596+
{
597+
type: 'string',
598+
},
599+
{
600+
type: 'number',
601+
},
602+
{
603+
type: 'object',
604+
properties: {
605+
name: {
606+
type: 'string',
607+
},
608+
},
609+
},
610+
],
611+
};
612+
613+
const tree = new SchemaTree(schema, new SchemaTreeState(), {
614+
expandedDepth: Infinity,
615+
mergeAllOf: true,
616+
resolveRef: void 0,
617+
});
618+
619+
expect(tree.populate.bind(tree)).not.toThrow();
620+
expect(printTree(tree)).toMatchInlineSnapshot(`
621+
"└─ #
622+
├─ type: undefined
623+
└─ children
624+
├─ 0
625+
│ └─ #/allOf/0
626+
│ └─ type: string
627+
├─ 1
628+
│ └─ #/allOf/1
629+
│ └─ type: number
630+
└─ 2
631+
└─ #/allOf/2
632+
├─ type: object
633+
└─ children
634+
└─ 0
635+
└─ #/allOf/2/properties/name
636+
└─ type: string
637+
"
638+
`);
639+
});
640+
});
641+
525642
describe('paths generation', () => {
526643
let schema: JSONSchema4;
527644
let tree: SchemaTree;

src/tree/tree.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Tree, TreeListParentNode, TreeState } from '@stoplight/tree-list';
33
import { JsonPath, Optional } from '@stoplight/types';
44
import { JSONSchema4 } from 'json-schema';
55
import { get as _get, isEqual as _isEqual, isObject as _isObject } from 'lodash';
6+
import { ResolvingError } from '../errors';
67
import { SchemaTreeListNode } from '../types';
78
import { generateId } from '../utils/generateId';
89
import { hasRefItems, isRefNode } from '../utils/guards';
@@ -143,9 +144,9 @@ export class SchemaTree extends Tree {
143144
if (this.resolver !== void 0) {
144145
return this.resolver({ source, pointer }, path, this.schema);
145146
} else if (source !== null) {
146-
throw new ReferenceError('Cannot dereference external references');
147+
throw new ResolvingError('Cannot dereference external references');
147148
} else if (pointer === null) {
148-
throw new ReferenceError('The pointer is empty');
149+
throw new ResolvingError('The pointer is empty');
149150
} else {
150151
return _get(this.schema, pointerToPath(pointer));
151152
}
@@ -159,7 +160,7 @@ export class SchemaTree extends Tree {
159160
const schemaFragment = this.resolveRef(path, $ref);
160161

161162
if (!_isObject(schemaFragment)) {
162-
throw new ReferenceError(`Could not dereference "${$ref}"`);
163+
throw new ResolvingError(`Could not dereference "${$ref}"`);
163164
}
164165

165166
this.populateTreeFragment(node, schemaFragment, path, false);

src/tree/utils/populateTree.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,16 @@ export const populateTree: Walker = (schema, parent, level, path, options): unde
5858
break;
5959
}
6060
} else if (node.combiner === 'allOf' && options?.mergeAllOf) {
61-
parent.children.pop();
62-
populateTree(mergeAllOf(fragment, options.resolveRef), parent, level, path, options);
61+
try {
62+
const merged = mergeAllOf(fragment, options.resolveRef);
63+
parent.children.pop();
64+
populateTree(merged, parent, level, path, options);
65+
} catch (ex) {
66+
if (Array.isArray(fragment.allOf)) {
67+
(treeNode as TreeListParentNode).children = [];
68+
bailAllOf(treeNode as TreeListParentNode, fragment, level + 1, [...path, 'allOf'], options);
69+
}
70+
}
6371
} else if (_isObject(node.properties)) {
6472
(treeNode as TreeListParentNode).children = [];
6573

@@ -167,3 +175,17 @@ function processObject(
167175

168176
return node;
169177
}
178+
179+
function bailAllOf(
180+
node: TreeListParentNode,
181+
schema: JSONSchema4,
182+
level: number,
183+
path: JsonPath,
184+
options: WalkingOptions | null,
185+
) {
186+
if (Array.isArray(schema.allOf)) {
187+
for (const [i, item] of schema.allOf.entries()) {
188+
populateTree(item, node, level, [...path, i], options);
189+
}
190+
}
191+
}

src/utils/mergeAllOf.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { JSONSchema4 } from 'json-schema';
22
import { cloneDeep, isObject } from 'lodash';
3+
import { ResolvingError } from '../errors';
34
import { WalkerRefResolver } from '../tree/utils/populateTree';
45

56
const resolveAllOf = require('@stoplight/json-schema-merge-allof');
@@ -22,7 +23,7 @@ function _mergeAllOf(schema: JSONSchema4, resolveRef: WalkerRefResolver, seen: S
2223

2324
const resolved = resolveRef(null, $ref);
2425
if (seen.has($ref)) {
25-
throw new ReferenceError('Circular reference detected');
26+
throw new ResolvingError('Circular reference detected');
2627
}
2728

2829
seen.add($ref);

0 commit comments

Comments
 (0)