diff --git a/src/error-handlers/const.js b/src/error-handlers/const.js deleted file mode 100644 index 64c23c3..0000000 --- a/src/error-handlers/const.js +++ /dev/null @@ -1,32 +0,0 @@ -import { getSchema } from "@hyperjump/json-schema/experimental"; -import * as Schema from "@hyperjump/browser"; -import * as Instance from "@hyperjump/json-schema/instance/experimental"; - -/** - * @import { ErrorHandler, ErrorObject, Json } from "../index.d.ts" - */ - -/** @type ErrorHandler */ -const constErrorHandler = async (normalizedErrors, instance, localization) => { - /** @type ErrorObject[] */ - const errors = []; - - for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/const"]) { - if (normalizedErrors["https://json-schema.org/keyword/const"][schemaLocation]) { - continue; - } - - const keyword = await getSchema(schemaLocation); - const expected = /** @type Json */ (Schema.value(keyword)); - - errors.push({ - message: localization.getConstErrorMessage(expected), - instanceLocation: Instance.uri(instance), - schemaLocations: [schemaLocation] - }); - } - - return errors; -}; - -export default constErrorHandler; diff --git a/src/error-handlers/constAndEnum.js b/src/error-handlers/constAndEnum.js new file mode 100644 index 0000000..4c258e6 --- /dev/null +++ b/src/error-handlers/constAndEnum.js @@ -0,0 +1,96 @@ +import { getSchema } from "@hyperjump/json-schema/experimental"; +import * as Schema from "@hyperjump/browser"; +import * as Instance from "@hyperjump/json-schema/instance/experimental"; +import jsonStringify from "json-stringify-deterministic"; + +/** + * @import { ErrorHandler, ErrorObject, Json } from "../index.d.ts" + */ + +/** + * @typedef {{ + * allowedValues: Json[]; + * schemaLocation: string; + * }} Constraint + */ + +/** @type {ErrorHandler} */ +const constAndEnumErrorHandler = async (normalizedErrors, instance, localization) => { + /** @type {ErrorObject[]} */ + const errors = []; + + /** @type {Constraint[]} */ + const constraints = []; + let hasFailure = false; + + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/const"]) { + const passed = normalizedErrors["https://json-schema.org/keyword/const"][schemaLocation] === true; + if (!passed) { + hasFailure = true; + } + const keyword = await getSchema(schemaLocation); + constraints.push({ + allowedValues: [Schema.value(keyword)], + schemaLocation + }); + } + + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/enum"]) { + const passed = normalizedErrors["https://json-schema.org/keyword/enum"][schemaLocation] === true; + if (!passed) { + hasFailure = true; + } + const keyword = await getSchema(schemaLocation); + constraints.push({ + allowedValues: Schema.value(keyword), + schemaLocation + }); + } + + if (!hasFailure || constraints.length === 0) { + return errors; + } + + const mostConstraining = constraints.reduce((min, c) => + c.allowedValues.length < min.allowedValues.length ? c : min + ); + + let intersectionKeys = new Set(mostConstraining.allowedValues.map(toKey)); + for (let i = 1; i < constraints.length; i++) { + const otherKeys = new Set(constraints[i].allowedValues.map(toKey)); + intersectionKeys = new Set([...intersectionKeys].filter((k) => otherKeys.has(k))); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + const intersection = /** @type {Json[]} */ ([...intersectionKeys].map((k) => JSON.parse(k))); + + const instanceLocation = Instance.uri(instance); + if (intersection.length === 0) { + errors.push({ + message: localization.getBooleanSchemaErrorMessage(), + instanceLocation, + schemaLocations: constraints.map((c) => c.schemaLocation) + }); + } else if (intersection.length === 1) { + errors.push({ + message: localization.getConstErrorMessage(intersection[0]), + instanceLocation, + schemaLocations: [mostConstraining.schemaLocation] + }); + } else { + errors.push({ + message: localization.getEnumErrorMessage(intersection), + instanceLocation, + schemaLocations: [mostConstraining.schemaLocation] + }); + } + return errors; +}; + +/** + * @param {Json} val + * @returns {string} + */ +const toKey = (val) => jsonStringify(val); + +export default constAndEnumErrorHandler; diff --git a/src/error-handlers/enum.js b/src/error-handlers/enum.js deleted file mode 100644 index 2d1c1d0..0000000 --- a/src/error-handlers/enum.js +++ /dev/null @@ -1,32 +0,0 @@ -import { getSchema } from "@hyperjump/json-schema/experimental"; -import * as Schema from "@hyperjump/browser"; -import * as Instance from "@hyperjump/json-schema/instance/experimental"; - -/** - * @import { ErrorHandler, ErrorObject, Json } from "../index.d.ts" - */ - -/** @type ErrorHandler */ -const enumErrorHandler = async (normalizedErrors, instance, localization) => { - /** @type ErrorObject[] */ - const errors = []; - - for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/enum"]) { - if (normalizedErrors["https://json-schema.org/keyword/enum"][schemaLocation]) { - continue; - } - - const keyword = await getSchema(schemaLocation); - const expected = /** @type Json[] */ (Schema.value(keyword)); - - errors.push({ - message: localization.getEnumErrorMessage(expected), - instanceLocation: Instance.uri(instance), - schemaLocations: [schemaLocation] - }); - } - - return errors; -}; - -export default enumErrorHandler; diff --git a/src/index.js b/src/index.js index 3bf0374..6f8df9d 100644 --- a/src/index.js +++ b/src/index.js @@ -54,11 +54,10 @@ import unknownNormalizationHandler from "./normalization-handlers/unknown.js"; // Error Handlers import anyOfErrorHandler from "./error-handlers/anyOf.js"; import booleanSchemaErrorHandler from "./error-handlers/boolean-schema.js"; -import constErrorHandler from "./error-handlers/const.js"; +import constAndEnumErrorHandler from "./error-handlers/constAndEnum.js"; import containsErrorHandler from "./error-handlers/contains.js"; import dependenciesErrorHandler from "./error-handlers/draft-04/dependencies.js"; import dependentRequiredErrorHandler from "./error-handlers/dependentRequired.js"; -import enumErrorHandler from "./error-handlers/enum.js"; import exclusiveMaximumErrorHandler from "./error-handlers/exclusiveMaximum.js"; import exclusiveMinimumErrorHandler from "./error-handlers/exclusiveMinimum.js"; import formatErrorHandler from "./error-handlers/format.js"; @@ -139,11 +138,10 @@ setNormalizationHandler("https://json-schema.org/keyword/unknown", unknownNormal addErrorHandler(anyOfErrorHandler); addErrorHandler(booleanSchemaErrorHandler); -addErrorHandler(constErrorHandler); +addErrorHandler(constAndEnumErrorHandler); addErrorHandler(containsErrorHandler); addErrorHandler(dependenciesErrorHandler); addErrorHandler(dependentRequiredErrorHandler); -addErrorHandler(enumErrorHandler); addErrorHandler(exclusiveMaximumErrorHandler); addErrorHandler(exclusiveMinimumErrorHandler); addErrorHandler(formatErrorHandler); diff --git a/src/test-suite/tests/const.json b/src/test-suite/tests/const.json index c59ac52..4044877 100644 --- a/src/test-suite/tests/const.json +++ b/src/test-suite/tests/const.json @@ -29,6 +29,25 @@ }, "instance": 42, "errors": [] + }, + { + "description": "contradictory const - empty intersection", + "compatibility": "6", + "schema": { + "allOf": [ + { "const": "a" }, + { "const": "b" } + ] + }, + "instance": "a", + "errors": [ + { + "messageId": "boolean-schema-message", + "messageParams": {}, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/0/const", "#/allOf/1/const"] + } + ] } ] } diff --git a/src/test-suite/tests/constAndEnum.json b/src/test-suite/tests/constAndEnum.json new file mode 100644 index 0000000..7757057 --- /dev/null +++ b/src/test-suite/tests/constAndEnum.json @@ -0,0 +1,48 @@ +{ + "$schema": "../test-suite.schema.json", + + "description": "Combined const and enum constraints", + "tests": [ + { + "description": "const with enum constraints (combined)", + "compatibility": "6", + "schema": { + "allOf": [ + { "enum": ["a", "b", "c"] }, + { "enum": ["a", "b"] }, + { "const": "a" } + ] + }, + "instance": "x", + "errors": [ + { + "messageId": "const-message", + "messageParams": { "expected": "\"a\"" }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/2/const"] + } + ] + }, + { + "description": "enum with const - deterministic key order", + "compatibility": "6", + "schema": { + "allOf": [ + { "enum": [{"a": 1, "b": 2}, {"c": 3}] }, + { "const": {"b": 2, "a": 1} } + ] + }, + "instance": "x", + "errors": [ + { + "messageId": "const-message", + "messageParams": { + "expected": "{\n \"a\": 1,\n \"b\": 2\n}" + }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/1/const"] + } + ] + } + ] +} diff --git a/src/test-suite/tests/enum.json b/src/test-suite/tests/enum.json index 613e004..03f6c2d 100644 --- a/src/test-suite/tests/enum.json +++ b/src/test-suite/tests/enum.json @@ -21,6 +21,27 @@ } ] }, + { + "description": "multiple enum constraints (combined)", + "schema": { + "allOf": [ + { "enum": ["a", "b", "c"] }, + { "enum": ["a", "b"] } + ] + }, + "instance": "x", + "errors": [ + { + "messageId": "enum-message", + "messageParams": { + "expected": { "or": ["\"a\"", "\"b\""] }, + "count": 2 + }, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/1/enum"] + } + ] + }, { "description": "enum pass", "schema": { @@ -28,6 +49,25 @@ }, "instance": "foo", "errors": [] + }, + { + "description": "contradictory enum - empty intersection", + "compatibility": "6", + "schema": { + "allOf": [ + { "enum": ["a", "b", "c"] }, + { "enum": ["d", "e", "f"] } + ] + }, + "instance": "a", + "errors": [ + { + "messageId": "boolean-schema-message", + "messageParams": {}, + "instanceLocation": "#", + "schemaLocations": ["#/allOf/0/enum", "#/allOf/1/enum"] + } + ] } ] }