# Source: index.js

    const npmPackage = require('./package.json');
    const crypto = require('crypto');
    const express = require('express');
    const proxy = require('express-http-proxy');
    const bodyParser = require('body-parser');
    const urlUtil = require('url');
    const chalk = require('chalk');

    /** The caching proxy server. */
    class JsonCachingProxy {
    	/**
    	 * @param {Object} options - Options passed into the ctor will override defaults if defined
    	 */
    	constructor(options = {}) {
    		this.defaultOptions = {
    			remoteServerUrl: 'http://localhost:8080',
    			proxyPort: 3001,
    			harObject: null,
    			commandPrefix: 'proxy',
    			proxyHeaderIdentifier: 'caching-proxy-playback',
    			middlewareList: [],
    			excludedRouteMatchers: [],
    			cacheBustingParams: [],
    			cacheEverything: false,
    			dataPlayback: true,
    			dataRecord: true,
    			showConsoleOutput: false,
    			proxyTimeout: 3600000 // one hour
    		};

    		// Ignore undefined values and combine the options with defaults
    		this.options = Object.assign({},
    			this.defaultOptions,
    			Object.keys(options).reduce(function (passedOpts, key) {
    				if (typeof options[key] !== 'undefined' && options[key] !== null) passedOpts[key] = options[key];
    				return passedOpts;
    			}, {}));

    		// Will be set when the app starts
    		this.server = null;

    		this.app = express();
    		this.routeCache = {};

    		this.excludedParamMap = this.options.cacheBustingParams.reduce((map, param) => { map[param] = true; return map }, {});

    		if (this.options.showConsoleOutput) {
    			this.log = console.log;
    			this.err = console.error;
    		} else {
    			this.log = () => false;
    			this.err = () => false;
    		}
    	}

    	/**
    	* Remove the domain portion of any cookies from the object
    	* @param {Object} cookies - Express cookies array
    	* @returns {Object[]} - Cookies with domain portion removed
    	*/
    	removeCookiesDomain(cookies) {
    		return cookies.map(c => {
    			let cookieParts = c.split(';');
    			let newCookieParts = [];

    			cookieParts.forEach(c => {
    				if (c.indexOf('domain') === -1) {
    					newCookieParts.push(c);
    				}
    			});

    			return newCookieParts.join(';');
    		});
    	}

    	/**
    	 * Returns an Object's own  properties into an array of name-value pair objects
    	 * @param {Object} obj
    	 * @returns {Object[]}
    	 */
    	convertToNameValueList(obj) {
    		return typeof obj === 'object' ? Object.keys(obj).map(key => { return { name: key, value: obj[key] }; }) : [];
    	}

    	/**
    	 * Generate a unique hash key from a har file entry's request object: TODO: Include headers?
    	 * @param {Object} harEntryReq - HAR request object
    	 * @returns {Object} A unique key, hash tuple that identifies the request
    	 */
    	genKeyFromHarReq(harEntryReq) {
    		let { method, url, queryString = [], postData = { text: '' } } = harEntryReq;
    		let uri = urlUtil.parse(url).pathname;
    		let postParams = postData.text;

    		queryString = queryString.filter(param => !this.excludedParamMap[param.name]);

    		let plainText = `${method} ${uri} ${JSON.stringify(queryString)} ${postParams}`;
    		let hash = crypto.createHash('md5').update(plainText).digest("hex");
    		let key = `${method} ${uri} ${hash}`;

    		return { key, hash };
    	}

    	/**
    	 * Takes a generic express request and convert it into a HAR request so that a unique key can be generated
    	 * @param {Object} req - An express IncomingMessage request
    	 * @returns {string} A unique hash key that identifies the request
    	 */
    	genKeyFromExpressReq(req) {
    		return this.genKeyFromHarReq({
    			method: req.method,
    			url: req.url,
    			queryString: this.convertToNameValueList(req.query),
    			postData: { text: req.body && req.body.length > 0 ? req.body.toString('utf8') : '' }
    		});
    	}

    	/**
    	 * Build a HAR entry object from an express Request and response
    	 * @param {string} startedDateTime - An ISO Datetime String
    	 * @param {Object} req - An express IncomingMessage request
    	 * @param {Object} res - An express ServerResponse response
    	 * @param {Object} data - An express response body (the content)
    	 * @returns {Object} A HAR entry object
    	 */
    	createHarEntry(startedDateTime, req, res, data) {
    		let reqMimeType = req.get('Content-Type');
    		let resMimeType = res.get('Content-Type') || 'text/plain';
    		let encoding = (/^text\/|^application\/(javascript|json)/).test(resMimeType) ? 'utf8' : 'base64';

    		let entry = {
    			request: {
    				startedDateTime: startedDateTime,
    				method: req.method.toUpperCase(),
    				url: req.url,
    				cookies: this.convertToNameValueList(req.cookies),
    				headers: this.convertToNameValueList(req.headers),
    				queryString: this.convertToNameValueList(req.query),
    				headersSize: -1,
    				bodySize: -1
    			},
    			response: {
    				status: res.statusCode,
    				statusText: res.statusMessage,
    				cookies: this.convertToNameValueList(res.cookies),
    				headers: this.convertToNameValueList(res._headers).filter(header => header.name.toLowerCase() !== 'content-encoding'), // Not  compressed
    				content: {
    					size: -1,
    					mimeType: resMimeType,
    					text: data.toString(encoding),
    					encoding: encoding
    				},
    				headersSize: -1,
    				bodySize: -1
    			}
    		};

    		// Add the request body if it exists
    		if (req.body && req.body.length > 0) {
    			entry.request.postData = {
    				mimeType: reqMimeType,
    				text: req.body.toString(encoding)
    			};
    		}

    		return entry;
    	}

    	/**
    	 * Check to see if the pieces of a request are excluded. This checks only the method and the uri. It uses the list
    	 * of regExp matchers to test
    	 * @param {string} method - e.g. GET, POST, PUT, etc.
    	 * @param {string} uri - e.g. http://www.api.com/rest/accounts
    	 * @returns {boolean} Whether the test is true for some matcher
    	 */
    	isRouteExcluded(method, uri) {
    		return this.options.excludedRouteMatchers.some(regExp => regExp.test(`${method} ${uri}`))
    	}

    	/**
    	 * Add express routes for each entry in a harObject. The harObject would have been read in from a har file at some point
    	 * @param {Object} harObject - A standard HAR file object that contains a collection of entries
    	 * @returns {JsonCachingProxy}
    	 */
    	addHarEntriesToCache(harObject) {
    		if (harObject) {
    			harObject.log.entries.forEach(entry => {
    				let { key, hash } = this.genKeyFromHarReq(entry.request);

    				if (this.isRouteExcluded(entry.request.method, entry.request.url)) {
    					this.log(chalk.red('Excluded from Cache', chalk.bold(entry.request.method, entry.request.url)));
    					return;
    				}

    				// Only cache things that have content. Some times HAR files generated elsewhere will be missing this parameter
    				if (entry.response.content.text) {
    					let mimeType = entry.response.content.mimeType;

    					if (entry.response.headers && (this.options.cacheEverything || !this.options.cacheEverything && mimeType && mimeType.indexOf('application/json') >= 0)) {
    						// Remove content-encoding. gzip compression won't be used
    						entry.response.headers = entry.response.headers.filter(header => header.name.toLowerCase() !== 'content-encoding');
    						this.routeCache[key] = entry;

    						this.log(chalk.yellow('Saved to Cache', hash, chalk.bold(entry.request.method, entry.request.url)));
    					}
    				}
    			});
    		}

    		return this;
    	}

    	/**
    	 * Add the admin express routes for controlling the proxy server through a browser. Allows one to make GET requests to clear
    	 * the cache, disable/enable playback/recording, and generate a har file of the cache to download for later use.
    	 * @returns {JsonCachingProxy}
    	 */
    	addAdminRoutes() {
    		// These are not really restful because the GET is changing state. But it's easier to use in a browser
    		this.app.get(`/${this.options.commandPrefix}/playback`, (req, res) => {
    			this.options.dataPlayback = typeof req.query.enabled !== 'undefined' ? req.query.enabled === 'true'.toLowerCase() : this.options.dataPlayback;
    			this.log(chalk.blue(`Replay from cache: ${this.options.dataPlayback}`));
    			res.send(`Replay from cache: ${this.options.dataPlayback}`);
    		});

    		this.app.get(`/${this.options.commandPrefix}/record`, (req, res) => {
    			this.options.dataRecord = typeof req.query.enabled !== 'undefined' ? req.query.enabled === 'true'.toLowerCase() : this.options.dataRecord;
    			this.log(chalk.blue('Saving to cache: ' + this.options.dataRecord));
    			res.send(`Saving to cache: ${this.options.dataRecord}`);
    		});

    		this.app.get(`/${this.options.commandPrefix}/clear`, (req, res) => {
    			this.routeCache = {};
    			this.log(chalk.blue('Cleared cache'));
    			res.send('Cleared cache');
    		});

    		this.app.get(`/${this.options.commandPrefix}/har`, (req, res) => {
    			this.log(chalk.blue('Generating har file'));

    			let har = {
    				log: {
    					version: "1.2",
    					creator: {
    						name: npmPackage.name,
    						version: npmPackage.version
    					},
    					entries: []
    				}
    			};

    			Object.keys(this.routeCache).forEach(key => har.log.entries.push(this.routeCache[key]));

    			res.setHeader('Content-disposition', 'attachment; filename=json-caching-proxy.har');
    			res.setHeader('Content-type', 'application/json');
    			res.json(har);
    		});

    		return this;
    	}

    	/**
    	 * Add user supplied middleware routes to express in order to handle special cases (browser-sync middleware options)
    	 * @param {Object[]} middlewareList - A list of route/handler pairs
    	 * @returns {JsonCachingProxy}
    	 */
    	addMiddleWareRoutes(middlewareList) {
    		middlewareList.forEach(mw => {
    			if (mw.route) {
    				this.app.use(mw.route, mw.handle);
    			} else {
    				this.app.use(mw.handle);
    			}
    		});

    		return this;
    	}

    	/**
    	 * Add Request body parsing into RAW if there is actual body content
    	 * @returns {JsonCachingProxy}
    	 */
    	addBodyParser() {
    		this.app.use(bodyParser.raw({ type: '*/*', limit: '100mb' }));

    		// Remove the body if there is no body content. Some sites check for malformed requests
    		this.app.use((req, res, next) => {
    			if (!req.headers['content-length']) {
    				delete req.body;
    			}

    			next();
    		});

    		return this;
    	}

    	/**
    	 * An express route that reads from the cache if possible for any routes persisted in cache memory
    	 * @returns {JsonCachingProxy}
    	 */
    	addCachingRoute() {
    		this.app.use('/', (req, res, next) => {
    			if (!this.options.dataPlayback) {
    				next();
    			} else {
    				let { key, hash } = this.genKeyFromExpressReq(req);
    				let entry = this.routeCache[key];

    				if (!(entry && entry.response && entry.response.content)) {
    					next();
    				} else {
    					let response = entry.response;
    					let headerList = response.headers || [];
    					let text = response.content.text || '';
    					let encoding = response.content.encoding || 'utf8';

    					headerList.forEach(function (header) {
    						res.set(header.name, header.value);
    					});

    					// Use our header identifier to signal that this response is cached
    					res.set(this.options.proxyHeaderIdentifier, true);

    					if (text.length === 0) {
    						res.sendStatus(response.status);
    					} else {
    						if (encoding === 'base64') {
    							let bin = new Buffer(text, 'base64');
    							res.writeHead(response.status, {
    								'Content-Type': response.content.mimeType,
    								'Content-Length': bin.length
    							});
    							res.end(bin);
    						} else {
    							res.status(response.status);
    							res.type(response.content.mimeType);
    							res.send(text);
    						}
    					}

    					this.log(chalk.green('Reading From Cache', hash, chalk.bold(entry.request.method, entry.request.url)));
    				}
    			}
    		});

    		return this;
    	}

    	/**
    	 * Add the proxy route that makes the actual request to the target server and cache the response when it comes back.
    	 * Modifies locations on redirects.
    	 * @returns {JsonCachingProxy}
    	 */
    	addProxyRoute() {
    		this.app.use('/', proxy(this.options.remoteServerUrl, {
    			userResDecorator: (rsp, rspData, req, res) => {
    				// Handle Redirects by modifying the location property of the response header
    				let location = res.get('location');
    				if (location) {
    					res.location(urlUtil.parse(location).path);
    				}

    				if (res._headers['set-cookie']) {
    					res.header('set-cookie', this.removeCookiesDomain(res._headers['set-cookie'] || []));
    				}

    				if (this.isRouteExcluded(req.method, req.url)) {
    					this.log(chalk.red('Exclude Proxied Resource', chalk.bold(req.method, req.url)));
    				} else {
    					let mimeType = res._headers['content-type'];

    					if (this.options.dataRecord && (this.options.cacheEverything || !this.options.cacheEverything && mimeType && mimeType.indexOf('application/json') >= 0)) {
    						let { key, hash } = this.genKeyFromExpressReq(req);
    						let entry = this.createHarEntry(new Date().toISOString(), req, res, rspData);
    						this.routeCache[key] = entry;
    						this.log(chalk.yellow('Saved to Cache', hash, chalk.bold(entry.request.method, entry.request.url)));
    					} else {
    						this.log(chalk.gray('Proxied Resource', chalk.bold(req.method, req.url)));
    					}
    				}

    				return rspData;
    			}
    		}));

    		return this;
    	}

    	/**
    	 * Start the server and generate any log output if needed
    	 * @param {Function} callback fn executed after the server has started
    	 * @returns {JsonCachingProxy}
    	 */
    	start(onStarted) {
    		this.server = this.app.listen(this.options.proxyPort);
    		this.server.setTimeout(this.options.proxyTimeout);

    		this.log(chalk.bold(`\JSON Caching Proxy Started:`));
    		this.log(chalk.gray(`==============\n`));
    		this.log(`Remote server url: \t${chalk.bold(this.options.remoteServerUrl)}`);
    		this.log(`Proxy running on port: \t${chalk.bold(this.options.proxyPort)}`);
    		this.log(`Proxy Timeout: \t\t${chalk.bold(this.options.proxyTimeout)}`);
    		this.log(`Replay cache: \t\t${chalk.bold(this.options.dataPlayback)}`);
    		this.log(`Save to cache: \t\t${chalk.bold(this.options.dataRecord)}`);
    		this.log(`Command prefix: \t${chalk.bold(this.options.commandPrefix)}`);
    		this.log(`Proxy response header: \t${chalk.bold(this.options.proxyHeaderIdentifier)}`);
    		this.log(`Cache all: \t\t${chalk.bold(this.options.cacheEverything)}`);
    		this.log(`Cache busting params: \t${chalk.bold(this.options.cacheBustingParams)}`);
    		this.log(`Excluded routes: `);
    		this.options.excludedRouteMatchers.forEach((regExp) => {
    			this.log(`\t\t\t${chalk.bold(regExp)}`)
    		});

    		this.log('\nListening...\n');

    		// The order of these routes is important
    		this.addHarEntriesToCache(this.options.harObject);
    		this.addAdminRoutes();
    		this.addMiddleWareRoutes(this.options.middlewareList);
    		this.addBodyParser();
    		this.addCachingRoute();
    		this.addProxyRoute();

    		onStarted && onStarted();

    		return this;
    	}

    	/**
    	 * Stops the proxy server
    		* @param {Function} callback fn executed after the server has stopped
    	 * @returns {JsonCachingProxy}
    	 */
    	stop(onStopped) {
    		if (this.server) {
    			this.server.close(onStopped);
    			this.log(chalk.bold('\nStopping Proxy Server'));
    		}

    		return this;
    	}

    	/**
    	 * Returns the options passed into the proxy
    	 * @returns {Object}
    	 */
    	getOptions() { return this.options; }

    	/**
    	 * Returns the default options that are used when no options are passed in
    	 * @returns {Object}
    	 */
    	getDefaultOptions() { return this.defaultOptions; }

    	/**
    	 * Returns the Express App
    	 * @returns {Object}
    	 */
    	getApp() { return this.app; }

    	/**
    	 * Returns the Node server object
    	 * @returns {Object}
    	 */
    	getServer() { return this.server; }

    	/**
    	 * Returns the key value map of all the excluded params
    	 * @returns {Object}
    	 */
    	getExcludedParamMap() { return this.excludedParamMap; }

    	/**
    	 * Count of total cached routes in memory
    	 * @returns {number}
    	 */
    	getTotalCachedRoutes() { return Object.keys(this.routeCache).length; }

    	/**
    	 * Determines if we have anything in the cache
    	 * @returns {boolean}
    	 */
    	isRouteCacheEmpty() { return this.getTotalCachedRoutes() === 0; }

    	/**
    	 * Is the server sending us cached responses
    	 * @returns {boolean}
    	 */
    	isReplaying() { return this.options.dataPlayback; }

    	/**
    	 * Is the server saving to the cache
    	 * @returns {JsonCachingProxy}
    	 */
    	isRecording() { return this.options.dataRecord; }
    }

    module.exports = JsonCachingProxy;