Skip to content

Commit 66367f5

Browse files
committed
Add more functionality to external validators
1 parent 95feacc commit 66367f5

File tree

5 files changed

+458
-32
lines changed

5 files changed

+458
-32
lines changed

API.md

Lines changed: 80 additions & 1 deletion
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-enumerables properties. Defaults to `false`.

lib/common.js

Lines changed: 1 addition & 0 deletions
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

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// The following definitions have been copied (almost) as-is from:
22
// https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/hapi__joi
3-
//
3+
//
44
// Note: This file is expected to change dramatically in the next major release and have been
55
// imported here to make migrating back to the "joi" module name simpler. It include known bugs
66
// and other issues. It does not include some new features included in version 17.2.0 or newer.
@@ -143,6 +143,13 @@ declare namespace Joi {
143143
* @default true
144144
*/
145145
externals?: boolean;
146+
/**
147+
* if true, and "abortEarly" is false, the external rules set with `any.external()` will be executed even after synchronous validators have failed.
148+
* This setting has no effect if "abortEarly" is true since external rules get executed after all other validators. Default: false.
149+
*
150+
* @default true
151+
*/
152+
alwaysExecuteExternals?: boolean;
146153
/**
147154
* when true, do not apply default values.
148155
*
@@ -576,7 +583,7 @@ declare namespace Joi {
576583
iterables?: boolean;
577584

578585
/**
579-
* when true, the value of the reference is used instead of its name in error messages
586+
* when true, the value of the reference is used instead of its name in error messages
580587
* and template rendering. Defaults to false.
581588
*/
582589
render?: boolean;
@@ -706,16 +713,22 @@ declare namespace Joi {
706713

707714
interface ExternalHelpers {
708715
prefs: ValidationOptions;
716+
path: string[],
717+
label: string,
718+
root: any,
719+
context: any,
720+
error: ExternalValidationFunctionErrorCallback,
709721
}
710722

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

713726
type SchemaLikeWithoutArray = string | number | boolean | null | Schema | SchemaMap;
714727
type SchemaLike = SchemaLikeWithoutArray | object;
715728

716729
type NullableType<T> = undefined | null | T
717730

718-
type ObjectPropertiesSchema<T = any> =
731+
type ObjectPropertiesSchema<T = any> =
719732
T extends NullableType<string>
720733
? Joi.StringSchema
721734
: T extends NullableType<number>
@@ -730,11 +743,11 @@ declare namespace Joi {
730743
? Joi.ArraySchema
731744
: T extends NullableType<object>
732745
? ObjectSchema<StrictSchemaMap<T>>
733-
: never
734-
746+
: never
747+
735748
type PartialSchemaMap<TSchema = any> = {
736749
[key in keyof TSchema]?: SchemaLike | SchemaLike[];
737-
}
750+
}
738751

739752
type StrictSchemaMap<TSchema = any> = {
740753
[key in keyof TSchema]-?: ObjectPropertiesSchema<TSchema[key]>

lib/validator.js

Lines changed: 99 additions & 25 deletions
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)