You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

115 lines
5.0 KiB
JavaScript

"use strict";
// TODO: Rename this to something more appropriate, like any/anyOf? How to make sure that doesn't cause confusion with `oneOf`?
const flatten = require("flatten");
const ValidationError = require("@validatem/error");
const combinator = require("@validatem/combinator");
const validationResult = require("@validatem/validation-result");
const matchValidationError = require("@validatem/match-validation-error");
const areErrorsFromSameOrigin = require("./src/are-errors-from-same-origin");
function unpackNestedEithers(errors) {
// NOTE: We only unpack `either` errors that occurred *at the same level* as this error, ie. where there's directly a case of `either(either(...), ...)`, without any kind of data nesting (like `arrayOf` or `hasShape`) inbetween. Nested-data failures should still be shown separately, as their resolution strategy is actually different; unlike same-level nested `either` errors, where the nesting is purely an implementation detail that allows composing sets of alternatives together.
return flatten(errors.map((error) => {
if (error.path.length === 0 && error.__isValidatemEitherError) {
return error.subErrors;
} else {
return error;
}
}));
}
function hasNestedPaths(error) {
if (error.path.length > 0) {
return true;
} else if (error.subErrors != null) {
return error.subErrors.some((subError) => {
return hasNestedPaths(subError);
});
} else {
return false;
}
}
function errorAtLeastOneOf(errors) {
return new ValidationError(`Must satisfy at least one of:`, {
subErrors: unpackNestedEithers(errors),
__isValidatemEitherError: true
});
}
function errorAllOf(errors) {
return new ValidationError("Must satisfy all of the following:", {
subErrors: errors
});
}
module.exports = function (alternatives) {
if (!Array.isArray(alternatives)) {
throw new Error(`Must specify an array of alternatives`);
} else if (alternatives.length < 2) {
// This doesn't interfere with conditionally-specified alternatives using ternary expressions, because in those cases there is still *some* item specified, it's just going to have a value of `undefined` (and will subsequently be filtered out)
throw new Error("Must specify at least two alternatives");
} else if (arguments.length > 1) {
throw new Error(`Only one argument is accepted; maybe you forgot to wrap the different alternatives into an array?`);
} else {
return combinator((value, applyValidators, context) => {
let allErrors = [];
for (let alternative of alternatives) {
let { errors, newValue } = applyValidators(value, alternative);
let unexpectedErrors = errors.filter((error) => !matchValidationError(error));
if (unexpectedErrors.length > 0) {
// We want to immediately stop trying alternatives when a non-ValidationError occurred, since that means that something broke internally somewhere, and it is not safe to continue executing.
throw unexpectedErrors[0];
} else {
if (errors.length === 0) {
return newValue;
} else if (errors.length === 1) {
allErrors.push(errors[0]);
} else {
allErrors.push(errorAllOf(errors));
}
}
}
/*
We want to separate out the errors that occurred at this "level"; that is, errors that *didn't* originate from a nested validation combinator like `has-shape` or `array-of`.
Otherwise, the user could get very confusing errors that combine the `either` alternatives and some deeper validation error into a single list, without denoting the path.
An example of such a confusing error:
- At options -> credentials: Must satisfy at least one of: "Must be a plain object (eg. object literal)", "Encountered an unexpected property 'foo'"
With this implementation, it becomes a perfectly reasonable error instead:
- At options -> credentials -> 1: Encountered an unexpected property 'foo'"
*/
let nestedErrors = allErrors.filter((error) => {
return hasNestedPaths(error);
});
if (!context.__validatemNoHeuristics && nestedErrors.length > 0) {
let sameOrigin = areErrorsFromSameOrigin(nestedErrors);
if (sameOrigin || nestedErrors.length === 1) {
// One of the alternatives *did* match, but it failed somewhere further down the tree, and we don't have any errors originating from *other* nested rules. Chances are that the user intended to match the failing branch, so we pretend that the other alternatives don't exist, and pass through the original error(s).
return validationResult({ errors: nestedErrors });
} else {
// Somewhere, possibly nested inside of a "must satisfy all", we've seen errors at varying subpaths. This means that the user probably tried to match some sort of arm, but we don't know which, because the origins are not identical. Therefore we will just remove all the top-level errors, and only show the ones that are at *some* sort of sub-path.
throw errorAtLeastOneOf(nestedErrors);
}
} else {
// We have no idea what the intention was, as there are no nested errors. Just show all of the errors.
throw errorAtLeastOneOf(allErrors);
}
});
}
};