diff --git a/.gitignore b/.gitignore index 0a727b7..73d8a43 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ docs/ scratch/ TODO* +.DS_Store \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 18484e5..9cd9112 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@hyperjump/browser": "^1.3.1", - "@hyperjump/json-schema": "^1.16.0", + "@hyperjump/json-schema": "^1.16.2", "leven": "^4.0.0" }, "devDependencies": { @@ -763,9 +763,9 @@ } }, "node_modules/@hyperjump/json-schema": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.16.0.tgz", - "integrity": "sha512-7tAcnxrsfmu8JFH2oFzk+AEvp74VQh7sb2DfDl3HSxFE880tJIsKlnC0nBiIfLeeIyg4LsjgjL2PDS63foWULQ==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.16.2.tgz", + "integrity": "sha512-MJNvaEFc79+h5rvBPgAJK4OHEUr0RqsKcLC5rc3V9FEsJyQAjnP910deRFoZCE068kX/NrAPPhunMgUMwonPtg==", "license": "MIT", "dependencies": { "@hyperjump/json-pointer": "^1.1.0", diff --git a/package.json b/package.json index e11453c..75f4bd7 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "@hyperjump/browser": "^1.3.1", - "@hyperjump/json-schema": "^1.16.0", + "@hyperjump/json-schema": "^1.16.2", "leven": "^4.0.0" } } diff --git a/src/index.js b/src/index.js index 2b22021..c5dbd8b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,6 @@ import { normalizeOutputFormat } from "./normalizeOutputFormat/normalizeOutput.js"; import * as Schema from "@hyperjump/browser"; -import { getSchema } from "@hyperjump/json-schema/experimental"; +import { getKeywordByName, getSchema } from "@hyperjump/json-schema/experimental"; import * as Instance from "@hyperjump/json-pointer"; import leven from "leven"; @@ -19,7 +19,7 @@ export async function betterJsonSchemaErrors(instance, errorOutput, schemaUri) { const output = { errors: [] }; for (const errorHandler of errorHandlers) { - const errorObject = await errorHandler(normalizedErrors, instance); + const errorObject = await errorHandler(normalizedErrors, instance, schema); if (errorObject) { output.errors.push(...errorObject); } @@ -29,31 +29,64 @@ export async function betterJsonSchemaErrors(instance, errorOutput, schemaUri) { } /** - * @typedef {(normalizedErrors: NormalizedError[], instance: Json) => Promise} ErrorHandler + * @typedef {(normalizedErrors: NormalizedError[], instance: Json, schema: Browser) => Promise} ErrorHandler */ /** @type ErrorHandler[] */ const errorHandlers = [ - // async (normalizedErrors) => { - // /** @type ErrorObject[] */ - // const errors = []; - // for (const error of normalizedErrors) { - // if (error.keyword === "https://json-schema.org/keyword/anyOf") { - // // const outputArray = applicatorChildErrors(outputUnit.absoluteKeywordLocation, normalizedErrors); - // // const failingTypeErrors = outputArray - // // .filter((err) => err.keyword === "https://json-schema.org/keyword/type") - // // .map((err) => err.instanceLocation); - // // const numberOfAlternatives = /** @type any[] */ (Schema.value(schema)).length; - // errors.push({ - // message: `The instance must be a 'string' or 'number'. Found 'boolean'`, - // instanceLocation: error.instanceLocation, - // schemaLocation: error.absoluteKeywordLocation - // }); - // } - // } - - // return errors; - // }, + + // `anyOf` handler + async (normalizedErrors, instance, schema) => { + /** @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)); + }); + 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" ] } + // ] + // } + } + } + } + return errors; + }, async (normalizedErrors) => { /** @type ErrorObject[] */ @@ -393,14 +426,88 @@ const errorHandlers = [ } ]; -// /** -// * Groups errors whose absoluteKeywordLocation starts with a given prefix. -// * @param {string} parentKeywordLocation -// * @param {NormalizedError[]} allErrors -// * @returns {NormalizedError[]} -// */ -// function applicatorChildErrors(parentKeywordLocation, allErrors) { -// return allErrors.filter((err) => -// /** @type string */ (err.absoluteKeywordLocation).startsWith(parentKeywordLocation + "/") -// ); -// } +/** + * 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; + + 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 7e78053..7568c57 100644 --- a/src/keywordErrorMessage.test.js +++ b/src/keywordErrorMessage.test.js @@ -607,42 +607,142 @@ describe("Error messages", () => { }]); }); - // test("anyOf where the instance doesn't match type of either of the alternatives", async () => { - // registerSchema({ - // $schema: "https://json-schema.org/draft/2020-12/schema", - // anyOf: [ - // { type: "string" }, - // { type: "number" } - // ] - // }, schemaUri); - // const instance = false; - - // /** @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(instance, output, schemaUri); - // expect(result.errors).to.eql([ - // { - // schemaLocation: "https://example.com/main#/anyOf", - // instanceLocation: "#", - // message: "The instance must be a 'string' or 'number'. Found 'boolean'" - // } - // ]); - // }); + test("anyOf where the instance doesn't match type of either of the alternatives", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + anyOf: [ + { type: "string" }, + { type: "number" } + ] + }, schemaUri); + const instance = false; + + /** @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(instance, output, schemaUri); + expect(result.errors).to.eql([ + { + schemaLocation: "https://example.com/main#/anyOf", + instanceLocation: "#", + message: `The instance must be a number or string. Found 'boolean'.` + } + ]); + }); + + test("anyOf - one type matches, but fails constraint (minLength)", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + anyOf: [ + { type: "string", minLength: 5 }, + { type: "number" } + ] + }, schemaUri); + + const instance = "abc"; + + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/0/minLength", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/1/type", + instanceLocation: "#" + }, + { + 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/0/minLength`, + instanceLocation: "#", + message: "The instance should be at least 5 characters" + } + ]); + }); + + test("anyOf - multiple types match, pick based on field overlap", async () => { + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + anyOf: [ + { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" } + }, + required: ["name", "age"] + }, + { + type: "object", + properties: { + title: { type: "string" }, + author: { type: "string" }, + ID: { type: "string", pattern: "^[0-9\\-]+$" } + }, + required: ["title", "author", "ID"] + } + ] + }, schemaUri); + + const instance = { + title: "Clean Code", + author: "Robert Martin", + ID: "NotValidId" + }; + + const output = { + valid: false, + errors: [ + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/1/properties/ID/pattern", + instanceLocation: "#/ID" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/0/required", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf/1/properties", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "https://example.com/main#/anyOf", + instanceLocation: "#" + } + ] + }; + const result = await betterJsonSchemaErrors(instance, output, schemaUri); + expect(result.errors).to.eql([ + { + schemaLocation: `${schemaUri}#/anyOf/1/properties/ID/pattern`, + instanceLocation: "#/ID", + message: "The instance should match the pattern: ^[0-9\\-]+$." + } + ]); + }); });