diff --git a/src/error-handlers/contains.js b/src/error-handlers/contains.js index fb77047..3449f0d 100644 --- a/src/error-handlers/contains.js +++ b/src/error-handlers/contains.js @@ -1,7 +1,11 @@ import * as Instance from "@hyperjump/json-schema/instance/experimental"; +import { getSchema } from "@hyperjump/json-schema/experimental"; +import * as Schema from "@hyperjump/browser"; +import * as JsonPointer from "@hyperjump/json-pointer"; import { getErrors } from "../error-handling.js"; /** + * @import { ContainsConstraints } from "../localization.js" * @import { ErrorHandler, ErrorObject, NormalizedOutput } from "../index.d.ts" */ @@ -11,8 +15,26 @@ const contains = async (normalizedErrors, instance, localization) => { const errors = []; if (normalizedErrors["https://json-schema.org/keyword/contains"]) { for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/contains"]) { + const position = schemaLocation.lastIndexOf("/"); + const parentLocation = schemaLocation.slice(0, position); + + /** @type ContainsConstraints */ + const containsConstraints = {}; + const minContainsLocation = JsonPointer.append("minContains", parentLocation); + const minContainsNode = await getSchema(minContainsLocation); + /** @type number */ + containsConstraints.minContains = Schema.value(minContainsNode) ?? 1; + + const maxContainsLocation = JsonPointer.append("maxContains", parentLocation); + const maxContainsNode = await getSchema(maxContainsLocation); + /** @type number */ + const maxContains = Schema.value(maxContainsNode); + if (maxContains !== undefined) { + containsConstraints.maxContains = maxContains; + } + errors.push({ - message: localization.getContainsErrorMessage(), + message: localization.getContainsErrorMessage(containsConstraints), instanceLocation: Instance.uri(instance), schemaLocation: schemaLocation }); diff --git a/src/error-handlers/maxLength.js b/src/error-handlers/maxLength.js deleted file mode 100644 index dc178f0..0000000 --- a/src/error-handlers/maxLength.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 maxLength = async (normalizedErrors, instance, localization) => { - /** @type ErrorObject[] */ - const errors = []; - - if (normalizedErrors["https://json-schema.org/keyword/maxLength"]) { - for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/maxLength"]) { - if (!normalizedErrors["https://json-schema.org/keyword/maxLength"][schemaLocation]) { - const keyword = await getSchema(schemaLocation); - errors.push({ - message: localization.getMaxLengthErrorMessage(Schema.value(keyword)), - instanceLocation: Instance.uri(instance), - schemaLocation: schemaLocation - }); - } - } - } - - return errors; -}; - -export default maxLength; diff --git a/src/error-handlers/minLength.js b/src/error-handlers/minLength.js deleted file mode 100644 index 83ebc51..0000000 --- a/src/error-handlers/minLength.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 minLength = async (normalizedErrors, instance, localization) => { - /** @type ErrorObject[] */ - const errors = []; - - if (normalizedErrors["https://json-schema.org/keyword/minLength"]) { - for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/minLength"]) { - if (!normalizedErrors["https://json-schema.org/keyword/minLength"][schemaLocation]) { - const keyword = await getSchema(schemaLocation); - errors.push({ - message: localization.getMinLengthErrorMessage(Schema.value(keyword)), - instanceLocation: Instance.uri(instance), - schemaLocation: schemaLocation - }); - } - } - } - - return errors; -}; - -export default minLength; diff --git a/src/error-handlers/string-handler.js b/src/error-handlers/string-handler.js new file mode 100644 index 0000000..8428bd8 --- /dev/null +++ b/src/error-handlers/string-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 { StringConstraints } from "../localization.js" + * @import { ErrorHandler } from "../index.d.ts" + */ + +/** @type ErrorHandler */ +const stringHandler = async (normalizedErrors, instance, localization) => { + /** @type StringConstraints */ + const constraints = {}; + + /** @type string[] */ + const failedSchemaLocations = []; + + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/minLength"]) { + if (!normalizedErrors["https://json-schema.org/keyword/minLength"][schemaLocation]) { + failedSchemaLocations.push(schemaLocation); + } + + const keyword = await getSchema(schemaLocation); + /** @type number */ + const minLength = Schema.value(keyword); + constraints.minLength = Math.max(constraints.minLength ?? Number.MIN_VALUE, minLength); + } + + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/maxLength"]) { + if (!normalizedErrors["https://json-schema.org/keyword/maxLength"][schemaLocation]) { + failedSchemaLocations.push(schemaLocation); + } + + const keyword = await getSchema(schemaLocation); + /** @type number */ + const maxLength = Schema.value(keyword); + constraints.maxLength = Math.min(constraints.maxLength ?? Number.MAX_VALUE, maxLength); + } + + if (failedSchemaLocations.length > 0) { + return [ + { + message: localization.getStringErrorMessage(constraints), + instanceLocation: Instance.uri(instance), + schemaLocation: failedSchemaLocations.length > 1 ? failedSchemaLocations : failedSchemaLocations[0] + } + ]; + } + + return []; +}; + +export default stringHandler; diff --git a/src/index.js b/src/index.js index e10be68..2b216df 100644 --- a/src/index.js +++ b/src/index.js @@ -55,15 +55,14 @@ 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 minLengthErrorHandler from "./error-handlers/minLength.js"; import multipleOfErrorHandler from "./error-handlers/multipleOf.js"; import notErrorHandler from "./error-handlers/not.js"; import numberRangeHandler from "./error-handlers/number-range-handler.js"; -import patternErrorHandler from "./error-handlers/pattern.js"; import requiredErrorHandler from "./error-handlers/required.js"; import typeErrorHandler from "./error-handlers/type.js"; import uniqueItemsErrorHandler from "./error-handlers/uniqueItems.js"; -import maxLengthErrorHandler from "./error-handlers/maxLength.js"; +import stringErrorHandler from "./error-handlers/string-handler.js"; +import patternErrorHandler from "./error-handlers/pattern.js"; /** * @import { betterJsonSchemaErrors } from "./index.d.ts" @@ -127,15 +126,14 @@ addErrorHandler(maxItemsErrorHandler); addErrorHandler(minItemsErrorHandler); addErrorHandler(maxPropertiesErrorHandler); addErrorHandler(minPropertiesErrorHandler); -addErrorHandler(minLengthErrorHandler); -addErrorHandler(maxLengthErrorHandler); addErrorHandler(multipleOfErrorHandler); addErrorHandler(notErrorHandler); addErrorHandler(numberRangeHandler); -addErrorHandler(patternErrorHandler); addErrorHandler(requiredErrorHandler); addErrorHandler(typeErrorHandler); addErrorHandler(uniqueItemsErrorHandler); +addErrorHandler(stringErrorHandler); +addErrorHandler(patternErrorHandler); 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 10d390a..7c2240c 100644 --- a/src/keyword-error-message.test.js +++ b/src/keyword-error-message.test.js @@ -39,7 +39,7 @@ describe("Error messages", async () => { expect(result.errors).to.eql([{ schemaLocation: "https://example.com/main#/minLength", instanceLocation: "#", - message: localization.getMinLengthErrorMessage(3) + message: localization.getStringErrorMessage({ minLength: 3 }) }]); }); @@ -66,7 +66,7 @@ describe("Error messages", async () => { expect(result.errors).to.eql([{ schemaLocation: "https://example.com/main#/maxLength", instanceLocation: "#", - message: localization.getMaxLengthErrorMessage(3) + message: localization.getStringErrorMessage({ maxLength: 3 }) }]); }); @@ -822,7 +822,7 @@ describe("Error messages", async () => { { schemaLocation: `https://example.com/main#/anyOf/0/minLength`, instanceLocation: "#", - message: localization.getMinLengthErrorMessage(5) + message: localization.getStringErrorMessage({ minLength: 5 }) } ]); }); @@ -1032,13 +1032,139 @@ describe("Error messages", async () => { }); test("normalized output for a failing 'contains' keyword", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + contains: { + type: "number", + multipleOf: 2 + } + }, schemaUri); + const instance = ["", 3, 5]; + const output = { + valid: false, + errors: [ + { + valid: false, + keywordLocation: "/contains", + instanceLocation: "#", + absoluteKeywordLocation: "https://example.com/main#/contains", + errors: [ + { + valid: false, + instanceLocation: "#/0", + absoluteKeywordLocation: "https://example.com/main#/contains/type" + }, + { + valid: false, + instanceLocation: "#/1", + absoluteKeywordLocation: "https://example.com/main#/contains/multipleOf" + }, + { + valid: false, + instanceLocation: "#/2", + absoluteKeywordLocation: "https://example.com/main#/contains/multipleOf" + } + ] + } + ] + }; + const result = await betterJsonSchemaErrors(output, schemaUri, instance); + expect(result.errors).to.eql([ + { + instanceLocation: "#", + message: localization.getContainsErrorMessage({ minContains: 1 }), + schemaLocation: "https://example.com/main#/contains" + }, + { + instanceLocation: "#/0", + message: localization.getTypeErrorMessage("number", "string"), + schemaLocation: "https://example.com/main#/contains/type" + }, + { + instanceLocation: "#/1", + message: localization.getMultipleOfErrorMessage(2), + schemaLocation: "https://example.com/main#/contains/multipleOf" + }, + { + instanceLocation: "#/2", + message: localization.getMultipleOfErrorMessage(2), + schemaLocation: "https://example.com/main#/contains/multipleOf" + } + ]); + }); + + test("normalized output for a failing 'contains' keyword with only minContains", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + contains: { + type: "number", + multipleOf: 2 + }, + minContains: 2 + }, schemaUri); + const instance = ["", 3, 5]; + const output = { + valid: false, + errors: [ + { + valid: false, + keywordLocation: "/contains", + instanceLocation: "#", + absoluteKeywordLocation: "https://example.com/main#/contains", + errors: [ + { + valid: false, + instanceLocation: "#/0", + absoluteKeywordLocation: "https://example.com/main#/contains/type" + }, + { + valid: false, + instanceLocation: "#/1", + absoluteKeywordLocation: "https://example.com/main#/contains/multipleOf" + }, + { + valid: false, + instanceLocation: "#/2", + absoluteKeywordLocation: "https://example.com/main#/contains/multipleOf" + } + ] + } + ] + }; + const result = await betterJsonSchemaErrors(output, schemaUri, instance); + expect(result.errors).to.eql([ + { + instanceLocation: "#", + message: localization.getContainsErrorMessage({ minContains: 2 }), + schemaLocation: "https://example.com/main#/contains" + }, + { + instanceLocation: "#/0", + message: localization.getTypeErrorMessage("number", "string"), + schemaLocation: "https://example.com/main#/contains/type" + }, + { + instanceLocation: "#/1", + message: localization.getMultipleOfErrorMessage(2), + schemaLocation: "https://example.com/main#/contains/multipleOf" + }, + { + instanceLocation: "#/2", + message: localization.getMultipleOfErrorMessage(2), + schemaLocation: "https://example.com/main#/contains/multipleOf" + } + ]); + }); + + test("`contains` with `minContains` and `maxContains` keyword", async () => { registerSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", contains: { type: "number", multipleOf: 2 }, - minContains: 1 + minContains: 2, + maxContains: 4 }, schemaUri); const instance = ["", 3, 5]; const output = { @@ -1073,7 +1199,7 @@ describe("Error messages", async () => { expect(result.errors).to.eql([ { instanceLocation: "#", - message: localization.getContainsErrorMessage(), + message: localization.getContainsErrorMessage({ minContains: 2, maxContains: 4 }), schemaLocation: "https://example.com/main#/contains" }, { @@ -1371,4 +1497,41 @@ describe("Error messages", async () => { } ]); }); + + test("minLength/maxLength and pattern test", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + allOf: [ + { minLength: 3 }, + { maxLength: 5 } + ] + }, schemaUri); + + const instance = "AAAAAAA"; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/allOf/0/minLength", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/allOf/1/maxLength", + instanceLocation: "#" + } + ] + }; + + const result = await betterJsonSchemaErrors(output, schemaUri, instance); + expect(result.errors).to.eql([{ + schemaLocation: [ + "https://example.com/main#/allOf/0/minLength", + "https://example.com/main#/allOf/1/maxLength" + ], + instanceLocation: "#", + message: localization.getStringErrorMessage({ minLength: 3, maxLength: 5 }) + }]); + }); }); diff --git a/src/localization.js b/src/localization.js index 7093587..4355b98 100644 --- a/src/localization.js +++ b/src/localization.js @@ -14,6 +14,20 @@ import { FluentBundle, FluentResource } from "@fluent/bundle"; * }} NumberConstraints */ +/** + * @typedef {{ + * minLength?: number; + * maxLength?: number; + * }} StringConstraints + */ + +/** + * @typedef {{ + * maxContains?: number; + * minContains: number; + * }} ContainsConstraints + */ + export class Localization { /** * @param {string} locale @@ -65,16 +79,6 @@ export class Localization { }); } - /** @type (limit: number) => string */ - getMinLengthErrorMessage(limit) { - return this._formatMessage("min-length-error", { limit }); - } - - /** @type (limit: number) => string */ - getMaxLengthErrorMessage(limit) { - return this._formatMessage("max-length-error", { limit }); - } - /** @type (constraints: NumberConstraints) => string */ getNumberErrorMessage(constraints) { /** @type string[] */ @@ -101,6 +105,24 @@ export class Localization { }); } + /** @type (constraints: StringConstraints) => string */ + getStringErrorMessage(constraints) { + /** @type string[] */ + const messages = []; + + if (constraints.minLength) { + messages.push(this._formatMessage("string-error-minLength", constraints)); + } + + if (constraints.maxLength) { + messages.push(this._formatMessage("string-error-maxLength", constraints)); + } + + return this._formatMessage("string-error", { + constraints: new Intl.ListFormat(this.locale, { type: "conjunction" }).format(messages) + }); + } + /** @type (instanceLocation: string, missingProperties: string[]) => string */ getRequiredErrorMessage(instanceLocation, missingProperties) { return this._formatMessage("required-error", { @@ -154,9 +176,13 @@ export class Localization { return this._formatMessage("pattern-error", { pattern }); } - /** @type () => string */ - getContainsErrorMessage() { - return this._formatMessage("contains-error"); + /** @type (constraints: ContainsConstraints) => string */ + getContainsErrorMessage(constraints) { + if (constraints.maxContains) { + return this._formatMessage("contains-error-min-max", constraints); + } else { + return this._formatMessage("contains-error-min", constraints); + } } /** @type () => string */ diff --git a/src/normalized-output.test.js b/src/normalized-output.test.js index e15d9b5..7ca583b 100644 --- a/src/normalized-output.test.js +++ b/src/normalized-output.test.js @@ -41,7 +41,7 @@ describe("Error Output Normalization", async () => { expect(result.errors).to.eql([{ schemaLocation: "https://example.com/main#/minLength", instanceLocation: "#", - message: localization.getMinLengthErrorMessage(3) + message: localization.getStringErrorMessage({ minLength: 3 }) } ]); }); @@ -69,7 +69,7 @@ describe("Error Output Normalization", async () => { expect(result.errors).to.eql([{ schemaLocation: "https://example.com/main#/minLength", instanceLocation: "#", - message: localization.getMinLengthErrorMessage(3) + message: localization.getStringErrorMessage({ minLength: 3 }) }]); }); @@ -96,7 +96,7 @@ describe("Error Output Normalization", async () => { expect(result.errors).to.eql([{ schemaLocation: "https://example.com/main#/minLength", instanceLocation: "#", - message: localization.getMinLengthErrorMessage(3) + message: localization.getStringErrorMessage({ minLength: 3 }) }]); }); @@ -370,7 +370,7 @@ describe("Error Output Normalization", async () => { { schemaLocation: "https://example.com/main#/$defs/lengthDefinition/minLength", instanceLocation: "#/foo", - message: localization.getMinLengthErrorMessage(3) + message: localization.getStringErrorMessage({ minLength: 3 }) } ]); }); diff --git a/src/translations/en-US.ftl b/src/translations/en-US.ftl index cc4c136..5fc886f 100644 --- a/src/translations/en-US.ftl +++ b/src/translations/en-US.ftl @@ -1,11 +1,13 @@ type-error = The instance should be of type {$expected} but found {$actual}. -min-length-error = The instance should be atleast {$limit} characters. -max-length-error = The instance should be atmost {$limit} characters long. + +string-error = Expected a string {$constraints}. +string-error-minLength = at least {$minLength} characters long +string-error-maxLength = at most {$maxLength} characters long number-error = Expected a number {$constraints}. number-error-minimum = greater than {$minimum} number-error-exclusive-minimum = greater than or equal to {$minimum} -number-error-maximum = greater than {$maximum} +number-error-maximum = less than {$maximum} number-error-exclusive-maximum = less than or equal to {$maximum} required-error = "{$instanceLocation}" is missing required property(s): {$missingProperties}. @@ -18,7 +20,16 @@ min-items-error = The instance should contain a minimum of {$limit} items in the 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}. -contains-error = A required value is missing from the list. + +contains-error-min = The array must contain at least {$minContains -> + [one] item that passes + *[other] items that pass +} the 'contains' schema. +contains-error-min-max = The array must contain at least {$minContains} and at most {$maxContains -> + [one] item that passes + *[other] items that pass +} the 'contains' schema. + not-error = The instance is not allowed to be used in this schema. additional-properties-error = The property "{$propertyName}" is not allowed. dependent-required-error = Property "{$property}" requires property(s): {$missingDependents}.