fogine/json-inspector

View on GitHub
lib/validator.js

Summary

Maintainability
F
3 days
Test Coverage
var _                    = require('lodash');
var queryString          = require('qs');
var stringValidator      = require('validator');
var ValidatorError       = require('./error/validatorError.js');
var ValidationError      = require('./error/validationError.js');
var ValidationMultiError = require('./error/validationMultiError.js');
var assertions           = require('./assertions.js');
var sanitizers           = require('./sanitizers.js');
var conditions           = require('./conditions.js');

var InternalProps = ['message', 'required', 'forEach', 'sanitize'];
InternalProps = _.union(InternalProps, Object.keys(sanitizers), Object.keys(assertions), Object.keys(conditions));

module.exports = Validator;


/**
 * Validator
 *
 * @param {Object}          [options]
 * @param {Object}          [options.context={}]
 * @param {boolean}         [options.required=false]
 * @param {boolean}         [options.filterData=true]
 * @param {boolean}         [options.failOnUnexpectedData=false] - applies only if `filterData=true`
 * @param {string|Function} [options.message="Invalid data for %p ,got: %v"]
 * @param {boolean}         [options.failOnFirstErr=false]
 * @param {boolean}         [options.nullable=false]
 * @param {string}          [options.keywordPrefix="$"]
 * @param {Function}        [options.validationError=ValidationError] - returned only if single error occured (or `failOnFirstErr=true`)
 * @param {Function}        [options.validationMultiError=ValidationMultiError] - returned if multiple errors occured
 * @param {string}          [options.comparisonOperator="and"] - one of the property names form `conditions` list. Determines default comarison operator between multiple assertions in `where` filter object (most certainly you dont want to change this)
 */
function Validator(schema, options, valManager) {
    var defaults = {
        context              : {},
        required             : false,
        sanitizers           : [],
        message              : 'Invalid data for %p, got: "%v"',
        keywordPrefix        : '$',
        nullable             : false,
        filterData           : true,
        failOnUnexpectedData : false,
        failOnFirstErr       : false,
        validationError      : ValidationError,
        validationMultiError : ValidationMultiError,
        comparisonOperator   : 'and'
    };

    this.options = _.merge(defaults, options || {});

    this.schema = schema || {};
    this.validatorManager = valManager;
    this.options.context = {
        validatorManager: valManager,
        getSchemaFor: function(validator, customSchema) {
            var schema = valManager.get(validator).getSchema(customSchema);
            return schema;
        }
    };
    //validate method gradually builds data tree of successfuly validated data properties
    //so that data can be run through filter, deleting all unexpected data entities or entities
    //which did not pass the validation
    this.dataTree = {};
    this.error = null;
    this.pool = [];
    //data being validated
    this.data = null;
    this.success;

    if (!_.isPlainObject(schema) && typeof schema !== 'function' ) {
        throw new ValidatorError('validator schema must be an object or a function.')
    }
}

/*
 * getSchema
 *
 * @param  {Object} [customSchema] - schema definition which will be merged into the schema of the validator
 * @return {Object}
 */
Validator.prototype.getSchema = function(customSchema) {
    var schema;

    if (typeof this.schema === 'function') {
        schema = this.schema.apply(this.options.context);
    } else {
        schema = this.schema;
    };

    if (customSchema) {
        //'schema' must be cloned recursively!
        schema = _.cloneDeep(schema);

        _.mergeWith(schema, customSchema, function(objValue, srcValue) {
            //array value overwrites value in schema definition
            if (srcValue instanceof Array) {
                return srcValue;
            }
        });
    }

    return schema;
};

/*
 * validate
 *
 * returns validator report object
 *
 * @param {array}    data - data being validated
 * @param {object}   [customSchema] - schema definition which will be merged into the original schema of the validator
 * @param {object}   [options]
 * @param {boolean}  [options.filterData=true]
 * @param {boolean}  [options.failOnUnexpectedData=false] - applies only if `filterData=true`, it will failt the validation process if data include unexpected data (data which are not described by a validator schema)
 * @param {Array}    [options.only] - if an array is provided, only listed properties are validated. Any data which are not listed in the array will be filtered out (if `filterData` != false)
 * @param {boolean}  [options.failOnFirstErr=false]
 * @param {boolean}  [options.nullable=false]
 * @param {Function} [options.validationError=ValidationError]
 * @param {Function} [options.validationMultiError=ValidationMultiError]
 *
 * @return {Validator}
 */
