Skip to content

Commit babffbe

Browse files
committed
Add more functionality to external validators
1 parent f41d429 commit babffbe

File tree

5 files changed

+452
-26
lines changed

5 files changed

+452
-26
lines changed

API.md

+80-1
Original file line numberDiff line numberDiff line change
@@ -784,14 +784,92 @@ Adds an external validation rule where:
784784
- `value` - a clone of the object containing the value being validated.
785785
- `helpers` - an object with the following helpers:
786786
- `prefs` - the current preferences.
787+
- `path` - ordered array where each element is the accessor to the value where the error happened.
788+
- `label` - label of the value. If you are validating an object's property, it will contain the name of that property.
789+
- `root` - the root object or primitive value under validation.
790+
- `context` - same as `root`, but contains only the closest parent object in case of nested objects validation.
791+
- `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.
787792
- `description` - optional string used to document the purpose of the method.
788793

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

794-
If schema validation failed, no external validation rules are called.
799+
By default, if schema validation fails, no external validation rules are called. You can change this
800+
behavior by using `abortEarly: false` and `alwaysExecuteExternals: true` settings together.
801+
802+
Chains of external validation rules abort early regardless of any settings.
803+
804+
If your validator returns a replacement value after it added an error (using `error` helper), the replacement value will be ignored.
805+
806+
A few examples:
807+
```js
808+
const data = {
809+
foo: {
810+
bar: 'baz'
811+
}
812+
};
813+
814+
await Joi.object({
815+
foo: {
816+
bar: Joi.any().external((value, { prefs, path, label, root, context, error }) => {
817+
// "prefs" object contains current validation settings
818+
// value === 'baz'
819+
// path === ['foo', 'bar']
820+
// label === 'foo.bar'
821+
// root === { foo: { bar: 'baz' } }
822+
// context === { bar: 'baz' }
823+
824+
if (value !== 'hello') {
825+
return error(`"${value}" is not a valid value for prop ${label}`);
826+
}
827+
})
828+
}
829+
}).validateAsync(data);
830+
```
831+
832+
```js
833+
// an example of a reusable validator with additional params
834+
const exists = (tableName, columnName) => {
835+
columnName ??= 'id';
836+
837+
return async (value, { label, error }) => {
838+
const count = await doQueryTheDatabase(`SELECT COUNT(*) FROM ${tableName} WHERE ${columnName} = ?`, value);
839+
840+
if (count < 1) {
841+
return error(`${label} in invalid. Record does not exist.`);
842+
}
843+
};
844+
}
845+
846+
const data = {
847+
userId: 123,
848+
bookCode: 'AE-1432',
849+
};
850+
851+
const schema = Joi.object({
852+
userId: Joi.number().external(exists('users')),
853+
bookCode: Joi.string().external(exists('books', 'code'))
854+
});
855+
856+
await schema.validateAsync(data);
857+
```
858+
859+
```js
860+
Joi.any().external((value, { error }) => {
861+
// you can add more than one error in a single validator
862+
error('error 1');
863+
error('error 2');
864+
865+
// you can return at any moment
866+
if (value === 'hi!') {
867+
return;
868+
}
869+
870+
error('error 3');
871+
})
872+
```
795873
796874
#### `any.extract(path)`
797875
@@ -1131,6 +1209,7 @@ Validates a value using the current schema and options where:
11311209
- `string` - the characters used around each array string values. Defaults to `false`.
11321210
- `wrapArrays` - if `true`, array values in error messages are wrapped in `[]`. Defaults to `true`.
11331211
- `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`.
1212+
- `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`.
11341213
- `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`.
11351214
- `noDefaults` - when `true`, do not apply default values. Defaults to `false`.
11361215
- `nonEnumerables` - when `true`, inputs are shallow cloned to include non-enumerable properties. Defaults to `false`.

lib/common.js

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ exports.defaults = {
3737
}
3838
},
3939
externals: true,
40+
alwaysExecuteExternals: false,
4041
messages: {},
4142
nonEnumerables: false,
4243
noDefaults: false,

lib/index.d.ts

+13
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,13 @@ declare namespace Joi {
149149
* @default true
150150
*/
151151
externals?: boolean;
152+
/**
153+
* if true, and "abortEarly" is false, the external rules set with `any.external()` will be executed even after synchronous validators have failed.
154+
* This setting has no effect if "abortEarly" is true since external rules get executed after all other validators. Default: false.
155+
*
156+
* @default true
157+
*/
158+
alwaysExecuteExternals?: boolean;
152159
/**
153160
* when true, do not apply default values.
154161
*
@@ -731,9 +738,15 @@ declare namespace Joi {
731738

732739
interface ExternalHelpers {
733740
prefs: ValidationOptions;
741+
path: string[],
742+
label: string,
743+
root: any,
744+
context: any,
745+
error: ExternalValidationFunctionErrorCallback,
734746
}
735747

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

738751
type SchemaLikeWithoutArray = string | number | boolean | null | Schema | SchemaMap;
739752
type SchemaLike = SchemaLikeWithoutArray | object;

lib/validator.js

+99-25
Original file line numberDiff line numberDiff line change
@@ -62,49 +62,123 @@ exports.entryAsync = async function (value, schema, prefs) {
6262
result.error.debug = mainstay.debug;
6363
}
6464

65-
throw result.error;
65+
if (settings.abortEarly || !settings.alwaysExecuteExternals) {
66+
throw result.error;
67+
}
6668
}
6769

68-
if (mainstay.externals.length) {
70+
// group externals by their paths
71+
const groups = {};
72+
73+
mainstay.externals.forEach((row) => {
74+
75+
if (typeof groups[row.label] === 'undefined') {
76+
groups[row.label] = [];
77+
}
78+
79+
groups[row.label].push(row);
80+
});
81+
82+
const groupedExternals = Object.keys(groups).map((label) => groups[label]);
83+
84+
if (groupedExternals.length) {
6985
let root = result.value;
70-
for (const { method, path, label } of mainstay.externals) {
71-
let node = root;
72-
let key;
73-
let parent;
74-
75-
if (path.length) {
76-
key = path[path.length - 1];
77-
parent = Reach(root, path.slice(0, -1));
78-
node = parent[key];
79-
}
8086

81-
try {
82-
const output = await method(node, { prefs });
83-
if (output === undefined ||
84-
output === node) {
87+
for (const externalsGroup of groupedExternals) {
88+
let groupErrors = [];
8589

86-
continue;
90+
for (const { method, path, label } of externalsGroup) {
91+
let errors = [];
92+
let node = root;
93+
let key;
94+
let parent;
95+
96+
if (path.length) {
97+
key = path[path.length - 1];
98+
parent = Reach(root, path.slice(0, -1));
99+
node = parent[key];
87100
}
88101

89-
if (parent) {
90-
parent[key] = output;
102+
try {
103+
const output = await method(
104+
node,
105+
{
106+
prefs,
107+
path,
108+
label,
109+
root,
110+
context: parent ?? root,
111+
error: (message) => {
112+
113+
errors.push(message);
114+
}
115+
}
116+
);
117+
118+
if (errors.length) {
119+
// prepare errors
120+
if (settings.abortEarly) {
121+
// take only the first error if abortEarly is true
122+
errors = errors.slice(0, 1);
123+
}
124+
125+
errors = errors.map((message) => ({
126+
message,
127+
path,
128+
type: 'external',
129+
context: { value: node, label }
130+
}));
131+
132+
groupErrors = [...groupErrors, ...errors];
133+
134+
// do not execute other externals from the group
135+
break;
136+
}
137+
138+
if (output === undefined ||
139+
output === node) {
140+
141+
continue;
142+
}
143+
144+
if (parent) {
145+
parent[key] = output;
146+
}
147+
else {
148+
root = output;
149+
}
91150
}
92-
else {
93-
root = output;
151+
catch (err) {
152+
if (settings.errors.label) {
153+
err.message += ` (${label})`; // Change message to include path
154+
}
155+
156+
throw err;
94157
}
95158
}
96-
catch (err) {
97-
if (settings.errors.label) {
98-
err.message += ` (${label})`; // Change message to include path
159+
160+
if (groupErrors.length) {
161+
if (result.error) {
162+
result.error.details = [...result.error.details, ...groupErrors];
163+
}
164+
else {
165+
result.error = new Errors.ValidationError('Invalid input', groupErrors, value);
99166
}
100167

101-
throw err;
168+
if (settings.abortEarly) {
169+
// do not execute any other externals at all
170+
break;
171+
}
102172
}
103173
}
104174

105175
result.value = root;
106176
}
107177

178+
if (result.error) {
179+
throw result.error;
180+
}
181+
108182
if (!settings.warnings &&
109183
!settings.debug &&
110184
!settings.artifacts) {

0 commit comments

Comments
 (0)