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