Skip to content

Commit

Permalink
Add more functionality to external validators
Browse files Browse the repository at this point in the history
  • Loading branch information
asologor committed Oct 3, 2022
1 parent f41d429 commit babffbe
Show file tree
Hide file tree
Showing 5 changed files with 452 additions and 26 deletions.
81 changes: 80 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -784,14 +784,92 @@ Adds an external validation rule where:
- `value` - a clone of the object containing the value being validated.
- `helpers` - an object with the following helpers:
- `prefs` - the current preferences.
- `path` - ordered array where each element is the accessor to the value where the error happened.
- `label` - label of the value. If you are validating an object's property, it will contain the name of that property.
- `root` - the root object or primitive value under validation.
- `context` - same as `root`, but contains only the closest parent object in case of nested objects validation.
- `error` - a function with signature `function(message)`. You can use it in a return statement (`return error('Oops!')`) or you can call it multiple times if you want to push more than one error message in a single external validator.
- `description` - optional string used to document the purpose of the method.

Note that external validation rules are only called after the all other validation rules for the
entire schema (from the value root) are checked. This means that any changes made to the value by
the external rules are not available to any other validation rules during the non-external
validation phase.

If schema validation failed, no external validation rules are called.
By default, if schema validation fails, no external validation rules are called. You can change this
behavior by using `abortEarly: false` and `alwaysExecuteExternals: true` settings together.

Chains of external validation rules abort early regardless of any settings.

If your validator returns a replacement value after it added an error (using `error` helper), the replacement value will be ignored.

A few examples:
```js
const data = {
foo: {
bar: 'baz'
}
};

await Joi.object({
foo: {
bar: Joi.any().external((value, { prefs, path, label, root, context, error }) => {
// "prefs" object contains current validation settings
// value === 'baz'
// path === ['foo', 'bar']
// label === 'foo.bar'
// root === { foo: { bar: 'baz' } }
// context === { bar: 'baz' }

if (value !== 'hello') {
return error(`"${value}" is not a valid value for prop ${label}`);
}
})
}
}).validateAsync(data);
```

