doormen
Version:
Validate, sanitize and assert: the silver bullet of data!
1,327 lines (990 loc) • 40.3 kB
JavaScript
/*
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.
*/
;
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
} ) ;
}
} ;