nbsrc/net/HttpRequest.js

goog.module('nbsrc.net.HttpRequest');

const Completer = goog.require('clulib.async.Completer');

const array = goog.require('goog.array');
const {listen,unlistenByKey} = goog.require('goog.events');
const Key = goog.require('goog.events.Key');
const Event = goog.require('goog.events.Event');
const BrowserEvent = goog.require('goog.events.BrowserEvent');
const EventTarget = goog.require('goog.events.EventTarget');
const HttpStatus = goog.require('goog.net.HttpStatus');
const {isEmptyOrWhitespace,splitLimit,caseInsensitiveEquals} = goog.require('goog.string');
const QueryData = goog.require('goog.Uri.QueryData');

/**
 * @extends {EventTarget}
 */
class HttpRequest extends EventTarget
{
    constructor()
    {
        super();

        /**
         *
         * Map of default headers to add to every request
         * @type {Map}
         */
        this.headers = new Map();

        /**
         * The requested type for the response. The empty string means use the default
         * XHR behavior.
         *
         * @type {string}
         */
        this.responseType = HttpRequestResponseType.DEFAULT;

        /**
         * Whether a "credentialed" request is to be sent (one that is aware of
         * cookies and authentication). This is applicable only for cross-domain
         * requests and more recent browsers that support this part of the HTTP Access
         * Control standard.
         *
         * @see http://www.w3.org/TR/XMLHttpRequest/#the-withcredentials-attribute
         *
         * @type {boolean}
         */
        this.withCredentials = false;

        /**
         * Number of milliseconds after which an incomplete request will be aborted.
         * 0 means no timeout
         * is set.
         *
         * @type {number}
         */
        this.timeout = 0;

        /**
         * Completer which resolves the Promise returned by [send].
         *
         * @type {Completer}
         * @private
         */
        this.completer_ = null;

        /**
         * Whether XMLHttpRequest is active.  A request is active from the time send()
         * is called until onReadyStateChange() is complete, or error() or abort()
         * is called.
         *
         * @type {boolean}
         * @private
         */
        this.active_ = false;

        /**
         * The XMLHttpRequest object that is being used for the transfer.
         *
         * @type {?XMLHttpRequest}
         * @private
         */
        this.xhr_ = null;

        /**
         * Last URL that was requested.
         *
         * @type {string}
         * @private
         */
        this.lastUrl_ = '';

        /**
         * Response from the last request.
         *
         * @type {?*}
         * @private
         */
        this.lastResponse_ = null;

        /**
         * Response headers from the last request.
         *
         * @type {?Map}
         * @private
         */
        this.lastResponseHeaders_ = null;

        /**
         * Event keys for every eventlistener used inside this class.
         *
         * @type {Array<Key>}
         * @private
         */
        this.eventKeys_ = [];
    };

    /**
     * Function that actually uses XMLHttpRequest to make a server call.
     *
     * @param {string} url
     * @param {string=} opt_method
     * @param {ArrayBuffer|ArrayBufferView|Blob|Document|FormData|string|Map=} opt_data
     * @param {(string|null)=} opt_user
     * @param {(string|null)=} opt_password
     * @param {Map=} opt_requestHeaders
     * @returns {Promise}
     */
    send(url, opt_method = 'GET', opt_data = null, opt_user = null, opt_password = null, opt_requestHeaders = null) {
        if (this.active_)
        {
            throw new Error('[nb.core.net.HttpRequest2] Object is active with another request=' + this.lastUrl_ + '; newUri=' + url);
        }

        let method = opt_method != null ? opt_method.toUpperCase() : 'GET';
        let user = opt_user != null ? opt_user : '';
        let password = opt_password != null ? opt_password : '';

        this.lastResponse_ = null;
        this.lastResponseHeaders_ = null;
        this.lastUrl_ = url;
        this.active_ = true;
        this.xhr_ = new XMLHttpRequest();
        this.completer_ = new Completer();

        this.eventKeys_.push(listen(this.xhr_, 'load', this.onLoad_, false, this));

        try
        {
            this.xhr_.open(method, url, true, user, password);
        }
        catch (error)
        {
            this.active_ = false;
            this.xhr_.abort();
            this.completer_.reject(error);
            this.cleanUpXhr_();

            return this.completer_.getPromise();
        }

        let data = opt_data != null ? opt_data : '';
        if (data instanceof Map)
        {
            data = QueryData.createFromMap(data).toString();
        }

        let headers = new Map(this.headers);
        if (opt_requestHeaders != null)
        {
            opt_requestHeaders.forEach((value, key) => {
                headers.set(key, value);
            });
        }

        let containsContentTypeKey = array.find(/** @type {IArrayLike<?>} */ (headers.keys()), function (headerKey) {
            return caseInsensitiveEquals('Content-Type', headerKey);
        }, this);

        let isFormData = data instanceof goog.global['FormData'];

        if ((method == 'POST' || method == 'PUT')
            && !containsContentTypeKey && !isFormData)
        {
            headers.set('Content-Type', 'application/x-www-form-urlencoded;charset=utf-8');
        }

        headers.forEach(function (value, key) {
            this.xhr_.setRequestHeader(key, value);
        }, this);

        this.xhr_.responseType = this.responseType;
        this.xhr_.withCredentials = this.withCredentials;

        try
        {
            if (this.timeout > 0)
            {
                this.xhr_['timeout'] = this.timeout;

                this.eventKeys_.push(listen(this.xhr_, 'timeout', this.onTimeout_, false, this));
            }

            this.eventKeys_.push(listen(this.xhr_, 'progress', this.onDownloadProgress_, false, this));
            this.eventKeys_.push(listen(this.xhr_.upload, 'progress', this.onUploadProgress_, false, this));

            this.xhr_.send(data);
        }
        catch (error)
        {
            this.active_ = false;
            this.xhr_.abort();
            this.completer_.reject(error);
            this.cleanUpXhr_();
        }
        finally
        {
            return this.completer_.getPromise();
        }
    };