```js
// an example of a reusable validator with additional params
const exists = (tableName, columnName) => {
columnName ??= 'id';

return async (value, { label, error }) => {
const count = await doQueryTheDatabase(`SELECT COUNT(*) FROM ${tableName} WHERE ${columnName} = ?`, value);

if (count < 1) {
return error(`${label} in invalid. Record does not exist.`);
}
};
}

const data = {
userId: 123,
bookCode: 'AE-1432',
};

const schema = Joi.object({
userId: Joi.number().external(exists('users')),
bookCode: Joi.string().external(exists('books', 'code'))
});

await schema.validateAsync(data);
```
```js
Joi.any().external((value, { error }) => {
// you can add more than one error in a single validator
error('error 1');
error('error 2');

// you can return at any moment
if (value === 'hi!') {
return;
}

error('error 3');
})
```
#### `any.extract(path)`
Expand Down Expand Up @@ -1131,6 +1209,7 @@ Validates a value using the current schema and options where:
- `string` - the characters used around each array string values. Defaults to `false`.
- `wrapArrays` - if `true`, array values in error messages are wrapped in `[]`. Defaults to `true`.
- `externals` - if `false`, the external rules set with [`any.external()`](#anyexternalmethod-description) are ignored, which is required to ignore any external validations in synchronous mode (or an exception is thrown). Defaults to `true`.
- `alwaysExecuteExternals` - if `true`, and `abortEarly` is `false`, the external rules set with [`any.external()`](#anyexternalmethod-description) will be executed even after synchronous validators have failed. This setting has no effect if `abortEarly` is `true` since external rules get executed after all other validators. Default: `false`.
- `messages` - overrides individual error messages. Defaults to no override (`{}`). Use the `'*'` error code as a catch-all for all error codes that do not have a message provided in the override. Messages use the same rules as [templates](#template-syntax). Variables in double braces `{{var}}` are HTML escaped if the option `errors.escapeHtml` is set to `true`.
- `noDefaults` - when `true`, do not apply default values. Defaults to `false`.
- `nonEnumerables` - when `true`, inputs are shallow cloned to include non-enumerable properties. Defaults to `false`.
Expand Down
1 change: 1 addition & 0 deletions lib/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ exports.defaults = {
}
},
externals: true,
alwaysExecuteExternals: false,
messages: {},
nonEnumerables: false,
noDefaults: false,
Expand Down
13 changes: 13 additions & 0 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@ declare namespace Joi {
* @default true
*/
externals?: boolean;
/**
* if true, and "abortEarly" is false, the external rules set with `any.external()` will be executed even after synchronous validators have failed.
* This setting has no effect if "abortEarly" is true since external rules get executed after all other validators. Default: false.
*
* @default true
*/
alwaysExecuteExternals?: boolean;
/**
* when true, do not apply default values.
*
Expand Down Expand Up @@ -731,9 +738,15 @@ declare namespace Joi {

interface ExternalHelpers {
prefs: ValidationOptions;
path: string[],
label: string,
root: any,
context: any,
error: ExternalValidationFunctionErrorCallback,
}

type ExternalValidationFunction<V = any> = (value: V, helpers: ExternalHelpers) => V | undefined;
type ExternalValidationFunctionErrorCallback = (message: string) => void;

type SchemaLikeWithoutArray = string | number | boolean | null | Schema | SchemaMap;
type SchemaLike = SchemaLikeWithoutArray | object;
Expand Down
124 changes: 99 additions & 25 deletions lib/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,49 +62,123 @@ exports.entryAsync = async function (value, schema, prefs) {
result.error.debug = mainstay.debug;
}

throw result.error;
if (settings.abortEarly || !settings.alwaysExecuteExternals) {
throw result.error;
}
}

if (mainstay.externals.length) {
// group externals by their paths
const groups = {};

mainstay.externals.forEach((row) => {

if (typeof groups[row.label] === 'undefined') {
groups[row.label] = [];
}

groups[row.label].push(row);
});

const groupedExternals = Object.keys(groups).map((label) => groups[label]);

if (groupedExternals.length) {
let root = result.value;
for (const { method, path, label } of mainstay.externals) {
let node = root;
let key;
let parent;

if (path.length) {
key = path[path.length - 1];
parent = Reach(root, path.slice(0, -1));
node = parent[key];
}

try {
const output = await method(node, { prefs });
if (output === undefined ||
output === node) {
for (const externalsGroup of groupedExternals) {
let groupErrors = [];

continue;
for (const { method, path, label } of externalsGroup) {
let errors = [];
let node = root;
let key;
let parent;

if (path.length) {
key = path[path.length - 1];
parent = Reach(root, path.slice(0, -1));
node = parent[key];
}

if (parent) {
parent[key] = output;
try {
const output = await method(
node,
{
prefs,
path,
label,
root,
context: parent ?? root,
error: (message) => {

errors.push(message);
}
}
);

if (errors.length) {
// prepare errors
if (settings.abortEarly) {
// take only the first error if abortEarly is true
errors = errors.slice(0, 1);
}

errors = errors.map((message) => ({
message,
path,
type: 'external',
context: { value: node, label }
}));

groupErrors = [...groupErrors, ...errors];

// do not execute other externals from the group
break;
}

if (output === undefined ||
output === node) {

continue;
}

if (parent) {
parent[key] = output;
}
else {
root = output;
}
}
else {
root = output;
catch (err) {
if (settings.errors.label) {
err.message += ` (${label})`; // Change message to include path
}

throw err;
}
}
catch (err) {
if (settings.errors.label) {
err.message += ` (${label})`; // Change message to include path

if (groupErrors.length) {
if (result.error) {
result.error.details = [...result.error.details, ...groupErrors];
}
else {
result.error = new Errors.ValidationError('Invalid input', groupErrors, value);
}

throw err;
if (settings.abortEarly) {
// do not execute any other externals at all
break;
}
}
}

result.value = root;
}

if (result.error) {
throw result.error;
}

if (!settings.warnings &&
!settings.debug &&
!settings.artifacts) {
Expand Down

0 comments on commit babffbe

Please sign in to comment.