index.js

JavaScript source for Simple typechecking goes a long way, 601 lines. Raw file

Error.stackTraceLimit = 200;

const Security =
{
    /**
     * Descriptions of what's being done, innermost last, added to every failure message.
     * @private
     */
    _stack: [],

    /**
     * Runs a callback with a description of what it's doing, so any failure inside says what was going on.
     *
     * @param {string} description  What the callback is doing, e.g. 'loading level 3'
     * @param {function} callback  The code to run
     * @returns {*} Whatever the callback returns
     */
    Enter: (description, callback) => {
        Security._stack.push(description)
        try {
            return callback()
        }
        finally {
            Security._stack.pop()
        }
    },

    /**
     * Accepts any value, for an argument that can be anything.
     *
     * @param {*} value  The value
     * @returns {*} The value
     */
    Any: (value) => {
        return value
    },

    /**
     * Placeholder for a check by type name: checks the name is a string and accepts any value.
     *
     * @param {string} typeString  The type name
     * @returns {function} A check that takes the value and returns nothing
     */
    //  TODO: Implement
    Type: (typeString) => (value) => {
        Security.String(typeString)
        Security.Any(value)

    },

    /**
     * Fails unless a condition is true.
     *
     * @param {boolean} boolean  The condition
     * @param {string} [optionalMessage]  The message to fail with, instead of 'Check failed'
     */
    Check: (boolean, optionalMessage) => {
        if( ! boolean ) {
            Security.Fail(optionalMessage !== undefined ? optionalMessage : 'Check failed')
        }
    },

    /**
     * Fails if the value is null or undefined.
     *
     * @param {*} value  The value to check
     * @param {string} [optionalMessage]  The message to fail with, instead of 'Value is not defined'
     * @returns {*} The value
     */
    Defined: (value, optionalMessage) => {
        if( value === null || value === undefined ) {
            Security.Fail(optionalMessage !== undefined ? optionalMessage : 'Value is not defined')
        }
        return value
    },

    /**
     * Fails unless the value is a whole number, optionally within limits.
     *
     * @param {number} value  The value to check
     * @param {number} [optionalMinValue]  The smallest value allowed
     * @param {number} [optionalMaxValue]  The largest value allowed
     * @returns {number} The value
     */
    Integer: (value, optionalMinValue, optionalMaxValue) => {
        if( typeof value !== 'number' ) Security.Fail(`Expected number; got ${typeof value}`)
        if( value !== Math.floor(value) ) Security.Fail(`Expected integer; got ${value}`)

        if( optionalMinValue !== undefined ) optionalMinValue = Security.Integer(optionalMinValue)
        if( optionalMaxValue !== undefined ) optionalMaxValue = Security.Integer(optionalMaxValue)

        if( optionalMinValue !== undefined && value < optionalMinValue ) Security.Fail(`Expected number >= ${optionalMinValue}; got ${value}`)
        if( optionalMaxValue !== undefined && value > optionalMaxValue ) Security.Fail(`Expected number <= ${optionalMaxValue}; got ${value}`)

        return value;
    },

    /**
     * Fails unless the value is an ArrayBuffer or a Node Buffer.
     *
     * @param {ArrayBuffer|Buffer} buffer  The value to check
     * @returns {ArrayBuffer|Buffer} The value
     */
    Buffer: (buffer) => {
        if (!(buffer instanceof ArrayBuffer) && (typeof Buffer === 'undefined' || !Buffer.isBuffer(buffer))) {
            Security.Fail(`Expected buffer; got ${typeof buffer}`);
        }
        return buffer;
    },

    /**
     * Fails unless the value is a string.
     *
     * @param {string} value  The value to check
     * @returns {string} The value
     */
    String: (value) => {
        if( typeof value !== 'string' ) Security.Fail(`Expected string; got ${typeof value}: ${JSON.stringify(value)}`)
        return value;
    },

    /**
     * Fails unless the value is a string of hex digits, optionally of an exact length.
     *
     * @param {string} value  The value to check
     * @param {number} [expectedLength]  The number of digits it must have
     * @returns {string} The value
     */
    HexString: (value, expectedLength) => {
        Security.String(value);
        if (expectedLength !== undefined) {
            Security.Integer(expectedLength);
            if (value.length !== expectedLength) {
                Security.Fail(`Expected hex string of length ${expectedLength}; got length ${value.length}: ${JSON.stringify(value)}`);
            }
        }
        // Validate that the string contains only hexadecimal characters (0-9, a-f, A-F)
        const hexPattern = /^[0-9a-fA-F]+$/;
        if (!hexPattern.test(value)) {
            Security.Fail(`Expected hex string (0-9, a-f, A-F); got: ${JSON.stringify(value)}`);
        }
        return value;
    },

    /**
     * Fails unless the value is a string of exactly 32 hex digits.
     *
     * @param {string} value  The value to check
     * @returns {string} The value
     */
    Hex32: (value) => {
        return Security.HexString(value, 32);
    },

    /**
     * Fails unless the value is a string of exactly 64 hex digits.
     *
     * @param {string} value  The value to check
     * @returns {string} The value
     */
    Hex64: (value) => {
        return Security.HexString(value, 64);
    },

    /**
     * Fails unless the value is a number, optionally within limits.
     *
     * @param {number} value  The value to check
     * @param {number} [optionalMinValue]  The smallest value allowed
     * @param {number} [optionalMaxValue]  The largest value allowed
     * @returns {number} The value
     */
    Number: (value, optionalMinValue, optionalMaxValue) => {
        if( typeof value !== 'number' ) Security.Fail(`Expected number; got ${typeof value}: ${JSON.stringify(value)}`)
        if( Number.isNaN(value) ) Security.Fail(`Expected number; got NaN`)

        if( optionalMinValue !== undefined ) optionalMinValue = Security.Number(optionalMinValue)
        if( optionalMaxValue !== undefined ) optionalMaxValue = Security.Number(optionalMaxValue)

        if( optionalMinValue !== undefined && value < optionalMinValue ) Security.Fail(`Expected number >= ${optionalMinValue}; got ${value}`)
        if( optionalMaxValue !== undefined && value > optionalMaxValue ) Security.Fail(`Expected number <= ${optionalMaxValue}; got ${value}`)

        return value;
    },

    /**
     * Makes a check for an object (or a function used as one, but not an array), optionally of a type.
     *
     * @param {string|function} [of]  The type name its _is field must hold, or a check to hand the object to
     * @returns {function} A check that takes the value and returns it, or what the 'of' check returns
     */
    Object: function Security_Object(of) {
        return function Security_Object__value(value) {
            if( value === undefined ) Security.Fail( `Expected object; got undefined` )
            if( value === null ) Security.Fail( `Expected object; got null` )
            //  Allow for functions acting as objects
            if( typeof value !== 'function' && typeof value !== 'object' ) Security.Fail( `Expected object; got ${typeof value}: "${value}"` )
            if( Object.prototype.toString.call(value) === '[object Array]' ) Security.Fail( `Expected object; got array` )
            if ( of ) {
                if( typeof of === 'function' ) return of(value)
                else if ( typeof of === 'string' ) {
                    if( typeof value._is === 'function' ) {
                        const ofCallback = Security._typeStringToCallback(of)
                        if( ! Security._typeCallbacksEqual(value._is,ofCallback) ) Security.Fail( `Invalid data type; got a different type, expected ${of}`)
                    }
                    else if (value._is !== of) {
                        Security.Fail( `Invalid data type; got ${value._is}, expected ${of}`)
                    }
                }
                else {
                    Security.Fail( `Invalid validator` )
                }
            }

            return value;
        };
    },

    /**
     * Makes a check for an array, optionally checking every element.
     *
     * @param {string|function} [of]  The check, or type name, for each element
     * @returns {function} A check that takes the value and returns it
     */
    Array: function Security_Array(of) {
        return value => {
            if( Object.prototype.toString.call(value) !== '[object Array]' ) Security.Fail( `Expected array; got ${typeof value}: ${JSON.stringify(value)}` )
            if ( of ) value.forEach( value => Security.Validate(of)(value) )
            return value;
        };
    },

    /**
     * Makes a check for an object used as a dictionary, checking every value in it.
     *
     * @param {string|function} of  The check, or type name, for each value
     * @returns {function} A check that takes the value and returns it
     */
    Dictionary: function Security_Dictionary(of) {
        return function Security_Dictionary__values(value) {
            Security.Object()(value)
            Object.keys(value).forEach( function Security_Dictionary__values_value(key) {
                return Security.Validate(of)(value[key])
            })
            return value
        }
    },

    /**
     * Makes a check from either a check function or a type name.
     *
     * @param {string|function} validation  A check function, or the type name an object's _is field must hold
     * @returns {function} A check that takes the value and returns what the check function returns, or the value for a type name
     */
    Validate: (validation) => {
        return function Security_Validate__value(value) {
            if( typeof validation === 'function' ) {
                return validation(value)
            }
            else if ( typeof validation === 'string' ) {
                Security.Check( value !== undefined )
                if (value._is !== validation) {
                    Security.Fail( `Invalid data type; got ${JSON.stringify(value)}, expected ${validation}`)
                }
                return value
            }
            else {
                Security.Fail( `Invalid validator` )
            }
        }
    },

    /**
     * Fails unless the value is a function.
     *
     * @param {function} value  The value to check
     * @returns {function} The value
     */
    Callback: (value) => {
        if( typeof value !== 'function' ) Security.Fail( 'Expected function' )
        return value;
    },

    /**
     * Makes a check that lets undefined through and holds any other value to a check or type name.
     *
     * @param {string|function} of  The check, or type name, for a value that's there
     * @returns {function} A check that takes the value and returns what the 'of' check returns, or the value for a type name
     */
    Optional: (of) => {
        return function Optional__checkValue(value) {
            if ( value !== undefined ) {
                if ( typeof of === 'function') return of(value)
                if ( typeof of === 'string') {
                    if ( value._is !== of ) Security.Fail( `Invalid data type; got ${value._is}, expected ${of}`)
                };
            };
            return value
        };
    },

    /**
     * Fails unless the value is true or false.
     *
     * @param {boolean} value  The value to check
     * @returns {boolean} The value
     */
    Boolean: value => {
        if ( value !== true && value !== false ) Security.Fail(`Expected boolean; got ${typeof value}: ${JSON.stringify(value)}`)
        return value;
    },

    /**
     * Fails unless the value is undefined or a whole number.
     *
     * @param {number} [value]  The value to check
     * @returns {number|undefined} The value
     */
    OptionalInteger: (value) => Security.Optional( Security.Integer )( value ),

    /**
     * Fails unless the value is undefined or a string.
     *
     * @param {string} [value]  The value to check
     * @returns {string|undefined} The value
     */
    OptionalString: (value) => Security.Optional( Security.String )( value ),

    /**
     * Fails unless the value is undefined or a number.
     *
     * @param {number} [value]  The value to check
     * @returns {number|undefined} The value
     */
    OptionalNumber: (value) => Security.Optional( Security.Number )( value ),

    /**
     * Makes a check for a value that's either undefined or an array.
     *
     * @param {string|function} [of]  The check, or type name, for each element
     * @returns {function} A check that takes the value and returns it
     */
    OptionalArray: (of) => {
        return (value) => {
            return Security.Optional( Security.Array( of ) )( value )
        };
    },

    /**
     * Makes a check for a value that's either undefined or an object.
     *
     * @param {string|function} [of]  The type name, or a check to hand the object to
     * @returns {function} A check that takes the value and returns it, or what the 'of' check returns
     */
    OptionalObject: (of) => {
        return (value) => {
            return Security.Optional( Security.Object( of ) )( value )
        };
    },

    /**
     * Fails unless the value is undefined or a function.
     *
     * @param {function} [value]  The value to check
     * @returns {function|undefined} The value
     */
    OptionalCallback: (value) => Security.Optional( Security.Callback )( value ),

    /**
     * Throws an error with a message, followed by the descriptions recorded with Enter.
     *
     * @param {string} msg  What went wrong
     */
    Fail: function Security_Fail(msg) {
      debugger;
      const doing = Security._stack.map( description => '\n  while: '+description).join('')
      throw new Error(msg + doing)
    },

    /**
     * Fails unless the value has exactly one field, named in the options, whose contents pass that option's check.
     *
     * @param {object} value  The value to check
     * @param {object} options  The allowed field names, each with a type name or a check (or undefined for none)
     * @returns {object} The value
     */
    OneField: (value, options) => {
        Security.Object()(value)
        Security.Object()(options)

        //  Check that value only has one field and this field exists in the options
        let foundName;
        for( let name in value ) if( value.hasOwnProperty(name) && name.charAt(0) !== '_' ) {
            if( foundName !== undefined ) {
                Security.Fail(`Already found option "${foundName}" but there is a second option "${name}" present`)
            }
            else {
                Security.Check( options.hasOwnProperty(name), `${name} is not in the list of options for this type`)
                foundName = name;
            }
        }
        if( foundName === undefined ) {
            Security.Fail(`Expected one of the options ${Object.keys(options).join(', ')}; got none`)
        }

        for( let name in options ) if( options.hasOwnProperty(name) ) {
            if( value.hasOwnProperty(name) ) {
                const objectNameOrValidationCallback = options[name];

                if( typeof objectNameOrValidationCallback === 'string' ) {
                    Security.Object(objectNameOrValidationCallback)( value[name] )
                    return value;
                }
                else {
                    Security.OptionalCallback(objectNameOrValidationCallback)
                    if( objectNameOrValidationCallback ) {
                        objectNameOrValidationCallback( value[name] )
                    }
                    return value;
                }
            }
        }
    },

    /**
     * Fails unless the value is an object of a type: the same as Security.Object(type)(value).
     *
     * @param {object} value  The value to check
     * @param {string|function} type  The type name its _is field must hold, or a check to hand it to
     * @returns {object} The value
     */
    Is: (value, type) => {
        return Security.Object(type)(value)
    },

    /**
     * Fails unless the value is undefined or of a type: the same as Security.Optional(type)(value).
     *
     * @param {object} [value]  The value to check
     * @param {string|function} type  The type name its _is field must hold, or a check for it
     * @returns {*} The value, or what the check returns if type is a check
     */
    OptionalIs: (value, type) => {
        return Security.Optional(type)(value)
    },

    /**
     * Fails unless two values write out the same, showing them side by side from the first difference.
     *
     * @param {*} a  The value found
     * @param {*} b  The value expected
     * @param {string} [message]  What's being compared, put at the start of the failure
     */
    Equal: ( a, b, message ) => {
        const aString = Security._stringify(a)
        const bString = Security._stringify(b)

        if ( aString !== bString ) {

            const aLines = aString.split('\n')
            const bLines = bString.split('\n')
//            console.log({aString,bString})


            const combinedLines = []
            const width = 60
            let marked = false
//            console.log(aLines)
            for( let i=0; i<aLines.length || i<bLines.length; i++ ) {
                const aLine = aLines.length > i ? aLines[i] : ''
                const bLine = bLines.length > i ? bLines[i] : ''
                const aPadded = aLine + (' '.repeat(Math.max(0,width-aLine.length)))
                if( aLine !== bLine && !marked) {
                    combinedLines.push('')
                    combinedLines.push('------------------ 8< ------------------------------------- 8< ------------------------------------- 8< -------------------')
                    combinedLines.push('')
                    marked = true
                }
                combinedLines.push( '|' + aPadded + ' | '+bLine)
            }

            Security.Fail( (message ? message+'; ' : '') + 'Expected values to be equal;\n\n------------------------------------------------------------------------------\n\n'+/*Equal part:'+aJson.substring(0,i)+'\n\n*/'------------------------------------------------------------------------------\n\n'+combinedLines.join('\n'))
        }
    },

    /**
     * Fails unless the value is an ArrayBuffer.
     *
     * @param {ArrayBuffer} a  The value to check
     * @returns {ArrayBuffer} The value
     */
    ArrayBuffer: (a) => {
        Security.Check( a instanceof ArrayBuffer )
        return a
    },

    /**
     * Fails unless the value is a function or a string.
     *
     * @param {function|string} input  The value to check
     * @returns {function|string} The value
     */
    CallbackOrString: input => {
        try {
            Security.Callback(input)
            return input
        }
        catch (e) {}
        try {
            Security.String(input)
            return input
        }
        catch (e) {}

        Security.Fail(`Expected callback or string; got ${typeof input}`)
    },

    /**
     * Writes any value out as text for Equal, with object keys sorted and one field per line.
     * @private
     *
     * @param {*} a  The value
     * @param {string} [indent]  The indent for nested lines
     * @returns {string} The text
     */
    _stringify: (a, indent = '') => {
        if( a instanceof ArrayBuffer ) return 'new Uint8Array('+JSON.stringify( Array.from( new Uint8Array(a) ))+').buffer'
        else if( Array.isArray(a) ) {
            const stringify = e => Security._stringify(e, indent+'    ')
            const strings = a.map( stringify )
            const joined = strings.join(',\n')
            return '[\n' + indent + joined + '\n' + indent + ']'
        }
        else if( typeof a === 'function' ) {
            const keys = Object.keys(a).sort()
            if( keys.length > 0 ) {
                const stringify = key => key+':'+Security._stringify(a[key], indent+'    ')
                const strings = keys.map( stringify )
                const joined = strings.join(',\n')
                return '{\n' + indent + joined + '\n' + indent + '}'
                //  'Object.assign('+aString+', '+fields+'})'
            }
            else {
                return a.toString().split('\n').map( line => line.trim() ).join('\n')
            }
        }
        else if( typeof a === 'object' ) {
            const keys = Object.keys(a).sort()
            const stringify = key => key + ':' + Security._stringify(a[key], indent + '    ')
            const strings = keys.map(stringify)
            const joined = strings.join(',\n')
            return '{\n' + indent + joined + '\n' + indent + '}'
        } else if( a === undefined ) {
            return 'undefined'
        } else {
            return JSON.stringify(a)
        }
    },

    /**
     * Compares two type functions, and the types they were made from, for Object.
     * @private
     *
     * @param {function} typeCallback1  The first type function
     * @param {function} typeCallback2  The second type function
     * @returns {boolean} Whether they're the same type
     */
    _typeCallbacksEqual: (typeCallback1, typeCallback2) => {
        Security.Callback(typeCallback1)
        Security.Callback(typeCallback2)

        if( typeCallback1 === typeCallback2 ) return true
        if( typeCallback1._type && typeCallback2._type ) {
            if( typeCallback1._typeInputs.length === typeCallback2._typeInputs.length ) {
                for( let i=0; i < typeCallback1._typeInputs.length; i++ ) {
                    const equal = Security._typeCallbacksEqual( typeCallback1._typeInputs[i], typeCallback2._typeInputs[i] )
                    if(!equal) return false
                }
                return true
            }
        }
        return false
    },

    /**
     * Finds the function a type name refers to, by loading its module and walking down the dotted name.
     * @private
     *
     * @param {string} of  The type name, e.g. 'Game.Player'
     * @returns {function} The function
     */
    _typeStringToCallback: (of) => {
        const [moduleName,...rest] = of.split('.')
        let current = require(moduleName)
        rest.forEach( name => current = current[name])
        Security.Callback(current)
        return current
    },
};

module.exports = Security