Validator.prototype.validate = function(data, customSchema, options) {
    var self = this;
    options = options || {};

    this.error = null; //TODO ?
    this.pool = [];
    this.dataTree = [];
    this.success = undefined;
    this.data = data;
    //must be cleared
    this.options.sanitizers = [];
    options.sanitizers = [];

    options = _.defaults(options, this.options);
    options.propPath = '';

    var schema = this.getSchema(customSchema);

    if (options.only instanceof Array) {
        schema = this.reduceSchema(schema, options.only)
    }

    var report =  this.build(data, schema, options, data, null, this.dataTree)();
    report.errors = _.flattenDeep(report.errors);

    if (report.success === null) { // no assertions have been made
        report.success = true;
    }

    this.error = this._transformErrors(report.errors, options);
    this.pool = report.pool;
    this.success = report.success;
    this.sanitizers = report.options.sanitizers;

    //filter out unexpected data
    if (    this.success === true
        &&  options.filterData === true
        && (_.isPlainObject(data) || data instanceof Array)
    ) {
        var errors = [];
        this.filterData(data, this.dataTree, function(unexpectedDataItem, index, data) {
            if (options.failOnUnexpectedData === true) {
                var escapedIndex = stringValidator.escape(index);
                var error = new options.validationError('Unexpected data property: ' + escapedIndex);

                self.success = false;
                errors.push(error);

                throw error;
            }

            delete data[index];
        });

        this.error = this._transformErrors(errors, options);
    }

    //run sanitizers if the validator has succeeded
    if (this.success === true) {
        this.runSanitizers();
    }

    return this;
};


/**
 * _transformErrors
 *
 * @param {Array<ValidationError>} errors
 * @param {Object} options
 * @param {Object} options.validationError
 * @param {Object} options.validationMultiError
 *
 * @return {ValidationError|ValidationMultiError|Object} - returns instance of user defined constructor if an user overwriten validationError/validationMultiError constructors
 */
Validator.prototype._transformErrors = function(errors, options) {
    var len = errors.length;

    if (len === 1) {
        return errors.pop();
    } else if (len > 1) {
        var errorMessage = '';
        for (var i = 0, len = errors.length; i < len; i++) {
            errorMessage += errors[i].message + '\n';
        }
        return new options.validationMultiError(errorMessage, errors);
    } else {
        return null;
    }
};

/*
 * reduceSchema
 *
 * @param {Object} schema - validator schema defitions
 * @param {Array} filter - collection of validator keys which should be extracted from the `schema`
 * @return {Object} reduced schema
 */
Validator.prototype.reduceSchema = function(schema, filter) {
    var out = {};
    for (var i = 0, len = filter.length; i < len; i++) {
        if (schema.hasOwnProperty(filter[i])) {
            out[filter[i]] = schema[filter[i]];
        }
    }
    return out;
}

/*
 * filterData
 *
 * @param {Object|Array} data - data being validated
 * @param {Object|Array} filter
 * @param {function} callback - function which is run for every unexpected data item
 * @return {Object|Array}
 */
Validator.prototype.filterData = function(data, filter, callback) {

    if (_.isPlainObject(data)) {
        var allowedKeys = Object.keys(filter);
        var dataKeys = Object.keys(data);

        var allowedDataKeys = [];
        for (var i = 0, len = dataKeys.length; i < len; i++) {
            if (allowedKeys.indexOf(dataKeys[i]) === -1) {
                try {
                    callback.call(this, data[dataKeys[i]], dataKeys[i], data);
                } catch(e) {
                    return;
                }
            } else {
                allowedDataKeys.push(dataKeys[i]);
            }
        }
        for (var y = 0, len = allowedDataKeys.length; y < len; y++) {
            var dataItem = data[allowedDataKeys[y]];
            var key = allowedDataKeys[y];

            if (   typeof dataItem === 'object'
                && dataItem !== null
                && (_.isPlainObject(filter[key]) || filter[key] instanceof Array)
            ) {
                this.filterData(dataItem, filter[key], callback);
            }
        }
    }

    if (data instanceof Array) {
        for (var z = 0, len = data.length; z < len; z++) {
            if (_.isObject(data[z])
                    && (_.isPlainObject(filter[z]) || filter[z] instanceof Array)
               ) {
                this.filterData(data[z], filter[z], callback);
            }
        }
    }
}