    /**
     * Whether there is an active request.
     *
     * @returns {boolean}
     */
    isActive() {
        return this.active_;
    };

    /**
     * Gets the last URL that was requested
     *
     * @returns {string}
     */
    getLastUrl() {
        return this.lastUrl_;
    };

    /**
     * Gets the last response of this XMLHttpRequest
     *
     * @returns {?*}
     */
    getResponse() {
        return this.lastResponse_;
    };

    /**
     * Gets the last response headers of this XMLHttpRequest
     *
     * @returns {?Map}
     */
    getResponseHeaders()
    {
        return this.lastResponseHeaders_;
    };

    /**
     * Abort the current XMLHttpRequest
     */
    abort()
    {
        if (this.active_)
        {
            this.active_ = false;
            this.xhr_.abort();
            this.completer_.reject(HttpRequestErrorType.ABORT);
            this.cleanUpXhr_();
        }
    };

    /**
     * @param {BrowserEvent} event
     * @private
     */
    onDownloadProgress_(event) {
        let progressEvent = event.getBrowserEvent();

        this.dispatchEvent(
            new HttpRequestProgressEvent(
                HttpRequestEventType.DOWNLOAD_PROGRESS,
                this,
                progressEvent['lengthComputable'],
                progressEvent['total'],
                progressEvent['loaded']
            )
        );
    };

    /**
     * @param {BrowserEvent} event
     * @private
     */
    onUploadProgress_(event) {
        let progressEvent = event.getBrowserEvent();

        this.dispatchEvent(
            new HttpRequestProgressEvent(
                HttpRequestEventType.UPLOAD_PROGRESS,
                this,
                progressEvent['lengthComputable'],
                progressEvent['total'],
                progressEvent['loaded']
            )
        );
    };

    /**
     * @private
     */
    onLoad_() {
        let readyState = this.xhr_.readyState;
        let status = readyState > 2 ? this.xhr_.status : -1;

        if (readyState == 4)
        {
            this.active_ = false;

            if (HttpStatus.isSuccess(status))
            {
                this.lastResponse_ = this.xhr_.response;
                this.lastResponseHeaders_ = this.parseHeadersString_(this.xhr_.getAllResponseHeaders());

                this.completer_.resolve(this.lastResponse_);
            }
            else
            {
                this.completer_.reject(status);
            }

            this.cleanUpXhr_();
        }
    };

    /**
     * @param {string} string
     * @private
     * @return {Map}
     */
    parseHeadersString_(string)
    {
        let headers = new Map();
        let headersArray = string.split('\r\n');

        for (let i = 0; i < headersArray.length; i++)
        {
            if (isEmptyOrWhitespace(headersArray[i]))
            {
                continue;
            }

            let keyValue = splitLimit(headersArray[i], ': ', 2);

            if (headers.has(keyValue[0]))
            {
                headers.set(keyValue[0], headers.get(keyValue[0]) + ', ' + keyValue[1]);
            }
            else
            {
                headers.set(keyValue[0], keyValue[1]);
            }
        }

        return headers;
    };

    /**
     * @private
     */
    onTimeout_()
    {
        if (this.active_)
        {
            this.active_ = false;
            this.xhr_.abort();
            this.completer_.reject(HttpRequestErrorType.TIMEOUT);
            this.cleanUpXhr_();
        }
    };

    /**
     * Remove the listeners to protect against leaks, and nullify the XMLHttpRequest
     * object.
     *
     * @private
     */
    cleanUpXhr_()
    {
        this.eventKeys_.forEach((eventKey) => {
            unlistenByKey(eventKey);
        }, this);

        this.eventKeys_ = [];
        this.xhr_ = null;
    };

