Source: models/protocols.js

'use strict';

const responseResolver = require('./responseResolver'),
    childProcess = require('child_process'),
    fsExtra = require('fs-extra'),
    path = require('path'),
    errors = require('../util/errors.js'),
    Imposter = require('./imposter.js'),
    helpers = require('../util/helpers.js'),
    tcpServer = require('./tcp/tcpServer.js'),
    httpServer = require('./http/httpServer.js'),
    httpsServer = require('./https/httpsServer.js'),
    smtpServer = require('./smtp/smtpServer.js');

/**
 * Abstracts the protocol configuration between the built-in in-memory implementations and out of process
 * implementations
 * @module
 */

/**
 * Loads the imposter creation functions for all built in and custom protocols
 * @param builtInProtocols {Object} - the in-memory protocol implementations that ship with mountebank
 * @param customProtocols {Object} - custom out-of-process protocol implementations
 * @param options {Object} - command line configuration
 * @param isAllowedConnection {Function} - a function that determines whether the connection is allowed or not for security verification
 * @param mbLogger {Object} - the logger
 * @param impostersRepository {Object} - the imposters repository
 * @returns {Object} - a map of protocol name to creation functions
 */
// eslint-disable-next-line max-params
function load (builtInProtocols, customProtocols, options, isAllowedConnection, mbLogger, impostersRepository) {
    function inProcessCreate (createProtocol) {
        return async (creationRequest, logger, responseFn) => {
            const server = await createProtocol(creationRequest, logger, responseFn),
                stubs = impostersRepository.stubsFor(server.port),
                resolver = responseResolver.create(stubs, server.proxy);

            return {
                port: server.port,
                metadata: server.metadata,
                stubs: stubs,
                resolver: resolver,
                close: server.close,
                encoding: server.encoding || 'utf8'
            };
        };
    }

    function outOfProcessCreate (protocolName, config) {
        function customFieldsFor (creationRequest) {
            const fields = {},
                commonFields = ['protocol', 'port', 'name', 'recordRequests', 'stubs', 'defaultResponse'];
            Object.keys(creationRequest).forEach(key => {
                if (commonFields.indexOf(key) < 0) {
                    fields[key] = creationRequest[key];
                }
            });
            return fields;
        }

        return (creationRequest, logger) => new Promise((res, rej) => {
            let isPending = true;
            const { spawn } = childProcess,
                command = config.createCommand.split(' ')[0],
                args = config.createCommand.split(' ').splice(1),
                port = creationRequest.port,
                commonArgs = {
                    port,
                    callbackURLTemplate: options.callbackURLTemplate,
                    loglevel: options.loglevel,
                    allowInjection: options.allowInjection
                },
                configArgs = helpers.merge(commonArgs, customFieldsFor(creationRequest)),
                resolve = obj => {
                    isPending = false;
                    res(obj);
                },
                reject = err => {
                    isPending = false;
                    rej(err);
                };

            if (typeof creationRequest.defaultResponse !== 'undefined') {
                configArgs.defaultResponse = creationRequest.defaultResponse;
            }

            const allArgs = args.concat(JSON.stringify(configArgs)),
                imposterProcess = spawn(command, allArgs);

            let closeCalled = false;

            imposterProcess.on('error', error => {
                const message = `Invalid configuration for protocol "${protocolName}": cannot run "${config.createCommand}"`;

                reject(errors.ProtocolError(message,
                    { source: config.createCommand, details: error }));
            });

            imposterProcess.once('exit', code => {
                if (code !== 0 && isPending) {
                    const message = `"${protocolName}" start command failed (exit code ${code})`;

                    reject(errors.ProtocolError(message, { source: config.createCommand }));
                }
                else if (!closeCalled) {
                    logger.error("Uh oh! I've crashed! Expect subsequent requests to fail.");
                }
            });

            function resolveWithMetadata (possibleJSON) {
                let metadata = {};

                try {
                    metadata = JSON.parse(possibleJSON);
                }
                catch (error) { /* do nothing */ }

                let serverPort = creationRequest.port;
                if (metadata.port) {
                    serverPort = metadata.port;
                    delete metadata.port;
                }
                const callbackURL = options.callbackURLTemplate.replace(':port', serverPort),
                    encoding = metadata.encoding || 'utf8',
                    stubs = impostersRepository.stubsFor(serverPort),
                    resolver = responseResolver.create(stubs, undefined, callbackURL);

                delete metadata.encoding;

                resolve({
                    port: serverPort,
                    metadata: metadata,
                    stubs,
                    resolver,
                    encoding,
                    close: callback => {
                        closeCalled = true;
                        imposterProcess.once('exit', callback);
                        imposterProcess.kill();
                    }
                });
            }

            function log (message) {
                if (message.indexOf(' ') > 0) {
                    const words = message.split(' '),
                        level = words[0],
                        rest = words.splice(1).join(' ').trim();
                    if (['debug', 'info', 'warn', 'error'].indexOf(level) >= 0) {
                        logger[level](rest);
                    }
                }
            }

            imposterProcess.stdout.on('data', data => {
                const lines = data.toString('utf8').trim().split(/\r?\n/);
                lines.forEach(line => {
                    if (isPending) {
                        resolveWithMetadata(line);
                    }
                    log(line);
                });
            });

            imposterProcess.stderr.on('data', logger.error);
        });
    }

    function createImposter (Protocol, creationRequest) {
        return Imposter.create(Protocol, creationRequest, mbLogger.baseLogger, options, isAllowedConnection);
    }

    const result = {};
    Object.keys(builtInProtocols).forEach(key => {
        result[key] = builtInProtocols[key];
        result[key].createServer = inProcessCreate(result[key].create);
        result[key].createImposterFrom = creationRequest => createImposter(result[key], creationRequest);
    });
    Object.keys(customProtocols).forEach(key => {
        result[key] = customProtocols[key];
        result[key].createServer = outOfProcessCreate(key, result[key]);
        result[key].createImposterFrom = creationRequest => createImposter(result[key], creationRequest);
    });
    return result;
}

function isBuiltInProtocol (protocol) {
    return ['tcp', 'smtp', 'http', 'https'].indexOf(protocol) >= 0;
}

function loadCustomProtocols (protofile, logger) {
    if (typeof protofile === 'undefined') {
        return {};
    }

    const filename = path.resolve(path.relative(process.cwd(), protofile));

    if (fsExtra.existsSync(filename)) {
        try {
            const customProtocols = require(filename);
            Object.keys(customProtocols).forEach(proto => {
                if (isBuiltInProtocol(proto)) {
                    logger.warn(`Using custom ${proto} implementation instead of the built-in one`);
                }
                else {
                    logger.info(`Loaded custom protocol ${proto}`);
                }
            });
            return customProtocols;
        }
        catch (e) {
            logger.error(`${protofile} contains invalid JSON -- no custom protocols loaded`);
            return {};
        }
    }
    else {
        return {};
    }
}

function loadProtocols (options, baseURL, logger, isAllowedConnection, imposters) {
    const builtInProtocols = {
            tcp: tcpServer,
            http: httpServer,
            https: httpsServer,
            smtp: smtpServer
        },
        customProtocols = loadCustomProtocols(options.protofile, logger),
        config = {
            callbackURLTemplate: `${baseURL}/imposters/:port/_requests`,
            recordRequests: options.mock,
            recordMatches: options.debug,
            loglevel: options.log.level,
            allowInjection: options.allowInjection,
            host: options.host
        };

    return load(builtInProtocols, customProtocols, config, isAllowedConnection, logger, imposters);
}

module.exports = { load, loadProtocols };