forms/Validator.js

/**
 * @module flitter-forms/Validator
 */

const validator = require('validator')
const messages = require('./Messages')
const { Injectable } = require('flitter-di')

/**
 * Validates input against a specific schema and returns errors.
 * @extends module:flitter-di/src/Injectable~Injectable
 */
class Validator extends Injectable {
    /**
     * Defines the services required by this class.
     * @returns {Array<string>}
     */
    static get services() {
        return [...super.services, 'app']
    }
    
    /**
     * Instantiate the class. Store the schema and name used for validation.
     * @param {string} name - name of this validator
     * @param {Object} schema - schema to validate input against
     * @constructor
     */
    constructor(name, schema){
        super()

        /**
         * Schema to validate input against.
         * @type {Object}
         * @name Validator#schema
         */
        this.schema = schema

        /**
         * Name of this validator.
         * @type {string}
         * @name Validator#name
         */
        this.name = name
    }

    /**
     * Some function that is called after the validation completes.
     * 
     * @callback module:flitter-forms/Validator~Validator#validate_callback
     * @name module:flitter-forms/Validator~Validator#validate_callback
     * @param {boolean} failed - if true, then the input has failed validation
     * @param {Object} [masked] - if the validation succeeded, this contains the masked input values. If the validation failed, it contains the error message object.
     */

