UNPKG

postman-runtime

Version:

Underlying library of executing Postman Collections (used by Newman)

544 lines (462 loc) 19.3 kB
var dns = require('dns'), constants = require('constants'), _ = require('lodash'), uuid = require('uuid/v4'), sdk = require('postman-collection'), urlEncoder = require('postman-url-encoder'), Socket = require('net').Socket, requestBodyBuilders = require('./core-body-builder'), version = require('../../package.json').version, 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', SSL_OP_NO = 'SSL_OP_NO_', ERROR_ADDRESS_RESOLVE = 'NETERR: getaddrinfo ENOTFOUND ', /** * List of request methods without body. * * @private * @type {Object} * * @note hash is used to reduce the lookup cost * these methods are picked from the app, which don't support body. * @todo move this list to SDK for parity. */ METHODS_WITHOUT_BODY = { get: true, copy: true, head: true, purge: true, unlock: true }, /** * List of request options with their corresponding protocol profile behavior property name; * * @private * @type {Object} */ PPB_OPTS = { // Enable or disable certificate verification strictSSL: 'strictSSL', // maximum number of redirects to follow (default: 10) maxRedirects: 'maxRedirects', // Controls redirect behavior // keeping the same convention as Newman followRedirect: 'followRedirects', followAllRedirects: 'followRedirects', // redirect with the original HTTP method (default: redirects with GET) followOriginalHttpMethod: 'followOriginalHttpMethod', // removes the `referer` header when a redirect happens (default: false) // @note `referer` header set in the initial request will be preserved during redirect chain removeRefererHeader: 'removeRefererHeaderOnRedirect' }, /** * Helper function to extract top level domain for the given hostname * * @private * * @param {String} hostname * @returns {String} */ getTLD = function (hostname) { if (!hostname) { return ''; } hostname = String(hostname); return hostname.substring(hostname.lastIndexOf('.') + 1); }, /** * 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 && hostname.toLowerCase(), networkOpts = lookupOptions.network || {}, hostLookup = networkOpts.hostLookup; // do dns.lookup if hostname is not one of: // - localhost // - *.localhost if (getTLD(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); }); }, /** * Resolves given property with protocol profile behavior. * Returns protocolProfileBehavior value if the given property is present. * Else, returns value defined in default options. * * @param {String} property - Property name to look for * @param {Object} defaultOpts - Default request options * @param {Object} protocolProfileBehavior - Protocol profile behaviors * @returns {*} - Resolved request option value */ resolveWithProtocolProfileBehavior = function (property, defaultOpts, protocolProfileBehavior) { // bail out if property or defaultOpts is not defined if (!(property && defaultOpts)) { return; } if (protocolProfileBehavior && protocolProfileBehavior.hasOwnProperty(property)) { return protocolProfileBehavior[property]; } return defaultOpts[property]; }; module.exports = { /** * Creates a node request compatible options object from a request. * * @param request * @param defaultOpts * @param defaultOpts.network * @param defaultOpts.keepAlive * @param defaultOpts.timeout * @param defaultOpts.strictSSL * @param defaultOpts.cookieJar The cookie jar to use (if any). * @param defaultOpts.followRedirects * @param defaultOpts.followOriginalHttpMethod * @param defaultOpts.maxRedirects * @param defaultOpts.maxResponseSize * @param defaultOpts.implicitCacheControl * @param defaultOpts.implicitTraceHeader * @param defaultOpts.removeRefererHeaderOnRedirect * @param defaultOpts.timings * @param protocolProfileBehavior * @returns {{}} */ getRequestOptions: function (request, defaultOpts, protocolProfileBehavior) { var options = {}, networkOptions = defaultOpts.network || {}, self = this, bodyParams, url = request.url && urlEncoder.toNodeUrl(request.url.toString(true)), isSSL, reqOption, portNumber, behaviorName, port = url && url.port, hostname = url && url.hostname && url.hostname.toLowerCase(); !defaultOpts && (defaultOpts = {}); !protocolProfileBehavior && (protocolProfileBehavior = {}); // resolve all *.localhost to localhost itself // RFC: 6761 section 6.3 (https://tools.ietf.org/html/rfc6761#section-6.3) if (getTLD(hostname) === LOCALHOST) { // @note setting hostname to localhost ensures that we override lookup function hostname = LOCALHOST; } options.url = url; options.method = request.method; options.jar = defaultOpts.cookieJar || true; options.timeout = defaultOpts.timeout; options.gzip = true; options.time = defaultOpts.timings; options.verbose = defaultOpts.verbose; options.extraCA = defaultOpts.extendedRootCA; // Disable encoding of URL in postman-request in order to use pre-encoded URL object returned from // toNodeUrl() function of postman-url-encoder options.disableUrlEncoding = 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; // set encoding to null so that the response is a stream options.encoding = null; // eslint-disable-next-line guard-for-in for (reqOption in PPB_OPTS) { behaviorName = PPB_OPTS[reqOption]; options[reqOption] = resolveWithProtocolProfileBehavior(behaviorName, defaultOpts, protocolProfileBehavior); } // use the server's cipher suite order instead of the client's during negotiation if (protocolProfileBehavior.tlsPreferServerCiphers) { options.honorCipherOrder = true; } // the SSL and TLS protocol versions to disabled during negotiation if (Array.isArray(protocolProfileBehavior.tlsDisabledProtocols)) { protocolProfileBehavior.tlsDisabledProtocols.forEach(function (protocol) { options.secureOptions |= constants[SSL_OP_NO + protocol]; }); } // order of cipher suites that the SSL server profile uses to establish a secure connection if (Array.isArray(protocolProfileBehavior.tlsCipherSelection)) { options.ciphers = protocolProfileBehavior.tlsCipherSelection.join(':'); } if (typeof defaultOpts.maxResponseSize === 'number') { options.maxResponseSize = defaultOpts.maxResponseSize; } // Request body may return different options depending on the type of the body. bodyParams = self.getRequestBody(request, protocolProfileBehavior); // @note getRequestBody may add system headers based on intent options.headers = request.getHeaders({enabled: true, sanitizeKeys: true}); // 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', '*/*'); defaultOpts.implicitCacheControl && self.ensureHeaderExists(options.headers, 'Cache-Control', 'no-cache'); // @todo avoid invoking uuid() if header exists defaultOpts.implicitTraceHeader && self.ensureHeaderExists(options.headers, 'Postman-Token', uuid()); // 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', url.host); // override DNS lookup if (networkOptions.restrictedAddresses || hostname === 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; } }, /** * Processes a request body and puts it in a format compatible with * the "request" library. * * @todo: Move this to the SDK. * @param request - Request object * @param protocolProfileBehavior - Protocol profile behaviors * * @returns {Object} */ getRequestBody: function (request, protocolProfileBehavior) { if (!(request && request.body)) { return; } var requestBody = request.body, requestBodyType = requestBody.mode, requestMethod = (typeof request.method === 'string') ? request.method.toLowerCase() : undefined, bodyIsEmpty = requestBody.isEmpty(), bodyIsDisabled = requestBody.disabled, bodyContent = requestBody[requestBodyType], // flag to decide body pruning for METHODS_WITHOUT_BODY // @note this will be `true` even if protocolProfileBehavior is undefined pruneBody = protocolProfileBehavior ? !protocolProfileBehavior.disableBodyPruning : true; // early bailout for empty or disabled body (this area has some legacy shenanigans) if (bodyIsEmpty || bodyIsDisabled) { return; } // bail out if request method doesn't support body and pruneBody is true. if (METHODS_WITHOUT_BODY[requestMethod] && pruneBody) { return; } // even if body is not empty, but the body type is not known, we do not know how to parse the same // // @note if you'd like to support additional body types beyond formdata, url-encoding, etc, add the same to // the builder module if (!requestBodyBuilders.hasOwnProperty(requestBodyType)) { return; } return requestBodyBuilders[requestBodyType](bodyContent, request); }, /** * 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 && requestOptions.url.href || requestOptions.url }, url: requestOptions.url && requestOptions.url.href || requestOptions.url }); rawResponse.rawHeaders && (responseJSON.headers = this.arrayPairsToObject(rawResponse.rawHeaders) || responseJSON.headers); return responseJSON; } responseBody = responseBody || ''; // @todo drop support or isolate XHR requester in v8 // 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 && requestOptions.url.href || requestOptions.url }, url: requestOptions.url && requestOptions.url.href || 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())]; } };