diff --git a/src/error-handlers/anyOf.js b/src/error-handlers/anyOf.js index 14d14d0..6c57fa6 100644 --- a/src/error-handlers/anyOf.js +++ b/src/error-handlers/anyOf.js @@ -23,16 +23,34 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { /** @type NormalizedOutput[] */ const alternatives = []; for (const alternative of allAlternatives) { - if (Object.values(alternative[Instance.uri(instance)]["https://json-schema.org/keyword/type"] ?? {}).every((valid) => valid)) { + const schemaErrors = alternative[Instance.uri(instance)]; + const isTypeValid = schemaErrors["https://json-schema.org/keyword/type"] + ? Object.values(schemaErrors["https://json-schema.org/keyword/type"]).every((valid) => valid) + : undefined; + const isEnumValid = schemaErrors["https://json-schema.org/keyword/enum"] + ? Object.values(schemaErrors["https://json-schema.org/keyword/enum"] ?? {}).every((valid) => valid) + : undefined; + 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); + } + + if (isConstValid === undefined && isEnumValid === undefined && isTypeValid === undefined) { alternatives.push(alternative); } } - // No alternative matched the type of the instance. + // No alternative matched the type/enum/const of the instance. if (alternatives.length === 0) { /** @type Set */ const expectedTypes = new Set(); + /** @type Set */ + const expectedEnums = new Set(); + for (const alternative of allAlternatives) { for (const instanceLocation in alternative) { if (instanceLocation === Instance.uri(instance)) { @@ -41,12 +59,27 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { const expectedType = /** @type string */ (Schema.value(keyword)); expectedTypes.add(expectedType); } + for (const schemaLocation in alternative[instanceLocation]["https://json-schema.org/keyword/enum"]) { + const keyword = await getSchema(schemaLocation); + const enums = /** @type Json[] */ (Schema.value(keyword)); + for (const enumValue of enums) { + expectedEnums.add(enumValue); + } + } + for (const schemaLocation in alternative[instanceLocation]["https://json-schema.org/keyword/const"]) { + const keyword = await getSchema(schemaLocation); + const constValue = /** @type Json */ (Schema.value(keyword)); + expectedEnums.add(constValue); + } } } } errors.push({ - message: localization.getTypeErrorMessage([...expectedTypes], Instance.typeOf(instance)), + message: localization.getEnumErrorMessage({ + allowedValues: [...expectedEnums], + allowedTypes: [...expectedTypes] + }, Instance.value(instance)), instanceLocation: Instance.uri(instance), schemaLocation: schemaLocation }); @@ -80,7 +113,6 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { 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)) { @@ -133,10 +165,6 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { continue; } - // TODO: Handle alternatives with const - // TODO: Handle alternatives with enum - // TODO: Handle null alternatives - // TODO: Handle boolean alternatives // TODO: Handle string alternatives // TODO: Handle array alternatives // TODO: Handle alternatives without a type diff --git a/src/error-handlers/enum.js b/src/error-handlers/enum.js index e7f4e83..e888307 100644 --- a/src/error-handlers/enum.js +++ b/src/error-handlers/enum.js @@ -1,7 +1,6 @@ import { getSchema } from "@hyperjump/json-schema/experimental"; import * as Schema from "@hyperjump/browser"; import * as Instance from "@hyperjump/json-schema/instance/experimental"; -import leven from "leven"; /** * @import { ErrorHandler, ErrorObject } from "../index.d.ts" @@ -18,34 +17,11 @@ const enum_ = async (normalizedErrors, instance, localization) => { const keyword = await getSchema(schemaLocation); /** @type {Array} */ - const allowedValues = Schema.value(keyword); + let allowedValues = Schema.value(keyword); const currentValue = /** @type {string} */ (Instance.value(instance)); - const bestMatch = allowedValues - .map((value) => ({ - value, - weight: leven(value, currentValue) - })) - .sort((a, b) => a.weight - b.weight)[0]; - let message; - if ( - allowedValues.length === 1 - || (bestMatch && bestMatch.weight < bestMatch.value.length) - ) { - message = localization.getEnumErrorMessage({ - variant: "suggestion", - instanceValue: currentValue, - suggestion: bestMatch.value - }); - } else { - message = localization.getEnumErrorMessage({ - variant: "fallback", - instanceValue: currentValue, - allowedValues: allowedValues - }); - } errors.push({ - message, + message: localization.getEnumErrorMessage({ allowedValues }, currentValue), instanceLocation: Instance.uri(instance), schemaLocation: schemaLocation }); diff --git a/src/keyword-error-message.test.js b/src/keyword-error-message.test.js index c79a0b6..64349b2 100644 --- a/src/keyword-error-message.test.js +++ b/src/keyword-error-message.test.js @@ -518,7 +518,7 @@ describe("Error messages", async () => { expect(result.errors).to.eql([{ schemaLocation: "https://example.com/main#/enum", instanceLocation: "#", - message: localization.getEnumErrorMessage({ variant: "suggestion", instanceValue: "rwd", suggestion: "red" }) + message: localization.getEnumErrorMessage({ allowedValues: ["red", "green", "blue"] }, "rwd") }]); }); @@ -856,7 +856,7 @@ describe("Error messages", async () => { { schemaLocation: "https://example.com/main#/anyOf", instanceLocation: "#", - message: localization.getTypeErrorMessage(["string", "number"], "boolean") + message: localization.getEnumErrorMessage({ allowedTypes: ["string", "number"] }, false) } ]); }); @@ -989,7 +989,7 @@ describe("Error messages", async () => { const instance = { type: "d", banana: "yellow", - box: 10 + box: "" }; /** @type OutputFormat */ @@ -1163,6 +1163,171 @@ describe("Error messages", async () => { ]); }); + test("anyOf with enums provides a 'did you mean' suggestion", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + anyOf: [ + { enum: ["apple", "orange", "banana"] }, + { enum: [100, 200, 300] } + ] + }, schemaUri); + + // The instance is a typo but is clearly intended to be "apple". + const instance = "aple"; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/0/enum", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/1/enum", + 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({ allowedValues: ["apple"] }, "aple") + } + ]); + }); + + test("anyOf with const", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + anyOf: [ + { const: "a" }, + { const: 1 } + ] + }, schemaUri); + + const instance = 12; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/0/const", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/1/const", + 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({ allowedValues: ["a", 1] }, 12) + } + ]); + }); + + test("anyOf with const and enum", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + anyOf: [ + { enum: ["a", "b", "c"] }, + { const: 1 } + ] + }, schemaUri); + + const instance = 12; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/0/enum", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/1/const", + 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({ allowedValues: ["a", "b", "c", 1] }, 12) + } + ]); + }); + + test("anyOf with enum and type", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + anyOf: [ + { enum: ["a", "b", "c"] }, + { type: "number" } + ] + }, schemaUri); + + const instance = false; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/0/enum", + 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({ allowedValues: ["a", "b", "c"], allowedTypes: ["number"] }, false) + } + ]); + }); + 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 9bad051..025b6c7 100644 --- a/src/localization.js +++ b/src/localization.js @@ -1,8 +1,10 @@ import { readFile } from "node:fs/promises"; import { FluentBundle, FluentResource } from "@fluent/bundle"; +import leven from "leven"; /** * @import { FluentVariable} from "@fluent/bundle" + * @import { Json } from "./index.d.ts" */ /** @@ -42,6 +44,13 @@ import { FluentBundle, FluentResource } from "@fluent/bundle"; * }} PropertiesConstraints */ +/** + * @typedef {{ + * allowedValues?: Json[], + * allowedTypes?: string[] + * }} ValueConstraints + */ + export class Localization { /** * @param {string} locale @@ -225,39 +234,58 @@ export class Localization { } /** - * @typedef {Object} EnumSuggestionArgs - * @property {"suggestion"} variant - * @property {string} instanceValue - * @property {string} suggestion + * @param {ValueConstraints} constraints + * @param {Json} currentValue + * @returns {string} */ + getEnumErrorMessage(constraints, currentValue) { + /** @type {"suggestion" | "types" | "values" | "both"} */ + let variant = "suggestion"; + + /** @type string */ + let allowedValues = ""; + + /** @type string */ + let expectedTypes = ""; + + const instanceValue = JSON.stringify(currentValue); + + if (constraints.allowedValues && constraints.allowedValues.length > 0 && constraints.allowedTypes?.length === 0) { + const bestMatch = constraints.allowedValues + .map((value) => { + const r = { + value: JSON.stringify(value), + weight: leven(JSON.stringify(value), instanceValue) + }; + return r; + }) + .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", + suggestion: bestMatch.value, + instanceValue + }); + } - /** - * @typedef {Object} EnumFallbackArgs - * @property {"fallback"} variant - * @property {string} instanceValue - * @property {string[]} allowedValues - */ + variant = "values"; + allowedValues = new Intl.ListFormat(this.locale, { type: "disjunction" }) + .format(constraints.allowedValues.map((value) => JSON.stringify(value))); + } - /** - * @param {EnumSuggestionArgs | EnumFallbackArgs} args - * @returns {string} - */ - getEnumErrorMessage(args) { - const formattedArgs = { - variant: args.variant, - instanceValue: `"${args.instanceValue}"`, - suggestion: "", - allowedValues: "" - }; - - if (args.variant === "fallback") { - const quotedValues = args.allowedValues.map((value) => JSON.stringify(value)); - formattedArgs.allowedValues = new Intl.ListFormat(this.locale, { type: "disjunction" }).format(quotedValues); - } else { - formattedArgs.suggestion = args.suggestion; + if (constraints.allowedTypes && constraints.allowedTypes.length > 0) { + variant = variant === "values" ? "both" : "types"; + expectedTypes = new Intl.ListFormat(this.locale, { type: "disjunction" }) + .format(constraints.allowedTypes.map((value) => JSON.stringify(value))); } - return this._formatMessage("enum-error", formattedArgs); + return this._formatMessage("enum-error", { + variant, + allowedValues, + expectedTypes, + instanceValue + }); } /** @type () => string */ diff --git a/src/translations/en-US.ftl b/src/translations/en-US.ftl index 3545455..77c22b3 100644 --- a/src/translations/en-US.ftl +++ b/src/translations/en-US.ftl @@ -1,9 +1,11 @@ # Non-type specific messages type-error = The instance should be of type {$expected} but found {$actual}. const-error = The instance should be equal to {$expectedValue}. -enum-error = { $variant -> - [suggestion] Unexpected value {$instanceValue}. Did you mean {$suggestion}? - *[fallback] Unexpected value {$instanceValue}. Expected one of: {$allowedValues}. +enum-error = 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}? } # String messages