/*
 * runSanitizers
 * @return {undefined}
 */
Validator.prototype.runSanitizers = function() {

    for (var i = 0, len = this.sanitizers.length; i < len; i++) {
        this.sanitizers[i]();
    }
};

/*
 * buildCondition
 *
 * @param {array}  pool
 * @param {string} condType - see `conditions` list for available property values
 * @param {object} options
 * @return {Function}
 */
Validator.prototype.buildCondition = function(pool, condType, options) {
    var context = {
        errors: [],
        pool: pool,
        options: options
    };

    return function bulkAssert() {
        if (!context.pool.length) {
            context.success = null; //we don't have any assertions to make
            return context;
        }
        return conditions[condType].call(context);
    }
}

/*
 * buildAssertion
 *
 * @param {mixed}  val - data value which is being inspected
 * @param {mixed}  filterValue - value of one filter from `where` filter object
 * @param {string} assertType - see `assertions` list for available assertions
 * @return {function}
 */
Validator.prototype.buildAssertion = function(val, filterValue, assertType, options) {
    var context = {
        val           : val,
        filter        : filterValue,
        assertions    : assertions,
        sanitizers    : sanitizers,
        propPath      : options.propPath,
        message       : options.message,
        onSuccess     : options.onSuccess,
        keywordPrefix : options.keywordPrefix,
        overallData   : options.overallData,
        parentTreeObj : options.parentTreeObj,
        negated       : options.comparisonOperator ? conditions[options.comparisonOperator].negated : false
    };
    return function() {
        try {
            return assertions[assertType].call(context);
        } catch(e) {
            if (e instanceof ValidatorError) {
                throw e;
            }
            context.success = context.negated ? true : false;
            return context;
        }
    }
}

/*
 * build
 *
 * build array of assertion functions
 *
 * @param {Object} data
 * @param {Object} where - filter object
 * @param {Object}   [options]
 * @param {string}   [options.propPath='']
 * @param {Array}    [options.sanitizers=[]]
 * @param {object}   [options.filterData=true]
 * @param {boolean}  [options.failOnUnexpectedData=false] - applies only if `filterData=true`
 * @param {Array}    [options.only] - if an array is provided, only listed properties are validated. Any data which are not listed in the array will be filtered out (if `filterData` != false)
 * @param {string}   [options.keywordPrefix="$"]
 * @param {boolean}  [options.failOnFirstErr=false]
 * @param {boolean}  [options.nullable=false]
 * @param {Function} [options.validationError=ValidationError]
 * @param {String} [options.propPath] - path to current property being validated. In dot notation
 * @param {Object|Array} parentDataObj - object which contains value of `data` property OR `data` === parentDataOb
 * @param {String|Integer} parentProp - `parentDataObj[parentProp]` === `data`
 * @return {Function}
 */
