'use strict';
const helpers = require('../util/helpers.js'),
errors = require('./errors.js');
/**
* Express middleware functions to inject into the HTTP processing
* @module
*/
/**
* Returns a middleware function to transforms all outgoing relative links in the response body
* to absolute URLs, incorporating the current host name and port
* @param {number} port - The port of the current instance
* @returns {Function}
*/
function useAbsoluteUrls (port) {
return function (request, response, next) {
const setHeaderOriginal = response.setHeader,
sendOriginal = response.send,
host = request.headers.host || `localhost:${port}`,
absolutize = link => `http://${host}${link}`,
isObject = helpers.isObject;
response.setHeader = function () {
const args = Array.prototype.slice.call(arguments);
if (args[0] && args[0].toLowerCase() === 'location') {
args[1] = absolutize(args[1]);
}
setHeaderOriginal.apply(this, args);
};
response.send = function () {
const args = Array.prototype.slice.call(arguments),
body = args[0],
changeLinks = function (obj) {
if (obj._links) {
Object.keys(obj._links).forEach(function (rel) {
if (obj._links[rel].href) {
obj._links[rel].href = absolutize(obj._links[rel].href);
}
});
}
},
traverse = function (obj, fn, parent) {
if (parent === 'stubs' || parent === 'response') {
// Don't change _links within stubs or within the response
// sent back to protocol implementations
return;
}
fn(obj);
Object.keys(obj).forEach(key => {
if (obj[key] && isObject(obj[key])) {
traverse(obj[key], fn, key);
}
});
};
if (isObject(body)) {
traverse(body, changeLinks);
// Special case stubs _links. Hard to manage in the traverse function because stubs is an array
// and we want to change stubs[]._links but not stubs[]._responses.is.body._links
if (Array.isArray(body.stubs)) {
body.stubs.forEach(changeLinks);
}
else if (Array.isArray(body.imposters)) {
body.imposters.forEach(imposter => {
if (Array.isArray(imposter.stubs)) {
imposter.stubs.forEach(changeLinks);
}
});
}
}
sendOriginal.apply(this, args);
};
next();
};
}
/**
* Returns a middleware function to return a 404 if the imposter does not exist
* @param {Object} imposters - The imposters repository
* @returns {Function}
*/
function createImposterValidator (imposters) {
return async function validateImposterExists (request, response, next) {
const exists = await imposters.exists(request.params.id);
if (exists) {
next();
}
else {
response.statusCode = 404;
response.send({
errors: [errors.MissingResourceError('Try POSTing to /imposters first?')]
});
}
};
}
/**
* Returns a middleware function that logs the requests made to the server
* @param {Object} log - The logger
* @param {string} format - The log format
* @returns {Function}
*/
function logger (log, format) {
function shouldLog (request) {
const isStaticAsset = (['.js', '.css', '.gif', '.png', '.ico'].some(function (fileType) {
return request.url.indexOf(fileType) >= 0;
})),
isHtmlRequest = (request.headers.accept || '').indexOf('html') >= 0,
isXHR = request.headers['x-requested-with'] === 'XMLHttpRequest';
return !(isStaticAsset || isHtmlRequest || isXHR);
}
return function (request, response, next) {
if (shouldLog(request)) {
const message = format.replace(':method', request.method).replace(':url', request.url);
if (request.url.indexOf('_requests') > 0) {
// Protocol implementations communicating with mountebank
log.debug(message);
}
else {
log.info(message);
}
}
next();
};
}
/**
* Returns a middleware function that passes global variables to all render calls without
* having to pass them explicitly
* @param {Object} vars - the global variables to pass
* @returns {Function}
*/
function globals (vars) {
return function (request, response, next) {
const originalRender = response.render;
response.render = function () {
const args = Array.prototype.slice.call(arguments),
variables = args[1] || {};
Object.keys(vars).forEach(function (name) {
variables[name] = vars[name];
});
args[1] = variables;
originalRender.apply(this, args);
};
next();
};
}
/**
* The mountebank server uses header-based content negotiation to return either HTML or JSON
* for each URL. This breaks down on IE browsers as they fail to send the correct Accept header,
* and since we default to JSON (to make the API easier to use), that leads to a poor experience
* for IE users. We special case IE to html by inspecting the user agent, making sure not to
* interfere with XHR requests that do add the Accept header
* @param {Object} request - The http request
* @param {Object} response - The http response
* @param {Function} next - The next middleware function to call
*/
function defaultIEtoHTML (request, response, next) {
// IE has inconsistent Accept headers, often defaulting to */*
// Our default is JSON, which fails to render in the browser on content-negotiated pages
if (request.headers['user-agent'] && request.headers['user-agent'].indexOf('MSIE') >= 0) {
if (!(request.headers.accept && request.headers.accept.match(/application\/json/))) {
request.headers.accept = 'text/html';
}
}
next();
}
/**
* Returns a middleware function that defaults the content type to JSON if not set to make
* command line testing easier (e.g. you don't have to set the Accept header with curl) and
* parses the JSON before reaching a controller, handling errors gracefully.
* @param {Object} log - The logger
* @returns {Function}
*/
function json (log) {
return function (request, response, next) {
// Disable body parsing, if already parsed
if (request.headers['content-type'] === 'application/json' && helpers.isObject(request.body)) {
next();
return;
}
request.body = '';
request.setEncoding('utf8');
request.on('data', chunk => {
request.body += chunk;
});
request.on('end', function () {
if (request.body === '') {
next();
}
else {
try {
request.body = JSON.parse(request.body);
request.headers['content-type'] = 'application/json';
next();
}
catch (e) {
log.error('Invalid JSON: ' + request.body);
response.statusCode = 400;
response.send({
errors: [errors.InvalidJSONError({ source: request.body })]
});
}
}
});
};
}
function validateApiKey (expectedApiKey, log) {
return function (request, response, next) {
if (!expectedApiKey) {
next();
return;
}
if (!request.headers['x-api-key']) {
log.error('The x-api-key header is required but was not provided');
response.statusCode = 401;
response.send({
errors: [errors.UnauthorizedError()]
});
return;
}
const crypto = require('crypto');
const hash = crypto.createHash('sha512');
if (crypto.timingSafeEqual(
hash.copy().update(request.headers['x-api-key']).digest(),
hash.copy().update(expectedApiKey).digest()
)) {
next();
}
else {
log.error('The x-api-key header value does not match the expected API key');
response.statusCode = 401;
response.send({
errors: [errors.UnauthorizedError()]
});
}
};
}
module.exports = { useAbsoluteUrls, createImposterValidator, logger, globals, defaultIEtoHTML, json, validateApiKey };