    /**
     * Validates the given input against the Validator's schema.
     * This method will only provide inputs listed in the schema.
     * All other fields are ignored and are not returned.
     * 
     * If a callback is provided, the boolean fail status and error message object will be passed to it.
     * Otherwise, the function will return the error message object.
     * 
     * @param {Object} input - input to be validated where key => value is the field name => field value
     * @param {module:flitter-forms/Validator~Validator#validate_callback} [callback] - function called when validation has completed
     * @returns {Error|boolean}
     */
    validate(input, callback = false){
        
        /*
         * Get the list of fields listed in the schema.
         */
        const schema_fields = Object.keys(this.schema)
        
        /*
         * Should we fail? - if the validation fails any of the criteria
         * If so, have a list of errors by field.
         */
        let fail = false
        const failures = this.make_error_chain()
        
        /*
         * Iterate over the fields in the schema.
         */
        for ( let schema_key in schema_fields ) {
            let schema_field = schema_fields[schema_key]
            
            /*
             * Get the array of validator names for the given field in the schema.
             */
            let field_validators = this.schema[schema_field]
            
            /*
             * Iterate over the validators.
             */
            for ( let criterion_key in field_validators ){
                
                /*
                 * Split the criterion string into its substituent parts.
                 * 'name:"JSON Arguments"'. We must pass along the input
                 * data for interpolation.
                 */
                let criterion = this.extract_validator_arguments(field_validators[criterion_key], input)
                
                /*
                 * If the validator (criterion) is invalid, return an error.
                 */
                if ( !( criterion.name in validator ) && !( ["required", "verify"].includes(criterion.name) ) ){
                    return new Error("Invalid validation criterion: "+criterion.name)
                }
                
                /*
                 * Manually check the 'required' criterion.
                 */
                if ( criterion.name === "required" ){
                    
                    /*
                     * Check if the field is not present, or if it is an empty string
                     */
                    if ( !( schema_field in input ) || input[schema_field] === "" ){
                        
                        /*
                         * If the field is not present and better than "",
                         * get the error message and set fail = true
                         */
                        fail = true
                        const error_message = messages('required', schema_field)
                        
                        /*
                         * The terminology here is confusing. If the message
                         * function returns an Error(), then something has gone
                         * wrong in the program. That Error() should be passed up
                         * to the next level so it can be dealt with.
                         */
                        if ( error_message instanceof Error ){
                            return error_message
                        }
                        
                        /*
                         * Otherwise, push the validation error message to the
                         * appropriate schema field in the errors object.
                         */
                        failures[schema_field].push(error_message)
                    }
                }
                
                /*
                 * Check the 'verify' criterion. This checks for an identical
                 * field with '_verify' appended to its name.
                 * e.g.
                 *      'password' must match 'password_verify'
                 */
                else if ( criterion.name === "verify" ) {
                    
                    /*
                     * Check that the verification field is present.
                     */
                    if ( !( schema_field+"_verify" in input ) || ( input[schema_field] !== input[schema_field+"_verify"] ) ){
                            
                        /*
                         * If the corresponding _verify field is not present,
                         * or does not match the input field, get the error
                         * message and set fail = true.
                         */
                        fail = true
                        const error_message = messages('verify', schema_field)

                        /*
                         * The terminology here is confusing. If the message
                         * function returns an Error(), then something has gone
                         * wrong in the program. That Error() should be passed up
                         * to the next level so it can be dealt with.
                         */
                        if (error_message instanceof Error) {
                            return error_message
                        }

                        /*
                         * Otherwise, push the validation error message to the
                         * appropriate schema field in the errors object.
                         */
                        failures[schema_field].push(error_message)
                    }
                }
                
                /*
                 * Otherwise, pass it off to validator.
                 */
                else {
                    let passes_validator = false
                    
                    /*
                     * If the criterion has a secondary argument, parse it and pass it along.
                     */
                    if ( 'args' in criterion ){
                        
                        /*
                         * Here, we wrap the input in String() to cast it so the validator class
                         * will accept it.
                         */
                        passes_validator = validator[criterion.name](String(input[schema_field]), criterion.args)
                    }
                    else {
                        
                        /*
                         * Here, we wrap the input in String() to cast it so the validator class
                         * will accept it.
                         */
                        passes_validator = validator[criterion.name](String(input[schema_field]))
                    }
                    
                    /*
                     * If the criterion fails, fail the validation and push
                     * the error message to the failures object.
                     * 
                     * Here, we stringify the argument so it behaves more like
                     * a readable sentence.
                     */
                    if ( !passes_validator ){
                        
                        /*
                         * If the validation check fails,
                         * get the error message and set fail = true
                         */
                        fail = true
                        const error_message = messages(criterion.name, schema_field, this.stringify_argument(criterion.args))

                        /*
                         * The terminology here is confusing. If the message
                         * function returns an Error(), then something has gone
                         * wrong in the program. That Error() should be passed up
                         * to the next level so it can be dealt with.
                         */
                        if ( error_message instanceof Error ){
                            return error_message
                        }

                        /*
                         * Otherwise, push the validation error message to the
                         * appropriate schema field in the errors object.
                         */
                        failures[schema_field].push(error_message)
                    }
                }
            }
        }
        
        /*
         * if the validation has failed, run the callback with fail = true
         * and pass it the list of validation errors.
         */
        if ( fail ){
            
            /*
             * if a callback is provided, call it and pass it the fail and errors
             */
            if ( callback ) {
                /*
                 * The callback function is called from within an anonymous function
                 * to isolate it from the variable scope of this method.
                 * 
                 * If it isn't done this way, an arrow function passed as a callback
                 * would have access to all of the variables in this method by name.
                 * 
                 * e.g. if callback was () => console.log(input)
                 * then it could access the input variable of this method.
                 */
                (function (fail, errors) {callback(fail, errors)})(fail, failures)
            }
            
            /*
             * Otherwise, return the failure
             */
            else {
                return fail
            }
        }

        /*
         * If it succeeds, run the callback with fail = false and pass
         * the masked input -- that is, only the input fields that are
         * present in the schema.
         */
        else {
            
            /*
             * If a callback is provided (which it should be!), call the callback
             * and pass the masked returns.
             */
            if ( callback ){

                /*
                 * The callback function is called from within an anonymous function
                 * to isolate it from the variable scope of this method.
                 * 
                 * If it isn't done this way, an arrow function passed as a callback
                 * would have access to all of the variables in this method by name.
                 * 
                 * e.g. if callback was () => console.log(input)
                 * then it could access the input variable of this method.
                 */
                (function(fail, returns){ callback(fail, returns) })(fail, this.mask_returns(this.schema, input))
            }
            
            /*
             * Otherwise, return a boolean status of whether or not the
             * input is valid. This is considered a fallback, and should
             * not be used as the primary functionality of this method.
             */
            else {
                return fail
            }
        }
    }
    
    
    /**
     * Given an input and a schema, returns only the input fields that are present in the schema.
     * 
     *  e.g. provided
     *      schema: {test:"JSON"}  and  
     *      input:  {test:"foo", also_test:"bar"}, we have the output: 
     *
     *      output: {test:"foo"}
     * 
     * @param {Object} schema - schema to check fields against
     * @param {Object} input - input to be masked
     * @returns {Object}
     */
    mask_returns(schema, input){
        const schema_fields = Object.keys(schema)
        const masked = {}
        
        /*
         * Iterate over the schema fields.
         */
        for ( let schema_field_key in schema_fields ){
            const schema_field = schema_fields[schema_field_key]
            
            /*
             * If the schema field is present in the given input,
             * assign it to the masked return.
             */
            if ( schema_field in input ){
                masked[schema_field] = input[schema_field]
            }
        }
        
        return masked
    }
    