Validator.prototype.build = function(data, where, options, parentDataObj, parentProp, parentTreeObj) {

    var self = this;

    var pool = [];
    var objectAssertionWrapperPool = [];
    var keywordPrefix = options.keywordPrefix;
    var requiredKeyword = keywordPrefix + 'required';
    var nullableKeyword = keywordPrefix + 'nullable';
    var messageKeyword = keywordPrefix + 'message';

    // loop through all assertion statements recursively
    Object.keys(where).forEach(function(prop) {
        var prefixedProp = prop;
        prop = getPropWithoutPrefix(prop, options);

        if (where[prefixedProp] instanceof Validator) {
            where[prefixedProp] = where[prefixedProp].getSchema();
        }

        if (assertions.hasOwnProperty(prop)) {//builds single assertion
            var required = where.hasOwnProperty(requiredKeyword) ? where[requiredKeyword] : options.required;
            var nullable = where.hasOwnProperty(nullableKeyword) ? where[nullableKeyword] : options.nullable;

            if (required === false) {
                if (data === undefined) return;

                if (nullable === true && data === null) {
                    _.set(parentTreeObj, options.propPath, parentProp);
                    return;
                }
            }

            pool.push( self.buildAssertion(data, where[prefixedProp], prop, {
                propPath: options.propPath,
                overallData: self.data,
                message: where[messageKeyword] || options.message,
                parentTreeObj: parentTreeObj,
                keywordPrefix: options.keywordPrefix,
                comparisonOperator: options.comparisonOperator,
                onSuccess: function(context){
                    var currVal = _.get(context.parentTreeObj, options.propPath);
                    if (!_.isPlainObject(currVal) && !(currVal instanceof Array)) {
                        _.set(context.parentTreeObj, options.propPath, parentProp);
                    }
                }
            }));

        } else if (sanitizers.hasOwnProperty(prop)) {// builds sanitizer fn for current data
            self.addSanitizer(prop, where, parentDataObj, parentProp, options);
        } else if (prop === 'sanitize') {// builds sanitizer fn for current data
            options.sanitizers.push(function() {
                if (parentProp !== null) {
                    parentDataObj[parentProp] = where[prefixedProp].call(options.context, parentDataObj[parentProp], parentDataObj);
                } else {//top level sanitizer => does not have any parent object
                    where[prefixedProp].call(options.context, parentDataObj);
                }
            });
        } else if (conditions.hasOwnProperty(prop)) { // yields new pool of assertions
            if (where[prefixedProp] instanceof Array) {
                var subPool = [];
                where[prefixedProp].forEach(function(subWhere) {
                    var opt = _.assign({}, options, {
                        required: where.hasOwnProperty(requiredKeyword) ?
                            where[requiredKeyword] :
                                subWhere.hasOwnProperty(requiredKeyword) ?
                                subWhere[requiredKeyword] :
                                options.required,
                        message: subWhere[messageKeyword] || options.message
                    });
                    subPool.push(self.build(data, subWhere, opt, parentDataObj, parentProp, parentTreeObj));
                });
                pool.push(self.buildCondition(subPool, prop, options));
            } else if(_.isPlainObject(where[prefixedProp])) {
                var opt = _.assign({}, options, {
                    comparisonOperator: prop,
                    required: where[prefixedProp].hasOwnProperty(requiredKeyword) ? where[prefixedProp][requiredKeyword] : options.required,
                    message: where[prefixedProp][messageKeyword] || where[messageKeyword] || options.message
                });
                pool.push(self.build(data, where[prefixedProp], opt, parentDataObj, parentProp, parentTreeObj));
            }
        } else if (['message', 'required', 'nullable'].indexOf(prop) !== -1) {
            return;
        } else if (prop === 'forEach') {// yields new pool of assertions for every item of an array
            var fun = self.buildArrayCondition(prefixedProp, data, where, options, parentTreeObj);
            if (fun instanceof Function) {
                pool.push(fun);
            }
        // builds validators for object's properties
        // yields new pool of assertions
        } else if (_.isPlainObject(where[prefixedProp]) )  {

            var required = where.hasOwnProperty(requiredKeyword) ? where[requiredKeyword] : options.required;
            var nullable = where.hasOwnProperty(nullableKeyword) ? where[nullableKeyword] : options.nullable;

            if (required !== true) {
                if (data === undefined) return;

                if (nullable === true && data === null) {
                    _.set(parentTreeObj, options.propPath, {});
                    return;
                }
            }

            //
            var fun = self.buildObjectCondition(prefixedProp, data, where, options, parentTreeObj);
            if (fun instanceof Function) {
                pool.push(fun);
            }

            if (!objectAssertionWrapperPool.length && (pool.length || required)) {
                objectAssertionWrapperPool.push( self.buildAssertion(data, Object, 'is', {
                    propPath: options.propPath,
                    keywordPrefix: options.keywordPrefix,
                    message: where[messageKeyword] || options.message,
                    parentTreeObj: parentTreeObj
                }));
                //this is faster way than defining "onSuccess" callback which would
                //set the property but if and only if it is not set already
                if (_.isPlainObject(data)) {
                    if (options.propPath) {
                        _.set(parentTreeObj, options.propPath, {});
                    }
                }
            }
        }
    });

    //return condition fn
    var cond = self.buildCondition(pool, options.comparisonOperator, options);

    if (objectAssertionWrapperPool.length && (pool.length || options.required)) {
        objectAssertionWrapperPool.push(cond);
        return self.buildCondition(objectAssertionWrapperPool, 'and', options);
    }

    return cond;
}

