diff --git a/README.md b/README.md index 911e969..449ff44 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ BetterJSONSchemaErrors Output:- ``` Instead of 2 error message it manages to give a single concise error message. For details, see the dedicated [Range documenetation](./documentation/range-handler.md) -### 6. Custom Keywords and Error Handlers +### 7. Custom Keywords and Error Handlers In order to create the custom keywords and error handlers we need to create and register two types of handlers: **Normalization Handler** and **Error Handlers**. diff --git a/src/error-handlers/anyOf.js b/src/error-handlers/anyOf.js index 6c57fa6..7550250 100644 --- a/src/error-handlers/anyOf.js +++ b/src/error-handlers/anyOf.js @@ -3,9 +3,10 @@ import * as Instance from "@hyperjump/json-schema/instance/experimental"; import * as Schema from "@hyperjump/browser"; import * as JsonPointer from "@hyperjump/json-pointer"; import { getErrors } from "../error-handling.js"; +import { getSchemaDescription } from "../schema-descriptions.js"; /** - * @import { ErrorHandler, ErrorObject, Json, NormalizedOutput } from "../index.d.ts" + * @import { ErrorHandler, ErrorObject, Json, NormalizedOutput, InstanceOutput } from "../index.d.ts" */ /** @type ErrorHandler */ @@ -19,7 +20,6 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { if (typeof allAlternatives === "boolean") { continue; } - /** @type NormalizedOutput[] */ const alternatives = []; for (const alternative of allAlternatives) { @@ -33,7 +33,6 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { const isConstValid = schemaErrors["https://json-schema.org/keyword/const"] ? Object.values(schemaErrors["https://json-schema.org/keyword/const"] ?? {}).every((valid) => valid) : undefined; - if (isTypeValid === true || isEnumValid === true || isConstValid === true) { alternatives.push(alternative); } @@ -46,7 +45,7 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { // No alternative matched the type/enum/const of the instance. if (alternatives.length === 0) { /** @type Set */ - const expectedTypes = new Set(); + let expectedTypes = new Set(); /** @type Set */ const expectedEnums = new Set(); @@ -54,11 +53,23 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { for (const alternative of allAlternatives) { for (const instanceLocation in alternative) { if (instanceLocation === Instance.uri(instance)) { + let alternativeTypes = new Set(["null", "boolean", "number", "string", "array", "object"]); for (const schemaLocation in alternative[instanceLocation]["https://json-schema.org/keyword/type"]) { const keyword = await getSchema(schemaLocation); - const expectedType = /** @type string */ (Schema.value(keyword)); - expectedTypes.add(expectedType); + if (Schema.typeOf(keyword) === "array") { + const expectedTypes = /** @type string[] */ (Schema.value(keyword)); + alternativeTypes = alternativeTypes.intersection(new Set(expectedTypes)); + } else { + const expectedType = /** @type string */ (Schema.value(keyword)); + alternativeTypes = alternativeTypes.intersection(new Set([expectedType])); + } + } + + // The are 6 types. If all types are allowed, don't use expectedTypes + if (alternativeTypes.size !== 6) { + expectedTypes = expectedTypes.union(alternativeTypes); } + for (const schemaLocation in alternative[instanceLocation]["https://json-schema.org/keyword/enum"]) { const keyword = await getSchema(schemaLocation); const enums = /** @type Json[] */ (Schema.value(keyword)); @@ -74,7 +85,6 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { } } } - errors.push({ message: localization.getEnumErrorMessage({ allowedValues: [...expectedEnums], @@ -96,7 +106,6 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { const definedProperties = allAlternatives.map((alternative) => { /** @type Set */ const alternativeProperties = new Set(); - for (const instanceLocation in alternative) { const pointer = instanceLocation.slice(Instance.uri(instance).length + 1); if (pointer.length > 0) { @@ -106,69 +115,86 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { alternativeProperties.add(location); } } - return alternativeProperties; }); - const discriminator = definedProperties.reduce((acc, properties) => { - return acc.intersection(properties); - }, definedProperties[0]); - const discriminatedAlternatives = alternatives.filter((alternative) => { - for (const instanceLocation in alternative) { - if (!discriminator.has(instanceLocation)) { - continue; - } + const anyPropertiesDefined = definedProperties.some((propSet) => propSet.size > 0); - let valid = true; - for (const keyword in alternative[instanceLocation]) { - for (const schemaLocation in alternative[instanceLocation][keyword]) { - if (alternative[instanceLocation][keyword][schemaLocation] !== true) { - valid = false; - break; + if (anyPropertiesDefined) { + const discriminator = definedProperties.reduce((acc, properties) => { + return acc.intersection(properties); + }, definedProperties[0]); + const discriminatedAlternatives = alternatives.filter((alternative) => { + for (const instanceLocation in alternative) { + if (!discriminator.has(instanceLocation)) { + continue; + } + let valid = true; + for (const keyword in alternative[instanceLocation]) { + for (const schemaLocation in alternative[instanceLocation][keyword]) { + if (alternative[instanceLocation][keyword][schemaLocation] !== true) { + valid = false; + break; + } } } + if (valid) { + return true; + } } - if (valid) { - return true; - } + return false; + }); + // Discriminator match + if (discriminatedAlternatives.length === 1) { + errors.push(...await getErrors(discriminatedAlternatives[0], instance, localization)); + continue; + } + // Discriminator identified, but none of the alternatives match + if (discriminatedAlternatives.length === 0) { + // TODO: For now, it will use the schema description strategy } - return false; - }); - // Discriminator match - if (discriminatedAlternatives.length === 1) { - errors.push(...await getErrors(discriminatedAlternatives[0], instance, localization)); + // Last resort, select the alternative with the most properties matching the instance + const instanceProperties = new Set(Instance.values(instance).map((node) => Instance.uri(node))); + let maxMatches = -1; + let selectedIndex = 0; + let index = -1; + for (const alternativeProperties of definedProperties) { + index++; + const matches = alternativeProperties.intersection(instanceProperties).size; + if (matches > maxMatches) { + selectedIndex = index; + } + } + errors.push(...await getErrors(alternatives[selectedIndex], instance, localization)); continue; } + } - // Discriminator identified, but none of the alternatives match - if (discriminatedAlternatives.length === 0) { - // TODO: How do we handle this case? - } + // TODO: Handle alternatives without a type - // Last resort, select the alternative with the most properties matching the instance - // TODO: We shouldn't use this strategy if alternatives have the same number of matching instances - const instanceProperties = new Set(Instance.values(instance) - .map((node) => Instance.uri(node))); - let maxMatches = -1; - let selectedIndex = 0; - let index = -1; - for (const alternativeProperties of definedProperties) { - index++; - const matches = alternativeProperties.intersection(instanceProperties).size; - if (matches > maxMatches) { - selectedIndex = index; - } + /** @type string[] */ + const descriptions = []; + let allAlternativesHaveDescriptions = true; + for (const alternative of alternatives) { + const description = await getSchemaDescription(normalizedErrors, alternative[Instance.uri(instance)], localization); + if (description !== undefined) { + descriptions.push(description); + } else { + allAlternativesHaveDescriptions = false; + break; } + } - errors.push(...await getErrors(alternatives[selectedIndex], instance, localization)); + if (allAlternativesHaveDescriptions) { + errors.push({ + message: localization.getAnyOfBulletsErrorMessage(descriptions), + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); continue; } - // TODO: Handle string alternatives - // TODO: Handle array alternatives - // TODO: Handle alternatives without a type - // TODO: If we get here, we don't know what else to do and give a very generic message // Ideally this should be replace by something that can handle whatever case is missing. errors.push({ diff --git a/src/keyword-error-message.test.js b/src/keyword-error-message.test.js index 64349b2..f51e197 100644 --- a/src/keyword-error-message.test.js +++ b/src/keyword-error-message.test.js @@ -2,6 +2,7 @@ import { afterEach, describe, test, expect } from "vitest"; import { betterJsonSchemaErrors } from "./index.js"; import { registerSchema } from "@hyperjump/json-schema/draft-2020-12"; import { unregisterSchema } from "@hyperjump/json-schema"; +import { getSchemaDescription } from "./schema-descriptions.js"; import { Localization } from "./localization.js"; /** @@ -1328,6 +1329,419 @@ describe("Error messages", async () => { ]); }); + test("anyOf with type arrays", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + anyOf: [ + { type: ["string", "number"] }, + { type: "boolean" } + ] + }, schemaUri); + + const instance = null; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/0/type", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/1/type", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf", + instanceLocation: "#" + } + ] + }; + + const result = await betterJsonSchemaErrors(output, schemaUri, instance); + + expect(result.errors).to.eql([ + { + schemaLocation: "https://example.com/main#/anyOf", + instanceLocation: "#", + message: localization.getEnumErrorMessage({ allowedTypes: ["string", "number", "boolean"] }, null) + } + ]); + }); + + test("anyOf with string alternatives", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "string", + anyOf: [ + { minLength: 5 }, + { maxLength: 2 }, + { pattern: "^[a-z]*$" } + ] + }, schemaUri); + + const instance = "AAA"; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/0/minLength", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/1/maxLength", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/2/pattern", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf", + instanceLocation: "#" + } + ] + }; + + const result = await betterJsonSchemaErrors(output, schemaUri, instance); + + expect(result.errors).to.eql([ + { + schemaLocation: "https://example.com/main#/anyOf", + instanceLocation: "#", + message: localization.getAnyOfBulletsErrorMessage([ + /** @type string */ (await getSchemaDescription({ + ["https://json-schema.org/keyword/type"]: { + ["https://example.com/main#/type"]: true + } + }, + { + ["https://json-schema.org/keyword/minLength"]: { + ["https://example.com/main#/anyOf/0/minLength"]: false + } + }, localization)), + /** @type string */ (await getSchemaDescription({ + ["https://json-schema.org/keyword/type"]: { + ["https://example.com/main#/type"]: true + } + }, + { + ["https://json-schema.org/keyword/maxLength"]: { + ["https://example.com/main#/anyOf/1/maxLength"]: false + } + }, localization)), + /** @type string */ (await getSchemaDescription({ + ["https://json-schema.org/keyword/type"]: { + ["https://example.com/main#/type"]: true + } + }, + { + ["https://json-schema.org/keyword/pattern"]: { + ["https://example.com/main#/anyOf/2/pattern"]: false + } + }, localization)) + ]) + } + ]); + }); + + test("anyOf with number alternatives", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "number", + anyOf: [ + { minimum: 5 }, + { maximum: 2 }, + { multipleOf: 2 } + ] + }, schemaUri); + + const instance = 3; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/0/minimum", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/1/maximum", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/2/multipleOf", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf", + instanceLocation: "#" + } + ] + }; + + const result = await betterJsonSchemaErrors(output, schemaUri, instance); + + expect(result.errors).to.eql([ + { + schemaLocation: "https://example.com/main#/anyOf", + instanceLocation: "#", + message: localization.getAnyOfBulletsErrorMessage([ + /** @type string */ (await getSchemaDescription({ + ["https://json-schema.org/keyword/type"]: { + ["https://example.com/main#/type"]: true + } + }, + { + ["https://json-schema.org/keyword/minimum"]: { + ["https://example.com/main#/anyOf/0/minimum"]: false + } + }, localization)), + /** @type string */ (await getSchemaDescription({ + ["https://json-schema.org/keyword/type"]: { + ["https://example.com/main#/type"]: true + } + }, + { + ["https://json-schema.org/keyword/maximum"]: { + ["https://example.com/main#/anyOf/1/maximum"]: false + } + }, localization)), + /** @type string */ (await getSchemaDescription({ + ["https://json-schema.org/keyword/type"]: { + ["https://example.com/main#/type"]: true + } + }, + { + ["https://json-schema.org/keyword/multipleOf"]: { + ["https://example.com/main#/anyOf/2/multipleOf"]: false + } + }, localization)) + ]) + } + ]); + }); + + test("anyOf with object alternatives)", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + anyOf: [ + { minProperties: 3 }, + { maxProperties: 1 } + ] + }, schemaUri); + + const instance = { a: 1, b: 2 }; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/0/minProperties", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/1/maxProperties", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf", + instanceLocation: "#" + } + ] + }; + const result = await betterJsonSchemaErrors(output, schemaUri, instance); + expect(result.errors).to.eql([ + { + schemaLocation: "https://example.com/main#/anyOf", + instanceLocation: "#", + message: localization.getAnyOfBulletsErrorMessage([ + /** @type string */ (await getSchemaDescription({ + ["https://json-schema.org/keyword/type"]: { + ["https://example.com/main#/type"]: true + } + }, + { + ["https://json-schema.org/keyword/minProperties"]: { + ["https://example.com/main#/anyOf/0/minProperties"]: false + } + }, localization)), + /** @type string */ (await getSchemaDescription({ + ["https://json-schema.org/keyword/type"]: { + ["https://example.com/main#/type"]: true + } + }, + { + ["https://json-schema.org/keyword/maxProperties"]: { + ["https://example.com/main#/anyOf/1/maxProperties"]: false + } + }, localization)) + ]) + } + ]); + }); + + test("anyOf array alternatives", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "array", + anyOf: [ + { minItems: 3 }, + { maxItems: 1 } + ] + }, schemaUri); + + const instance = [1, 2]; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/0/minItems", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/1/maxItems", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf", + instanceLocation: "#" + } + ] + }; + + const result = await betterJsonSchemaErrors(output, schemaUri, instance); + expect(result.errors).to.eql([ + { + schemaLocation: "https://example.com/main#/anyOf", + instanceLocation: "#", + message: localization.getAnyOfBulletsErrorMessage([ + /** @type string */ (await getSchemaDescription( + { + ["https://json-schema.org/keyword/type"]: { + ["https://example.com/main#/type"]: true + } + }, + { + ["https://json-schema.org/keyword/minItems"]: { + ["https://example.com/main#/anyOf/0/minItems"]: false + } + }, + localization + )), + /** @type string */ (await getSchemaDescription( + { + ["https://json-schema.org/keyword/type"]: { + ["https://example.com/main#/type"]: true + } + }, + { + ["https://json-schema.org/keyword/maxItems"]: { + ["https://example.com/main#/anyOf/1/maxItems"]: false + } + }, + localization + )) + ]) + } + ]); + }); + + test("conflicting type error", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + anyOf: [ + { + allOf: [ + { type: "string" }, + { type: "boolean" } + ] + }, + { type: "null" } + ] + }, schemaUri); + + const instance = "aaa"; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/anyOf", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/0/allOf/0/type", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/0/allOf/1/type", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/1/type", + instanceLocation: "#" + } + ] + }; + + const result = await betterJsonSchemaErrors(output, schemaUri, instance); + + expect(result.errors).to.eql([ + { + schemaLocation: "https://example.com/main#/anyOf", + instanceLocation: "#", + message: localization.getEnumErrorMessage({ allowedTypes: ["null"] }, "aaa") + } + ]); + }); + + test.skip("allOf conflicting type", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + allOf: [ + { type: "string" }, + { type: "boolean" } + ] + }, schemaUri); + + const instance = "aaa"; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/allOf/0/type", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/allOf/1/type", + instanceLocation: "#" + } + ] + }; + + const result = await betterJsonSchemaErrors(output, schemaUri, instance); + + expect(result.errors).to.eql([ + { + schemaLocation: "https://example.com/main#/allOf/0/type", + instanceLocation: "#", + message: localization.getConflictingTypeMessage() + } + ]); + }); + test("normalized output for a failing 'contains' keyword", async () => { registerSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", diff --git a/src/localization.js b/src/localization.js index 025b6c7..e973bd2 100644 --- a/src/localization.js +++ b/src/localization.js @@ -13,6 +13,7 @@ import leven from "leven"; * exclusiveMinimum?: boolean; * maximum?: number; * exclusiveMaximum?: boolean; + * multipleOf?: number; * }} NumberConstraints */ @@ -20,6 +21,7 @@ import leven from "leven"; * @typedef {{ * minLength?: number; * maxLength?: number; + * pattern? : string; * }} StringConstraints */ @@ -239,8 +241,8 @@ export class Localization { * @returns {string} */ getEnumErrorMessage(constraints, currentValue) { - /** @type {"suggestion" | "types" | "values" | "both"} */ - let variant = "suggestion"; + /** @type {"types" | "values" | "both"} */ + let variant = "both"; /** @type string */ let allowedValues = ""; @@ -250,7 +252,7 @@ export class Localization { const instanceValue = JSON.stringify(currentValue); - if (constraints.allowedValues && constraints.allowedValues.length > 0 && constraints.allowedTypes?.length === 0) { + if (constraints.allowedValues && constraints.allowedValues.length > 0 && !constraints.allowedTypes?.length) { const bestMatch = constraints.allowedValues .map((value) => { const r = { @@ -262,8 +264,7 @@ export class Localization { .sort((a, b) => a.weight - b.weight)[0]; if (constraints.allowedValues.length === 1 || (bestMatch && bestMatch.weight < bestMatch.value.length)) { - return this._formatMessage("enum-error", { - variant: "suggestion", + return this._formatMessage("enum-error-suggestion", { suggestion: bestMatch.value, instanceValue }); @@ -279,8 +280,7 @@ export class Localization { expectedTypes = new Intl.ListFormat(this.locale, { type: "disjunction" }) .format(constraints.allowedTypes.map((value) => JSON.stringify(value))); } - - return this._formatMessage("enum-error", { + return this._formatMessage("enum-error-types-values", { variant, allowedValues, expectedTypes, @@ -288,8 +288,114 @@ export class Localization { }); } + /** @type (descriptions: string[]) => string */ + getAnyOfBulletsErrorMessage(descriptions) { + const constraints = "\n - " + descriptions.join("\n - "); + return this._formatMessage("anyOf-error-bullets", { constraints }); + } + /** @type () => string */ getAnyOfErrorMessage() { return this._formatMessage("anyOf-error"); } + + getNullDescription() { + return this._formatMessage("null-description"); + } + + getBooleanDescription() { + return this._formatMessage("boolean-description"); + } + + /** @type (constraints: NumberConstraints) => string */ + getNumberDescription(constraints) { + /** @type string[] */ + const messages = []; + + if (constraints.minimum !== undefined) { + if (constraints.exclusiveMinimum) { + messages.push(this._formatMessage("number-error-exclusive-minimum", constraints)); + } else { + messages.push(this._formatMessage("number-error-minimum", constraints)); + } + } + + if (constraints.maximum !== undefined) { + if (constraints.exclusiveMaximum) { + messages.push(this._formatMessage("number-error-exclusive-maximum", constraints)); + } else { + messages.push(this._formatMessage("number-error-maximum", constraints)); + } + } + + if (constraints.multipleOf) { + messages.push(this._formatMessage("number-error-multiple-of", constraints)); + } + + return this._formatMessage("number-description", { + constraints: new Intl.ListFormat(this.locale).format(messages) + }); + } + + /** @type (constraints: StringConstraints) => string */ + getStringDescription(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)); + } + + if (constraints.pattern) { + messages.push(this._formatMessage("string-error-pattern", constraints)); + } + + return this._formatMessage("string-description", { + constraints: new Intl.ListFormat(this.locale).format(messages) + }); + } + + /** @type (constraints: ArrayConstraints) => string */ + getArrayDescription(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-description", { + constraints: new Intl.ListFormat(this.locale).format(messages) + }); + } + + /** @type (constraints: PropertiesConstraints) => string */ + getObjectDescription(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("object-description", { + constraints: new Intl.ListFormat(this.locale).format(messages) + }); + } + + getConflictingTypeMessage() { + return this._formatMessage("conflicting-message"); + } } diff --git a/src/schema-descriptions.js b/src/schema-descriptions.js new file mode 100644 index 0000000..8a0d7af --- /dev/null +++ b/src/schema-descriptions.js @@ -0,0 +1,161 @@ +import { getSchema } from "@hyperjump/json-schema/experimental"; +import * as Schema from "@hyperjump/browser"; +/** + * @import { NormalizedOutput, InstanceOutput } from "./index.d.ts"; + * @import { Localization, StringConstraints, NumberConstraints, ArrayConstraints, PropertiesConstraints } from "./localization.js" + * + */ + +/** @type (instanceErrors: InstanceOutput, alternativeErrors: InstanceOutput, localization: Localization) => Promise */ +export const getSchemaDescription = async (instanceErrors, alternativeErrors, localization) => { + let types = new Set(["null", "boolean", "number", "string", "array", "object"]); + + for (const schemaLocation in instanceErrors["https://json-schema.org/keyword/type"]) { + const typeNode = await getSchema(schemaLocation); + /** @type Set */ + let keywordTypes; + if (Schema.typeOf(typeNode) === "array") { + keywordTypes = new Set(/** @type string[] */ (Schema.value(typeNode))); + } else { + keywordTypes = new Set([/** @type string */(Schema.value(typeNode))]); + } + + types = types.intersection(keywordTypes); + } + + for (const schemaLocation in alternativeErrors["https://json-schema.org/keyword/type"]) { + const typeNode = await getSchema(schemaLocation); + /** @type Set */ + let keywordTypes; + if (Schema.typeOf(typeNode) === "array") { + keywordTypes = new Set(/** @type string[] */ (Schema.value(typeNode))); + } else { + keywordTypes = new Set([/** @type string */(Schema.value(typeNode))]); + } + + types = types.intersection(keywordTypes); + } + + if (types.size > 1) { + return undefined; + } + + // The schema has conflicting types { "allOf": [{ "type": "boolean" }, { "type": "null" }]} + if (types.size === 0) { + return localization.getConflictingTypeMessage(); + } + + switch ([...types][0]) { + case "null": + return localization.getNullDescription(); + + case "boolean": + return localization.getBooleanDescription(); + + case "number": + /** @type NumberConstraints */ + const numberConstraints = {}; + for (const schemaLocation in alternativeErrors["https://json-schema.org/keyword/minimum"]) { + const keyword = await getSchema(schemaLocation); + /** @type number */ + const minLength = Schema.value(keyword); + numberConstraints.minimum = Math.max(numberConstraints.minimum ?? Number.MIN_VALUE, minLength); + } + + for (const schemaLocation in alternativeErrors["https://json-schema.org/keyword/exculsiveMinimum"]) { + const keyword = await getSchema(schemaLocation); + /** @type number */ + const minimum = Schema.value(keyword); + numberConstraints.minimum = Math.max(numberConstraints.minimum ?? Number.MIN_VALUE, minimum); + if (numberConstraints.minimum === minimum) { + numberConstraints.exclusiveMinimum = true; + } + } + + for (const schemaLocation in alternativeErrors["https://json-schema.org/keyword/maximum"]) { + const keyword = await getSchema(schemaLocation); + /** @type number */ + const maximum = Schema.value(keyword); + numberConstraints.maximum = Math.min(numberConstraints.maximum ?? Number.MAX_VALUE, maximum); + } + + for (const schemaLocation in alternativeErrors["https://json-schema.org/keyword/exculsiveMaximum"]) { + const keyword = await getSchema(schemaLocation); + /** @type number */ + const maximum = Schema.value(keyword); + numberConstraints.minimum = Math.max(numberConstraints.minimum ?? Number.MIN_VALUE, maximum); + if (numberConstraints.minimum === maximum) { + numberConstraints.exclusiveMaximum = true; + } + } + + for (const schemaLocation in alternativeErrors["https://json-schema.org/keyword/multipleOf"]) { + const keyword = await getSchema(schemaLocation); + /** @type string */ + numberConstraints.multipleOf = Schema.value(keyword); + } + + return localization.getNumberDescription(numberConstraints); + + case "string": + /** @type StringConstraints */ + const stringConstraints = {}; + for (const schemaLocation in alternativeErrors["https://json-schema.org/keyword/minLength"]) { + const keyword = await getSchema(schemaLocation); + /** @type number */ + const minLength = Schema.value(keyword); + stringConstraints.minLength = Math.max(stringConstraints.minLength ?? Number.MIN_VALUE, minLength); + } + + for (const schemaLocation in alternativeErrors["https://json-schema.org/keyword/maxLength"]) { + const keyword = await getSchema(schemaLocation); + /** @type number */ + const maxLength = Schema.value(keyword); + stringConstraints.maxLength = Math.min(stringConstraints.maxLength ?? Number.MAX_VALUE, maxLength); + } + + for (const schemaLocation in alternativeErrors["https://json-schema.org/keyword/pattern"]) { + const keyword = await getSchema(schemaLocation); + /** @type string */ + stringConstraints.pattern = Schema.value(keyword); + } + + return localization.getStringDescription(stringConstraints); + + case "array": + /** @type ArrayConstraints */ + const arrayConstraints = {}; + for (const schemaLocation in alternativeErrors["https://json-schema.org/keyword/minItems"]) { + const keyword = await getSchema(schemaLocation); + /** @type number */ + const minItems = Schema.value(keyword); + arrayConstraints.minItems = Math.max(arrayConstraints.minItems ?? Number.MIN_VALUE, minItems); + } + + for (const schemaLocation in alternativeErrors["https://json-schema.org/keyword/maxItems"]) { + const keyword = await getSchema(schemaLocation); + /** @type number */ + const maxItems = Schema.value(keyword); + arrayConstraints.maxItems = Math.min(arrayConstraints.maxItems ?? Number.MAX_VALUE, maxItems); + } + return localization.getArrayDescription(arrayConstraints); + + case "object": + /** @type PropertiesConstraints */ + const propertiesConstraints = {}; + for (const schemaLocation in alternativeErrors["https://json-schema.org/keyword/minProperties"]) { + const keyword = await getSchema(schemaLocation); + /** @type number */ + const minProperties = Schema.value(keyword); + propertiesConstraints.minProperties = Math.max(propertiesConstraints.minProperties ?? Number.MIN_VALUE, minProperties); + } + + for (const schemaLocation in alternativeErrors["https://json-schema.org/keyword/maxProperties"]) { + const keyword = await getSchema(schemaLocation); + /** @type number */ + const maxProperties = Schema.value(keyword); + propertiesConstraints.maxProperties = Math.min(propertiesConstraints.maxProperties ?? Number.MAX_VALUE, maxProperties); + } + return localization.getObjectDescription(propertiesConstraints); + } +}; diff --git a/src/translations/en-US.ftl b/src/translations/en-US.ftl index 77c22b3..1c0887b 100644 --- a/src/translations/en-US.ftl +++ b/src/translations/en-US.ftl @@ -1,17 +1,31 @@ # Non-type specific messages type-error = The instance should be of type {$expected} but found {$actual}. +conflicting-message = Conflicting types found. A JSON value can't be more than one type at a time. const-error = The instance should be equal to {$expectedValue}. -enum-error = Unexpected value {$instanceValue}. { $variant -> +enum-error-suggestion = Unexpected value {$instanceValue}. Did you mean {$suggestion}? +enum-error-types-values = Unexpected value {$instanceValue}. { $variant -> [types] Expected a {$expectedTypes}. - [values] Expected one of: ${allowedValues}. - [both] Expected a type of {$expectedTypes}, or one of: ${allowedValues}. - [suggestion] Did you mean {$suggestion}? + [values] Expected one of: {$allowedValues}. + *[both] Expected a type of {$expectedTypes}, or one of: {$allowedValues}. } +enum-error-strings-range = The string must be at least {$minLength} or at most {$maxLength} characters. +enum-error-strings-range-pattern = The string must match the pattern {$pattern} or be at most {$maxLength} characters. +enum-error-strings-pattern-and-range = The string must match the pattern {$pattern} or have at most {$maxLength} characters or be at least {$minLength} characters. + +# Schema Descriptions +null-description = a null. +boolean-description = a boolean. +number-description = a number {$constraints} +string-description = a string {$constraints}. +array-description = an array {$constraints} +tuple-description = a tuple with {$numItems} items. +object-description = an object {$constraints} # String messages string-error = Expected a string {$constraints}. string-error-minLength = at least {$minLength} characters long string-error-maxLength = at most {$maxLength} characters long +string-error-pattern = that matches the pattern: {$pattern}. pattern-error = The instance should match the pattern: {$pattern}. format-error = The instance should match the format: {$format}. @@ -21,6 +35,7 @@ number-error-minimum = greater than {$minimum} number-error-exclusive-minimum = greater than or equal to {$minimum} number-error-maximum = less than {$maximum} number-error-exclusive-maximum = less than or equal to {$maximum} +number-error-multiple-of = multiple of {$multipleOf}. multiple-of-error = The instance should be a multiple of {$divisor}. # Object messages @@ -46,4 +61,5 @@ contains-error-min-max = The array must contain at least {$minContains} and at m # Conditional messages anyOf-error = The instance must pass at least one of the given schemas. -not-error = The instance is not allowed to be used in this schema. +anyOf-error-bullets = The value must be either of {$constraints} +not-error = The instance is not allowed to be used in this schema. \ No newline at end of file