diff --git a/src/error-handlers/array-range-handler.js b/src/error-handlers/array-range-handler.js new file mode 100644 index 0000000..dc67037 --- /dev/null +++ b/src/error-handlers/array-range-handler.js @@ -0,0 +1,53 @@ +import { getSchema } from "@hyperjump/json-schema/experimental"; +import * as Schema from "@hyperjump/browser"; +import * as Instance from "@hyperjump/json-schema/instance/experimental"; + +/** + * @import { ArrayConstraints } from "../localization.js" + * @import { ErrorHandler } from "../index.d.ts" + */ + +/** @type ErrorHandler */ +const arrayRangeHandler = async (normalizedErrors, instance, localization) => { + /** @type ArrayConstraints */ + const constraints = {}; + + /** @type string[] */ + const failedSchemaLocations = []; + + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/minItems"]) { + if (!normalizedErrors["https://json-schema.org/keyword/minItems"][schemaLocation]) { + failedSchemaLocations.push(schemaLocation); + } + + const keyword = await getSchema(schemaLocation); + /** @type number */ + const minItems = Schema.value(keyword); + constraints.minItems = Math.max(constraints.minItems ?? Number.MIN_VALUE, minItems); + } + + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/maxItems"]) { + if (!normalizedErrors["https://json-schema.org/keyword/maxItems"][schemaLocation]) { + failedSchemaLocations.push(schemaLocation); + } + + const keyword = await getSchema(schemaLocation); + /** @type number */ + const maxItems = Schema.value(keyword); + constraints.maxItems = Math.min(constraints.maxItems ?? Number.MAX_VALUE, maxItems); + } + + if (failedSchemaLocations.length > 0) { + return [ + { + message: localization.getArrayErrorMessage(constraints), + instanceLocation: Instance.uri(instance), + schemaLocation: failedSchemaLocations.length > 1 ? failedSchemaLocations : failedSchemaLocations[0] + } + ]; + } + + return []; +}; + +export default arrayRangeHandler; diff --git a/src/error-handlers/maxItems.js b/src/error-handlers/maxItems.js deleted file mode 100644 index 8f8154a..0000000 --- a/src/error-handlers/maxItems.js +++ /dev/null @@ -1,30 +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 } from "../index.d.ts" - */ - -/** @type ErrorHandler */ -const maxItems = async (normalizedErrors, instance, localization) => { - /** @type ErrorObject[] */ - const errors = []; - - if (normalizedErrors["https://json-schema.org/keyword/maxItems"]) { - for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/maxItems"]) { - if (!normalizedErrors["https://json-schema.org/keyword/maxItems"][schemaLocation]) { - const keyword = await getSchema(schemaLocation); - errors.push({ - message: localization.getMaxItemsErrorMessage(Schema.value(keyword)), - instanceLocation: Instance.uri(instance), - schemaLocation: schemaLocation - }); - } - } - } - - return errors; -}; - -export default maxItems; diff --git a/src/error-handlers/maxProperties.js b/src/error-handlers/maxProperties.js deleted file mode 100644 index 9f49057..0000000 --- a/src/error-handlers/maxProperties.js +++ /dev/null @@ -1,30 +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 } from "../index.d.ts" - */ - -/** @type ErrorHandler */ -const maxProperties = async (normalizedErrors, instance, localization) => { - /** @type ErrorObject[] */ - const errors = []; - - if (normalizedErrors["https://json-schema.org/keyword/maxProperties"]) { - for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/maxProperties"]) { - if (!normalizedErrors["https://json-schema.org/keyword/maxProperties"][schemaLocation]) { - const keyword = await getSchema(schemaLocation); - errors.push({ - message: localization.getMaxPropertiesErrorMessage(Schema.value(keyword)), - instanceLocation: Instance.uri(instance), - schemaLocation: schemaLocation - }); - } - } - } - - return errors; -}; - -export default maxProperties; diff --git a/src/error-handlers/minItems.js b/src/error-handlers/minItems.js deleted file mode 100644 index 1ca3b74..0000000 --- a/src/error-handlers/minItems.js +++ /dev/null @@ -1,30 +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 } from "../index.d.ts" - */ - -/** @type ErrorHandler */ -const minItems = async (normalizedErrors, instance, localization) => { - /** @type ErrorObject[] */ - const errors = []; - - if (normalizedErrors["https://json-schema.org/keyword/minItems"]) { - for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/minItems"]) { - if (!normalizedErrors["https://json-schema.org/keyword/minItems"][schemaLocation]) { - const keyword = await getSchema(schemaLocation); - errors.push({ - message: localization.getMinItemsErrorMessage(Schema.value(keyword)), - instanceLocation: Instance.uri(instance), - schemaLocation: schemaLocation - }); - } - } - } - - return errors; -}; - -export default minItems; diff --git a/src/error-handlers/minProperties.js b/src/error-handlers/minProperties.js deleted file mode 100644 index c390ecb..0000000 --- a/src/error-handlers/minProperties.js +++ /dev/null @@ -1,30 +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 } from "../index.d.ts" - */ - -/** @type ErrorHandler */ -const minProperties = async (normalizedErrors, instance, localization) => { - /** @type ErrorObject[] */ - const errors = []; - - if (normalizedErrors["https://json-schema.org/keyword/minProperties"]) { - for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/minProperties"]) { - if (!normalizedErrors["https://json-schema.org/keyword/minProperties"][schemaLocation]) { - const keyword = await getSchema(schemaLocation); - errors.push({ - message: localization.getMinPropertiesErrorMessage(Schema.value(keyword)), - instanceLocation: Instance.uri(instance), - schemaLocation: schemaLocation - }); - } - } - } - - return errors; -}; - -export default minProperties; diff --git a/src/error-handlers/properties-range-handler.js b/src/error-handlers/properties-range-handler.js new file mode 100644 index 0000000..b050709 --- /dev/null +++ b/src/error-handlers/properties-range-handler.js @@ -0,0 +1,53 @@ +import { getSchema } from "@hyperjump/json-schema/experimental"; +import * as Schema from "@hyperjump/browser"; +import * as Instance from "@hyperjump/json-schema/instance/experimental"; + +/** + * @import { PropertiesConstraints } from "../localization.js" + * @import { ErrorHandler } from "../index.d.ts" + */ + +/** @type ErrorHandler */ +const propertiesRangeHandler = async (normalizedErrors, instance, localization) => { + /** @type PropertiesConstraints */ + const constraints = {}; + + /** @type string[] */ + const failedSchemaLocations = []; + + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/minProperties"]) { + if (!normalizedErrors["https://json-schema.org/keyword/minProperties"][schemaLocation]) { + failedSchemaLocations.push(schemaLocation); + } + + const keyword = await getSchema(schemaLocation); + /** @type number */ + const minProperties = Schema.value(keyword); + constraints.minProperties = Math.max(constraints.minProperties ?? Number.MIN_VALUE, minProperties); + } + + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/maxProperties"]) { + if (!normalizedErrors["https://json-schema.org/keyword/maxProperties"][schemaLocation]) { + failedSchemaLocations.push(schemaLocation); + } + + const keyword = await getSchema(schemaLocation); + /** @type number */ + const maxProperties = Schema.value(keyword); + constraints.maxProperties = Math.min(constraints.maxProperties ?? Number.MAX_VALUE, maxProperties); + } + + if (failedSchemaLocations.length > 0) { + return [ + { + message: localization.getPropertiesErrorMessage(constraints), + instanceLocation: Instance.uri(instance), + schemaLocation: failedSchemaLocations.length > 1 ? failedSchemaLocations : failedSchemaLocations[0] + } + ]; + } + + return []; +}; + +export default propertiesRangeHandler; diff --git a/src/index.js b/src/index.js index 2b216df..afad914 100644 --- a/src/index.js +++ b/src/index.js @@ -46,15 +46,12 @@ import uniqueItems from "./normalization-handlers/uniqueItems.js"; // Error Handlers import anyOfErrorHandler from "./error-handlers/anyOf.js"; import additionalPropertiesErrorHandler from "./error-handlers/additionalProperties.js"; +import arrayRangeErrorHandler from "./error-handlers/array-range-handler.js"; import constErrorHandler from "./error-handlers/const.js"; import containsErrorHandler from "./error-handlers/contains.js"; import dependentRequiredErrorHandler from "./error-handlers/dependentRequired.js"; import enumErrorHandler from "./error-handlers/enum.js"; import formatErrorHandler from "./error-handlers/format.js"; -import maxItemsErrorHandler from "./error-handlers/maxItems.js"; -import minItemsErrorHandler from "./error-handlers/minItems.js"; -import maxPropertiesErrorHandler from "./error-handlers/maxProperties.js"; -import minPropertiesErrorHandler from "./error-handlers/minProperties.js"; import multipleOfErrorHandler from "./error-handlers/multipleOf.js"; import notErrorHandler from "./error-handlers/not.js"; import numberRangeHandler from "./error-handlers/number-range-handler.js"; @@ -63,6 +60,7 @@ import typeErrorHandler from "./error-handlers/type.js"; import uniqueItemsErrorHandler from "./error-handlers/uniqueItems.js"; import stringErrorHandler from "./error-handlers/string-handler.js"; import patternErrorHandler from "./error-handlers/pattern.js"; +import propertiesRangeHandler from "./error-handlers/properties-range-handler.js"; /** * @import { betterJsonSchemaErrors } from "./index.d.ts" @@ -122,10 +120,7 @@ addErrorHandler(containsErrorHandler); addErrorHandler(dependentRequiredErrorHandler); addErrorHandler(enumErrorHandler); addErrorHandler(formatErrorHandler); -addErrorHandler(maxItemsErrorHandler); -addErrorHandler(minItemsErrorHandler); -addErrorHandler(maxPropertiesErrorHandler); -addErrorHandler(minPropertiesErrorHandler); +addErrorHandler(arrayRangeErrorHandler); addErrorHandler(multipleOfErrorHandler); addErrorHandler(notErrorHandler); addErrorHandler(numberRangeHandler); @@ -134,6 +129,7 @@ addErrorHandler(typeErrorHandler); addErrorHandler(uniqueItemsErrorHandler); addErrorHandler(stringErrorHandler); addErrorHandler(patternErrorHandler); +addErrorHandler(propertiesRangeHandler); export { setNormalizationHandler } from "./normalized-output.js"; export { addErrorHandler } from "./error-handling.js"; diff --git a/src/keyword-error-message.test.js b/src/keyword-error-message.test.js index 7c2240c..ffbbd03 100644 --- a/src/keyword-error-message.test.js +++ b/src/keyword-error-message.test.js @@ -396,7 +396,7 @@ describe("Error messages", async () => { expect(result.errors).to.eql([{ schemaLocation: "https://example.com/main#/maxProperties", instanceLocation: "#", - message: localization.getMaxPropertiesErrorMessage(2) + message: localization.getPropertiesErrorMessage({ maxProperties: 2 }) }]); }); @@ -424,7 +424,43 @@ describe("Error messages", async () => { expect(result.errors).to.eql([{ schemaLocation: "https://example.com/main#/minProperties", instanceLocation: "#", - message: localization.getMinPropertiesErrorMessage(2) + message: localization.getPropertiesErrorMessage({ minProperties: 2 }) + }]); + }); + + test("max-min Properties", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + maxProperties: 3, + minProperties: 2 + + }, schemaUri); + + const instance = { foo: 1 }; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/maxProperties", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/minProperties", + instanceLocation: "#" + } + ] + }; + + const result = await betterJsonSchemaErrors(output, schemaUri, instance); + expect(result.errors).to.eql([{ + schemaLocation: [ + "https://example.com/main#/minProperties", + "https://example.com/main#/maxProperties" + ], + instanceLocation: "#", + message: localization.getPropertiesErrorMessage({ maxProperties: 3, minProperties: 2 }) }]); }); @@ -507,7 +543,7 @@ describe("Error messages", async () => { expect(result.errors).to.eql([{ schemaLocation: "https://example.com/main#/maxItems", instanceLocation: "#", - message: localization.getMaxItemsErrorMessage(3) + message: localization.getArrayErrorMessage({ maxItems: 3 }) }]); }); @@ -534,7 +570,42 @@ describe("Error messages", async () => { expect(result.errors).to.eql([{ schemaLocation: "https://example.com/main#/minItems", instanceLocation: "#", - message: localization.getMinItemsErrorMessage(3) + message: localization.getArrayErrorMessage({ minItems: 3 }) + }]); + }); + + test("minItems and maxItems", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + minItems: 3, + maxItems: 4 + }, schemaUri); + + const instance = [1, 3]; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/minItems", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/maxItems", + instanceLocation: "#" + } + ] + }; + + const result = await betterJsonSchemaErrors(output, schemaUri, instance); + expect(result.errors).to.eql([{ + schemaLocation: [ + "https://example.com/main#/minItems", + "https://example.com/main#/maxItems" + ], + instanceLocation: "#", + message: localization.getArrayErrorMessage({ minItems: 3, maxItems: 4 }) }]); }); diff --git a/src/localization.js b/src/localization.js index 4355b98..e246596 100644 --- a/src/localization.js +++ b/src/localization.js @@ -28,6 +28,20 @@ import { FluentBundle, FluentResource } from "@fluent/bundle"; * }} ContainsConstraints */ +/** + * @typedef {{ + * minItems?: number; + * maxItems?: number; + * }} ArrayConstraints + */ + +/** + * @typedef {{ + * maxProperties?: number; + * minProperties?: number; + * }} PropertiesConstraints + */ + export class Localization { /** * @param {string} locale @@ -136,29 +150,45 @@ export class Localization { return this._formatMessage("multiple-of-error", { divisor }); } - /** @type (limit: number) => string */ - getMaxPropertiesErrorMessage(limit) { - return this._formatMessage("max-properties-error", { limit }); - } - - /** @type (limit: number) => string */ - getMinPropertiesErrorMessage(limit) { - return this._formatMessage("min-properties-error", { limit }); - } - /** @type (expectedValue: FluentVariable) => string */ getConstErrorMessage(expectedValue) { return this._formatMessage("const-error", { expectedValue }); } - /** @type (limit: number) => string */ - getMaxItemsErrorMessage(limit) { - return this._formatMessage("max-items-error", { limit }); + /** @type (constraints: PropertiesConstraints) => string */ + getPropertiesErrorMessage(constraints) { + /** @type string[] */ + const messages = []; + + if (constraints.minProperties) { + messages.push(this._formatMessage("properties-error-min", constraints)); + } + + if (constraints.maxProperties) { + messages.push(this._formatMessage("properties-error-max", constraints)); + } + + return this._formatMessage("properties-error", { + constraints: new Intl.ListFormat(this.locale, { type: "conjunction" }).format(messages) + }); } - /** @type (limit: number) => string */ - getMinItemsErrorMessage(limit) { - return this._formatMessage("min-items-error", { limit }); + /** @type (constraints: ArrayConstraints) => string */ + getArrayErrorMessage(constraints) { + /** @type string[] */ + const messages = []; + + if (constraints.minItems !== undefined) { + messages.push(this._formatMessage("array-error-min", constraints)); + } + + if (constraints.maxItems !== undefined) { + messages.push(this._formatMessage("array-error-max", constraints)); + } + + return this._formatMessage("array-error", { + constraints: new Intl.ListFormat(this.locale, { type: "conjunction" }).format(messages) + }); } /** @type () => string */ diff --git a/src/translations/en-US.ftl b/src/translations/en-US.ftl index 5fc886f..b210cd8 100644 --- a/src/translations/en-US.ftl +++ b/src/translations/en-US.ftl @@ -12,11 +12,17 @@ number-error-exclusive-maximum = less than or equal to {$maximum} required-error = "{$instanceLocation}" is missing required property(s): {$missingProperties}. multiple-of-error = The instance should be a multiple of {$divisor}. -max-properties-error = The instance should have a maximum of {$limit} properties. -min-properties-error = The instance should have a minimum of {$limit} properties. + +properties-error = Expected object to have {$constraints} +properties-error-max = at most {$maxProperties} properties. +properties-error-min = at least {$minProperties} properties. + const-error = The instance should be equal to {$expectedValue}. -max-items-error = The instance should contain a maximum of {$limit} items in the array. -min-items-error = The instance should contain a minimum of {$limit} items in the array. + +array-error = Expected the array to have {$constraints}. +array-error-min = at least {$minItems} items +array-error-max = at most {$maxItems} items + unique-items-error = The instance should have unique items in the array. format-error = The instance should match the format: {$format}. pattern-error = The instance should match the pattern: {$pattern}.