Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: track logical paths in recursive validation functions #709

Merged
merged 1 commit into from
Jan 7, 2025
Merged
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
53 changes: 51 additions & 2 deletions docs/openapi-ruleset-utilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -404,14 +416,30 @@ 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.

#### Parameters

- **`schema`** `<object>`: simple or composite OpenAPI 3.x schema object
- **`path`** `<Array>`: path array for the provided schema
- **`validate`** `<function>`: a `(schema, path) => errors` function to validate a simple schema
- **`validate`** `<function>`: a `(schema, path, logicalPath) => errors` function to validate a simple schema
- **`includeSelf`** `<boolean>`: validate the provided schema in addition to its nested schemas (defaults to `true`)
- **`includeNot`** `<boolean>`: validate schemas composed with `not` (defaults to `false`)

Expand All @@ -433,14 +461,35 @@ 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.

#### Parameters

- **`schema`** `<object>`: simple or composite OpenAPI 3.x schema object
- **`path`** `<Array>`: path array for the provided schema
- **`validate`** `<function>`: a `(schema, path) => errors` function to validate a simple schema
- **`validate`** `<function>`: a `(schema, path, logicalPath) => errors` function to validate a simple schema
- **`includeSelf`** `<boolean>`: validate the provided schema in addition to its subschemas (defaults to `true`)
- **`includeNot`** `<boolean>`: validate schemas composed with `not` (defaults to `true`)

Expand Down
53 changes: 53 additions & 0 deletions packages/utilities/src/utils/schema-path.js
Original file line number Diff line number Diff line change
@@ -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;
34 changes: 30 additions & 4 deletions packages/utilities/src/utils/validate-composed-schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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
Expand All @@ -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
Expand All @@ -42,22 +59,31 @@ 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)
);
}

for (const applicatorType of ['allOf', 'oneOf', 'anyOf']) {
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
)
);
});
}
Expand Down
73 changes: 47 additions & 26 deletions packages/utilities/src/utils/validate-nested-schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -27,26 +31,42 @@ 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
) {
// 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`
);
}

Expand All @@ -57,17 +77,18 @@ 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) {
for (const property of Object.entries(schema.properties)) {
errors.push(
...validateNestedSchemas(
property[1],
[...path, 'properties', property[0]],
path.withProperty(property[0]),
validate,
true,
includeNot
Expand All @@ -80,7 +101,7 @@ function validateNestedSchemas(
errors.push(
...validateNestedSchemas(
schema.items,
[...path, 'items'],
path.withArrayItem(),
validate,
true,
includeNot
Expand All @@ -95,19 +116,36 @@ function validateNestedSchemas(
errors.push(
...validateNestedSchemas(
schema.additionalProperties,
[...path, 'additionalProperties'],
path.withAdditionalProperty(),
validate,
true,
includeNot
)
);
}

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
Expand All @@ -121,7 +159,7 @@ function validateNestedSchemas(
errors.push(
...validateNestedSchemas(
s,
[...path, applicatorType, i],
path.withApplicator(applicatorType, i),
validate,
false,
includeNot
Expand All @@ -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;
}

Expand Down
Loading
Loading