    /**
     * Create an Object for holding the errors for the fields defined in {@link module:flitter-forms/Validator~Validator#schema}.
     * Each key in the schema is assigned an empty array.
     * 
     * @returns {Object}
     */
    make_error_chain(){
        
        /*
         * Get the schema fields.
         */
        const input_fields = Object.keys(this.schema)
        const errors = {}
        
        /*
         * Iterate over the schema fields and create arrays for each of them.
         */
        for ( let field_key in input_fields ){
            errors[input_fields[field_key]] = []
        }
        
        return errors
    }
    
    /**
     * Break the string-form criterion into its name and argument(s). 
     * If the argument has any references to input fields, those values will be interpolated in place of the reference.
     * This is done by calling {@link module:flitter-forms/Validator~Validator#interpolate_fields}.
     * @param {string} criterion - string-form criterion to be parsed
     * @param {Object} input - input to be interpolated
     * @returns {{args: void, name: string}|{name: string}|{args: *, name: string}}
     */
    extract_validator_arguments(criterion, input){
        /*
         * If the criterion is of the format: "validator:arguments",
         * split it at the first instance of ':' and get the args.
         */
        if ( criterion.indexOf(':') > -1 ){
            /*
             * Split at the first instance of ':' ONLY.
             */
            let criterion_args = criterion.split(/:(.+)/)[1]
            criterion = criterion.split(/:(.+)/)[0]

            /*
             * Interpolate field data. This means that where ever there
             * is a reference to a field in the arguments string (e.g. '$cost$'),
             * replace it with the actual value of the field with that given
             * name (e.g. input['cost']).
             */
            criterion_args = this.interpolate_fields(criterion_args, input)
            
            /*
             * If the criterion argument is not a JSON argument,
             * treat it as a string.
             */
            if ( !(['"', '[', '{'].includes(criterion_args.charAt(0))) ){
                
                return {name: criterion, args: criterion_args}
            }
            
            /*
             * Otherwise, try to parse it as JSON.
             */
            else {
                return {name: criterion, args: JSON.parse(criterion_args)}
            }
        }
        
        /*
         * If the criterion has no argument, return just the criterion.
         */
        return {name: criterion}
    }
    
    
    /**
     * Given some string that may contain references to field names, create an array of those field names.
     * @param {string} string - String containing the references. (e.g. "Price: $cost$")
     * @returns {array}
     */
    parse_interpolators(string){
        string = string
            /*
             * This call removes all escaped characters so they aren't
             * picked up by the parser. This is meant to be used to tell the
             * parser to ignore dollar signs in argument strings.
             * 
             * Because of the way JS handles string escaping, if you want
             * to escape a dollar sign, you need to prefix it with a double
             * backslash.
             * 
             * e.g.:  "\\$cost\\$" -> the parser will ignore $cost$
             * 
             * This probably won't come up particularly often.
             */
            .replace(/\\./g, '')
            
            /*
             * Now, we run regex to match all strings beginning
             * and ending with $ (e.g. $variable$).
             * 
             * \$       matches the $ character
             * (        starts an inner match
             * [^$]+    matches any string that is not $
             * )        closes the inner match
             * \$       matches the $ character
             */
            .match(/\$([^$]+)\$/g)
        
        /*
         * For each of the matches, remove the first and last characters
         * from the string, which should be the matched $ characters.
         */
        for ( let match in string ){
            string[match] = string[match].slice(1, -1)
        }
        
        return string
    }
    
    
    /**
     * Replace any references to fields with the value of that field from the given input.
     * @param {string} string - String that may contain references to be interpolated.
     * @param {Object} inputs - collection of input field values to be interpolated
     * @returns {string}
     */
    interpolate_fields(string, inputs){
        
        /*
         * Get a list of interpolation names.
         */
        const interpolators = this.parse_interpolators(string)
        
        /*
         * For each of the names, replace all instances of it
         * with the value of the corresponding field.
         */
        for (let i in interpolators){
            const interpolator = interpolators[i]
            
            /*
             * Create a regex search key for the field name
             * surrounded with $ characters.
             * 
             * e.g. if we have 'cost', create an expression to match '$cost$'
             */
            const searchkey = '\\$'+ interpolator + '$'
                
                /*
                 * Escapes some characters that are regex keywords.
                 * Specifically, we need to escape the $.
                 */
                .replace(/[-\/\\$]/g, '\\$&')
            
            /*
             * Create a regex searcher using this expression.
             * Set it to global (find all instances).
             */
            const searcher = new RegExp(searchkey, 'g')
            
            /*
             * If the input field exists, replace the instances with
             * that. Otherwise, just use an empty JSON string.
             */
            let replace = ""
            if ( interpolator in inputs ){
                replace = inputs[interpolator]
            }

            /*
             * Replace all matches with the value of the corresponding field
             * from the input object.
             */
            string = string.replace(searcher, replace)
        }
        
        return string
    }
    
