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 Apr 26, 2022
1 parent 95feacc commit 66367f5
Show file tree
Hide file tree
Showing 5 changed files with 458 additions and 32 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-enumerables 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
25 changes: 19 additions & 6 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// The following definitions have been copied (almost) as-is from:
// https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/hapi__joi
//
//
// Note: This file is expected to change dramatically in the next major release and have been
// imported here to make migrating back to the "joi" module name simpler. It include known bugs
// and other issues. It does not include some new features included in version 17.2.0 or newer.
Expand Down Expand Up @@ -143,6 +143,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 @@ -576,7 +583,7 @@ declare namespace Joi {
iterables?: boolean;

/**
* when true, the value of the reference is used instead of its name in error messages
* when true, the value of the reference is used instead of its name in error messages
* and template rendering. Defaults to false.
*/
render?: boolean;
Expand Down Expand Up @@ -706,16 +713,22 @@ 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;

type NullableType<T> = undefined | null | T

type ObjectPropertiesSchema<T = any> =
type ObjectPropertiesSchema<T = any> =
T extends NullableType<string>
? Joi.StringSchema
: T extends NullableType<number>
Expand All @@ -730,11 +743,11 @@ declare namespace Joi {
? Joi.ArraySchema
: T extends NullableType<object>
? ObjectSchema<StrictSchemaMap<T>>
: never
: never

type PartialSchemaMap<TSchema = any> = {
[key in keyof TSchema]?: SchemaLike | SchemaLike[];
}
}

type StrictSchemaMap<TSchema = any> = {
[key in keyof TSchema]-?: ObjectPropertiesSchema<TSchema[key]>
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
Loading

0 comments on commit 66367f5

Please sign in to comment.