diff --git a/src/error-handlers/anyOf.js b/src/error-handlers/anyOf.js index d0aa801..fd20ce0 100644 --- a/src/error-handlers/anyOf.js +++ b/src/error-handlers/anyOf.js @@ -1,8 +1,9 @@ import * as Instance from "@hyperjump/json-schema/instance/experimental"; +import * as Pact from "@hyperjump/pact"; import { getErrors } from "../json-schema-errors.js"; /** - * @import { ErrorHandler, ErrorObject } from "../index.d.ts" + * @import { ErrorHandler, ErrorObject, InstanceOutput } from "../index.d.ts" */ /** @type ErrorHandler */ @@ -16,18 +17,44 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { continue; } + const propertyLocations = Pact.pipe( + Instance.values(instance), + Pact.map(Instance.uri), + Pact.collectArray + ); + + const discriminators = propertyLocations.filter((propertyLocation) => { + return anyOf.some((alternative) => isPassingProperty(alternative[propertyLocation])); + }); + + /** @type ErrorObject[][] */ const alternatives = []; const instanceLocation = Instance.uri(instance); for (const alternative of anyOf) { - const typeErrors = alternative[instanceLocation]?.["https://json-schema.org/keyword/type"]; - const match = !typeErrors || Object.values(typeErrors).every((isValid) => isValid); + // Filter alternatives whose declared type doesn't match the instance type + const typeResults = alternative[instanceLocation]?.["https://json-schema.org/keyword/type"]; + if (typeResults && !Object.values(typeResults).every((isValid) => isValid)) { + continue; + } - if (match) { - alternatives.push(await getErrors(alternative, instance, localization)); + if (Instance.typeOf(instance) === "object") { + // Filter alternative if it has no declared properties in common with the instance + if (!propertyLocations.some((propertyLocation) => propertyLocation in alternative)) { + continue; + } + + // Filter alternative if it has failing properties that are declared and passing in another alternative + if (discriminators.some((propertyLocation) => !isPassingProperty(alternative[propertyLocation]))) { + continue; + } } + + // The alternative passed all the filters + alternatives.push(await getErrors(alternative, instance, localization)); } + // If all alternatives were filtered out, default to returning all of them if (alternatives.length === 0) { for (const alternative of anyOf) { alternatives.push(await getErrors(alternative, instance, localization)); @@ -39,8 +66,8 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { } else { errors.push({ message: localization.getAnyOfErrorMessage(), - alternatives: alternatives, - instanceLocation: Instance.uri(instance), + alternatives, + instanceLocation, schemaLocations: [schemaLocation] }); } @@ -49,4 +76,21 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { return errors; }; +/** @type (alternative: InstanceOutput | undefined) => boolean */ +const isPassingProperty = (propertyOutput) => { + if (!propertyOutput) { + return false; + } + + for (const keywordUri in propertyOutput) { + for (const schemaLocation in propertyOutput[keywordUri]) { + if (propertyOutput[keywordUri][schemaLocation] !== true) { + return false; + } + } + } + + return true; +}; + export default anyOfErrorHandler; diff --git a/src/error-handlers/oneOf.js b/src/error-handlers/oneOf.js index d0cd83b..63aa4ef 100644 --- a/src/error-handlers/oneOf.js +++ b/src/error-handlers/oneOf.js @@ -1,8 +1,9 @@ import * as Instance from "@hyperjump/json-schema/instance/experimental"; +import * as Pact from "@hyperjump/pact"; import { getErrors } from "../json-schema-errors.js"; /** - * @import { ErrorHandler, ErrorObject } from "../index.d.ts" + * @import { ErrorHandler, ErrorObject, InstanceOutput } from "../index.d.ts" */ /** @type ErrorHandler */ @@ -16,21 +17,45 @@ const oneOfErrorHandler = async (normalizedErrors, instance, localization) => { continue; } + const propertyLocations = Pact.pipe( + Instance.values(instance), + Pact.map(Instance.uri), + Pact.collectArray + ); + + const discriminators = propertyLocations.filter((propertyLocation) => { + return oneOf.some((alternative) => isPassingProperty(alternative[propertyLocation])); + }); + const alternatives = []; const instanceLocation = Instance.uri(instance); let matchCount = 0; for (const alternative of oneOf) { - const typeErrors = alternative[instanceLocation]?.["https://json-schema.org/keyword/type"]; - const match = !typeErrors || Object.values(typeErrors).every((isValid) => isValid); + // Filter alternatives whose declared type doesn't match the instance type + const typeResults = alternative[instanceLocation]?.["https://json-schema.org/keyword/type"]; + if (typeResults && !Object.values(typeResults).every((isValid) => isValid)) { + continue; + } - if (match) { - const alternativeErrors = await getErrors(alternative, instance, localization); - if (alternativeErrors.length) { - alternatives.push(alternativeErrors); - } else { - matchCount++; + if (Instance.typeOf(instance) === "object") { + // Filter alternative if it has no declared properties in common with the instance + if (!propertyLocations.some((propertyLocation) => propertyLocation in alternative)) { + continue; } + + // Filter alternative if it has failing properties that are declared and passing in another alternative + if (discriminators.some((propertyLocation) => !isPassingProperty(alternative[propertyLocation]))) { + continue; + } + } + + // The alternative passed all the filters + const alternativeErrors = await getErrors(alternative, instance, localization); + if (alternativeErrors.length) { + alternatives.push(alternativeErrors); + } else { + matchCount++; } } @@ -60,4 +85,21 @@ const oneOfErrorHandler = async (normalizedErrors, instance, localization) => { return errors; }; +/** @type (alternative: InstanceOutput | undefined) => boolean */ +const isPassingProperty = (propertyOutput) => { + if (!propertyOutput) { + return false; + } + + for (const keywordUri in propertyOutput) { + for (const schemaLocation in propertyOutput[keywordUri]) { + if (propertyOutput[keywordUri][schemaLocation] !== true) { + return false; + } + } + } + + return true; +}; + export default oneOfErrorHandler; diff --git a/src/test-suite/tests/anyOf.json b/src/test-suite/tests/anyOf.json index 8998ad9..4f80ffe 100644 --- a/src/test-suite/tests/anyOf.json +++ b/src/test-suite/tests/anyOf.json @@ -20,7 +20,7 @@ { "messageId": "type-message", "messageParams": { - "expectedTypes": "string" + "expectedTypes": { "or": ["string"] } }, "instanceLocation": "#", "schemaLocations": ["#/anyOf/0/type"] @@ -30,7 +30,7 @@ { "messageId": "type-message", "messageParams": { - "expectedTypes": "number" + "expectedTypes": { "or": ["number"] } }, "instanceLocation": "#", "schemaLocations": ["#/anyOf/1/type"] @@ -253,6 +253,306 @@ "schemaLocations": ["https://example.com/main#/anyOf"] } ] + }, + { + "description": "alternatives with no declared properties matching the instance are filtered out", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "a": { "type": "string" } + }, + "required": ["a"] + }, + { + "type": "object", + "properties": { + "b": { "type": "string" } + }, + "required": ["b"] + } + ] + }, + "instance": { "a": 42 }, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": { "or": ["string"] } + }, + "instanceLocation": "#/a", + "schemaLocations": ["#/anyOf/0/properties/a/type"] + } + ] + }, + { + "description": "None of the instance properties are declared in any of the alternatives", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "a": { "type": "string" } + }, + "required": ["a"] + }, + { + "type": "object", + "properties": { + "b": { "type": "string" } + }, + "required": ["b"] + } + ] + }, + "instance": { "c": 42 }, + "errors": [ + { + "messageId": "anyOf-message", + "alternatives": [ + [ + { + "messageId": "required-message", + "messageParams": { + "required": { "and": ["a"] }, + "count": 1 + }, + "instanceLocation": "#", + "schemaLocations": ["#/anyOf/0/required"] + } + ], + [ + { + "messageId": "required-message", + "messageParams": { + "required": { "and": ["b"] }, + "count": 1 + }, + "instanceLocation": "#", + "schemaLocations": ["#/anyOf/1/required"] + } + ] + ], + "instanceLocation": "#", + "schemaLocations": ["#/anyOf"] + } + ] + }, + { + "description": "anyOf object alternatives keep only one branch when only one branch has a passing instance property", + "compatibility": "6", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { "const": "a" }, + "a": { "type": "string" } + }, + "required": ["type", "a"] + }, + { + "type": "object", + "properties": { + "type": { "const": "b" }, + "b": { "type": "number" } + }, + "required": ["type", "b"] + } + ] + }, + "instance": { "type": "b", "b": "oops" }, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": { "or": ["number"] } + }, + "instanceLocation": "#/b", + "schemaLocations": ["#/anyOf/1/properties/b/type"] + } + ] + }, + { + "description": "anyOf object alternatives keep both branches when each branch has at least one passing instance property", + "compatibility": "6", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { "const": "a" }, + "x": { "type": "string" } + }, + "required": ["type", "x"] + }, + { + "type": "object", + "properties": { + "type": { + "allOf": [ + { "type": "string" }, + { "minLength": 2 } + ] + }, + "x": { "type": "number" } + }, + "required": ["type", "x"] + } + ] + }, + "instance": { "type": "a", "x": 42 }, + "errors": [ + { + "messageId": "anyOf-message", + "alternatives": [ + [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": { "or": ["string"] } + }, + "instanceLocation": "#/x", + "schemaLocations": ["#/anyOf/0/properties/x/type"] + } + ], + [ + { + "messageId": "minLength-message", + "messageParams": { + "minLength": "2" + }, + "instanceLocation": "#/type", + "schemaLocations": ["#/anyOf/1/properties/type/allOf/1/minLength"] + } + ] + ], + "instanceLocation": "#", + "schemaLocations": ["#/anyOf"] + } + ] + }, + { + "description": "anyOf object alternatives filters to matching branch by declared property intersection", + "compatibility": "6", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "a": { "type": "string" } + }, + "required": ["a"] + }, + { + "type": "object", + "properties": { + "b": { "type": "string" } + }, + "required": ["b"] + } + ] + }, + "instance": { "a": 42 }, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": "string" + }, + "instanceLocation": "#/a", + "schemaLocations": ["#/anyOf/0/properties/a/type"] + } + ] + }, + { + "description": "anyOf object alternatives fallback to all when no instance property passes in any branch", + "compatibility": "6", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { "const": "a" } + }, + "required": ["kind"] + }, + { + "type": "object", + "properties": { + "kind": { "const": "b" } + }, + "required": ["kind"] + } + ] + }, + "instance": { "kind": "c" }, + "errors": [ + { + "messageId": "anyOf-message", + "alternatives": [ + [ + { + "messageId": "const-message", + "messageParams": { + "expected": "\"a\"" + }, + "instanceLocation": "#/kind", + "schemaLocations": ["#/anyOf/0/properties/kind/const"] + } + ], + [ + { + "messageId": "const-message", + "messageParams": { + "expected": "\"b\"" + }, + "instanceLocation": "#/kind", + "schemaLocations": ["#/anyOf/1/properties/kind/const"] + } + ] + ], + "instanceLocation": "#", + "schemaLocations": ["#/anyOf"] + } + ] + }, + { + "description": "Compound discriminators", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "foo": { "enum": ["a"] }, + "bar": { "enum": ["b"] }, + "a": { "type": "string" } + }, + "required": ["foo", "bar"] + }, + { + "type": "object", + "properties": { + "foo": { "enum": ["a"] }, + "bar": { "enum": ["c"] }, + "b": { "type": "string" } + }, + "required": ["foo", "bar"] + } + ] + }, + "instance": { "foo": "a", "bar": "c", "b": 42 }, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": { "or": ["string"] } + }, + "instanceLocation": "#/b", + "schemaLocations": ["#/anyOf/1/properties/b/type"] + } + ] } ] } diff --git a/src/test-suite/tests/oneOf.json b/src/test-suite/tests/oneOf.json index 05c1f4b..c6466d1 100644 --- a/src/test-suite/tests/oneOf.json +++ b/src/test-suite/tests/oneOf.json @@ -15,7 +15,9 @@ "errors": [ { "messageId": "oneOf-message", - "messageParams": { "matchCount": 0 }, + "messageParams": { + "matchCount": 0 + }, "alternatives": [ [ { @@ -321,6 +323,315 @@ "schemaLocations": ["https://example.com/main#/oneOf"] } ] + }, + { + "description": "alternatives with no declared properties matching the instance are filtered out", + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "a": { "type": "string" } + }, + "required": ["a"] + }, + { + "type": "object", + "properties": { + "b": { "type": "string" } + }, + "required": ["b"] + } + ] + }, + "instance": { "a": 42 }, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": { "or": ["string"] } + }, + "instanceLocation": "#/a", + "schemaLocations": ["#/oneOf/0/properties/a/type"] + } + ] + }, + { + "description": "None of the instance properties are declared in any of the alternatives", + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "a": { "type": "string" } + }, + "required": ["a"] + }, + { + "type": "object", + "properties": { + "b": { "type": "string" } + }, + "required": ["b"] + } + ] + }, + "instance": { "c": 42 }, + "errors": [ + { + "messageId": "oneOf-message", + "messageParams": { + "matchCount": 0 + }, + "alternatives": [ + [ + { + "messageId": "required-message", + "messageParams": { + "required": { "and": ["a"] }, + "count": 1 + }, + "instanceLocation": "#", + "schemaLocations": ["#/oneOf/0/required"] + } + ], + [ + { + "messageId": "required-message", + "messageParams": { + "required": { "and": ["b"] }, + "count": 1 + }, + "instanceLocation": "#", + "schemaLocations": ["#/oneOf/1/required"] + } + ] + ], + "instanceLocation": "#", + "schemaLocations": ["#/oneOf"] + } + ] + }, + { + "description": "oneOf object alternatives keep only one branch when only one branch has a passing instance property", + "compatibility": "6", + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { "const": "a" }, + "a": { "type": "string" } + }, + "required": ["type", "a"] + }, + { + "type": "object", + "properties": { + "type": { "const": "b" }, + "b": { "type": "number" } + }, + "required": ["type", "b"] + } + ] + }, + "instance": { "type": "b", "b": "oops" }, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": { "or": ["number"] } + }, + "instanceLocation": "#/b", + "schemaLocations": ["#/oneOf/1/properties/b/type"] + } + ] + }, + { + "description": "oneOf object alternatives filters to matching branch by declared property intersection", + "compatibility": "6", + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "a": { "type": "string" } + }, + "required": ["a"] + }, + { + "type": "object", + "properties": { + "b": { "type": "string" } + }, + "required": ["b"] + } + ] + }, + "instance": { "a": 42 }, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": { "or": ["string"] } + }, + "instanceLocation": "#/a", + "schemaLocations": ["#/oneOf/0/properties/a/type"] + } + ] + }, + { + "description": "oneOf object alternatives fallback to all when no instance property passes in any branch", + "compatibility": "6", + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "const": "a" } + }, + "required": ["kind"] + }, + { + "type": "object", + "properties": { + "kind": { "const": "b" } + }, + "required": ["kind"] + } + ] + }, + "instance": { "kind": "c" }, + "errors": [ + { + "messageId": "oneOf-message", + "messageParams": { + "matchCount": 0 + }, + "alternatives": [ + [ + { + "messageId": "const-message", + "messageParams": { + "expected": "\"a\"" + }, + "instanceLocation": "#/kind", + "schemaLocations": ["#/oneOf/0/properties/kind/const"] + } + ], + [ + { + "messageId": "const-message", + "messageParams": { + "expected": "\"b\"" + }, + "instanceLocation": "#/kind", + "schemaLocations": ["#/oneOf/1/properties/kind/const"] + } + ] + ], + "instanceLocation": "#", + "schemaLocations": ["#/oneOf"] + } + ] + }, + { + "description": "oneOf object alternatives keep both branches when each branch has at least one passing instance property", + "compatibility": "6", + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { "const": "a" }, + "x": { "type": "string" } + }, + "required": ["type", "x"] + }, + { + "type": "object", + "properties": { + "type": { + "allOf": [ + { "type": "string" }, + { "minLength": 2 } + ] + }, + "x": { "type": "number" } + }, + "required": ["type", "x"] + } + ] + }, + "instance": { "type": "a", "x": 42 }, + "errors": [ + { + "messageId": "oneOf-message", + "messageParams": { + "matchCount": 0 + }, + "alternatives": [ + [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": { "or": ["string"] } + }, + "instanceLocation": "#/x", + "schemaLocations": ["#/oneOf/0/properties/x/type"] + } + ], + [ + { + "messageId": "minLength-message", + "messageParams": { + "minLength": "2" + }, + "instanceLocation": "#/type", + "schemaLocations": ["#/oneOf/1/properties/type/allOf/1/minLength"] + } + ] + ], + "instanceLocation": "#", + "schemaLocations": ["#/oneOf"] + } + ] + }, + { + "description": "Compound discriminators", + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "foo": { "enum": ["a"] }, + "bar": { "enum": ["b"] }, + "a": { "type": "string" } + }, + "required": ["foo", "bar"] + }, + { + "type": "object", + "properties": { + "foo": { "enum": ["a"] }, + "bar": { "enum": ["c"] }, + "b": { "type": "string" } + }, + "required": ["foo", "bar"] + } + ] + }, + "instance": { "foo": "a", "bar": "c", "b": 42 }, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": { "or": ["string"] } + }, + "instanceLocation": "#/b", + "schemaLocations": ["#/oneOf/1/properties/b/type"] + } + ] } ] }