apimocker
Version:
Simple HTTP server that returns mock service API responses to your front end.
713 lines (618 loc) • 23.2 kB
JavaScript
/* eslint-disable no-prototype-builtins, comma-dangle */
const express = require('express');
const _ = require('underscore');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
const xmlparser = require('express-xml-bodyparser');
const jp = require('jsonpath');
const untildify = require('untildify');
const util = require('util');
const proxy = require('express-http-proxy');
const multer = require('multer');
const crypto = require('crypto');
const { createLogger, createMiddleware } = require('./logger');
const apiMocker = {};
apiMocker.defaults = {
port: '8888',
mockDirectory: './mocks/',
allowedDomains: ['*'],
allowedHeaders: ['Content-Type'],
logRequestHeaders: false,
allowAvoidPreFlight: false,
useUploadFieldname: false,
webServices: {}
};
apiMocker.createServer = (options = {}) => {
apiMocker.options = Object.assign({}, apiMocker.defaults, options);
const { quiet } = apiMocker.options;
const logger = createLogger({ quiet });
const loggerMiddleware = createMiddleware({ quiet });
apiMocker.express = express();
apiMocker.middlewares = [];
apiMocker.middlewares.push(loggerMiddleware);
if (options.uploadRoot) {
apiMocker.middlewares.push(
multer({
storage: multer.diskStorage({
destination: untildify(options.uploadRoot),
filename: options.useUploadFieldname
? (req, filename, cb) => {
cb(null, filename.fieldname);
}
: (req, filename, cb) => {
cb(null, filename.originalname);
}
})
}).any()
);
}
let saveBody;
if (options.proxyURL || options.allowAvoidPreFlight) {
saveBody = (req, res, buf) => {
req.rawBody = buf;
};
}
apiMocker.middlewares.push(
bodyParser.urlencoded({
extended: true,
verify: saveBody
})
);
if (options.allowAvoidPreFlight) {
apiMocker.middlewares.push(
bodyParser.json({
strict: false,
verify: saveBody,
type: '*/*'
})
);
} else {
apiMocker.middlewares.push(
bodyParser.json({
verify: saveBody
})
);
}
apiMocker.middlewares.push(xmlparser());
apiMocker.middlewares.push(apiMocker.corsMiddleware);
// new in Express 4, we use a Router now.
apiMocker.router = express.Router();
apiMocker.middlewares.push(apiMocker.router);
if (options.proxyURL) {
logger.info(`Proxying to ${options.proxyURL}`);
const proxyOptions = {
proxyReqPathResolver(req) {
logger.info(`Forwarding request: ${req.originalUrl}`);
return req.originalUrl;
}
};
if (options.proxyIntercept) {
const interceptPath = path.join(process.cwd(), options.proxyIntercept);
logger.info(`Loading proxy intercept from ${interceptPath}`);
// eslint-disable-next-line global-require, import/no-dynamic-require
proxyOptions.intercept = require(interceptPath);
}
apiMocker.middlewares.push((req, res, next) => {
if (req.rawBody) {
req.body = req.rawBody;
}
next();
});
apiMocker.middlewares.push(proxy(options.proxyURL, proxyOptions));
}
apiMocker.logger = logger;
return apiMocker;
};
apiMocker.setConfigFile = (file) => {
if (!file) {
return apiMocker;
}
if (!file.startsWith(path.sep)) {
// relative path from command line
apiMocker.configFilePath = path.resolve(process.cwd(), file);
} else {
apiMocker.configFilePath = file;
}
return apiMocker;
};
apiMocker.loadConfigFile = () => {
if (!apiMocker.configFilePath) {
apiMocker.logger.warn('No config file path set.');
return;
}
apiMocker.logger.info(`Loading config file: ${apiMocker.configFilePath}`);
let newOptions = _.clone(apiMocker.defaults);
// eslint-disable-next-line global-require, import/no-dynamic-require
const exportedValue = require(apiMocker.configFilePath);
const config = typeof exportedValue === 'function' ? exportedValue() : exportedValue;
if (process.env.VCAP_APP_PORT) {
// we're running in cloudfoundry, and we need to use the VCAP port.
config.port = process.env.VCAP_APP_PORT;
}
newOptions = _.extend(newOptions, apiMocker.options, config);
newOptions.mockDirectory = untildify(newOptions.mockDirectory);
if (newOptions.mockDirectory === '/file/system/path/to/apimocker/samplemocks/') {
newOptions.mockDirectory = path.join(__dirname, '/../samplemocks');
apiMocker.logger.info('Set mockDirectory to: ', newOptions.mockDirectory);
}
apiMocker.options = newOptions;
_.each(apiMocker.options.webServices, (svc) => {
_.each(svc.alternatePaths, (altPath) => {
const altSvc = _.clone(svc);
apiMocker.options.webServices[altPath] = altSvc;
});
});
apiMocker.setRoutes(apiMocker.options.webServices);
};
apiMocker.createAdminServices = () => {
apiMocker.router.all('/admin/reload', (req, res) => {
apiMocker.stop();
apiMocker.createServer(apiMocker.options).start();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(`{"configFilePath": "${apiMocker.configFilePath}", "reloaded": "true"}`);
});
apiMocker.router.all('/admin/setMock', (req, res) => {
let newRoute = {};
if (req.body.serviceUrl && req.body.verb && req.body.mockFile) {
apiMocker.logger.info(`Received JSON request: ${JSON.stringify(req.body)}`);
newRoute = req.body;
newRoute.verb = newRoute.verb.toLowerCase();
newRoute.httpStatus = req.body.httpStatus;
} else {
newRoute.verb = req.param('verb').toLowerCase();
newRoute.serviceUrl = req.param('serviceUrl');
newRoute.mockFile = req.param('mockFile');
newRoute.latency = req.param('latency');
newRoute.contentType = req.param('contentType');
newRoute.httpStatus = req.param('httpStatus');
}
// also need to save in our webServices object.
delete apiMocker.options.webServices[newRoute.serviceUrl];
apiMocker.options.webServices[newRoute.serviceUrl] = newRoute;
apiMocker.setRoute(newRoute);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(newRoute));
});
};
apiMocker.setRoutes = (webServices) => {
const topLevelKeys = _.keys(webServices);
_.each(topLevelKeys, (key) => {
const svc = _.clone(webServices[key]);
// apiMocker.logger.info('about to add a new service: ' + JSON.stringify(svc));
_.each(svc.verbs, (v) => {
apiMocker.setRoute(apiMocker.getServiceRoute(key, v));
});
});
};
apiMocker.getServiceRoute = (routePath, verb) => {
let finalSvc = _.clone(apiMocker.options.webServices[routePath]);
finalSvc.verb = verb.toLowerCase();
finalSvc.serviceUrl = routePath;
if (finalSvc.responses) {
finalSvc = _.extend(finalSvc, finalSvc.responses[verb]);
}
if (typeof finalSvc.latency === 'undefined') {
finalSvc.latency = apiMocker.options.latency ? apiMocker.options.latency : 0;
}
delete finalSvc.responses;
delete finalSvc.verbs;
return finalSvc;
};
// Fills in templated Values.
apiMocker.fillTemplate = (data, req) => {
let filled = data.toString();
Object.keys(req.params).forEach((key) => {
// Handle unquoted numbers first
// Search for '"@@key"' in JSON template,
// replace with value (no double quotes around final value)
filled = filled.replace(new RegExp(`"@@${key}"`, 'g'), req.params[key]);
// Handle quoted values second
// Search for '@key' in JSON template, replace with value
filled = filled.replace(new RegExp(`@${key}`, 'g'), req.params[key]);
});
return filled;
};
apiMocker.fillTemplateSwitch = (options, data) => {
const switches = options.templateSwitch;
let filled = data.toString();
switches.forEach((s) => {
let key;
let value;
if (!(s instanceof Object)) {
({ key, value } = switches[s]);
} else {
({ key, value } = s);
}
if (value !== null) {
// Handle unquoted numbers first
// Search for '"@@key"' in JSON template,
// replace with value (no double quotes around final value)
apiMocker.logger.info(`fillTemplateSwitch -> search for "@@${key}" replace with ${value}`);
filled = filled.replace(new RegExp(`"@@${key}"`, 'g'), value);
// Handle quoted values second
// Search for '@key' in JSON template, replace with value
apiMocker.logger.info(`fillTemplateSwitch -> search for @${key} replace with ${value}`);
filled = filled.replace(new RegExp(`@${key}`, 'g'), value);
} else {
apiMocker.logger.info(`fillTemplateSwitch -> skipping search for @${key} with no value.`);
}
});
return filled;
};
apiMocker.processTemplateData = (data, options, req, res) => {
let templatedData;
if (options.templateSwitch) {
templatedData = apiMocker.fillTemplateSwitch(options, data, req);
}
if (options.enableTemplate === true) {
templatedData = apiMocker.fillTemplate(data, req);
}
const buff = Buffer.from(templatedData || data, 'utf8');
res.status(options.httpStatus || 200).send(buff);
};
apiMocker.sendResponse = (req, res, serviceKeys) => {
let originalOptions;
let mockPath;
// we want to look up the service info from our in-memory 'webServices' every time.
let options = apiMocker.getServiceRoute(serviceKeys.serviceUrl, serviceKeys.verb);
setTimeout(() => {
if (options.httpStatus === 204 || options.httpStatus === 304) {
// express handles these two differently - it strips out body, content-type,
// and content-length headers.
// There's no body or content-length, so we just send the status code.
res.sendStatus(options.httpStatus);
return;
}
// Filter whether the raw body is what we're expecting, if such filter is provided.
if (!!options.bodies && !!options.bodies[req.method.toLowerCase()]) {
if (
// eslint-disable-next-line max-len
!_.find(options.bodies[req.method.toLowerCase()], filterDef => apiMocker.compareHashed(filterDef, req.rawBody || JSON.stringify(req.body)))
) {
res.status(404).send();
return;
}
}
if (options.switch && !options.jsonPathSwitchResponse) {
options = _.clone(options);
originalOptions = _.clone(options);
apiMocker.setSwitchOptions(options, req);
mockPath = path.join(apiMocker.options.mockDirectory, options.mockFile || '');
if (!fs.existsSync(mockPath)) {
apiMocker.logger.warn(
`No file found: ${options.mockFile} attempting base file: ${originalOptions.mockFile}`
);
options.mockFile = originalOptions.mockFile;
}
}
if (options.templateSwitch) {
apiMocker.setTemplateSwitchOptions(options, req);
}
if (apiMocker.options.logRequestHeaders || options.logRequestHeaders) {
apiMocker.logger.info('Request headers:');
apiMocker.logger.info(req.headers);
}
if (options.headers) {
res.set(options.headers);
}
if (options.mockBody) {
if (options.contentType) {
res.set('Content-Type', options.contentType);
}
apiMocker.processTemplateData(options.mockBody, options, req, res);
return;
}
if (!options.mockFile) {
const status = options.httpStatus || 404;
res.status(status).send();
return;
}
// Add mockFile name for logging
res.locals.mockFile = options.mockFile;
if (options.switch && options.jsonPathSwitchResponse) {
let jpath = options.jsonPathSwitchResponse.jsonpath;
const fpath = path.join(
apiMocker.options.mockDirectory,
options.jsonPathSwitchResponse.mockFile
);
const forceFirstObject = options.jsonPathSwitchResponse.forceFirstObject || false;
_.each(_.keys(req.params), (key) => {
const param = '#key#'.replace('key', key);
jpath = jpath.replace(param, req.params[key]);
});
try {
const mockFile = fs.readFileSync(fpath, { encoding: 'utf8' });
const allElems = jp.query(JSON.parse(mockFile), jpath);
res.status(options.httpStatus || 200).send(forceFirstObject ? allElems[0] : allElems);
} catch (err) {
apiMocker.logger.error(err);
res.sendStatus(options.httpStatus || 404);
}
return;
}
mockPath = path.join(apiMocker.options.mockDirectory, options.mockFile);
fs.exists(mockPath, (exists) => {
if (exists) {
if (options.contentType) {
res.set('Content-Type', options.contentType);
fs.readFile(mockPath, { encoding: 'utf8' }, (err, data) => {
if (err) {
throw err;
}
apiMocker.processTemplateData(data.toString(), options, req, res);
});
} else {
res
.status(options.httpStatus || 200)
.sendFile(options.mockFile, { root: apiMocker.options.mockDirectory });
}
} else {
res.sendStatus(options.httpStatus || 404);
}
});
}, options.latency);
};
// Utility function to get a key's value from json body, route param, querystring, or header.
const getRequestParam = (req, key) => {
const rawParamValue = req.body[key] || req.params[key] || req.query[key] || req.header(key);
return rawParamValue;
};
// only used when there is a switch configured
apiMocker.setSwitchOptions = (options, req) => {
let switchFilePrefix = '';
let switchParamValue;
let mockFileParts;
let mockFilePrefix = '';
let mockFileBaseName;
let switches = options.switch;
if (!(switches instanceof Array)) {
switches = [switches];
}
switches.forEach((s) => {
switchParamValue = null;
let switchObject = s;
let specific = true;
if (!(s instanceof Object)) {
// The user didn't configure a switch object. Make one.
switchObject = {
key: s,
switch: s,
type: 'default'
};
if (s.match(/\/(.+)\//)) {
switchObject.type = 'regexp';
} else if (s.indexOf('$') === 0) {
switchObject.type = 'jsonpath';
}
// As we had no switch object, we have to test default-type first to
// mimic the old behaviour.
specific = false;
}
if (!switchObject.hasOwnProperty('key')) {
// Add key if the user was too lazy
switchObject.key = switchObject.switch;
}
// Sanity check the switchobject
if (
!switchObject.hasOwnProperty('switch')
|| !switchObject.hasOwnProperty('type')
|| !switchObject.hasOwnProperty('key')
) {
return;
}
if (!specific || switchObject.type === 'default') {
const rawParamValue = getRequestParam(req, switchObject.switch);
if (rawParamValue) {
switchParamValue = encodeURIComponent(rawParamValue);
}
}
if (!switchParamValue) {
if (switchObject.type === 'regexp') {
const regexpTest = switchObject.switch.match(/\/(.+)\//);
if (regexpTest) {
// A regexp switch
let searchBody = req.body;
if (typeof req.body !== 'string') {
// We don't have a body string, parse it in JSON
searchBody = JSON.stringify(req.body);
}
const regexpSwitch = new RegExp(regexpTest[1]).exec(searchBody);
if (regexpSwitch) {
// Value is the first group
switchParamValue = encodeURIComponent(regexpSwitch[1]);
}
}
} else {
// use JsonPath - use first value found if multiple occurances exist
const allElems = jp.query(req.body, switchObject.switch);
if (allElems.length > 0) {
switchParamValue = encodeURIComponent(allElems[0]);
}
}
}
if (switchParamValue) {
switchFilePrefix = switchFilePrefix + switchObject.key + switchParamValue;
}
});
if (!switchFilePrefix) {
return;
}
if (options.switchResponses && options.switchResponses[switchFilePrefix]) {
_.extend(options, options.switchResponses[switchFilePrefix]);
if (options.switchResponses[switchFilePrefix].mockFile) {
return;
}
}
if (options.mockFile) {
mockFileParts = options.mockFile.split('/');
mockFileBaseName = mockFileParts.pop();
if (mockFileParts.length > 0) {
mockFilePrefix = `${mockFileParts.join('/')}/`;
}
// eslint-disable-next-line no-param-reassign
options.mockFile = `${mockFilePrefix + switchFilePrefix}.${mockFileBaseName}`;
}
};
// only used when there is a templateSwitch configured
apiMocker.setTemplateSwitchOptions = (options, req) => {
let switchParamValue;
let switches = options.templateSwitch;
if (!(switches instanceof Array)) {
switches = [switches];
}
switches.forEach((s) => {
switchParamValue = null;
let switchObject = s;
let specific = true;
if (!(s instanceof Object)) {
// The user didn't configure a switch object. Make one.
switchObject = {
key: s,
switch: s,
type: 'default',
value: null
};
if (s.match(/\/(.+)\//)) {
switchObject.type = 'regexp';
} else if (s.indexOf('$') === 0) {
switchObject.type = 'jsonpath';
}
// As we had no switch object, we have to test default-type first to
// mimic the old behaviour.
specific = false;
}
if (!switchObject.hasOwnProperty('key')) {
// Add key if the user was too lazy
switchObject.key = switchObject.switch;
}
// Sanity check the switchobject
if (
!switchObject.hasOwnProperty('switch')
|| !switchObject.hasOwnProperty('type')
|| !switchObject.hasOwnProperty('key')
) {
apiMocker.logger.info(
'templateSwitch invalid config: missing switch, type or key property. Aborting templateSwitch for this request.'
);
return;
}
if (!specific || switchObject.type === 'default') {
const rawParamValue = getRequestParam(req, switchObject.switch);
if (rawParamValue) {
switchParamValue = encodeURIComponent(rawParamValue);
}
}
if (!switchParamValue) {
if (switchObject.type === 'regexp') {
const regexpTest = switchObject.switch.match(/\/(.+)\//);
if (regexpTest) {
// A regexp switch
let searchBody = req.body;
if (typeof req.body !== 'string') {
// We don't have a body string, parse it in JSON
searchBody = JSON.stringify(req.body);
}
const regexpSwitch = new RegExp(regexpTest[1]).exec(searchBody);
if (regexpSwitch) {
// Value is the first group
switchParamValue = encodeURIComponent(regexpSwitch[1]);
}
}
} else {
// use JsonPath - use first value found if multiple occurances exist
const allElems = jp.query(req.body, switchObject.switch);
if (allElems.length > 0) {
switchParamValue = encodeURIComponent(allElems[0]);
}
}
}
if (switchParamValue) {
switchObject.value = switchParamValue;
// eslint-disable-next-line no-param-reassign
options.templateSwitch[s] = switchObject;
} else {
apiMocker.logger.warn(`templateSwitch[${switchObject.switch}] value NOT FOUND`);
}
});
};
// Sets the route for express, in case it was not set yet.
apiMocker.setRoute = (options) => {
const displayFile = options.mockFile || '<no mockFile>';
const displayLatency = options.latency ? `${options.latency} ms` : '';
apiMocker.router[options.verb](`/${options.serviceUrl}`, (req, res) => {
apiMocker.sendResponse(req, res, options);
});
apiMocker.logger.info(
`Set route: ${options.verb.toUpperCase()} ${
options.serviceUrl
} : ${displayFile} ${displayLatency}`
);
if (options.switch) {
let switchDescription = options.switch;
if (options.switch instanceof Array || options.switch instanceof Object) {
switchDescription = util.inspect(options.switch);
}
apiMocker.logger.info(` with switch on param: ${switchDescription}`);
}
};
// CORS middleware
apiMocker.corsMiddleware = (req, res, next) => {
const allowedHeaders = apiMocker.options.allowedHeaders.join(',');
const credentials = apiMocker.options.corsCredentials || '';
res.set('Access-Control-Allow-Origin', apiMocker.options.allowedDomains);
res.set('Access-Control-Allow-Methods', 'GET,PUT,POST,PATCH,DELETE');
res.set('Access-Control-Allow-Headers', allowedHeaders);
res.set('Access-Control-Allow-Credentials', credentials);
next();
};
apiMocker.compareHashed = (filterDef, body) => {
if (!(filterDef instanceof Object)) {
// eslint-disable-next-line eqeqeq
return filterDef == body;
}
const algo = _.keys(filterDef)[0];
const hasher = crypto.createHash(algo);
hasher.update(body);
const digest = hasher.digest('hex');
apiMocker.logger.warn(`Body hash ${algo}: ${digest}`);
// eslint-disable-next-line eqeqeq
return digest.toLowerCase() == filterDef[algo].toLowerCase();
};
apiMocker.start = (serverPort, callback) => {
apiMocker.createAdminServices();
apiMocker.loadConfigFile();
apiMocker.middlewares.forEach((mw) => {
if (mw === apiMocker.router && apiMocker.options.basepath) {
apiMocker.logger.info('Using basepath: ', apiMocker.options.basepath);
apiMocker.express.use(apiMocker.options.basepath, mw);
} else {
apiMocker.express.use(mw);
}
});
const port = serverPort || apiMocker.options.port;
if (apiMocker.options.staticDirectory && apiMocker.options.staticPath) {
apiMocker.express.use(
apiMocker.options.staticPath,
express.static(apiMocker.options.staticDirectory)
);
}
apiMocker.expressInstance = apiMocker.express.listen(port, callback);
apiMocker.logger.info(`Mock server listening on port ${port}`);
return apiMocker;
};
apiMocker.stop = (callback) => {
// Invalidate cached config between uses to allow it to be reconstructed.
delete require.cache[require.resolve(apiMocker.configFilePath)];
if (apiMocker.expressInstance) {
apiMocker.logger.info('Stopping mock server.');
apiMocker.expressInstance.close(callback);
}
return apiMocker;
};
// expose all the 'public' methods.
exports.createServer = apiMocker.createServer;
exports.start = apiMocker.start;
exports.setConfigFile = apiMocker.setConfigFile;
exports.stop = apiMocker.stop;
exports.middlewares = apiMocker.middlewares;