    /**
     * Format the provided criterion argument as a human-readable string. For example, if the argument is an Object
     * containing "min" and/or "max" values, make it human readable with "less than...greater than."
     * @param {string|Object} argument - argument to be converted
     * @returns {string}
     */
    stringify_argument(argument = ""){
        /*
         * If the argument is already a string, just return it.
         */
        if ( typeof argument === 'string' ){
            return argument
        }
        
        /*
         * If it is an array, create a comma-separated list:
         * [0,1,2,3] -> "0, 1, 2, and 3"
         */
        else if ( argument.constructor === Array ){
            let string = ""
            for ( let element in argument ){
                string = string + ', '+element
            }
            
            /*
             * Get rid of the preceeding ", " and replace the last "," with ", and"
             */
            string = string.substring(2).replace(/,([^,]*)$/,', and$1')
            
            return string
        }
        
        /*
         * If the argument is an object that specified minimum and maximum values,
         * create a string with those values.
         */
        else if ( typeof argument === 'object' && ( 'min' in argument || 'max' in argument ) ) {
            let string = ""
            
            /*
             * If there is a minimum, add that.
             */
            if ( 'min' in argument ){
                string += "greater than "+argument.min
            }
            
            /*
             * If there are both, add " and "
             */
            if ( 'min' in argument && 'max' in argument ){
                string += " and "
            }
            
            /*
             * If there is a maximum, add that.
             */
            if ( 'max' in argument ){
                string += "less than "+argument.max
            }
            
            return string
        }
        
        /*
         * If all the above conditions fail, return an empty string.
         */
        return ""
    }

    /**
     * Called after a form submission passes validation.
     * 
     * @callback module:flitter-forms/Validator~Validator#handle_callback
     * @name module:flitter-forms/Validator~Validator#handle_callback
     * @param {Express/Request} req
     * @param {Express/Response} res
     * @param {Object} input - the masked input from the form submission. This input was successfully validated.
     */

    /**
     * Handle an incoming form submission from the Express request's body.
     * Runs {@link module:flitter-forms/Validator~Validator#validate} on the input. If the validation succeeds, pass the masked input to the
     * provided callback. Otherwise, store the error messages in the request session and redirect the user
     * to the specified error route.
     * @param {Express/Request} req
     * @param {Express/Response} res
     * @param {string} error_route - route the user should be redirected to upon a validation failure
     * @param {module:flitter-forms/Validator~Validator#handle_callback} callback
     */
    handle(req, res, error_route, callback){
        
        /*
         * Run the form validation.
         */
        const error = this.validate(req.body, (fails, returns) => {
            
            /*
             * If the validation fails, write the errors to session
             * and redirect to the specified route.
             */
            if ( fails ){ 
                req.session.forms[this.name].errors = returns
                res.redirect(error_route)
            }
            else {

                /*
                 * Otherwise, run the callback and pass along
                 * the sanitized input.
                 */
                callback(req, res, returns)
            }
        })
        
        if ( error && error instanceof Error ){
            throw error
        }
    }
}

module.exports = exports = Validator