Source: models/behaviors.js

'use strict';

/**
 * The functionality behind the behaviors field in the API, supporting post-processing responses
 * @module
 */

const os = require('os'),
    fsExtra = require('fs-extra'),
    childProcess = require('child_process'),
    safeRegex = require('safe-regex'),
    csvParse = require('csv-parse'),
    buffer = require('buffer'),
    prometheus = require('prom-client'),
    xPath = require('./xpath'),
    jsonPath = require('./jsonpath'),
    helpers = require('../util/helpers.js'),
    exceptions = require('../util/errors.js'),
    behaviorsValidator = require('./behaviorsValidator.js'),
    compatibility = require('./compatibility.js');


const metrics = {
    behaviorDuration: new prometheus.Histogram({
        name: 'mb_behavior_duration_seconds',
        help: 'Time it takes to run all the behaviors',
        buckets: [0.05, 0.1, 0.2, 0.5, 1, 3],
        labelNames: ['imposter']
    })
};

// The following schemas are used by both the lookup and copy behaviors and should be kept consistent
const fromSchema = {
        _required: true,
        _allowedTypes: {
            string: {},
            object: { singleKeyOnly: true }
        },
        _additionalContext: 'the request field to select from'
    },
    intoSchema = {
        _required: true,
        _allowedTypes: { string: {} },
        _additionalContext: 'the token to replace in response fields'
    },
    usingSchema = {
        _required: true,
        _allowedTypes: { object: {} },
        method: {
            _required: true,
            _allowedTypes: { string: { enum: ['regex', 'xpath', 'jsonpath'] } }
        },
        selector: {
            _required: true,
            _allowedTypes: { string: {} }
        }
    },
    validations = {
        wait: {
            _required: true,
            _allowedTypes: { string: {}, number: { nonNegativeInteger: true } }
        },
        copy: {
            from: fromSchema,
            into: intoSchema,
            using: usingSchema
        },
        lookup: {
            key: {
                _required: true,
                _allowedTypes: { object: {} },
                from: fromSchema,
                using: usingSchema
            },
            fromDataSource: {
                _required: true,
                _allowedTypes: { object: { singleKeyOnly: true, enum: ['csv'] } },
                csv: {
                    _required: false,
                    _allowedTypes: { object: {} },
                    path: {
                        _required: true,
                        _allowedTypes: { string: {} },
                        _additionalContext: 'the path to the CSV file'
                    },
                    delimiter: {
                        _required: false,
                        _allowedTypes: { string: {} },
                        _additionalContext: 'the delimiter separator values'
                    },
                    keyColumn: {
                        _required: true,
                        _allowedTypes: { string: {} },
                        _additionalContext: 'the column header to select against the "key" field'
                    }
                }
            },
            into: intoSchema
        },
        shellTransform: {
            _required: true,
            _allowedTypes: { string: {} },
            _additionalContext: 'the path to a command line application'
        },
        decorate: {
            _required: true,
            _allowedTypes: { string: {} },
            _additionalContext: 'a JavaScript function'
        }
    };

/**
 * Validates the behavior configuration and returns all errors
 * @param {Object} config - The behavior configuration
 * @returns {Object} The array of errors
 */
function validate (config) {
    const validator = behaviorsValidator.create();
    return validator.validate(config, validations);
}

/**
 * Waits a specified number of milliseconds before sending the response.  Due to the approximate
 * nature of the timer, there is no guarantee that it will wait the given amount, but it will be close.
 * @param {Object} request - The request object
 * @param {Object} response - The response
 * @param {number} millisecondsOrFn - The number of milliseconds to wait before returning, or a function returning milliseconds
 * @param {Object} logger - The mountebank logger, useful for debugging
 * @returns {Object} A promise resolving to the response
 */
async function wait (request, response, millisecondsOrFn, logger) {
    const fn = `(${millisecondsOrFn})()`;

    let milliseconds = parseInt(millisecondsOrFn);

    if (isNaN(milliseconds)) {
        try {
            milliseconds = eval(fn);
        }
        catch (error) {
            logger.error('injection X=> ' + error);
            logger.error('    full source: ' + JSON.stringify(fn));
            return Promise.reject(exceptions.InjectionError('invalid wait injection',
                { source: millisecondsOrFn, data: error.message }));
        }
    }

    logger.debug('Waiting %s ms...', milliseconds);
    return new Promise(resolve => {
        setTimeout(() => resolve(response), milliseconds);
    });
}

