// Copyright (c) 2011 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview * Utility class for making XHRs more pleasant. * * Note: a mock version of this API exists in mock_xhr.js. */ /** @suppress {duplicate} */ var remoting = remoting || {}; (function() { 'use strict'; /** * @constructor * @param {remoting.Xhr.Params} params */ remoting.Xhr = function(params) { remoting.Xhr.checkParams_(params); // Apply URL parameters. var url = params.url; var parameterString = ''; if (typeof(params.urlParams) === 'string') { parameterString = params.urlParams; } else if (typeof(params.urlParams) === 'object') { parameterString = remoting.Xhr.urlencodeParamHash( base.copyWithoutNullFields(params.urlParams)); } if (parameterString) { url += '?' + parameterString; } // Prepare the build modified headers. /** @const */ this.headers_ = base.copyWithoutNullFields(params.headers); // Convert the content fields to a single text content variable. /** @private {?string} */ this.content_ = null; if (params.textContent !== undefined) { this.maybeSetContentType_('text/plain'); this.content_ = params.textContent; } else if (params.formContent !== undefined) { this.maybeSetContentType_('application/x-www-form-urlencoded'); this.content_ = remoting.Xhr.urlencodeParamHash(params.formContent); } else if (params.jsonContent !== undefined) { this.maybeSetContentType_('application/json'); this.content_ = JSON.stringify(params.jsonContent); } // Apply the oauthToken field. if (params.oauthToken !== undefined) { this.setAuthToken_(params.oauthToken); } /** @private @const {boolean} */ this.acceptJson_ = params.acceptJson || false; if (this.acceptJson_) { this.maybeSetHeader_('Accept', 'application/json'); } // Apply useIdentity field. /** @const {boolean} */ this.useIdentity_ = params.useIdentity || false; /** @private @const {!XMLHttpRequest} */ this.nativeXhr_ = new XMLHttpRequest(); this.nativeXhr_.onreadystatechange = this.onReadyStateChange_.bind(this); this.nativeXhr_.withCredentials = params.withCredentials || false; this.nativeXhr_.open(params.method, url, true); /** @private {base.Deferred} */ this.deferred_ = null; }; /** * Starts and HTTP request and gets a promise that is resolved when * the request completes. * * Any error that prevents sending the request causes the promise to * be rejected. * * NOTE: Calling this method more than once will return the same * promise and not start a new request, despite what the name * suggests. * * @return {!Promise} */ remoting.Xhr.prototype.start = function() { if (this.deferred_ == null) { this.deferred_ = new base.Deferred(); // Send the XHR, possibly after getting an OAuth token. var that = this; if (this.useIdentity_) { remoting.identity.getToken().then(function(token) { console.assert(that.nativeXhr_.readyState == 1, 'Bad |readyState|: ' + that.nativeXhr_.readyState + '.'); that.setAuthToken_(token); that.sendXhr_(); }).catch(function(error) { that.deferred_.reject(error); }); } else { this.sendXhr_(); } } return this.deferred_.promise(); }; /** * The set of possible fields in remoting.Xhr.Params. * @const */ var ALLOWED_PARAMS = [ 'method', 'url', 'urlParams', 'textContent', 'formContent', 'jsonContent', 'headers', 'withCredentials', 'oauthToken', 'useIdentity', 'acceptJson' ]; /** * @param {remoting.Xhr.Params} params * @throws {Error} if params are invalid * @private */ remoting.Xhr.checkParams_ = function(params) { // Provide a sensible error message when the user misspells a // parameter name, since the compiler won't catch it. for (var field in params) { if (ALLOWED_PARAMS.indexOf(field) == -1) { throw new Error('unknow parameter: ' + field); } } if (params.urlParams) { if (params.url.indexOf('?') != -1) { throw new Error('URL may not contain "?" when urlParams is set'); } if (params.url.indexOf('#') != -1) { throw new Error('URL may not contain "#" when urlParams is set'); } } if ((Number(params.textContent !== undefined) + Number(params.formContent !== undefined) + Number(params.jsonContent !== undefined)) > 1) { throw new Error( 'may only specify one of textContent, formContent, and jsonContent'); } if (params.useIdentity && params.oauthToken !== undefined) { throw new Error('may not specify both useIdentity and oauthToken'); } if ((params.useIdentity || params.oauthToken !== undefined) && params.headers && params.headers['Authorization'] != null) { throw new Error( 'may not specify useIdentity or oauthToken ' + 'with an Authorization header'); } }; /** * @param {string} token * @private */ remoting.Xhr.prototype.setAuthToken_ = function(token) { this.setHeader_('Authorization', 'Bearer ' + token); }; /** * @param {string} type * @private */ remoting.Xhr.prototype.maybeSetContentType_ = function(type) { this.maybeSetHeader_('Content-type', type + '; charset=UTF-8'); }; /** * @param {string} key * @param {string} value * @private */ remoting.Xhr.prototype.setHeader_ = function(key, value) { var wasSet = this.maybeSetHeader_(key, value); console.assert(wasSet, 'setHeader(' + key + ', ' + value + ') failed.'); }; /** * @param {string} key * @param {string} value * @return {boolean} * @private */ remoting.Xhr.prototype.maybeSetHeader_ = function(key, value) { if (!(key in this.headers_)) { this.headers_[key] = value; return true; } return false; }; /** @private */ remoting.Xhr.prototype.sendXhr_ = function() { for (var key in this.headers_) { this.nativeXhr_.setRequestHeader( key, /** @type {string} */ (this.headers_[key])); } this.nativeXhr_.send(this.content_); this.content_ = null; // for gc }; /** * @private */ remoting.Xhr.prototype.onReadyStateChange_ = function() { var xhr = this.nativeXhr_; if (xhr.readyState == 4) { // See comments at remoting.Xhr.Response. this.deferred_.resolve(remoting.Xhr.Response.fromXhr_( xhr, this.acceptJson_)); } }; /** * The response-related parts of an XMLHttpRequest. Note that this * class is not just a facade for XMLHttpRequest; it saves the value * of the |responseText| field becuase once onReadyStateChange_ * (above) returns, the value of |responseText| is reset to the empty * string! This is a documented anti-feature of the XMLHttpRequest * API. * * @constructor * @param {number} status * @param {string} statusText * @param {?string} url * @param {string} text * @param {boolean} allowJson */ remoting.Xhr.Response = function( status, statusText, url, text, allowJson) { /** * The HTTP status code. * @const {number} */ this.status = status; /** * The HTTP status description. * @const {string} */ this.statusText = statusText; /** * The response URL, if any. * @const {?string} */ this.url = url; /** @private {string} */ this.text_ = text; /** @private @const */ this.allowJson_ = allowJson; /** @private {*|undefined} */ this.json_ = undefined; }; /** * @param {!XMLHttpRequest} xhr * @param {boolean} allowJson * @return {!remoting.Xhr.Response} */ remoting.Xhr.Response.fromXhr_ = function(xhr, allowJson) { return new remoting.Xhr.Response( xhr.status, xhr.statusText, xhr.responseURL, xhr.responseText || '', allowJson); }; /** * @return {boolean} True if the response code is outside the 200-299 * range (i.e. success as defined by the HTTP protocol). */ remoting.Xhr.Response.prototype.isError = function() { return this.status < 200 || this.status >= 300; }; /** * @return {string} The text content of the response. */ remoting.Xhr.Response.prototype.getText = function() { return this.text_; }; /** * Get the JSON content of the response. Requires acceptJson to have * been true in the request. * @return {*} The parsed JSON content of the response. */ remoting.Xhr.Response.prototype.getJson = function() { console.assert(this.allowJson_, 'getJson() called with |allowJson_| false.'); if (this.json_ === undefined) { this.json_ = JSON.parse(this.text_); } return this.json_; }; /** * Takes an associative array of parameters and urlencodes it. * * @param {Object} paramHash The parameter key/value pairs. * @return {string} URLEncoded version of paramHash. */ remoting.Xhr.urlencodeParamHash = function(paramHash) { var paramArray = []; for (var key in paramHash) { var value = paramHash[key]; if (value != null) { paramArray.push(encodeURIComponent(key) + '=' + encodeURIComponent(value)); } } if (paramArray.length > 0) { return paramArray.join('&'); } return ''; }; /** * An object that will retry an XHR request upon network failures until * |opt_maxRetryAttempts| is reached. * * According to http://www.w3.org/TR/XMLHttpRequest/#the-status-attribute, the * HTTP status would be 0 when the STATE is UNSENT, which occurs when we have * lost network connectivity. * * @param {remoting.Xhr.Params} params * @param {number=} opt_maxRetryAttempts * @implements {base.Disposable} * * @constructor */ remoting.AutoRetryXhr = function(params, opt_maxRetryAttempts) { /** @private */ this.xhrParams_ = params; /** * Retry for 60 x 250ms = 15s by default. * @private */ this.retryAttemptsRemaining_ = opt_maxRetryAttempts != undefined && Number.isInteger(opt_maxRetryAttempts) ? opt_maxRetryAttempts : 60; /** @private */ this.deferred_ = new base.Deferred(); }; remoting.AutoRetryXhr.prototype.dispose = function() { this.retryAttemptsRemaining_ = 0; this.deferred_.reject(new remoting.Error(remoting.Error.Tag.CANCELLED)); }; /** * Calling this method multiple times will return the same promise and will not * start a new request. * * @return {!Promise} */ remoting.AutoRetryXhr.prototype.start = function() { this.doXhr_(); return this.deferred_.promise(); }; /** @private */ remoting.AutoRetryXhr.prototype.onNetworkFailure_ = function() { if (--this.retryAttemptsRemaining_ > 0) { var timer = new base.OneShotTimer(this.doXhr_.bind(this), 250); } else { this.deferred_.reject( new remoting.Error(remoting.Error.Tag.NETWORK_FAILURE)); } }; /** @private */ remoting.AutoRetryXhr.prototype.doXhr_ = function() { if (!base.isOnline()) { this.deferred_.reject( new remoting.Error(remoting.Error.Tag.NETWORK_FAILURE)); return; } var that = this; var xhr = new remoting.Xhr(this.xhrParams_); return xhr.start().then(function(response){ if (response.status === 0) { that.onNetworkFailure_(); } else { that.deferred_.resolve(response); } }); }; })(); /** * Parameters for the 'start' function. Unless otherwise noted, all * parameters are optional. * * method: (required) The HTTP method to use. * * url: (required) The URL to request. * * urlParams: Parameters to be appended to the URL. Null-valued * parameters are omitted. * * textContent: Text to be sent as the request body. * * formContent: Data to be URL-encoded and sent as the request body. * Causes Content-type header to be set appropriately. * * jsonContent: Data to be JSON-encoded and sent as the request body. * Causes Content-type header to be set appropriately. * * headers: Additional request headers to be sent. Null-valued * headers are omitted. * * withCredentials: Value of the XHR's withCredentials field. * * oauthToken: An OAuth2 token used to construct an Authentication * header. * * useIdentity: Use identity API to get an OAuth2 token. * * acceptJson: If true, send an Accept header indicating that a JSON * response is expected. * * @typedef {{ * method: string, * url:string, * urlParams:(string|Object|undefined), * textContent:(string|undefined), * formContent:(Object|undefined), * jsonContent:(*|undefined), * headers:(Object|undefined), * withCredentials:(boolean|undefined), * oauthToken:(string|undefined), * useIdentity:(boolean|undefined), * acceptJson:(boolean|undefined) * }} */ remoting.Xhr.Params;