UNPKG

@luminati-io/mountebank

Version:

Over the wire test doubles

244 lines (207 loc) 9.35 kB
'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 };