function quoteForShell (obj) {
    const json = JSON.stringify(obj),
        isWindows = os.platform().indexOf('win') === 0;

    if (isWindows) {
        // Confused? Me too. All other approaches I tried were spectacular failures
        // in both 1) keeping the JSON as a single CLI arg, and 2) maintaining the inner quotes
        return `"${json.replace(/"/g, '\\"')}"`;
    }
    else {
        return `'${json}'`;
    }
}

function execShell (command, request, response, logger) {
    const exec = childProcess.exec,
        env = helpers.clone(process.env),
        maxBuffer = buffer.constants.MAX_STRING_LENGTH,
        maxShellCommandLength = 2048;

    logger.debug(`Shelling out to ${command}`);

    // Switched to environment variables because of inconsistencies in Windows shell quoting
    // Leaving the CLI args for backwards compatibility
    env.MB_REQUEST = JSON.stringify(request);
    env.MB_RESPONSE = JSON.stringify(response);

    // Windows has a pretty low character limit to the command line. When we're in danger
    // of the character limit, we'll remove the command line arguments under the assumption
    // that backwards compatibility doesn't matter when it never would have worked to begin with
    let fullCommand = `${command} ${quoteForShell(request)} ${quoteForShell(response)}`;
    if (fullCommand.length >= maxShellCommandLength) {
        fullCommand = command;
    }

    return new Promise((resolve, reject) => {
        exec(fullCommand, { env, maxBuffer }, (error, stdout, stderr) => {
            if (error) {
                if (stderr) {
                    logger.error(stderr);
                }
                reject(error.message);
            }
            else {
                logger.debug(`Shell returned '${stdout}'`);
                try {
                    resolve(JSON.parse(stdout));
                }
                catch (err) {
                    reject(`Shell command returned invalid JSON: '${stdout}'`);
                }
            }
        });
    });
}

/**
 * Runs the response through a shell function, passing the JSON in as stdin and using
 * stdout as the new response
 * @param {Object} request - The request
 * @param {Object} response - The response
 * @param {string} command - The shell command to execute
 * @param {Object} logger - The mountebank logger, useful in debugging
 * @returns {Object}
 */
function shellTransform (request, response, command, logger) {
    return execShell(command, request, response, logger);
}

/**
 * Runs the response through a post-processing function provided by the user
 * @param {Object} originalRequest - The request object, in case post-processing depends on it
 * @param {Object} response - The response
 * @param {Function} fn - The function that performs the post-processing
 * @param {Object} logger - The mountebank logger, useful in debugging
 * @param {Object} imposterState - The user controlled state variable
 * @returns {Object}
 */
function decorate (originalRequest, response, fn, logger, imposterState) {
    const config = {
            request: helpers.clone(originalRequest),
            response,
            logger,
            state: imposterState
        },
        injected = `(${fn})(config, response, logger);`; // backwards compatibility

    compatibility.downcastInjectionConfig(config);

    try {
        // Support functions that mutate response in place and those
        // that return a new response
        let result = eval(injected);
        if (!result) {
            result = response;
        }
        return Promise.resolve(result);
    }
    catch (error) {
        logger.error('injection X=> ' + error);
        logger.error('    full source: ' + JSON.stringify(injected));
        logger.error('    config: ' + JSON.stringify(config));
        return Promise.reject(exceptions.InjectionError('invalid decorator injection', { source: injected, data: error.message }));
    }
}

function getKeyIgnoringCase (obj, expectedKey) {
    return Object.keys(obj).find(key => {
        if (key.toLowerCase() === expectedKey.toLowerCase()) {
            return key;
        }
        else {
            return undefined;
        }
    });
}

