/**
* @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