From 12d2e545d3a7c95a578f89862e1b3b81c3001d10 Mon Sep 17 00:00:00 2001 From: Arpit Kuriyal Date: Tue, 29 Jul 2025 00:40:05 +0530 Subject: [PATCH 1/2] completed normalizeOuput and tests for it --- src/index.d.ts | 7 +- src/index.js | 629 +++++++++--------- src/keywordErrorMessage.test.js | 154 ++++- src/normalizeOutputFormat/normalizeOutput.js | 290 ++++++-- .../normalizeOutput.test.js | 320 +++++---- 5 files changed, 856 insertions(+), 544 deletions(-) diff --git a/src/index.d.ts b/src/index.d.ts index 0e7e730..df9f95e 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -24,18 +24,15 @@ export type SchemaObject = { [keyword: string]: SchemaFragment; }; -export type OutputFormat = { +export type OutputFormat = OutputUnit & { valid: boolean; - errors: OutputUnit[]; }; export type OutputUnit = { valid?: boolean; - keyword?: string; absoluteKeywordLocation?: string; keywordLocation?: string; - instanceLocation: string; - error?: string; + instanceLocation?: string; errors?: OutputUnit[]; }; diff --git a/src/index.js b/src/index.js index c5dbd8b..27d20a3 100644 --- a/src/index.js +++ b/src/index.js @@ -1,300 +1,344 @@ -import { normalizeOutputFormat } from "./normalizeOutputFormat/normalizeOutput.js"; import * as Schema from "@hyperjump/browser"; -import { getKeywordByName, getSchema } from "@hyperjump/json-schema/experimental"; -import * as Instance from "@hyperjump/json-pointer"; +import { getSchema } from "@hyperjump/json-schema/experimental"; +import * as Instance from "@hyperjump/json-schema/instance/experimental"; import leven from "leven"; +import { normalizedErrorOuput } from "./normalizeOutputFormat/normalizeOutput.js"; /** * @import { Browser } from "@hyperjump/browser"; * @import { SchemaDocument } from "@hyperjump/json-schema/experimental"; + * @import { JsonNode } from "@hyperjump/json-schema/instance/experimental"; * @import { Json } from "@hyperjump/json-pointer"; * @import {betterJsonSchemaErrors, NormalizedError, OutputUnit, BetterJsonSchemaErrors, ErrorObject } from "./index.d.ts" + * @import { NormalizedOutput, InstanceOutput } from "./normalizeOutputFormat/normalizeOutput.js" */ /** @type betterJsonSchemaErrors */ export async function betterJsonSchemaErrors(instance, errorOutput, schemaUri) { - const schema = await getSchema(schemaUri); - const normalizedErrors = await normalizeOutputFormat(errorOutput, schema); - /** @type BetterJsonSchemaErrors */ - const output = { errors: [] }; - - for (const errorHandler of errorHandlers) { - const errorObject = await errorHandler(normalizedErrors, instance, schema); - if (errorObject) { - output.errors.push(...errorObject); + const normalizedErrors = await normalizedErrorOuput(instance, errorOutput, schemaUri); + const rootInstance = Instance.fromJs(instance); + return { errors: await getErrors(normalizedErrors, rootInstance) }; +} + +/** @type (normalizedErrors: NormalizedOutput, rootInstance: JsonNode) => Promise */ +const getErrors = async (normalizedErrors, rootInstance) => { + /** @type ErrorObject[] */ + const errors = []; + + for (const instanceLocation in normalizedErrors) { + const instance = Instance.get(instanceLocation, rootInstance); + for (const errorHandler of errorHandlers) { + const errorObject = await errorHandler(normalizedErrors[instanceLocation], /** @type JsonNode */ (instance)); + if (errorObject) { + errors.push(...errorObject); + } } } - return output; -} + return errors; +}; /** - * @typedef {(normalizedErrors: NormalizedError[], instance: Json, schema: Browser) => Promise} ErrorHandler + * @typedef {(normalizedErrors: InstanceOutput, instance: JsonNode) => Promise} ErrorHandler */ /** @type ErrorHandler[] */ const errorHandlers = [ - // `anyOf` handler - async (normalizedErrors, instance, schema) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizedErrors) { - if (error.keyword === "https://json-schema.org/keyword/anyOf") { - const anyOfSchema = await getSchema(error.absoluteKeywordLocation); - const numberOfAlternatives = Schema.length(anyOfSchema); - // const discriminatorKeys = await findDiscriminatorKeywords(anyOfSchema); - const outputArray = applicatorChildErrors(error.absoluteKeywordLocation, normalizedErrors); - - const keyword = getKeywordByName("type", schema.document.dialectId); - const matchingKeywordErrors = outputArray.filter((e) => e.keyword === keyword.id); - - if (isOnlyOneTypeValid(matchingKeywordErrors, numberOfAlternatives)) { - // all the matchingKeywordErrors are filter out from the outputArray and push in the normalizedErrors array to produce the output. - const remainingErrors = outputArray.filter((err) => { - return !matchingKeywordErrors.some((matchingErr) => { - return matchingErr.absoluteKeywordLocation === err.absoluteKeywordLocation; - }); - }); - normalizedErrors.push(...remainingErrors); - } else if (matchingKeywordErrors.length === numberOfAlternatives) { - const noMatchFound = await noDiscriminatorKeyMatchError(matchingKeywordErrors, error, instance); - errors.push(noMatchFound); - } else if (false) { - // Discriminator cases - } else if (jsonTypeOf(instance) === "object") { - // Number of matching properties - const selectedAlternative = outputArray.find((error) => { - return error.keyword = "https://json-schema.org/keyword/properties"; - })?.absoluteKeywordLocation; - const remainingErrors = outputArray.filter((err) => { - return err.absoluteKeywordLocation.startsWith(/** @type string */ (selectedAlternative)); + if (normalizedErrors["https://json-schema.org/keyword/anyOf"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/anyOf"]) { + /** @type NormalizedOutput[] */ + const alternatives = []; + const allAlternatives = /** @type NormalizedOutput[] */ (normalizedErrors["https://json-schema.org/keyword/anyOf"][schemaLocation]); + for (const alternative of allAlternatives) { + if (Object.values(alternative[Instance.uri(instance)]["https://json-schema.org/keyword/type"]).every((valid) => valid)) { + alternatives.push(alternative); + } + } + // case 1 where no. alternative matched the type of the instance. + if (alternatives.length === 0) { + /** @type Set */ + const expectedTypes = new Set(); + + for (const alternative of allAlternatives) { + for (const instanceLocation in alternative) { + if (instanceLocation === Instance.uri(instance)) { + 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); + } + } + } + } + errors.push({ + message: `The instance must be a ${[...expectedTypes].join(", ")}. Found '${Instance.typeOf(instance)}'.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation }); - normalizedErrors.push(...remainingErrors); - } else { - // I don't know yet what to do - - // { - // "$schema": "https://json-schema.org/draft/2020-12/schema", - // "anyOf": [ - // { "required": [ "foo" ] }, - // { "required": [ "bar" ] } - // ] - // } + } else if (alternatives.length === 1) { + return getErrors(alternatives[0], instance); } + + // const anyOfSchema = await getSchema(schemaLocation); + // const numberOfAlternatives = Schema.length(anyOfSchema); + // Instance.typeOf(instance); + // const instance = Instance.fromJs(instance) + // if(numberOfAlternatives == ) + // errors.push({ + // message: `The instance should be at least ${Schema.value(keyword)} characters`, + // instanceLocation: Instance.uri(instance), + // schemaLocation: schemaLocation + // }); } } + return errors; }, - async (normalizedErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizedErrors) { - if (error.keyword === "https://json-schema.org/keyword/minLength") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - message: `The instance should be at least ${Schema.value(keyword)} characters`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + 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: `The instance should be at least ${Schema.value(keyword)} characters`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } return errors; }, - async (normalizeErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizeErrors) { - if (error.keyword === "https://json-schema.org/keyword/maxLength") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - message: `The instance should be atmost ${Schema.value(keyword)} characters long.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + 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: `The instance should be atmost ${Schema.value(keyword)} characters long.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } return errors; }, - async (normalizeErrors, instance) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizeErrors) { - if (error.keyword === "https://json-schema.org/keyword/type") { - const keyword = await getSchema(error.absoluteKeywordLocation); - const pointer = error.instanceLocation.replace(/^#/, ""); - const actualValue = Instance.get(pointer, instance); - errors.push({ - message: `The instance should be of type "${Schema.value(keyword)}" but found "${typeof actualValue}".`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + if (normalizedErrors["https://json-schema.org/keyword/type"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/type"]) { + if (!normalizedErrors["https://json-schema.org/keyword/type"][schemaLocation]) { + const keyword = await getSchema(schemaLocation); + errors.push({ + message: `The instance should be of type "${Schema.value(keyword)}" but found "${Instance.typeOf(instance)}".`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } return errors; }, - async (normalizeErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizeErrors) { - if (error.keyword === "https://json-schema.org/keyword/maximum") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - message: `The instance should be less than or equal to ${Schema.value(keyword)}.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + if (normalizedErrors["https://json-schema.org/keyword/maximum"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/maximum"]) { + if (!normalizedErrors["https://json-schema.org/keyword/maximum"][schemaLocation]) { + const keyword = await getSchema(schemaLocation); + errors.push({ + message: `The instance should be less than or equal to ${Schema.value(keyword)}.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } return errors; }, - async (normalizeErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizeErrors) { - if (error.keyword === "https://json-schema.org/keyword/minimum") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - message: `The instance should be greater than or equal to ${Schema.value(keyword)}.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + if (normalizedErrors["https://json-schema.org/keyword/minimum"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/minimum"]) { + if (!normalizedErrors["https://json-schema.org/keyword/minimum"][schemaLocation]) { + const keyword = await getSchema(schemaLocation); + errors.push({ + message: `The instance should be greater than or equal to ${Schema.value(keyword)}.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } return errors; }, - async (normalizeErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizeErrors) { - if (error.keyword === "https://json-schema.org/keyword/exclusiveMinimum") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - message: `The instance should be greater than ${Schema.value(keyword)}.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + if (normalizedErrors["https://json-schema.org/keyword/exclusiveMinimum"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/exclusiveMinimum"]) { + if (!normalizedErrors["https://json-schema.org/keyword/exclusiveMinimum"][schemaLocation]) { + const keyword = await getSchema(schemaLocation); + errors.push({ + message: `The instance should be greater than ${Schema.value(keyword)}.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } return errors; }, - async (normalizeErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizeErrors) { - if (error.keyword === "https://json-schema.org/keyword/exclusiveMaximum") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - message: `The instance should be less than ${Schema.value(keyword)}.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + if (normalizedErrors["https://json-schema.org/keyword/exclusiveMaximum"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/exclusiveMaximum"]) { + if (!normalizedErrors["https://json-schema.org/keyword/exclusiveMaximum"][schemaLocation]) { + const keyword = await getSchema(schemaLocation); + errors.push({ + message: `The instance should be less than ${Schema.value(keyword)}.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } return errors; }, - async (normalizeErrors, instance) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizeErrors) { - if (error.keyword === "https://json-schema.org/keyword/required") { - const keyword = await getSchema(error.absoluteKeywordLocation); - /** @type {Set} */ - const required = new Set(Schema.value(keyword)); - const pointer = error.instanceLocation.replace(/^#/, ""); - const object = /** @type Object */ (Instance.get(pointer, instance)); - for (const propertyName of Object.keys(object)) { - required.delete(propertyName); + + if (normalizedErrors["https://json-schema.org/keyword/required"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/required"]) { + if (!normalizedErrors["https://json-schema.org/keyword/required"][schemaLocation]) { + const keyword = await getSchema(schemaLocation); + /** @type Set */ + const required = new Set(Schema.value(keyword)); + for (const propertyName in Instance.value(instance)) { + required.delete(propertyName); + } + errors.push({ + message: `"${Instance.uri(instance)}" is missing required property(s): ${[...required].join(", ")}.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); } - errors.push({ - message: `"${error.instanceLocation}" is missing required property(s): ${[...required].join(", ")}.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); } } return errors; }, - async (normalizedErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizedErrors) { - if (error.keyword === "https://json-schema.org/keyword/multipleOf") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - message: `The instance should be of multiple of ${Schema.value(keyword)}.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + if (normalizedErrors["https://json-schema.org/keyword/multipleOf"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/multipleOf"]) { + if (!normalizedErrors["https://json-schema.org/keyword/multipleOf"][schemaLocation]) { + const keyword = await getSchema(schemaLocation); + errors.push({ + message: `The instance should be of multiple of ${Schema.value(keyword)}.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } return errors; }, - async (normalizedErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizedErrors) { - if (error.keyword === "https://json-schema.org/keyword/maxProperties") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - message: `The instance should have maximum ${Schema.value(keyword)} properties.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + 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: `The instance should have maximum ${Schema.value(keyword)} properties.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } return errors; }, - async (normalizedErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizedErrors) { - if (error.keyword === "https://json-schema.org/keyword/minProperties") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - message: `The instance should have minimum ${Schema.value(keyword)} properties.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + 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: `The instance should have minimum ${Schema.value(keyword)} properties.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } return errors; }, - async (normalizedErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizedErrors) { - if (error.keyword === "https://json-schema.org/keyword/const") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - message: `The instance should be equal to ${Schema.value(keyword)}.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + if (normalizedErrors["https://json-schema.org/keyword/const"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/const"]) { + if (!normalizedErrors["https://json-schema.org/keyword/const"][schemaLocation]) { + const keyword = await getSchema(schemaLocation); + errors.push({ + message: `The instance should be equal to ${Schema.value(keyword)}.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } @@ -304,188 +348,149 @@ const errorHandlers = [ async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizedErrors) { - if (error.keyword === "https://json-schema.org/keyword/enum") { - const keyword = await getSchema(error.absoluteKeywordLocation); - - /** @type {Array} */ - const allowedValues = Schema.value(keyword); - const pointer = error.instanceLocation.replace(/^#/, ""); - const currentValue = /** @type {string} */ (Instance.get(pointer, instance)); - - const bestMatch = allowedValues - .map((value) => ({ - value, - weight: leven(value, currentValue) - })) - .sort((a, b) => a.weight - b.weight)[0]; - - let suggestion = ""; - if ( - allowedValues.length === 1 - || (bestMatch && bestMatch.weight < bestMatch.value.length) - ) { - suggestion = ` Did you mean "${bestMatch.value}"?`; + + if (normalizedErrors["https://json-schema.org/keyword/enum"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/enum"]) { + if (!normalizedErrors["https://json-schema.org/keyword/enum"][schemaLocation]) { + const keyword = await getSchema(schemaLocation); + + /** @type {Array} */ + const 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 suggestion = ""; + if ( + allowedValues.length === 1 + || (bestMatch && bestMatch.weight < bestMatch.value.length) + ) { + suggestion = ` Did you mean "${bestMatch.value}"?`; + errors.push({ + message: `Unexpected value "${currentValue}". ${suggestion}`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + continue; + } + errors.push({ - message: `Unexpected value "${currentValue}". ${suggestion}`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation + message: `Unexpected value "${currentValue}". Expected one of: ${allowedValues.join(",")}.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation }); - continue; } - - errors.push({ - message: `Unexpected value "${currentValue}". Expected one of: ${allowedValues.join(",")}.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); } } + return errors; }, - async (normalizedErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizedErrors) { - if (error.keyword === "https://json-schema.org/keyword/maxItems") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - // can improve this by adding the how many items are more in the arrary and for unique what are the duplicate items. - message: `The instance should contain maximum ${Schema.value(keyword)} items in the array.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + 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: `The instance should contain maximum ${Schema.value(keyword)} items in the array.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } + return errors; }, - async (normalizedErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizedErrors) { - if (error.keyword === "https://json-schema.org/keyword/minItems") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - message: `The instance should contain minimum ${Schema.value(keyword)} items in the array.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + 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: `The instance should contain minimum ${Schema.value(keyword)} items in the array.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } + return errors; }, // eslint-disable-next-line @typescript-eslint/require-await - async (normalizedErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizedErrors) { - if (error.keyword === "https://json-schema.org/keyword/uniqueItems") { - errors.push({ - message: `The instance should have unique items in the array.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + if (normalizedErrors["https://json-schema.org/keyword/uniqueItems"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/uniqueItems"]) { + if (!normalizedErrors["https://json-schema.org/keyword/uniqueItems"][schemaLocation]) { + errors.push({ + message: `The instance should have unique items in the array.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } + return errors; }, - async (normalizedErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizedErrors) { - if (error.keyword === "https://json-schema.org/keyword/format") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - message: `The instance should match the format: ${Schema.value(keyword)}.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + if (normalizedErrors["https://json-schema.org/keyword/format"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/format"]) { + if (!normalizedErrors["https://json-schema.org/keyword/format"][schemaLocation]) { + const keyword = await getSchema(schemaLocation); + errors.push({ + message: `The instance should match the format: ${Schema.value(keyword)}.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } + return errors; }, - - async (normalizedErrors) => { + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; - for (const error of normalizedErrors) { - if (error.keyword === "https://json-schema.org/keyword/pattern") { - const keyword = await getSchema(error.absoluteKeywordLocation); - errors.push({ - message: `The instance should match the pattern: ${Schema.value(keyword)}.`, - instanceLocation: error.instanceLocation, - schemaLocation: error.absoluteKeywordLocation - }); + + if (normalizedErrors["https://json-schema.org/keyword/pattern"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/pattern"]) { + if (!normalizedErrors["https://json-schema.org/keyword/pattern"][schemaLocation]) { + const keyword = await getSchema(schemaLocation); + errors.push({ + message: `The instance should match the pattern: ${Schema.value(keyword)}.`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); + } } } + return errors; } ]; -/** - * Groups errors whose absoluteKeywordLocation starts with a given prefix. - * @param {string} parentKeywordLocation - * @param {NormalizedError[]} allErrors - * @returns {NormalizedError[]} - */ -function applicatorChildErrors(parentKeywordLocation, allErrors) { - const matching = []; - - for (let i = allErrors.length - 1; i >= 0; i--) { - const err = allErrors[i]; - if (err.absoluteKeywordLocation.startsWith(parentKeywordLocation + "/")) { - matching.push(err); - allErrors.splice(i, 1); - } - } - - return matching; -} - -/** - * @param {NormalizedError[]} matchingErrors - * @param {number} numOfAlternatives - * @returns {boolean} - */ -function isOnlyOneTypeValid(matchingErrors, numOfAlternatives) { - const typeErrors = matchingErrors.filter( - (e) => e.keyword === "https://json-schema.org/keyword/type" - ); - return numOfAlternatives - typeErrors.length === 1; -} - -/** - * @param {NormalizedError[]} matchingErrors - * @param {NormalizedError} parentError - * @param {Json} instance - * @returns {Promise} - */ -async function noDiscriminatorKeyMatchError(matchingErrors, parentError, instance) { - const expectedTypes = []; - - for (const err of matchingErrors) { - const typeSchema = await getSchema(err.absoluteKeywordLocation); - const typeValue = /** @type any[] */ (Schema.value(typeSchema)); - expectedTypes.push(typeValue); - } - - const pointer = parentError.instanceLocation.replace(/^#/, ""); - const actualValue = /** @type Json */ (Instance.get(pointer, instance)); - const actualType = jsonTypeOf(actualValue); - - const expectedString = expectedTypes.join(" or "); - - return { - message: `The instance must be a ${expectedString}. Found '${actualType}'.`, - instanceLocation: parentError.instanceLocation, - schemaLocation: parentError.absoluteKeywordLocation - }; -} - /** @type (value: Json) => "null" | "boolean" | "number" | "string" | "array" | "object" | "undefined" */ const jsonTypeOf = (value) => { const jsType = typeof value; diff --git a/src/keywordErrorMessage.test.js b/src/keywordErrorMessage.test.js index 7568c57..66be2be 100644 --- a/src/keywordErrorMessage.test.js +++ b/src/keywordErrorMessage.test.js @@ -547,12 +547,12 @@ describe("Error messages", () => { const result = await betterJsonSchemaErrors(instance, output, schemaUri); expect(result.errors).to.eql([ { - instanceLocation: "#/3", + instanceLocation: "#/2", message: `The instance should be of type "number" but found "string".`, schemaLocation: "https://example.com/main#/items/type" }, { - instanceLocation: "#/2", + instanceLocation: "#/3", message: `The instance should be of type "number" but found "string".`, schemaLocation: "https://example.com/main#/items/type" } @@ -574,19 +574,16 @@ describe("Error messages", () => { valid: false, errors: [ { - keyword: "https://json-schema.org/keyword/type", absoluteKeywordLocation: "https://example.com/main#/prefixItems/1/type", instanceLocation: "#/1", valid: false }, { - keyword: "https://json-schema.org/keyword/type", absoluteKeywordLocation: "https://example.com/main#/prefixItems/2/type", instanceLocation: "#/2", valid: false }, { - keyword: "https://json-schema.org/keyword/prefixItems", absoluteKeywordLocation: "https://example.com/main#/prefixItems", instanceLocation: "#", valid: false @@ -641,7 +638,7 @@ describe("Error messages", () => { { schemaLocation: "https://example.com/main#/anyOf", instanceLocation: "#", - message: `The instance must be a number or string. Found 'boolean'.` + message: `The instance must be a string, number. Found 'boolean'.` } ]); }); @@ -739,10 +736,153 @@ describe("Error messages", () => { const result = await betterJsonSchemaErrors(instance, output, schemaUri); expect(result.errors).to.eql([ { - schemaLocation: `${schemaUri}#/anyOf/1/properties/ID/pattern`, + schemaLocation: "https://example.com/main#/anyOf/1/properties/ID/pattern", instanceLocation: "#/ID", message: "The instance should match the pattern: ^[0-9\\-]+$." } ]); }); + + test("anyOf - const-based discriminator mismatch", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + anyOf: [ + { + type: "object", + properties: { + type: { const: "a" }, + apple: { type: "string" }, + angle: { type: "number" } + }, + required: ["type", "apple", "angle"] + }, + { + type: "object", + properties: { + type: { const: "b" }, + banana: { type: "string" }, + box: { type: "number" } + }, + required: ["type", "banana", "box"] + } + ] + }, schemaUri); + + const instance = { + type: "d", + banana: "yellow", + box: 10 + }; + + /** @type OutputFormat */ + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: `https://example.com/main#/anyOf/0/properties/type/const`, + instanceLocation: "#/type" + }, + { + absoluteKeywordLocation: `https://example.com/main#/anyOf/1/properties/type/const`, + instanceLocation: "#/type" + }, + { + absoluteKeywordLocation: `https://example.com/main#/anyOf`, + instanceLocation: "#" + } + ] + }; + + const result = await betterJsonSchemaErrors(instance, output, schemaUri); + + expect(result.errors).to.eql([ + { + schemaLocation: `https://example.com/main#/anyOf`, + instanceLocation: "#/type", + message: `Invalid value. Expected "a", "b". Found "d".` + } + ]); + }); + + test("anyOf - using $ref in alternatives", async () => { + const subjectUri = "https://example.com/main"; + + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + properties: { + foo: { + anyOf: [ + { $ref: "#/$defs/stringSchema" }, + { $ref: "#/$defs/numberSchema" } + ] + }, + bar: { type: "boolean" } + }, + + $defs: { + stringSchema: { + type: "string", + minLength: 5 + }, + numberSchema: { + type: "number", + minimum: 10 + } + } + }, subjectUri); + + const instance = { foo: 3 }; + + const output = { + valid: false, + errors: [ + { + valid: false, + keywordLocation: "/properties/foo/anyOf", + instanceLocation: "/foo", + errors: [ + { + valid: false, + keywordLocation: "/properties/foo/anyOf/0/$ref", + instanceLocation: "/foo", + errors: [ + { + valid: false, + keywordLocation: "/properties/foo/anyOf/0/$ref/type", + instanceLocation: "/foo" + } + ] + }, + { + valid: false, + keywordLocation: "/properties/foo/anyOf/1/$ref", + instanceLocation: "/foo", + errors: [ + { + valid: true, + keywordLocation: "/properties/foo/anyOf/1/$ref/type", + instanceLocation: "/foo" + }, + { + valid: false, + keywordLocation: "/properties/foo/anyOf/1/$ref/minimum", + instanceLocation: "/foo" + } + ] + } + ] + } + ] + }; + + const result = await betterJsonSchemaErrors(instance, output, schemaUri); + + expect(result.errors).to.eql([ + { + schemaLocation: "https://example.com/main#/$defs/numberSchema/minimum", + instanceLocation: "#/foo", + message: "The instance should be greater than or equal to 10." + } + ]); + }); }); diff --git a/src/normalizeOutputFormat/normalizeOutput.js b/src/normalizeOutputFormat/normalizeOutput.js index 39f9af9..6cfe7dd 100644 --- a/src/normalizeOutputFormat/normalizeOutput.js +++ b/src/normalizeOutputFormat/normalizeOutput.js @@ -1,78 +1,250 @@ -import * as Browser from "@hyperjump/browser"; -import { getKeywordByName } from "@hyperjump/json-schema/experimental"; +import { compile, getSchema } from "@hyperjump/json-schema/experimental"; +import * as Instance from "@hyperjump/json-schema/instance/experimental"; import { pointerSegments } from "@hyperjump/json-pointer"; +import * as Browser from "@hyperjump/browser"; /** - * @import { - * OutputFormat, - * OutputUnit, - * NormalizedError, - * SchemaObject - * } from "../index.d.ts"; - * @import { SchemaDocument } from "@hyperjump/json-schema/experimental"; + * @import { OutputUnit, Json } from "../index.d.ts" + * @import { AST } from "@hyperjump/json-schema/experimental" + * @import { JsonNode } from "@hyperjump/json-schema/instance/experimental" * @import { Browser as BrowserType } from "@hyperjump/browser"; + * @import { SchemaDocument } from "@hyperjump/json-schema/experimental"; */ /** - * @param {OutputFormat} errorOutput - * @param {BrowserType} schema - * @returns {Promise} + * @typedef {{ + * [keywordUri: string]: { + * [keywordLocation: string]: boolean | NormalizedOutput[] + * } + * }} InstanceOutput + * + * @typedef {{ + * [instanceLocation: string]: InstanceOutput + * }} NormalizedOutput */ -export async function normalizeOutputFormat(errorOutput, schema) { - /** @type {NormalizedError[]} */ - const output = []; - if (!errorOutput || errorOutput.valid !== false) { - throw new Error("error Output must follow Draft 2019-09"); +/** @type (schemaLocation: string, ast: AST, instance: JsonNode, errorIndex: ErrorIndex) => NormalizedOutput */ +const evaluateSchema = (schemaLocation, ast, instance, errorIndex) => { + const instanceLocation = Instance.uri(instance); + const schemaNode = ast[schemaLocation]; + if (typeof schemaNode === "boolean") { + return { + [instanceLocation]: { + "https://json-schema.org/validation": { + [schemaLocation]: schemaNode + } + } + }; } - if (!errorOutput.errors) { - throw new Error("error Output must follow Draft 2019-09"); - } + /** @type NormalizedOutput */ + const output = { [instanceLocation]: {} }; + for (const [keywordUri, keywordLocation, keywordValue] of schemaNode) { + const keyword = keywordHandlers[keywordUri] ?? {}; - for (const err of errorOutput.errors) { - await collectErrors(err, output, schema); + const keywordOutput = keyword.evaluate?.(keywordValue, ast, instance, errorIndex); + if (keyword.simpleApplicator) { + for (const suboutput of (keywordOutput ?? [])) { + mergeOutput(output, suboutput); + } + } else if (errorIndex[keywordLocation]?.[instanceLocation]) { + output[instanceLocation][keywordUri] ??= {}; + output[instanceLocation][keywordUri][keywordLocation] = keywordOutput ?? false; + } else if (keyword.appliesTo?.(Instance.typeOf(instance)) !== false) { + output[instanceLocation][keywordUri] ??= {}; + output[instanceLocation][keywordUri][keywordLocation] = !errorIndex[keywordLocation]?.[instanceLocation]; + } } return output; -} +}; -/** @type {(errorOutput: OutputUnit, output: NormalizedError[], schema: BrowserType) => Promise} */ -async function collectErrors(error, output, schema) { - if (error.valid) return; +/** @type (a: NormalizedOutput, b: NormalizedOutput) => void */ +const mergeOutput = (a, b) => { + for (const instanceLocation in b) { + for (const keywordUri in b[instanceLocation]) { + a[instanceLocation] ??= {}; + a[instanceLocation][keywordUri] ??= {}; - if (!("instanceLocation" in error) || !("absoluteKeywordLocation" in error || "keywordLocation" in error)) { - throw new Error("error Output must follow Draft 2019-09"); + Object.assign(a[instanceLocation][keywordUri], b[instanceLocation][keywordUri]); + } } +}; + +/** + * @typedef {{ + * evaluate?(value: any, ast: AST, instance: JsonNode, errorIndex: ErrorIndex): NormalizedOutput[] + * appliesTo?(type: string): boolean; + * simpleApplicator?: true; + * }} KeywordHandler + */ - const absoluteKeywordLocation = error.absoluteKeywordLocation - ?? await toAbsoluteKeywordLocation(schema, /** @type string */ (error.keywordLocation)); +/** @type Record */ +const keywordHandlers = {}; - const fragment = absoluteKeywordLocation.split("#")[1]; - const lastSegment = fragment.split("/").filter(Boolean).pop(); - const keywordHandler = getKeywordByName(/** @type string */ (lastSegment), schema.document.dialectId); +keywordHandlers["https://json-schema.org/keyword/anyOf"] = { + evaluate(/** @type string[] */ anyOf, ast, instance, errorIndex) { + /** @type NormalizedOutput[] */ + const errors = []; - // make a check here to remove the schemaLocation. - if (lastSegment && !keywordHandler.id.startsWith("https://json-schema.org/keyword/unknown")) { - output.push({ - valid: false, - keyword: error.keyword ?? keywordHandler.id, - absoluteKeywordLocation, - instanceLocation: normalizeInstanceLocation(error.instanceLocation) - }); + for (const schemaLocation of anyOf) { + errors.push(evaluateSchema(schemaLocation, ast, instance, errorIndex)); + } + + return errors; } +}; + +keywordHandlers["https://json-schema.org/keyword/items"] = { + evaluate(/** @type string[] */ itemsSchemaLocation, ast, instance, errorIndex) { + /** @type NormalizedOutput[] */ + const errors = []; + if (Instance.typeOf(instance) !== "array") { + return errors; + } + for (const itemNode of Instance.iter(instance)) { + errors.push(evaluateSchema(itemsSchemaLocation[1], ast, itemNode, errorIndex)); + } + return errors; + }, + simpleApplicator: true +}; + +keywordHandlers["https://json-schema.org/keyword/allOf"] = { + evaluate(/** @type string[] */ allOf, ast, instance, errorIndex) { + /** @type NormalizedOutput[] */ + const errors = []; + for (const schemaLocation of allOf) { + errors.push(evaluateSchema(schemaLocation, ast, instance, errorIndex)); + } + + return errors; + }, + simpleApplicator: true +}; - if (error.errors) { - for (const nestedError of error.errors) { - await collectErrors(nestedError, output, schema); // Recursive +keywordHandlers["https://json-schema.org/keyword/oneOf"] = { + evaluate(/** @type string[] */ oneOf, ast, instance, errorIndex) { + /** @type NormalizedOutput[] */ + const errors = []; + + for (const schemaLocation of oneOf) { + errors.push(evaluateSchema(schemaLocation, ast, instance, errorIndex)); } + + return errors; } -} +}; -/** @type {(location: string) => string} */ -function normalizeInstanceLocation(location) { - return location.startsWith("/") || location === "" ? `#${location}` : location; -} +keywordHandlers["https://json-schema.org/keyword/ref"] = { + evaluate(/** @type string */ ref, ast, instance, errorIndex) { + return [evaluateSchema(ref, ast, instance, errorIndex)]; + }, + simpleApplicator: true +}; + +keywordHandlers["https://json-schema.org/keyword/properties"] = { + evaluate(/** @type Record */ properties, ast, instance, errorIndex) { + /** @type NormalizedOutput[] */ + const errors = []; + + for (const propertyName in properties) { + const propertyNode = Instance.step(propertyName, instance); + if (!propertyNode) { + continue; + } + + errors.push(evaluateSchema(properties[propertyName], ast, propertyNode, errorIndex)); + } + + return errors; + }, + simpleApplicator: true +}; + +keywordHandlers["https://json-schema.org/keyword/definitions"] = { + appliesTo() { + return false; + } +}; + +keywordHandlers["https://json-schema.org/keyword/minLength"] = { + appliesTo(type) { + return type === "string"; + } +}; + +keywordHandlers["https://json-schema.org/keyword/maxLength"] = { + appliesTo(type) { + return type === "string"; + } +}; + +keywordHandlers["https://json-schema.org/keyword/minimum"] = { + appliesTo(type) { + return type === "number"; + } +}; + +keywordHandlers["https://json-schema.org/keyword/maximum"] = { + appliesTo(type) { + return type === "number"; + } +}; + +keywordHandlers["https://json-schema.org/keyword/pattern"] = { + appliesTo(type) { + return type === "string"; + } +}; + +keywordHandlers["https://json-schema/keyword/exclusiveMinimum"] = { + appliesTo(type) { + return type === "number"; + } +}; + +keywordHandlers["https://json-schema/keyword/exclusiveMaximum"] = { + appliesTo(type) { + return type === "number"; + } +}; + +keywordHandlers["https://json-schema/keyword/multipleOf"] = { + appliesTo(type) { + return type === "number"; + } +}; + +keywordHandlers["https://json-schema.org/keyword/maximum"] = { + appliesTo(type) { + return type === "number"; + } +}; +/** @typedef {Record>} ErrorIndex */ + +/** @type (outputUnit: OutputUnit, schema: BrowserType, errorIndex?: ErrorIndex) => Promise */ +export const constructErrorIndex = async (outputUnit, schema, errorIndex = {}) => { + if (outputUnit.valid) { + return errorIndex; + } + + for (const errorOutputUnit of outputUnit.errors ?? []) { + if (errorOutputUnit.valid) { + continue; + } + if (!("instanceLocation" in errorOutputUnit) || !("keywordLocation" in errorOutputUnit || "absoluteKeywordLocation" in errorOutputUnit)) { + throw new Error("error Output must follow Draft 2019-09"); + } + const absoluteKeywordLocation = errorOutputUnit.absoluteKeywordLocation + ?? await toAbsoluteKeywordLocation(schema, /** @type string */ (errorOutputUnit.keywordLocation)); + const instanceLocation = /** @type string */ (normalizeInstanceLocation(errorOutputUnit.instanceLocation)); + errorIndex[absoluteKeywordLocation] ??= {}; + errorIndex[absoluteKeywordLocation][instanceLocation] = true; + await constructErrorIndex(errorOutputUnit, schema, errorIndex); + } + return errorIndex; +}; /** * Convert keywordLocation to absoluteKeywordLocation @@ -84,6 +256,24 @@ export async function toAbsoluteKeywordLocation(schema, keywordLocation) { for (const segment of pointerSegments(keywordLocation)) { schema = /** @type BrowserType */ (await Browser.step(segment, schema)); } - return `${schema.document.baseUri}#${schema.cursor}`; } + +/** @type {(location: string | undefined) => string | undefined} */ +function normalizeInstanceLocation(location) { + return location?.startsWith("/") || location === "" ? `#${location}` : location; +} + +/** + * @param {Json} instance + * @param {OutputUnit} errorOutput + * @param {string} subjectUri + * @returns {Promise} + */ +export async function normalizedErrorOuput(instance, errorOutput, subjectUri) { + const schema = await getSchema(subjectUri); + const errorIndex = await constructErrorIndex(errorOutput, schema); + const { schemaUri, ast } = await compile(schema); + const value = Instance.fromJs(instance); + return evaluateSchema(schemaUri, ast, value, errorIndex); +} diff --git a/src/normalizeOutputFormat/normalizeOutput.test.js b/src/normalizeOutputFormat/normalizeOutput.test.js index d280538..c8c1cb4 100644 --- a/src/normalizeOutputFormat/normalizeOutput.test.js +++ b/src/normalizeOutputFormat/normalizeOutput.test.js @@ -1,8 +1,7 @@ import { afterEach, describe, expect, test } from "vitest"; -import { normalizeOutputFormat } from "./normalizeOutput.js"; +import { normalizedErrorOuput } from "./normalizeOutput.js"; import { betterJsonSchemaErrors } from "../index.js"; import { registerSchema, unregisterSchema } from "@hyperjump/json-schema/draft-2020-12"; -import { getSchema } from "@hyperjump/json-schema/experimental"; /** * @import { OutputFormat} from "../index.d.ts" */ @@ -90,7 +89,6 @@ describe("Error Output Normalization", () => { } ] }; - const result = await betterJsonSchemaErrors(instance, output, schemaUri); expect(result.errors).to.eql([{ schemaLocation: "https://example.com/main#/minLength", @@ -100,188 +98,220 @@ describe("Error Output Normalization", () => { }); test("checking for the basic output format", async () => { - const schemaUri = "https://example.com/polygon"; registerSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", - $defs: { - point: { - type: "object", - properties: { - x: { type: "number" }, - y: { type: "number" } - }, - additionalProperties: false, - required: ["x", "y"] - } + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" } }, - type: "array", - items: { $ref: "#/$defs/point" } + required: ["name", "age"] }, schemaUri); + const instance = { + age: "twenty" + }; + /** @type OutputFormat */ const errorOutput = { valid: false, errors: [ { - valid: false, - keywordLocation: "#/items/$ref", - absoluteKeywordLocation: "https://example.com/polygon#/$defs/point", - instanceLocation: "#/1", - error: "A subschema had errors." - }, - { - valid: false, - keywordLocation: "#/items/$ref/required", - absoluteKeywordLocation: "https://example.com/polygon#/$defs/point/required", - instanceLocation: "#/1", - error: "Required property 'y' not found." + absoluteKeywordLocation: "https://example.com/main#/required", + instanceLocation: "#" }, { - valid: false, - keywordLocation: "#/items/$ref/additionalProperties", - absoluteKeywordLocation: "https://example.com/polygon#/$defs/point/additionalProperties", - instanceLocation: "#/1/z", - error: "Additional property 'z' found but was invalid." + absoluteKeywordLocation: "https://example.com/main#/properties/age/type", + instanceLocation: "#/age" } ] }; - const schema = await getSchema(schemaUri); - expect(await normalizeOutputFormat(errorOutput, schema)).to.eql([ - { - valid: false, - keyword: "https://json-schema.org/keyword/required", - absoluteKeywordLocation: "https://example.com/polygon#/$defs/point/required", - instanceLocation: "#/1" + expect(await normalizedErrorOuput(instance, errorOutput, schemaUri)).to.eql({ + "#": { + "https://json-schema.org/keyword/required": { + "https://example.com/main#/required": false + }, + "https://json-schema.org/keyword/type": { + "https://example.com/main#/type": true + } }, - { - valid: false, - keyword: "https://json-schema.org/keyword/additionalProperties", - absoluteKeywordLocation: "https://example.com/polygon#/$defs/point/additionalProperties", - instanceLocation: "#/1/z" + "#/age": { + "https://json-schema.org/keyword/type": { + "https://example.com/main#/properties/age/type": false + } } - ]); + }); }); test("checking for the detailed output format", async () => { - const schemaUri = "https://example.com/polygon"; registerSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", + $id: schemaUri, + type: "object", + properties: { + profile: { $ref: "#/$defs/profile" } + }, + required: ["profile"], $defs: { - point: { + profile: { type: "object", properties: { - x: { type: "number" }, - y: { type: "number" } + name: { type: "string" }, + age: { type: "integer" } }, - additionalProperties: false, - required: ["x", "y"] + required: ["name", "age"] } - }, - type: "array", - items: { $ref: "#/$defs/point" } + } }, schemaUri); - + const instance = { + profile: { + name: 123 + } + }; + /** @type OutputFormat */ const errorOutput = { valid: false, - keywordLocation: "#", - instanceLocation: "#", errors: [ { - valid: false, - keywordLocation: "#/items/$ref", - absoluteKeywordLocation: "https://example.com/polygon#/$defs/point", - instanceLocation: "#/1", + absoluteKeywordLocation: "https://example.com/main#/$defs/profile", + instanceLocation: "/profile", errors: [ { - valid: false, - keywordLocation: "#/items/$ref/required", - absoluteKeywordLocation: "https://example.com/polygon#/$defs/point/required", - instanceLocation: "#/1", - error: "Required property 'y' not found." + absoluteKeywordLocation: "https://example.com/main#/$defs/profile/properties/name/type", + instanceLocation: "/profile/name" }, { - valid: false, - keywordLocation: "#/items/$ref/additionalProperties", - absoluteKeywordLocation: "https://example.com/polygon#/$defs/point/additionalProperties", - instanceLocation: "#/1/z", - error: "Additional property 'z' found but was invalid." + absoluteKeywordLocation: "https://example.com/main#/$defs/profile/required", + instanceLocation: "/profile" } ] } ] }; - const schema = await getSchema(schemaUri); - expect(await normalizeOutputFormat(errorOutput, schema)).to.eql([ - { - valid: false, - keyword: "https://json-schema.org/keyword/required", - absoluteKeywordLocation: "https://example.com/polygon#/$defs/point/required", - instanceLocation: "#/1" + expect(await normalizedErrorOuput(instance, errorOutput, schemaUri)).to.eql({ + "#": { + "https://json-schema.org/keyword/required": { + "https://example.com/main#/required": true + }, + "https://json-schema.org/keyword/type": { + "https://example.com/main#/type": true + } }, - { - valid: false, - keyword: "https://json-schema.org/keyword/additionalProperties", - absoluteKeywordLocation: "https://example.com/polygon#/$defs/point/additionalProperties", - instanceLocation: "#/1/z" + "#/profile": { + "https://json-schema.org/keyword/required": { + "https://example.com/main#/$defs/profile/required": false + }, + "https://json-schema.org/keyword/type": { + "https://example.com/main#/$defs/profile/type": true + } + }, + "#/profile/name": { + "https://json-schema.org/keyword/type": { + "https://example.com/main#/$defs/profile/properties/name/type": false + } } - ]); + }); }); test("checking for the verbose output format", async () => { registerSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", + $id: schemaUri, type: "object", - properties: {}, - additionalProperties: false + properties: { + profile: { $ref: "#/$defs/profile" } + }, + required: ["profile"], + $defs: { + profile: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" } + }, + required: ["name", "age"] + } + } }, schemaUri); + const instance = { + profile: { + name: 123 + } + }; + /** @type {OutputFormat} */ const errorOutput = { valid: false, - keywordLocation: "#", - instanceLocation: "#", + keywordLocation: "", + instanceLocation: "", errors: [ { valid: true, - absoluteKeywordLocation: "https://example.com/main4#/type", - instanceLocation: "#" - }, - { - valid: true, - absoluteKeywordLocation: "https://example.com/main4#/properties", - instanceLocation: "#" + keywordLocation: "/type", + instanceLocation: "" }, { valid: false, - absoluteKeywordLocation: "https://example.com/main4#/additionalProperties", - instanceLocation: "#", + keywordLocation: "/properties/profile/$ref", + absoluteKeywordLocation: "https://example.com/main#/$defs/profile", + instanceLocation: "/profile", errors: [ + { + valid: true, + keywordLocation: "/properties/profile/$ref/type", + instanceLocation: "/profile" + }, + { + valid: true, + keywordLocation: "/properties/profile/$ref/properties", + instanceLocation: "/profile" + }, + { + valid: false, + keywordLocation: "/properties/profile/$ref/properties/name/type", + absoluteKeywordLocation: "https://example.com/main#/$defs/profile/properties/name/type", + instanceLocation: "/profile/name" + }, { valid: false, - absoluteKeywordLocation: "https://example.com/main4#/additionalProperties", - instanceLocation: "#/disallowedProp", - error: "Additional property 'disallowedProp' found but was invalid." + keywordLocation: "/properties/profile/$ref/required", + absoluteKeywordLocation: "https://example.com/main#/$defs/profile/required", + instanceLocation: "/profile" } ] + }, + { + valid: true, + keywordLocation: "/required", + instanceLocation: "" } ] }; - const schema = await getSchema(schemaUri); - expect(await normalizeOutputFormat(errorOutput, schema)).to.eql([ - { - valid: false, - keyword: "https://json-schema.org/keyword/additionalProperties", - absoluteKeywordLocation: "https://example.com/main4#/additionalProperties", - instanceLocation: "#" + expect(await normalizedErrorOuput(instance, errorOutput, schemaUri)).to.eql({ + "#": { + "https://json-schema.org/keyword/required": { + "https://example.com/main#/required": true + }, + "https://json-schema.org/keyword/type": { + "https://example.com/main#/type": true + } }, - { - valid: false, - keyword: "https://json-schema.org/keyword/additionalProperties", - absoluteKeywordLocation: "https://example.com/main4#/additionalProperties", - instanceLocation: "#/disallowedProp" + "#/profile": { + "https://json-schema.org/keyword/required": { + "https://example.com/main#/$defs/profile/required": false + }, + "https://json-schema.org/keyword/type": { + "https://example.com/main#/$defs/profile/type": true + } + }, + "#/profile/name": { + "https://json-schema.org/keyword/type": { + "https://example.com/main#/$defs/profile/properties/name/type": false + } } - ]); + }); }); test("when error output doesnot contain any of these three keyword (valid, absoluteKeywordLocation, instanceLocation)", async () => { @@ -294,7 +324,7 @@ describe("Error Output Normalization", () => { } } }, schemaUri); - + const instance = ""; const errorOutput = { valid: false, errors: [ @@ -304,9 +334,7 @@ describe("Error Output Normalization", () => { } ] }; - - const schema = await getSchema(schemaUri); - await expect(async () => normalizeOutputFormat(/** @type any */(errorOutput), schema)).to.rejects.toThrow("error Output must follow Draft 2019-09"); + await expect(async () => normalizedErrorOuput(instance, /** @type any */(errorOutput), schemaUri)).to.rejects.toThrow("error Output must follow Draft 2019-09"); }); test("correctly resolves keywordLocation through $ref in $defs", async () => { @@ -328,67 +356,19 @@ describe("Error Output Normalization", () => { errors: [ { keywordLocation: "/properties/foo/$ref/minLength", - instanceLocation: "#" + instanceLocation: "#/foo" } ] }; + const result = await betterJsonSchemaErrors(instance, output, schemaUri); + expect(result.errors).to.eql([ { schemaLocation: "https://example.com/main#/$defs/lengthDefinition/minLength", - instanceLocation: "#", + instanceLocation: "#/foo", message: "The instance should be at least 3 characters" } ]); }); - - test("removes schemaLocation nodes from the error output", async () => { - registerSchema({ - $schema: "https://json-schema.org/draft/2020-12/schema", - $defs: { - point: { - type: "object", - properties: { - x: { type: "number" }, - y: { type: "number" } - }, - additionalProperties: false, - required: ["x", "y"] - } - }, - type: "array", - items: { $ref: "#/$defs/point" }, - minItems: 3 - }, schemaUri); - - const errorOutput = { - valid: false, - errors: [ - { - valid: false, - keywordLocation: "#/items/$ref", - absoluteKeywordLocation: "https://example.com/polygon#/$defs/point", - instanceLocation: "#/1", - error: "A subschema had errors." - }, - { - valid: false, - keywordLocation: "#/items/$ref/required", - absoluteKeywordLocation: "https://example.com/polygon#/$defs/point/required", - instanceLocation: "#/1", - error: "Required property 'y' not found." - } - ] - }; - - const schema = await getSchema(schemaUri); - expect(await normalizeOutputFormat(errorOutput, schema)).to.eql([ - { - valid: false, - keyword: "https://json-schema.org/keyword/required", - absoluteKeywordLocation: "https://example.com/polygon#/$defs/point/required", - instanceLocation: "#/1" - } - ]); - }); }); From a54f90db19f1c8004196d6533038291a60f78579 Mon Sep 17 00:00:00 2001 From: Arpit Kuriyal Date: Tue, 29 Jul 2025 22:26:11 +0530 Subject: [PATCH 2/2] added keywordHandlers and update normalizedErrorOuput format --- src/index.js | 91 ++++++----- src/keywordErrorMessage.test.js | 114 ++++++++++++- src/normalizeOutputFormat/normalizeOutput.js | 160 ++++++++++++++++--- 3 files changed, 309 insertions(+), 56 deletions(-) diff --git a/src/index.js b/src/index.js index 27d20a3..cd082d3 100644 --- a/src/index.js +++ b/src/index.js @@ -80,20 +80,19 @@ const errorHandlers = [ instanceLocation: Instance.uri(instance), schemaLocation: schemaLocation }); - } else if (alternatives.length === 1) { + } else if (alternatives.length === 1) { // case 2 when only one type match return getErrors(alternatives[0], instance); + } else if (instance.type === "object") { + let targetAlternativeIndex = -1; + for (const alternative of alternatives) { + targetAlternativeIndex++; + for (const instanceLocation in alternative) { + if (instanceLocation !== "#") { + return getErrors(alternatives[targetAlternativeIndex], instance); + } + } + } } - - // const anyOfSchema = await getSchema(schemaLocation); - // const numberOfAlternatives = Schema.length(anyOfSchema); - // Instance.typeOf(instance); - // const instance = Instance.fromJs(instance) - // if(numberOfAlternatives == ) - // errors.push({ - // message: `The instance should be at least ${Schema.value(keyword)} characters`, - // instanceLocation: Instance.uri(instance), - // schemaLocation: schemaLocation - // }); } } @@ -470,6 +469,7 @@ const errorHandlers = [ return errors; }, + async (normalizedErrors, instance) => { /** @type ErrorObject[] */ const errors = []; @@ -488,31 +488,50 @@ const errorHandlers = [ } return errors; - } -]; + }, -/** @type (value: Json) => "null" | "boolean" | "number" | "string" | "array" | "object" | "undefined" */ -const jsonTypeOf = (value) => { - const jsType = typeof value; - - switch (jsType) { - case "number": - case "string": - case "boolean": - case "undefined": - return jsType; - case "object": - if (Array.isArray(value)) { - return "array"; - } else if (value === null) { - return "null"; - } else if (Object.getPrototypeOf(value) === Object.prototype) { - return "object"; + // eslint-disable-next-line @typescript-eslint/require-await + async (normalizedErrors, instance) => { + /** @type ErrorObject[] */ + const errors = []; + + if (normalizedErrors["https://json-schema.org/keyword/contains"]) { + for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/contains"]) { + // const keyword = await getSchema(schemaLocation); + errors.push({ + message: `TODO - contains`, + instanceLocation: Instance.uri(instance), + schemaLocation: schemaLocation + }); } - default: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const type = jsType === "object" ? Object.getPrototypeOf(value).constructor.name ?? "anonymous" : jsType; - throw Error(`Not a JSON compatible type: ${type}`); } + + return errors; } -}; +]; + +// /** @type (value: Json) => "null" | "boolean" | "number" | "string" | "array" | "object" | "undefined" */ +// const jsonTypeOf = (value) => { +// const jsType = typeof value; + +// switch (jsType) { +// case "number": +// case "string": +// case "boolean": +// case "undefined": +// return jsType; +// case "object": +// if (Array.isArray(value)) { +// return "array"; +// } else if (value === null) { +// return "null"; +// } else if (Object.getPrototypeOf(value) === Object.prototype) { +// return "object"; +// } +// default: { +// // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access +// const type = jsType === "object" ? Object.getPrototypeOf(value).constructor.name ?? "anonymous" : jsType; +// throw Error(`Not a JSON compatible type: ${type}`); +// } +// } +// }; diff --git a/src/keywordErrorMessage.test.js b/src/keywordErrorMessage.test.js index 66be2be..2be8844 100644 --- a/src/keywordErrorMessage.test.js +++ b/src/keywordErrorMessage.test.js @@ -743,7 +743,7 @@ describe("Error messages", () => { ]); }); - test("anyOf - const-based discriminator mismatch", async () => { + test.skip("anyOf - const-based discriminator mismatch", async () => { registerSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", anyOf: [ @@ -885,4 +885,116 @@ describe("Error messages", () => { } ]); }); + + test("normalized output for a failing 'contains' keyword", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + contains: { + type: "number", + multipleOf: 2 + }, + minContains: 1 + }, schemaUri); + const instance = [3, 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/multipleOf" + }, + { + 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(instance, output, schemaUri); + expect(result.errors).to.eql([ + { + instanceLocation: "#", + message: "TODO - contains", + schemaLocation: "https://example.com/main#/contains" + } + ]); + }); + + test("when then fails in if-then-else", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + if: { multipleOf: 2 }, + then: { minimum: 0 } + }, schemaUri); + const instance = -2; + const errorOutput = { + valid: false, + errors: [ + { + valid: false, + absoluteKeywordLocation: "https://example.com/main#/then/minimum", + instanceLocation: "#" + } + ] + }; + + const result = await betterJsonSchemaErrors(instance, errorOutput, schemaUri); + expect(result.errors).to.eql([ + { + instanceLocation: "#", + message: `The instance should be greater than or equal to 0.`, + schemaLocation: "https://example.com/main#/then/minimum" + } + ]); + }); + + test("when else fails in if-then-else", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + if: { multipleOf: 2 }, + else: { minimum: 0 } + }, schemaUri); + const instance = -3; + const errorOutput = { + valid: false, + errors: [ + { + valid: false, + absoluteKeywordLocation: "https://example.com/main#/else/minimum", + instanceLocation: "#" + } + ] + }; + + const result = await betterJsonSchemaErrors(instance, errorOutput, schemaUri); + expect(result.errors).to.eql([ + { + instanceLocation: "#", + message: `The instance should be greater than or equal to 0.`, + schemaLocation: "https://example.com/main#/else/minimum" + } + ]); + }); + + // not + // dependentRequired + // patternProperties + // propertyNames + // additionalProperties + // unevaluatedProperties + // unevaluatedItems }); diff --git a/src/normalizeOutputFormat/normalizeOutput.js b/src/normalizeOutputFormat/normalizeOutput.js index 6cfe7dd..30cba64 100644 --- a/src/normalizeOutputFormat/normalizeOutput.js +++ b/src/normalizeOutputFormat/normalizeOutput.js @@ -86,7 +86,6 @@ keywordHandlers["https://json-schema.org/keyword/anyOf"] = { evaluate(/** @type string[] */ anyOf, ast, instance, errorIndex) { /** @type NormalizedOutput[] */ const errors = []; - for (const schemaLocation of anyOf) { errors.push(evaluateSchema(schemaLocation, ast, instance, errorIndex)); } @@ -95,21 +94,6 @@ keywordHandlers["https://json-schema.org/keyword/anyOf"] = { } }; -keywordHandlers["https://json-schema.org/keyword/items"] = { - evaluate(/** @type string[] */ itemsSchemaLocation, ast, instance, errorIndex) { - /** @type NormalizedOutput[] */ - const errors = []; - if (Instance.typeOf(instance) !== "array") { - return errors; - } - for (const itemNode of Instance.iter(instance)) { - errors.push(evaluateSchema(itemsSchemaLocation[1], ast, itemNode, errorIndex)); - } - return errors; - }, - simpleApplicator: true -}; - keywordHandlers["https://json-schema.org/keyword/allOf"] = { evaluate(/** @type string[] */ allOf, ast, instance, errorIndex) { /** @type NormalizedOutput[] */ @@ -153,7 +137,6 @@ keywordHandlers["https://json-schema.org/keyword/properties"] = { if (!propertyNode) { continue; } - errors.push(evaluateSchema(properties[propertyName], ast, propertyNode, errorIndex)); } @@ -162,12 +145,130 @@ keywordHandlers["https://json-schema.org/keyword/properties"] = { simpleApplicator: true }; +keywordHandlers["https://json-schema.org/keyword/items"] = { + evaluate(/** @type string[] */ itemsSchemaLocation, ast, instance, errorIndex) { + /** @type NormalizedOutput[] */ + const errors = []; + if (Instance.typeOf(instance) !== "array") { + return errors; + } + for (const itemNode of Instance.iter(instance)) { + errors.push(evaluateSchema(itemsSchemaLocation[1], ast, itemNode, errorIndex)); + } + return errors; + }, + simpleApplicator: true +}; + +keywordHandlers["https://json-schema.org/keyword/prefixItems"] = { + evaluate(/** @type string[] */ prefixItemsSchemaLocations, ast, instance, errorIndex) { + /** @type NormalizedOutput[] */ + const outputs = []; + if (Instance.typeOf(instance) !== "array") { + return outputs; + } + for (const [index, schemaLocation] of prefixItemsSchemaLocations.entries()) { + const itemNode = Instance.step(String(index), instance); + if (itemNode) { + outputs.push(evaluateSchema(schemaLocation, ast, itemNode, errorIndex)); + } + } + return outputs; + }, + simpleApplicator: true +}; + +keywordHandlers["https://json-schema.org/keyword/dependentSchemas"] = { + evaluate(/** @type [string, string][] */dependentSchemas, ast, instance, errorIndex) { + /** @type NormalizedOutput[] */ + const outputs = []; + if (Instance.typeOf(instance) !== "object") { + return outputs; + } + const instanceKeys = Object.keys(Instance.value(instance)); + for (const [propertyName, schemaLocation] of dependentSchemas) { + if (instanceKeys.includes(propertyName)) { + outputs.push(evaluateSchema(schemaLocation, ast, instance, errorIndex)); + } + } + return outputs; + }, + simpleApplicator: true +}; + +/** + * @typedef {{ + * minContains: number; + * maxContains: number; + * contains: string; + * }} ContainsKeyword + */ +keywordHandlers["https://json-schema.org/keyword/contains"] = { + evaluate(/** @type ContainsKeyword */contains, ast, instance, errorIndex) { + /** @type NormalizedOutput[] */ + const outputs = []; + if (Instance.typeOf(instance) !== "array") { + return outputs; + } + for (const itemNode of Instance.iter(instance)) { + // console.log(errorIndex) + // console.log(evaluateSchema(contains.contains, ast, itemNode, errorIndex)) + outputs.push(evaluateSchema(contains.contains, ast, itemNode, errorIndex)); + } + return outputs; + } +}; + +keywordHandlers["https://json-schema.org/keyword/then"] = { + evaluate(/** @type [string, string] */ [, then], ast, instance, errorIndex) { + return [evaluateSchema(then, ast, instance, errorIndex)]; + }, + simpleApplicator: true +}; + +keywordHandlers["https://json-schema.org/keyword/else"] = { + evaluate(/** @type [string, string] */ [, elseSchema], ast, instance, errorIndex) { + return [evaluateSchema(elseSchema, ast, instance, errorIndex)]; + }, + simpleApplicator: true +}; + keywordHandlers["https://json-schema.org/keyword/definitions"] = { appliesTo() { return false; } }; +keywordHandlers["https://json-schema.org/keyword/type"] = { + appliesTo() { + return true; + } +}; +keywordHandlers["https://json-schema.org/keyword/enum"] = { + appliesTo() { + return true; + } +}; +keywordHandlers["https://json-schema.org/keyword/const"] = { + appliesTo() { + return true; + } +}; +keywordHandlers["https://json-schema.org/keyword/required"] = { + appliesTo(type) { + return type === "object"; + } +}; +keywordHandlers["https://json-schema.org/keyword/maxProperties"] = { + appliesTo(type) { + return type === "object"; + } +}; +keywordHandlers["https://json-schema.org/keyword/minProperties"] = { + appliesTo(type) { + return type === "object"; + } +}; keywordHandlers["https://json-schema.org/keyword/minLength"] = { appliesTo(type) { return type === "string"; @@ -216,11 +317,32 @@ keywordHandlers["https://json-schema/keyword/multipleOf"] = { } }; -keywordHandlers["https://json-schema.org/keyword/maximum"] = { +keywordHandlers["https://json-schema.org/keyword/maxItems"] = { appliesTo(type) { - return type === "number"; + return type === "array"; } }; +keywordHandlers["https://json-schema.org/keyword/minItems"] = { + appliesTo(type) { + return type === "array"; + } +}; +keywordHandlers["https://json-schema.org/keyword/uniqueItems"] = { + appliesTo(type) { + return type === "array"; + } +}; +keywordHandlers["https://json-schema.org/keyword/maxContains"] = { + appliesTo(type) { + return type === "array"; + } +}; +keywordHandlers["https://json-schema.org/keyword/minContains"] = { + appliesTo(type) { + return type === "array"; + } +}; + /** @typedef {Record>} ErrorIndex */ /** @type (outputUnit: OutputUnit, schema: BrowserType, errorIndex?: ErrorIndex) => Promise */