Skip to content
53 changes: 31 additions & 22 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,28 +54,35 @@ Anchor.prototype.rules = require('./lib/match/rules');
*/

Anchor.prototype.to = function (ruleset, context) {
"use strict";

var errors = [];
var errors = [], self = this;

// If ruleset doesn't contain any explicit rule keys,
// assume that this is a type

if (util.isPlainObject(ruleset) || util.isArray(ruleset)) {

if (util.has(ruleset, 'type') && util.keys(ruleset).length == 1) {
// backward compatible for anchor(data).to({ type: typeDef })
errors = errors.concat(Anchor.match.type.call(context, self.data, ruleset['type']));
} else if (util.difference(util.keys(ruleset), util.keys(this.rules)).length === 0) {
// backward compatible for anchor(data).to({ ruleName: ruleArgs })
// Look for explicit rules
util.forOwn(ruleset, function (args, ruleName) {
// Validate a non-type rule
errors = errors.concat(Anchor.match.rule.call(context, self.data, ruleName, args));
});
} else {
// Use deep match to descend into the collection and verify each item and/or key
// Stop at default maxDepth (50) to prevent infinite loops in self-associations
errors = errors.concat(Anchor.match.schema.call(context, self.data, ruleset));
}
} else {
errors = errors.concat(Anchor.match.rule.call(context, self.data, ruleset.toString(), ruleset));
}

// Look for explicit rules
for (var rule in ruleset) {

if (rule === 'type') {

// Use deep match to descend into the collection and verify each item and/or key
// Stop at default maxDepth (50) to prevent infinite loops in self-associations
errors = errors.concat(Anchor.match.type.call(context, this.data, ruleset['type']));
}

// Validate a non-type rule
else {
errors = errors.concat(Anchor.match.rule.call(context, this.data, rule, ruleset[rule]));
}
}

// If errors exist, return the list of them
if (errors.length) {
Expand Down Expand Up @@ -186,19 +193,21 @@ Anchor.prototype.defaults = function (ruleset) {
*/

Anchor.prototype.define = function (name, definition) {
"use strict";

// check to see if we have an dictionary
if ( util.isObject(name) ) {
if ( util.isPlainObject(name) && definition === undefined ) {
definition = name;

// if so all the attributes should be validation functions
for (var attr in name){
if(!util.isFunction(name[attr])){
throw new Error('Definition error: \"' + attr + '\" does not have a definition');
}
}
util.forOwn(definition, function (value, attr) {
if(!util.isFunction(value)) {
throw new Error('Definition error: \"' + attr + '\" does not have a definition');
}
});

// add the new custom data types
util.extend(Anchor.prototype.rules, name);
util.extend(Anchor.prototype.rules, definition);

return this;

Expand Down
156 changes: 156 additions & 0 deletions lib/match/deepMatchType.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Module dependencies
*/

var util = require('util');
var _ = require('lodash');
var rules = require('./rules');
var errorFactory = require('./errorFactory');
var matchType = require('./matchType');

// var JSValidationError

// Exposes `matchType` as `deepMatchType`.
module.exports = deepMatchType;


var RESERVED_KEYS = {
$validate: '$validate',
$message: '$message'
};

// Max depth value
var MAX_DEPTH = 50;



/**
* Match a complex collection or model against a schema
*
* @param {?} data
* @param {?} ruleset
* @param {Number} depth
* @param {String} keyName
* @param {String} customMessage
* (optional)
*
* @returns {[]} a list of errors (or an empty list if no errors were found)
*/

function deepMatchType(data, ruleset, depth, keyName, customMessage) {

var self = this;

// Prevent infinite recursion
depth = depth || 0;
if (depth > MAX_DEPTH) {
return [
new Error({ message: 'Exceeded MAX_DEPTH when validating object. Maybe it\'s recursively referencing itself?'})
];
}

// (1) Base case - primitive
// ----------------------------------------------------
// If ruleset is not an object or array, use the provided function to validate
if (!_.isObject(ruleset)) {
return matchType.call(self, data, ruleset, keyName, customMessage);
}


// (2) Recursive case - Array
// ----------------------------------------------------
// If this is a schema rule, check each item in the data collection
else if (_.isArray(ruleset)) {
if (ruleset.length !== 0) {
if (ruleset.length > 1) {
return [
new Error({ message: '[] (or schema) rules must contain exactly one item.'})
];
}

// Handle plurals (arrays with a schema rule)
// Match each object in data array against ruleset until error is detected
return _.reduce(data, function getErrors(errors, datum) {
errors = errors.concat(deepMatchType.call(self, datum, ruleset[0], depth + 1, keyName, customMessage));
return errors;
}, []);
}
// Leaf rules land here and execute the iterator fn
else return matchType.call(self, data, ruleset, keyName, customMessage);
}

// (3) Recursive case - POJO
// ----------------------------------------------------
// If the current rule is an object, check each key
else {

// Note:
//
// We take advantage of a couple of preconditions at this point:
// (a) ruleset must be an Object
// (b) ruleset must NOT be an Array


// *** Check for special reserved keys ***

// { $message: '...' } specified as data type
// uses supplied message instead of the default
var _customMessage = ruleset[RESERVED_KEYS.$message];

// { $validate: {...} } specified as data type
// runs a sub-validation (recursive)
var subValidation = ruleset[RESERVED_KEYS.$validate];

// Don't allow a `$message` without a `$validate`
if (_customMessage) {
if (!subValidation) {
return [{
code: 'E_USAGE',
status: 500,
$message: _customMessage,
property: keyName,
message: 'Custom messages ($message) require a subvalidation - please specify a `$validate` option on `'+keyName+'`'
}];
}
else {
// Use the specified message as the `customMessage`
customMessage = _customMessage;
}
}

// Execute subvalidation rules
if (subValidation) {
if (!subValidation.type) {
return [
new Error({message: 'Sub-validation rules (i.e. using $validate) other than `type` are not currently supported'})
];
}

return deepMatchType.call(self, data, subValidation.type, depth+1, keyName, customMessage);
}





// Don't treat empty object as a ruleset
// Instead, treat it as 'object'
if (_.keys(ruleset).length === 0) {
return matchType.call(self, data, ruleset, keyName, customMessage);
} else {
// Iterate through rules in dictionary until error is detected
return _.reduce(ruleset, function(errors, subRule, key) {

// Prevent throwing when encountering unexpectedly "shallow" data
// (instead- this should be pushed as an error where "undefined" is
// not of the expected type: "object")
if (!_.isObject(data)) {
return errors.concat(errorFactory(data, 'object', key, customMessage));
} else {
return errors.concat(deepMatchType.call(self, data[key], ruleset[key], depth + 1, key, customMessage));
}
}, []);
}
}
}

5 changes: 3 additions & 2 deletions lib/match/errorFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ module.exports = function errorFactory(value, ruleName, keyName, customMessage)
// errMsg += keyName ? '(' + keyName + ') ' : '';
// errMsg += 'is not of type "' + ruleName + '"';

var expectedType = _.isString(ruleName) ? ruleName : Object.prototype.toString.call(ruleName);
errMsg = util.format(
'`%s` should be a %s (instead of "%s", which is a %s)',
keyName, ruleName, value, typeof value
keyName, expectedType, value, typeof value
);
}

Expand All @@ -48,6 +49,6 @@ module.exports = function errorFactory(value, ruleName, keyName, customMessage)
message: errMsg,
rule: ruleName,
actualType: typeof value,
expectedType: ruleName
expectedType: expectedType
}];
};
3 changes: 2 additions & 1 deletion lib/match/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module.exports = {
type: require('./matchType'),
schema: require('./matchSchema'),
type: require('./deepMatchType'),
rule: require('./matchRule')
};
5 changes: 3 additions & 2 deletions lib/match/matchRule.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ var rules = require('./rules');
* or a list of errors if things go wrong
*/

module.exports = function matchRule (data, ruleName, args) {
module.exports = function matchRule (data, ruleName, args, keyName) {
var self = this,
errors = [];

Expand Down Expand Up @@ -45,9 +45,10 @@ module.exports = function matchRule (data, ruleName, args) {
// If outcome is false, an error occurred
if (!outcome) {
return [{
property: keyName,
rule: ruleName,
data: data,
message: util.format('"%s" validation rule failed for input: %s', ruleName, util.inspect(data))
message: util.format('"%s" validation rule failed for value of `%s`: %s', ruleName, keyName, util.inspect(data))
}];
}
else {
Expand Down
55 changes: 55 additions & 0 deletions lib/match/matchRuleset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Module dependencies
*/

var _ = require('lodash')
, util = require('util')
, matchType = require('./matchType')
, matchRule = require('./matchRule')
;


/**
* Match a value against a ruleset.
*
* @param {*} data
* @param {{}} ruleset
* @param {string} keyName
*
* @returns {[]} a list of errors, or an empty list in the absence of them
*/
module.exports = function matchRuleset (data, ruleset, keyName) {
"use strict";

var errors = [], self = this;

if (!_.isPlainObject(ruleset)) {
return [{
property: keyName,
message: util.format('Ruleset definition for property `%s` should be a plain object.', keyName)
}];
}

if (!_.has(ruleset, 'type')) {
ruleset['type'] = {};
}

// if no explicit `required` rule, then treat as optional TODO: What about `null` and empty string same?
if (data === undefined && !ruleset.required) {
return errors;
}

_.forOwn(ruleset, function (value, key) {
"use strict";

if (key === 'type') {
// Validate a type rule
errors = errors.concat(matchType.call(self, data, value, keyName));
} else {
// Validate a non-type rule
errors = errors.concat(matchRule.call(self, data, key, value, keyName));
}
});

return errors;
};
Loading