diff --git a/README.md b/README.md index 6d803ea..4c9b340 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,38 @@ console.log(errors); // ] ``` +With `@hyperjump/json-schema`, you can also get error messages directly from +validation. Importing this package adds a `JSE` output format to +`@hyperjump/json-schema`. It uses the full results of evaluation rather than +only what the standard output formats report. Use the `locale` option to choose +the language of the messages. The default is `en-US`, which is currently the +only locale available. + +```TypeScript +import { registerSchema, validate } from "@hyperjump/json-schema/draft-2020-12"; +import { JSE } from "@hyperjump/json-schema-errors"; + +const schemaUri = "https://example.com/schema/string"; +registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + + type: "string" +}); + +const output = await validate(schemaUri, 42, { outputFormat: JSE, locale: "en-US" }); +console.log(output); +// { +// valid: false, +// errors: [ +// { +// message: "Expected a string", +// instanceLocation: "#", +// schemaLocations: ["https://example.com/schema/string#/type"] +// } +// ] +// } +``` + If using this package with the results from another validator, you still need to register the schema. Here's an example using `@cfworker/json-schema`. @@ -145,8 +177,10 @@ addErrorHandler(async (normalizedErrors, instance, localization) => { Simple applicator keywords that just evaluate subschemas and don't make any assertions of their own don't need an error handler, only a normalization -handler. For example, support for the `allOf` keyword could look like the -following. +handler. Whether a keyword is a simple applicator comes from the +`simpleApplicator` property of its `@hyperjump/json-schema` keyword definition. +The results of its subschemas are merged into the results of the parent schema. +For example, support for the `allOf` keyword could look like the following. ```TypeScript import { setNormalizationHandler, evaluateSchema } from "@hyperjump/json-schema-errors"; @@ -156,8 +190,7 @@ const KEYWORD_URI = "https://json-schema.org/keyword/allOf"; setNormalizationHandler(KEYWORD_URI, { evaluate(allOf, instance, context) { return allOf.map((schemaLocation) => evaluateSchema(schemaLocation, instance, context)); - }, - simpleApplicator: true + } }); ``` diff --git a/package-lock.json b/package-lock.json index 13978e3..b91a783 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@fluent/bundle": "^0.19.1", "@hyperjump/json-pointer": "^1.1.1", - "@hyperjump/json-schema": "^1.17.2", + "@hyperjump/json-schema": "^1.18.0", "@hyperjump/pact": "^1.4.0", "@hyperjump/uri": "^1.3.2", "json-stringify-deterministic": "^1.0.12" @@ -356,9 +356,9 @@ } }, "node_modules/@hyperjump/json-schema": { - "version": "1.17.8", - "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.17.8.tgz", - "integrity": "sha512-XOqbR9GRNHaH4JEXHdbsm7xfYwudZG7HVDq3qPZUb1gi+ZQPklgNvhMi6zf0Plf433qR61MK+xeeprUwUUvGPg==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.18.0.tgz", + "integrity": "sha512-YPLJsCosW6pHhulKrFogUsl1WxQVRy+fY0pROt66FDFygjIWZkMxXyiy8HLRR1JKeAd5OE6NSZs0NVjSBNwb2Q==", "license": "MIT", "dependencies": { "@hyperjump/json-pointer": "^1.1.0", @@ -366,7 +366,6 @@ "@hyperjump/pact": "^1.2.0", "@hyperjump/uri": "^1.2.0", "content-type": "^1.0.4", - "json-stringify-deterministic": "^1.0.12", "just-curry-it": "^5.3.0", "uuid": "^14.0.0" }, diff --git a/package.json b/package.json index 350c1ef..58fd621 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "dependencies": { "@fluent/bundle": "^0.19.1", "@hyperjump/json-pointer": "^1.1.1", - "@hyperjump/json-schema": "^1.17.2", + "@hyperjump/json-schema": "^1.18.0", "@hyperjump/pact": "^1.4.0", "@hyperjump/uri": "^1.3.2", "json-stringify-deterministic": "^1.0.12" diff --git a/src/error-handlers/dependentSchemas.js b/src/error-handlers/dependentSchemas.js index 6e069ed..8ad9a09 100644 --- a/src/error-handlers/dependentSchemas.js +++ b/src/error-handlers/dependentSchemas.js @@ -3,9 +3,7 @@ import { describeConditional, evaluateRequirements, getCompiledKeywordValue, - getErrors, - getSuccesses, - mergeOutputs + getSuccesses } from "../json-schema-errors.js"; /** @@ -16,23 +14,8 @@ import { /** @type ErrorHandler */ const dependentSchemasErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { - /** @type ErrorObject[] */ - const errors = []; - - for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/dependentSchemas"]) { - const dependentSchemas = normalizedErrors["https://json-schema.org/keyword/dependentSchemas"][schemaLocation]; - if (dependentSchemas.valid !== false) { - continue; - } - - // Merged so errors from different dependencies can be combined - const merged = mergeOutputs(dependentSchemas.outputs ?? []); - errors.push(...getErrors(merged, instance, localization, ast)); - } - - return errors; - }, + // Failures in dependent schemas are merged into the parent schema's results, + // so they're reported by the handlers for the keywords that failed success: (normalizedOutput, instance, localization, ast) => { /** @type ErrorObject[] */ diff --git a/src/error-handlers/ifThenElse.js b/src/error-handlers/ifThenElse.js index 90930bd..896c610 100644 --- a/src/error-handlers/ifThenElse.js +++ b/src/error-handlers/ifThenElse.js @@ -2,7 +2,6 @@ import { describeConditional, evaluateRequirements, getCompiledKeywordValue, - getErrors, getSuccesses, someTrue } from "../json-schema-errors.js"; @@ -15,25 +14,8 @@ import { /** @type ErrorHandler */ const ifThenElseErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { - /** @type ErrorObject[] */ - const errors = []; - - for (const keywordUri of ["https://json-schema.org/keyword/then", "https://json-schema.org/keyword/else"]) { - for (const schemaLocation in normalizedErrors[keywordUri]) { - const keywordOutput = normalizedErrors[keywordUri][schemaLocation]; - if (keywordOutput.valid !== false) { - continue; - } - - for (const subschemaOutput of keywordOutput.outputs ?? []) { - errors.push(...getErrors(subschemaOutput, instance, localization, ast)); - } - } - } - - return errors; - }, + // Failures in 'then' and 'else' are merged into the parent schema's results, + // so they're reported by the handlers for the keywords that failed success: (normalizedOutput, instance, localization, ast) => { /** @type ErrorObject[] */ diff --git a/src/hyperjump-json-schema.test.js b/src/hyperjump-json-schema.test.js index f3d0f22..8749e74 100644 --- a/src/hyperjump-json-schema.test.js +++ b/src/hyperjump-json-schema.test.js @@ -12,7 +12,7 @@ import "@hyperjump/json-schema/draft-06"; import "@hyperjump/json-schema/draft-04"; import "@hyperjump/json-schema/formats"; import { BASIC } from "@hyperjump/json-schema/experimental"; -import { jsonSchemaErrors, validate as validateWithErrors } from "../src/index.js"; +import { JSE, jsonSchemaErrors } from "../src/index.js"; import { FluentBundle, FluentResource } from "@fluent/bundle"; import { translations } from "./translations/index.js"; @@ -88,7 +88,7 @@ const runTests = (dialectUri, dialect) => { expect(errors).to.eql(buildErrors(testCase.errors, schemaUri)); expectNestedLocations(errors); - const result = await validateWithErrors(schemaUri, instance); + const result = await validate(schemaUri, instance, JSE); const fullResultsErrors = result.valid ? [] : result.errors; expect(fullResultsErrors).to.eql(buildErrors(testCase.errorsWithFullResults ?? testCase.errors, schemaUri)); expectNestedLocations(fullResultsErrors); diff --git a/src/index.d.ts b/src/index.d.ts index f62cf6e..e1ed76e 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,4 +1,4 @@ -import { AST, CompiledSchema, EvaluationPlugin } from "@hyperjump/json-schema/experimental"; +import { AST, EvaluationPlugin } from "@hyperjump/json-schema/experimental"; import { JsonNode } from "@hyperjump/json-schema/instance/experimental"; import { Localization } from "./localization.js"; @@ -89,25 +89,15 @@ export type NormalizationHandler Promise -) & ( - (schemaUri: string, instance: Json, options?: ValidationOptions) => Promise -); - -export const evaluateCompiledSchema: (compiledSchema: CompiledSchema, instance: Json, options?: ValidationOptions) => ValidationResult; - -export type EvaluateInstance = (instance: Json, options?: ValidationOptions) => ValidationResult; - -export type ValidationOptions = { - /** - * A locale identifier in the form of "{language}-{region}". - * - * @example "en-US" - */ - locale?: string; - plugins?: EvaluationPlugin[]; -}; +export const JSE: "JSE"; -export type ValidationResult = { +export type JSEOutput = { valid: true; } | { valid: false; errors: JsonSchemaErrors; }; + +declare module "@hyperjump/json-schema" { + interface OutputFormats { + JSE: JSEOutput; + } + + interface ValidationOptions { + /** + * A locale identifier in the form of "{language}-{region}" used for the + * messages of the JSE output format. + * + * @example "en-US" + */ + locale?: string; + } +} diff --git a/src/index.js b/src/index.js index 69e5573..cb497e5 100644 --- a/src/index.js +++ b/src/index.js @@ -191,7 +191,6 @@ export { jsonSchemaErrors, removeErrorHandler, setErrorHandler, - setNormalizationHandler, - validate, - evaluateCompiledSchema + setNormalizationHandler } from "./json-schema-errors.js"; +export { JSE } from "./output-format.js"; diff --git a/src/json-schema-errors.js b/src/json-schema-errors.js index f9376b8..94a7d63 100644 --- a/src/json-schema-errors.js +++ b/src/json-schema-errors.js @@ -1,10 +1,9 @@ -import { compile, getKeyword, getSchema, Validation } from "@hyperjump/json-schema/experimental"; +import { compile, getKeyword, getSchema } from "@hyperjump/json-schema/experimental"; import * as Instance from "@hyperjump/json-schema/instance/experimental"; import * as Schema from "@hyperjump/browser"; import * as JsonPointer from "@hyperjump/json-pointer"; import { toAbsoluteIri } from "@hyperjump/uri"; import { Localization } from "./localization.js"; -import { JsonSchemaErrorsOutputPlugin } from "./output-plugin.js"; /** * @import * as API from "./index.d.ts" @@ -41,11 +40,6 @@ export const allowsAnyValue = (schemaLocation, ast) => { return schemaNode.every(([keywordUri]) => normalizationHandlers[toAbsoluteIri(keywordUri)]?.annotation); }; -/** @type (keywordUri: string) => boolean */ -export const isSimpleApplicator = (keywordUri) => { - return normalizationHandlers[toAbsoluteIri(keywordUri)]?.simpleApplicator ?? false; -}; - /** @type API.setNormalizationHandler */ export const setNormalizationHandler = (schemaUri, handler) => { normalizationHandlers[schemaUri] = handler; @@ -155,7 +149,7 @@ export const evaluateSchema = (schemaLocation, instance, context) => { plugins: context.plugins, // The output won't say what happened inside an applicator that passed isValidityUnknown: context.isValidityUnknown || ( - !keyword.simpleApplicator && !keyword.validityFromSubschemas && isKeywordValid === true + !validationKeyword.simpleApplicator && isKeywordValid === true ) }; for (const plugin of context.plugins) { @@ -165,8 +159,7 @@ export const evaluateSchema = (schemaLocation, instance, context) => { const keywordOutput = keyword.evaluate(keywordValue, instance, keywordContext); const isReported = context.errorIndex[keywordLocation]?.[keywordInstanceLocation] !== undefined; - const isValidityFromSubschemas = keyword.validityFromSubschemas || keyword.simpleApplicator; - if (isValidityFromSubschemas && !isReported && keywordOutput?.some(isFailing)) { + if (validationKeyword.simpleApplicator && !isReported && keywordOutput?.some(isFailing)) { isKeywordValid = false; } @@ -174,15 +167,15 @@ export const evaluateSchema = (schemaLocation, instance, context) => { valid = false; } - if (keyword.simpleApplicator) { - for (const suboutput of /** @type API.NormalizedOutput[] */ (keywordOutput)) { + if (validationKeyword.simpleApplicator) { + for (const suboutput of keywordOutput ?? []) { mergeOutput(output, suboutput); } } output[instanceLocation] ??= {}; output[instanceLocation][normalizedKeywordUri] ??= {}; - output[instanceLocation][normalizedKeywordUri][keywordLocation] = !keyword.simpleApplicator && keywordOutput + output[instanceLocation][normalizedKeywordUri][keywordLocation] = keywordOutput ? { valid: isKeywordValid, outputs: keywordOutput } : { valid: isKeywordValid }; @@ -409,16 +402,6 @@ const relocate = (errorObject, from, to) => { return relocated; }; -/** @type (outputs: API.NormalizedOutput[]) => API.NormalizedOutput */ -export const mergeOutputs = (outputs) => { - /** @type API.NormalizedOutput */ - const merged = {}; - for (const output of outputs) { - mergeOutput(merged, output); - } - return merged; -}; - /** @type (a: API.NormalizedOutput, b: API.NormalizedOutput) => void */ const mergeOutput = (a, b) => { for (const instanceLocation in b) { @@ -460,6 +443,63 @@ export const getErrors = (normalizedErrors, rootInstance, localization, ast) => return errors; }; +/** + * The results of a conditional keyword's subschemas are merged into the results + * of its parent schema like any simple applicator, but they only apply when the + * condition holds. The conditional keyword's handler describes them along with + * the condition, so they aren't described again as requirements of the parent. + * + * @type (normalizedOutput: API.NormalizedOutput) => API.NormalizedOutput + */ +const withoutConditionalResults = (normalizedOutput) => { + /** @type Set */ + const conditionalResults = new Set(); + for (const instanceLocation in normalizedOutput) { + for (const keywordUri in normalizedOutput[instanceLocation]) { + if (!normalizationHandlers[toAbsoluteIri(keywordUri)]?.conditional) { + continue; + } + + for (const keywordLocation in normalizedOutput[instanceLocation][keywordUri]) { + for (const output of normalizedOutput[instanceLocation][keywordUri][keywordLocation].outputs ?? []) { + for (const subInstanceLocation in output) { + for (const subKeywordUri in output[subInstanceLocation]) { + for (const subKeywordLocation in output[subInstanceLocation][subKeywordUri]) { + conditionalResults.add(resultKey(subInstanceLocation, subKeywordUri, subKeywordLocation)); + } + } + } + } + } + } + } + + if (conditionalResults.size === 0) { + return normalizedOutput; + } + + /** @type API.NormalizedOutput */ + const result = {}; + for (const instanceLocation in normalizedOutput) { + for (const keywordUri in normalizedOutput[instanceLocation]) { + for (const keywordLocation in normalizedOutput[instanceLocation][keywordUri]) { + if (conditionalResults.has(resultKey(instanceLocation, keywordUri, keywordLocation))) { + continue; + } + + result[instanceLocation] ??= {}; + result[instanceLocation][keywordUri] ??= {}; + result[instanceLocation][keywordUri][keywordLocation] = normalizedOutput[instanceLocation][keywordUri][keywordLocation]; + } + } + } + + return result; +}; + +/** @type (instanceLocation: string, keywordUri: string, keywordLocation: string) => string */ +const resultKey = (instanceLocation, keywordUri, keywordLocation) => JSON.stringify([instanceLocation, keywordUri, keywordLocation]); + /** @type API.getSuccesses */ export const getSuccesses = (normalizedOutput, rootInstance, localization, ast) => { // Descriptions of nested subschemas can get very large, so stop describing @@ -478,14 +518,16 @@ export const getSuccesses = (normalizedOutput, rootInstance, localization, ast) /** @type API.ErrorObject[] */ const successes = []; + const describedOutput = withoutConditionalResults(normalizedOutput); + descriptionDepth++; try { - for (const instanceLocation in normalizedOutput) { + for (const instanceLocation in describedOutput) { // Descriptions can be about values that don't exist yet const instance = getInstance(instanceLocation, rootInstance) ?? toPlaceholder(instanceLocation, rootInstance); for (const errorHandlerUri in errorHandlers) { - const successObjects = errorHandlers[errorHandlerUri].success?.(normalizedOutput[instanceLocation], instance, localization, ast) ?? []; + const successObjects = errorHandlers[errorHandlerUri].success?.(describedOutput[instanceLocation], instance, localization, ast) ?? []; successes.push(...successObjects); } } @@ -732,53 +774,3 @@ export const getSiblingKeywordLocation = (ast, schemaLocation, siblingKeywordUri return node[1]; }; - -/** - * @overload - * @param {string} schemaUri - * @returns {Promise} - * - * @overload - * @param {string} schemaUri - * @param {API.Json} instance - * @param {API.JsonSchemaErrorsOptions} [options] - * @returns {Promise} - * - * @param {string} schemaUri - * @param {API.Json} instance - * @param {API.JsonSchemaErrorsOptions} [options] - */ -export const validate = async (schemaUri, instance, options) => { - const schema = await getSchema(schemaUri); - const compiledSchema = await compile(schema); - - if (instance === undefined) { - /** @type API.EvaluateInstance */ - return (instance, options) => { - return evaluateCompiledSchema(compiledSchema, instance, options); - }; - } else { - return evaluateCompiledSchema(compiledSchema, instance, options); - } -}; - -/** @type API.evaluateCompiledSchema */ -export const evaluateCompiledSchema = (compiledSchema, instance, options = {}) => { - const localization = Localization.forLocale(options.locale ?? "en-US"); - const jsonNode = Instance.fromJs(instance); - const outputPlugin = new JsonSchemaErrorsOutputPlugin(); - const context = { - ast: compiledSchema.ast, - plugins: [...compiledSchema.ast.plugins, outputPlugin, ...options.plugins ?? []] - }; - const valid = Validation.interpret(compiledSchema.schemaUri, jsonNode, context); - - if (valid) { - return { valid }; - } else { - return { - valid, - errors: getErrors(outputPlugin.output, jsonNode, localization, compiledSchema.ast) - }; - } -}; diff --git a/src/normalization-handlers/additionalProperties.js b/src/normalization-handlers/additionalProperties.js index 6593298..9e9dad5 100644 --- a/src/normalization-handlers/additionalProperties.js +++ b/src/normalization-handlers/additionalProperties.js @@ -25,8 +25,7 @@ const additionalPropertiesNormalizationHandler = { } return outputs; - }, - simpleApplicator: true + } }; export default additionalPropertiesNormalizationHandler; diff --git a/src/normalization-handlers/allOf.js b/src/normalization-handlers/allOf.js index da5a787..6ea83ab 100644 --- a/src/normalization-handlers/allOf.js +++ b/src/normalization-handlers/allOf.js @@ -15,8 +15,7 @@ const allOfNormalizationHandler = { } return outputs; - }, - simpleApplicator: true + } }; export default allOfNormalizationHandler; diff --git a/src/normalization-handlers/dependentSchemas.js b/src/normalization-handlers/dependentSchemas.js index f3e80ad..8825645 100644 --- a/src/normalization-handlers/dependentSchemas.js +++ b/src/normalization-handlers/dependentSchemas.js @@ -24,7 +24,7 @@ const dependentSchemaNormalizationHandler = { return outputs; }, - validityFromSubschemas: true + conditional: true }; export default dependentSchemaNormalizationHandler; diff --git a/src/normalization-handlers/draft-04/additionalItems.js b/src/normalization-handlers/draft-04/additionalItems.js index 0a85eb7..c9d1497 100644 --- a/src/normalization-handlers/draft-04/additionalItems.js +++ b/src/normalization-handlers/draft-04/additionalItems.js @@ -21,8 +21,7 @@ const additionalItemsNormalizationHandler = { } return outputs; - }, - simpleApplicator: true + } }; export default additionalItemsNormalizationHandler; diff --git a/src/normalization-handlers/draft-04/items.js b/src/normalization-handlers/draft-04/items.js index 8ae00d2..bcc1101 100644 --- a/src/normalization-handlers/draft-04/items.js +++ b/src/normalization-handlers/draft-04/items.js @@ -29,8 +29,7 @@ const itemsDraft04NormalizationHandler = { } return outputs; - }, - simpleApplicator: true + } }; export default itemsDraft04NormalizationHandler; diff --git a/src/normalization-handlers/dynamicRef.js b/src/normalization-handlers/dynamicRef.js index 8920a5d..bfb7ea3 100644 --- a/src/normalization-handlers/dynamicRef.js +++ b/src/normalization-handlers/dynamicRef.js @@ -19,8 +19,7 @@ const dynamicRefNormalizationHandler = { } else { return [evaluateSchema(ref, instance, context)]; } - }, - simpleApplicator: true + } }; export default dynamicRefNormalizationHandler; diff --git a/src/normalization-handlers/else.js b/src/normalization-handlers/else.js index 6defa14..3376c49 100644 --- a/src/normalization-handlers/else.js +++ b/src/normalization-handlers/else.js @@ -9,7 +9,7 @@ const elseNormalizationHandler = { evaluate([, elseLocation], instance, context) { return [evaluateSchema(elseLocation, instance, context)]; }, - validityFromSubschemas: true + conditional: true }; export default elseNormalizationHandler; diff --git a/src/normalization-handlers/items.js b/src/normalization-handlers/items.js index 57f4dfa..1ea6e27 100644 --- a/src/normalization-handlers/items.js +++ b/src/normalization-handlers/items.js @@ -21,8 +21,7 @@ const itemsNormalizationHandler = { } return outputs; - }, - simpleApplicator: true + } }; export default itemsNormalizationHandler; diff --git a/src/normalization-handlers/patternProperties.js b/src/normalization-handlers/patternProperties.js index 0181f93..e1fe699 100644 --- a/src/normalization-handlers/patternProperties.js +++ b/src/normalization-handlers/patternProperties.js @@ -25,8 +25,7 @@ const patternPropertiesNormalizationHandler = { } return outputs; - }, - simpleApplicator: true + } }; export default patternPropertiesNormalizationHandler; diff --git a/src/normalization-handlers/prefixItems.js b/src/normalization-handlers/prefixItems.js index 2fc5925..8641dde 100644 --- a/src/normalization-handlers/prefixItems.js +++ b/src/normalization-handlers/prefixItems.js @@ -23,8 +23,7 @@ const prefixItemsNormalizationHandler = { } return outputs; - }, - simpleApplicator: true + } }; export default prefixItemsNormalizationHandler; diff --git a/src/normalization-handlers/properties.js b/src/normalization-handlers/properties.js index 6c443c6..e6dc7d2 100644 --- a/src/normalization-handlers/properties.js +++ b/src/normalization-handlers/properties.js @@ -24,8 +24,7 @@ const propertiesNormalizationHandler = { } return outputs; - }, - simpleApplicator: true + } }; export default propertiesNormalizationHandler; diff --git a/src/normalization-handlers/propertyNames.js b/src/normalization-handlers/propertyNames.js index de49b66..1e3a473 100644 --- a/src/normalization-handlers/propertyNames.js +++ b/src/normalization-handlers/propertyNames.js @@ -20,8 +20,7 @@ const propertyNamesNormalizationHandler = { } return outputs; - }, - simpleApplicator: true + } }; export default propertyNamesNormalizationHandler; diff --git a/src/normalization-handlers/ref.js b/src/normalization-handlers/ref.js index 479acdb..cf00a63 100644 --- a/src/normalization-handlers/ref.js +++ b/src/normalization-handlers/ref.js @@ -8,8 +8,7 @@ import { evaluateSchema } from "../json-schema-errors.js"; const refNormalizationHandler = { evaluate(ref, instance, context) { return [evaluateSchema(ref, instance, context)]; - }, - simpleApplicator: true + } }; export default refNormalizationHandler; diff --git a/src/normalization-handlers/then.js b/src/normalization-handlers/then.js index c56e95a..1c7c468 100644 --- a/src/normalization-handlers/then.js +++ b/src/normalization-handlers/then.js @@ -9,7 +9,7 @@ const thenNormalizationHandler = { evaluate([, then], instance, context) { return [evaluateSchema(then, instance, context)]; }, - validityFromSubschemas: true + conditional: true }; export default thenNormalizationHandler; diff --git a/src/normalization-handlers/unevaluatedItems.js b/src/normalization-handlers/unevaluatedItems.js index 4511b0e..ae177be 100644 --- a/src/normalization-handlers/unevaluatedItems.js +++ b/src/normalization-handlers/unevaluatedItems.js @@ -20,8 +20,7 @@ const unevaluatedItemsNormalizationHandler = { } return outputs; - }, - simpleApplicator: true + } }; export default unevaluatedItemsNormalizationHandler; diff --git a/src/normalization-handlers/unevaluatedProperties.js b/src/normalization-handlers/unevaluatedProperties.js index 94b866d..e749876 100644 --- a/src/normalization-handlers/unevaluatedProperties.js +++ b/src/normalization-handlers/unevaluatedProperties.js @@ -20,8 +20,7 @@ const unevaluatedPropertiesNormalizationHandler = { } return outputs; - }, - simpleApplicator: true + } }; export default unevaluatedPropertiesNormalizationHandler; diff --git a/src/output-format.js b/src/output-format.js new file mode 100644 index 0000000..e78ed32 --- /dev/null +++ b/src/output-format.js @@ -0,0 +1,24 @@ +import { setOutputFormat } from "@hyperjump/json-schema/experimental"; +import { getErrors } from "./json-schema-errors.js"; +import { Localization } from "./localization.js"; +import { JsonSchemaErrorsOutputPlugin } from "./output-plugin.js"; + +/** + * @import { OutputFormatHandler } from "@hyperjump/json-schema/experimental" + */ + +export const JSE = "JSE"; + +/** @type OutputFormatHandler */ +const jseOutputFormatHandler = (options) => { + const plugin = new JsonSchemaErrorsOutputPlugin(); + return { + plugin, + getErrors: (instance, context) => { + const localization = Localization.forLocale(options.locale ?? "en-US"); + return getErrors(plugin.output, instance, localization, context.ast); + } + }; +}; + +setOutputFormat(JSE, jseOutputFormatHandler); diff --git a/src/output-plugin.js b/src/output-plugin.js index 61b74b5..85041b5 100644 --- a/src/output-plugin.js +++ b/src/output-plugin.js @@ -1,5 +1,4 @@ import * as Instance from "@hyperjump/json-schema/instance/experimental"; -import { isSimpleApplicator } from "./json-schema-errors.js"; /** * @import { EvaluationPlugin, ValidationContext } from "@hyperjump/json-schema/experimental" @@ -30,15 +29,15 @@ export class JsonSchemaErrorsOutputPlugin { context.output = schemaContext.output; } + // Each keyword's result includes the output of its subschemas so the + // subschemas can be described. The results of a simple applicator's + // subschemas are also merged into the results of its parent schema. + /** @type NonNullable["afterKeyword"]> */ - afterKeyword(keywordNode, instance, context, valid, schemaContext) { + afterKeyword(keywordNode, instance, context, valid, schemaContext, keyword) { const [keywordUri, schemaLocation] = keywordNode; - // Whether the result is merged with its parent depends on how this package - // models the keyword rather than the validator. For example, 'if' never - // fails, but its result isn't a requirement of its parent. - const isSimple = isSimpleApplicator(keywordUri); - if (isSimple) { + if (keyword.simpleApplicator) { for (const subSchemaOutput of context.subSchemaOutput ?? []) { mergeOutput(schemaContext.output, subSchemaOutput); } @@ -46,7 +45,7 @@ export class JsonSchemaErrorsOutputPlugin { schemaContext.output[Instance.uri(instance)] ??= {}; schemaContext.output[Instance.uri(instance)][keywordUri] ??= {}; - schemaContext.output[Instance.uri(instance)][keywordUri][schemaLocation] = !isSimple && context.subSchemaOutput + schemaContext.output[Instance.uri(instance)][keywordUri][schemaLocation] = context.subSchemaOutput ? { valid, outputs: context.subSchemaOutput } : { valid }; } diff --git a/src/output-plugin.test.ts b/src/output-plugin.test.ts index 390774e..7c0f05f 100644 --- a/src/output-plugin.test.ts +++ b/src/output-plugin.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "vitest"; import { validate, registerSchema, unregisterSchema } from "@hyperjump/json-schema"; -import "./index.js"; import { JsonSchemaErrorsOutputPlugin } from "./output-plugin.js"; describe("JSON Schema Errors Output Format", () => { @@ -28,7 +27,18 @@ describe("JSON Schema Errors Output Format", () => { [`${schemaUri}#/$defs/string/type`]: { valid: false } }, "https://json-schema.org/keyword/ref": { - [`${schemaUri}#/$ref`]: { valid: false } + [`${schemaUri}#/$ref`]: { + valid: false, + outputs: [ + { + "#": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/$defs/string/type`]: { valid: false } + } + } + } + ] + } }, "https://json-schema.org/keyword/definitions": { [`${schemaUri}#/$defs`]: { valid: true } @@ -53,7 +63,18 @@ describe("JSON Schema Errors Output Format", () => { [`${schemaUri}#/$defs/string/type`]: { valid: true } }, "https://json-schema.org/keyword/ref": { - [`${schemaUri}#/$ref`]: { valid: true } + [`${schemaUri}#/$ref`]: { + valid: true, + outputs: [ + { + "#": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/$defs/string/type`]: { valid: true } + } + } + } + ] + } }, "https://json-schema.org/keyword/definitions": { [`${schemaUri}#/$defs`]: { valid: true } @@ -77,7 +98,18 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/additionalProperties": { - [`${schemaUri}#/additionalProperties`]: { valid: false } + [`${schemaUri}#/additionalProperties`]: { + valid: false, + outputs: [ + { + "#/foo": { + "https://json-schema.org/validation": { + [`${schemaUri}#/additionalProperties`]: { valid: false } + } + } + } + ] + } } } }); @@ -101,7 +133,25 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/additionalProperties": { - [`${schemaUri}#/additionalProperties`]: { valid: false } + [`${schemaUri}#/additionalProperties`]: { + valid: false, + outputs: [ + { + "#/foo": { + "https://json-schema.org/validation": { + [`${schemaUri}#/additionalProperties`]: { valid: false } + } + } + }, + { + "#/bar": { + "https://json-schema.org/validation": { + [`${schemaUri}#/additionalProperties`]: { valid: false } + } + } + } + ] + } } } }); @@ -122,7 +172,18 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/additionalProperties": { - [`${schemaUri}#/additionalProperties`]: { valid: false } + [`${schemaUri}#/additionalProperties`]: { + valid: false, + outputs: [ + { + "#/foo": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/additionalProperties/type`]: { valid: false } + } + } + } + ] + } } } }); @@ -163,7 +224,25 @@ describe("JSON Schema Errors Output Format", () => { [`${schemaUri}#/allOf/1/maximum`]: { valid: false } }, "https://json-schema.org/keyword/allOf": { - [`${schemaUri}#/allOf`]: { valid: false } + [`${schemaUri}#/allOf`]: { + valid: false, + outputs: [ + { + "#": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/allOf/0/type`]: { valid: true } + } + } + }, + { + "#": { + "https://json-schema.org/keyword/maximum": { + [`${schemaUri}#/allOf/1/maximum`]: { valid: false } + } + } + } + ] + } } } }); @@ -190,7 +269,25 @@ describe("JSON Schema Errors Output Format", () => { [`${schemaUri}#/allOf/1/maximum`]: { valid: false } }, "https://json-schema.org/keyword/allOf": { - [`${schemaUri}#/allOf`]: { valid: false } + [`${schemaUri}#/allOf`]: { + valid: false, + outputs: [ + { + "#": { + "https://json-schema.org/keyword/maximum": { + [`${schemaUri}#/allOf/0/maximum`]: { valid: false } + } + } + }, + { + "#": { + "https://json-schema.org/keyword/maximum": { + [`${schemaUri}#/allOf/1/maximum`]: { valid: false } + } + } + } + ] + } } } }); @@ -215,7 +312,25 @@ describe("JSON Schema Errors Output Format", () => { [`${schemaUri}#/allOf/1/maximum`]: { valid: true } }, "https://json-schema.org/keyword/allOf": { - [`${schemaUri}#/allOf`]: { valid: true } + [`${schemaUri}#/allOf`]: { + valid: true, + outputs: [ + { + "#": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/allOf/0/type`]: { valid: true } + } + } + }, + { + "#": { + "https://json-schema.org/keyword/maximum": { + [`${schemaUri}#/allOf/1/maximum`]: { valid: true } + } + } + } + ] + } } } }); @@ -512,6 +627,9 @@ describe("JSON Schema Errors Output Format", () => { expect(outputPlugin.output).to.eql({ "#": { + "https://json-schema.org/keyword/required": { + [`${schemaUri}#/dependentSchemas/foo/required`]: { valid: false } + }, "https://json-schema.org/keyword/dependentSchemas": { [`${schemaUri}#/dependentSchemas`]: { valid: false, @@ -542,6 +660,10 @@ describe("JSON Schema Errors Output Format", () => { expect(outputPlugin.output).to.eql({ "#": { + "https://json-schema.org/keyword/required": { + [`${schemaUri}#/dependentSchemas/foo/required`]: { valid: false }, + [`${schemaUri}#/dependentSchemas/bar/required`]: { valid: false } + }, "https://json-schema.org/keyword/dependentSchemas": { [`${schemaUri}#/dependentSchemas`]: { valid: false, @@ -578,6 +700,9 @@ describe("JSON Schema Errors Output Format", () => { expect(outputPlugin.output).to.eql({ "#": { + "https://json-schema.org/keyword/required": { + [`${schemaUri}#/dependentSchemas/foo/required`]: { valid: true } + }, "https://json-schema.org/keyword/dependentSchemas": { [`${schemaUri}#/dependentSchemas`]: { valid: true, @@ -622,6 +747,9 @@ describe("JSON Schema Errors Output Format", () => { ] } }, + "https://json-schema.org/keyword/minLength": { + [`${schemaUri}#/then/minLength`]: { valid: false } + }, "https://json-schema.org/keyword/then": { [`${schemaUri}#/then`]: { valid: false, @@ -664,6 +792,9 @@ describe("JSON Schema Errors Output Format", () => { ] } }, + "https://json-schema.org/keyword/minLength": { + [`${schemaUri}#/then/minLength`]: { valid: true } + }, "https://json-schema.org/keyword/then": { [`${schemaUri}#/then`]: { valid: true, @@ -712,6 +843,9 @@ describe("JSON Schema Errors Output Format", () => { ] } }, + "https://json-schema.org/keyword/minimum": { + [`${schemaUri}#/else/minimum`]: { valid: false } + }, "https://json-schema.org/keyword/else": { [`${schemaUri}#/else`]: { valid: false, @@ -758,6 +892,9 @@ describe("JSON Schema Errors Output Format", () => { ] } }, + "https://json-schema.org/keyword/minimum": { + [`${schemaUri}#/else/minimum`]: { valid: true } + }, "https://json-schema.org/keyword/else": { [`${schemaUri}#/else`]: { valid: true, @@ -798,7 +935,25 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/items": { - [`${schemaUri}#/items`]: { valid: false } + [`${schemaUri}#/items`]: { + valid: false, + outputs: [ + { + "#/0": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/items/type`]: { valid: false } + } + } + }, + { + "#/1": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/items/type`]: { valid: false } + } + } + } + ] + } } } }); @@ -819,7 +974,18 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/items": { - [`${schemaUri}#/items`]: { valid: true } + [`${schemaUri}#/items`]: { + valid: true, + outputs: [ + { + "#/0": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/items/type`]: { valid: true } + } + } + } + ] + } } } }); @@ -850,7 +1016,25 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/patternProperties": { - [`${schemaUri}#/patternProperties`]: { valid: false } + [`${schemaUri}#/patternProperties`]: { + valid: false, + outputs: [ + { + "#/foo": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/patternProperties/%5Ef/type`]: { valid: false } + } + } + }, + { + "#/bar": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/patternProperties/%5Eb/type`]: { valid: false } + } + } + } + ] + } } } }); @@ -879,7 +1063,25 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/patternProperties": { - [`${schemaUri}#/patternProperties`]: { valid: true } + [`${schemaUri}#/patternProperties`]: { + valid: true, + outputs: [ + { + "#/foo": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/patternProperties/%5Ef/type`]: { valid: true } + } + } + }, + { + "#/bar": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/patternProperties/%5Eb/type`]: { valid: true } + } + } + } + ] + } } } }); @@ -910,7 +1112,25 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/prefixItems": { - [`${schemaUri}#/prefixItems`]: { valid: false } + [`${schemaUri}#/prefixItems`]: { + valid: false, + outputs: [ + { + "#/0": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/prefixItems/0/type`]: { valid: false } + } + } + }, + { + "#/1": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/prefixItems/1/type`]: { valid: false } + } + } + } + ] + } } } }); @@ -939,7 +1159,25 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/prefixItems": { - [`${schemaUri}#/prefixItems`]: { valid: true } + [`${schemaUri}#/prefixItems`]: { + valid: true, + outputs: [ + { + "#/0": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/prefixItems/0/type`]: { valid: true } + } + } + }, + { + "#/1": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/prefixItems/1/type`]: { valid: true } + } + } + } + ] + } } } }); @@ -970,7 +1208,25 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/properties": { - [`${schemaUri}#/properties`]: { valid: false } + [`${schemaUri}#/properties`]: { + valid: false, + outputs: [ + { + "#/foo": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/properties/foo/type`]: { valid: false } + } + } + }, + { + "#/bar": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/properties/bar/type`]: { valid: false } + } + } + } + ] + } } } }); @@ -999,7 +1255,25 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/properties": { - [`${schemaUri}#/properties`]: { valid: true } + [`${schemaUri}#/properties`]: { + valid: true, + outputs: [ + { + "#/foo": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/properties/foo/type`]: { valid: true } + } + } + }, + { + "#/bar": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/properties/bar/type`]: { valid: true } + } + } + } + ] + } } } }); @@ -1027,7 +1301,25 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/propertyNames": { - [`${schemaUri}#/propertyNames`]: { valid: false } + [`${schemaUri}#/propertyNames`]: { + valid: false, + outputs: [ + { + "#*/banana": { + "https://json-schema.org/keyword/pattern": { + [`${schemaUri}#/propertyNames/pattern`]: { valid: false } + } + } + }, + { + "#*/pear": { + "https://json-schema.org/keyword/pattern": { + [`${schemaUri}#/propertyNames/pattern`]: { valid: false } + } + } + } + ] + } } } }); @@ -1048,7 +1340,18 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/propertyNames": { - [`${schemaUri}#/propertyNames`]: { valid: true } + [`${schemaUri}#/propertyNames`]: { + valid: true, + outputs: [ + { + "#*/apple": { + "https://json-schema.org/keyword/pattern": { + [`${schemaUri}#/propertyNames/pattern`]: { valid: true } + } + } + } + ] + } } } }); @@ -1069,7 +1372,18 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/unevaluatedProperties": { - [`${schemaUri}#/unevaluatedProperties`]: { valid: false } + [`${schemaUri}#/unevaluatedProperties`]: { + valid: false, + outputs: [ + { + "#/foo": { + "https://json-schema.org/validation": { + [`${schemaUri}#/unevaluatedProperties`]: { valid: false } + } + } + } + ] + } } } }); @@ -1094,10 +1408,34 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/properties": { - [`${schemaUri}#/properties`]: { valid: false } + [`${schemaUri}#/properties`]: { + valid: false, + outputs: [ + { + }, + { + "#/bar": { + "https://json-schema.org/validation": { + [`${schemaUri}#/properties/bar`]: { valid: false } + } + } + } + ] + } }, "https://json-schema.org/keyword/unevaluatedProperties": { - [`${schemaUri}#/unevaluatedProperties`]: { valid: false } + [`${schemaUri}#/unevaluatedProperties`]: { + valid: false, + outputs: [ + { + "#/baz": { + "https://json-schema.org/validation": { + [`${schemaUri}#/unevaluatedProperties`]: { valid: false } + } + } + } + ] + } } }, "#/baz": { @@ -1123,7 +1461,18 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/unevaluatedProperties": { - [`${schemaUri}#/unevaluatedProperties`]: { valid: false } + [`${schemaUri}#/unevaluatedProperties`]: { + valid: false, + outputs: [ + { + "#/foo": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/unevaluatedProperties/type`]: { valid: false } + } + } + } + ] + } } } }); @@ -1158,7 +1507,18 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/unevaluatedItems": { - [`${schemaUri}#/unevaluatedItems`]: { valid: false } + [`${schemaUri}#/unevaluatedItems`]: { + valid: false, + outputs: [ + { + "#/0": { + "https://json-schema.org/validation": { + [`${schemaUri}#/unevaluatedItems`]: { valid: false } + } + } + } + ] + } } } }); @@ -1179,7 +1539,18 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/unevaluatedItems": { - [`${schemaUri}#/unevaluatedItems`]: { valid: false } + [`${schemaUri}#/unevaluatedItems`]: { + valid: false, + outputs: [ + { + "#/0": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/unevaluatedItems/type`]: { valid: false } + } + } + } + ] + } } } }); @@ -1201,10 +1572,34 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/prefixItems": { - [`${schemaUri}#/prefixItems`]: { valid: false } + [`${schemaUri}#/prefixItems`]: { + valid: false, + outputs: [ + { + }, + { + "#/1": { + "https://json-schema.org/validation": { + [`${schemaUri}#/prefixItems/1`]: { valid: false } + } + } + } + ] + } }, "https://json-schema.org/keyword/unevaluatedItems": { - [`${schemaUri}#/unevaluatedItems`]: { valid: false } + [`${schemaUri}#/unevaluatedItems`]: { + valid: false, + outputs: [ + { + "#/2": { + "https://json-schema.org/validation": { + [`${schemaUri}#/unevaluatedItems`]: { valid: false } + } + } + } + ] + } } }, "#/2": { @@ -1844,7 +2239,18 @@ describe("JSON Schema Errors Output Format", () => { }, "#": { "https://json-schema.org/keyword/properties": { - [`${schemaUri}#/properties`]: { valid: false } + [`${schemaUri}#/properties`]: { + valid: false, + outputs: [ + { + "#/foo": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/properties/foo/type`]: { valid: false } + } + } + } + ] + } }, "https://json-schema.org/keyword/required": { [`${schemaUri}#/required`]: { valid: false } @@ -1874,12 +2280,50 @@ describe("JSON Schema Errors Output Format", () => { }, "#/foo": { "https://json-schema.org/keyword/properties": { - [`${schemaUri}#/properties/foo/properties`]: { valid: false } + [`${schemaUri}#/properties/foo/properties`]: { + valid: false, + outputs: [ + { + "#/foo/bar": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/properties/foo/properties/bar/type`]: { valid: false } + } + } + } + ] + } } }, "#": { "https://json-schema.org/keyword/properties": { - [`${schemaUri}#/properties`]: { valid: false } + [`${schemaUri}#/properties`]: { + valid: false, + outputs: [ + { + "#/foo/bar": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/properties/foo/properties/bar/type`]: { valid: false } + } + }, + "#/foo": { + "https://json-schema.org/keyword/properties": { + [`${schemaUri}#/properties/foo/properties`]: { + valid: false, + outputs: [ + { + "#/foo/bar": { + "https://json-schema.org/keyword/type": { + [`${schemaUri}#/properties/foo/properties/bar/type`]: { valid: false } + } + } + } + ] + } + } + } + } + ] + } } } });