    /**
     * @protected
     */
    disposeInternal()
    {
        super.disposeInternal();

        if (this.active_)
        {
            this.active_ = false;
            this.xhr_.abort();
            this.completer_.reject(HttpRequestErrorType.ABORT);
            this.cleanUpXhr_();
        }
    };
}

class HttpRequestProgressEvent extends Event
{
    /**
     * @param {string} type
     * @param {EventTarget} target
     * @param {boolean} sizeKnown
     * @param {number=} opt_total
     * @param {number=} opt_loaded
     */
    constructor(type, target, sizeKnown, opt_total = 0, opt_loaded = 0)
    {
        super(type, target);

        /**
         * If total size is known or not.
         *
         * @type {boolean}
         * @private
         */
        this.sizeKnown_ = sizeKnown;

        /**
         * Total size of content.
         *
         * @type {number|null}
         * @private
         */
        this.totalSize_ = opt_total || null;

        /**
         * Loaded size of content.
         *
         * @type {number|null}
         * @private
         */
        this.loadedSize_ = opt_loaded || null;
    }

    /**
     * If total size is known or not.
     *
     * @returns {boolean}
     */
    isSizeKnown()
    {
        return this.sizeKnown_;
    };

    /**
     * Total size of content.
     *
     * @returns {number|null}
     */
    getTotalSize()
    {
        return this.totalSize_;
    };

    /**
     * Loaded size of content.
     *
     * @returns {number|null}
     */
    getLoadedSize()
    {
        return this.loadedSize_;
    };

    /**
     * The factor of loaded bytes (from 0 to 1, float).
     *
     * @returns {number}
     */
    getLoadedFactor()
    {
        return this.getLoadedSize() / this.getTotalSize();
    };

    /**
     * The percentage of loaded bytes (from 0 to 100, int).
     *
     * @returns {number}
     */
    getLoadedPercentage()
    {
        return Math.round(this.getLoadedFactor() * 100);
    };
};

/**
 * Creates and sends a URL request for the specified [url].
 *
 * By default `request` will perform an HTTP GET request, but a different
 * method (`POST`, `PUT`, `DELETE`, etc) can be used by specifying the
 * [method] parameter. (See also [HttpRequest2.postFormData] for `POST`
 * requests only.
 *
 * The Promise is completed when the response is available.
 *
 * @param {string} url
 * @param {string=} opt_method
 * @param {string=} opt_responseType
 * @param {ArrayBuffer|ArrayBufferView|Blob|Document|FormData|string|Map=} opt_data
 * @param {Map=} opt_requestHeaders
 * @returns {Promise}
 */
function request(url, opt_method = 'GET', opt_responseType = '', opt_data = null, opt_requestHeaders = null) {
    let http = new HttpRequest();

    if (opt_responseType != null)
    {
        http.responseType = opt_responseType;
    }

    return http.send(url, opt_method, opt_data, '', '', opt_requestHeaders);
};

/**
 * Creates a GET request for the specified [url].
 *
 * @param {string} url
 * @param {Map=} opt_requestHeaders
 * @returns {Promise}
 */
function getString(url, opt_requestHeaders = null) {
    return request(url, 'GET', HttpRequestResponseType.TEXT, null, opt_requestHeaders);
};

/**
 * Creates a GET request for the specified [url].
 *
 * Returns a parsed json object as promise result.
 *
 * @param {string} url
 * @param {Map=} opt_requestHeaders
 * @returns {Promise}
 */
function getJson(url, opt_requestHeaders = null)
{
    return /** @type {Promise} */ (getString(url, opt_requestHeaders).then((result) => {return JSON.parse(result);}));
};

/**
 * Makes a server POST request with the specified data encoded as form data.
 *
 * This is roughly the POST equivalent of getString. This method is similar
 * to sending a FormData object with broader browser support but limited to
 * String values.
 *
 * @param {string} url
 * @param {!ArrayBuffer|ArrayBufferView|Blob|Document|FormData|string|Map} data
 * @param {string=} opt_responseType
 * @param {Map=} opt_requestHeaders
 * @returns {Promise}
 */
function postFormData(url, data, opt_responseType, opt_requestHeaders)
{
    return request(url, 'POST', opt_responseType, data, opt_requestHeaders);
};

/**
 * @enum {string}
 */
const HttpRequestResponseType = {
    DEFAULT: '',
    ARRAY_BUFFER: 'arraybuffer',
    BLOB: 'blob',
    DOCUMENT: 'document',
    TEXT: 'text'
};

/**
 * @enum {string}
 */
const HttpRequestEventType = {
    DOWNLOAD_PROGRESS: 'downloadprogress',
    UPLOAD_PROGRESS: 'uploadprogress'
};

/**
 * @enum {string}
 */
const HttpRequestErrorType = {
    TIMEOUT: 'timeout',
    ABORT: 'abort'
};

exports = {HttpRequest,request,getString,getJson,postFormData,HttpRequestResponseType,HttpRequestEventType,HttpRequestErrorType,HttpRequestProgressEvent};