mockserver
Version:
Easily mock your webservices while testing frontends.
414 lines (358 loc) • 10.5 kB
JavaScript
const fs = require('fs');
const path = require('path');
const colors = require('colors');
const join = path.join;
const Combinatorics = require('js-combinatorics');
const normalizeHeader = require('header-case-normalizer');
const Monad = require('./monad');
const importHandler = require('./handlers/importHandler');
const headerHandler = require('./handlers/headerHandler');
const evalHandler = require('./handlers/evalHandler');
/**
* Returns the status code out of the
* first line of an HTTP response
* (ie. HTTP/1.1 200 Ok)
*/
function parseStatus(header) {
const regex = /(?<=HTTP\/\d.\d\s{1,1})(\d{3,3})(?=[a-z0-9\s]+)/gi;
if (!regex.test(header)) throw new Error('Response code should be valid string');
const res = header.match(regex);
return res.join('');
}
/**
* Parses an HTTP header, splitting
* by colon.
*/
const parseHeader = function (header, context, request) {
header = header.split(': ');
return { key: normalizeHeader(header[0]), value: parseValue(header[1], context, request) };
};
const parseValue = function(value, context, request) {
return Monad
.of(value)
.map((value) => importHandler(value, context, request))
.map((value) => headerHandler(value, request))
.map((value) => evalHandler(value, request))
.join();
};
/**
* Prepares headers to watch, no duplicates, non-blanks.
* Priority exports over ENV definition.
*/
const prepareWatchedHeaders = function() {
const exportHeaders =
module.exports.headers && module.exports.headers.toString();
const headers = (exportHeaders || process.env.MOCK_HEADERS || '').split(',');
return headers.filter(function(item, pos, self) {
return item && self.indexOf(item) == pos;
});
};
/**
* Combining the identically named headers
*/
const addHeader = function(headers, line) {
const { key, value } = parseHeader(line);
if (headers[key]) {
headers[key] = [...(Array.isArray(headers[key]) ? headers[key] : [headers[key]]), value];
} else {
headers[key] = value;
}
}
/**
* Parser the content of a mockfile
* returning an HTTP-ish object with
* status code, headers and body.
*/
const parse = function(content, file, request) {
const context = path.parse(file).dir + '/';
const headers = {};
let body;
const bodyContent = [];
content = content.split(/\r?\n/);
const status = Monad
.of(content[0])
.map((value) => importHandler(value, context, request))
.map((value) => evalHandler(value, context, request))
.map(parseStatus)
.join();
let headerEnd = false;
delete content[0];
content.forEach(function(line) {
switch (true) {
case headerEnd:
bodyContent.push(line);
break;
case line === '' || line === '\r':
headerEnd = true;
break;
default:
addHeader(headers, line);
break;
}
});
body = Monad
.of(bodyContent.join('\n'))
.map((value) => importHandler(value, context, request))
.map((value) => evalHandler(value, context, request))
.join();
return { status: status, headers: headers, body: body };
};
function removeBlanks(array) {
return array.filter(function(i) {
return i;
});
}
/**
* This method will look for a header named Response-Delay. When set it
* delay the response in that number of milliseconds simulating latency
* for HTTP calls.
*
* Example from a file:
* Response-Delay: 5000
*
* @param {mock.headers} headers : {
* 'Response-Delay': is the property name,
* 'value': Positive integer value
*/
const getResponseDelay = function(headers) {
if (headers && headers.hasOwnProperty('Response-Delay')) {
let delayVal = parseInt(headers['Response-Delay'], 10);
delayVal = isNaN(delayVal) || delayVal < 0 ? 0 : delayVal;
return delayVal;
}
return 0;
};
function getWildcardPath(dir) {
let steps = removeBlanks(dir.split('/'));
let testPath;
let newPath;
let exists = false;
while (steps.length) {
steps.pop();
testPath = join(steps.join('/'), '/__');
exists = fs.existsSync(join(mockserver.directory, testPath));
if (exists) {
newPath = testPath;
}
}
const res = getDirectoriesRecursive(mockserver.directory)
.filter(dir => {
const directories = dir.split(path.sep);
return directories.includes('__');
})
.sort((a, b) => {
const aLength = a.split(path.sep);
const bLength = b.split(path.sep);
if (aLength == bLength) return 0;
// Order from longest file path to shortest.
return aLength > bLength ? -1 : 1;
})
.map(dir => {
const steps = dir.split(path.sep);
const baseDir = mockserver.directory.split(path.sep);
steps.splice(0, baseDir.length);
return steps.join(path.sep);
});
steps = removeBlanks(dir.split('/'));
newPath = matchWildcardPaths(res, steps) || newPath;
return newPath;
}
function matchWildcardPaths(res, steps) {
for (let resIndex = 0; resIndex < res.length; resIndex++) {
const dirSteps = res[resIndex].split(/\/|\\/);
if (dirSteps.length !== steps.length) {
continue;
}
const result = matchWildcardPath(steps, dirSteps);
if (result) {
return result;
}
}
return null;
}
function matchWildcardPath(steps, dirSteps) {
for (let stepIndex = 1; stepIndex <= steps.length; stepIndex++) {
const step = steps[steps.length - stepIndex];
const dirStep = dirSteps[dirSteps.length - stepIndex];
if (step !== dirStep && dirStep != '__') {
return null;
}
}
return '/' + dirSteps.join('/');
}
function flattenDeep(directories) {
return directories.reduce(
(acc, val) =>
Array.isArray(val) ? acc.concat(flattenDeep(val)) : acc.concat(val),
[]
);
}
function getDirectories(srcpath) {
return fs
.readdirSync(srcpath)
.map(file => path.join(srcpath, file))
.filter(path => fs.statSync(path).isDirectory());
}
function getDirectoriesRecursive(srcpath) {
const nestedDirectories = getDirectories(srcpath).map(
getDirectoriesRecursive
);
const directories = flattenDeep(nestedDirectories);
directories.push(srcpath);
return directories;
}
/**
* Returns the body or query string to be used in
* the mock name.
*
* In any case we will prepend the value with a double
* dash so that the mock files will look like:
*
* POST--My-Body=123.mock
*
* or
*
* GET--query=string&hello=hella.mock
*/
function getBodyOrQueryString(body, query) {
if (query) {
return '--' + query;
}
if (body && body !== '') {
return '--' + body;
}
return body;
}
/**
* Ghetto way to get the body
* out of the request.
*
* There are definitely better
* ways to do this (ie. npm/body
* or npm/body-parser) but for
* the time being this does it's work
* (ie. we don't need to support
* fancy body parsing in mockserver
* for now).
*/
function getBody(req, callback) {
let body = '';
req.on('data', function(b) {
body = body + b.toString();
});
req.on('end', function() {
callback(body);
});
}
function getMockedContent(path, prefix, body, query) {
const mockName = prefix + (getBodyOrQueryString(body, query) || '') + '.mock';
const mockFile = join(mockserver.directory, path, mockName);
let content;
try {
content = fs.readFileSync(mockFile, { encoding: 'utf8' });
if (mockserver.verbose) {
console.log(
'Reading from ' + mockFile.yellow + ' file: ' + 'Matched'.green
);
}
} catch (err) {
if (mockserver.verbose) {
console.log(
'Reading from ' + mockFile.yellow + ' file: ' + 'Not matched'.red
);
}
content = (body || query) && getMockedContent(path, prefix);
}
return content;
}
function getContentFromPermutations(path, method, body, query, permutations) {
let content, prefix;
while (permutations.length) {
prefix = method + permutations.pop().join('');
content = getMockedContent(path, prefix, body, query) || content;
}
return { content: content, prefix: prefix };
}
const mockserver = {
directory: '.',
verbose: false,
headers: [],
init: function(directory, verbose) {
this.directory = directory;
this.verbose = !!verbose;
this.headers = prepareWatchedHeaders();
},
handle: function(req, res) {
getBody(req, function(body) {
req.body = body;
const url = req.url;
let path = url;
const queryIndex = url.indexOf('?'),
query =
queryIndex >= 0 ? url.substring(queryIndex).replace(/\?/g, '') : '',
method = req.method.toUpperCase(),
headers = [];
if (queryIndex > 0) {
path = url.substring(0, queryIndex);
}
if (req.headers && mockserver.headers.length) {
mockserver.headers.forEach(function(header) {
header = header.toLowerCase();
if (req.headers[header]) {
headers.push(
'_' + normalizeHeader(header) + '=' + req.headers[header]
);
}
});
}
// Now, permute the possible headers, and look for any matching files, prioritizing on
// both # of headers and the original header order
let matched,
permutations = [[]];
if (headers.length) {
permutations = Combinatorics.permutationCombination(headers)
.toArray()
.sort(function(a, b) {
return b.length - a.length;
});
permutations.push([]);
}
matched = getContentFromPermutations(
path,
method,
body,
query,
permutations.slice(0)
);
if (!matched.content && (path = getWildcardPath(path))) {
matched = getContentFromPermutations(
path,
method,
body,
query,
permutations.slice(0)
);
}
if (matched.content) {
const mock = parse(
matched.content,
join(mockserver.directory, path, matched.prefix),
req
);
const delay = getResponseDelay(mock.headers);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
res.writeHead(mock.status, mock.headers);
return res.end(mock.body);
} else {
res.writeHead(404);
res.end('Not Mocked');
}
});
},
};
module.exports = function(directory, silent) {
mockserver.init(directory, silent);
return mockserver.handle;
};
module.exports.headers = null;
module.exports.getResponseDelay = getResponseDelay;