Commit db3ef016 authored by Stefan Cameron's avatar Stefan Cameron

Custom validators are to throw an error when validation fails

This error, hopefully including a helpful message, is then captured
in the resulting RtvError's `failure` property, as well as part of
its `message`.
parent 43bac45d
Pipeline #30291345 passed with stages
in 10 minutes and 23 seconds
......@@ -31,12 +31,13 @@ Members herein are _indirectly_ exposed through the [rtv](#rtv) object.
* [.verify(value, [silent])](#rtvref.Enumeration+verify) ⇒ <code>\*</code>
* [.toString()](#rtvref.Enumeration+toString)<code>string</code>
* [.RtvError](#rtvref.RtvError)[<code>JS_Error</code>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error)
* [new RtvError(value, typeset, path, cause)](#new_rtvref.RtvError_new)
* [new RtvError(value, typeset, path, cause, [failure])](#new_rtvref.RtvError_new)
* [.valid](#rtvref.RtvError+valid) : <code>boolean</code>
* [.value](#rtvref.RtvError+value) : <code>\*</code>
* [.typeset](#rtvref.RtvError+typeset) : [<code>typeset</code>](#rtvref.types.typeset)
* [.path](#rtvref.RtvError+path) : <code>Array.&lt;string&gt;</code>
* [.cause](#rtvref.RtvError+cause) : [<code>fully_qualified_typeset</code>](#rtvref.types.fully_qualified_typeset)
* [.failure](#rtvref.RtvError+failure) : <code>Error</code> \| <code>undefined</code>
* [.toString()](#rtvref.RtvError+toString)<code>string</code>
* [.RtvSuccess](#rtvref.RtvSuccess)
* [new RtvSuccess()](#new_rtvref.RtvSuccess_new)
......@@ -95,7 +96,7 @@ Members herein are _indirectly_ exposed through the [rtv](#rtv) object.
* [.collection_args](#rtvref.types.collection_args) : <code>Object</code>
* [.typeset](#rtvref.types.typeset) : <code>Object</code> \| <code>string</code> \| <code>Array</code> \| <code>function</code>
* [.fully_qualified_typeset](#rtvref.types.fully_qualified_typeset) : <code>Array</code>
* [.custom_validator](#rtvref.types.custom_validator) <code>boolean</code>
* [.custom_validator](#rtvref.types.custom_validator) : <code>function</code>
* [.STRING_args](#rtvref.types.STRING_args) : <code>Object</code>
* [.SYMBOL_args](#rtvref.types.SYMBOL_args) : <code>Object</code>
* [.numeric_args](#rtvref.types.numeric_args) : <code>Object</code>
......@@ -250,12 +251,13 @@ A string representation of this Enumeration.
**Extends**: [<code>JS_Error</code>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error)
* [.RtvError](#rtvref.RtvError)[<code>JS_Error</code>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error)
* [new RtvError(value, typeset, path, cause)](#new_rtvref.RtvError_new)
* [new RtvError(value, typeset, path, cause, [failure])](#new_rtvref.RtvError_new)
* [.valid](#rtvref.RtvError+valid) : <code>boolean</code>
* [.value](#rtvref.RtvError+value) : <code>\*</code>
* [.typeset](#rtvref.RtvError+typeset) : [<code>typeset</code>](#rtvref.types.typeset)
* [.path](#rtvref.RtvError+path) : <code>Array.&lt;string&gt;</code>
* [.cause](#rtvref.RtvError+cause) : [<code>fully_qualified_typeset</code>](#rtvref.types.fully_qualified_typeset)
* [.failure](#rtvref.RtvError+failure) : <code>Error</code> \| <code>undefined</code>
* [.toString()](#rtvref.RtvError+toString)<code>string</code>
......@@ -263,7 +265,7 @@ A string representation of this Enumeration.
<a name="new_rtvref.RtvError_new"></a>
#### new RtvError(value, typeset, path, cause)
#### new RtvError(value, typeset, path, cause, [failure])
Runtime Verification Error Indicator
Describes a failed runtime verification of a value against a given
......@@ -281,6 +283,7 @@ Describes a failed runtime verification of a value against a given
| typeset | [<code>typeset</code>](#rtvref.types.typeset) | The typeset used for verification. |
| path | <code>Array.&lt;string&gt;</code> | The path deep into `value` where the failure occurred. An empty array signifies the _root_ (top-level) value that was checked. |
| cause | [<code>fully_qualified_typeset</code>](#rtvref.types.fully_qualified_typeset) | The fully qualified typeset that caused the failure. This is normally the fully-qualified version of `typeset`, but could be a sub-type if `typeset` is an Array typeset or a [shape descriptor](#rtvref.shape_descriptor). |
| [failure] | <code>Error</code> | [Custom Validator](#rtvref.types.custom_validator) error, if the `RtvError` is a result of a failed custom validation. |
* * *
......@@ -344,6 +347,18 @@ If `typeset` is `[[rtv.t.STRING]]` (a required array of required strings),
* * *
<a name="rtvref.RtvError+failure"></a>
#### rtvError.failure : <code>Error</code> \| <code>undefined</code>
Validation error thrown by a [Custom Validator](#rtvref.types.custom_validator),
which resulted in this `RtvError`. `undefined` if this error was not the result
of a failed custom validation.
**Kind**: instance property of [<code>RtvError</code>](#rtvref.RtvError)
**Read only**: true
* * *
<a name="rtvref.RtvError+toString"></a>
#### rtvError.toString() ⇒ <code>string</code>
......@@ -799,7 +814,7 @@ Convenience function to check if a nil value (either `undefined` or `null`)
* [.collection_args](#rtvref.types.collection_args) : <code>Object</code>
* [.typeset](#rtvref.types.typeset) : <code>Object</code> \| <code>string</code> \| <code>Array</code> \| <code>function</code>
* [.fully_qualified_typeset](#rtvref.types.fully_qualified_typeset) : <code>Array</code>
* [.custom_validator](#rtvref.types.custom_validator) <code>boolean</code>
* [.custom_validator](#rtvref.types.custom_validator) : <code>function</code>
* [.STRING_args](#rtvref.types.STRING_args) : <code>Object</code>
* [.SYMBOL_args](#rtvref.types.SYMBOL_args) : <code>Object</code>
* [.numeric_args](#rtvref.types.numeric_args) : <code>Object</code>
......@@ -1879,7 +1894,7 @@ For example:
<a name="rtvref.types.custom_validator"></a>
#### types.custom_validator ⇒ <code>boolean</code>
#### types.custom_validator : <code>function</code>
<h3>Custom Validator</h3>
A function used as a [typeset](#rtvref.types.typeset), or as a subset to
......@@ -1904,7 +1919,14 @@ There is one disadvantage to using a custom validator: It cannot be de/serialize
on how to reconstruct the validator dynamically.
**Kind**: static typedef of [<code>types</code>](#rtvref.types)
**Returns**: <code>boolean</code> - A _truthy_ value to verify, a _falsy_ value to reject.
**Throws**:
- <code>Error</code> If the validation fails. This error will fail the overall
validation check, and will be included in the resulting `RtvError` as its
[failure](#rtvref.RtvError+failure) property, as well as part of its
`message`. Therefore, it's recommended to throw an error with a message that
will help the developer determine why the custom validation failed.
**See**: [rtvref.validation.isValidator](rtvref.validation.isValidator)
| Param | Type | Description |
......
......@@ -2,6 +2,7 @@
import isTypeset from './validation/isTypeset';
import isArray from './validation/isArray';
import isError from './validation/isError';
import {print} from './util';
......@@ -42,9 +43,11 @@ const renderPath = function(path) {
* that caused the failure. This is normally the fully-qualified version of `typeset`,
* but could be a sub-type if `typeset` is an Array typeset or a
* {@link rtvref.shape_descriptor shape descriptor}.
* @param {Error} [failure] {@link rtvref.types.custom_validator Custom Validator}
* error, if the `RtvError` is a result of a failed custom validation.
* @throws {Error} If `typeset`, `path`, or `cause` is invalid.
*/
const RtvError = function(value, typeset, path, cause) {
const RtvError = function(value, typeset, path, cause, failure) {
// NOTE: We're using the old ES5 way of doing classical inheritance rather than
// an ES6 'class' because extending from Error doesn't appear to work very well,
// at least not with Babel 6.x. It seems OK in Node 9.x, however. Anyway,
......@@ -65,13 +68,23 @@ const RtvError = function(value, typeset, path, cause) {
throw new Error(`Invalid cause (expecting a fully-qualified typeset): ${print(cause)}`);
}
if (failure && !isError(failure)) {
throw new Error(`Invalid failure (expecting JavaScript Error): ${print(failure)}`);
} else if (!failure) {
failure = undefined; // normalize falsy values
}
// NOTE: For some reason, calling `extendsFrom.call(this, message)` has
// no effect on `this` whatsoever, perhaps because it's calling native code,
// or there's something strange about the built-in Error type, so we just
// call the super's constructor as a formality.
extendsFrom.call(this);
this.message = `Verification failed: value=${print(value)}, path="${renderPath(path)}", cause=${print(cause)}`;
this.name = 'RtvError';
this.message = `Verification failed: value=${print(value)}, path="${renderPath(path)}", cause=${print(cause)}`;
if (failure) {
this.message += `, failure="${failure.message}"`;
}
Object.defineProperties(this, {
/**
......@@ -150,6 +163,22 @@ const RtvError = function(value, typeset, path, cause) {
get() {
return cause;
}
},
/**
* Validation error thrown by a {@link rtvref.types.custom_validator Custom Validator},
* which resulted in this `RtvError`. `undefined` if this error was not the result
* of a failed custom validation.
* @readonly
* @name rtvref.RtvError#failure
* @type {(Error|undefined)}
*/
failure: {
enumerable: true,
configurable: true,
get() {
return failure;
}
}
});
};
......@@ -163,7 +192,15 @@ RtvError.prototype.constructor = RtvError;
* @returns {string} String representation.
*/
RtvError.prototype.toString = function() {
return `{rtvref.RtvError value=${print(this.value)}, path="${renderPath(this.path)}", cause=${print(this.cause)}}`;
let str = `{rtvref.RtvError value=${print(this.value)}, path="${renderPath(this.path)}", cause=${print(this.cause)}}`;
if (this.failure) {
str += `, failure="${this.failure.message}"`;
} else {
str += ', failure=<none>';
}
return str;
};
export default RtvError;
......@@ -613,11 +613,12 @@ const checkWithArray = function(value, typeset /*, options*/) {
// check for a validator at the end of the Array typeset and invoke it
const lastType = typeset[typeset.length - 1];
if (isValidator(lastType)) {
if (!lastType(value, match, typeset)) {
try {
lastType(value, match, typeset);
} catch (cvErr) {
// invalid in spite of the match since the validator said no
err = new RtvError(value, typeset, options.path, fullyQualify(typeset, qualifier));
err = new RtvError(value, typeset, options.path, fullyQualify(typeset, qualifier), cvErr);
}
// else, valid!
}
// else, valid, since we have a match
} else {
......
......@@ -377,7 +377,11 @@ import Enumeration from './Enumeration';
* be part of a larger parent typeset (though there would be no reference to
* the parent typeset, if any). This typeset is as it was specified in the
* parent shape, and therefore it may not be fully-qualified.
* @returns {boolean} A _truthy_ value to verify, a _falsy_ value to reject.
* @throws {Error} If the validation fails. This error will fail the overall
* validation check, and will be included in the resulting `RtvError` as its
* {@link rtvref.RtvError#failure failure} property, as well as part of its
* `message`. Therefore, it's recommended to throw an error with a message that
* will help the developer determine why the custom validation failed.
* @see {@link rtvref.validation.isValidator}
*/
......
......@@ -5,6 +5,35 @@ import qualifiers from '../../src/lib/qualifiers';
import RtvError from '../../src/lib/RtvError';
describe('module: lib/RtvError', function() {
it('should extend Error', function() {
const value = null;
const typeset = [types.STRING];
const path = ['the', 'path'];
const cause = [qualifiers.REQUIRED, types.STRING];
const err = new RtvError(value, typeset, path, cause);
expect(err instanceof Error).to.equal(true);
expect(err.name).to.equal('RtvError');
});
it('should normalize falsy failures to undefined', function() {
const value = null;
const typeset = [types.STRING];
const path = ['the', 'path'];
const cause = [qualifiers.REQUIRED, types.STRING];
let err = new RtvError(value, typeset, path, cause, 0);
expect(err.failure).to.equal(undefined);
err = new RtvError(value, typeset, path, cause, false);
expect(err.failure).to.equal(undefined);
err = new RtvError(value, typeset, path, cause, '');
expect(err.failure).to.equal(undefined);
err = new RtvError(value, typeset, path, cause, null);
expect(err.failure).to.equal(undefined);
});
it('should accept any value', function() {
const otherParams = [types.STRING, ['path'], [qualifiers.REQUIRED, types.STRING]];
......@@ -115,7 +144,31 @@ describe('module: lib/RtvError', function() {
}).to.throw(/invalid cause/i);
});
it('should have a message including the value and path', function() {
it('should require a valid failure if specified', function() {
expect(function() {
new RtvError(null, types.STRING, ['path'], [qualifiers.REQUIRED, types.STRING], new Error());
}).not.to.throw();
expect(function() {
new RtvError(null, types.STRING, ['path'], [qualifiers.REQUIRED, types.STRING],
new TypeError());
}).not.to.throw();
expect(function() {
new RtvError(null, types.STRING, ['path'], [qualifiers.REQUIRED, types.STRING],
new RangeError());
}).not.to.throw();
expect(function() {
new RtvError(null, types.STRING, ['path'], [qualifiers.REQUIRED, types.STRING], 'Error');
}).to.throw(/Invalid failure/);
expect(function() {
new RtvError(null, types.STRING, ['path'], [qualifiers.REQUIRED, types.STRING], null); // falsy ignored
}).not.to.throw();
});
it('should have a message including the value, path, cause', function() {
const value = null;
const typeset = [types.STRING];
const path = ['the', 'path'];
......@@ -124,25 +177,29 @@ describe('module: lib/RtvError', function() {
expect(err.message).to.contain(`value=${value}`);
expect(err.message).to.contain(`path="/${path.join('/')}"`);
expect(err.message).to.contain(`cause=["${qualifiers.REQUIRED}","${types.STRING}"]`);
expect(err.message).not.to.contain('failure='); // failure not provided, so no failure message
});
it('should have a message including path="/" with path array is empty', function() {
it('should have a message including the value, path, cause, and failure message', function() {
const value = null;
const typeset = [types.STRING];
const path = [];
const path = ['the', 'path'];
const cause = [qualifiers.REQUIRED, types.STRING];
const err = new RtvError(value, typeset, path, cause);
expect(err.message).to.contain('path="/"');
const failure = new Error('failure');
const err = new RtvError(value, typeset, path, cause, failure);
expect(err.message).to.contain(`value=${value}`);
expect(err.message).to.contain(`path="/${path.join('/')}"`);
expect(err.message).to.contain(`cause=["${qualifiers.REQUIRED}","${types.STRING}"]`);
expect(err.message).to.contain(`failure="${failure.message}"`);
});
it('should extend Error', function() {
it('should have a message including path="/" with path array is empty', function() {
const value = null;
const typeset = [types.STRING];
const path = ['the', 'path'];
const path = [];
const cause = [qualifiers.REQUIRED, types.STRING];
const err = new RtvError(value, typeset, path, cause);
expect(err instanceof Error).to.equal(true);
expect(err.name).to.equal('RtvError');
expect(err.message).to.contain('path="/"');
});
it('should have a readonly valid=false property', function() {
......@@ -158,18 +215,20 @@ describe('module: lib/RtvError', function() {
expect(err.valid).to.equal(false);
});
it('should provide a readonly value, typeset, path, and cause property', function() {
it('should provide readonly value, typeset, path, cause, failure properties', function() {
const value = null;
const typeset = [types.STRING];
const path = ['the', 'path'];
const cause = [qualifiers.REQUIRED, types.STRING];
const err = new RtvError(value, typeset, path, cause);
const failure = new Error('custom validator failed');
const err = new RtvError(value, typeset, path, cause, failure);
expect(err.value).to.equal(value);
expect(err.typeset).to.equal(typeset);
expect(err.cause).to.equal(cause);
expect(err.path).not.to.equal(path); // shallow-clone, so not same reference
expect(err.path).to.eql(['the', 'path']); // shallow-clone
expect(err.cause).to.equal(cause);
expect(err.failure).to.equal(failure);
expect(function() {
err.value = true;
......@@ -183,24 +242,42 @@ describe('module: lib/RtvError', function() {
expect(function() {
err.cause = [];
}).to.throw(/Cannot set property cause of .+ which has only a getter/);
expect(function() {
err.failure = new Error();
}).to.throw(/Cannot set property failure of .+ which has only a getter/);
// nothing changed all are readonly
expect(err.value).to.equal(value);
expect(err.typeset).to.equal(typeset);
expect(err.cause).to.equal(cause);
expect(err.path).not.to.equal(path); // shallow-clone, so not same reference
expect(err.path).to.eql(['the', 'path']); // shallow-clone
expect(err.cause).to.equal(cause);
expect(err.failure).to.equal(failure);
});
it('should have custom string serialization', function() { // TODO fix this test
const value = null;
const path = ['the', 'path'];
const err = new RtvError(value, types.STRING, path, [qualifiers.REQUIRED, types.STRING]);
const str = err + '';
const failure = new Error('custom validator failed');
let err = new RtvError(value, types.STRING, path, [qualifiers.REQUIRED, types.STRING]);
let str = err + '';
expect(str.match(/^Error: /)).to.equal(null); // not the default serialization
expect(str).to.contain('RtvError');
expect(str).to.contain(`value=${value}`);
expect(str).to.contain(`path="/${path.join('/')}"`);
expect(str).to.contain(`cause=["${qualifiers.REQUIRED}","${types.STRING}"]`);
expect(str).to.contain('failure=<none>');
err = new RtvError(value, types.STRING, path, [qualifiers.REQUIRED, types.STRING], failure);
str = err + '';
expect(str.match(/^Error: /)).to.equal(null); // not the default serialization
expect(str).to.contain('RtvError');
expect(str).to.contain(`value=${value}`);
expect(str).to.contain(`path="/${path.join('/')}"`);
expect(str).to.contain(`cause=["${qualifiers.REQUIRED}","${types.STRING}"]`);
expect(str).to.contain(`failure="${failure.message}"`);
});
});
......@@ -746,7 +746,8 @@ describe('module: lib/impl', function() {
});
it('should invoke a custom validator if present', function() {
const validator = sinon.stub().returns(false);
const cvError = new Error('custom validator failed');
const validator = sinon.stub().throws(cvError);
let typeset = [validator];
let result = impl.checkWithArray('foo', typeset);
......@@ -762,6 +763,7 @@ describe('module: lib/impl', function() {
expect(result.path).to.eql([]);
expect(result.typeset).to.equal(typeset);
expect(result.cause).to.eql([qualifiers.REQUIRED, types.ANY, validator]);
expect(result.failure).to.equal(cvError);
validator.resetHistory();
......@@ -779,6 +781,7 @@ describe('module: lib/impl', function() {
expect(result.path).to.eql([]);
expect(result.typeset).to.equal(typeset);
expect(result.cause).to.eql([qualifiers.REQUIRED, types.STRING, validator]);
expect(result.failure).to.equal(cvError);
validator.resetHistory();
validator.returns(true);
......@@ -795,7 +798,7 @@ describe('module: lib/impl', function() {
});
it('should not invoke a custom validator if no types matched', function() {
const validator = sinon.stub().returns(true);
const validator = sinon.stub().throws(new Error('failure'));
const typeset = [types.FINITE, validator];
const result = impl.checkWithArray('foo', typeset);
expect(validator.callCount).to.equal(0);
......@@ -805,6 +808,7 @@ describe('module: lib/impl', function() {
expect(result.path).to.eql([]);
expect(result.typeset).to.equal(typeset);
expect(result.cause).to.eql([qualifiers.REQUIRED, types.FINITE, validator]);
expect(result.failure).to.equal(undefined);
});
describe('Options', function() {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment