Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions src/error-handlers/anyOf.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as Instance from "@hyperjump/json-schema/instance/experimental";
import * as Pact from "@hyperjump/pact";
import { allTrue, flattenOutput, getErrors, getSuccesses, someTrue } from "../json-schema-errors.js";
import { allTrue, flattenOutput, getErrorsFromFlattenedOutput, getSuccesses, someTrue } from "../json-schema-errors.js";

/**
* @import { ErrorHandler, ErrorObject, InstanceOutput } from "../index.d.ts"
Expand Down Expand Up @@ -34,9 +34,7 @@ const anyOfErrorHandler = {
const alternatives = [];
const instanceLocation = Instance.uri(instance);

for (const [index, alternative] of anyOf.entries()) {
const results = alternativeResults[index];

for (const results of alternativeResults) {
// Filter alternatives whose declared type doesn't match the instance type
const typeResults = results[instanceLocation]?.["https://json-schema.org/keyword/type"];
if (typeResults && !Object.values(typeResults).every(({ valid }) => valid)) {
Expand All @@ -56,13 +54,13 @@ const anyOfErrorHandler = {
}

// The alternative passed all the filters
alternatives.push(getErrors(alternative, instance, context));
alternatives.push(getErrorsFromFlattenedOutput(results, instance, context));
}

// If all alternatives were filtered out, default to returning all of them
if (alternatives.length === 0) {
for (const alternative of anyOf) {
alternatives.push(getErrors(alternative, instance, context));
for (const results of alternativeResults) {
alternatives.push(getErrorsFromFlattenedOutput(results, instance, context));
}
}

Expand Down
24 changes: 14 additions & 10 deletions src/error-handlers/oneOf.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as Instance from "@hyperjump/json-schema/instance/experimental";
import * as Pact from "@hyperjump/pact";
import { allowsAnyValue, allTrue, countTrue, flattenOutput, getErrors, getSuccesses, getValidity, negate, someTrue } from "../json-schema-errors.js";
import { allowsAnyValue, allTrue, countTrue, flattenOutput, getErrorsFromFlattenedOutput, getSuccesses, getValidityFromFlattenedOutput, negate, someTrue } from "../json-schema-errors.js";

/**
* @import { JsonNode } from "@hyperjump/json-schema/instance/experimental"
Expand All @@ -19,10 +19,13 @@ const oneOfErrorHandler = {
continue;
}
const oneOf = oneOfOutput.outputs ?? [];
const alternativeResults = oneOf.map(flattenOutput);

const alternativeLocations = /** @type string[] */ (normalizedErrors["https://json-schema.org/keyword/oneOf"][schemaLocation].value);
const matches = alternativeLocations.flatMap((alternativeLocation, index) => {
return getValidity(oneOf[index]) === true ? [{ alternativeLocation, output: oneOf[index] }] : [];
// Descriptions need the original output to keep conditional requirements separate.
// An alternative without output has unknown validity, so it isn't a match.
const matches = alternativeResults.flatMap((results, index) => {
return getValidityFromFlattenedOutput(results) === true ? [{ alternativeLocation: alternativeLocations[index], output: oneOf[index] }] : [];
});
if (matches.length > 1) {
errors.push(multipleMatchesError(matches, schemaLocation, instance, context));
Expand All @@ -35,17 +38,17 @@ const oneOfErrorHandler = {
Pact.collectArray
);

const alternativeResults = oneOf.map(flattenOutput);
const discriminators = propertyLocations.filter((propertyLocation) => {
return alternativeResults.some((results) => isPassingProperty(results[propertyLocation]));
});

const alternatives = [];
const instanceLocation = Instance.uri(instance);
// Errors of alternatives that passed the filters, kept for the fallback
/** @type ErrorObject[][] */
const errorsByAlternative = [];

for (const [index, alternative] of oneOf.entries()) {
const results = alternativeResults[index];

for (const [index, results] of alternativeResults.entries()) {
// Filter alternatives whose declared type doesn't match the instance type
const typeResults = results[instanceLocation]?.["https://json-schema.org/keyword/type"];
if (typeResults && !Object.values(typeResults).every(({ valid }) => valid)) {
Expand All @@ -65,16 +68,17 @@ const oneOfErrorHandler = {
}

// The alternative passed all the filters
const alternativeErrors = getErrors(alternative, instance, context);
const alternativeErrors = getErrorsFromFlattenedOutput(results, instance, context);
errorsByAlternative[index] = alternativeErrors;
if (alternativeErrors.length) {
alternatives.push(alternativeErrors);
}
}

// If all alternatives were filtered out, default to returning all of them
if (alternatives.length === 0) {
for (const alternative of oneOf) {
const alternativeErrors = getErrors(alternative, instance, context);
for (const [index, results] of alternativeResults.entries()) {
const alternativeErrors = errorsByAlternative[index] ?? getErrorsFromFlattenedOutput(results, instance, context);
if (alternativeErrors.length) {
alternatives.push(alternativeErrors);
}
Expand Down
24 changes: 20 additions & 4 deletions src/json-schema-errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ import { Localization } from "./localization.js";
* @import { JsonNode } from "@hyperjump/json-schema/instance/experimental"
*/

/**
* Normalized output with the results of simple applicators' subschemas
* flattened into the parent schema's results. The brand keeps unflattened
* output from being passed where flattened output is expected.
*
* @typedef {API.NormalizedOutput & { readonly __flattened: true }} FlattenedOutput
*/

/** @type API.jsonSchemaErrors */
export const jsonSchemaErrors = async (errorOutput, schemaUri, instance, options = {}) => {
const rootInstance = Instance.fromJs(instance);
Expand Down Expand Up @@ -443,10 +451,14 @@ export const removeErrorHandler = (errorHandlerUri) => {

/** @type API.getErrors */
export const getErrors = (normalizedErrors, rootInstance, context) => {
return getErrorsFromFlattenedOutput(flattenOutput(normalizedErrors), rootInstance, context);
};

/** @type (results: FlattenedOutput, rootInstance: JsonNode, context: API.ErrorHandlerContext) => API.ErrorObject[] */
export const getErrorsFromFlattenedOutput = (reportedOutput, rootInstance, context) => {
/** @type API.ErrorObject[] */
const errors = [];

const reportedOutput = flattenOutput(normalizedErrors);
for (const instanceLocation in reportedOutput) {
const instance = /** @type JsonNode */ (Instance.get(instanceLocation, rootInstance));
for (const errorHandlerUri in errorHandlers) {
Expand All @@ -458,8 +470,8 @@ export const getErrors = (normalizedErrors, rootInstance, context) => {
return errors;
};

/** @type (normalizedOutput: API.NormalizedOutput) => API.NormalizedOutput */
export const flattenOutput = (normalizedOutput) => flatten(normalizedOutput, isApplied);
/** @type (normalizedOutput: API.NormalizedOutput) => FlattenedOutput */
export const flattenOutput = (normalizedOutput) => /** @type FlattenedOutput */ (flatten(normalizedOutput, isApplied));

/**
* The results of a simple applicator's subschemas are results of its parent
Expand Down Expand Up @@ -679,10 +691,14 @@ const sizeOf = (errorObjects) => errorObjects.reduce((size, errorObject) => {
* @type API.getValidity
*/
export const getValidity = (normalizedOutput) => {
return getValidityFromFlattenedOutput(flattenOutput(normalizedOutput));
};

/** @type (results: FlattenedOutput) => boolean | undefined */
export const getValidityFromFlattenedOutput = (results) => {
/** @type boolean | undefined */
let validity = true;

const results = flattenOutput(normalizedOutput);
for (const instanceLocation in results) {
for (const keywordUri in results[instanceLocation]) {
for (const schemaLocation in results[instanceLocation][keywordUri]) {
Expand Down
32 changes: 32 additions & 0 deletions src/test-suite/tests/anyOf.json
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,38 @@
"schemaLocations": ["#/anyOf/1/properties/b/type"]
}
]
},
{
"description": "anyOf reports an applied conditional in a selected alternative",
"compatibility": "7",
"schema": {
"anyOf": [
{
"if": {
"type": "number"
},
"then": {
"minimum": 5
}
},
{
"type": "string"
}
]
},
"instance": 1,
"errors": [
{
"messageId": "minimum-message",
"messageParams": {
"minimum": 5
},
"instanceLocation": "#",
"schemaLocations": [
"#/anyOf/0/then/minimum"
]
}
]
}
]
}
172 changes: 172 additions & 0 deletions src/test-suite/tests/oneOf.json
Original file line number Diff line number Diff line change
Expand Up @@ -2324,6 +2324,178 @@
"schemaLocations": ["#/oneOf"]
}
]
},
{
"description": "oneOf reports an applied conditional in a selected alternative",
"compatibility": "7",
"schema": {
"oneOf": [
{
"if": {
"type": "number"
},
"then": {
"minimum": 5
}
},
{
"type": "string"
}
]
},
"instance": 1,
"errors": [
{
"messageId": "minimum-message",
"messageParams": {
"minimum": 5
},
"instanceLocation": "#",
"schemaLocations": [
"#/oneOf/0/then/minimum"
]
}
]
},
{
"description": "oneOf matching alternative keeps direct and conditional references separate",
"compatibility": "2019",
"schema": {
"oneOf": [
{
"$ref": "#/$defs/user"
},
{
"required": [
"other"
]
}
],
"$defs": {
"base": {
"required": [
"id"
]
},
"admin": {
"$ref": "#/$defs/base",
"required": [
"permissions"
]
},
"user": {
"$ref": "#/$defs/base",
"if": {
"required": [
"admin"
]
},
"then": {
"$ref": "#/$defs/admin"
}
}
}
},
"instance": {
"id": 1,
"admin": true,
"permissions": [],
"other": true
},
"errors": [
{
"messageId": "oneOf-multiple-matches-message",
"alternatives": [
[
{
"messageId": "count-true-message",
"messageParams": {
"kind": "atLeast",
"min": 1
},
"alternatives": [
[
{
"messageId": "required-negated-message",
"messageParams": {
"count": 1,
"required": {
"or": [
"'admin'"
]
}
},
"instanceLocation": "#",
"schemaLocations": [
"#/$defs/user/if/required"
]
}
],
[
{
"messageId": "required-success-message",
"messageParams": {
"count": 2,
"required": {
"and": [
"'id'",
"'permissions'"
]
}
},
"instanceLocation": "#",
"schemaLocations": [
"#/$defs/base/required",
"#/$defs/admin/required"
]
}
]
],
"instanceLocation": "#",
"schemaLocations": [
"#/$defs/user/if"
]
},
{
"messageId": "required-success-message",
"messageParams": {
"count": 1,
"required": {
"and": [
"'id'"
]
}
},
"instanceLocation": "#",
"schemaLocations": [
"#/$defs/base/required"
]
}
],
[
{
"messageId": "required-success-message",
"messageParams": {
"count": 1,
"required": {
"and": [
"'other'"
]
}
},
"instanceLocation": "#",
"schemaLocations": [
"#/oneOf/1/required"
]
}
]
],
"instanceLocation": "#",
"schemaLocations": [
"#/oneOf"
]
}
]
}
]
}
Loading