UNPKG

doormen

Version:

Validate, sanitize and assert: the silver bullet of data!

1,327 lines (990 loc) 40.3 kB
/* Doormen Copyright (c) 2015 - 2021 Cédric Ronvel The MIT License (MIT) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ "use strict" ; const dotPath = require( 'tree-kit/lib/dotPath.js' ) ; const clone_ = require( 'tree-kit/lib/clone.js' ) ; /* doormen( schema , data ) doormen( options , schema , data ) options: * userContext: a context that can be accessed by user-land type-checker and sanitizer * fake: activate the fake mode: everywhere a 'fakeFn' property is defined, it is used instead of a defaultFn * report: activate the report mode: report as many error as possible (same as doormen.report()) * export: activate the export mode: sanitizers export into a new object (same as doormen.export()) * onlyConstraints: only check constraints, typically: validate a patch, apply it, then check complex constraints only */ function doormen( ... args ) { var options , data , schema , context , sanitized ; if ( args.length < 2 || args.length > 3 ) { throw new Error( 'doormen() needs at least 2 and at most 3 arguments' ) ; } if ( args.length === 2 ) { schema = args[ 0 ] ; data = args[ 1 ] ; } else { options = args[ 0 ] ; schema = args[ 1 ] ; data = args[ 2 ] ; } if ( ! schema || typeof schema !== 'object' ) { throw new doormen.SchemaError( 'Bad schema, it should be an object or an array of object!' ) ; } if ( ! options || typeof options !== 'object' ) { options = {} ; } if ( ! options.patch || typeof options.patch !== 'object' || Array.isArray( options.patch ) ) { options.patch = false ; } context = { userContext: options.userContext , validate: true , onlyConstraints: !! options.onlyConstraints , errors: [] , patch: options.patch , check: check , validatorError: validatorError , fake: !! options.fake , report: !! options.report , export: !! options.export } ; sanitized = context.check( schema , data , { path: '' , displayPath: data === null ? 'null' : ( Array.isArray( data ) ? 'array' : typeof data ) , // eslint-disable-line no-nested-ternary key: '' } , false ) ; if ( context.report ) { return { validate: context.validate , sanitized: sanitized , errors: context.errors } ; } return sanitized ; } module.exports = doormen ; // Shorthand doormen.report = doormen.bind( doormen , { report: true } ) ; doormen.export = doormen.bind( doormen , { export: true } ) ; doormen.checkConstraints = doormen.bind( doormen , { onlyConstraints: true } ) ; // Submodules doormen.ValidatorError = require( './ValidatorError.js' ) ; doormen.SchemaError = require( './SchemaError.js' ) ; doormen.AssertionError = require( './AssertionError.js' ) ; var mask = require( './mask.js' ) ; doormen.tierMask = mask.tierMask ; doormen.tagMask = mask.tagMask ; doormen.getAllSchemaTags = mask.getAllSchemaTags ; doormen.isEqual = require( './isEqual.js' ) ; doormen.schemaSchema = require( './schemaSchema.js' ) ; doormen.validateSchema = function( schema ) { return doormen( doormen.schemaSchema , schema ) ; } ; doormen.purifySchema = function( schema ) { return doormen.export( doormen.schemaSchema , schema ) ; } ; // For browsers... if ( ! global ) { global = window ; } // eslint-disable-line no-global-assign // Extendable things if ( ! global.DOORMEN_GLOBAL_EXTENSIONS ) { global.DOORMEN_GLOBAL_EXTENSIONS = {} ; } if ( ! global.DOORMEN_GLOBAL_EXTENSIONS.typeCheckers ) { global.DOORMEN_GLOBAL_EXTENSIONS.typeCheckers = {} ; } if ( ! global.DOORMEN_GLOBAL_EXTENSIONS.sanitizers ) { global.DOORMEN_GLOBAL_EXTENSIONS.sanitizers = {} ; } if ( ! global.DOORMEN_GLOBAL_EXTENSIONS.filters ) { global.DOORMEN_GLOBAL_EXTENSIONS.filters = {} ; } if ( ! global.DOORMEN_GLOBAL_EXTENSIONS.constraints ) { global.DOORMEN_GLOBAL_EXTENSIONS.constraints = {} ; } if ( ! global.DOORMEN_GLOBAL_EXTENSIONS.defaultFunctions ) { global.DOORMEN_GLOBAL_EXTENSIONS.defaultFunctions = {} ; } doormen.typeCheckers = require( './typeCheckers.js' ) ; doormen.sanitizers = require( './sanitizers.js' ) ; doormen.filters = require( './filters.js' ) ; doormen.constraints = require( './constraints.js' ) ; doormen.defaultFunctions = require( './defaultFunctions.js' ) ; doormen.topLevelFilters = [ 'instanceOf' , 'min' , 'max' , 'length' , 'minLength' , 'maxLength' , 'match' , 'in' , 'notIn' , 'eq' ] ; function check( schema , data_ , element , isPatch ) { var i , key , newKey , sanitizerList , keyList , data = data_ , src , returnValue , alternativeErrors , constraint , bkup ; if ( ! schema || typeof schema !== 'object' ) { throw new doormen.SchemaError( element.displayPath + " is not a schema (not an object or an array of object)." ) ; } // 0) Arrays are alternatives if ( Array.isArray( schema ) ) { alternativeErrors = [] ; for ( i = 0 ; i < schema.length ; i ++ ) { try { // using .export() is mandatory here: we should not modify the original data // since we should check against alternative (and sanitize can change things, for example) data = doormen.export( schema[ i ] , data_ ) ; } catch( error ) { alternativeErrors.push( error.message.replace( /\.$/ , '' ) ) ; continue ; } return data ; } this.validatorError( element.displayPath + " does not validate any schema alternatives: ( " + alternativeErrors.join( ' ; ' ) + " )." , element ) ; return ; } if ( ! this.onlyConstraints ) { // 1) Forced value, default value or optional value if ( schema.value !== undefined ) { return schema.value ; } if ( data === null ) { if ( schema.nullIsUndefined ) { data = undefined ; } else if ( ! schema.nullIsValue ) { if ( this.fake && typeof schema.fakeFn === 'function' ) { return schema.fakeFn( schema ) ; } else if ( schema.defaultFn ) { if ( typeof schema.defaultFn === 'function' ) { return schema.defaultFn( schema ) ; } if ( doormen.defaultFunctions[ schema.defaultFn ] ) { return doormen.defaultFunctions[ schema.defaultFn ]( schema ) ; } else if ( ! doormen.clientMode ) { throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), unexistant default function '" + schema.defaultFn + "'." ) ; } } if ( 'default' in schema ) { return clone( schema.default ) ; } if ( schema.optional ) { return data ; } } } if ( data === undefined ) { // if the data has default value or is optional and its value is null or undefined, it's ok! if ( this.fake && typeof schema.fakeFn === 'function' ) { return schema.fakeFn( schema ) ; } else if ( schema.defaultFn ) { if ( typeof schema.defaultFn === 'function' ) { return schema.defaultFn( schema ) ; } if ( doormen.defaultFunctions[ schema.defaultFn ] ) { return doormen.defaultFunctions[ schema.defaultFn ]( schema ) ; } else if ( ! doormen.clientMode ) { throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), unexistant default function '" + schema.defaultFn + "'." ) ; } } if ( 'default' in schema ) { return clone( schema.default ) ; } if ( schema.optional ) { return data ; } } // 2) apply available sanitizers before anything else if ( schema.sanitize ) { sanitizerList = Array.isArray( schema.sanitize ) ? schema.sanitize : [ schema.sanitize ] ; bkup = data ; for ( i = 0 ; i < sanitizerList.length ; i ++ ) { if ( ! doormen.sanitizers[ sanitizerList[ i ] ] ) { if ( doormen.clientMode ) { continue ; } throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), unexistant sanitizer '" + sanitizerList[ i ] + "'." ) ; } data = doormen.sanitizers[ sanitizerList[ i ] ].call( this , data , schema , this.export && data === data_ ) ; } // if you want patch reporting if ( this.patch && bkup !== data && ! ( Number.isNaN( bkup ) && Number.isNaN( data ) ) ) { addToPatch( this.patch , element.path , data ) ; } } // 3) check the type if ( schema.type ) { if ( ! doormen.typeCheckers[ schema.type ] ) { if ( ! doormen.clientMode ) { throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), unexistant type '" + schema.type + "'." ) ; } } else if ( ! doormen.typeCheckers[ schema.type ].call( this , data , schema ) ) { this.validatorError( element.displayPath + " is not a " + schema.type + "." , element ) ; } } // 4) check top-level built-in filters, i.e. filters that are directly named, like 'min', 'max', etc for ( i = 0 ; i < doormen.topLevelFilters.length ; i ++ ) { key = doormen.topLevelFilters[ i ] ; if ( schema[ key ] !== undefined ) { doormen.filters[ key ].call( this , data , schema[ key ] , element ) ; } } // 5) check filters if ( schema.filter ) { if ( typeof schema.filter !== 'object' ) { throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), 'filter' should be an object." ) ; } for ( key in schema.filter ) { if ( ! doormen.filters[ key ] ) { if ( doormen.clientMode ) { continue ; } throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), unexistant filter '" + key + "'." ) ; } doormen.filters[ key ].call( this , data , schema.filter[ key ] , element ) ; } } // 6) Recursivity // keys if ( schema.keys !== undefined && ( data && ( typeof data === 'object' || typeof data === 'function' ) ) ) { if ( ! schema.keys || typeof schema.keys !== 'object' ) { throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), 'keys' should contain a schema object." ) ; } if ( this.export && data === data_ ) { data = {} ; src = data_ ; } else { src = data ; } for ( key in src ) { newKey = this.check( schema.keys , key , { path: element.path ? element.path + '.' + key : key , displayPath: element.displayPath + ':' + key , key: key } , isPatch ) ; if ( newKey in data && newKey !== key ) { this.validatorError( "'keys' cannot overwrite another existing key: " + element.displayPath + " want to rename '" + key + "' to '" + newKey + "' but it already exists." , element ) ; } data[ newKey ] = src[ key ] ; if ( newKey !== key ) { delete data[ key ] ; } } } } // End of non-constraint-block // of if ( schema.of !== undefined && ( data && ( typeof data === 'object' || typeof data === 'function' ) ) ) { if ( ! schema.of || typeof schema.of !== 'object' ) { throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), 'of' should contain a schema object." ) ; } if ( Array.isArray( data ) ) { if ( this.export && data === data_ ) { data = [] ; src = data_ ; } else { src = data ; } for ( i = 0 ; i < src.length ; i ++ ) { data[ i ] = this.check( schema.of , src[ i ] , { path: element.path ? element.path + '.' + i : '' + i , displayPath: element.displayPath + '[' + i + ']' , key: i } , isPatch ) ; } } else { if ( this.export && data === data_ ) { data = {} ; src = data_ ; } else { src = data ; } for ( key in src ) { data[ key ] = this.check( schema.of , src[ key ] , { path: element.path ? element.path + '.' + key : key , displayPath: element.displayPath + '.' + key , key: key } , isPatch ) ; } } } // properties if ( schema.properties !== undefined && ( data && ( typeof data === 'object' || typeof data === 'function' ) ) ) { if ( ! schema.properties || typeof schema.properties !== 'object' ) { throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), 'properties' should be an object." ) ; } if ( this.export && data === data_ ) { data = {} ; src = data_ ; } else { src = data ; } keyList = new Set() ; if ( Array.isArray( schema.properties ) ) { for ( i = 0 ; i < schema.properties.length ; i ++ ) { key = schema.properties[ i ] ; if ( ! ( key in src ) ) { this.validatorError( element.displayPath + " does not have all required properties (" + JSON.stringify( schema.properties ) + ")." , element ) ; } data[ key ] = src[ key ] ; keyList.add( key ) ; } } else { for ( key in schema.properties ) { if ( ! schema.properties[ key ] || typeof schema.properties[ key ] !== 'object' ) { throw new doormen.SchemaError( element.displayPath + '.' + key + " is not a schema (not an object or an array of object)." ) ; } keyList.add( key ) ; returnValue = this.check( schema.properties[ key ] , src[ key ] , { path: element.path ? element.path + '.' + key : key , displayPath: element.displayPath + '.' + key , key: key } , isPatch ) ; // Do not create new properties with undefined if ( returnValue !== undefined || key in src ) { data[ key ] = returnValue ; } } } if ( ! this.onlyConstraints && ! schema.extraProperties ) { for ( key in src ) { if ( ! keyList.has( key ) ) { this.validatorError( element.displayPath + " has extra properties ('" + key + "' is not in " + JSON.stringify( [ ... keyList ] ) + ")." , element ) ; } } } } // elements if ( schema.elements !== undefined && Array.isArray( data ) ) { if ( ! Array.isArray( schema.elements ) ) { throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), 'elements' should be an array." ) ; } if ( this.export && data === data_ ) { data = [] ; src = data_ ; } else { src = data ; } for ( i = 0 ; i < schema.elements.length ; i ++ ) { data[ i ] = this.check( schema.elements[ i ] , src[ i ] , { path: element.path ? element.path + '.' + i : '' + i , displayPath: element.displayPath + '[' + i + ']' , key: i } , isPatch ) ; } if ( ! schema.extraElements && src.length > schema.elements.length ) { this.validatorError( element.displayPath + " has extra elements (" + src.length + " instead of " + schema.elements.length + ")." , element ) ; } } // 7) Constraints // There is no constraint check for patch: it's not possible since we only get partial data if ( schema.constraints && ! isPatch ) { if ( ! Array.isArray( schema.constraints ) ) { throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), 'constraints' should be an object." ) ; } if ( ! data || typeof data !== 'object' ) { this.validatorError( element.displayPath + " has a constraints but is not an object." , element ) ; } bkup = data ; for ( i = 0 ; i < schema.constraints.length ; i ++ ) { constraint = schema.constraints[ i ] ; if ( ! constraint || typeof constraint !== 'object' ) { throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), constraints #" + i + " should be an object." ) ; } if ( ! doormen.constraints[ constraint.enforce ] ) { if ( doormen.clientMode ) { continue ; } throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), unexistant constraints '" + constraint.enforce + "'." ) ; } data = doormen.constraints[ constraint.enforce ].call( this , data , constraint , element , this.export && data === data_ ) ; } // if you want patch reporting if ( this.patch && bkup !== data && ! ( Number.isNaN( bkup ) && Number.isNaN( data ) ) ) { addToPatch( this.patch , element.path , data ) ; } } return data ; } function clone( value ) { if ( value && typeof value === 'object' ) { return clone_( value ) ; } return value ; } // This function is used to add a new patch entry and discard any children entries function addToPatch( patch , path , data ) { var innerPath , prefix ; patch[ path ] = data ; prefix = path + '.' ; for ( innerPath in patch ) { if ( innerPath.startsWith( prefix ) ) { // Found a child entry, delete it delete patch[ innerPath ] ; } } } // Merge two patch, the second override the first, and the final result does not have overlap. // Note that the two patches MUST BE VALID ALREADY. doormen.mergePatch = function( targetPatch , patch ) { for ( let path in patch ) { let done = false ; for ( let targetPath in targetPatch ) { if ( path.startsWith( targetPath + '.' ) ) { // Here we alter an existing patch key let subPath = path.slice( targetPath.length + 1 ) ; dotPath.set( targetPatch[ targetPath ] , subPath , patch[ path ] ) ; done = true ; } else if ( targetPath.startsWith( path + '.' ) ) { // The override version contain everything that will be kept, remove the older key // (even if the override does not contain the precise key, it is meant to override WITHOUT it anyway) delete targetPatch[ targetPath ] ; } } if ( ! done ) { targetPatch[ path ] = patch[ path ] ; } } return targetPatch ; } ; doormen.path = // DEPRECATED name, use doormen.subSchema() doormen.subSchema = ( schema , path , noSubmasking = false , noOpaque = false ) => { var i , iMax ; if ( ! Array.isArray( path ) ) { if ( typeof path !== 'string' ) { throw new Error( "Argument #1 'path' should be a string or an array" ) ; } path = path.split( '.' ).filter( e => e ) ; } try { // It should exit if schema is falsy (e.g. when the noSubmasking option on) for ( i = 0 , iMax = path.length ; i < iMax && schema ; i ++ ) { schema = doormen.directSubSchema( schema , path[ i ] , noSubmasking , noOpaque ) ; } } catch ( error ) { error.message += ' (at: ' + path.slice( 0 , i + 1 ).join( '.' ) + ')' ; throw error ; } return schema ; } ; const EMPTY_SCHEMA = {} ; Object.freeze( EMPTY_SCHEMA ) ; doormen.directSubSchema = ( schema , key , noSubmasking , noOpaque ) => { if ( ! schema || typeof schema !== 'object' ) { throw new doormen.SchemaError( "Not a schema (not an object or an array of object)." ) ; } if ( noOpaque && schema.opaque ) { throw new doormen.ValidatorError( "Path leading inside an opaque object." ) ; } if ( noSubmasking && schema.noSubmasking ) { return null ; } // 0) Arrays are alternatives if ( Array.isArray( schema ) ) { throw new Error( "Schema alternatives are not supported for subSchema ATM." ) ; } // 1) Recursivity if ( schema.properties !== undefined ) { if ( ! schema.properties || typeof schema.properties !== 'object' ) { throw new doormen.SchemaError( "Bad schema: 'properties' should be an object." ) ; } if ( schema.properties[ key ] ) { return schema.properties[ key ] ; } else if ( ! schema.extraProperties ) { throw new doormen.SchemaError( "Bad path: property '" + key + "' not found and the schema does not allow extra properties." ) ; } } if ( schema.elements !== undefined ) { if ( ! Array.isArray( schema.elements ) ) { throw new doormen.SchemaError( "Bad schema: 'elements' should be an array." ) ; } key = + key ; if ( schema.elements[ key ] ) { return schema.elements[ key ] ; } else if ( ! schema.extraElements ) { throw new doormen.SchemaError( "Bad path: element #" + key + " not found and the schema does not allow extra elements." ) ; } } if ( schema.of !== undefined ) { if ( ! schema.of || typeof schema.of !== 'object' ) { throw new doormen.SchemaError( "Bad schema: 'of' should contain a schema object." ) ; } return schema.of ; } // Sub-schema not found, it should be open to anything, so return {} return EMPTY_SCHEMA ; } ; // Refacto: // Manage recursivity when dealing with schemas and data // ----------------------------------------------------------------------------------------------------------- TODO ---------------------------------------------------- // The main check() function should use it /* doormen.dataWalker = function( ctx , fn ) { var key , ret , count , deleted , alternativeErrors , schema = ctx.schema ; /* if ( Array.isArray( schema ) ) { alternativeErrors = [] ; count = deleted = 0 ; for ( key = 0 ; key < schema.length ; key ++ ) { count ++ ; try { ret = doormen.dataWalker( { schema: ctx.schema[ key ] , schemaPath: ctx.schemaPath.concat( key ) , alternative: true , options: ctx.options } ) ; if ( ret !== ctx.schema[ key ] ) { if ( schema === ctx.schema ) { schema = Array.from( ctx.schema ) ; } schema[ key ] = ret ; if ( ret === undefined ) { deleted ++ ; } } } // Because deleted is true, schema is already a clone if ( deleted && count === deleted ) { schema = undefined ; } return schema ; } *//* if ( ctx.schema.properties && typeof ctx.schema.properties === 'object' ) { count = deleted = 0 ; for ( key in ctx.schema.properties ) { count ++ ; ret = fn( { schema: ctx.schema.properties[ key ] , schemaPath: ctx.schemaPath.concat( 'properties' , key ) , options: ctx.options } ) ; if ( ret !== ctx.schema.properties[ key ] ) { if ( schema === ctx.schema ) { schema = Object.assign( {} , ctx.schema ) ; } if ( schema.properties === ctx.schema.properties ) { schema.properties = Object.assign( {} , ctx.schema.properties ) ; } if ( ret === undefined ) { delete schema.properties[ key ] ; deleted ++ ; if ( ctx.options && ctx.options.extraProperties ) { schema.extraProperties = true ; } } else { schema.properties[ key ] = ret ; } } } // Because deleted is true, schema is already a clone if ( deleted && count === deleted ) { delete schema.properties ; } if ( deleted && ctx.options && ctx.options.extraProperties ) { schema.extraProperties = true ; } } if ( schema.of !== undefined && ( data && ( typeof data === 'object' || typeof data === 'function' ) ) ) { if ( ! schema.of || typeof schema.of !== 'object' ) { throw new doormen.SchemaError( "Bad schema (at " + element.displayPath + "), 'of' should contain a schema object." ) ; } if ( Array.isArray( data ) ) { if ( this.export && data === data_ ) { data = [] ; src = data_ ; } else { src = data ; } for ( i = 0 ; i < src.length ; i ++ ) { data[ i ] = this.check( schema.of , src[ i ] , { path: element.path ? element.path + '.' + i : '' + i , displayPath: element.displayPath + '[' + i + ']' , key: i } , isPatch ) ; } } else { if ( this.export && data === data_ ) { data = {} ; src = data_ ; } else { src = data ; } for ( key in src ) { data[ key ] = this.check( schema.of , src[ key ] , { path: element.path ? element.path + '.' + key : key , displayPath: element.displayPath + '.' + key , key: key } , isPatch ) ; } } // ---------------------------------------------------------------------------------------------------------- ret = fn( { schema: ctx.schema.of , schemaPath: ctx.schemaPath.concat( 'of' ) , options: ctx.options } ) ; if ( ret !== ctx.schema.of ) { if ( schema === ctx.schema ) { schema = Object.assign( {} , ctx.schema ) ; } if ( ret === undefined ) { delete schema.of ; } else { schema.of = ret ; } } } if ( schema.elements && Array.isArray( schema.elements ) ) { count = deleted = 0 ; for ( key = 0 ; key < schema.elements.length ; key ++ ) { count ++ ; ret = fn( { schema: ctx.schema.elements[ key ] , schemaPath: ctx.schemaPath.concat( 'elements' , key ) , options: ctx.options } ) ; if ( ret !== ctx.schema.elements[ key ] ) { if ( schema === ctx.schema ) { schema = Object.assign( {} , ctx.schema ) ; } if ( schema.elements === ctx.schema.elements ) { schema.elements = Array.from( ctx.schema.elements ) ; } schema.elements[ key ] = ret ; if ( ret === undefined ) { deleted ++ ; } } } // Because deleted is true, schema is already a clone if ( deleted && count === deleted ) { delete schema.elements ; } } return schema ; } ; */ // Manage recursivity when dealing with schemas doormen.schemaWalker = function( ctx , fn ) { var key , ret , count , deleted , schema = ctx.schema ; if ( Array.isArray( schema ) ) { count = deleted = 0 ; for ( key = 0 ; key < schema.length ; key ++ ) { count ++ ; ret = doormen.schemaWalker( { schema: ctx.schema[ key ] , schemaPath: ctx.schemaPath.concat( key ) , options: ctx.options } ) ; if ( ret !== ctx.schema[ key ] ) { if ( schema === ctx.schema ) { schema = Array.from( ctx.schema ) ; } schema[ key ] = ret ; if ( ret === undefined ) { deleted ++ ; } } } // Because deleted is true, schema is already a clone if ( deleted && count === deleted ) { schema = undefined ; } return schema ; } if ( ctx.schema.properties && typeof ctx.schema.properties === 'object' ) { count = deleted = 0 ; for ( key in ctx.schema.properties ) { count ++ ; ret = fn( { schema: ctx.schema.properties[ key ] , schemaPath: ctx.schemaPath.concat( 'properties' , key ) , options: ctx.options } ) ; if ( ret !== ctx.schema.properties[ key ] ) { if ( schema === ctx.schema ) { schema = Object.assign( {} , ctx.schema ) ; } if ( schema.properties === ctx.schema.properties ) { schema.properties = Object.assign( {} , ctx.schema.properties ) ; } if ( ret === undefined ) { delete schema.properties[ key ] ; deleted ++ ; if ( ctx.options && ctx.options.extraProperties ) { schema.extraProperties = true ; } } else { schema.properties[ key ] = ret ; } } } // Because deleted is true, schema is already a clone if ( deleted && count === deleted ) { delete schema.properties ; } if ( deleted && ctx.options && ctx.options.extraProperties ) { schema.extraProperties = true ; } } if ( schema.of && typeof schema.of === 'object' ) { ret = fn( { schema: ctx.schema.of , schemaPath: ctx.schemaPath.concat( 'of' ) , options: ctx.options } ) ; if ( ret !== ctx.schema.of ) { if ( schema === ctx.schema ) { schema = Object.assign( {} , ctx.schema ) ; } if ( ret === undefined ) { delete schema.of ; } else { schema.of = ret ; } } } if ( schema.elements && Array.isArray( schema.elements ) ) { count = deleted = 0 ; for ( key = 0 ; key < schema.elements.length ; key ++ ) { count ++ ; ret = fn( { schema: ctx.schema.elements[ key ] , schemaPath: ctx.schemaPath.concat( 'elements' , key ) , options: ctx.options } ) ; if ( ret !== ctx.schema.elements[ key ] ) { if ( schema === ctx.schema ) { schema = Object.assign( {} , ctx.schema ) ; } if ( schema.elements === ctx.schema.elements ) { schema.elements = Array.from( ctx.schema.elements ) ; } schema.elements[ key ] = ret ; if ( ret === undefined ) { deleted ++ ; } } } // Because deleted is true, schema is already a clone if ( deleted && count === deleted ) { delete schema.elements ; } } return schema ; } ; doormen.constraintSchema = function( schema ) { return constraintSchema_( { schema: schema , schemaPath: [] , options: { extraProperties: true } } ) ; } ; function constraintSchema_( ctx ) { var schema = doormen.schemaWalker( ctx , constraintSchema_ ) ; if ( Array.isArray( schema ) ) { return schema ; } if ( schema === ctx.schema ) { if ( ! schema.constraints ) { return ; } schema = Object.assign( {} , ctx.schema ) ; } delete schema.type ; delete schema.sanitize ; delete schema.filter ; return schema ; } // Get the tier of a patch, i.e. the highest tier for all path of the patch. doormen.patchTier = function( schema , patch ) { var i , iMax , path , maxTier = 1 , paths = Object.keys( patch ) ; for ( i = 0 , iMax = paths.length ; i < iMax ; i ++ ) { path = paths[ i ].split( '.' ) ; while ( path.length ) { maxTier = Math.max( maxTier , doormen.subSchema( schema , path ).tier || 1 ) ; path.pop() ; } } return maxTier ; } ; // Check if a patch is allowed by a tag-list doormen.checkPatchByTags = function( schema , patch , allowedTags ) { var path ; if ( ! ( allowedTags instanceof Set ) ) { if ( Array.isArray( allowedTags ) ) { allowedTags = new Set( allowedTags ) ; } else { allowedTags = new Set( [ allowedTags ] ) ; } } for ( path in patch ) { checkOnePatchPathByTags( schema , path , allowedTags , patch[ path ] ) ; } } ; function checkOnePatchPathByTags( schema , path , allowedTags , element ) { var subSchema , tag , found ; path = path.split( '.' ) ; while ( path.length ) { subSchema = doormen.subSchema( schema , path ) ; if ( subSchema.tags ) { found = false ; for ( tag of subSchema.tags ) { if ( allowedTags.has( tag ) ) { found = true ; break ; } } if ( ! found ) { if ( this && this.validatorError ) { this.validatorError( "Not allowed by tags" , element ) ; } else { throw new doormen.ValidatorError( "Not allowed by tags" , element ) ; } } } path.pop() ; } } /* doormen.patch( [options] , schema , patch , [data] ) Validate the 'patch' format. If 'data' is given, also check that immutable properties are not overwritten. */ doormen.patch = function( ... args ) { var options , schema , patch , data , path , value , subSchema , sanitized , context , patchCommandName ; // Share a lot of code with the doormen() function if ( args.length < 2 || args.length > 4 ) { throw new Error( 'doormen.patch() needs at least 2 and at most 4 arguments' ) ; } if ( args.length === 2 ) { schema = args[ 0 ] ; patch = args[ 1 ] ; } else if ( args.length === 3 ) { options = args[ 0 ] ; schema = args[ 1 ] ; patch = args[ 2 ] ; } else { options = args[ 0 ] ; schema = args[ 1 ] ; patch = args[ 2 ] ; data = args[ 3 ] ; } if ( ! schema || typeof schema !== 'object' ) { throw new doormen.SchemaError( 'Bad schema, it should be an object or an array of object!' ) ; } if ( ! options || typeof options !== 'object' ) { options = {} ; } // End of common part if ( ! patch || typeof patch !== 'object' ) { throw new Error( 'The patch should be an object' ) ; } // If in the 'export' mode, create a new object, else modify it in place sanitized = options.export ? {} : patch ; context = { userContext: options.userContext , validate: true , errors: [] , check: check , checkAllowed: options.allowedTags ? checkOnePatchPathByTags : null , allowedTags: options.allowedTags ? new Set( Array.isArray( options.allowedTags ) ? options.allowedTags : [ options.allowedTags ] ) : null , validatorError: validatorError , report: !! options.report , export: !! options.export } ; for ( path in patch ) { value = patch[ path ] ; // Don't try-catch! Let it throw! if ( context.checkAllowed ) { context.checkAllowed( schema , path , context.allowedTags , value ) ; } let element = { displayPath: 'patch.' + path , path: path , key: path } ; if ( ( patchCommandName = isPatchCommand( value ) ) ) { value = value[ patchCommandName ] ; if ( patchCommands[ patchCommandName ].getValue ) { value = patchCommands[ patchCommandName ].getValue( value ) ; } if ( patchCommands[ patchCommandName ].applyToChildren ) { subSchema = doormen.subSchema( schema , path , undefined , true ).of || {} ; } else { subSchema = doormen.subSchema( schema , path , undefined , true ) ; } if ( subSchema?.immutable && data && dotPath.get( data , path ) !== undefined ) { throw new doormen.ValidatorError( "Cannot patch an immutable property." , element ) ; } if ( patchCommands[ patchCommandName ].sanitize ) { sanitized[ path ][ patchCommandName ] = patchCommands[ patchCommandName ].sanitize( value ) ; context.check( subSchema , value , element , true ) ; } else { sanitized[ path ][ patchCommandName ] = context.check( subSchema , value , element , true ) ; } } else { subSchema = doormen.subSchema( schema , path , undefined , true ) ; if ( subSchema?.immutable && data && dotPath.get( data , path ) !== undefined ) { throw new doormen.ValidatorError( "Cannot patch an immutable property." , element ) ; } //sanitized[ path ] = doormen( options , subSchema , value ) ; sanitized[ path ] = context.check( subSchema , value , element , true ) ; } } if ( context.report ) { return { validate: context.validate , sanitized: sanitized , errors: context.errors } ; } return sanitized ; } ; // Shorthand doormen.patch.report = doormen.patch.bind( doormen , { report: true } ) ; doormen.patch.export = doormen.patch.bind( doormen , { export: true } ) ; /* doormen.applyPatch( data , patch ) Apply the 'patch' format (does not validate). */ doormen.applyPatch = function( data , patch ) { for ( let path in patch ) { let value = patch[ path ] ; let patchCommandName = isPatchCommand( value ) ; if ( patchCommandName ) { patchCommands[ patchCommandName ]( data , path , value[ patchCommandName ] ) ; } else { dotPath.set( data , path , value ) ; } } return data ; } ; function isPatchCommand( value ) { var key ; if ( ! value || typeof value !== 'object' ) { return false ; } for ( key in value ) { if ( key[ 0 ] !== '$' ) { return false ; } if ( ! patchCommands[ key ] ) { throw new Error( "Bad command '" + key + "'" ) ; } return key ; } } const patchCommands = {} ; patchCommands.$set = ( data , path , value ) => dotPath.set( data , path , value ) ; patchCommands.$delete = patchCommands.$unset = ( data , path ) => dotPath.delete( data , path ) ; patchCommands.$delete.getValue = () => undefined ; patchCommands.$delete.sanitize = () => true ; patchCommands.$push = ( data , path , value ) => dotPath.append( data , path , value ) ; patchCommands.$push.applyToChildren = true ; /* Specific Error class */ function validatorError( message , element ) { var error = new doormen.ValidatorError( message , element ) ; this.validate = false ; if ( this.report ) { this.errors.push( error ) ; } else { throw error ; } } /* Extend */ function extend( base , extension , overwrite ) { var key ; if ( ! extension || typeof extension !== 'object' || Array.isArray( extension ) ) { throw new TypeError( '[doormen] .extend*(): Argument #0 should be a plain object' ) ; } for ( key in extension ) { if ( ( ( key in base ) && ! overwrite ) || typeof extension[ key ] !== 'function' ) { continue ; } base[ key ] = extension[ key ] ; } } doormen.extendTypeCheckers = function( extension , overwrite ) { extend( global.DOORMEN_GLOBAL_EXTENSIONS.typeCheckers , extension , overwrite ) ; } ; doormen.extendSanitizers = function( extension , overwrite ) { extend( global.DOORMEN_GLOBAL_EXTENSIONS.sanitizers , extension , overwrite ) ; } ; doormen.extendFilters = function( extension , overwrite ) { extend( global.DOORMEN_GLOBAL_EXTENSIONS.filters , extension , overwrite ) ; } ; doormen.extendConstraints = function( extension , overwrite ) { extend( global.DOORMEN_GLOBAL_EXTENSIONS.constraints , extension , overwrite ) ; } ; doormen.extendDefaultFunctions = function( extension , overwrite ) { extend( global.DOORMEN_GLOBAL_EXTENSIONS.defaultFunctions , extension , overwrite ) ; } ; // Client mode does not throw when type checker, a sanitizer or a filter is not found doormen.clientMode = false ; doormen.setClientMode = function( clientMode ) { doormen.clientMode = !! clientMode ; } ; /* Assertion specific utilities */ doormen.shouldThrow = function shouldThrow( fn , from ) { var thrown = false ; from = from || shouldThrow ; try { fn() ; } catch ( error ) { thrown = true ; } if ( ! thrown ) { throw new doormen.AssertionError( "Function '" + ( fn.name || '(anonymous)' ) + "' should have thrown." , from ) ; } } ; doormen.shouldReject = async function shouldReject( fn , from ) { var thrown = false ; from = from || shouldReject ; try { await fn() ; } catch ( error ) { thrown = true ; } if ( ! thrown ) { throw new doormen.AssertionError( "Function '" + ( fn.name || '(anonymous)' ) + "' should have rejected." , from ) ; } } ; // For internal usage or dev only doormen.shouldThrowAssertion = function shouldThrowAssertion( fn , from ) { var error , thrown = false ; from = from || shouldThrowAssertion ; try { fn() ; } catch ( error_ ) { thrown = true ; error = error_ ; } if ( ! thrown ) { throw new doormen.AssertionError( "Function '" + ( fn.name || '(anonymous)' ) + "' should have thrown." , from ) ; } if ( ! ( error instanceof doormen.AssertionError ) ) { // Throw a new error? Seems better to re-throw with a modified message, or the stack trace would be lost? //throw new doormen.AssertionError( "Function '" + ( fn.name || '(anonymous)' ) + "' should have thrown an AssertionError, but have thrown: " + error , from ) ; error.message = "Function '" + ( fn.name || '(anonymous)' ) + "' should have thrown an AssertionError, instead it had thrown: " + error.message ; throw error ; } return error ; } ; // For internal usage or dev only doormen.shouldRejectAssertion = async function shouldRejectAssertion( fn , from ) { var error , thrown = false ; from = from || shouldRejectAssertion ; try { await fn() ; } catch ( error_ ) { thrown = true ; error = error_ ; } if ( ! thrown ) { throw new doormen.AssertionError( "Function '" + ( fn.name || '(anonymous)' ) + "' should have rejected." , from ) ; } if ( ! ( error instanceof doormen.AssertionError ) ) { // Throw a new error? Seems better to re-throw with a modified message, or the stack trace would be lost? //throw new doormen.AssertionError( "Function '" + ( fn.name || '(anonymous)' ) + "' should have thrown an AssertionError, but have thrown: " + error , from ) ; error.message = "Function '" + ( fn.name || '(anonymous)' ) + "' should have rejected with an AssertionError, instead it had rejected with: " + error.message ; throw error ; } return error ; } ; // Inverse validation doormen.not = function not( ... args ) { doormen.shouldThrow( () => { doormen( ... args ) ; } , not ) ; } ; // Inverse of constraints-only validation doormen.checkConstraints.not = function checkConstraintsNot( ... args ) { doormen.shouldThrow( () => { doormen.constraints( ... args ) ; } , checkConstraintsNot ) ; } ; // Inverse validation for patch doormen.patch.not = function patchNot( ... args ) { doormen.shouldThrow( () => { doormen.patch( ... args ) ; } , patchNot ) ; } ; // DEPRECATED assertions! Only here for backward compatibility doormen.equals = function equals( left , right ) { if ( ! doormen.isEqual( left , right ) ) { throw new doormen.AssertionError( 'should have been equal' , equals , { actual: left , expected: right , showDiff: true } ) ; } } ; // Inverse of equals doormen.not.equals = function notEquals( left , right ) { if ( doormen.isEqual( left , right ) ) { throw new doormen.AssertionError( 'should not have been equal' , notEquals , { actual: left , expected: right , showDiff: true } ) ; } } ; const IS_EQUAL_LIKE = { like: true } ; doormen.alike = function alike( left , right ) { if ( ! doormen.isEqual( left , right , IS_EQUAL_LIKE ) ) { throw new doormen.AssertionError( 'should have been alike' , alike , { actual: left , expected: right , showDiff: true } ) ; } } ; // Inverse of alike doormen.not.alike = function notAlike( left , right ) { if ( doormen.isEqual( left , right , IS_EQUAL_LIKE ) ) { throw new doormen.AssertionError( 'should not have been alike' , notAlike , { actual: left , expected: right , showDiff: true } ) ; } } ;