var _ = require('lodash'),
dns = require('dns'),
Socket = require('net').Socket,
LOCAL_IPV6 = '::1',
LOCAL_IPV4 = '127.0.0.1',
LOCALHOST = 'localhost',
SOCKET_TIMEOUT = 500,
COLON = ':',
HOSTS_TYPE = {
HOST_IP_MAP: 'hostIpMap'
},
HTTPS = 'https',
HTTPS_DEFAULT_PORT = 443,
HTTP_DEFAULT_PORT = 80,
S_CONNECT = 'connect',
S_ERROR = 'error',
S_TIMEOUT = 'timeout',
sdk = require('postman-collection'),
version = require('../../package.json').version,
ERROR_ADDRESS_RESOLVE = 'NETERR: getaddrinfo ENOTFOUND ',
/**
* Different modes for a request body.
*
* @enum {String}
*/
REQUEST_MODES = {
RAW: 'raw',
URLENCODED: 'urlencoded',
FORMDATA: 'formdata',
FILE: 'file'
},
GET = 'get',
/**
* An iterative helper to reduce formdata/urlencoded request bodies from object arrays to a singular aggregated
* object.
*
* @param {Object} accumulator - The resultant aggregated object representation for the request body.
* @param {Object} param - One of many request body elements.
* @param {Boolean} param.disabled - A flag that can be set to true to indicate the disabled status of the param.
* @param {String} param.key - The name of the current body parameter.
* @param {String} param.value - The value of the current body parameter.
* @param {String} param.description - The description associated with the current request body parameter.
* @returns {*}
*/
transformRequestBody = function (accumulator, param) {
if (param.disabled) { return accumulator; }
var key = param.key,
value = param.value;
// This is actually pretty simple,
// If the variable already exists in the accumulator, we need to make the value an Array with
// all the variable values inside it.
// We can't replace the ad-hoc condition below with a sans-prototype object as an accumulator,
// since the request library needs to perform hasOwnProperty checks on the passed req body object
if (_.has(accumulator, key)) {
_.isArray(accumulator[key]) ? accumulator[key].push(value) :
(accumulator[key] = [accumulator[key], value]);
}
else {
accumulator[param.key] = param.value;
}
return accumulator;
},
/**
* Abstracts out the logic for domain resolution
* @param options
* @param hostLookup
* @param hostLookup.type
* @param hostLookup.hostIpMap
* @param hostname
* @param callback
*/
_lookup = function (options, hostLookup, hostname, callback) {
var hostIpMap,
resolvedFamily = 4,
resolvedAddr;
// first we try to resolve the hostname using hosts file configuration
hostLookup && hostLookup.type === HOSTS_TYPE.HOST_IP_MAP &&
(hostIpMap = hostLookup[HOSTS_TYPE.HOST_IP_MAP]) && (resolvedAddr = hostIpMap[hostname]);
if (resolvedAddr) {
// since we only get an string for the resolved ip address, we manually find it's family (4 or 6)
// there will be at-least one `:` in an IPv6 (https://en.wikipedia.org/wiki/IPv6_address#Representation)
resolvedAddr.indexOf(COLON) !== -1 && (resolvedFamily = 6); // eslint-disable-line lodash/prefer-includes
// returning error synchronously causes uncaught error because listeners are not attached to error events
// on socket yet
return setImmediate(function () {
callback(null, resolvedAddr, resolvedFamily);
});
}
// no hosts file configuration provided or no match found. Falling back to normal dns lookup
return dns.lookup(hostname, options, callback);
},
/**
* Tries to make a TCP connection to the given host and port. If successful, the connection is immediately
* destroyed.
*
* @param host
* @param port
* @param callback
*/
connect = function (host, port, callback) {
var socket = new Socket(),
called,
done = function (type) {
if (!called) {
callback(type === S_CONNECT ? null : true); // eslint-disable-line callback-return
called = true;
this.destroy();
}
};
socket.setTimeout(SOCKET_TIMEOUT, done.bind(socket, S_TIMEOUT));
socket.once('connect', done.bind(socket, S_CONNECT));
socket.once('error', done.bind(socket, S_ERROR));
socket.connect(port, host);
socket = null;
},
/**
* Override DNS lookups in Node, to handle localhost as a special case.
* Chrome tries connecting to IPv6 by default, so we try the same thing.
*
* @param lookupOptions
* @param lookupOptions.port
* @param lookupOptions.network
* @param lookupOptions.network.restrictedAddresses
* @param lookupOptions.network.hostLookup
* @param lookupOptions.network.hostLookup.type
* @param lookupOptions.network.hostLookup.hostIpMap
* @param hostname
* @param options
* @param callback
*/
lookup = function (lookupOptions, hostname, options, callback) {
var self = this,
lowercaseHost = hostname.toLowerCase(),
networkOpts = lookupOptions.network || {},
hostLookup = networkOpts.hostLookup;
if (lowercaseHost !== LOCALHOST) {
return _lookup(options, hostLookup, lowercaseHost, function (err, addr, family) {
if (err) { return callback(err); }
return callback(self.isAddressRestricted(addr, networkOpts) ?
new Error(ERROR_ADDRESS_RESOLVE + hostname) : null, addr, family);
});
}
// Try checking if we can connect to IPv6 localhost ('::1')
connect(LOCAL_IPV6, lookupOptions.port, function (err) {
// use IPv4 if we cannot connect to IPv6
if (err) { return callback(null, LOCAL_IPV4, 4); }
callback(null, LOCAL_IPV6, 6);
});
};
module.exports = {
/**
* Creates a node request compatible options object from a request.
*
* @param request
* @param defaultOpts
* @param defaultOpts.keepAlive
* @param defaultOpts.timeout
* @param defaultOpts.strictSSL
* @param defaultOpts.cookieJar The cookie jar to use (if any).
* @param defaultOpts.followRedirects
* @returns {{}}
*/
getRequestOptions: function (request, defaultOpts) {
var options = {},
networkOptions = defaultOpts.network || {},
self = this,
bodyParams,
url,
isSSL,
portNumber,
port = request.url && request.url.port,
hostname = request.url && request.url.getHost();
!defaultOpts && (defaultOpts = {});
// @todo replace this with request.header.toObject(true, true, true, true); once the sanitize option has been
// added to PropertyList~toObject
options.headers = self.getRequestHeaders(request);
url = request.url.toString();
options.url = (/^https?:\/\//).test(url) ? url : 'http://' + url;
options.method = request.method;
options.jar = defaultOpts.cookieJar || true;
options.timeout = defaultOpts.timeout;
options.gzip = true;
// Ensures that "request" creates URL encoded formdata or querystring as
// foo=bar&foo=baz instead of foo[0]=bar&foo[1]=baz
options.useQuerystring = true;
options.strictSSL = defaultOpts.strictSSL;
// keeping the same convention as Newman
options.followRedirect = defaultOpts.followRedirects;
options.followAllRedirects = defaultOpts.followRedirects;
// Request body may return different options depending on the type of the body.
bodyParams = self.getRequestBody(request, defaultOpts);
// set encoding to null so that the response is a stream
options.encoding = null;
// Insert any headers that XHR inserts, to keep the Node version compatible with the Chrome App
if (bodyParams && bodyParams.body) {
self.ensureHeaderExists(options.headers, 'Content-Type', 'text/plain');
}
self.ensureHeaderExists(options.headers, 'User-Agent', 'PostmanRuntime/' + version);
self.ensureHeaderExists(options.headers, 'Accept', '*/*');
// The underlying Node client does add the host header by itself, but we add it anyway, so that
// it is bubbled up to us after the request is made. If left to the underlying core, it's not :/
self.ensureHeaderExists(options.headers, 'Host', request.url.getRemote());
// override DNS lookup
if (networkOptions.restrictedAddresses || hostname.toLowerCase() === LOCALHOST || networkOptions.hostLookup) {
isSSL = _.startsWith(request.url.protocol, HTTPS);
portNumber = Number(port) || (isSSL ? HTTPS_DEFAULT_PORT : HTTP_DEFAULT_PORT);
_.isFinite(portNumber) && (options.lookup = lookup.bind(this, {
port: portNumber,
network: networkOptions
}));
}
_.assign(options, bodyParams, {
agentOptions: {
keepAlive: defaultOpts.keepAlive
}
});
return options;
},
/**
* Checks if a header already exists. If it does not, sets the value to whatever is passed as
* `defaultValue`
*
* @param {object} headers
* @param {String} headerKey
* @param {String} defaultValue
*/
ensureHeaderExists: function (headers, headerKey, defaultValue) {
var headerName = _.findKey(headers, function (value, key) {
return key.toLowerCase() === headerKey.toLowerCase();
});
if (!headerName) {
headers[headerKey] = defaultValue;
}
},
/**
* Returns a header object
*
* @param request
* @returns {{}}
*/
getRequestHeaders: function (request) {
var headers = {};
if (request.headers) {
request.forEachHeader(function (header) {
if (header && (header.disabled || !header.key)) { return; }
var key = header.key,
value = header.value;
if (headers[key]) {
_.isArray(headers[key]) ? headers[key].push(value) :
(headers[key] = [headers[key], value]);
}
else {
headers[key] = value;
}
});
return headers;
}
},
/**
* Processes a request body and puts it in a format compatible with
* the "request" library.
*
* @todo: Move this to the SDK.
* @param request
* @param options
*/
getRequestBody: function (request, options) {
var mode = _.get(request, 'body.mode'),
body = _.get(request, 'body'),
method = request && _.isString(request.method) ? request.method.toLowerCase() : undefined,
empty = body ? body.isEmpty() : true,
content,
computedBody;
if (empty || (method === GET && !options.sendBodyWithGetRequests)) {
return;
}
content = body[mode];
if (_.isFunction(content.all)) {
content = content.all();
}
if (mode === REQUEST_MODES.RAW) {
computedBody = {body: content};
}
else if (mode === REQUEST_MODES.URLENCODED) {
computedBody = {
form: _.reduce(content, transformRequestBody, {})
};
}
else if (request.body.mode === REQUEST_MODES.FORMDATA) {
computedBody = {
formData: _.reduce(content, transformRequestBody, {})
};
}
else if (request.body.mode === REQUEST_MODES.FILE) {
computedBody = {
body: _.get(request, 'body.file.content')
};
}
return computedBody;
},
/**
* Returns a JSON compatible with the Node's request library. (Also contains the original request)
*
* @param rawResponse Can be an XHR response or a Node request compatible response.
* about the actual request that was sent.
* @param requestOptions Options that were used to send the request.
* @param responseBody Body as a string.
*/
jsonifyResponse: function (rawResponse, requestOptions, responseBody) {
if (!rawResponse) {
return;
}
var responseJSON;
if (rawResponse.toJSON) {
responseJSON = rawResponse.toJSON();
responseJSON.request && _.assign(responseJSON.request, {
data: requestOptions.form || requestOptions.formData || requestOptions.body || {},
uri: { // @todo remove this
href: requestOptions.url
},
url: requestOptions.url
});
rawResponse.rawHeaders &&
(responseJSON.headers = this.arrayPairsToObject(rawResponse.rawHeaders) || responseJSON.headers);
return responseJSON;
}
responseBody = responseBody || '';
// XHR :/
return {
statusCode: rawResponse.status,
body: responseBody,
headers: _.transform(sdk.Header.parse(rawResponse.getAllResponseHeaders()), function (acc, header) {
acc[header.key] = header.value;
}, {}),
request: {
method: requestOptions.method || 'GET',
headers: requestOptions.headers,
uri: { // @todo remove this
href: requestOptions.url
},
url: requestOptions.url,
data: requestOptions.form || requestOptions.formData || requestOptions.body || {}
}
};
},
/**
* ArrayBuffer to String
*
* @param {ArrayBuffer} buffer
* @returns {String}
*/
arrayBufferToString: function (buffer) {
var str = '',
uArrayVal = new Uint8Array(buffer),
i,
ii;
for (i = 0, ii = uArrayVal.length; i < ii; i++) {
str += String.fromCharCode(uArrayVal[i]);
}
return str;
},
/**
* Converts an array of sequential pairs to an object.
*
* @param arr
* @returns {{}}
*
* @example
* ['a', 'b', 'c', 'd'] ====> {a: 'b', c: 'd' }
*/
arrayPairsToObject: function (arr) {
if (!_.isArray(arr)) {
return;
}
var obj = {},
key,
val,
i,
ii;
for (i = 0, ii = arr.length; i < ii; i += 2) {
key = arr[i];
val = arr[i + 1];
if (_.has(obj, key)) {
!_.isArray(obj[key]) && (obj[key] = [obj[key]]);
obj[key].push(val);
}
else {
obj[key] = val;
}
}
return obj;
},
/**
* Checks if a given host or IP is has been restricted in the options.
*
* @param {String} host
* @param {Object} networkOptions
* @param {Array<String>} networkOptions.restrictedAddresses
*
* @returns {Boolean}
*/
isAddressRestricted: function (host, networkOptions) {
return networkOptions.restrictedAddresses &&
networkOptions.restrictedAddresses[(host && host.toLowerCase())];
}
};