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'); /** * Returns the status code out of the * first line of an HTTP response * (ie. HTTP/1.1 200 Ok) */ function parseStatus(header) { return header.split(' ')[1]; }; /** * Parses an HTTP header, splitting * by colon. */ const parseHeader = function (header) { header = header.split(': '); return {key: normalizeHeader(header[0]), value: parseValue(header[1])}; }; const parseValue = function(value) { if (/^#header/m.test(value)) { return value.replace(/^#header (.*);/m, function (statement, val) { const expression = val.replace(/[${}]/g, ''); return eval(expression); }).replace(/\r\n?/g, '\n'); } return value; }; /** * 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; }); } /** * Parser the content of a mockfile * returning an HTTP-ish object with * status code, headers and body. */ const parse = function (content, file, request) { const headers = {}; let body; const bodyContent = []; content = content.split(/\r?\n/); const status = parseStatus(content[0]); 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: const header = parseHeader(line); headers[header.key] = header.value; break; } }); body = bodyContent.join('\n'); if (/^#import/m.test(body)) { const context = path.parse(file).dir + '/'; body = body.replace(/^#import (.*);/m, function (includeStatement, file) { const importThisFile = file.replace(/['"]/g, ''); const content = fs.readFileSync(path.join(context, importThisFile)); if (importThisFile.endsWith(".js")) { return JSON.stringify(eval(content.toString())); } else { return content; } }) .replace(/\r\n?/g, '\n'); } return {status: status, headers: headers, body: body}; }; function removeBlanks(array) { return array.filter(function (i) { return i; }); } 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 */ // POST--email=accountant@barong.io&password=123123&data.address=111 function getObjectPath(instance, parentName) { let result = ''; Object.keys(instance).forEach(key=>{ const value = instance[key]; const formatKey = parentName? `${parentName}.${key}`: key; if(typeof value === 'object') { result += getObjectPath(value, formatKey); } else { result += `&${formatKey}=${value}`; } }); return result; } function getBodyOrQueryString(body, query) { if (query) { return '--' + query; } if (body && body !== '') { try{ let obj = eval('(' + body + ')'); const path = getObjectPath(obj); return '--' + path.replace(/^&/gi, ''); } catch (error) { console.log('[Body parse error]', error); } 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); 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;