/*
 * addSanitizer
 *
 * mutates options.sanitizers array
 *
 * @param {string} sanitizerProp - one of the properties from `sanitizers` Map
 * @param {Object} where
 * @param {Object|Array} parentDataObj
 * @param {string} parentProp
 * @param {Object} options
 *
 * @return {undefined}
 */
Validator.prototype.addSanitizer = function(sanitizerProp, where, parentDataObj, parentProp, options) {
    var val;

    if (parentProp === null) {
        //top level sanitizer does not have any parent object
        val = parentDataObj;
    } else {
        if (!parentDataObj.hasOwnProperty(parentProp)) {
            //when data does not has such property, do not run the sanitizer for that property
            return;
        }
        val = parentDataObj[parentProp];
    }

    var context = {
        filter           : where[options.keywordPrefix + sanitizerProp],
        getSchemaFor     : options.context.getSchemaFor,
        req              : options.context.req,
        validatorManager : options.context.validatorManager,
        val              : val
    };

    options.sanitizers.push(function() {
        var result = sanitizers[sanitizerProp].call(context);
        if (parentProp !== null) {
            parentDataObj[parentProp] = result;
        }
    });
}

/*
 * buildObjectCondition
 *
 * @param {string} prop
 * @param {object} data
 * @param {object} where - filter object
 * @param {object} [options]
 * @param {string} [options.propPath] - path to current property being validated. In dot notation
 * @return {Function}
 */
Validator.prototype.buildObjectCondition = function(prop, data, where, options, parentTreeObj) {
    var path = options.propPath ? options.propPath + '.' + prop : prop;
    var keywordPrefix = options.keywordPrefix;
    var requiredKeyword = keywordPrefix + 'required';
    var messageKeyword = keywordPrefix + 'message';

    var required = where.hasOwnProperty(requiredKeyword) ? where[requiredKeyword] : options.required;

    //validate property of an object
    required = where[prop].hasOwnProperty(requiredKeyword) ? where[prop][requiredKeyword] : required;

    if ( _.isPlainObject(data) && (data.hasOwnProperty(prop) || required )) {
        var opt = _.assign({}, options, {
            propPath      : path,
            required      : required,
            message       : where[messageKeyword] || options.message
        });
        return this.build(data && data[prop], where[prop], opt, data, prop, parentTreeObj);
    }
}

/*
 * buildArrayCondition
 *
 * @param {string} prop = 'forEach'
 * @param {object} data
 * @param {object} where - filter object
 * @param {object} [options]
 * @param {string} [options.propPath] - path to current property being validated. In dot notation
 * @return {Function}
 */
Validator.prototype.buildArrayCondition = function(prop, data, where, options, parentTreeObj) {
    var andCondPool = [];
    var keywordPrefix = options.keywordPrefix;
    var requiredKeyword = keywordPrefix + 'required';
    var messageKeyword = keywordPrefix + 'message';
    var nullableKeyword = keywordPrefix + 'nullable';

    var nullable = where.hasOwnProperty(nullableKeyword) ? where[nullableKeyword] : options.nullable;

    if (options.required !== true) {
        if (data === undefined) return;

        if (nullable === true && data === null) {
            _.set(parentTreeObj, options.propPath, []);
            return;
        }
    }
    var required = where[prop].hasOwnProperty(requiredKeyword) ? where[prop][requiredKeyword] : options.required;

    andCondPool.push( this.buildAssertion(data, Array, 'is', {
        propPath: options.propPath,
        keywordPrefix: options.keywordPrefix,
        message: where[messageKeyword] || options.message
    }));
    _.set(parentTreeObj, options.propPath, []);

    if ( data instanceof Array ) {
        for (var i = 0, len = data.length; i < len; i++) {
            var opt = _.assign({}, options, {
                comparisonOperator: options.comparisonOperator,
                required: required,
                propPath: options.propPath + '.[' + i + ']',
                message: where[prop][messageKeyword] || where[messageKeyword] || options.message
            });
            andCondPool.push(this.build(data[i], where[prop], opt, data, i, parentTreeObj));
        }
    }

    return this.buildCondition(andCondPool, 'and', options);
}


/**
 * getPropWithoutPrefix
 *
 * @param {string} prop
 *
 * @return {string|undefined}
 */
function getPropWithoutPrefix(prop, options) {
    if (prop.indexOf(options.keywordPrefix) === 0) {
        return prop.substr(options.keywordPrefix.length);
    }
}