function getFrom (obj, from) {
    const isObject = helpers.isObject;

    if (typeof obj === 'undefined') {
        return undefined;
    }
    else if (isObject(from)) {
        const keys = Object.keys(from);
        return getFrom(obj[keys[0]], from[keys[0]]);
    }
    else {
        const result = obj[getKeyIgnoringCase(obj, from)];

        // Some request fields, like query parameters, can be multi-valued
        if (Array.isArray(result)) {
            return result[0];
        }
        else {
            return result;
        }
    }
}

function regexFlags (options) {
    let result = '';
    if (options && options.ignoreCase) {
        result += 'i';
    }
    if (options && options.multiline) {
        result += 'm';
    }
    return result;
}

function getMatches (selectionFn, selector, logger) {
    const matches = selectionFn();

    if (matches && matches.length > 0) {
        return matches;
    }
    else {
        logger.debug('No match for "%s"', selector);
        return [];
    }
}

function regexValue (from, config, logger) {
    const regex = new RegExp(config.using.selector, regexFlags(config.using.options)),
        selectionFn = () => regex.exec(from);

    if (!safeRegex(regex)) {
        logger.warn(`If mountebank becomes unresponsive, it is because of this unsafe regular expression: ${config.using.selector}`);
    }
    return getMatches(selectionFn, regex, logger);
}

function xpathValue (from, config, logger) {
    const selectionFn = () => {
        return xPath.select(config.using.selector, config.using.ns, from, logger);
    };
    return getMatches(selectionFn, config.using.selector, logger);
}

function jsonpathValue (from, config, logger) {
    const selectionFn = () => {
        return jsonPath.select(config.using.selector, from, logger);
    };
    return getMatches(selectionFn, config.using.selector, logger);
}

function globalStringReplace (str, substring, newSubstring, logger) {
    if (substring !== newSubstring) {
        logger.debug('Replacing %s with %s', JSON.stringify(substring), JSON.stringify(newSubstring));
        return str.split(substring).join(newSubstring);
    }
    else {
        return str;
    }
}

function globalObjectReplace (obj, replacer) {
    const isObject = helpers.isObject,
        renames = {};

    Object.keys(obj).forEach(key => {
        if (typeof obj[key] === 'string') {
            obj[key] = replacer(obj[key]);
        }
        else if (isObject(obj[key])) {
            globalObjectReplace(obj[key], replacer);
        }
        var newKey = replacer(key);
        if (newKey !== key) {
            renames[key] = newKey;
        }
    });
    Object.keys(renames).forEach(key => {
        obj[renames[key]] = obj[key];
        delete obj[key];
    });
}

function replaceArrayValuesIn (response, token, values, logger) {
    const replacer = field => {
        values.forEach(function (replacement, index) {
            // replace ${TOKEN}[1] with indexed element
            const indexedToken = `${token}[${index}]`;
            field = globalStringReplace(field, indexedToken, replacement, logger);
        });
        if (values.length > 0) {
            // replace ${TOKEN} with first element
            field = globalStringReplace(field, token, values[0], logger);
        }
        return field;
    };

    globalObjectReplace(response, replacer);
}

/**
 * Copies a value from the request and replaces response tokens with that value
 * @param {Object} originalRequest - The request object, in case post-processing depends on it
 * @param {Object} response - The response
 * @param {Function} copyConfig - The config to copy
 * @param {Object} logger - The mountebank logger, useful in debugging
 * @returns {Object}
 */
function copy (originalRequest, response, copyConfig, logger) {
    const from = getFrom(originalRequest, copyConfig.from),
        using = copyConfig.using || {},
        fnMap = { regex: regexValue, xpath: xpathValue, jsonpath: jsonpathValue },
        values = fnMap[using.method](from, copyConfig, logger);

    replaceArrayValuesIn(response, copyConfig.into, values, logger);
    return response;
}

function containsKey (headers, keyColumn) {
    const key = Object.values(headers).find(value => value === keyColumn);

    return helpers.defined(key);
}

function createRowObject (headers, rowArray) {
    const row = {};
    rowArray.forEach(function (value, index) {
        row[headers[index]] = value;
    });
    return row;
}

