import HarWriter from '../har.js';
import randomUseragent from 'random-useragent';
import moment from 'moment-timezone';
import sift from 'sift';
import needle from 'needle';
import promiseRetry from 'promise-retry';
let overrideFunc = null;
/**
* Set an override function to use for HTTP requests
* Accepts (method, URL, data, options)
* @param {function} fn
*/
export function setOverrideFunction(fn) {
overrideFunc = fn;
}
/**
* Generate a random Android user agent for making network requests
* @return {string}
*/
export function generateRandomAndroidUseragent() {
return randomUseragent.getRandom((ua) => {
return (ua.osName === 'Android');
});
}
// start our har writer (if debugging)
const harWriter = process.env['THEMEPARKS_HAR'] ?
new HarWriter({filename: `${process.env['THEMEPARKS_HAR']}.har`}) :
null;
/**
* Write a HTTP response to HAR file for debugging
* @param {*} method
* @param {*} url
* @param {*} data
* @param {*} options
* @param {*} resp
* @param {*} startTime
* @param {*} timeTaken
* @private
*/
async function writeToHAR(method, url, data, options, resp, startTime, timeTaken) {
const objToArr = (obj) => {
return Object.keys(obj).map((header) => {
return {name: header, value: obj[header].toString()};
});
};
const entry = {
startedDateTime: startTime,
time: timeTaken,
request: {
method: method,
url: url,
httpVersion: `HTTP/${resp.httpVersion}`, // this is actually the response, TODO
cookies: [],
headers: objToArr(options.headers), // not the actual headers needle sends - TODO, how to get these?
queryString: method === 'GET' ? objToArr(data) : [], // TODO - parse from needle's .path
postData: {
mimeType: options.json ? 'application/json' : (options.headers['content-type'] || ''),
params: method !== 'GET' ? [] : [],
text: '',
},
headersSize: -1,
bodySize: -1,
},
response: {
status: resp.statusCode,
statusText: resp.statusMessage,
httpVersion: `HTTP/${resp.httpVersion}`,
cookies: [],
headers: objToArr(resp.headers),
content: {
size: resp.raw.length || -1,
mimeType: resp.headers['content-type'],
text: resp.raw.toString('base64'),
encoding: 'base64',
},
redirectURL: '',
headersSize: -1,
bodySize: -1,
},
cache: {},
timings: {
send: -1,
wait: -1,
receive: -1,
},
};
await harWriter.recordEntry(entry);
}
/**
* HTTP helper with injection
* @return {*}
*/
export const HTTP = (function() {
this._httpInjections = [];
this._httpResponseInjections = [];
this.useragent = generateRandomAndroidUseragent();
this.customOverrideFunc = null;
/**
* Helper function to make an HTTP request for this park
* Parks can automatically add in authentication headers etc. to requests sent to this function
* @param {string} method HTTP method to use (GET,POST,DELETE, etc)
* @param {string} url URL to request
* @param {object} [data] data to send. Will become querystring for GET, body for POST
* @param {object} [options = {}] Object containing needle-compatible HTTP options
*/
const mainFunction = async (method, url, data, options = {}) => {
// default to GET if we only have one argument
if (url === undefined && data === undefined) {
url = method;
method = 'GET';
}
// always have a headers array
if (!options.headers) {
options.headers = {};
}
// default to accepting compressed data
options.compressed = options.compressed === undefined ? true : options.compressed;
// 10 seconds default timeout opening response
options.response_timeout = options.response_timeout !== undefined ? options.response_timeout : 10000;
// 30 seconds default timeout for reading data (for large data streams)
options.read_timeout = options.read_timeout !== undefined ? options.read_timeout : 30000;
// inject custom standard user agent (if we have one)
// do this before any custom injections so parks can optionally override this for each domain
if (this.useragent && !options.headers['user-agent']) {
options.headers['user-agent'] = this.useragent;
}
if (!options.headers['user-agent'] && process.env.DEFAULT_USER_AGENT) {
// if no user-agent supplied, set a default one from the env
options.headers['user-agent'] = process.env.DEFAULT_USER_AGENT;
}
// check any hostname injections we have setup
const urlObj = new URL(url);
const urlFilter = {
protocol: urlObj.protocol,
host: urlObj.host,
hostname: urlObj.hostname,
pathname: urlObj.pathname,
search: urlObj.search,
hash: urlObj.hash,
};
// wrap our needle call in a retry
return await promiseRetry({
retries: options.retries === undefined ? 3 : options.retries,
}, async (retryFn) => {
// make sure we run initial injections on each retry
for (let injectionIDX = 0; injectionIDX < this._httpInjections.length; injectionIDX++) {
const injection = this._httpInjections[injectionIDX];
// check if the domain matches
if (injection.filter(urlFilter)) {
const injectionResp = await injection.func(method, url, data, options);
if (injectionResp) {
url = injectionResp.url || url;
method = injectionResp.method || method;
data = injectionResp.data || data;
options = injectionResp.options || options;
}
}
}
// record some stats for the optional HAR Writer
const startMs = +new Date();
const startTime = moment(startMs).toISOString();
// optionally override the HTTP function to use
const httpFunc = this.customOverrideFunc || (overrideFunc ? overrideFunc : needle);
return httpFunc(method, url, data, options).then(async (resp) => {
// intercept response to write to our .har file
if (harWriter) {
await writeToHAR(method, url, data, options, resp, startTime, (+new Date()) - startMs);
}
// call any response injections
for (let injectionIDX = 0; injectionIDX < this._httpResponseInjections.length; injectionIDX++) {
const injection = this._httpResponseInjections[injectionIDX];
// check if the domain matches
// (reuse urlFilter from the incoming injections)
if (injection.filter(urlFilter)) {
resp = await injection.func(resp, method, url, data, options);
}
}
// if our response if now undefined, retry our request
if (resp === undefined) {
return retryFn();
}
// if we got an error code, retry our request
if (!options.ignoreErrors && resp.statusCode >= 400) {
return retryFn();
}
// force response to JSON object if options.json is set
if (options.json && resp.body && typeof resp.body === 'string') {
try {
resp.body = JSON.parse(resp.body);
} catch (e) {
// ignore
}
}
return resp;
});
});
};
/**
* Register a new injection for a specific domain
* @param {object} filter Mongo-type query to use to match a URL
* @param {function} func Function to call with needle request to inject extra data into.
* Function will take arguments: (method, URL, data, options)
*/
mainFunction.injectForDomain = (filter, func) => {
// add to our array of injections, this is processing by HTTP()
this._httpInjections.push({
filter: sift(filter),
func,
});
};
/**
* Register a new response injection for a specific domain
* @param {object} filter Mongo-type query to use to match a URL
* @param {function} func Function to call with needle response object to make changes
* Function will take arguments: (response)
* Function *must* return the response object back, or undefined if you want to force a retry
*/
mainFunction.injectForDomainResponse = (filter, func) => {
this._httpResponseInjections.push({
filter: sift(filter),
func,
});
};
/**
* Helper function to make a GET request
* @param {string} url URL to request
* @param {object} [data] data to send. Will become querystring for GET, body for POST
* @param {object} [options = {}] Object containing needle-compatible HTTP options
* @return {Promise<*>}
*/
mainFunction.get = (url, data, options) => {
return mainFunction('GET', url, data, options);
};
/**
* Helper function to make a POST request
* @param {string} url URL to request
* @param {object} [data] data to send. Will become querystring for GET, body for POST
* @param {object} [options = {}] Object containing needle-compatible HTTP options
* @return {Promise<*>}
*/
mainFunction.post = (url, data, options) => {
return mainFunction('POST', url, data, options);
};
/**
* Helper function to make a PUT request
* @param {string} url URL to request
* @param {object} [data] data to send. Will become querystring for GET, body for POST
* @param {object} [options = {}] Object containing needle-compatible HTTP options
* @return {Promise<*>}
*/
mainFunction.put = (url, data, options) => {
return mainFunction('PUT', url, data, options);
};
/**
* Helper function to make a DELETE request
* @param {string} url URL to request
* @param {object} [data] data to send. Will become querystring for GET, body for POST
* @param {object} [options = {}] Object containing needle-compatible HTTP options
* @return {Promise<*>}
*/
mainFunction.delete = (url, data, options) => {
return mainFunction('DELETE', url, data, options);
};
/**
* Helper function to make a PATCH request
* @param {string} url URL to request
* @param {object} [data] data to send. Will become querystring for GET, body for POST
* @param {object} [options = {}] Object containing needle-compatible HTTP options
* @return {Promise<*>}
*/
mainFunction.patch = (url, data, options) => {
return mainFunction('PATCH', url, data, options);
};
/**
* Helper function to make a HEAD request
* @param {string} url URL to request
* @param {object} [data] data to send. Will become querystring for GET, body for POST
* @param {object} [options = {}] Object containing needle-compatible HTTP options
* @return {Promise<*>}
*/
mainFunction.head = (url, data, options) => {
return mainFunction('HEAD', url, data, options);
};
/**
* Helper function to make a OPTIONS request
* @param {string} url URL to request
* @param {object} [data] data to send. Will become querystring for GET, body for POST
* @param {object} [options = {}] Object containing needle-compatible HTTP options
* @return {Promise<*>}
*/
mainFunction.options = (url, data, options) => {
return mainFunction('OPTIONS', url, data, options);
};
/**
* Helper function to make a TRACE request
* @param {string} url URL to request
* @param {object} [data] data to send. Will become querystring for GET, body for POST
* @param {object} [options = {}] Object containing needle-compatible HTTP options
* @return {Promise<*>}
*/
mainFunction.trace = (url, data, options) => {
return mainFunction('TRACE', url, data, options);
};
/**
* Customise the network function used for this instance of the HTTP lib
* @param {*} fn
*/
mainFunction.setOverrideFunction = (fn) => {
this.customOverrideFunc = fn;
};
return mainFunction;
});
export default HTTP;