495 lines
18 KiB
JavaScript
495 lines
18 KiB
JavaScript
var _ = require('lodash'),
|
|
core = require('./core'),
|
|
Emitter = require('events'),
|
|
inherits = require('inherits'),
|
|
now = require('performance-now'),
|
|
sdk = require('postman-collection'),
|
|
requests = require('./request-wrapper'),
|
|
dryRun = require('./dry-run'),
|
|
|
|
RESPONSE_START_EVENT_BASE = 'response.start.',
|
|
RESPONSE_END_EVENT_BASE = 'response.end.',
|
|
|
|
RESPONSE_START = 'responseStart',
|
|
RESPONSE_END = 'response',
|
|
|
|
ERROR_RESTRICTED_ADDRESS = 'NETERR: getaddrinfo ENOTFOUND ',
|
|
|
|
/**
|
|
* Headers which get overwritten by the requester.
|
|
*
|
|
* @private
|
|
* @const
|
|
* @type {Object}
|
|
*/
|
|
OVERWRITTEN_HEADERS = {
|
|
cookie: true, // cookies get appended with `;`
|
|
'content-length': true
|
|
},
|
|
|
|
/**
|
|
* Creates a sdk compatible cookie from a tough-cookie compatible cookie.
|
|
*
|
|
* @param cookie
|
|
* @returns {Object}
|
|
*/
|
|
toPostmanCookie = function (cookie) {
|
|
var expires = cookie.expiryTime();
|
|
|
|
cookie.toJSON && (cookie = cookie.toJSON());
|
|
|
|
return new sdk.Cookie({
|
|
name: cookie.key,
|
|
value: cookie.value,
|
|
expires: Number.isFinite(expires) ? new Date(expires) : null,
|
|
maxAge: cookie.maxAge,
|
|
domain: cookie.domain,
|
|
path: cookie.path,
|
|
secure: cookie.secure,
|
|
httpOnly: cookie.httpOnly,
|
|
hostOnly: cookie.hostOnly,
|
|
extensions: cookie.extensions
|
|
});
|
|
},
|
|
|
|
/**
|
|
* This method is used in conjunction with _.transform method to convert multi-value headers to multiple single
|
|
* value headers
|
|
*
|
|
* @param {Array} acc
|
|
* @param {Array|String} val
|
|
* @param {String} key
|
|
* @return {Object}
|
|
*/
|
|
transformMultiValueHeaders = function (acc, val, key) {
|
|
var i, ii;
|
|
|
|
if (Array.isArray(val)) {
|
|
for (i = 0, ii = val.length; i < ii; i++) {
|
|
acc.push({
|
|
key: key,
|
|
value: val[i]
|
|
});
|
|
}
|
|
}
|
|
else {
|
|
acc.push({
|
|
key: key,
|
|
value: val
|
|
});
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Calculate request timings offset by adding runtime overhead which
|
|
* helps to determine request prepare and process time taken.
|
|
*
|
|
* @param {Number} runtimeTimer - Runtime request start HR time
|
|
* @param {Number} requestTimer - Request start HR time
|
|
* @param {Object} timings - Request timings offset
|
|
* @returns {Object}
|
|
*/
|
|
calcTimingsOffset = function (runtimeTimer, requestTimer, timings) {
|
|
if (!(runtimeTimer && requestTimer && timings)) { return; }
|
|
|
|
// runtime + postman-request initialization time
|
|
var initTime = requestTimer - runtimeTimer,
|
|
offset = {
|
|
request: initTime
|
|
};
|
|
|
|
// add initialization overhead to request offsets
|
|
_.forOwn(timings, function (value, key) {
|
|
offset[key] = value + initTime;
|
|
});
|
|
|
|
// total time taken by runtime to get the response
|
|
// @note if offset.end is missing, that means request is not complete.
|
|
// this is used to calculate timings on responseStart.
|
|
if (offset.end) {
|
|
offset.done = now() - runtimeTimer;
|
|
}
|
|
|
|
return offset;
|
|
},
|
|
|
|
Requester;
|
|
|
|
/**
|
|
* Creates a new Requester, which is used to make HTTP(s) requests.
|
|
*
|
|
* @param trace
|
|
* @param options
|
|
* @param {Boolean} [options.keepAlive=true] Optimizes HTTP connections by keeping them alive, so that new requests
|
|
* to the same host are made over the same underlying TCP connection.
|
|
* @param {CookieJar} [options.cookieJar] A cookie jar to use with Node requests.
|
|
* @param {Boolean} [options.strictSSL]
|
|
* @param {Boolean} [options.followRedirects=true] If false, returns a 301/302 as the response code
|
|
* instead of following the redirect
|
|
* @note `options.keepAlive` is only supported in Node.
|
|
* @note `options.cookieJar` is only supported in Node.
|
|
*
|
|
* @constructor
|
|
*/
|
|
inherits(Requester = function (trace, options) {
|
|
this.options = options || {};
|
|
|
|
// protect the timeout value from being non-numeric or infinite
|
|
if (!_.isFinite(this.options.timeout)) {
|
|
this.options.timeout = undefined;
|
|
}
|
|
|
|
this.trace = trace;
|
|
Requester.super_.call(this);
|
|
}, Emitter);
|
|
|
|
_.assign(Requester.prototype, /** @lends Requester.prototype */ {
|
|
|
|
/**
|
|
* Perform an HTTP request.
|
|
*
|
|
* @param {String} id
|
|
* @param {Request} request
|
|
* @param {Object} protocolProfileBehavior
|
|
* @param {Function} callback
|
|
*/
|
|
request: function (id, request, protocolProfileBehavior, callback) {
|
|
var self = this,
|
|
hostname,
|
|
cookieJar,
|
|
requestOptions,
|
|
networkOptions = self.options.network || {},
|
|
startTime = Date.now(),
|
|
startTimer = now(), // high-resolution time
|
|
cookies = [],
|
|
responseHeaders = [],
|
|
responseJSON = {},
|
|
|
|
// keep track of `responseStart` and `response` triggers
|
|
_responseStarted = false,
|
|
_responseEnded = false,
|
|
_responseData = {},
|
|
|
|
// Refer: https://github.com/postmanlabs/postman-runtime/blob/v7.14.0/docs/history.md
|
|
getExecutionHistory = function (debugInfo) {
|
|
var history = {
|
|
execution: {
|
|
verbose: Boolean(requestOptions.verbose),
|
|
sessions: {},
|
|
data: []
|
|
}
|
|
},
|
|
executionData = [],
|
|
requestSessions = {};
|
|
|
|
if (!Array.isArray(debugInfo)) {
|
|
return history;
|
|
}
|
|
|
|
// prepare history from request debug data
|
|
debugInfo.forEach(function (debugData) {
|
|
if (!debugData) { return; }
|
|
|
|
// @todo cache connection sessions and fetch reused session
|
|
// from the requester pool.
|
|
if (debugData.session && !requestSessions[debugData.session.id]) {
|
|
requestSessions[debugData.session.id] = debugData.session.data;
|
|
}
|
|
|
|
executionData.push({
|
|
request: debugData.request,
|
|
response: debugData.response,
|
|
timings: debugData.timings && {
|
|
// runtime start time
|
|
start: startTime,
|
|
// request start time
|
|
requestStart: debugData.timingStart,
|
|
// offsets calculated are relative to runtime start time
|
|
offset: calcTimingsOffset(startTimer, debugData.timingStartTimer, debugData.timings)
|
|
},
|
|
session: debugData.session && {
|
|
id: debugData.session.id,
|
|
// is connection socket reused
|
|
reused: debugData.session.reused
|
|
}
|
|
});
|
|
});
|
|
|
|
// update history object
|
|
history.execution.data = executionData;
|
|
history.execution.sessions = requestSessions;
|
|
|
|
return history;
|
|
},
|
|
|
|
/**
|
|
* Add the missing/system headers in the request object
|
|
*
|
|
* @param {Object[]} headers
|
|
*/
|
|
addMissingRequestHeaders = function (headers) {
|
|
_.forEach(headers, function (header) {
|
|
var lowerCasedKey = header.key.toLowerCase();
|
|
|
|
// update headers which gets overwritten by the requester
|
|
if (OVERWRITTEN_HEADERS[lowerCasedKey]) {
|
|
if (Array.isArray(_.get(request.headers, ['reference', lowerCasedKey]))) {
|
|
request.headers.remove(header.key);
|
|
}
|
|
|
|
request.headers.upsert({
|
|
key: header.key,
|
|
value: header.value,
|
|
system: true
|
|
});
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Helper function to trigger `callback` and complete the request function
|
|
*
|
|
* @param {Error} error - error while requesting
|
|
* @param {Response} response - SDK Response instance
|
|
* @param {Object} history - Request-Response History
|
|
*/
|
|
onEnd = function (error, response, history) {
|
|
self.emit(RESPONSE_END_EVENT_BASE + id, error, self.trace.cursor,
|
|
self.trace, response, request, cookies, history);
|
|
|
|
return callback(error, response, request, cookies, history);
|
|
},
|
|
|
|
/**
|
|
* Helper function to keep track of `responseStart` and `response`
|
|
* triggers to make they are emitted in correct order.
|
|
*
|
|
* @todo fix requester control flow to remove this hack!
|
|
* this is required because CookieJar.getCookies is async method
|
|
* and by that time postman-request ends the request, which affects
|
|
* request post-send helpers because `response.start` event is not
|
|
* emitted on time and shared variables `cookies`, `responseJSON`,
|
|
* and, `responseHeaders` are initialized in onStart function.
|
|
*
|
|
* @param {String} trigger - trigger name
|
|
* @param {Response} response - SDK Response instance
|
|
* @param {Object} history - Request-Response History
|
|
*/
|
|
onComplete = function (trigger, response, history) {
|
|
if (trigger === RESPONSE_START) {
|
|
// set flag for responseStart callback
|
|
_responseStarted = true;
|
|
|
|
// if response is ended, end the response using cached data
|
|
if (_responseEnded) {
|
|
onEnd(null, _responseData.response, _responseData.history);
|
|
}
|
|
|
|
// bail out and wait for response end if not ended already
|
|
return;
|
|
}
|
|
|
|
// if response started, don't wait and end the response
|
|
if (_responseStarted) {
|
|
onEnd(null, response, history);
|
|
|
|
return;
|
|
}
|
|
|
|
// wait for responseStart and cache response callback data
|
|
_responseEnded = true;
|
|
_responseData = {
|
|
response: response,
|
|
history: history
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Helper function to trigger `responseStart` callback and
|
|
* - transform postman-request response instance to SDK Response
|
|
* - filter cookies
|
|
* - filter response headers
|
|
* - add missing request headers
|
|
*
|
|
* @param {Object} response - Postman-Request response instance
|
|
*/
|
|
onStart = function (response) {
|
|
var responseStartEventName = RESPONSE_START_EVENT_BASE + id,
|
|
executionData,
|
|
initialRequest,
|
|
finalRequest,
|
|
sdkResponse,
|
|
history,
|
|
done = function () {
|
|
// emit the response.start event which eventually
|
|
// triggers responseStart callback
|
|
self.emit(responseStartEventName, null, sdkResponse, request, cookies, history);
|
|
|
|
// trigger completion of responseStart
|
|
onComplete(RESPONSE_START);
|
|
};
|
|
|
|
// @todo get rid of jsonifyResponse
|
|
responseJSON = core.jsonifyResponse(response, requestOptions);
|
|
|
|
// transform response headers to SDK compatible HeaderList
|
|
responseHeaders = _.transform(responseJSON.headers, transformMultiValueHeaders, []);
|
|
|
|
// initialize SDK Response instance
|
|
sdkResponse = new sdk.Response({
|
|
status: response && response.statusMessage,
|
|
code: responseJSON.statusCode,
|
|
header: responseHeaders
|
|
});
|
|
|
|
// prepare history from request debug data
|
|
history = getExecutionHistory(_.get(response, 'request._debug'));
|
|
|
|
// get the initial and final (on redirect) request from history
|
|
executionData = _.get(history, 'execution.data') || [];
|
|
initialRequest = _.get(executionData, '[0].request') || {};
|
|
finalRequest = executionData.length > 1 ?
|
|
// get final redirect
|
|
_.get(executionData, [executionData.length - 1, 'request']) :
|
|
// no redirects
|
|
initialRequest;
|
|
|
|
// add missing request headers so that they get bubbled up into the UI
|
|
addMissingRequestHeaders(initialRequest.headers);
|
|
|
|
// pull out cookies from the cookie jar, and make them chrome compatible.
|
|
if (cookieJar && _.isFunction(cookieJar.getCookies)) {
|
|
// get cookies set for the final request URL
|
|
cookieJar.getCookies(finalRequest.href, function (err, cookiesFromJar) {
|
|
if (err) {
|
|
return done();
|
|
}
|
|
|
|
cookies = _.transform(cookiesFromJar, function (acc, cookie) {
|
|
acc.push(toPostmanCookie(cookie));
|
|
}, []);
|
|
|
|
cookies = new sdk.CookieList(null, cookies);
|
|
|
|
done();
|
|
});
|
|
}
|
|
else {
|
|
cookies = new sdk.CookieList(null, []);
|
|
done();
|
|
}
|
|
};
|
|
|
|
// at this point the request could have come from collection, auth or sandbox
|
|
// we can't trust the integrity of this request
|
|
// bail out if request url is empty
|
|
if (!(request && request.url && request.url.toString && request.url.toString())) {
|
|
return onEnd(new Error('runtime:extensions~request: request url is empty'));
|
|
}
|
|
|
|
cookieJar = self.options.cookieJar;
|
|
requestOptions = core.getRequestOptions(request, self.options, protocolProfileBehavior);
|
|
|
|
// update url with the final encoded url
|
|
// @note this mutates the request object which will be passed in request
|
|
// and response callbacks
|
|
request.url.update(requestOptions.url.href);
|
|
hostname = request.url.getHost();
|
|
|
|
// check if host is on the `restrictedAddresses`
|
|
if (networkOptions.restrictedAddresses && core.isAddressRestricted(hostname, networkOptions)) {
|
|
return onEnd(new Error(ERROR_RESTRICTED_ADDRESS + hostname));
|
|
}
|
|
|
|
return requests(request, requestOptions, onStart, function (err, res, resBody, debug) {
|
|
// prepare history from request debug data
|
|
var history = getExecutionHistory(debug),
|
|
responseTime,
|
|
response;
|
|
|
|
if (err) {
|
|
// bubble up http errors
|
|
// @todo - Should we send an empty sdk Response here?
|
|
//
|
|
// Sending `history` object even in case of error
|
|
return onEnd(err, undefined, history);
|
|
}
|
|
|
|
|
|
// Calculate the time taken for us to get the response.
|
|
responseTime = Date.now() - startTime;
|
|
|
|
if (res && res.timings) {
|
|
// update response time to actual response end time
|
|
// of the final request in the redirect chain.
|
|
responseTime = Math.ceil(res.timings.end);
|
|
}
|
|
|
|
if (resBody && resBody instanceof ArrayBuffer) {
|
|
resBody = Buffer.from(resBody);
|
|
}
|
|
|
|
// Response in the SDK format
|
|
// @todo reuse same response instance used for responseStart callback
|
|
response = new sdk.Response({
|
|
code: responseJSON.statusCode,
|
|
status: res && res.statusMessage,
|
|
header: responseHeaders,
|
|
stream: resBody,
|
|
responseTime: responseTime
|
|
});
|
|
|
|
onComplete(RESPONSE_END, response, history);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Removes all current event listeners on the requester, and makes it ready for garbage collection :).
|
|
*
|
|
* @param {Function=} cb - Optional callback to be called on disposal
|
|
*
|
|
* @todo - In the future, when the requester manages its own connections etc, close them all here.
|
|
*/
|
|
dispose: function (cb) {
|
|
// This is safe for us, because we do not use wait on events. (i.e, no part of Runtime ever waits on
|
|
// any event to occur). We rely on callbacks for that, only choosing to use events as a way of streaming
|
|
// information outside runtime.
|
|
this.removeAllListeners();
|
|
|
|
_.isFunction(cb) && cb();
|
|
}
|
|
});
|
|
|
|
_.assign(Requester, /** @lends Requester */ {
|
|
/**
|
|
* Asynchronously create a new requester.
|
|
*
|
|
* @param trace
|
|
* @param trace.type - type of requester to return (for now, just http)
|
|
* @param trace.source - information about who needs this requester, e.g Auth, etc.
|
|
* @param trace.cursor - the cursor
|
|
* @param options
|
|
* @param callback
|
|
* @returns {*}
|
|
*/
|
|
create: function (trace, options, callback) {
|
|
return callback(null, new Requester(trace, options));
|
|
},
|
|
|
|
/**
|
|
* A helper method to dry run the given request instance.
|
|
* It returns the cloned request instance with the system added properties.
|
|
*
|
|
* @param {Request} request
|
|
* @param {Object} options
|
|
* @param {Object} options.cookieJar
|
|
* @param {Object} options.protocolProfileBehavior
|
|
* @param {Object} options.implicitCacheControl
|
|
* @param {Object} options.implicitTraceHeader
|
|
* @param {Function} done
|
|
*/
|
|
dryRun
|
|
});
|
|
|
|
module.exports.Requester = Requester;
|