diff --git a/README.md b/README.md index c1c84d0..bf36123 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,8 @@ When defining the schema for your blueprint, you can define the property type wi * _@param {any}_ **value** - the value being validated (i.e. `input[key]`) * _@param {any}_ **input** - the object that is being validated * _@param {any}_ **root** - the root object that is being validated (different than input when the input is nested in another object) +* _@param {any}_ **output** - the current state of the `value` property for the `IValueOrError` that is returned by `validate`. You can use this to validate the values of other properties that were already processed, and to mutate the output (the latter is not recommended). +* _@param {object}_ **schema** - the schema for this validation context In this example, we require the given property based on the value of another property: diff --git a/dist/blueprint.js b/dist/blueprint.js index 639a34d..a72f72e 100644 --- a/dist/blueprint.js +++ b/dist/blueprint.js @@ -59,6 +59,7 @@ function _classCallCheck(instance, Constructor) { if (!(instance instanceof Cons this.value = input.value; this.input = input.input; this.root = input.root; + this.output = input.output; this.schema = input.schema; }; @@ -189,6 +190,7 @@ function _classCallCheck(instance, Constructor) { if (!(instance instanceof Cons value: input && input[key], input: input, root: root || input, + output: output.value, schema: schema }); var result = validator(context, makeDefaultErrorMessage(context)); @@ -201,6 +203,7 @@ function _classCallCheck(instance, Constructor) { if (!(instance instanceof Cons output.value[key] = result ? result.value : input[key]; return output; }, { + /* output */ validationErrors: [], value: tryMakeFromProto(input) }); // /reduce @@ -557,6 +560,7 @@ function _classCallCheck(instance, Constructor) { if (!(instance instanceof Cons func: undefined, promise: undefined, asyncFunction: undefined, + asyncFunc: undefined, object: undefined, array: undefined, string: undefined, @@ -573,6 +577,7 @@ function _classCallCheck(instance, Constructor) { if (!(instance instanceof Cons func: undefined, promise: undefined, asyncFunction: undefined, + asyncFunc: undefined, object: undefined, array: undefined, string: undefined, @@ -658,10 +663,14 @@ function _classCallCheck(instance, Constructor) { if (!(instance instanceof Cons return is.promise(obj); }; + is.asyncFunc = is.asyncFunction; // consistency for typescript + is.not.asyncFunction = function (obj) { return is.asyncFunction(obj) === false; }; + is.not.asyncFunc = is.not.asyncFunction; // consistency for typescript + is.object = function (obj) { return is.getType(obj) === 'object'; }; diff --git a/dist/blueprint.min.js b/dist/blueprint.min.js index 21f9881..410a6d9 100644 --- a/dist/blueprint.min.js +++ b/dist/blueprint.min.js @@ -2,7 +2,7 @@ ;(function(root){// eslint-disable-line no-extra-semi 'use strict';var module={factories:{}};Object.defineProperty(module,"exports",{get:function get(){return null},set:function set(val){module.factories[val.name]=val.factory},// this property should show up when this object's property names are enumerated enumerable:true,// this property may not be deleted -configurable:false});module.exports={name:"blueprint",factory:function factory(is){'use strict';var validators={};var ValueOrError=function ValueOrError(input){_classCallCheck(this,ValueOrError);this.err=input.err||null;this.value=is.defined(input.value)?input.value:null;if(is.array(input.messages)){this.messages=input.messages}else if(input.err){this.messages=[input.err.message]}else{this.messages=null}Object.freeze(this)};var ValidationContext=function ValidationContext(input){_classCallCheck(this,ValidationContext);this.key=input.key;this.value=input.value;this.input=input.input;this.root=input.root;this.schema=input.schema};var Blueprint=function Blueprint(input){_classCallCheck(this,Blueprint);this.name=input.name;this.schema=input.schema;this.validate=input.validate;Object.freeze(this)};/** +configurable:false});module.exports={name:"blueprint",factory:function factory(is){'use strict';var validators={};var ValueOrError=function ValueOrError(input){_classCallCheck(this,ValueOrError);this.err=input.err||null;this.value=is.defined(input.value)?input.value:null;if(is.array(input.messages)){this.messages=input.messages}else if(input.err){this.messages=[input.err.message]}else{this.messages=null}Object.freeze(this)};var ValidationContext=function ValidationContext(input){_classCallCheck(this,ValidationContext);this.key=input.key;this.value=input.value;this.input=input.input;this.root=input.root;this.output=input.output;this.schema=input.schema};var Blueprint=function Blueprint(input){_classCallCheck(this,Blueprint);this.name=input.name;this.schema=input.schema;this.validate=input.validate;Object.freeze(this)};/** * Makes a message factory that produces an error message on demand * @param {string} options.key - the property name * @param {any} options.value - the value being validated @@ -28,7 +28,7 @@ configurable:false});module.exports={name:"blueprint",factory:function factory(i * @param {string} name - the name of the model being validated * @param {object} schema - the type definitions * @param {object} input - the values being validated - */var validate=function validate(name,schema){return function(input,root){var outcomes=Object.keys(schema).reduce(function(output,key){var keyName=root?"".concat(name,".").concat(key):key;if(is.object(schema[key])){var child=validate("".concat(keyName),schema[key])(input[key],root||input);if(child.err){output.validationErrors=output.validationErrors.concat(child.messages)}output.value[key]=child.value;return output}var validator;if(is.function(schema[key])){validator=normalIsValid(schema[key])}else if(is.regexp(schema[key])){validator=normalIsValid(validators.expression(schema[key]))}else{validator=validators[schema[key]]}if(is.not.function(validator)){output.validationErrors.push("I don't know how to validate ".concat(schema[key]));return output}var context=new ValidationContext({key:"".concat(keyName),value:input&&input[key],input:input,root:root||input,schema:schema});var result=validator(context,makeDefaultErrorMessage(context));if(result&&result.err){output.validationErrors.push(result.err.message);return output}output.value[key]=result?result.value:input[key];return output},{validationErrors:[],value:tryMakeFromProto(input)});// /reduce + */var validate=function validate(name,schema){return function(input,root){var outcomes=Object.keys(schema).reduce(function(output,key){var keyName=root?"".concat(name,".").concat(key):key;if(is.object(schema[key])){var child=validate("".concat(keyName),schema[key])(input[key],root||input);if(child.err){output.validationErrors=output.validationErrors.concat(child.messages)}output.value[key]=child.value;return output}var validator;if(is.function(schema[key])){validator=normalIsValid(schema[key])}else if(is.regexp(schema[key])){validator=normalIsValid(validators.expression(schema[key]))}else{validator=validators[schema[key]]}if(is.not.function(validator)){output.validationErrors.push("I don't know how to validate ".concat(schema[key]));return output}var context=new ValidationContext({key:"".concat(keyName),value:input&&input[key],input:input,root:root||input,output:output.value,schema:schema});var result=validator(context,makeDefaultErrorMessage(context));if(result&&result.err){output.validationErrors.push(result.err.message);return output}output.value[key]=result?result.value:input[key];return output},{/* output */validationErrors:[],value:tryMakeFromProto(input)});// /reduce if(outcomes.validationErrors.length){return new ValueOrError({err:new Error("Invalid ".concat(name,": ").concat(outcomes.validationErrors.join(", "))),messages:outcomes.validationErrors})}return new ValueOrError({value:outcomes.value})}};// /validate /** * Returns a validator (fluent interface) for validating the input values @@ -77,7 +77,7 @@ bp=blueprint(name,schema.schema)}else{bp=blueprint(name,schema)}if(bp.err){throw * the value presented is null, or undefined. * @param {any} comparator - the name of the validator, or a function that performs validation */var optional=function optional(comparator){var options={};var validator;if(is.function(comparator)){validator=normalIsValid(comparator)}else if(is.regexp(comparator)){validator=normalIsValid(validators.expression(comparator))}else{validator=validators[comparator]}var valueOrDefaultValue=function valueOrDefaultValue(value){if(is.function(options.defaultValue)){return{value:options.defaultValue()}}else if(is.defined(options.defaultValue)){return{value:options.defaultValue}}else{return{value:value}}};var output=function output(context){var value=context.value;if(is.nullOrUndefined(value)){return valueOrDefaultValue(value)}else{return validator(context)}};output.withDefault=function(defaultValue){options.defaultValue=defaultValue;return output};return output};return{blueprint:blueprint,registerValidator:registerValidator,registerType:registerType,registerBlueprint:registerBlueprint,registerExpression:registerExpression,optional:optional,// below are undocumented / subject to breaking changes -registerInstanceOfType:registerInstanceOfType,registerArrayOfType:registerArrayOfType,getValidators:getValidators,getValidator:getValidator}}};module.exports={name:"is",factory:function factory(){'use strict';var is={getType:undefined,defined:undefined,nullOrUndefined:undefined,function:undefined,func:undefined,promise:undefined,asyncFunction:undefined,object:undefined,array:undefined,string:undefined,boolean:undefined,date:undefined,regexp:undefined,number:undefined,nullOrWhitespace:undefined,decimal:undefined,not:{defined:undefined,nullOrUndefined:undefined,function:undefined,func:undefined,promise:undefined,asyncFunction:undefined,object:undefined,array:undefined,string:undefined,boolean:undefined,date:undefined,regexp:undefined,number:undefined,nullOrWhitespace:undefined,decimal:undefined}/** +registerInstanceOfType:registerInstanceOfType,registerArrayOfType:registerArrayOfType,getValidators:getValidators,getValidator:getValidator}}};module.exports={name:"is",factory:function factory(){'use strict';var is={getType:undefined,defined:undefined,nullOrUndefined:undefined,function:undefined,func:undefined,promise:undefined,asyncFunction:undefined,asyncFunc:undefined,object:undefined,array:undefined,string:undefined,boolean:undefined,date:undefined,regexp:undefined,number:undefined,nullOrWhitespace:undefined,decimal:undefined,not:{defined:undefined,nullOrUndefined:undefined,function:undefined,func:undefined,promise:undefined,asyncFunction:undefined,asyncFunc:undefined,object:undefined,array:undefined,string:undefined,boolean:undefined,date:undefined,regexp:undefined,number:undefined,nullOrWhitespace:undefined,decimal:undefined}/** * Produces the printed type (i.e. [object Object], [object Function]), * removes everything except for the type, and returns the lowered form. * (i.e. boolean, number, string, function, asyncfunction, promise, array, @@ -86,7 +86,9 @@ registerInstanceOfType:registerInstanceOfType,registerArrayOfType:registerArrayO // /^$|\s+/ = is empty or whitespace return /([^\s])/.test(str)};is.nullOrWhitespace=function(str){return is.not.nullOrWhitespace(str)===false};is.function=function(obj){var type=is.getType(obj);return type==="function"||type==="asyncfunction"||type==="promise"};is.func=is.function;// typescript support is.not.function=function(obj){return is.function(obj)===false};is.not.func=is.not.function;// typescript support -is.promise=function(obj){var type=is.getType(obj);return type==="asyncfunction"||type==="promise"};is.not.asyncFunction=function(obj){return is.promise(obj)===false};is.asyncFunction=function(obj){return is.promise(obj)};is.not.asyncFunction=function(obj){return is.asyncFunction(obj)===false};is.object=function(obj){return is.getType(obj)==="object"};is.not.object=function(obj){return is.object(obj)===false};is.array=function(obj){return is.getType(obj)==="array"};is.not.array=function(obj){return is.array(obj)===false};is.string=function(obj){return is.getType(obj)==="string"};is.not.string=function(obj){return is.string(obj)===false};is.boolean=function(obj){return is.getType(obj)==="boolean"};is.not.boolean=function(obj){return is.boolean(obj)===false};is.date=function(obj){return is.getType(obj)==="date"&&is.function(obj.getTime)&&!isNaN(obj.getTime())};is.not.date=function(obj){return is.date(obj)===false};is.regexp=function(obj){return is.getType(obj)==="regexp"};is.not.regexp=function(obj){return is.regexp(obj)===false};is.number=function(obj){return is.getType(obj)==="number"};is.not.number=function(obj){return is.number(obj)===false};is.decimal=function(num,places){if(is.not.number(num)){return false}if(!places&&is.number(num)){return true}var padded=+(+num||0).toFixed(20);// pad to the right for whole numbers +is.promise=function(obj){var type=is.getType(obj);return type==="asyncfunction"||type==="promise"};is.not.asyncFunction=function(obj){return is.promise(obj)===false};is.asyncFunction=function(obj){return is.promise(obj)};is.asyncFunc=is.asyncFunction;// consistency for typescript +is.not.asyncFunction=function(obj){return is.asyncFunction(obj)===false};is.not.asyncFunc=is.not.asyncFunction;// consistency for typescript +is.object=function(obj){return is.getType(obj)==="object"};is.not.object=function(obj){return is.object(obj)===false};is.array=function(obj){return is.getType(obj)==="array"};is.not.array=function(obj){return is.array(obj)===false};is.string=function(obj){return is.getType(obj)==="string"};is.not.string=function(obj){return is.string(obj)===false};is.boolean=function(obj){return is.getType(obj)==="boolean"};is.not.boolean=function(obj){return is.boolean(obj)===false};is.date=function(obj){return is.getType(obj)==="date"&&is.function(obj.getTime)&&!isNaN(obj.getTime())};is.not.date=function(obj){return is.date(obj)===false};is.regexp=function(obj){return is.getType(obj)==="regexp"};is.not.regexp=function(obj){return is.regexp(obj)===false};is.number=function(obj){return is.getType(obj)==="number"};is.not.number=function(obj){return is.number(obj)===false};is.decimal=function(num,places){if(is.not.number(num)){return false}if(!places&&is.number(num)){return true}var padded=+(+num||0).toFixed(20);// pad to the right for whole numbers return padded.toFixed(places)==="".concat(+num)};is.not.decimal=function(val,places){return is.decimal(val,places)===false};return is}};module.exports={name:"numberValidators",factory:function factory(is){'use strict';var makeErrorMessage=function makeErrorMessage(options){return"expected `".concat(options.key,"` to be ").concat(options.comparator," ").concat(options.boundary)};var gt=function gt(min){if(is.not.number(min)){throw new Error("gt requires a minimum number to compare values to")}return function(_ref3){var key=_ref3.key,value=_ref3.value;if(is.number(value)&&value>min){return{err:null,value:value}}return{err:new Error(makeErrorMessage({key:key,comparator:"greater than",boundary:min})),value:null}}};var gte=function gte(min){if(is.not.number(min)){throw new Error("gte requires a minimum number to compare values to")}return function(_ref4){var key=_ref4.key,value=_ref4.value;if(is.number(value)&&value>=min){return{err:null,value:value}}return{err:new Error(makeErrorMessage({key:key,comparator:"greater than, or equal to",boundary:min})),value:null}}};var lt=function lt(max){if(is.not.number(max)){throw new Error("lt requires a maximum number to compare values to")}return function(_ref5){var key=_ref5.key,value=_ref5.value;if(is.number(value)&&value { if (typeof context.value === 'string' && context.value.length === 1) { - return { - err: null, - messages: null, - value: context.value - } + return { value: context.value } } return { err: new Error('BOOM!'), - messages: ['BOOM!'], + messages: ['BOOM!', 'BOOM AGAIN!'], value: context.value } } @@ -151,9 +147,9 @@ import { { value }: IValidationContext ): IValueOrError => { if (value && is.string(value.prop1) && is.number(value.prop2)) { - return { value, err: null, messages: null }; + return { value }; } - return { value: null, err: new Error('Boom!'), messages: null }; + return { err: new Error('Boom!') }; } }); diff --git a/index.d.ts b/index.d.ts index 1bdceb5..ae3b108 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,23 +5,79 @@ // blueprint =================================================================== +/** + * When Blueprint returns a value, or an error, it will always + * guarantee IValueOrError, as opposed to boolean, or IOptionalValueOrError + */ export interface IValueOrError { - err: any | null; - messages: string[] | null; - value: T | null; + /** + * Blueprint.validate, and custom validators return either an + * error, or a value, and may be accompanied by additional messages. + */ + err?: any | null; + /** + * At least the err.message is available in this array. + * Additional context may also be here. For instance, when + * `validate` returns an error, the error message potentially + * a concatenation of several validation errors. This messages + * property will have each message on it's own, so you don't + * have to split the err.message. + */ + messages?: string[] | null; + /** + * Blueprint maps the values to `value` as it validates properties. + * This will essentially be the same as the object that was passed + * to `validate`, however any properties not on the schema will be + * omitted, so this can be used as part of a strategy to mitigate + * property pollution attacks. + */ + value?: T | null; } export interface IBlueprint { + /** + * The name of the model being validated + */ name: string; + /** + * The type definitions + */ schema: object; + /** + * Validates a given object against the schema + */ validate: (input: any) => IValueOrError } export interface IValidationContext { + /** + * The property name + */ key: string; + /** + * The value that is being validated for this property (i.e. `input[key]`) + */ value: T; + /** + * The object this property is on + */ input: any; + /** + * If the property being validated is on an object that is a + * child of another object in the validation context, this will + * be the top-most parent. Otherwise, it's the same as input. + */ root: any; + /** + * The current state of the `value` property for the `IValueOrError` + * that is returned by `validate`. You can use this to validate + * the values of other properties that were already processed, and + * to mutate the output (the latter is not recommended). + */ + output: any; + /** + * The schema for this validation context + */ schema: object; } @@ -210,6 +266,8 @@ declare namespace is { function defined (input?: any): boolean; function nullOrUndefined (input?: any): boolean; function func (input?: any): boolean; + function asyncFunc (input?: any): boolean; + function promise (input?: any): boolean; function object (input?: any): boolean; function array (input?: any): boolean; function string (input?: any): boolean; @@ -225,6 +283,8 @@ declare namespace is { function defined (input?: any): boolean; function nullOrUndefined (input?: any): boolean; function func (input?: any): boolean; + function asyncFunc (input?: any): boolean; + function promise (input?: any): boolean; function object (input?: any): boolean; function array (input?: any): boolean; function string (input?: any): boolean; diff --git a/package.json b/package.json index 4a36b8e..ce9bca2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@polyn/blueprint", - "version": "2.2.0", + "version": "2.3.0", "description": "An easy to use, flexible, and powerful validation library for nodejs and browsers", "main": "index.js", "types": "index.d.ts", diff --git a/src/blueprint.js b/src/blueprint.js index 6404c01..8bfe19a 100644 --- a/src/blueprint.js +++ b/src/blueprint.js @@ -27,6 +27,7 @@ module.exports = { this.value = input.value this.input = input.input this.root = input.root + this.output = input.output this.schema = input.schema } } @@ -150,6 +151,7 @@ module.exports = { value: input && input[key], input, root: root || input, + output: output.value, schema }) @@ -162,7 +164,7 @@ module.exports = { output.value[key] = result ? result.value : input[key] return output - }, { + }, { /* output */ validationErrors: [], value: tryMakeFromProto(input) }) // /reduce diff --git a/src/blueprint.test.js b/src/blueprint.test.js index 0bc46ea..6224514 100644 --- a/src/blueprint.test.js +++ b/src/blueprint.test.js @@ -309,7 +309,7 @@ module.exports = (test) => { } } - const bp = registerBlueprint('protosWithRegBp', { + registerBlueprint('protosWithRegBp', { requiredString: 'string', optionalString: 'string?', foo: { @@ -420,25 +420,72 @@ module.exports = (test) => { expect(actualInvalid.err.message).to.equal('Invalid sut: Invalid user: expected `firstName` {undefined} to be {string}, expected `lastName` {undefined} to be {string}') expect(actualInvalid.value).to.be.null }, - 'it should pass `key`, `value`, `input`, and `root` to registered validators': (expect) => { - let actual - registerValidator('registerValidatorArgs', ({ key, value, input, root }) => { - actual = { key, value, input, root } - return { err: null } + 'it should pass `ValidationContext` to registered validators': (expect) => { + let actual1 + registerValidator('ValidationContext1', (context) => { + // copy the context so we can see progression of the output object + actual1 = { ...context } + actual1.output = { ...context.output } + return { value: 42, err: null } + }) + + let actual2 + registerValidator('ValidationContext2', (context) => { + // the output should have the value from ValidationContext1 by now + if (context.output.args1 === 42) { + actual2 = { ...context } + actual2.output = { ...context.output } + return { value: 43 } + } else { + return { err: new Error('The output isn\'t set') } + } }) - blueprint('sut', { - args: 'registerValidatorArgs' - }).validate({ - args: 'args-value', - other: 'other-value' + let actual3 + registerValidator('ValidationContext3', (context) => { + // not sure it's a good idea to mutate the output, + // but not sure this library should have an opinion + // on that, either + context.output.foo = 'bar' + actual3 = context.output + return { value: 44, err: null } }) - expect(actual).to.deep.equal({ - key: 'args', - value: 'args-value', - input: { args: 'args-value', other: 'other-value' }, - root: { args: 'args-value', other: 'other-value' } + const expectedSchema = { + args1: 'ValidationContext1', + args2: 'ValidationContext2', + args3: 'ValidationContext3' + } + + blueprint('sut', expectedSchema).validate({ + args1: 'args-value-1', + args2: 'args-value-2', + args3: 'args-value-3' + }) + + expect(actual1).to.deep.equal({ + key: 'args1', + value: 'args-value-1', + input: { args1: 'args-value-1', args2: 'args-value-2', args3: 'args-value-3' }, + root: { args1: 'args-value-1', args2: 'args-value-2', args3: 'args-value-3' }, + output: {}, + schema: expectedSchema + }) + + expect(actual2).to.deep.equal({ + key: 'args2', + value: 'args-value-2', + input: { args1: 'args-value-1', args2: 'args-value-2', args3: 'args-value-3' }, + root: { args1: 'args-value-1', args2: 'args-value-2', args3: 'args-value-3' }, + output: { args1: 42 }, + schema: expectedSchema + }) + + expect(actual3).to.deep.equal({ + args1: 42, + args2: 43, + args3: 44, + foo: 'bar' }) }, 'it should return an error if a validator doesn\'t return anything': (expect) => { diff --git a/src/is.js b/src/is.js index e68c409..0aedc38 100644 --- a/src/is.js +++ b/src/is.js @@ -11,6 +11,7 @@ module.exports = { func: undefined, promise: undefined, asyncFunction: undefined, + asyncFunc: undefined, object: undefined, array: undefined, string: undefined, @@ -27,6 +28,7 @@ module.exports = { func: undefined, promise: undefined, asyncFunction: undefined, + asyncFunc: undefined, object: undefined, array: undefined, string: undefined, @@ -117,10 +119,14 @@ module.exports = { return is.promise(obj) } + is.asyncFunc = is.asyncFunction // consistency for typescript + is.not.asyncFunction = function (obj) { return is.asyncFunction(obj) === false } + is.not.asyncFunc = is.not.asyncFunction // consistency for typescript + is.object = function (obj) { return is.getType(obj) === 'object' }