collection/response.js

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
};