UNPKG

node-mocker-server

Version:

File based Node REST API mock server

837 lines (724 loc) 20.3 kB
'use strict'; var nodePath = require('path'); var Utils = require('../Utils'); var extend = require('util')._extend; var ejs = require('ejs'); var mime = require('mime-types'); var log = require('chip')(); var faker = require('faker'); var request = require('request'); var fs = require('fs'); var AppControllerSingleton = require('./AppController'); var appController = AppControllerSingleton.getInstance(); /** * * @class MockController * @constructor * */ function MockController() { this.init(); } function _fetch(opt) { var isJson = opt.headers['content-type'] === 'application/json'; request({ uri: opt.url, method: opt.method || 'GET', body: isJson ? opt.data || {} : undefined, form: isJson ? undefined : opt.data || {}, json: isJson, headers: opt.headers || {}, gzip: true, }, function (error, res, data) { if (error) { opt.error.call(this, error); } else { opt.success.call(this, data, res); } }); } function _buildTunnelUrl(tunnelOpt) { var url = []; url.push(tunnelOpt.protocol); url.push('://'); if (tunnelOpt.authUser && tunnelOpt.authPass) { url.push(tunnelOpt.authUser); url.push(':'); url.push(tunnelOpt.authPass); url.push('@'); } url.push(tunnelOpt.host); if (tunnelOpt.port && tunnelOpt.port !== 80) { url.push(':'); url.push(tunnelOpt.port); } return url.join(''); } MockController.prototype = extend(MockController.prototype, Utils.prototype); MockController.prototype = extend(MockController.prototype, { constructor: MockController, /** * * @method init * called by constructor * @public */ init: function () { this.options = appController.options; appController.app.all('/*', this._handleMockRequest.bind(this)); }, /** * @method _acceptMiddleware * @param {Object} serverOptions * @param {Object} responseOptions * @private */ _acceptMiddleware: function (serverOptions, responseOptions) { var endPointId = responseOptions.dir.replace(serverOptions.dirName, '').replace(/\/$/, '').replace('\\', '/'); var middleware = serverOptions.middleware; if (!endPointId || !middleware || typeof middleware[endPointId] !== 'function') { responseOptions.res.statusCode = 500; responseOptions.res.end('Error: middleware for ' + endPointId + '" don\'t exist!'); return false; } return middleware[endPointId](serverOptions, responseOptions); }, /** * @method _acceptTunnel * @param {Object} serverOptions * @param {Object} responseOptions * @private */ _acceptTunnel: function (serverOptions, responseOptions) { var tunnelOpt = serverOptions.tunnel; var req = responseOptions.req; var res = responseOptions.res; var tunnelUrl = _buildTunnelUrl(tunnelOpt) + req.url; // append request headers from tunnel config if (tunnelOpt.requestHeaders) { req.headers = extend(req.headers, tunnelOpt.requestHeaders); } _fetch({ url: tunnelUrl, method: req.method, data: req.body, headers: req.headers, success: function (data, response) { var statusCode = parseInt(response.statusCode, 10); var headersToSkip = ['transfer-encoding']; this.forIn(response.headers, function (key, value) { if (! headersToSkip.includes(key)) { res.setHeader(key, value); } }); res.removeHeader('content-encoding'); res.statusCode = response.statusCode; res.send(data); log.log('tunnel data from "' + tunnelUrl + '" to "' + req.url + '"'); var tunnelFile = responseOptions.dir + 'mock/tunnel-latest.json'; if (statusCode !== 200 && statusCode !== 201) { tunnelFile = responseOptions.dir + 'mock/tunnel-latest-' + statusCode + '.json'; } this.writeFile(tunnelFile, data); log.log('save tunnel reponse in "' + tunnelFile + '""'); res.end(); }.bind(this), error: function (error) { log.error(error); res.statusCode = 500; res.send(error); res.end(); }, }); }, /** * @method _getPath * @param {string} originalUrl (/rest/v1/products/search) * @param {string} urlPath (/rest/v1) * @param {string} restPath (__dirname + /demo/rest) * @private */ _getPath: function (originalUrl, urlPath, restPath) { var urlPathRegExp = new RegExp(urlPath.replace(/{[^}]*}/g, '[^/]*')); return originalUrl.replace(urlPathRegExp, restPath); }, /** * @method _handleMockRequest * @param {Object} req * @param {Object} res * @private */ _handleMockRequest: function (req, res) { var path = this._getPath(req.originalUrl, this.options.urlPath, this.options.restPath); var responseHeaders; var headers = Object.assign({}, this.options.headers); var options; if (path.search('favicon.ico') >= 0) { res.end(); return true; } options = this.getResponseOptions(req, res); if (!options) { return; } if (options.expectedResponse.name === 'tunnel') { this._acceptTunnel(this.options, options); return; } // Fallback to success.json if (!this.existFile(options.responseFilePath)) { options.expectedResponse = { name: 'success', type: 'json', }; options.responseFilePath = options.dir + 'mock/success.json'; this.writeFile(options.dir + 'mock/response.txt', 'success'); } // Add response headers if (this.existFile(options.responseHeadersFilePath)) { responseHeaders = JSON.parse(this.readFile(options.responseHeadersFilePath)) || {}; } this._writeDefaultHeader(res, extend(headers, responseHeaders)); setTimeout(function () { if (!this._hasValidDynamicPathParam(options)) { this._sendErrorEmptyPath(options); } else if (options.expectedResponse.name.search('error') >= 0) { this._sendError(options); } else if (options.method === 'HEAD') { this._sendHead(options); } else { this._sendSuccess(options); } }.bind(this), options.timeout); }, /** * @method _sendSuccess * @param {Object} options * @returns {void} * @private */ _sendSuccess: function (options) { if (options.expectedResponse.type === 'json') { this._sendSuccessJSON(options); return; } this._sendSuccessNotJSON(options); }, /** * @method _sendSuccessNotJSON * @param {Object} options * @returns {void} * @private */ _sendSuccessNotJSON: function (options) { try { if (this._setMimeType(options.res, options.expectedResponse.type)) { // add statusCode defined by filename var status = this._getHttpStatusFromFilePath(options.responseFilePath); if (status && options.res.statusCode === 200) { options.res.statusCode = status; } options.res.sendFile(options.responseFilePath); return; } log.log('unknown mime type'); options.res.contentType('text/html'); options.res.statusCode = 500; options.res.end('unknown mime type'); } catch (err) { log.error(err); options.res.contentType('text/html'); options.res.statusCode = 500; options.res.end(err); } }, /** * @method _sendSuccessJSON * @param {Object} options * @returns {void} * @private */ _sendSuccessJSON: function (options) { try { var responseFile = this.readFile(options.responseFilePath); var outStr; var ejsData = { body: this._getResponseData(options.req, options.method), require: require, faker: faker, params: this._getDynamicPathParams(options), query: options.req.query, headers: options.req.headers, __dirname: this.options.dirName, }; ejsData = extend(ejsData, this._getFunc(this.options.funcPath)); ejsData.response = this._getResponseFiles(options, ejsData); try { outStr = ejs.render(responseFile, ejsData); } catch (err) { log.error(err); } if (outStr) { // add statusCode defined by filename var status = this._getHttpStatusFromFilePath(options.responseFilePath); if (status && options.res.statusCode === 200) { options.res.statusCode = status; } options.res.send(outStr); } else { // this happens when something gone wrong with the ejs render options.res.statusCode = 500; options.res.send(responseFile); } } catch (err) { log.error(err); options.res.statusCode = 500; options.res.end(); } }, /** * @method _setMimeType * @param {Object} res * @param {string} type * @returns {boolean} * @private */ _setMimeType: function (res, type) { var mimeType = mime.lookup(type); if (!mimeType) { return false; } res.contentType(mimeType); return true; }, /** * @method getResponseOptions * @param {Object} req * @param {Object} res * @public */ getResponseOptions: function (req, res) { var path = this._getPath(req.originalUrl, this.options.urlPath, this.options.restPath); var method = req.method; var dir = this._processOptionsFallback( req, this._findFolder(path, this.options) + '/' + method + '/', this.options ); var expectedResponse = this._getExpectedResponse(req, dir, this.readFile); var preferences = this.getPreferences(this.options); var timeout = 0; var responseFilePath = dir + 'mock/' + expectedResponse.name + '.' + expectedResponse.type; var responseHeadersFilePath = dir + 'mock/' + expectedResponse.name + '.headers.' + expectedResponse.type; if (preferences && preferences.responseDelay) { timeout = parseInt(preferences.responseDelay, 10); } if (expectedResponse.name === 'middleware') { var middlewareResponse = this._acceptMiddleware(this.options, { req: req, res: res, method: method, dir: dir, preferences: preferences, }); if (typeof middlewareResponse === 'string') { expectedResponse = this._getExpectedResponse({}, '', function () { return middlewareResponse; }); responseFilePath = dir + 'mock/' + expectedResponse.name + '.' + expectedResponse.type; responseHeadersFilePath = dir + 'mock/' + expectedResponse.name + '.headers.' + expectedResponse.type; } else { return; } } return { req: req, res: res, path: path, method: method, dir: dir, expectedResponse: expectedResponse, preferences: preferences, timeout: timeout, responseFilePath: responseFilePath, responseHeadersFilePath: responseHeadersFilePath, }; }, /** * @method _processOptionsFallback * @param {Object} req is the original request * @param {String} dir directory for the response * @param {Object} options * @returns {String} * @private */ _processOptionsFallback: function (req, dir, options) { if (!fs.existsSync(dir) && req.method === 'OPTIONS') { if (options.optionsFallbackPath) { if (options.optionsFallbackPath.slice(-1) === '/') { return options.optionsFallbackPath; } return options.optionsFallbackPath + '/'; } } return dir; }, /** * @method _sendError * @param {Object} options * @returns {void} * @private */ _sendError: function (options) { var status = this._getHttpStatusFromFilePath(options.expectedResponse.name) || 500; // add statusCode defined by filename if (options.res.statusCode === 200) { options.res.statusCode = status; } this._setMimeType(options.res, options.expectedResponse.type); options.res.send(this.readFile(options.responseFilePath)); }, /** * @method _sendError * @param {Object} options * @returns {void} * @private */ _sendErrorEmptyPath: function (options) { options.res.statusCode = 400; options.res.send(JSON.stringify({ errors: [ { message: 'Invalid path, please check the path params!', type: 'InvalidPathError', }, ], })); }, /** * @method _sendHead * @param {Object} options * @returns {void} * @private */ _sendHead: function (options) { options.res.setHeader('X-Total-Count', Math.floor(Math.random() * 100)); options.res.end(); }, /** * @method _cleanPath * @param {string} path * @returns {string} * @private */ _cleanPath: function (path) { return decodeURIComponent(path) .split('?')[0] .split('#')[0] .replace(/\/$/, '') ; }, /** * @method _cleanDir * @param {string} dir * @param {string} method * @returns {string} * @private */ _cleanDir: function (dir, method) { var regDirReplace = new RegExp('\/' + method + '\/$'); return dir.replace(regDirReplace, '') .replace(/#/g, '/') .replace(/\/\//g, '/') .replace(/\/$/, '') ; }, /** * @method _hasValidDynamicPathParam * @param {Object} options * @returns {boolean} * @private */ _hasValidDynamicPathParam: function (options) { var path = this._cleanPath(options.path); var pathSpl = path.split('/'); var regMatchDyn = /^{([^}]*)}$/; var dir = this._cleanDir(options.dir, options.method); var dirSpl = dir.split('/'); var result = true; // Ignore path length check in case of OPTIONS call because of fallback #68 if (dirSpl.length !== pathSpl.length && options.method !== 'OPTIONS') { return false; } if (!this.existDir(options.dir)) { return false; } this.for(dirSpl, function (dirItem, i) { var exp = regMatchDyn.exec(dirItem); if (exp !== null && exp.length > 0) { if (pathSpl[i] === '' || regMatchDyn.test(pathSpl[i])) { result = false; } } }); return result; }, /** * @method _getResponseFiles * @param {Object} options * @param {Object} ejsData * @returns {Object} * @private */ _getResponseFiles: function (options, ejsData) { var responses = {}; var path = options.dir + 'mock'; var files = this.readDir(path, ['.DS_Store']); this.for(files, function (filesObj) { try { var fileData = this.readFile(filesObj.path); if (filesObj.file.search(/.json$/) >= 0) { responses[filesObj.file.replace('.json', '')] = JSON.parse(ejs.render(fileData, ejsData)); } } catch (err) { return; } }.bind(this)); return responses; }, /** * @method _getExpectedResponse * @param {Object} req * @param {string} dir * @param {Function} readFile * @returns {string} * @private */ _getExpectedResponse: function (req, dir, readFile) { var name; var fileName; var fileType; var type = 'json'; var path = dir + 'mock/response.txt'; try { fileName = readFile(path); } catch (err) { fileName = 'success'; } fileType = fileName.match(/\.[a-zA-Z]*$/); if (fileType) { type = fileType[0].replace(/^\./, '').toLowerCase(); name = fileName.replace(/\.[a-zA-Z]*$/, ''); } else { name = fileName; } if (type === 'jpeg') { type = 'jpg'; } if (req.query && typeof req.query._expected === 'string') { name = req.query._expected; } if (req.headers && typeof req.headers._expected === 'string') { name = req.headers._expected; } return { name: name.trim(), type: type, }; }, /** * @method _getFunc * @param {string|Array} path * @returns {Object} * @private */ _getFunc: function (path) { var _this = this; var func = {}; var list = []; function addFunc(thisPath) { var out = {}; try { // eslint-disable-next-line out = require(thisPath); } catch (err) { out = {}; } func = extend(func, out); } function addDirectory(thisPath) { try { list = _this.readDir(thisPath, ['.DS_Store']); } catch (err) { if (process.env.NODE_ENV !== 'test') { log.error('Folder "' + thisPath + '" not found!'); } } list.forEach(function (item) { addFunc(item.path); }); } if (path instanceof Array) { path.forEach(function (itemPath) { addDirectory(itemPath); }); } else if (typeof(path) === 'string' && path !== '') { addDirectory(path); } return func; }, /** * @method _getDynamicPathParams * @param {Object} options * @returns {Object} * @private */ _getDynamicPathParams: function (options) { var path = options.path.split('?')[0].split('#')[0]; var pathSpl = path.split('/'); var regDirReplace = new RegExp('\/' + options.method + '\/$'); var regMatchDyn = /^{([^}]*)}$/; var dir = options.dir.replace(regDirReplace, '').replace(/#/g, '/').replace(/\/\//g, '/'); var dirSpl = dir.split('/'); var params = {}; if (dirSpl.length !== pathSpl.length) { return {}; } this.for(dirSpl, function (dirItem, i) { var exp = regMatchDyn.exec(dirItem); if (exp !== null && exp.length > 0) { params[exp[1]] = pathSpl[i]; } }); return params; }, /** * @method _getResponseData * @param {Object} req * @param {string} method * @returns {Object} * @private */ _getResponseData: function (req, method) { switch (method) { case 'POST': return req.body; case 'PUT': return req.body; case 'PATCH': return req.body; case 'DELETE': return req.body; default: return req.query; } }, /** * * @method _isPathMatch * @param {string} pathPatter * @param {string} path * @returns {boolean} isPathMatch * @private */ _isPathMatch: function (pathPatter, path) { var pathPatterSpl = pathPatter.split('#'); var pathSpl = path.split('#'); pathSpl.forEach(function (pathItem, index) { var pathPatterItem = pathPatterSpl[index]; // eslint-disable-next-line if (/({)(.*)(})/.test(pathPatterItem)) { pathSpl[index] = pathPatterItem; } }); return pathSpl.join('#') === pathPatter; }, /** * @method _findFolder * @param {string} path * @param {Object} options * @returns {Object} * @private */ _findFolder: function (path, options) { path = path.split('?')[0]; // removing trailing slashes to make it work on windows if (!options.useTrailingSlashes && path.slice(-1) === '/') { path = path.slice(0, -1); } var pathArr = path.split('/'); var restPathLength = options.restPath.split('/').length + 1; var pathRoot = pathArr.splice(0, restPathLength).join('/'); var pathFolder = '#' + pathArr.join('#'); var pathFolderArr; var dirs; var output = ''; var i; if (pathFolder === '#') { return pathRoot + '/#'; } dirs = this.readDir(pathRoot, ['.DS_Store']); pathFolderArr = pathFolder.split('#'); for (i = 0; i < dirs.length; i += 1) { var item = dirs[i].file; var itemArr = item.split('#'); if (item !== 'preferences.json' && itemArr.length === pathFolderArr.length) { if (item === pathFolder) { output = pathRoot + '/' + item; i = dirs.length + 1; } else if (this._isPathMatch(item, pathFolder)) { output = pathRoot + '/' + item; i = dirs.length + 1; } } } return output; }, /** * @method _writeDefaultHeader * @param {Object} customHeaders * @param {Object} res * @returns {void} * @private */ _writeDefaultHeader: function (res, customHeaders) { // set custom headers this.forIn(customHeaders, function (key, value) { res.setHeader(key, value); }); res.setHeader('Content-Type', this.options.contentType); res.setHeader('Access-Control-Expose-Headers', typeof this.options.accessControlExposeHeaders === 'function' ? this.options.accessControlExposeHeaders(res.req) : this.options.accessControlExposeHeaders ); res.setHeader('Access-Control-Allow-Origin', typeof this.options.accessControlAllowOrigin === 'function' ? this.options.accessControlAllowOrigin(res.req) : this.options.accessControlAllowOrigin ); res.setHeader('Access-Control-Allow-Methods', typeof this.options.accessControlAllowMethods === 'function' ? this.options.accessControlAllowMethods(res.req) : this.options.accessControlAllowMethods ); res.setHeader('Access-Control-Allow-Headers', typeof this.options.accessControlAllowHeaders === 'function' ? this.options.accessControlAllowHeaders(res.req) : this.options.accessControlAllowHeaders ); res.setHeader('Access-Control-Allow-Credentials', typeof this.options.accessControlAllowCredentials === 'function' ? this.options.accessControlAllowCredentials(res.req) : this.options.accessControlAllowCredentials ); }, /** * @method _getHttpStatusFromFilePath * @param {string} filePath - absolute path to file * @returns {number|void} HTTP status code * @private */ _getHttpStatusFromFilePath: function (filePath) { var reg = (/([\w]+)(-)([0-9]{3})/).exec(nodePath.basename(filePath)); if (reg === null) { return; } return parseInt(reg[3], 10); }, }); module.exports = MockController;