function selectRowFromCSV (csvConfig, keyValue, logger) {
    const delimiter = csvConfig.delimiter || ',',
        inputStream = fsExtra.createReadStream(csvConfig.path),
        parser = csvParse.parse({ delimiter: delimiter }),
        pipe = inputStream.pipe(parser);
    let headers;

    return new Promise(resolve => {
        inputStream.on('error', e => {
            logger.error('Cannot read ' + csvConfig.path + ': ' + e);
            resolve({});
        });

        pipe.on('data', function (rowArray) {
            if (!helpers.defined(headers)) {
                headers = rowArray;
                const keyOnHeader = containsKey(headers, csvConfig.keyColumn);
                if (!keyOnHeader) {
                    logger.error('CSV headers "' + headers + '" with delimiter "' + delimiter + '" does not contain keyColumn:"' + csvConfig.keyColumn + '"');
                    resolve({});
                }
            }
            else {
                const row = createRowObject(headers, rowArray);
                if (helpers.defined(row[csvConfig.keyColumn]) && row[csvConfig.keyColumn].localeCompare(keyValue) === 0) {
                    resolve(row);
                }
            }
        });

        pipe.on('error', e => {
            logger.debug('Error: ' + e);
            resolve({});
        });

        pipe.on('end', () => {
            resolve({});
        });
    });
}

function lookupRow (lookupConfig, originalRequest, logger) {
    const from = getFrom(originalRequest, lookupConfig.key.from),
        fnMap = { regex: regexValue, xpath: xpathValue, jsonpath: jsonpathValue },
        keyValues = fnMap[lookupConfig.key.using.method](from, lookupConfig.key, logger),
        index = lookupConfig.key.index || 0;

    if (lookupConfig.fromDataSource.csv) {
        return selectRowFromCSV(lookupConfig.fromDataSource.csv, keyValues[index], logger);
    }
    else {
        return Promise.resolve({});
    }
}

function replaceObjectValuesIn (response, token, values, logger) {
    const replacer = field => {
        Object.keys(values).forEach(key => {
            // replace ${TOKEN}["key"] and ${TOKEN}['key'] and ${TOKEN}[key]
            ['"', "'", ''].forEach(function (quoteChar) {
                const quoted = `${token}[${quoteChar}${key}${quoteChar}]`;
                field = globalStringReplace(field, quoted, values[key], logger);
            });
        });
        return field;
    };

    globalObjectReplace(response, replacer);
}


/**
 * Looks up request values from a data source and replaces response tokens with the resulting data
 * @param {Object} originalRequest - The request object
 * @param {Object} response - The response
 * @param {Function} lookupConfig - The lookup configurations
 * @param {Object} logger - The mountebank logger, useful in debugging
 * @returns {Object}
 */
async function lookup (originalRequest, response, lookupConfig, logger) {
    try {
        const row = await lookupRow(lookupConfig, originalRequest, logger);
        replaceObjectValuesIn(response, lookupConfig.into, row, logger);
    }
    catch (error) {
        logger.error(error);
    }
    return response;
}

/**
 * The entry point to execute all behaviors provided in the API
 * @param {Object} request - The request object
 * @param {Object} response - The response generated from the stubs
 * @param {Object} behaviors - The behaviors specified in the API
 * @param {Object} logger - The mountebank logger, useful for debugging
 * @param {Object} imposterState - the user-controlled state variable
 * @returns {Object}
 */
async function execute (request, response, behaviors, logger, imposterState) {
    const fnMap = {
        wait: wait,
        copy: copy,
        lookup: lookup,
        shellTransform: shellTransform,
        decorate: decorate
    };
    let result = Promise.resolve(response);

    if (!behaviors || behaviors.length === 0 || request.isDryRun) {
        return result;
    }

    logger.debug('using stub response behavior ' + JSON.stringify(behaviors));
    behaviors.forEach(behavior => {
        Object.keys(behavior).forEach(key => {
            if (fnMap[key]) {
                result = result.then(newResponse => fnMap[key](request, newResponse, behavior[key], logger, imposterState));
            }
        });
    });

    const observeBehaviorDuration = metrics.behaviorDuration.startTimer(),
        transformed = await result;
    observeBehaviorDuration({ imposter: logger.scopePrefix });
    return transformed;
}

module.exports = {
    validate,
    execute
};