diff --git a/src/index.js b/src/index.js index ac30e57..4cf93a8 100644 --- a/src/index.js +++ b/src/index.js @@ -10,18 +10,28 @@ import { jsonPointerStep, jsonValue } from "./jsonast-util.js"; -import { Output } from "./output.js"; /** - * @import { - * Json, - * JsonNode, - * JsonObjectNode, - * JsonStringNode - * } from "./jsonast.d.ts" + * @import { Json, JsonNode, JsonObjectNode, JsonStringNode } from "./jsonast.d.ts" */ +/** + * @typedef {{ + * instanceLocation: string; + * absoluteKeywordLocation: string; + * keywordLocation?: string; + * error?: string; + * }} OutputUnit + * + * @typedef {{ + * valid: true; + * } | { + * valid: false; + * errors?: OutputUnit[]; + * }} Output + */ + /** @type (schema: Json, instance: Json) => Output */ export const validate = (schema, instance) => { // Determine schema identifier @@ -39,33 +49,48 @@ export const validate = (schema, instance) => { } } - const output = validateSchema(schemaNode, toJsonNode(instance)); + /** @type OutputUnit[] */ + const errors = []; + const valid = validateSchema(schemaNode, toJsonNode(instance), errors); schemaRegistry.delete(uri); - return output; + return valid ? { valid } : { valid, errors }; }; -/** @type (schemaNode: JsonNode, instanceNode: JsonNode) => Output */ -const validateSchema = (schemaNode, instanceNode) => { +/** @type (schemaNode: JsonNode, instanceNode: JsonNode, errors: OutputUnit[]) => boolean */ +const validateSchema = (schemaNode, instanceNode, errors) => { if (schemaNode.type === "json") { switch (schemaNode.jsonType) { case "boolean": - return new Output(schemaNode.value, schemaNode, instanceNode); + if (!schemaNode.value) { + errors.push({ + absoluteKeywordLocation: schemaNode.location, + instanceLocation: instanceNode.location + }); + } + return schemaNode.value; + case "object": let isValid = true; for (const propertyNode of schemaNode.children) { const [keywordNode, keywordValueNode] = propertyNode.children; const keywordHandler = keywordHandlers.get(keywordNode.value); if (keywordHandler) { - const keywordOutput = keywordHandler(keywordValueNode, instanceNode, schemaNode); - if (!keywordOutput.valid) { + /** @type OutputUnit[] */ + const keywordErrors = []; + if (!keywordHandler(keywordValueNode, instanceNode, schemaNode, keywordErrors)) { isValid = false; + errors.push({ + absoluteKeywordLocation: keywordValueNode.location, + instanceLocation: instanceNode.location + }); + errors.push(...keywordErrors); } } } - return new Output(isValid, schemaNode, instanceNode); + return isValid; } } @@ -84,14 +109,15 @@ export const registerSchema = (schema, uri) => { * @typedef {( * keywordNode: JsonNode, * instanceNode: JsonNode, - * schemaNode: JsonObjectNode - * ) => Output} KeywordHandler + * schemaNode: JsonObjectNode, + * errors: OutputUnit[], + * ) => boolean} KeywordHandler */ /** @type Map */ const keywordHandlers = new Map(); -keywordHandlers.set("$ref", (refNode, instanceNode) => { +keywordHandlers.set("$ref", (refNode, instanceNode, _schemaNode, errors) => { assertNodeType(refNode, "string"); const uri = refNode.location.startsWith("#") @@ -106,13 +132,12 @@ keywordHandlers.set("$ref", (refNode, instanceNode) => { const pointer = decodeURI(parseIriReference(refNode.value).fragment ?? ""); const referencedSchemaNode = jsonPointerGet(pointer, schemaNode, uri); - const keywordOutput = validateSchema(referencedSchemaNode, instanceNode); - return new Output(keywordOutput.valid, refNode, instanceNode, keywordOutput.errors); + return validateSchema(referencedSchemaNode, instanceNode, errors); }); -keywordHandlers.set("additionalProperties", (additionalPropertiesNode, instanceNode, schemaNode) => { +keywordHandlers.set("additionalProperties", (additionalPropertiesNode, instanceNode, schemaNode, errors) => { if (instanceNode.jsonType !== "object") { - return new Output(true, additionalPropertiesNode, instanceNode); + return true; } const propertyPatterns = []; @@ -138,15 +163,12 @@ keywordHandlers.set("additionalProperties", (additionalPropertiesNode, instanceN let isValid = true; for (const propertyNode of instanceNode.children) { const [propertyNameNode, instancePropertyNode] = propertyNode.children; - if (!isDefinedProperty.test(propertyNameNode.value)) { - const schemaOutput = validateSchema(additionalPropertiesNode, instancePropertyNode); - if (!schemaOutput.valid) { - isValid = false; - } + if (!isDefinedProperty.test(propertyNameNode.value) && !validateSchema(additionalPropertiesNode, instancePropertyNode, errors)) { + isValid = false; } } - return new Output(isValid, additionalPropertiesNode, instanceNode); + return isValid; }); /** @type (string: string) => string */ @@ -154,54 +176,52 @@ const regexEscape = (string) => string .replace(/[|\\{}()[\]^$+*?.]/g, "\\$&") .replace(/-/g, "\\x2d"); -keywordHandlers.set("allOf", (allOfNode, instanceNode) => { +keywordHandlers.set("allOf", (allOfNode, instanceNode, _schemaNode, errors) => { assertNodeType(allOfNode, "array"); let isValid = true; for (const schemaNode of allOfNode.children) { - if (!validateSchema(schemaNode, instanceNode).valid) { + if (!validateSchema(schemaNode, instanceNode, errors)) { isValid = false; } } - return new Output(isValid, allOfNode, instanceNode); + return isValid; }); -keywordHandlers.set("anyOf", (anyOfNode, instanceNode) => { +keywordHandlers.set("anyOf", (anyOfNode, instanceNode, _schemaNode, errors) => { assertNodeType(anyOfNode, "array"); let isValid = false; for (const schemaNode of anyOfNode.children) { - const schemaOutput = validateSchema(schemaNode, instanceNode); - if (schemaOutput.valid) { + if (validateSchema(schemaNode, instanceNode, errors)) { isValid = true; } } - return new Output(isValid, anyOfNode, instanceNode); + + return isValid; }); -keywordHandlers.set("oneOf", (oneOfNode, instanceNode) => { +keywordHandlers.set("oneOf", (oneOfNode, instanceNode, _schemaNode, errors) => { assertNodeType(oneOfNode, "array"); let matches = 0; for (const schemaNode of oneOfNode.children) { - const schemaOutput = validateSchema(schemaNode, instanceNode); - if (schemaOutput.valid) { + if (validateSchema(schemaNode, instanceNode, errors)) { matches++; } } - return new Output(matches === 1, oneOfNode, instanceNode); + return matches === 1; }); keywordHandlers.set("not", (notNode, instanceNode) => { - const schemaOutput = validateSchema(notNode, instanceNode); - return new Output(!schemaOutput.valid, notNode, instanceNode); + return !validateSchema(notNode, instanceNode, []); }); -keywordHandlers.set("contains", (containsNode, instanceNode, schemaNode) => { +keywordHandlers.set("contains", (containsNode, instanceNode, schemaNode, errors) => { if (instanceNode.jsonType !== "array") { - return new Output(true, containsNode, instanceNode); + return true; } let minContains = 1; @@ -222,19 +242,17 @@ keywordHandlers.set("contains", (containsNode, instanceNode, schemaNode) => { let matches = 0; for (const itemNode of instanceNode.children) { - const schemaOutput = validateSchema(containsNode, itemNode); - if (schemaOutput.valid) { + if (validateSchema(containsNode, itemNode, errors)) { matches++; } } - const isValid = matches >= minContains && matches <= maxContains; - return new Output(isValid, containsNode, instanceNode); + return matches >= minContains && matches <= maxContains; }); -keywordHandlers.set("dependentSchemas", (dependentSchemasNode, instanceNode) => { +keywordHandlers.set("dependentSchemas", (dependentSchemasNode, instanceNode, _schemaNode, errors) => { if (instanceNode.jsonType !== "object") { - return new Output(true, dependentSchemasNode, instanceNode); + return true; } assertNodeType(dependentSchemasNode, "object"); @@ -242,44 +260,39 @@ keywordHandlers.set("dependentSchemas", (dependentSchemasNode, instanceNode) => let isValid = true; for (const propertyNode of dependentSchemasNode.children) { const [keyNode, schemaNode] = propertyNode.children; - if (jsonObjectHas(keyNode.value, instanceNode)) { - const schemaOutput = validateSchema(schemaNode, instanceNode); - if (!schemaOutput.valid) { - isValid = false; - } + if (jsonObjectHas(keyNode.value, instanceNode) && !validateSchema(schemaNode, instanceNode, errors)) { + isValid = false; } } - return new Output(isValid, dependentSchemasNode, instanceNode); + return isValid; }); -keywordHandlers.set("then", (thenNode, instanceNode, schemaNode) => { +keywordHandlers.set("then", (thenNode, instanceNode, schemaNode, errors) => { if (jsonObjectHas("if", schemaNode)) { const ifNode = jsonPointerStep("if", schemaNode); - const schemaOutput = validateSchema(ifNode, instanceNode); - if (schemaOutput.valid) { - return validateSchema(thenNode, instanceNode); + if (validateSchema(ifNode, instanceNode, [])) { + return validateSchema(thenNode, instanceNode, errors); } } - return new Output(true, thenNode, instanceNode); + return true; }); -keywordHandlers.set("else", (elseNode, instanceNode, schemaNode) => { +keywordHandlers.set("else", (elseNode, instanceNode, schemaNode, errors) => { if (jsonObjectHas("if", schemaNode)) { const ifNode = jsonPointerStep("if", schemaNode); - const schemaOutput = validateSchema(ifNode, instanceNode); - if (!schemaOutput.valid) { - return validateSchema(elseNode, instanceNode); + if (!validateSchema(ifNode, instanceNode, [])) { + return validateSchema(elseNode, instanceNode, errors); } } - return new Output(true, elseNode, instanceNode); + return true; }); -keywordHandlers.set("items", (itemsNode, instanceNode, schemaNode) => { +keywordHandlers.set("items", (itemsNode, instanceNode, schemaNode, errors) => { if (instanceNode.jsonType !== "array") { - return new Output(true, itemsNode, instanceNode); + return true; } let numberOfPrefixItems = 0; @@ -292,18 +305,17 @@ keywordHandlers.set("items", (itemsNode, instanceNode, schemaNode) => { let isValid = true; for (const itemNode of instanceNode.children.slice(numberOfPrefixItems)) { - const schemaOutput = validateSchema(itemsNode, itemNode); - if (!schemaOutput.valid) { + if (!validateSchema(itemsNode, itemNode, errors)) { isValid = false; } } - return new Output(isValid, itemsNode, instanceNode); + return isValid; }); -keywordHandlers.set("patternProperties", (patternPropertiesNode, instanceNode) => { +keywordHandlers.set("patternProperties", (patternPropertiesNode, instanceNode, _schemaNode, errors) => { if (instanceNode.jsonType !== "object") { - return new Output(true, patternPropertiesNode, instanceNode); + return true; } assertNodeType(patternPropertiesNode, "object"); @@ -315,41 +327,35 @@ keywordHandlers.set("patternProperties", (patternPropertiesNode, instanceNode) = for (const propertyNode of instanceNode.children) { const [propertyNameNode, propertyValueNode] = propertyNode.children; const propertyName = propertyNameNode.value; - if (pattern.test(propertyName)) { - const schemaOutput = validateSchema(patternSchemaNode, propertyValueNode); - if (!schemaOutput.valid) { - isValid = false; - } + if (pattern.test(propertyName) && !validateSchema(patternSchemaNode, propertyValueNode, errors)) { + isValid = false; } } } - return new Output(isValid, patternPropertiesNode, instanceNode); + return isValid; }); -keywordHandlers.set("prefixItems", (prefixItemsNode, instanceNode) => { +keywordHandlers.set("prefixItems", (prefixItemsNode, instanceNode, _schemaNode, errors) => { if (instanceNode.jsonType !== "array") { - return new Output(true, prefixItemsNode, instanceNode); + return true; } assertNodeType(prefixItemsNode, "array"); let isValid = true; for (let index = 0; index < instanceNode.children.length; index++) { - if (prefixItemsNode.children[index]) { - const schemaOutput = validateSchema(prefixItemsNode.children[index], instanceNode.children[index]); - if (!schemaOutput.valid) { - isValid = false; - } + if (prefixItemsNode.children[index] && !validateSchema(prefixItemsNode.children[index], instanceNode.children[index], errors)) { + isValid = false; } } - return new Output(isValid, prefixItemsNode, instanceNode); + return isValid; }); -keywordHandlers.set("properties", (propertiesNode, instanceNode) => { +keywordHandlers.set("properties", (propertiesNode, instanceNode, _schemaNode, errors) => { if (instanceNode.jsonType !== "object") { - return new Output(true, propertiesNode, instanceNode); + return true; } assertNodeType(propertiesNode, "object"); @@ -359,19 +365,18 @@ keywordHandlers.set("properties", (propertiesNode, instanceNode) => { const [propertyNameNode, instancePropertyNode] = jsonPropertyNode.children; if (jsonObjectHas(propertyNameNode.value, propertiesNode)) { const schemaPropertyNode = jsonPointerStep(propertyNameNode.value, propertiesNode); - const schemaOutput = validateSchema(schemaPropertyNode, instancePropertyNode); - if (!schemaOutput.valid) { + if (!validateSchema(schemaPropertyNode, instancePropertyNode, errors)) { isValid = false; } } } - return new Output(isValid, propertiesNode, instanceNode); + return isValid; }); -keywordHandlers.set("propertyNames", (propertyNamesNode, instanceNode) => { +keywordHandlers.set("propertyNames", (propertyNamesNode, instanceNode, _schemaNode, errors) => { if (instanceNode.jsonType !== "object") { - return new Output(true, propertyNamesNode, instanceNode); + return true; } let isValid = true; @@ -383,178 +388,155 @@ keywordHandlers.set("propertyNames", (propertyNamesNode, instanceNode) => { value: propertyNode.children[0].value, location: JsonPointer.append(propertyNode.children[0].value, instanceNode.location) }; - const schemaOutput = validateSchema(propertyNamesNode, keyNode); - if (!schemaOutput.valid) { + if (!validateSchema(propertyNamesNode, keyNode, errors)) { isValid = false; } } - return new Output(isValid, propertyNamesNode, instanceNode); + return isValid; }); keywordHandlers.set("const", (constNode, instanceNode) => { - const isValid = jsonStringify(jsonValue(instanceNode)) === jsonStringify(jsonValue(constNode)); - return new Output(isValid, constNode, instanceNode); + return jsonStringify(jsonValue(instanceNode)) === jsonStringify(jsonValue(constNode)); }); keywordHandlers.set("dependentRequired", (dependentRequiredNode, instanceNode) => { if (instanceNode.jsonType !== "object") { - return new Output(true, dependentRequiredNode, instanceNode); + return true; } assertNodeType(dependentRequiredNode, "object"); - let isValid = true; - for (const propertyNode of dependentRequiredNode.children) { + return dependentRequiredNode.children.every((propertyNode) => { const [keyNode, requiredPropertiesNode] = propertyNode.children; - if (jsonObjectHas(keyNode.value, instanceNode)) { - assertNodeType(requiredPropertiesNode, "array"); - const isConditionValid = requiredPropertiesNode.children.every((requiredPropertyNode) => { - assertNodeType(requiredPropertyNode, "string"); - return jsonObjectHas(requiredPropertyNode.value, instanceNode); - }); - - if (!isConditionValid) { - isValid = false; - } + if (!jsonObjectHas(keyNode.value, instanceNode)) { + return true; } - } - return new Output(isValid, dependentRequiredNode, instanceNode); + assertNodeType(requiredPropertiesNode, "array"); + return requiredPropertiesNode.children.every((requiredPropertyNode) => { + assertNodeType(requiredPropertyNode, "string"); + return jsonObjectHas(requiredPropertyNode.value, instanceNode); + }); + }); }); keywordHandlers.set("enum", (enumNode, instanceNode) => { assertNodeType(enumNode, "array"); const instanceValue = jsonStringify(jsonValue(instanceNode)); - for (const enumItemNode of enumNode.children) { - if (jsonStringify(jsonValue(enumItemNode)) === instanceValue) { - return new Output(true, enumNode, instanceNode); - } - } - return new Output(false, enumNode, instanceNode); + return enumNode.children.some((enumItemNode) => jsonStringify(jsonValue(enumItemNode)) === instanceValue); }); keywordHandlers.set("exclusiveMaximum", (exclusiveMaximumNode, instanceNode) => { if (instanceNode.jsonType !== "number") { - return new Output(true, exclusiveMaximumNode, instanceNode); + return true; } assertNodeType(exclusiveMaximumNode, "number"); - const isValid = instanceNode.value < exclusiveMaximumNode.value; - return new Output(isValid, exclusiveMaximumNode, instanceNode); + return instanceNode.value < exclusiveMaximumNode.value; }); keywordHandlers.set("exclusiveMinimum", (exclusiveMinimumNode, instanceNode) => { if (instanceNode.jsonType !== "number") { - return new Output(true, exclusiveMinimumNode, instanceNode); + return true; } assertNodeType(exclusiveMinimumNode, "number"); - const isValid = instanceNode.value > exclusiveMinimumNode.value; - return new Output(isValid, exclusiveMinimumNode, instanceNode); + return instanceNode.value > exclusiveMinimumNode.value; }); keywordHandlers.set("maxItems", (maxItemsNode, instanceNode) => { if (instanceNode.jsonType !== "array") { - return new Output(true, maxItemsNode, instanceNode); + return true; } assertNodeType(maxItemsNode, "number"); - const isValid = instanceNode.children.length <= maxItemsNode.value; - return new Output(isValid, maxItemsNode, instanceNode); + return instanceNode.children.length <= maxItemsNode.value; }); keywordHandlers.set("minItems", (minItemsNode, instanceNode) => { if (instanceNode.jsonType !== "array") { - return new Output(true, minItemsNode, instanceNode); + return true; } assertNodeType(minItemsNode, "number"); - const isValid = instanceNode.children.length >= minItemsNode.value; - return new Output(isValid, minItemsNode, instanceNode); + return instanceNode.children.length >= minItemsNode.value; }); keywordHandlers.set("maxLength", (maxLengthNode, instanceNode) => { if (instanceNode.jsonType !== "string") { - return new Output(true, maxLengthNode, instanceNode); + return true; } assertNodeType(maxLengthNode, "number"); - const isValid = [...instanceNode.value].length <= maxLengthNode.value; - return new Output(isValid, maxLengthNode, instanceNode); + return [...instanceNode.value].length <= maxLengthNode.value; }); keywordHandlers.set("minLength", (minLengthNode, instanceNode) => { if (instanceNode.jsonType !== "string") { - return new Output(true, minLengthNode, instanceNode); + return true; } assertNodeType(minLengthNode, "number"); - const isValid = [...instanceNode.value].length >= minLengthNode.value; - return new Output(isValid, minLengthNode, instanceNode); + return [...instanceNode.value].length >= minLengthNode.value; }); keywordHandlers.set("maxProperties", (maxPropertiesNode, instanceNode) => { if (instanceNode.jsonType !== "object") { - return new Output(true, maxPropertiesNode, instanceNode); + return true; } assertNodeType(maxPropertiesNode, "number"); - const isValid = instanceNode.children.length <= maxPropertiesNode.value; - return new Output(isValid, maxPropertiesNode, instanceNode); + return instanceNode.children.length <= maxPropertiesNode.value; }); keywordHandlers.set("minProperties", (minPropertiesNode, instanceNode) => { if (instanceNode.jsonType !== "object") { - return new Output(true, minPropertiesNode, instanceNode); + return true; } assertNodeType(minPropertiesNode, "number"); - const isValid = instanceNode.children.length >= minPropertiesNode.value; - return new Output(isValid, minPropertiesNode, instanceNode); + return instanceNode.children.length >= minPropertiesNode.value; }); keywordHandlers.set("maximum", (maximumNode, instanceNode) => { if (instanceNode.jsonType !== "number") { - return new Output(true, maximumNode, instanceNode); + return true; } assertNodeType(maximumNode, "number"); - const isValid = instanceNode.value <= maximumNode.value; - return new Output(isValid, maximumNode, instanceNode); + return instanceNode.value <= maximumNode.value; }); keywordHandlers.set("minimum", (minimumNode, instanceNode) => { if (instanceNode.jsonType !== "number") { - return new Output(true, minimumNode, instanceNode); + return true; } assertNodeType(minimumNode, "number"); - const isValid = instanceNode.value >= minimumNode.value; - return new Output(isValid, minimumNode, instanceNode); + return instanceNode.value >= minimumNode.value; }); keywordHandlers.set("multipleOf", (multipleOfNode, instanceNode) => { if (instanceNode.jsonType !== "number") { - return new Output(true, multipleOfNode, instanceNode); + return true; } assertNodeType(multipleOfNode, "number"); const remainder = instanceNode.value % multipleOfNode.value; - const isValid = numberEqual(0, remainder) || numberEqual(multipleOfNode.value, remainder); - return new Output(isValid, multipleOfNode, instanceNode); + return numberEqual(0, remainder) || numberEqual(multipleOfNode.value, remainder); }); /** @type (a: number, b: number) => boolean */ @@ -562,52 +544,44 @@ const numberEqual = (a, b) => Math.abs(a - b) < 1.19209290e-7; keywordHandlers.set("pattern", (patternNode, instanceNode) => { if (instanceNode.jsonType !== "string") { - return new Output(true, patternNode, instanceNode); + return true; } assertNodeType(patternNode, "string"); - const isValid = new RegExp(patternNode.value, "u").test(instanceNode.value); - return new Output(isValid, patternNode, instanceNode); + return new RegExp(patternNode.value, "u").test(instanceNode.value); }); keywordHandlers.set("required", (requiredNode, instanceNode) => { if (instanceNode.jsonType !== "object") { - return new Output(true, requiredNode, instanceNode); + return true; } assertNodeType(requiredNode, "array"); + for (const requiredPropertyNode of requiredNode.children) { assertNodeType(requiredPropertyNode, "string"); if (!jsonObjectHas(requiredPropertyNode.value, instanceNode)) { - return new Output(false, requiredNode, instanceNode); + return false; } } - return new Output(true, requiredNode, instanceNode); + return true; }); keywordHandlers.set("type", (typeNode, instanceNode) => { - if (typeNode.type === "json") { - if (typeNode.jsonType === "string") { - return new Output(isTypeOf(instanceNode, typeNode.value), typeNode, instanceNode); - } - - if (typeNode.jsonType === "array") { - for (const itemNode of typeNode.children) { - if (itemNode.type !== "json" || itemNode.jsonType != "string") { - throw Error("Invalid Schema"); - } - - if (isTypeOf(instanceNode, itemNode.value)) { - return new Output(true, typeNode, instanceNode); - } - } + switch (typeNode.jsonType) { + case "string": + return isTypeOf(instanceNode, typeNode.value); + + case "array": + return typeNode.children.some((itemNode) => { + assertNodeType(itemNode, "string"); + return isTypeOf(instanceNode, itemNode.value); + }); - return new Output(false, typeNode, instanceNode); - } + default: + throw Error("Invalid Schema"); } - - throw Error("Invalid Schema"); }); /** @type (instanceNode: JsonNode, type: string) => boolean */ @@ -617,26 +591,25 @@ const isTypeOf = (instance, type) => type === "integer" keywordHandlers.set("uniqueItems", (uniqueItemsNode, instanceNode) => { if (instanceNode.jsonType !== "array") { - return new Output(true, uniqueItemsNode, instanceNode); + return true; } assertNodeType(uniqueItemsNode, "boolean"); if (uniqueItemsNode.value === false) { - return new Output(true, uniqueItemsNode, instanceNode); + return true; } const normalizedItems = instanceNode.children.map((itemNode) => jsonStringify(jsonValue(itemNode))); - const isValid = new Set(normalizedItems).size === normalizedItems.length; - return new Output(isValid, uniqueItemsNode, instanceNode); + return new Set(normalizedItems).size === normalizedItems.length; }); -keywordHandlers.set("$id", (idNode, instanceNode, schemaNode) => { +keywordHandlers.set("$id", (idNode, _instanceNode, schemaNode) => { if (!idNode.location.endsWith("#/$id")) { throw Error(`Embedded schemas are not supported. Found at ${schemaNode.location}`); } - return new Output(true, idNode, instanceNode); + return true; }); keywordHandlers.set("$anchor", (anchorNode) => { diff --git a/src/jsonast-util.js b/src/jsonast-util.js index 9fb40de..7245900 100644 --- a/src/jsonast-util.js +++ b/src/jsonast-util.js @@ -19,16 +19,18 @@ import * as JsonPointer from "@hyperjump/json-pointer"; /** @type (json: Json, uri?: string, pointer?: string) => JsonNode */ export const toJsonNode = (json, uri = "", pointer = "") => { + const location = `${uri}#${encodeURI(pointer)}`; + switch (typeof json) { case "boolean": - return { type: "json", jsonType: "boolean", value: json, location: `${uri}#${pointer}` }; + return { type: "json", jsonType: "boolean", value: json, location: location }; case "number": - return { type: "json", jsonType: "number", value: json, location: `${uri}#${pointer}` }; + return { type: "json", jsonType: "number", value: json, location: location }; case "string": - return { type: "json", jsonType: "string", value: json, location: `${uri}#${pointer}` }; + return { type: "json", jsonType: "string", value: json, location: location }; case "object": if (json === null) { - return { type: "json", jsonType: "null", value: json, location: `${uri}#${pointer}` }; + return { type: "json", jsonType: "null", value: json, location: location }; } else if (Array.isArray(json)) { return { type: "json", @@ -40,7 +42,7 @@ export const toJsonNode = (json, uri = "", pointer = "") => { }; } else { /** @type JsonObjectNode */ - const objectNode = { type: "json", jsonType: "object", children: [], location: `${uri}#${pointer}` }; + const objectNode = { type: "json", jsonType: "object", children: [], location: location }; for (const property in json) { /** @type JsonPropertyNode */ @@ -87,7 +89,7 @@ export const jsonPointerStep = (segment, node, uri = "#") => { }; /** @type (pointer: string, tree: JsonNode, uri?: string) => JsonNode */ -export const jsonPointerGet = (pointer, tree, uri) => { +export const jsonPointerGet = (pointer, tree, uri = "") => { let currentPointer = ""; let node = tree; for (const segment of pointerSegments(pointer)) { diff --git a/src/output.js b/src/output.js deleted file mode 100644 index 558247e..0000000 --- a/src/output.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @import { JsonNode } from "./jsonast.d.ts" - */ - - -export class Output { - valid; - instanceLocation; - absoluteKeywordLocation; - errors; - - /** - * @param {boolean} valid - * @param {JsonNode} keywordNode - * @param {JsonNode} instanceNode - * @param {Output[]} [errors] - */ - constructor(valid, keywordNode, instanceNode, errors) { - this.valid = valid; - this.absoluteKeywordLocation = keywordNode.location; - this.instanceLocation = instanceNode.location; - - if (errors) { - this.errors = errors; - } - } -} diff --git a/src/output.test.js b/src/output.test.js new file mode 100644 index 0000000..9e92aa0 --- /dev/null +++ b/src/output.test.js @@ -0,0 +1,1109 @@ +import { describe, expect, test } from "vitest"; +import { validate } from "./index.js"; + + +describe("Basic Output Format", () => { + describe("$ref", () => { + test("invalid", () => { + const output = validate({ + $ref: "#/$defs/string", + $defs: { + string: { type: "string" } + } + }, 42); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/$ref", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/$defs/string/type", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + $ref: "#/$defs/string", + $defs: { + string: { type: "string" } + } + }, "foo"); + + expect(output).to.eql({ valid: true }); + }); + }); + + describe("additionalProperties", () => { + test("invalid", () => { + const output = validate({ additionalProperties: false }, { foo: 42 }); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/additionalProperties", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/additionalProperties", + instanceLocation: "#/foo" + } + ] + }); + }); + + test("invalid - multiple errors", () => { + const output = validate({ additionalProperties: false }, { foo: 42, bar: 24 }); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/additionalProperties", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/additionalProperties", + instanceLocation: "#/foo" + }, + { + absoluteKeywordLocation: "#/additionalProperties", + instanceLocation: "#/bar" + } + ] + }); + }); + + test("invalid - schema", () => { + const output = validate({ + additionalProperties: { type: "string" } + }, { foo: 42 }); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/additionalProperties", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/additionalProperties/type", + instanceLocation: "#/foo" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ additionalProperties: true }, {}); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("allOf", () => { + test("invalid", () => { + const output = validate({ + allOf: [ + { type: "number" }, + { maximum: 5 } + ] + }, 42); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/allOf", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/allOf/1/maximum", + instanceLocation: "#" + } + ] + }); + }); + + test("invalid - multiple errors", () => { + const output = validate({ + type: "number", + allOf: [ + { maximum: 2 }, + { maximum: 5 } + ] + }, 42); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/allOf", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/allOf/0/maximum", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/allOf/1/maximum", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + allOf: [ + { type: "number" }, + { maximum: 5 } + ] + }, 3); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("anyOf", () => { + test("invalid", () => { + const output = validate({ + anyOf: [ + { type: "string" }, + { type: "number" } + ] + }, true); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/anyOf", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/anyOf/0/type", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/anyOf/1/type", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + anyOf: [ + { type: "string" }, + { type: "number" } + ] + }, "foo"); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("oneOf", () => { + test("invalid", () => { + const output = validate({ + oneOf: [ + { type: "string" }, + { type: "number" } + ] + }, true); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/oneOf", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/oneOf/0/type", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/oneOf/1/type", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + oneOf: [ + { type: "string" }, + { type: "number" } + ] + }, "foo"); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("not", () => { + test("invalid", () => { + const output = validate({ + not: { type: "number" } + }, 42); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/not", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + not: { type: "number" } + }, "foo"); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("contains", () => { + test("invalid", () => { + const output = validate({ + contains: { type: "string" } + }, [1, 2]); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/contains", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/contains/type", + instanceLocation: "#/0" + }, + { + absoluteKeywordLocation: "#/contains/type", + instanceLocation: "#/1" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + contains: { type: "string" } + }, [1, "foo"]); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("dependentSchemas", () => { + test("invalid", () => { + const output = validate({ + dependentSchemas: { + foo: { required: ["a"] } + } + }, { foo: 42 }); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/dependentSchemas", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/dependentSchemas/foo/required", + instanceLocation: "#" + } + ] + }); + }); + + test("invalid - multiple conditions fail", () => { + const output = validate({ + dependentSchemas: { + foo: { required: ["a"] }, + bar: { required: ["b"] } + } + }, { foo: 42, bar: 24 }); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/dependentSchemas", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/dependentSchemas/foo/required", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/dependentSchemas/bar/required", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + dependentSchemas: { + foo: { required: ["a"] } + } + }, { foo: 42, a: true }); + + expect(output).to.eql({ valid: true }); + }); + }); + + describe("then", () => { + test("invalid", () => { + const output = validate({ + if: { type: "string" }, + then: { minLength: 1 } + }, ""); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/then", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/then/minLength", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + if: { type: "string" }, + then: { minLength: 1 } + }, "foo"); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("else", () => { + test("invalid", () => { + const output = validate({ + type: ["string", "number"], + if: { type: "string" }, + else: { minimum: 42 } + }, 5); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/else", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/else/minimum", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + type: ["string", "number"], + if: { type: "string" }, + else: { minimum: 5 } + }, 42); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("items", () => { + test("invalid", () => { + const output = validate({ + items: { type: "string" } + }, [42, 24]); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/items", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/items/type", + instanceLocation: "#/0" + }, + { + absoluteKeywordLocation: "#/items/type", + instanceLocation: "#/1" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + items: { type: "string" } + }, ["foo"]); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("patternProperties", () => { + test("invalid", () => { + const output = validate({ + patternProperties: { + "^f": { type: "string" }, + "^b": { type: "number" } + } + }, { foo: 42, bar: true }); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/patternProperties", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/patternProperties/%5Ef/type", + instanceLocation: "#/foo" + }, + { + absoluteKeywordLocation: "#/patternProperties/%5Eb/type", + instanceLocation: "#/bar" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + patternProperties: { + "^f": { type: "string" }, + "^b": { type: "number" } + } + }, { foo: "a", bar: 42 }); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("prefixItems", () => { + test("invalid", () => { + const output = validate({ + prefixItems: [ + { type: "string" }, + { type: "number" } + ] + }, [42, "foo"]); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/prefixItems", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/prefixItems/0/type", + instanceLocation: "#/0" + }, + { + absoluteKeywordLocation: "#/prefixItems/1/type", + instanceLocation: "#/1" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + prefixItems: [ + { type: "string" }, + { type: "number" } + ] + }, ["foo", 42]); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("properties", () => { + test("invalid", () => { + const output = validate({ + properties: { + foo: { type: "string" }, + bar: { type: "number" } + } + }, { foo: 42, bar: true }); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/properties", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/properties/foo/type", + instanceLocation: "#/foo" + }, + { + absoluteKeywordLocation: "#/properties/bar/type", + instanceLocation: "#/bar" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + properties: { + foo: { type: "string" }, + bar: { type: "number" } + } + }, { foo: "a", bar: 42 }); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("propertyNames", () => { + test("invalid", () => { + const output = validate({ + propertyNames: { pattern: "^a" } + }, { banana: true, pear: false }); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/propertyNames", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/propertyNames/pattern", + instanceLocation: "#/banana" + }, + { + absoluteKeywordLocation: "#/propertyNames/pattern", + instanceLocation: "#/pear" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + propertyNames: { pattern: "^a" } + }, { apple: true }); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("const", () => { + test("invalid", () => { + const output = validate({ const: "foo" }, 42); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/const", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ const: "foo" }, "foo"); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("dependentRequired", () => { + test("invalid", () => { + const output = validate({ + dependentRequired: { + foo: ["a"] + } + }, { foo: 42 }); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/dependentRequired", + instanceLocation: "#" + } + ] + }); + }); + + test("invalid - multiple conditions fail", () => { + const output = validate({ + dependentRequired: { + foo: ["a"], + bar: ["b"] + } + }, { foo: 42, bar: 24 }); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/dependentRequired", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ + dependentRequired: { + foo: ["a"] + } + }, { foo: 42, a: true }); + + expect(output).to.eql({ valid: true }); + }); + }); + + describe("enum", () => { + test("invalid", () => { + const output = validate({ enum: ["foo"] }, 42); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/enum", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ enum: ["foo"] }, "foo"); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("exclusiveMaximum", () => { + test("invalid", () => { + const output = validate({ exclusiveMaximum: 5 }, 42); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/exclusiveMaximum", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ exclusiveMaximum: 42 }, 5); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("exclusiveMinimum", () => { + test("invalid", () => { + const output = validate({ exclusiveMinimum: 42 }, 5); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/exclusiveMinimum", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ exclusiveMinimum: 5 }, 42); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("maxItems", () => { + test("invalid", () => { + const output = validate({ maxItems: 1 }, [1, 2]); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/maxItems", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ maxItems: 1 }, []); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("minItems", () => { + test("invalid", () => { + const output = validate({ minItems: 1 }, []); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/minItems", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ minItems: 1 }, [1, 2]); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("maxLength", () => { + test("invalid", () => { + const output = validate({ maxLength: 2 }, "foo"); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/maxLength", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ maxLength: 2 }, "a"); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("minLength", () => { + test("invalid", () => { + const output = validate({ minLength: 2 }, "a"); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/minLength", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ minLength: 1 }, "foo"); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("maxProperties", () => { + test("invalid", () => { + const output = validate({ maxProperties: 1 }, { a: 1, b: 2 }); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/maxProperties", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ maxProperties: 1 }, {}); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("minProperties", () => { + test("invalid", () => { + const output = validate({ minProperties: 1 }, {}); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/minProperties", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ minProperties: 1 }, { a: 1, b: 2 }); + + expect(output).to.eql({ valid: true }); + }); + }); + + describe("maximum", () => { + test("invalid", () => { + const output = validate({ maximum: 5 }, 42); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/maximum", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ maximum: 42 }, 5); + + expect(output).to.eql({ valid: true }); + }); + }); + + describe("minimum", () => { + test("invalid", () => { + const output = validate({ minimum: 42 }, 5); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/minimum", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ minimum: 5 }, 42); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("multipleOf", () => { + test("invalid", () => { + const output = validate({ multipleOf: 2 }, 3); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/multipleOf", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ multipleOf: 2 }, 4); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("pattern", () => { + test("invalid", () => { + const output = validate({ pattern: "^a" }, "banana"); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/pattern", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ pattern: "^a" }, "apple"); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("required", () => { + test("invalid", () => { + const output = validate({ required: ["a"] }, {}); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/required", + instanceLocation: "#" + } + ] + }); + }); + + test("invalid - multiple missing", () => { + const output = validate({ required: ["a", "b"] }, {}); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/required", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ required: ["a"] }, { a: 1 }); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("type", () => { + test("invalid", () => { + const output = validate({ type: "string" }, 42); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/type", + instanceLocation: "#" + } + ] + }); + }); + + test("invalid - multiple types", () => { + const output = validate({ type: ["string", "null"] }, 42); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/type", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ type: "string" }, "foo"); + expect(output).to.eql({ valid: true }); + }); + }); + + describe("uniqueItems", () => { + test("invalid", () => { + const output = validate({ uniqueItems: true }, [1, 1]); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/uniqueItems", + instanceLocation: "#" + } + ] + }); + }); + + test("valid", () => { + const output = validate({ uniqueItems: true }, [1, 2]); + expect(output).to.eql({ valid: true }); + }); + }); + + test("Multiple errors in schema", () => { + const output = validate({ + properties: { + foo: { type: "string" }, + bar: { type: "boolean" } + }, + required: ["foo", "bar"] + }, { foo: 42 }); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/properties", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/properties/foo/type", + instanceLocation: "#/foo" + }, + { + absoluteKeywordLocation: "#/required", + instanceLocation: "#" + } + ] + }); + }); + + test("Deeply nested", () => { + const output = validate({ + properties: { + foo: { + properties: { + bar: { type: "boolean" } + } + } + } + }, { foo: { bar: 42 } }); + + expect(output).to.eql({ + valid: false, + errors: [ + { + absoluteKeywordLocation: "#/properties", + instanceLocation: "#" + }, + { + absoluteKeywordLocation: "#/properties/foo/properties", + instanceLocation: "#/foo" + }, + { + absoluteKeywordLocation: "#/properties/foo/properties/bar/type", + instanceLocation: "#/foo/bar" + } + ] + }); + }); +});