var util = require('../util'),
_ = util.lodash,
httpReasons = require('http-reasons'),
LJSON = require('liquid-json'),
Property = require('./property').Property,
PropertyBase = require('./property-base').PropertyBase,
Request = require('./request').Request,
CookieList = require('./cookie-list').CookieList,
HeaderList = require('./header-list').HeaderList,
contentInfo = require('../content-info').contentInfo,
/**
* @private
* @const
* @type {string}
*/
E = '',
/**
* @private
* @const
* @type {String}
*/
HEADER = 'header',
/**
* @private
* @const
* @type {String}
*/
BODY = 'body',
/**
* @private
* @const
* @type {String}
*/
GZIP = 'gzip',
/**
* @private
* @const
* @type {String}
*/
CONTENT_ENCODING = 'Content-Encoding',
/**
* @private
* @const
* @type {String}
*/
CONTENT_LENGTH = 'Content-Length',
/**
* @private
* @const
* @type {string}
*/
BASE64 = 'base64',
/**
* @private
* @const
* @type {string}
*/
STREAM_TYPE_BUFFER = 'Buffer',
/**
* @private
* @const
* @type {string}
*/
STREAM_TYPE_BASE64 = 'Base64',
/**
* @private
* @const
* @type {string}
*/
FUNCTION = 'function',
/**
* @private
* @const
* @type {string}
*/
STRING = 'string',
/**
* @private
* @const
* @type {String}
*/
HTTP_X_X = 'HTTP/X.X',
/**
* @private
* @const
* @type {String}
*/
SP = ' ',
/**
* @private
* @const
* @type {String}
*/
CRLF = '\r\n',
/**
* @private
* @const
* @type {RegExp}
*/
REGEX_JSONP_LEFT = /^[^{(].*\(/,
/**
* @private
* @const
* @type {RegExp}
*/
REGEX_JSONP_RIGHT = /\)[^}].*$|\)$/,
/**
* Remove JSON padded string to pure JSON
*
* @private
* @param {String} str -
* @returns {String}
*/
stripJSONP = function (str) {
return str.replace(REGEX_JSONP_LEFT, E).replace(REGEX_JSONP_RIGHT, E);
},
/**
* @private
* @type {Boolean}
*/
supportsBuffer = (typeof Buffer !== undefined) && _.isFunction(Buffer.byteLength),
/**
* Normalizes an input Buffer, Buffer.toJSON() or base64 string into a Buffer or ArrayBuffer.
*
* @private
* @param {Buffer|Object} stream - An instance of Buffer, Buffer.toJSON(), or Base64 string
* @returns {Buffer|ArrayBuffer|undefined}
*/
normalizeStream = function (stream) {
if (!stream) { return; }
// create buffer from buffer's JSON representation
if (stream.type === STREAM_TYPE_BUFFER && _.isArray(stream.data)) {
// @todo Add tests for Browser environments, where ArrayBuffer is returned instead of Buffer
return typeof Buffer === FUNCTION ? Buffer.from(stream.data) : new Uint8Array(stream.data).buffer;
}
// create buffer from base64 string
if (stream.type === STREAM_TYPE_BASE64 && typeof stream.data === STRING) {
return Buffer.from(stream.data, BASE64);
}
// probably it's already of type buffer
return stream;
},
Response; // constructor
/**
* @typedef Response.definition
* @property {Number} code - define the response code
* @property {String=} [reason] - optionally, if the response has a non-standard response code reason, provide it here
* @property {Array<Header.definition>} [header]
* @property {Array<Cookie.definition>} [cookie]
* @property {String} [body]
* @property {Buffer|ArrayBuffer} [stream]
* @property {Number} responseTime
*
* @todo pluralise `header`, `cookie`
*/
_.inherit((
/**
* Response holds data related to the request body. By default, it provides a nice wrapper for url-encoded,
* form-data, and raw types of request bodies.
*
* @constructor
* @extends {Property}
*
* @param {Response.definition} options -
*/
Response = function PostmanResponse (options) {
// this constructor is intended to inherit and as such the super constructor is required to be executed
Response.super_.apply(this, arguments);
this.update(options || {});
}), Property);
_.assign(Response.prototype, /** @lends Response.prototype */ {
update (options) {
// options.stream accepts Buffer, Buffer.toJSON() or base64 string
// @todo this temporarily doubles the memory footprint (options.stream + generated buffer).
var stream = normalizeStream(options.stream);
_.mergeDefined((this._details = _.clone(httpReasons.lookup(options.code))), {
name: _.choose(options.reason, options.status),
code: options.code,
standardName: this._details.name
});
_.mergeDefined(this, /** @lends Response.prototype */ {
/**
* @type {Request}
*/
originalRequest: options.originalRequest ? new Request(options.originalRequest) : undefined,
/**
* @type {String}
*/
status: this._details.name,
/**
* @type {Number}
*/
code: options.code,
/**
* @type {HeaderList}
*/
headers: new HeaderList(this, options.header),
/**
* @type {String}
*/
body: options.body,
/**
* @private
*
* @type {Buffer|UInt8Array}
*/
stream: (options.body && _.isObject(options.body)) ? options.body : stream,
/**
* @type {CookieList}
*/
cookies: new CookieList(this, options.cookie),
/**
* Time taken for the request to complete.
*
* @type {Number}
*/
responseTime: options.responseTime,
/**
* @private
* @type {Number}
*/
responseSize: stream && stream.byteLength
});
}
});
_.assign(Response.prototype, /** @lends Response.prototype */ {
/**
* Defines that this property requires an ID field
*
* @private
* @readOnly
*/
_postman_propertyRequiresId: true,
/**
* Convert this response into a JSON serializable object. The _details meta property is omitted.
*
* @returns {Object}
*
* @todo consider switching to a different response buffer (stream) representation as Buffer.toJSON
* appears to cause multiple performance issues.
*/
toJSON: function () {
// @todo benchmark PropertyBase.toJSON, response Buffer.toJSON or _.cloneElement might
// be the bottleneck.
var response = PropertyBase.toJSON(this);
response._details && (delete response._details);
return response;
},
/**
* Get the http response reason phrase based on the current response code.
*
* @returns {String|undefined}
*/
reason: function () {
return this.status || httpReasons.lookup(this.code).name;
},
/**
* Creates a JSON representation of the current response details, and returns it.
*
* @returns {Object} A set of response details, including the custom server reason.
* @private
*/
details: function () {
if (!this._details || this._details.code !== this.code) {
this._details = _.clone(httpReasons.lookup(this.code));
this._details.code = this.code;
this._details.standardName = this._details.name;
}
return _.clone(this._details);
},
/**
* Get the response body as a string/text.
*
* @returns {String|undefined}
*/
text: function () {
return (this.stream ? util.bufferOrArrayBufferToString(this.stream, this.contentInfo().charset) : this.body);
},
/**
* Get the response body as a JavaScript object. Note that it throws an error if the response is not a valid JSON
*
* @param {Function=} [reviver] -
* @param {Boolean} [strict=false] Specify whether JSON parsing will be strict. This will fail on comments and BOM
* @example
* // assuming that the response is stored in a collection instance `myCollection`
* var response = myCollection.items.one('some request').responses.idx(0),
* jsonBody;
* try {
* jsonBody = response.json();
* }
* catch (e) {
* console.log("There was an error parsing JSON ", e);
* }
* // log the root-level keys in the response JSON.
* console.log('All keys in json response: ' + Object.keys(json));
*
* @returns {Object}
*/
json: function (reviver, strict) {
return LJSON.parse(this.text(), reviver, strict);
},
/**
* Get the JSON from response body that reuturns JSONP response.
*
* @param {Function=} [reviver] -
* @param {Boolean} [strict=false] Specify whether JSON parsing will be strict. This will fail on comments and BOM
*
* @throws {JSONError} when response body is empty
*/
jsonp: function (reviver, strict) {
return LJSON.parse(stripJSONP(this.text() || /* istanbul ignore next */ E), reviver, strict);
},
/**
* Extracts mime type, format, charset, extension and filename of the response content
* A fallback of default filename is given, if filename is not present in content-disposition header
*
* @returns {Response.ResponseContentInfo} - contentInfo for the response
*/
contentInfo: function () {
return contentInfo(this);
},
/**
* @private
* @deprecated discontinued in v4.0
*/
mime: function () {
throw new Error('`Response#mime` has been discontinued, use `Response#contentInfo` instead.');
},
/**
* Converts the response to a dataURI that can be used for storage or serialisation. The data URI is formed using
* the following syntax `data:<content-type>;baseg4, <base64-encoded-body>`.
*
* @returns {String}
* @todo write unit tests
*/
dataURI: function () {
const { contentType } = this.contentInfo();
// if there is no mime detected, there is no accurate way to render this thing
/* istanbul ignore if */
if (!contentType) {
return E;
}
// we create the body string first from stream and then fallback to body
return `data:${contentType};base64, ` + ((!_.isNil(this.stream) &&
util.bufferOrArrayBufferToBase64(this.stream)) || (!_.isNil(this.body) && util.btoa(this.body)) || E);
},
/**
* Get the response size by computing the same from content length header or using the actual response body.
*
* @returns {Number}
* @todo write unit tests
*/
size: function () {
var sizeInfo = {
body: 0,
header: 0,
total: 0
},
contentEncoding = this.headers.get(CONTENT_ENCODING),
contentLength = this.headers.get(CONTENT_LENGTH),
isCompressed = false,
byteLength;
// if server sent encoded data, we should first try deriving length from headers
if (_.isString(contentEncoding)) {
// desensitise case of content encoding
contentEncoding = contentEncoding.toLowerCase();
// eslint-disable-next-line lodash/prefer-includes
isCompressed = (contentEncoding.indexOf('gzip') > -1) || (contentEncoding.indexOf('deflate') > -1);
}
// if 'Content-Length' header is present and encoding is of type gzip/deflate, we take body as declared by
// server. else we need to compute the same.
if (contentLength && isCompressed && util.isNumeric(contentLength)) {
sizeInfo.body = _.parseInt(contentLength, 10);
}
// if there is a stream defined which looks like buffer, use it's data and move on
else if (this.stream) {
byteLength = this.stream.byteLength;
sizeInfo.body = util.isNumeric(byteLength) ? byteLength :
/* istanbul ignore next */
0;
}
// otherwise, if body is defined, we try get the true length of the body
else if (!_.isNil(this.body)) {
sizeInfo.body = supportsBuffer ? Buffer.byteLength(this.body.toString()) :
/* istanbul ignore next */
this.body.toString().length;
}
// size of header is added
// https://tools.ietf.org/html/rfc7230#section-3
// HTTP-message = start-line (request-line / status-line)
// *( header-field CRLF )
// CRLF
// [ message-body ]
// status-line = HTTP-version SP status-code SP reason-phrase CRLF
sizeInfo.header = (HTTP_X_X + SP + this.code + SP + this.reason() + CRLF + CRLF).length +
this.headers.contentSize();
// compute the approximate total body size by adding size of header and body
sizeInfo.total = (sizeInfo.body || 0) + (sizeInfo.header);
return sizeInfo;
},
/**
* Returns the response encoding defined as header or detected from body.
*
* @private
* @returns {Object} - {format: string, source: string}
*/
encoding: function () {
var contentEncoding = this.headers.get(CONTENT_ENCODING),
body = this.stream || this.body,
source;
if (contentEncoding) {
source = HEADER;
}
// if the encoding is not found, we check
else if (body) { // @todo add detection for deflate
// eslint-disable-next-line lodash/prefer-matches
if (body[0] === 0x1F && body[1] === 0x8B && body[2] === 0x8) {
contentEncoding = GZIP;
}
if (contentEncoding) {
source = BODY;
}
}
return {
format: contentEncoding,
source: source
};
}
});
_.assign(Response, /** @lends Response */ {
/**
* Defines the name of this property for internal use.
*
* @private
* @readOnly
* @type {String}
*/
_postman_propertyName: 'Response',
/**
* Check whether an object is an instance of {@link ItemGroup}.
*
* @param {*} obj -
* @returns {Boolean}
*/
isResponse: function (obj) {
return Boolean(obj) && ((obj instanceof Response) ||
_.inSuperChain(obj.constructor, '_postman_propertyName', Response._postman_propertyName));
},
/**
* Converts the response object from the request module to the postman responseBody format
*
* @param {Object} response The response object, as received from the request module
* @param {Object} cookies -
* @returns {Object} The transformed responseBody
* @todo Add a key: `originalRequest` to the returned object as well, referring to response.request
*/
createFromNode: function (response, cookies) {
return new Response({
cookie: cookies,
body: response.body.toString(),
stream: response.body,
header: response.headers,
code: response.statusCode,
status: response.statusMessage,
responseTime: response.elapsedTime
});
},
/**
* @private
* @deprecated discontinued in v4.0
*/
mimeInfo: function () {
throw new Error('`Response.mimeInfo` has been discontinued, use `Response#contentInfo` instead.');
},
/**
* Returns the durations of each request phase in milliseconds
*
* @typedef Response.timings
* @property {Number} start - timestamp of the request sent from the client (in Unix Epoch milliseconds)
* @property {Object} offset - event timestamps in millisecond resolution relative to start
* @property {Number} offset.request - timestamp of the start of the request
* @property {Number} offset.socket - timestamp when the socket is assigned to the request
* @property {Number} offset.lookup - timestamp when the DNS has been resolved
* @property {Number} offset.connect - timestamp when the server acknowledges the TCP connection
* @property {Number} offset.secureConnect - timestamp when secure handshaking process is completed
* @property {Number} offset.response - timestamp when the first bytes are received from the server
* @property {Number} offset.end - timestamp when the last bytes of the response are received
* @property {Number} offset.done - timestamp when the response is received at the client
*
* @note If there were redirects, the properties reflect the timings
* of the final request in the redirect chain
*
* @param {Response.timings} timings -
* @returns {Object}
*
* @example Output
* Request.timingPhases(timings);
* {
* prepare: Number, // duration of request preparation
* wait: Number, // duration of socket initialization
* dns: Number, // duration of DNS lookup
* tcp: Number, // duration of TCP connection
* secureHandshake: Number, // duration of secure handshake
* firstByte: Number, // duration of HTTP server response
* download: Number, // duration of HTTP download
* process: Number, // duration of response processing
* total: Number // duration entire HTTP round-trip
* }
*
* @note if there were redirects, the properties reflect the timings of the
* final request in the redirect chain.
*/
timingPhases: function (timings) {
// bail out if timing information is not provided
if (!(timings && timings.offset)) {
return;
}
var phases,
offset = timings.offset;
// REFER: https://github.com/postmanlabs/postman-request/blob/v2.88.1-postman.5/request.js#L996
phases = {
prepare: offset.request,
wait: offset.socket - offset.request,
dns: offset.lookup - offset.socket,
tcp: offset.connect - offset.lookup,
firstByte: offset.response - offset.connect,
download: offset.end - offset.response,
process: offset.done - offset.end,
total: offset.done
};
if (offset.secureConnect) {
phases.secureHandshake = offset.secureConnect - offset.connect;
phases.firstByte = offset.response - offset.secureConnect;
}
return phases;
}
});
module.exports = {
Response
};