diff --git a/docs/openapi-ruleset-utilities.md b/docs/openapi-ruleset-utilities.md index 139e9b7f..9c05c96e 100644 --- a/docs/openapi-ruleset-utilities.md +++ b/docs/openapi-ruleset-utilities.md @@ -371,6 +371,18 @@ composed by those schemas. Composed schemas **do not** include nested schemas (`property`, `additionalProperties`, `patternProperties`, and `items` schemas). +The provided validate function is called with two arguments: +- `schema` — the composed schema +- `path` — the array of path segments to locate the composed schema within the resolved document + +The provided `validate()` function is guaranteed to be called: +- for a schema before any of its composed schemas +- more recently for the schema that composes it (its "composition parent") than for that schema's + siblings (or their descendants) in the composition tree + +However, it is not guaranteed that the `validate()` function is called in any particular order +for a schema's directly composed schemas. + WARNING: It is only safe to use this function for a "resolved" schema — it cannot traverse `$ref` references. @@ -404,6 +416,22 @@ schemas. Nested schemas included via `allOf`, `oneOf`, and `anyOf` are validated, but composed schemas are not themselves validated. By default, nested schemas included via `not` are not validated. +The provided validate function is called with three arguments: +- `nestedSchema`: the nested schema +- `path`: the array of path segments to locate the nested schema within the resolved document +- `logicalPath`: the array of path segments to locate an instance of `nestedSchema` within an + instance of `schema` (the schema for which `validateNestedSchemas()` was originally called.) + Within the logical path, an arbitrary array item is represented by `[]` and an arbitrary + dictionary property is represented by `*`. + +The provided `validate()` function is guaranteed to be called: +- for a schema before any of its nested schemas +- more recently for the schema that nests it (its "nesting parent") than for that schema's + siblings (or their descendants) in the nesting tree + +However, it is not guaranteed that the `validate()` function is called in any particular order +for a schema's directly nested schemas. + WARNING: It is only safe to use this function for a "resolved" schema — it cannot traverse `$ref` references. @@ -411,7 +439,7 @@ references. - **`schema`** ``: simple or composite OpenAPI 3.x schema object - **`path`** ``: path array for the provided schema -- **`validate`** ``: a `(schema, path) => errors` function to validate a simple schema +- **`validate`** ``: a `(schema, path, logicalPath) => errors` function to validate a simple schema - **`includeSelf`** ``: validate the provided schema in addition to its nested schemas (defaults to `true`) - **`includeNot`** ``: validate schemas composed with `not` (defaults to `false`) @@ -433,6 +461,27 @@ Subschemas include property schemas, 'additionalProperties', and 'patternPropert (such as those in an 'allOf', 'anyOf' or 'oneOf' property), plus all subschemas of those schemas. +The provided `validate()` function is called with three arguments: +- `subschema`: the composed or nested schema +- `path`: the array of path segments to locate the subschema within the resolved document +- `logicalPath`: the array of path segments to locate an instance of `subschema` within an + instance of `schema` (the schema for which `validateSubschemas()` was originally called.) + Within the logical path, an arbitrary array item is represented by `[]` and an arbitrary + dictionary property is represented by `*`. + +The provided `validate()` function is guaranteed to be called: +- for a schema before any of its composed schemas +- for a schema before any of its nested schemas +- more recently for the schema that composes it (its "composition parent") than for that schema's + siblings (or their descendants) in the portion of composition tree local to the composition + parent's branch of the nesting tree +- more recently for the schema that nests it (its "nesting parent") than for that schema's + siblings (or their descendants) in the portion of nesting tree local to the nesting parent's + branch of the composition tree + +However, it is not guaranteed that the `validate()` function is called in any particular order +for a schema's directly composed or directly nested schemas. + WARNING: It is only safe to use this function for a "resolved" schema — it cannot traverse `$ref` references. @@ -440,7 +489,7 @@ references. - **`schema`** ``: simple or composite OpenAPI 3.x schema object - **`path`** ``: path array for the provided schema -- **`validate`** ``: a `(schema, path) => errors` function to validate a simple schema +- **`validate`** ``: a `(schema, path, logicalPath) => errors` function to validate a simple schema - **`includeSelf`** ``: validate the provided schema in addition to its subschemas (defaults to `true`) - **`includeNot`** ``: validate schemas composed with `not` (defaults to `true`) diff --git a/packages/utilities/src/utils/schema-path.js b/packages/utilities/src/utils/schema-path.js new file mode 100644 index 00000000..9756576a --- /dev/null +++ b/packages/utilities/src/utils/schema-path.js @@ -0,0 +1,53 @@ +/** + * Copyright 2024 IBM Corporation. + * SPDX-License-Identifier: Apache2.0 + */ + +// Necessary to get exceptions thrown for attempts to modify frozen objects +'use strict'; + +/** + * @private + */ +class SchemaPath extends Array { + constructor(physical, logical = []) { + super(...physical); + this.logical = Object.freeze([...logical]); + Object.freeze(this); + } + + withProperty(name) { + return new SchemaPath( + [...this, 'properties', name], + [...this.logical, name] + ); + } + + withAdditionalProperty() { + return new SchemaPath( + [...this, 'additionalProperties'], + [...this.logical, '*'] + ); + } + + withPatternProperty(pattern) { + return new SchemaPath( + [...this, 'patternProperties', pattern], + [...this.logical, '*'] + ); + } + + withArrayItem() { + return new SchemaPath([...this, 'items'], [...this.logical, '[]']); + } + + withApplicator(applicator, index) { + return new SchemaPath([...this, applicator, String(index)], this.logical); + } + + withNot() { + return new SchemaPath([...this, 'not'], this.logical); + } +} + +module.exports = SchemaPath; diff --git a/packages/utilities/src/utils/validate-composed-schemas.js b/packages/utilities/src/utils/validate-composed-schemas.js index 9ba3d8c9..10aaa3cb 100644 --- a/packages/utilities/src/utils/validate-composed-schemas.js +++ b/packages/utilities/src/utils/validate-composed-schemas.js @@ -3,6 +3,11 @@ * SPDX-License-Identifier: Apache2.0 */ +/** + * @private + */ +const SchemaPath = require('./schema-path'); + /** * Performs validation on a schema and all of its composed schemas. * @@ -19,6 +24,18 @@ * Composed schemas **do not** include nested schemas (`property`, `additionalProperties`, * `patternProperties`, and `items` schemas). * + * The provided validate function is called with two arguments: + * - `schema` — the composed schema + * - `path` — the array of path segments to locate the composed schema within the resolved document + * + * The provided `validate()` function is guaranteed to be called: + * - for a schema before any of its composed schemas + * - more recently for the schema that composes it (its "composition parent") than for that schema's + * siblings (or their descendants) in the composition tree + * + * However, it is not guaranteed that the `validate()` function is called in any particular order + * for a schema's directly composed schemas. + * * WARNING: It is only safe to use this function for a "resolved" schema — it cannot traverse `$ref` * references. * @param {object} schema simple or composite OpenAPI 3.x schema object @@ -30,7 +47,7 @@ */ function validateComposedSchemas( schema, - path, + p, // internally, we pass a SchemaPath to this, but the external contract is just an array validate, includeSelf = true, includeNot = true @@ -42,14 +59,19 @@ function validateComposedSchemas( } const errors = []; + const path = p instanceof SchemaPath ? p : new SchemaPath(p); if (includeSelf) { - errors.push(...validate(schema, path)); + // We intentionally do not use `path.logical` here because the documented external contract + // for `validateComposedSchemas()` is that the validate function is called with two arguments. + // Tracking the logical path is only useful when `validateComposedSchemas()` is embedded in + // another utility that can pass it an instance of `SchemaPath` (which is not exported). + errors.push(...validate(schema, [...path], p?.logical)); } if (includeNot && schema.not) { errors.push( - ...validateComposedSchemas(schema.not, [...path, 'not'], validate) + ...validateComposedSchemas(schema.not, path.withNot(), validate) ); } @@ -57,7 +79,11 @@ function validateComposedSchemas( if (Array.isArray(schema[applicatorType])) { schema[applicatorType].forEach((s, i) => { errors.push( - ...validateComposedSchemas(s, [...path, applicatorType, i], validate) + ...validateComposedSchemas( + s, + path.withApplicator(applicatorType, i), + validate + ) ); }); } diff --git a/packages/utilities/src/utils/validate-nested-schemas.js b/packages/utilities/src/utils/validate-nested-schemas.js index b5ba2f52..1d738a73 100644 --- a/packages/utilities/src/utils/validate-nested-schemas.js +++ b/packages/utilities/src/utils/validate-nested-schemas.js @@ -7,6 +7,10 @@ * @private */ const isObject = require('./is-object'); +/** + * @private + */ +const SchemaPath = require('./schema-path'); /** * Performs validation on a schema and all of its nested schemas. @@ -27,18 +31,34 @@ const isObject = require('./is-object'); * Nested schemas included via `allOf`, `oneOf`, and `anyOf` are validated, but composed schemas * are not themselves validated. By default, nested schemas included via `not` are not validated. * + * The provided validate function is called with three arguments: + * - `nestedSchema`: the nested schema + * - `path`: the array of path segments to locate the nested schema within the resolved document + * - `logicalPath`: the array of path segments to locate an instance of `nestedSchema` within an + * instance of `schema` (the schema for which `validateNestedSchemas()` was originally called.) + * Within the logical path, an arbitrary array item is represented by `[]` and an arbitrary + * dictionary property is represented by `*`. + * + * The provided `validate()` function is guaranteed to be called: + * - for a schema before any of its nested schemas + * - more recently for the schema that nests it (its "nesting parent") than for that schema's + * siblings (or their descendants) in the nesting tree + * + * However, it is not guaranteed that the `validate()` function is called in any particular order + * for a schema's directly nested schemas. + * * WARNING: It is only safe to use this function for a "resolved" schema — it cannot traverse `$ref` * references. * @param {object} schema simple or composite OpenAPI 3.x schema object * @param {Array} path path array for the provided schema - * @param {Function} validate a `(schema, path) => errors` function to validate a simple schema + * @param {Function} validate a `(schema, path, logicalPath) => errors` function to validate a simple schema * @param {boolean} includeSelf validate the provided schema in addition to its nested schemas (defaults to `true`) * @param {boolean} includeNot validate schemas composed with `not` (defaults to `false`) * @returns {Array} validation errors */ function validateNestedSchemas( schema, - path, + p, // internally, we pass a SchemaPath to this, but the external contract is just an array validate, includeSelf = true, includeNot = false @@ -46,7 +66,7 @@ function validateNestedSchemas( // Make sure 'schema' is an object. if (!isObject(schema)) { throw new Error( - `the entity at location ${path.join('.')} must be a schema object` + `the entity at location ${p.join('.')} must be a schema object` ); } @@ -57,9 +77,10 @@ function validateNestedSchemas( } const errors = []; + const path = p instanceof SchemaPath ? p : new SchemaPath(p); if (includeSelf) { - errors.push(...validate(schema, path)); + errors.push(...validate(schema, [...path], [...path.logical])); } if (schema.properties) { @@ -67,7 +88,7 @@ function validateNestedSchemas( errors.push( ...validateNestedSchemas( property[1], - [...path, 'properties', property[0]], + path.withProperty(property[0]), validate, true, includeNot @@ -80,7 +101,7 @@ function validateNestedSchemas( errors.push( ...validateNestedSchemas( schema.items, - [...path, 'items'], + path.withArrayItem(), validate, true, includeNot @@ -95,7 +116,7 @@ function validateNestedSchemas( errors.push( ...validateNestedSchemas( schema.additionalProperties, - [...path, 'additionalProperties'], + path.withAdditionalProperty(), validate, true, includeNot @@ -103,11 +124,28 @@ function validateNestedSchemas( ); } + if ( + schema.patternProperties && + typeof schema.patternProperties === 'object' + ) { + for (const entry of Object.entries(schema.patternProperties)) { + errors.push( + ...validateNestedSchemas( + entry[1], + path.withPatternProperty(entry[0]), + validate, + true, + includeNot + ) + ); + } + } + if (includeNot && schema.not) { errors.push( ...validateNestedSchemas( schema.not, - [...path, 'not'], + path.withNot(), validate, false, includeNot @@ -121,7 +159,7 @@ function validateNestedSchemas( errors.push( ...validateNestedSchemas( s, - [...path, applicatorType, i], + path.withApplicator(applicatorType, i), validate, false, includeNot @@ -131,23 +169,6 @@ function validateNestedSchemas( } } - if ( - schema.patternProperties && - typeof schema.patternProperties === 'object' - ) { - for (const entry of Object.entries(schema.patternProperties)) { - errors.push( - ...validateNestedSchemas( - entry[1], - [...path, 'patternProperties', entry[0]], - validate, - true, - includeNot - ) - ); - } - } - return errors; } diff --git a/packages/utilities/src/utils/validate-subschemas.js b/packages/utilities/src/utils/validate-subschemas.js index 6ececb60..4cfd172e 100644 --- a/packages/utilities/src/utils/validate-subschemas.js +++ b/packages/utilities/src/utils/validate-subschemas.js @@ -3,6 +3,10 @@ * SPDX-License-Identifier: Apache2.0 */ +/** + * @private + */ +const SchemaPath = require('./schema-path'); /** * @private */ @@ -27,11 +31,32 @@ const validateNestedSchemas = require('./validate-nested-schemas'); * (such as those in an 'allOf', 'anyOf' or 'oneOf' property), plus all subschemas * of those schemas. * + * The provided `validate()` function is called with three arguments: + * - `subschema`: the composed or nested schema + * - `path`: the array of path segments to locate the subschema within the resolved document + * - `logicalPath`: the array of path segments to locate an instance of `subschema` within an + * instance of `schema` (the schema for which `validateSubschemas()` was originally called.) + * Within the logical path, an arbitrary array item is represented by `[]` and an arbitrary + * dictionary property is represented by `*`. + * + * The provided `validate()` function is guaranteed to be called: + * - for a schema before any of its composed schemas + * - for a schema before any of its nested schemas + * - more recently for the schema that composes it (its "composition parent") than for that schema's + * siblings (or their descendants) in the portion of composition tree local to the composition + * parent's branch of the nesting tree + * - more recently for the schema that nests it (its "nesting parent") than for that schema's + * siblings (or their descendants) in the portion of nesting tree local to the nesting parent's + * branch of the composition tree + * + * However, it is not guaranteed that the `validate()` function is called in any particular order + * for a schema's directly composed or directly nested schemas. + * * WARNING: It is only safe to use this function for a "resolved" schema — it cannot traverse `$ref` * references. * @param {object} schema simple or composite OpenAPI 3.x schema object * @param {Array} path path array for the provided schema - * @param {Function} validate a `(schema, path) => errors` function to validate a simple schema + * @param {Function} validate a `(schema, path, logicalPath) => errors` function to validate a simple schema * @param {boolean} includeSelf validate the provided schema in addition to its subschemas (defaults to `true`) * @param {boolean} includeNot validate schemas composed with `not` (defaults to `true`) * @returns {Array} validation errors @@ -46,7 +71,14 @@ function validateSubschemas( return validateNestedSchemas( schema, path, - (s, p) => validateComposedSchemas(s, p, validate, true, includeNot), + (s, p, lp) => + validateComposedSchemas( + s, + new SchemaPath(p, lp), + validate, + true, + includeNot + ), includeSelf, includeNot ); diff --git a/packages/utilities/test/schema-path.test.js b/packages/utilities/test/schema-path.test.js new file mode 100644 index 00000000..ec6ee071 --- /dev/null +++ b/packages/utilities/test/schema-path.test.js @@ -0,0 +1,148 @@ +/** + * Copyright 2017 - 2023 IBM Corporation. + * SPDX-License-Identifier: Apache2.0 + */ + +// Necessary to get exceptions thrown for attempts to modify frozen objects +'use strict'; + +const SchemaPath = require('../src/utils/schema-path'); + +describe('Utility class: SchemaPath', () => { + describe('physical path is preserved', () => { + it('empty physical path is preserved', async () => { + expect([...new SchemaPath([])]).toEqual([]); + }); + it('empty physical path is preserved regardless of logical path', async () => { + expect([...new SchemaPath([], ['foo', 'bar'])]).toEqual([]); + }); + it('physical path is preserved', async () => { + expect([...new SchemaPath(['one', 'two'])]).toEqual(['one', 'two']); + }); + it('physical path is preserved regardless of logical path', async () => { + expect([...new SchemaPath(['one', 'two'], ['foo', 'bar'])]).toEqual([ + 'one', + 'two', + ]); + }); + it('physical path is immutable', async () => { + const path = new SchemaPath(['one', 'two']); + expect(() => path.push('three')).toThrow(); + }); + }); + describe('logical path is preserved', () => { + it('logical path defaults to empty', async () => { + expect(new SchemaPath(['one', 'two']).logical).toEqual([]); + }); + it('empty logical path is preserved', async () => { + expect(new SchemaPath(['one', 'two'], []).logical).toEqual([]); + }); + it('logical path is preserved', async () => { + expect(new SchemaPath(['one', 'two'], ['foo', 'bar']).logical).toEqual([ + 'foo', + 'bar', + ]); + }); + it('logical path is immutable', async () => { + const path = new SchemaPath(['one', 'two'], ['foo', 'bar']); + expect(() => (path.logical = [])).toThrow(); + expect(() => path.logical.push('three')).toThrow(); + }); + }); + describe('withProperty()', () => { + it('physical path is correctly appended', async () => { + expect([ + ...new SchemaPath(['one', 'two'], ['foo', 'bar']).withProperty('three'), + ]).toEqual(['one', 'two', 'properties', 'three']); + }); + it('logical path is correctly appended', async () => { + expect( + new SchemaPath(['one', 'two'], ['foo', 'bar']).withProperty('three') + .logical + ).toEqual(['foo', 'bar', 'three']); + }); + }); + describe('withAdditionalProperty()', () => { + it('physical path is correctly appended', async () => { + expect([ + ...new SchemaPath( + ['one', 'two'], + ['foo', 'bar'] + ).withAdditionalProperty(), + ]).toEqual(['one', 'two', 'additionalProperties']); + }); + it('logical path is correctly appended', async () => { + expect( + new SchemaPath(['one', 'two'], ['foo', 'bar']).withAdditionalProperty( + 'three' + ).logical + ).toEqual(['foo', 'bar', '*']); + }); + }); + describe('withPatternProperty()', () => { + it('physical path is correctly appended', async () => { + expect([ + ...new SchemaPath(['one', 'two'], ['foo', 'bar']).withPatternProperty( + 'baz' + ), + ]).toEqual(['one', 'two', 'patternProperties', 'baz']); + }); + it('logical path is correctly appended', async () => { + expect( + new SchemaPath(['one', 'two'], ['foo', 'bar']).withPatternProperty( + 'baz' + ).logical + ).toEqual(['foo', 'bar', '*']); + }); + }); + describe('withArrayItem()', () => { + it('physical path is correctly appended', async () => { + expect([ + ...new SchemaPath(['one', 'two'], ['foo', 'bar']).withArrayItem(), + ]).toEqual(['one', 'two', 'items']); + }); + it('logical path is correctly appended', async () => { + expect( + new SchemaPath(['one', 'two'], ['foo', 'bar']).withArrayItem().logical + ).toEqual(['foo', 'bar', '[]']); + }); + }); + describe('withApplicator()', () => { + it('physical path is correctly appended', async () => { + expect([ + ...new SchemaPath(['one', 'two'], ['foo', 'bar']).withApplicator( + 'oneOf', + '0' + ), + ]).toEqual(['one', 'two', 'oneOf', '0']); + }); + it('logical path is correctly (not) appended', async () => { + expect( + new SchemaPath(['one', 'two'], ['foo', 'bar']).withApplicator( + 'oneOf', + '0' + ).logical + ).toEqual(['foo', 'bar']); + }); + it('numeric index is coerced to string', async () => { + expect([ + ...new SchemaPath(['one', 'two'], ['foo', 'bar']).withApplicator( + 'oneOf', + 0 + ), + ]).toEqual(['one', 'two', 'oneOf', '0']); + }); + }); + describe('withNot()', () => { + it('physical path is correctly appended', async () => { + expect([ + ...new SchemaPath(['one', 'two'], ['foo', 'bar']).withNot(), + ]).toEqual(['one', 'two', 'not']); + }); + it('logical path is correctly (not) appended', async () => { + expect( + new SchemaPath(['one', 'two'], ['foo', 'bar']).withNot().logical + ).toEqual(['foo', 'bar']); + }); + }); +}); diff --git a/packages/utilities/test/validate-composed-schemas.test.js b/packages/utilities/test/validate-composed-schemas.test.js index 89d35bef..6c71917b 100644 --- a/packages/utilities/test/validate-composed-schemas.test.js +++ b/packages/utilities/test/validate-composed-schemas.test.js @@ -4,31 +4,42 @@ */ const { validateComposedSchemas } = require('../src'); +const SchemaPath = require('../src/utils/schema-path'); describe('Utility function: validateComposedSchemas()', () => { it('should validate a simple schema by default', async () => { - const simpleSchema = {}; + const schema = {}; + + const visitedPaths = []; + let index = 1; - const visitedPaths = validateComposedSchemas(simpleSchema, [], (s, p) => { - return [p.join('.')]; + const results = validateComposedSchemas(schema, [], (s, p) => { + visitedPaths.push(p.join('.')); + return [index++]; }); expect(visitedPaths).toEqual(['']); + expect(results).toEqual([1]); }); it('should not validate a simple schema if `includeSelf` is `false`', async () => { const schema = {}; - const visitedPaths = validateComposedSchemas( + const visitedPaths = []; + let index = 1; + + const results = validateComposedSchemas( schema, [], (s, p) => { - return [p.join('.')]; + visitedPaths.push(p.join('.')); + return [index++]; }, false ); expect(visitedPaths).toEqual([]); + expect(results).toEqual([]); }); it('should validate a composed schema even if `includeSelf` is `false`', async () => { @@ -36,16 +47,21 @@ describe('Utility function: validateComposedSchemas()', () => { allOf: [{}], }; - const visitedPaths = validateComposedSchemas( + const visitedPaths = []; + let index = 1; + + const results = validateComposedSchemas( schema, [], (s, p) => { - return [p.join('.')]; + visitedPaths.push(p.join('.')); + return [index++]; }, false ); expect(visitedPaths).toEqual(['allOf.0']); + expect(results).toEqual([1]); }); it('should validate `allOf` schemas', async () => { @@ -53,11 +69,16 @@ describe('Utility function: validateComposedSchemas()', () => { allOf: [{}, {}], }; - const visitedPaths = validateComposedSchemas(schema, [], (s, p) => { - return [p.join('.')]; + const visitedPaths = []; + let index = 1; + + const results = validateComposedSchemas(schema, [], (s, p) => { + visitedPaths.push(p.join('.')); + return [index++]; }); - expect(visitedPaths.sort()).toEqual(['', 'allOf.0', 'allOf.1'].sort()); + expect(visitedPaths.sort()).toEqual(['', 'allOf.0', 'allOf.1']); + expect(results.sort()).toEqual([1, 2, 3]); }); it('should validate `oneOf` schemas', async () => { @@ -65,11 +86,16 @@ describe('Utility function: validateComposedSchemas()', () => { oneOf: [{}, {}], }; - const visitedPaths = validateComposedSchemas(schema, [], (s, p) => { - return [p.join('.')]; + const visitedPaths = []; + let index = 1; + + const results = validateComposedSchemas(schema, [], (s, p) => { + visitedPaths.push(p.join('.')); + return [index++]; }); - expect(visitedPaths.sort()).toEqual(['', 'oneOf.0', 'oneOf.1'].sort()); + expect(visitedPaths.sort()).toEqual(['', 'oneOf.0', 'oneOf.1']); + expect(results.sort()).toEqual([1, 2, 3]); }); it('should validate `anyOf` schemas', async () => { @@ -77,11 +103,16 @@ describe('Utility function: validateComposedSchemas()', () => { anyOf: [{}, {}], }; - const visitedPaths = validateComposedSchemas(schema, [], (s, p) => { - return [p.join('.')]; + const visitedPaths = []; + let index = 1; + + const results = validateComposedSchemas(schema, [], (s, p) => { + visitedPaths.push(p.join('.')); + return [index++]; }); - expect(visitedPaths.sort()).toEqual(['', 'anyOf.0', 'anyOf.1'].sort()); + expect(visitedPaths.sort()).toEqual(['', 'anyOf.0', 'anyOf.1']); + expect(results.sort()).toEqual([1, 2, 3]); }); it('should validate `not` schema', async () => { @@ -89,11 +120,16 @@ describe('Utility function: validateComposedSchemas()', () => { not: {}, }; - const visitedPaths = validateComposedSchemas(schema, [], (s, p) => { - return [p.join('.')]; + const visitedPaths = []; + let index = 1; + + const results = validateComposedSchemas(schema, [], (s, p) => { + visitedPaths.push(p.join('.')); + return [index++]; }); - expect(visitedPaths.sort()).toEqual(['', 'not'].sort()); + expect(visitedPaths.sort()).toEqual(['', 'not']); + expect(results.sort()).toEqual([1, 2]); }); it('should not validate `not` schema if `includeNot` is false', async () => { @@ -101,17 +137,22 @@ describe('Utility function: validateComposedSchemas()', () => { not: {}, }; - const visitedPaths = validateComposedSchemas( + const visitedPaths = []; + let index = 1; + + const results = validateComposedSchemas( schema, [], (s, p) => { - return [p.join('.')]; + visitedPaths.push(p.join('.')); + return [index++]; }, true, false ); expect(visitedPaths).toEqual(['']); + expect(results).toEqual([1]); }); it('should recurse through `allOf`, `oneOf`, `anyOf`, and `not`', async () => { @@ -119,18 +160,82 @@ describe('Utility function: validateComposedSchemas()', () => { allOf: [{ oneOf: [{ anyOf: [{ not: {} }] }] }], }; - const visitedPaths = validateComposedSchemas(schema, [], (s, p) => { - return [p.join('.')]; + const visitedPaths = []; + let index = 1; + + const results = validateComposedSchemas(schema, [], (s, p) => { + visitedPaths.push(p.join('.')); + return [index++]; }); - expect(visitedPaths.sort()).toEqual( - [ - '', - 'allOf.0', - 'allOf.0.oneOf.0', - 'allOf.0.oneOf.0.anyOf.0', - 'allOf.0.oneOf.0.anyOf.0.not', - ].sort() + expect(visitedPaths.sort()).toEqual([ + '', + 'allOf.0', + 'allOf.0.oneOf.0', + 'allOf.0.oneOf.0.anyOf.0', + 'allOf.0.oneOf.0.anyOf.0.not', + ]); + expect(results.sort()).toEqual([1, 2, 3, 4, 5]); + }); + + it('should validate a schema before validating its composed schemas', async () => { + const schema = { + allOf: [{ oneOf: [{ anyOf: [{ not: {} }] }] }], + }; + + const visitedPaths = []; + let index = 1; + + const results = validateComposedSchemas(schema, [], (s, p) => { + visitedPaths.push(p.join('.')); + return [index++]; + }); + + expect(visitedPaths).toEqual([ + '', + 'allOf.0', + 'allOf.0.oneOf.0', + 'allOf.0.oneOf.0.anyOf.0', + 'allOf.0.oneOf.0.anyOf.0.not', + ]); + expect(results).toEqual([1, 2, 3, 4, 5]); + }); + + it("should validate a schema's composition parent more recently than its parent's siblings", async () => { + const schema = { + allOf: [{ oneOf: [{}] }, { oneOf: [{}] }], + oneOf: [{ anyOf: [{}] }, { anyOf: [{}] }], + anyOf: [{ not: {} }, { not: {} }], + not: { allOf: [{}] }, + }; + + const visitedPaths = []; + + validateComposedSchemas(schema, [], (s, p) => { + visitedPaths.push(p.join('.')); + return []; + }); + + expect(visitedPaths.indexOf('allOf.0.oneOf.0') - 1).toEqual( + visitedPaths.indexOf('allOf.0') + ); + expect(visitedPaths.indexOf('allOf.1.oneOf.0') - 1).toEqual( + visitedPaths.indexOf('allOf.1') + ); + expect(visitedPaths.indexOf('oneOf.0.anyOf.0') - 1).toEqual( + visitedPaths.indexOf('oneOf.0') + ); + expect(visitedPaths.indexOf('oneOf.1.anyOf.0') - 1).toEqual( + visitedPaths.indexOf('oneOf.1') + ); + expect(visitedPaths.indexOf('anyOf.0.not') - 1).toEqual( + visitedPaths.indexOf('anyOf.0') + ); + expect(visitedPaths.indexOf('anyOf.1.not') - 1).toEqual( + visitedPaths.indexOf('anyOf.1') + ); + expect(visitedPaths.indexOf('not.allOf.0') - 1).toEqual( + visitedPaths.indexOf('not') ); }); @@ -163,11 +268,22 @@ describe('Utility function: validateComposedSchemas()', () => { }, }; - const visitedPaths = validateComposedSchemas(schema, [], (s, p) => { - return [p.join('.')]; - }); + const visitedPaths = []; + let index = 1; + + const results = validateComposedSchemas( + schema, + [], + (s, p) => { + visitedPaths.push(p.join('.')); + return [index++]; + }, + true, + false + ); expect(visitedPaths).toEqual(['']); + expect(results).toEqual([1]); }); it('should not validate `additionalProperties` schema', async () => { @@ -175,11 +291,22 @@ describe('Utility function: validateComposedSchemas()', () => { additionalProperties: {}, }; - const visitedPaths = validateComposedSchemas(schema, [], (s, p) => { - return [p.join('.')]; - }); + const visitedPaths = []; + let index = 1; + + const results = validateComposedSchemas( + schema, + [], + (s, p) => { + visitedPaths.push(p.join('.')); + return [index++]; + }, + true, + false + ); expect(visitedPaths).toEqual(['']); + expect(results).toEqual([1]); }); it('should not validate `items` schema', async () => { @@ -187,11 +314,22 @@ describe('Utility function: validateComposedSchemas()', () => { items: {}, }; - const visitedPaths = validateComposedSchemas(schema, [], (s, p) => { - return [p.join('.')]; - }); + const visitedPaths = []; + let index = 1; + + const results = validateComposedSchemas( + schema, + [], + (s, p) => { + visitedPaths.push(p.join('.')); + return [index++]; + }, + true, + false + ); expect(visitedPaths).toEqual(['']); + expect(results).toEqual([1]); }); it('should skip schemas defined by a $ref', async () => { @@ -204,10 +342,44 @@ describe('Utility function: validateComposedSchemas()', () => { ], }; - const visitedPaths = validateComposedSchemas(schema, [], (s, p) => { - return [p.join('.')]; - }); + const visitedPaths = []; + let index = 1; + + const results = validateComposedSchemas( + schema, + [], + (s, p) => { + visitedPaths.push(p.join('.')); + return [index++]; + }, + true, + false + ); - expect(visitedPaths.sort()).toEqual(['', 'allOf.1'].sort()); + expect(visitedPaths.sort()).toEqual(['', 'allOf.1']); + expect(results.sort()).toEqual([1, 2]); + }); + + // internal-only functionality + it('should preserve the logical path with which it is called', async () => { + const schema = {}; + + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; + + const results = validateComposedSchemas( + schema, + new SchemaPath([], ['foo']), + (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; + } + ); + + expect(visitedPaths).toEqual(['']); + expect(visitedLogicalPaths).toEqual(['foo']); + expect(results).toEqual([1]); }); }); diff --git a/packages/utilities/test/validate-nested-schemas.test.js b/packages/utilities/test/validate-nested-schemas.test.js index eb7a860b..61c42304 100644 --- a/packages/utilities/test/validate-nested-schemas.test.js +++ b/packages/utilities/test/validate-nested-schemas.test.js @@ -7,28 +7,44 @@ const { validateNestedSchemas } = require('../src'); describe('Utility function: validateNestedSchemas()', () => { it('should validate a simple schema by default', async () => { - const simpleSchema = {}; + const schema = {}; + + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; - const visitedPaths = validateNestedSchemas(simpleSchema, [], (s, p) => { - return [p.join('.')]; + const results = validateNestedSchemas(schema, [], (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; }); expect(visitedPaths).toEqual(['']); + expect(visitedLogicalPaths).toEqual(['']); + expect(results).toEqual([1]); }); it('should not validate a simple schema if `includeSelf` is `false`', async () => { const schema = {}; - const visitedPaths = validateNestedSchemas( + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; + + const results = validateNestedSchemas( schema, [], - (s, p) => { - return [p.join('.')]; + (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; }, false ); expect(visitedPaths).toEqual([]); + expect(visitedLogicalPaths).toEqual([]); + expect(results).toEqual([]); }); it('should validate a nested schema even if `includeSelf` is `false`', async () => { @@ -38,16 +54,24 @@ describe('Utility function: validateNestedSchemas()', () => { }, }; - const visitedPaths = validateNestedSchemas( + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; + + const results = validateNestedSchemas( schema, [], - (s, p) => { - return [p.join('.')]; + (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; }, false ); expect(visitedPaths).toEqual(['properties.nested_property']); + expect(visitedLogicalPaths).toEqual(['nested_property']); + expect(results).toEqual([1]); }); it('should validate `property` schemas', async () => { @@ -59,13 +83,23 @@ describe('Utility function: validateNestedSchemas()', () => { }, }; - const visitedPaths = validateNestedSchemas(schema, [], (s, p) => { - return [p.join('.')]; + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; + + const results = validateNestedSchemas(schema, [], (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; }); expect(visitedPaths.sort()).toEqual( ['', 'properties.one', 'properties.two', 'properties.three'].sort() ); + expect(visitedLogicalPaths.sort()).toEqual( + ['', 'one', 'two', 'three'].sort() + ); + expect(results.sort()).toEqual([1, 2, 3, 4]); }); it('should validate `additionalProperties` schema', async () => { @@ -73,11 +107,19 @@ describe('Utility function: validateNestedSchemas()', () => { additionalProperties: {}, }; - const visitedPaths = validateNestedSchemas(schema, [], (s, p) => { - return [p.join('.')]; + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; + + const results = validateNestedSchemas(schema, [], (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; }); - expect(visitedPaths.sort()).toEqual(['', 'additionalProperties'].sort()); + expect(visitedPaths.sort()).toEqual(['', 'additionalProperties']); + expect(visitedLogicalPaths.sort()).toEqual(['', '*']); + expect(results.sort()).toEqual([1, 2]); }); it('should validate `items` schema', async () => { @@ -85,11 +127,141 @@ describe('Utility function: validateNestedSchemas()', () => { items: {}, }; - const visitedPaths = validateNestedSchemas(schema, [], (s, p) => { - return [p.join('.')]; + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; + + const results = validateNestedSchemas(schema, [], (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; + }); + + expect(visitedPaths.sort()).toEqual(['', 'items']); + expect(visitedLogicalPaths.sort()).toEqual(['', '[]']); + expect(results.sort()).toEqual([1, 2]); + }); + + it('should validate deeply nested schemas', async () => { + const schema = { + properties: { + one: { + properties: { + two: { + additionalProperties: { + patternProperties: { + '^foo$': { + items: {}, + }, + }, + }, + }, + }, + }, + }, + }; + + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; + + const results = validateNestedSchemas(schema, [], (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; }); - expect(visitedPaths.sort()).toEqual(['', 'items'].sort()); + expect(visitedPaths.sort()).toEqual([ + '', + 'properties.one', + 'properties.one.properties.two', + 'properties.one.properties.two.additionalProperties', + 'properties.one.properties.two.additionalProperties.patternProperties.^foo$', + 'properties.one.properties.two.additionalProperties.patternProperties.^foo$.items', + ]); + expect(visitedLogicalPaths.sort()).toEqual([ + '', + 'one', + 'one.two', + 'one.two.*', + 'one.two.*.*', + 'one.two.*.*.[]', + ]); + expect(results.sort()).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('should validate a schema before validating its nested schemas', async () => { + const schema = { + properties: { + one: { + properties: { + two: { + additionalProperties: { + patternProperties: { + '^foo$': { + items: {}, + }, + }, + }, + }, + }, + }, + }, + }; + + const visitedPaths = []; + let index = 1; + + const results = validateNestedSchemas(schema, [], (s, p) => { + visitedPaths.push(p.join('.')); + return [index++]; + }); + + expect(visitedPaths).toEqual([ + '', + 'properties.one', + 'properties.one.properties.two', + 'properties.one.properties.two.additionalProperties', + 'properties.one.properties.two.additionalProperties.patternProperties.^foo$', + 'properties.one.properties.two.additionalProperties.patternProperties.^foo$.items', + ]); + expect(results.sort()).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it("should validate a schema's nesting parent more recently than its parent's siblings", async () => { + const schema = { + items: { additionalProperties: {} }, + additionalProperties: { patternProperties: { '^baz$': {} } }, + patternProperties: { + '^foo$': { properties: { baz: {} } }, + '^bar$': { properties: { baz: {} } }, + }, + properties: { + a: {}, + one: { items: {} }, + z: {}, + }, + }; + + const visitedPaths = []; + + validateNestedSchemas(schema, [], (s, p) => { + visitedPaths.push(p.join('.')); + return []; + }); + + expect(visitedPaths.indexOf('items.additionalProperties') - 1).toEqual( + visitedPaths.indexOf('items') + ); + expect( + visitedPaths.indexOf('additionalProperties.patternProperties.^baz$') - 1 + ).toEqual(visitedPaths.indexOf('additionalProperties')); + expect( + visitedPaths.indexOf('patternProperties.^foo$.properties.baz') - 1 + ).toEqual(visitedPaths.indexOf('patternProperties.^foo$')); + expect(visitedPaths.indexOf('properties.one.items') - 1).toEqual( + visitedPaths.indexOf('properties.one') + ); }); it('should validate through `allOf` schema', async () => { @@ -97,13 +269,22 @@ describe('Utility function: validateNestedSchemas()', () => { allOf: [{ properties: { inside_all_of: {} } }], }; - const visitedPaths = validateNestedSchemas(schema, [], (s, p) => { - return [p.join('.')]; + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; + + const results = validateNestedSchemas(schema, [], (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; }); - expect(visitedPaths.sort()).toEqual( - ['', 'allOf.0.properties.inside_all_of'].sort() - ); + expect(visitedPaths.sort()).toEqual([ + '', + 'allOf.0.properties.inside_all_of', + ]); + expect(visitedLogicalPaths.sort()).toEqual(['', 'inside_all_of']); + expect(results.sort()).toEqual([1, 2]); }); it('should validate through `oneOf` schema', async () => { @@ -111,13 +292,22 @@ describe('Utility function: validateNestedSchemas()', () => { oneOf: [{ properties: { inside_one_of: {} } }], }; - const visitedPaths = validateNestedSchemas(schema, [], (s, p) => { - return [p.join('.')]; + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; + + const results = validateNestedSchemas(schema, [], (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; }); - expect(visitedPaths.sort()).toEqual( - ['', 'oneOf.0.properties.inside_one_of'].sort() - ); + expect(visitedPaths.sort()).toEqual([ + '', + 'oneOf.0.properties.inside_one_of', + ]); + expect(visitedLogicalPaths.sort()).toEqual(['', 'inside_one_of']); + expect(results.sort()).toEqual([1, 2]); }); it('should validate through `anyOf` schema', async () => { @@ -125,13 +315,22 @@ describe('Utility function: validateNestedSchemas()', () => { anyOf: [{ properties: { inside_any_of: {} } }], }; - const visitedPaths = validateNestedSchemas(schema, [], (s, p) => { - return [p.join('.')]; + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; + + const results = validateNestedSchemas(schema, [], (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; }); - expect(visitedPaths.sort()).toEqual( - ['', 'anyOf.0.properties.inside_any_of'].sort() - ); + expect(visitedPaths.sort()).toEqual([ + '', + 'anyOf.0.properties.inside_any_of', + ]); + expect(visitedLogicalPaths.sort()).toEqual(['', 'inside_any_of']); + expect(results.sort()).toEqual([1, 2]); }); it('should not validate through `not` schema by default', async () => { @@ -139,11 +338,19 @@ describe('Utility function: validateNestedSchemas()', () => { not: { properties: { inside_not: {} } }, }; - const visitedPaths = validateNestedSchemas(schema, [], (s, p) => { - return [p.join('.')]; + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; + + const results = validateNestedSchemas(schema, [], (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; }); expect(visitedPaths).toEqual(['']); + expect(visitedLogicalPaths).toEqual(['']); + expect(results).toEqual([1]); }); it('should validate through `not` schema if `includeNot` is true', async () => { @@ -151,17 +358,25 @@ describe('Utility function: validateNestedSchemas()', () => { not: { properties: { inside_not: {} } }, }; - const visitedPaths = validateNestedSchemas( + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; + + const results = validateNestedSchemas( schema, [], - (s, p) => { - return [p.join('.')]; + (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; }, true, true ); - expect(visitedPaths).toEqual(['', 'not.properties.inside_not']); + expect(visitedPaths.sort()).toEqual(['', 'not.properties.inside_not']); + expect(visitedLogicalPaths.sort()).toEqual(['', 'inside_not']); + expect(results.sort()).toEqual([1, 2]); }); it('should recurse through `allOf`, `oneOf`, and `anyOf`', async () => { @@ -171,13 +386,28 @@ describe('Utility function: validateNestedSchemas()', () => { ], }; - const visitedPaths = validateNestedSchemas(schema, [], (s, p) => { - return [p.join('.')]; - }); + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; - expect(visitedPaths.sort()).toEqual( - ['', 'allOf.0.oneOf.0.anyOf.0.properties.can_you_find_me'].sort() + const results = validateNestedSchemas( + schema, + [], + (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; + }, + true, + true ); + + expect(visitedPaths.sort()).toEqual([ + '', + 'allOf.0.oneOf.0.anyOf.0.properties.can_you_find_me', + ]); + expect(visitedLogicalPaths.sort()).toEqual(['', 'can_you_find_me']); + expect(results.sort()).toEqual([1, 2]); }); it('recorded paths are accurate for each schema', async () => { @@ -221,12 +451,20 @@ describe('Utility function: validateNestedSchemas()', () => { }, }; - const visitedPaths = validateNestedSchemas(schema, [], (s, p) => { - return [p.join('.')]; + const visitedPaths = []; + const visitedLogicalPaths = []; + let index = 1; + + const results = validateNestedSchemas(schema, [], (s, p, lp) => { + visitedPaths.push(p.join('.')); + visitedLogicalPaths.push(lp.join('.')); + return [index++]; }); expect(visitedPaths.sort()).toEqual( ['', 'properties.one', 'properties.two'].sort() ); + expect(visitedLogicalPaths.sort()).toEqual(['', 'one', 'two'].sort()); + expect(results.sort()).toEqual([1, 2, 3]); }); }); diff --git a/packages/utilities/test/validate-subschemas.test.js b/packages/utilities/test/validate-subschemas.test.js index fcbdf4b3..47f145e1 100644 --- a/packages/utilities/test/validate-subschemas.test.js +++ b/packages/utilities/test/validate-subschemas.test.js @@ -5,12 +5,15 @@ const { allSchemasDocument, testRule } = require('./utils'); const { validateSubschemas } = require('../src'); +const { schemas } = require('../src/collections'); describe('Utility: validateSubschemas', () => { - const visitedSchemas = []; + const visitedPaths = []; + const visitedLogicalPaths = []; - function pathRecorder(schema, path) { - visitedSchemas.push(path.join('.')); + function pathRecorder(schema, path, logicalPath) { + visitedPaths.push(path.join('.')); + visitedLogicalPaths.push(logicalPath.join('.')); return []; } @@ -18,17 +21,6 @@ describe('Utility: validateSubschemas', () => { return validateSubschemas(schema, path, pathRecorder); } - // NOTE: Duplicated from ruleset package. Need to revisit - // This was formerly imported from the "collections" module in the rulest - const schemas = [ - '$.paths[*][parameters][*].schema', - '$.paths[*][parameters][*].content[*].schema', - '$.paths[*][*][parameters][*].schema', - '$.paths[*][*][parameters,responses][*].content[*].schema', - '$.paths[*][*].responses[*].headers[*].schema', - '$.paths[*][*][requestBody].content[*].schema', - ]; - // this needs to be executed as a spectral rule to resolve the document const ruleForTesting = { given: schemas, @@ -41,79 +33,316 @@ describe('Utility: validateSubschemas', () => { it('should find all subschemas', async () => { await testRule('rule-name', ruleForTesting, allSchemasDocument); - expect(visitedSchemas.length).toBe(24); + expect(visitedPaths.length).toBe(24); + expect(visitedLogicalPaths.length).toBe(24); - expect(visitedSchemas).toContain( - 'paths./schema.get.responses.200.content.application/json.schema' - ); - expect(visitedSchemas).toContain( + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_property_schema' ); - expect(visitedSchemas).toContain( + expect(visitedLogicalPaths).toContain('schema_with_property_schema'); + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_property_schema.properties.property_schema' ); - expect(visitedSchemas).toContain( + expect(visitedLogicalPaths).toContain( + 'schema_with_property_schema.property_schema' + ); + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_additional_properties_schema' ); - expect(visitedSchemas).toContain( + expect(visitedLogicalPaths).toContain( + 'schema_with_additional_properties_schema' + ); + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_additional_properties_schema.additionalProperties' ); - expect(visitedSchemas).toContain( + expect(visitedLogicalPaths).toContain( + 'schema_with_additional_properties_schema.*' + ); + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_items_schema' ); - expect(visitedSchemas).toContain( + expect(visitedLogicalPaths).toContain('schema_with_items_schema'); + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_items_schema.items' ); - expect(visitedSchemas).toContain( + expect(visitedLogicalPaths).toContain('schema_with_items_schema.[]'); + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_all_of_schema' ); - expect(visitedSchemas).toContain( + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_all_of_schema.allOf.0' ); - expect(visitedSchemas).toContain( + expect( + visitedLogicalPaths.filter(p => p === 'schema_with_all_of_schema').length + ).toEqual(2); + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_one_of_schema' ); - expect(visitedSchemas).toContain( + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_one_of_schema.oneOf.0' ); - expect(visitedSchemas).toContain( + expect( + visitedLogicalPaths.filter(p => p === 'schema_with_one_of_schema').length + ).toEqual(2); + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_any_of_schema' ); - expect(visitedSchemas).toContain( + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_any_of_schema.anyOf.0' ); - expect(visitedSchemas).toContain( + expect( + visitedLogicalPaths.filter(p => p === 'schema_with_any_of_schema').length + ).toEqual(2); + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_not_schema' ); - expect(visitedSchemas).toContain( + expect(visitedPaths).toContain( 'paths./schema.get.responses.200.content.application/json.schema.properties.schema_with_not_schema.not' ); - expect(visitedSchemas).toContain( - 'paths./every_flavor.get.responses.200.content.application/json.schema' - ); - expect(visitedSchemas).toContain( + expect(visitedPaths).toContain( 'paths./every_flavor.get.responses.200.content.application/json.schema.properties.property_schema' ); - expect(visitedSchemas).toContain( + expect(visitedLogicalPaths).toContain('property_schema'); + expect(visitedPaths).toContain( 'paths./every_flavor.get.responses.200.content.application/json.schema.properties.property_schema.properties.property_schema' ); - expect(visitedSchemas).toContain( + expect(visitedLogicalPaths).toContain('property_schema.property_schema'); + expect(visitedPaths).toContain( 'paths./every_flavor.get.responses.200.content.application/json.schema.items' ); - expect(visitedSchemas).toContain( + expect(visitedLogicalPaths).toContain('[]'); + expect(visitedPaths).toContain( 'paths./every_flavor.get.responses.200.content.application/json.schema.additionalProperties' ); - expect(visitedSchemas).toContain( + expect(visitedLogicalPaths).toContain('*'); + expect(visitedPaths).toContain( + 'paths./schema.get.responses.200.content.application/json.schema' + ); + expect(visitedPaths).toContain( + 'paths./every_flavor.get.responses.200.content.application/json.schema' + ); + expect(visitedPaths).toContain( 'paths./every_flavor.get.responses.200.content.application/json.schema.not' ); - expect(visitedSchemas).toContain( + expect(visitedPaths).toContain( 'paths./every_flavor.get.responses.200.content.application/json.schema.allOf.0' ); - expect(visitedSchemas).toContain( + expect(visitedPaths).toContain( 'paths./every_flavor.get.responses.200.content.application/json.schema.oneOf.0' ); - expect(visitedSchemas).toContain( + expect(visitedPaths).toContain( 'paths./every_flavor.get.responses.200.content.application/json.schema.anyOf.0' ); + expect(visitedLogicalPaths.filter(p => p === '').length).toEqual(6); + }); + + it('should validate a schema before validating its composed or nested schemas', async () => { + const schema = { + items: { + allOf: [ + { + properties: { + one: { + oneOf: [ + { + additionalProperties: { + anyOf: [ + { + patternProperties: { + '^foo$': { + not: {}, + }, + }, + }, + ], + }, + }, + ], + }, + }, + }, + ], + }, + }; + + const visitedPaths = []; + + validateSubschemas(schema, [], (s, p) => { + visitedPaths.push(p.join('.')); + return []; + }); + + expect(visitedPaths).toEqual([ + '', + 'items', + 'items.allOf.0', + 'items.allOf.0.properties.one', + 'items.allOf.0.properties.one.oneOf.0', + 'items.allOf.0.properties.one.oneOf.0.additionalProperties', + 'items.allOf.0.properties.one.oneOf.0.additionalProperties.anyOf.0', + 'items.allOf.0.properties.one.oneOf.0.additionalProperties.anyOf.0.patternProperties.^foo$', + 'items.allOf.0.properties.one.oneOf.0.additionalProperties.anyOf.0.patternProperties.^foo$.not', + ]); + }); + + it("should validate a schema's composition parent more recently than its parent's siblings", async () => { + const schema = { + allOf: [{ oneOf: [{}] }, { oneOf: [{}] }], + oneOf: [{ anyOf: [{}] }, { anyOf: [{}] }], + anyOf: [{ not: {} }, { not: {} }], + not: { allOf: [{}] }, + }; + + const visitedPaths = []; + + validateSubschemas(schema, [], (s, p) => { + visitedPaths.push(p.join('.')); + return []; + }); + + expect(visitedPaths.indexOf('allOf.0.oneOf.0') - 1).toEqual( + visitedPaths.indexOf('allOf.0') + ); + expect(visitedPaths.indexOf('allOf.1.oneOf.0') - 1).toEqual( + visitedPaths.indexOf('allOf.1') + ); + expect(visitedPaths.indexOf('oneOf.0.anyOf.0') - 1).toEqual( + visitedPaths.indexOf('oneOf.0') + ); + expect(visitedPaths.indexOf('oneOf.1.anyOf.0') - 1).toEqual( + visitedPaths.indexOf('oneOf.1') + ); + expect(visitedPaths.indexOf('anyOf.0.not') - 1).toEqual( + visitedPaths.indexOf('anyOf.0') + ); + expect(visitedPaths.indexOf('anyOf.1.not') - 1).toEqual( + visitedPaths.indexOf('anyOf.1') + ); + expect(visitedPaths.indexOf('not.allOf.0') - 1).toEqual( + visitedPaths.indexOf('not') + ); + }); + + it("should validate a schema's nesting parent more recently than its parent's siblings", async () => { + const schema = { + allOf: [ + { + items: { additionalProperties: {} }, + additionalProperties: { patternProperties: { '^baz$': {} } }, + }, + { + additionalProperties: { patternProperties: { '^baz$': {} } }, + patternProperties: { + '^foo$': { properties: { baz: {} } }, + '^bar$': { properties: { baz: {} } }, + }, + }, + ], + oneOf: [ + { + additionalProperties: { patternProperties: { '^baz$': {} } }, + patternProperties: { + '^foo$': { properties: { baz: {} } }, + '^bar$': { properties: { baz: {} } }, + }, + }, + { + patternProperties: { + '^foo$': { properties: { baz: {} } }, + '^bar$': { properties: { baz: {} } }, + }, + properties: { + a: {}, + one: { items: {} }, + z: {}, + }, + }, + ], + anyOf: [ + { + patternProperties: { + '^foo$': { properties: { baz: {} } }, + '^bar$': { properties: { baz: {} } }, + }, + properties: { + a: {}, + one: { items: {} }, + z: {}, + }, + }, + { + properties: { + a: {}, + one: { items: {} }, + z: {}, + }, + items: { additionalProperties: {} }, + }, + ], + not: { + properties: { + a: {}, + one: { items: {} }, + z: {}, + }, + items: { additionalProperties: {} }, + }, + }; + + const visitedPaths = []; + + validateSubschemas(schema, [], (s, p) => { + visitedPaths.push(p.join('.')); + return []; + }); + + expect( + visitedPaths.indexOf('allOf.0.items.additionalProperties') - 1 + ).toEqual(visitedPaths.indexOf('allOf.0.items')); + expect( + visitedPaths.indexOf( + 'allOf.0.additionalProperties.patternProperties.^baz$' + ) - 1 + ).toEqual(visitedPaths.indexOf('allOf.0.additionalProperties')); + expect( + visitedPaths.indexOf( + 'allOf.1.additionalProperties.patternProperties.^baz$' + ) - 1 + ).toEqual(visitedPaths.indexOf('allOf.1.additionalProperties')); + expect( + visitedPaths.indexOf('allOf.1.patternProperties.^foo$.properties.baz') - 1 + ).toEqual(visitedPaths.indexOf('allOf.1.patternProperties.^foo$')); + expect( + visitedPaths.indexOf( + 'oneOf.0.additionalProperties.patternProperties.^baz$' + ) - 1 + ).toEqual(visitedPaths.indexOf('oneOf.0.additionalProperties')); + expect( + visitedPaths.indexOf('oneOf.0.patternProperties.^foo$.properties.baz') - 1 + ).toEqual(visitedPaths.indexOf('oneOf.0.patternProperties.^foo$')); + expect( + visitedPaths.indexOf('oneOf.1.patternProperties.^foo$.properties.baz') - 1 + ).toEqual(visitedPaths.indexOf('oneOf.1.patternProperties.^foo$')); + expect(visitedPaths.indexOf('oneOf.1.properties.one.items') - 1).toEqual( + visitedPaths.indexOf('oneOf.1.properties.one') + ); + expect( + visitedPaths.indexOf('anyOf.0.patternProperties.^foo$.properties.baz') - 1 + ).toEqual(visitedPaths.indexOf('anyOf.0.patternProperties.^foo$')); + expect(visitedPaths.indexOf('anyOf.0.properties.one.items') - 1).toEqual( + visitedPaths.indexOf('anyOf.0.properties.one') + ); + expect(visitedPaths.indexOf('anyOf.1.properties.one.items') - 1).toEqual( + visitedPaths.indexOf('anyOf.1.properties.one') + ); + expect( + visitedPaths.indexOf('anyOf.1.items.additionalProperties') - 1 + ).toEqual(visitedPaths.indexOf('anyOf.1.items')); + expect(visitedPaths.indexOf('not.properties.one.items') - 1).toEqual( + visitedPaths.indexOf('not.properties.one') + ); + expect(visitedPaths.indexOf('not.items.additionalProperties') - 1).toEqual( + visitedPaths.indexOf('not.items') + ); }); });