Source: models/imposter.js

'use strict';

/**
 * An imposter represents a protocol listening on a socket.  Most imposter
 * functionality is in each particular protocol implementation.  This module
 * exists as a bridge between the API and the protocol, mapping back to pretty
 * JSON for the end user.
 * @module
 */

const prometheus = require('prom-client'),
    compatibility = require('./compatibility.js'),
    scopedLogger = require('../util/scopedLogger.js'),
    helpers = require('../util/helpers.js'),
    predicates = require('./predicates.js'),
    imposterPrinter = require('./imposterPrinter.js'),
    cachedObjects = {};

/**
 * Create the imposter
 * @param {Object} Protocol - The protocol factory for creating servers of that protocol
 * @param {Object} creationRequest - the parsed imposter JSON
 * @param {Object} baseLogger - the logger
 * @param {Object} config - command line options
 * @param {Function} isAllowedConnection - function to determine if the IP address of the requestor is allowed
 * @returns {Object}
 */
async function create (Protocol, creationRequest, baseLogger, config, isAllowedConnection) {

    const metrics = getMetrics();

    function scopeFor (port) {
        let scope = `${creationRequest.protocol}:${port}`;

        if (creationRequest.name) {
            scope += ' ' + creationRequest.name;
        }
        return scope;
    }

    const logger = scopedLogger.create(baseLogger, scopeFor(creationRequest.port)),
        imposterState = {},
        unresolvedProxies = {},
        header = helpers.clone(creationRequest);

    // Free up the memory by allowing garbage collection of stubs when using filesystemBackedImpostersRepository
    delete header.stubs;

    let stubs;
    let resolver;
    let encoding;
    let numberOfRequests = 0;

    compatibility.upcast(creationRequest);

    // If the CLI --mock flag is passed, we record even if the imposter level recordRequests = false
    const recordRequests = config.recordRequests || creationRequest.recordRequests;

    async function findFirstMatch (request) {
        const filter = stubPredicates => {
                return stubPredicates.every(predicate =>
                    predicates.evaluate(predicate, request, encoding, logger, imposterState));
            },
            observePredicateMatchDuration = metrics.predicateMatchDuration.startTimer(),
            match = await stubs.first(filter);

        observePredicateMatchDuration({ imposter: logger.scopePrefix });
        if (match.success) {
            logger.debug(`using predicate match: ${JSON.stringify(match.stub.predicates || {})}`);
        }
        else {
            metrics.noMatchCount.inc({ imposter: logger.scopePrefix });
            logger.info('no predicate match, using default response');
        }
        return match;
    }

    async function recordMatch (stub, request, response, responseConfig, start) {
        if (response.proxy) {
            // Out of process proxying, so we don't have the actual response yet.
            const parts = response.callbackURL.split('/'),
                proxyResolutionKey = parts[parts.length - 1];

            unresolvedProxies[proxyResolutionKey] = {
                recordMatch: proxyResponse => {
                    return stub.recordMatch(request, proxyResponse, responseConfig, new Date() - start);
                }
            };
        }
        else if (response.response) {
            // Out of process responses wrap the result in an outer response object
            await stub.recordMatch(request, response.response, responseConfig, new Date() - start);
        }
        else {
            // In process resolution
            await stub.recordMatch(request, response, responseConfig, new Date() - start);
        }
    }

    // requestDetails are not stored with the imposter
    // It was created to pass the raw URL to maintain the exact querystring during http proxying
    // without having to change the path / query options on the stored request
    async function getResponseFor (request, requestDetails) {
        if (!isAllowedConnection(request.ip, logger)) {
            metrics.blockedIPCount.inc({ imposter: logger.scopePrefix });
            return { blocked: true, code: 'unauthorized ip address' };
        }

        const start = new Date();

        metrics.requestCount.inc({ imposter: logger.scopePrefix });
        numberOfRequests += 1;
        if (recordRequests) {
            await stubs.addRequest(request);
        }

        const match = await findFirstMatch(request),
            observeResponseGenerationDuration = metrics.responseGenerationDuration.startTimer(),
            responseConfig = await match.stub.nextResponse();

        logger.debug(`generating response from ${JSON.stringify(responseConfig)}`);
        const response = await resolver.resolve(responseConfig, request, logger, imposterState, requestDetails);
        observeResponseGenerationDuration({ imposter: logger.scopePrefix });

        if (config.recordMatches) {
            await recordMatch(match.stub, request, response, responseConfig, start);
        }
        return response;
    }

    async function getProxyResponseFor (proxyResponse, proxyResolutionKey) {
        const response = await resolver.resolveProxy(proxyResponse, proxyResolutionKey, logger, imposterState);
        if (config.recordMatches && unresolvedProxies[String(proxyResolutionKey)].recordMatch) {
            await unresolvedProxies[String(proxyResolutionKey)].recordMatch(response);
        }
        delete unresolvedProxies[String(proxyResolutionKey)];
        return response;
    }

    async function resetRequests () {
        await stubs.deleteSavedRequests();
        numberOfRequests = 0;
    }

    function getMetrics () {
        return metricsAlreadyCreated() ? getCreatedMetrics() : createImposterMetrics();
    }

    function metricsAlreadyCreated () {
        return Boolean(cachedObjects.metrics);
    }
    function getCreatedMetrics () {
        return cachedObjects.metrics;
    }

    function createImposterMetrics () {
        const impostersMetrics = {
            predicateMatchDuration: new prometheus.Histogram({
                name: 'mb_predicate_match_duration_seconds',
                help: 'Time it takes to match the predicates and select a stub',
                buckets: [0.01, 0.05, 0.1, 0.2, 0.5, 1],
                labelNames: ['imposter']
            }),
            noMatchCount: new prometheus.Counter({
                name: 'mb_no_match_total',
                help: 'Number of times no stub matched the request',
                labelNames: ['imposter']
            }),
            requestCount: new prometheus.Counter({
                name: 'mb_request_total',
                help: 'Number of requests to the imposter',
                labelNames: ['imposter']
            }),
            responseGenerationDuration: new prometheus.Histogram({
                name: 'mb_response_generation_duration_seconds',
                help: 'Time it takes to generate the response from a stub',
                buckets: [0.01, 0.05, 0.1, 0.2, 0.5, 1, 3, 5, 10, 30],
                labelNames: ['imposter']
            }),
            blockedIPCount: new prometheus.Counter({
                name: 'mb_blocked_ip_total',
                help: 'Number of times a connection was blocked from a non-whitelisted IP address',
                labelNames: ['imposter']
            })
        };

        cachedObjects.metrics = impostersMetrics;

        return cachedObjects.metrics;
    }

    return new Promise((resolve, reject) => {
        try {
            if (!helpers.defined(creationRequest.host) && helpers.defined(config.host)) {
                creationRequest.host = config.host;
            }

            Protocol.createServer(creationRequest, logger, getResponseFor).then(server => {
                if (creationRequest.port !== server.port) {
                    creationRequest.port = server.port;
                    logger.changeScope(scopeFor(server.port));
                }
                logger.info('Open for business...');

                stubs = server.stubs;
                resolver = server.resolver;
                encoding = server.encoding;

                function stop () {
                    return new Promise(closed => {
                        server.close(() => {
                            logger.info('Ciao for now');
                            closed();
                        });
                    });
                }

                async function loadRequests () {
                    return recordRequests ? stubs.loadRequests() : [];
                }

                const printer = imposterPrinter.create(header, server, loadRequests),
                    toJSON = options => printer.toJSON(numberOfRequests, options);

                return resolve({
                    port: server.port,
                    url: '/imposters/' + server.port,
                    creationRequest: creationRequest,
                    toJSON,
                    stop,
                    getResponseFor,
                    getProxyResponseFor,
                    resetRequests
                });
            }, reject);
        }
        catch (error) {
            reject(error);
        }
    });
}

module.exports = { create };