From babffbea958c48076482c5a7a1d3fdc2df0b4d43 Mon Sep 17 00:00:00 2001 From: Andrew Sologor Date: Tue, 26 Apr 2022 18:00:06 +0300 Subject: [PATCH] Add more functionality to external validators --- API.md | 81 ++++++++++++++- lib/common.js | 1 + lib/index.d.ts | 13 +++ lib/validator.js | 124 +++++++++++++++++----- test/validator.js | 259 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 452 insertions(+), 26 deletions(-) diff --git a/API.md b/API.md index 3f2f2427b..7bc6dc3ce 100755 --- a/API.md +++ b/API.md @@ -784,6 +784,11 @@ 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 @@ -791,7 +796,80 @@ entire schema (from the value root) are checked. This means that any changes mad 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)` @@ -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`. diff --git a/lib/common.js b/lib/common.js index 7d572c139..cbc7cd1c5 100755 --- a/lib/common.js +++ b/lib/common.js @@ -37,6 +37,7 @@ exports.defaults = { } }, externals: true, + alwaysExecuteExternals: false, messages: {}, nonEnumerables: false, noDefaults: false, diff --git a/lib/index.d.ts b/lib/index.d.ts index 6881f2a61..a3fdfe91d 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -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. * @@ -731,9 +738,15 @@ declare namespace Joi { interface ExternalHelpers { prefs: ValidationOptions; + path: string[], + label: string, + root: any, + context: any, + error: ExternalValidationFunctionErrorCallback, } type ExternalValidationFunction = (value: V, helpers: ExternalHelpers) => V | undefined; + type ExternalValidationFunctionErrorCallback = (message: string) => void; type SchemaLikeWithoutArray = string | number | boolean | null | Schema | SchemaMap; type SchemaLike = SchemaLikeWithoutArray | object; diff --git a/lib/validator.js b/lib/validator.js index cd29ed832..e04bf84dc 100755 --- a/lib/validator.js +++ b/lib/validator.js @@ -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) { diff --git a/test/validator.js b/test/validator.js index f4bc23269..6bbf5dd09 100755 --- a/test/validator.js +++ b/test/validator.js @@ -6,6 +6,7 @@ const Joi = require('..'); const Lab = require('@hapi/lab'); const Helper = require('./helper'); +const { ValidationError } = require('../lib/errors.js'); const internals = {}; @@ -421,6 +422,264 @@ describe('Validator', () => { await expect(schema.validateAsync(input, { context })).to.reject('Oops (value)'); await expect(schema.validateAsync(input, { context, errors: { label: false } })).to.reject('Oops'); }); + + it('externals receive correct helpers', async () => { + + let helpersObject; + + const schema = Joi + .object({ + foo: { + bar: Joi.any().external((value, helpers) => { + + helpersObject = helpers; + }) + } + }); + + await schema.validateAsync({ foo: { bar: 'baz' } }); + + expect(helpersObject.root).to.equal({ foo: { bar: 'baz' } }); + expect(helpersObject.context).to.equal({ bar: 'baz' }); + expect(helpersObject.path).to.equal(['foo', 'bar']); + expect(helpersObject.label).to.equal('foo.bar'); + expect(typeof helpersObject.error === 'function').to.be.true(); + }); + + it('externals receive correct context', async () => { + + const contexts = []; + const validator = Joi.any().external((value, { context }) => { + + contexts.push(context); + }); + + await Joi.object({ foo: { bar: validator } }).validateAsync({ foo: { bar: 'baz' } }); + await validator.validateAsync('hello'); + + const [contextForObject, contextForPrimitive] = contexts; + + expect(contextForObject).to.equal({ bar: 'baz' }); + expect(contextForPrimitive).to.equal('hello'); + }); + + it('should throw a ValidationError instance when externals fail', () => { + + const promise = Joi + .any() + .external((value, { error }) => error('Oops')) + .validateAsync(0); + + return promise.catch((err) => { + + expect(err).to.be.an.instanceOf(ValidationError); + expect(err).to.be.an.error('Invalid input'); + expect(err.details).to.equal([{ + message: 'Oops', + path: [], + type: 'external', + context: { value: 0, label: 'value' } + }]); + }); + }); + + it('should execute externals after another validator has failed when alwaysExecuteExternals is true and abortEarly is false', async () => { + + const schema = Joi + .string() + .external((value, { error }) => error('Oops')); + + // first check that it does not run externals with the default configuration + await schema.validateAsync(0).catch((err) => { + + expect(err.details).to.equal([{ + message: '"value" must be a string', + path: [], + type: 'string.base', + context: { value: 0, label: 'value' } + }]); + }); + + // now check with "alwaysExecuteExternals" param + await schema.validateAsync(0, { alwaysExecuteExternals: true, abortEarly: false }).catch((err) => { + + expect(err.details).to.equal([{ + message: '"value" must be a string', + path: [], + type: 'string.base', + context: { value: 0, label: 'value' } + }, { + message: 'Oops', + path: [], + type: 'external', + context: { value: 0, label: 'value' } + }]); + }); + }); + + it('externals should set "path" correctly', () => { + + const schema = Joi.object({ + foo: Joi.any().external((value, { error }) => error('Oops')) + }); + + return schema.validateAsync({ foo: 'bar' }).catch((err) => { + + expect(err.details).to.equal([{ + message: 'Oops', + path: ['foo'], + type: 'external', + context: { value: 'bar', label: 'foo' } + }]); + }); + }); + + it('an external may push multiple error messages', () => { + + const promise = Joi + .any() + .external((value, { error }) => { + + error('Oops 1'); + error('Oops 2'); + }).validateAsync(0, { abortEarly: false }); + + return promise.catch((err) => { + + expect(err.details[0].message).to.equal('Oops 1'); + expect(err.details[1].message).to.equal('Oops 2'); + }); + }); + + it('externals should not be executed if another validator failed, the "abortEarly" setting is true and "alwaysExecuteExternals" is true', () => { + + const promise = Joi + .string() + .external((value, { error }) => error('Oops 1')) + .validateAsync(0, { abortEarly: true, alwaysExecuteExternals: true }); + + return promise.catch((err) => { + + expect(err.details.length).to.equal(1); + expect(err.details[0].type).to.equal('string.base'); + }); + }); + + it('externals should not be executed if another validator failed, the "abortEarly" setting is false and "alwaysExecuteExternals" is false', () => { + + const promise = Joi + .string() + .external((value, { error }) => error('Oops 1')) + .validateAsync(0, { abortEarly: false, alwaysExecuteExternals: false }); + + return promise.catch((err) => { + + expect(err.details.length).to.equal(1); + expect(err.details[0].type).to.equal('string.base'); + }); + }); + + it('externals should respect the "abortEarly" setting', () => { + + const promise = Joi + .any() + .external((value, { error }) => { + + error('Oops 1'); + error('Oops 2'); + }) + .external((value, { error }) => { + + error('Oops 3'); + }).validateAsync(0, { abortEarly: true }); + + return promise.catch((err) => { + + expect(err.details.length).to.equal(1); + expect(err.details[0].message).to.equal('Oops 1'); + }); + }); + + it('chains of externals should abort once the first external validator in the chain fails regardless of the "abortEarly" setting', () => { + + const data = { + foo: 1, + bar: 2 + }; + + const schema = Joi.object({ + foo: Joi + .any() + .external((value, { error }) => error('foo err 1')) // fails + .external((value, { error }) => error('foo err 2')), // fails + bar: Joi + .any() + .external(() => {}) // does not fail + .external((value, { error }) => error('bar err 1')) // fails + }); + + const promise = schema.validateAsync(data, { abortEarly: false }); + + return promise.catch((err) => { + + expect(err.details.length).to.equal(2); + expect(err.details[0].message).to.equal('foo err 1'); + expect(err.details[1].message).to.equal('bar err 1'); + }); + }); + + it('returning some value from an external should modify the original value', () => { + + const data = { foo: 'bar' }; + const schema = Joi.object({ + foo: Joi.any().external(() => { + + return 'baz'; + }) + }); + const promise = schema.validateAsync(data); + + return promise.catch(() => { + + expect(data.foo).to.equal('baz'); + }); + }); + + it('returning an error from an external should not modify the original value', () => { + + const data = { foo: 'bar' }; + const schema = Joi.object({ + foo: Joi.any().external((value, { error }) => { + + return error('Oops'); + }) + }); + const promise = schema.validateAsync(data); + + return promise.catch(() => { + + expect(data.foo).to.equal('bar'); + }); + }); + + it('returning some value from an external after adding an error should not modify the original value', () => { + + const data = { foo: 'bar' }; + const schema = Joi.object({ + foo: Joi.any().external((value, { error }) => { + + error('Oops'); + + return 'baz'; + }) + }); + const promise = schema.validateAsync(data); + + return promise.catch(() => { + + expect(data.foo).to.equal('bar'); + }); + }); }); describe('finalize()', () => {