UNPKG

validator-chain

Version:

Powerful tool for validate input parameters in the most stylish way. Could be use as simple test case in conditional statement or in standalone chain of validation

681 lines (604 loc) 22.4 kB
const Promise = require('bluebird'); const _ = require('lodash'); const util = require('util'); const { default: Errors, registerError } = require('eratum'); registerError('greaterThan', '<%- name %>(<%- value %>) is greater than <%- limit %>', [ 'name', 'value', 'limit' ]); registerError('notGreaterThan', '<%- name %>(<%- value %>) is not greater than <%- limit %>', [ 'name', 'value', 'limit' ]); registerError('lowerThan', '<%- name %>(<%- value %>) is lower than <%- limit %>', [ 'name', 'value', 'limit' ]); registerError('notLowerThan', '<%- name %>(<%- value %>) is not lower than <%- limit %>', [ 'name', 'value', 'limit' ]); function stringify(...args) { return args.map((item) => util.inspect(item, { showHidden: false, depth: null })).join(', '); } /** * @name callbacks */ /** * Function building error. * @memberof callbacks * @name ErrorBuilder * @function * @return {Error} Responsible error. */ /** * Function checking if "value" is valid or not * @memberof callbacks * @name Checker * @function * @param {any} value Value to check. * @return {Boolean} true if valid, false otherwise. */ /** * Function appling chain validation on parameter. * @memberof callbacks * @name ChainApplier * @function * @param {Validator} Validator to apply chain on. */ /** * Access 'constructor.name'.<br/> * if undefined or null, return 'undefined' * * @param {*} data argument * @returns {String} Type name, one of 'undefined', 'Boolean', 'Number', 'String', 'Array', 'Object', 'Function', '${ClassName}' */ function instanceOf(data) { return (data !== undefined && data !== null) ? data.constructor.name : 'undefined'; } /** * Compare if item is an element of expectedType. Allow inheritance. * * @param {any} item Element for which type is checked. * @param {String|Function} expectedInstance String representation or constructor of type to looking for * @return {Boolean} true if item or its ancestry is type of expectedType. * @see {@link instanceOf} */ function isInstance(item, expectedInstance) { if (item === undefined || item === null) { return expectedInstance === 'undefined'; } if (typeof expectedInstance === 'function') { return item instanceof expectedInstance; } const isFinalInstance = [ 'Boolean', 'Number', 'String', 'Array', 'Object', 'Function' ]; const proto = Object.getPrototypeOf(item); const type = instanceOf(proto); return (type === expectedInstance) || (!isFinalInstance.includes(type) && isInstance(proto, expectedInstance)); } /** * Compare if item is an element of expectedType. Allow inheritance.<br/> * Throw error if failed. * * @param {any} item Element for which type is checked. * @param {String|Function} expectedInstance String representation or constructor of type to looking for * @param {String} name Name of item for error building * @return {undefined} */ function mustBeInstance(item, expectedInstance, name) { if (!isInstance(item, expectedInstance)) { throw Errors.invalidType({ name, actualType: instanceOf(item), expectedType: expectedInstance }); } } /** * @class Validator */ class Validator { constructor(value, name = '') { this.value = value; this.name = name; this._modifier = true; this.children = []; this._error = null; /* key words */ this.must = this; this.have = this; this.has = this; this.been = this; this.be = this; this.is = this; this.are = this; this.a = this; this.an = this; this.and = this; this.of = this; } static create(...args) { return new Validator(...args); } /** * Return if chain is valid or invalidated * * @getter * @return {Boolean} true if valid, false otherwise */ get isValid() { return !this._error && (this.children.find((child) => !child.isValid) === undefined); } /** * returns the error responsible for the invalidation * * @return {Errors} Responsible error or null. */ get error() { if (this._error) { return this._error; } const childrenErrors = this.children.map((child) => child.error).filter((error) => !!error); if (childrenErrors.length) { if (childrenErrors.length === 1) { return Errors.invalid({ name: this.name, cause: childrenErrors[0] }); } return Errors.invalid({ name: this.name, cause: childrenErrors }); } return null; } /** * Invalidate chain with given responsible error * * @param {Errors} error Responsible error. * @return {Validator} this for chaining. */ invalidate(error) { this._error = error; return this; } /** * Invalidate depending on test return * * @param {Checker} check Function returning boolean * @param {ErrorBuilder} buildError Function returning error in case of failure. Used if test succeed au modifie is true. * @param {ErrorBuilder} [buildRevError=buildError] Function returning error in case of failure. Used if test failed and modifier is false. * @return {Validator} this for chaining. */ invalidateOn(check, buildError, buildRevError) { if (this.isValid === true) { try { mustBeInstance(check, 'Function', 'invalidateOn@check'); const isInvalid = check(this.value); mustBeInstance(isInvalid, 'Boolean', 'invalidateOn@check#return'); if (isInvalid === this._modifier) { const builder = this._modifier ? buildError : (buildRevError || buildError); mustBeInstance(builder, 'Function', 'invalidateOn@builder'); this.invalidate(builder()); } } catch (cause) { throw Errors.programingFault({ reason: 'Validation was interupted by unhandeled throws error', cause }); } } return this.resetModifier(); } /** * Change modifier state. true for normal validation, false for reverse validation. * * @param {Boolean} modifier Boolean value for modifier. * @return {Validator} this for chaining. */ setModifier(modifier) { this._modifier = modifier; return this; } /** * Set modifier back to true (normal validation). * @return {Validator} this for chaining. */ resetModifier() { return this.setModifier(true); } /** * Reverse modifier * @getter * @return {Validator} this for chaining. */ get not() { return this.setModifier(!this._modifier); } /** * Execute "chain" with this validator. * @param {ChainApplier} chain Function appling chain validation. * @return {Validator} this for chaining. */ apply(chain) { try { chain(this); } catch (cause) { this.invalidate(Errors.programingFault({ reason: 'Validation was interupted by unhandeled throws error', cause })); } return this; } /** * Validate that value exists excluding undefined and null. * @see {@link instanceOf} * @return {Validator} this for chaining. */ exist() { return this.invalidateOn(() => instanceOf(this.value) === 'undefined', () => Errors.doesntExist({ name: this.name }), () => Errors.exist({ name: this.name })); } /** * Validate that value type is equal to expectedType. According to typeof. * @param {String} expectedType Type description string ('undefined', 'boolean', 'number', 'string', 'object'). * @return {Validator} this for chaining. */ type(expectedType) { const actualType = typeof this.value; return this.invalidateOn(() => actualType !== expectedType, () => Errors.invalidType({ name: this.name, actualType, expectedType: `${expectedType}` }), () => Errors.invalidType({ name: this.name, actualType, expectedType: `!${expectedType}` })); } /** * Validate that value instance is equals to expectedInstance. According to isInstance. * @see {@link isInstance} * @see {@link or} * @param {String|Function} expectedType Type description string ('Boolean', 'Number', 'String', 'Object', Object, Error, ...). * @return {Validator} this for chaining. */ instance(expectedType) { return this.invalidateOn(() => !isInstance(this.value, expectedType), () => Errors.invalidType({ name: this.name, actualType: instanceOf(this.value), expectedType: `${expectedType}` }), () => Errors.invalidType({ name: this.name, actualType: instanceOf(this.value), expectedType: `!${expectedType}` })); } /** * Validate that value is equal to parameter according to lodash#isEqual * @see {@link https://lodash.com/docs/4.17.15#isEqual|lodash#isEqual} * @param {any} value Value to compare with. * @return {Validator} this for chaining. */ equal(value) { return this.invalidateOn(() => !_.isEqual(this.value, value), () => Errors.notEqual({ name: this.name, actualValue: stringify(this.value), expectedValue: stringify(value) }), () => Errors.equal({ name: this.name, value: stringify(this.value) })); } /** * Validate that value is included in parameter values. * @param {Array<any>} values Values to look through. * @return {Validator} this for chaining. */ among(values) { return this.invalidateOn(() => typeof values.find((value) => _.isEqual(this.value, value)) === 'undefined', () => Errors.notIncluded({ name: this.name, value: stringify(this.value), possibleValues: stringify(values) }), () => Errors.included({ name: this.name, value: stringify(this.value), forbiddenValues: stringify(values) })); } /** * Validate that value is greater than limit parameter (excluded).<br/> * Use reverse function not.lowerThan to include limit. * @param {Number} limit Limit parameter. * @return {Validator} this for chaining. */ greaterThan(limit) { return this.invalidateOn(() => this.value <= limit, () => Errors.notGreaterThan({ name: this.name, value: stringify(this.value), limit: stringify(limit) }), () => Errors.greaterThan({ name: this.name, value: stringify(this.value), limit: stringify(limit) })); } /** * Validate that value is lower than limit parameter (excluded).<br/> * Use reverse function not.greaterThan to include limit. * @param {Number} limit Limit parameter. * @return {Validator} this for chaining. */ lowerThan(limit) { return this.invalidateOn(() => this.value >= limit, () => Errors.notLowerThan({ name: this.name, value: stringify(this.value), limit: stringify(limit) }), () => Errors.lowerThan({ name: this.name, value: stringify(this.value), limit: stringify(limit) })); } /** * Shortcut for not.lowerThan(limit) * @param {Number} limit Limit parameter. * @return {Validator} this for chaining. */ greaterOrEqualThan(limit) { return this.not.lowerThan(limit); } /** * Shortcut for not.greaterThan(limit) * @param {Number} limit Limit parameter. * @return {Validator} this for chaining. */ lowerOrEqualThan(limit) { return this.not.greaterThan(limit); } /** * Validate that value match given regexp * @param {RegExp} regex RegExp to validate. * @return {Validator} this for chaining. */ match(regex) { return this.invalidateOn(() => !regex.test(this.value), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: `regex(${stringify(regex)})` }), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: `!regex(${stringify(regex)})` })); } /** * Validate that value startsWith given sub string. * @see {@link https://developer.mozilla.org/fr/docs/Web/JavaScript/Reference/Objets_globaux/String/startsWith|String#startsWith} * @param {String} needle Sub string to look for. * @return {Validator} this for chaining. */ startsWith(needle) { return this.invalidateOn(() => !this.value.startsWith(needle), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: `startsWith(${stringify(needle)})` }), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: `!startsWith(${stringify(needle)})` })); } /** * Validate that value endsWith given sub string. * @see {@link https://developer.mozilla.org/fr/docs/Web/JavaScript/Reference/Objets_globaux/String/endsWith|String#endsWith} * @param {String} needle Sub string to look for. * @return {Validator} this for chaining. */ endsWith(needle) { return this.invalidateOn(() => !this.value.endsWith(needle), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: `endsWith(${stringify(needle)})` }), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: `!endsWith(${stringify(needle)})` })); } /** * Validate that value contains hexadecimal character only and it's length is even.<br/> * Shortcut for match(/^([0-9a-fA-F]{2})*$/). * @return {Validator} this for chaining. */ hexadecimal() { return this.invalidateOn(() => !/^([0-9a-fA-F]{2})*$/.test(this.value), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: 'hexadecimal' }), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: '!hexadecimal' })); } /** * Validate that value contains base64 character only and it's length is valid for base64.<br/> * Shortcut for match(/^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2,3}==?)?$/). * @return {Validator} this for chaining. */ base64() { return this.invalidateOn(() => !/^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2,3}==?)?$/.test(this.value), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: 'base64' }), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: '!base64' })); } /** * Validate that value is an url.<br/> * Used RegExp ^(http://www.|https://www.|http://|https://)?[a-z0-9]+([-.]{1}[a-z0-9]+)*.[a-z]{2,5}(:[0-9]{1,5})?(/.*)?$ * @return {Validator} this for chaining. */ url() { return this.invalidateOn(() => !RegExp('^(http://www.|https://www.|http://|https://)?[a-z0-9]+([-.]{1}[a-z0-9]+)*.[a-z]{2,5}(:[0-9]{1,5})?(/.*)?$').test(this.value), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: 'url' }), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: '!url' })); } /** * Shortcut for instance('Boolean') * @see {@link instance} * @return {Validator} this for chaining. */ boolean() { return this.instance('Boolean'); } /** * Shortcut for instance('Number') * @see {@link instance} * @return {Validator} this for chaining. */ number() { return this.instance('Number'); } /** * Validate that value is an integer. * @see {@link Number.isInteger} * @return {Validator} this for chaining. */ integer() { return this.invalidateOn(() => !Number.isInteger(this.value), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: 'Integer' }), () => Errors.invalidFormat({ name: this.name, value: stringify(this.value), format: '!Integer' })); } /** * Shortcut for instance('String') * @see {@link instance} * @return {Validator} this for chaining. */ string() { return this.instance('String'); } /** * Shortcut for instance('Object') * @see {@link instance} * @return {Validator} this for chaining. */ object() { return this.instance('Object'); } /** * Validate that value is an array. * @see {@link Array.isArray} * @return {Validator} this for chaining. */ array() { return this.invalidateOn(() => !Array.isArray(this.value), () => Errors.invalidType({ name: this.name, actualType: instanceOf(this.value), expectedType: 'Array' }), () => Errors.invalidType({ name: this.name, actualType: instanceOf(this.value), expectedType: '!Array' })); } /** * Shortcut for instance('Function') * @see {@link ChainApplier} * @return {Validator} this for chaining. */ function() { return this.instance('Function'); } /** * If arg is a Number, validate length is equals to arg.<br/> * Else arg must be a function then it's a shortcut for keys('length', arg).<br/> * ```javascript * // Exemple: * Validator(procs, 'procs').array().length(4); * Validator(name, 'name').string().length(vLength => vlength.greaterThan(3).not.greaterThan(20)); * ``` * @param {Number|ChainApplier} arg Exact length to compare with or function for length validation chain. * @return {Validator} this for chaining. */ length(arg) { if (isInstance(arg, 'Function')) { return this.keys({ key: 'length', chain: arg }); } if (isInstance(arg, 'Number')) { const modifier = this._modifier; return this.resetModifier().keys({ key: 'length', chain: (vLength) => vLength.setModifier(modifier).equal(arg) }); } throw Errors.invalidType({ name: 'length.arg', actualType: instanceOf(arg), expectedType: '[ Number, Function ]' }); } /** * Apply given validation chain to each element in the value array.<br/> * Doesn't validate that value is effectively an array.<br/> * each must not be used with not modifier.<br/> * ```javascript * // Exemple: * Validator(names, 'names').each(vName => vName.string().match(/^[\w-']{4,20}$/)); * ``` * @param {ChainApplier} apply Function validation chain * @return {Validator} this for chaining. */ each(apply) { if (!this._modifier) { throw Errors.programingFault({ reason: '"each" should not be use with not modifier' }); } try { if (this.isValid === true) { const children = this.value.map((child, idx) => new Validator(child, `${this.name}[${idx}]`)); this.children.push(...children); children.forEach(apply); } } catch (cause) { this.invalidate(Errors.programingFault({ reason: 'Children validation interupted by unhandeled throws error', cause })); } return this; } /** * Validate each give child key associated chainApplier.<br/> * If key is missing then DOESNT_EXISTS error id thrown unless optional is set to true then chainApplier isn't called.<br/> * keys must not be used with not modifier.<br/> * ```javascript * // Exemple: * Validator(customer, 'customer').keys( * { key: 'id', chain: vId => vId.integer().not.equal(0) }, * { key: 'name', chain: vName => vName.string().length(vLength => vlength.greaterThan(3).not.greaterThan(20)).match(/^[\w-']$/) }, * { key: 'email', chain: vEmail => vEmail.string().match(/^[\w]+@[\w]+\.[\w]{2,}$/) }, * { key: 'age', optional: true, chain: vAge => vAge.integer().greaterThan(0).not.greaterThan(130) }, * ); * ``` * @param {...String|Object} args List of object with properties key(String) and chain(chainApplier). Also accept String, then validate child property exists<br/> * args are deeply flatten, you can provided any kind of nested array structure as long as leafs are valid argument.<br/> * As well all falsy value are ignored (undefined, false, 0, '', null). * @return {Validator} this for chaining. */ keys(...args) { if (!this._modifier) { throw Errors.programingFault({ reason: '"keys" should not be use with not modifier' }); } try { const appliers = _.flattenDeep(args).map((arg, idx) => { if (!arg) { return null; } const actualType = instanceOf(arg); let applier = { optional: false, chain: (v) => v.exist(), }; switch (actualType) { case 'Object': applier = { ...applier, ...arg }; break; case 'String': applier = { ...applier, key: arg }; break; default: throw Errors.invalidType({ name: `appliers[${idx}]`, actualType, expectedType: 'String|Object' }); } mustBeInstance(applier.key, 'String', `appliers[${idx}].key`); mustBeInstance(applier.optional, 'Boolean', `appliers[${idx}].optional`); mustBeInstance(applier.chain, 'Function', `appliers[${idx}].chain`); return applier; }).filter((applier) => !!applier); if (this.isValid === true) { this.children.push( ...appliers.filter(({ key, optional }) => !optional || this.value[key]) .map(({ key, chain }) => Validator.create(this.value[key], `${this.name}.${key}`).apply(chain)), ); } } catch (cause) { this.invalidate(Errors.programingFault({ reason: '"keys" validation was interupted by unhandeled throws error', cause })); } return this; } /** * Create node from which many chains starts. Chain is invalidated only if all sub chains are invalidated. * ```javascript * // Exemple: * Validator(innate, 'innate').or( * vInnate => vInnate.boolean(), * vInnate => vInnate.string().among(EFFECTS), * vInnate => vInnate.object().keys('effect', 'value', (vEffect, vValue) => { * vEffect.string().among(EFFECTS); * vValue.integer().not.lowerThan(5).not.greaterThan(8); * }) * ); * ``` * @param {...ChainApplier} args List of subsequence chain. First parameter will be this validator. * @return {Validator} this for chaining. */ or(...args) { Validator.create(args, 'or.args').each((vChain) => vChain.function()).try(); if (!this._modifier) { throw Errors.programingFault({ reason: '"or" should not be use with not modifier' }); } try { if (this.isValid === true) { const [ errors, success ] = _.partition(args.map((apply) => { try { const validator = Validator.create(this.value, this.name); apply(validator); validator.try(); return null; } catch (error) { return error; } })); if (!success.length) { this.invalidate(Errors.invalid({ name: this.name, cause: errors })); } } } catch (cause) { this.invalidate(Errors.programingFault({ reason: '"or" validation was interupted by unhandeled throws error', cause })); } return this; } /** * Throw responsible error if chain is invalid. * @return {Validator} this for chaining. */ try() { if (this.isValid !== true) { throw this.error; } return this; } /** * Reject with responsible error if chain is invalid. Resolve without value otherwise. * @return {Promise} Bluebird promise */ resolve() { return Promise.try(() => this.try()); } } const newValidator = (...args) => new Validator(...args); newValidator.class = Validator; newValidator.instanceOf = instanceOf; newValidator.isInstance = isInstance; newValidator.mw = { express: { body(...args) { return (req, res, next) => { Validator.create(req.body, 'body').keys(...args).resolve() .return(undefined).catch((error) => error) .then((error) => next(error)); }; }, }, koa: { body(...args) { return (ctxt, next) => { return Validator.create(ctxt.req.body, 'body').keys(...args) .resolve().then(() => next()); }; }, }, }; module.exports = newValidator;