Merge Chromium + Blink git repositories
[chromium-blink-merge.git] / remoting / webapp / base / js / xhr.js
blob4815ec1dfaa003c6e62906189f467f96c84652dd
1 // Copyright (c) 2011 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 /**
6  * @fileoverview
7  * Utility class for making XHRs more pleasant.
8  *
9  * Note: a mock version of this API exists in mock_xhr.js.
10  */
12 /** @suppress {duplicate} */
13 var remoting = remoting || {};
15 (function() {
17 'use strict';
19 /**
20  * @constructor
21  * @param {remoting.Xhr.Params} params
22  */
23 remoting.Xhr = function(params) {
24   remoting.Xhr.checkParams_(params);
26   // Apply URL parameters.
27   var url = params.url;
28   var parameterString = '';
29   if (typeof(params.urlParams) === 'string') {
30     parameterString = params.urlParams;
31   } else if (typeof(params.urlParams) === 'object') {
32     parameterString = remoting.Xhr.urlencodeParamHash(
33         base.copyWithoutNullFields(params.urlParams));
34   }
35   if (parameterString) {
36     url += '?' + parameterString;
37   }
39   // Prepare the build modified headers.
40   /** @const */
41   this.headers_ = base.copyWithoutNullFields(params.headers);
43   // Convert the content fields to a single text content variable.
44   /** @private {?string} */
45   this.content_ = null;
46   if (params.textContent !== undefined) {
47     this.maybeSetContentType_('text/plain');
48     this.content_ = params.textContent;
49   } else if (params.formContent !== undefined) {
50     this.maybeSetContentType_('application/x-www-form-urlencoded');
51     this.content_ = remoting.Xhr.urlencodeParamHash(params.formContent);
52   } else if (params.jsonContent !== undefined) {
53     this.maybeSetContentType_('application/json');
54     this.content_ = JSON.stringify(params.jsonContent);
55   }
57   // Apply the oauthToken field.
58   if (params.oauthToken !== undefined) {
59     this.setAuthToken_(params.oauthToken);
60   }
62   /** @private @const {boolean} */
63   this.acceptJson_ = params.acceptJson || false;
64   if (this.acceptJson_) {
65     this.maybeSetHeader_('Accept', 'application/json');
66   }
68   // Apply useIdentity field.
69   /** @const {boolean} */
70   this.useIdentity_ = params.useIdentity || false;
72   /** @private @const {!XMLHttpRequest} */
73   this.nativeXhr_ = new XMLHttpRequest();
74   this.nativeXhr_.onreadystatechange = this.onReadyStateChange_.bind(this);
75   this.nativeXhr_.withCredentials = params.withCredentials || false;
76   this.nativeXhr_.open(params.method, url, true);
78   /** @private {base.Deferred<!remoting.Xhr.Response>} */
79   this.deferred_ = null;
82 /**
83  * Starts and HTTP request and gets a promise that is resolved when
84  * the request completes.
85  *
86  * Any error that prevents sending the request causes the promise to
87  * be rejected.
88  *
89  * NOTE: Calling this method more than once will return the same
90  * promise and not start a new request, despite what the name
91  * suggests.
92  *
93  * @return {!Promise<!remoting.Xhr.Response>}
94  */
95 remoting.Xhr.prototype.start = function() {
96   if (this.deferred_ == null) {
97     this.deferred_ = new base.Deferred();
99     // Send the XHR, possibly after getting an OAuth token.
100     var that = this;
101     if (this.useIdentity_) {
102       remoting.identity.getToken().then(function(token) {
103         console.assert(that.nativeXhr_.readyState == 1,
104                       'Bad |readyState|: ' + that.nativeXhr_.readyState + '.');
105         that.setAuthToken_(token);
106         that.sendXhr_();
107       }).catch(function(error) {
108         that.deferred_.reject(error);
109       });
110     } else {
111       this.sendXhr_();
112     }
113   }
114   return this.deferred_.promise();
118  * The set of possible fields in remoting.Xhr.Params.
119  * @const
120  */
121 var ALLOWED_PARAMS = [
122   'method',
123   'url',
124   'urlParams',
125   'textContent',
126   'formContent',
127   'jsonContent',
128   'headers',
129   'withCredentials',
130   'oauthToken',
131   'useIdentity',
132   'acceptJson'
136  * @param {remoting.Xhr.Params} params
137  * @throws {Error} if params are invalid
138  * @private
139  */
140 remoting.Xhr.checkParams_ = function(params) {
141   // Provide a sensible error message when the user misspells a
142   // parameter name, since the compiler won't catch it.
143   for (var field in params) {
144     if (ALLOWED_PARAMS.indexOf(field) == -1) {
145       throw new Error('unknow parameter: ' + field);
146     }
147   }
149   if (params.urlParams) {
150     if (params.url.indexOf('?') != -1) {
151       throw new Error('URL may not contain "?" when urlParams is set');
152     }
153     if (params.url.indexOf('#') != -1) {
154       throw new Error('URL may not contain "#" when urlParams is set');
155     }
156   }
158   if ((Number(params.textContent !== undefined) +
159        Number(params.formContent !== undefined) +
160        Number(params.jsonContent !== undefined)) > 1) {
161     throw new Error(
162         'may only specify one of textContent, formContent, and jsonContent');
163   }
165   if (params.useIdentity && params.oauthToken !== undefined) {
166     throw new Error('may not specify both useIdentity and oauthToken');
167   }
169   if ((params.useIdentity || params.oauthToken !== undefined) &&
170       params.headers &&
171       params.headers['Authorization'] != null) {
172     throw new Error(
173         'may not specify useIdentity or oauthToken ' +
174         'with an Authorization header');
175   }
179  * @param {string} token
180  * @private
181  */
182 remoting.Xhr.prototype.setAuthToken_ = function(token) {
183   this.setHeader_('Authorization', 'Bearer ' + token);
187  * @param {string} type
188  * @private
189  */
190 remoting.Xhr.prototype.maybeSetContentType_ = function(type) {
191   this.maybeSetHeader_('Content-type', type + '; charset=UTF-8');
195  * @param {string} key
196  * @param {string} value
197  * @private
198  */
199 remoting.Xhr.prototype.setHeader_ = function(key, value) {
200   var wasSet = this.maybeSetHeader_(key, value);
201   console.assert(wasSet, 'setHeader(' + key + ', ' + value + ') failed.');
205  * @param {string} key
206  * @param {string} value
207  * @return {boolean}
208  * @private
209  */
210 remoting.Xhr.prototype.maybeSetHeader_ = function(key, value) {
211   if (!(key in this.headers_)) {
212     this.headers_[key] = value;
213     return true;
214   }
215   return false;
218 /** @private */
219 remoting.Xhr.prototype.sendXhr_ = function() {
220   for (var key in this.headers_) {
221     this.nativeXhr_.setRequestHeader(
222         key, /** @type {string} */ (this.headers_[key]));
223   }
224   this.nativeXhr_.send(this.content_);
225   this.content_ = null;  // for gc
229  * @private
230  */
231 remoting.Xhr.prototype.onReadyStateChange_ = function() {
232   var xhr = this.nativeXhr_;
233   if (xhr.readyState == 4) {
234     // See comments at remoting.Xhr.Response.
235     this.deferred_.resolve(remoting.Xhr.Response.fromXhr_(
236         xhr, this.acceptJson_));
237   }
241  * The response-related parts of an XMLHttpRequest.  Note that this
242  * class is not just a facade for XMLHttpRequest; it saves the value
243  * of the |responseText| field becuase once onReadyStateChange_
244  * (above) returns, the value of |responseText| is reset to the empty
245  * string!  This is a documented anti-feature of the XMLHttpRequest
246  * API.
248  * @constructor
249  * @param {number} status
250  * @param {string} statusText
251  * @param {?string} url
252  * @param {string} text
253  * @param {boolean} allowJson
254  */
255 remoting.Xhr.Response = function(
256     status, statusText, url, text, allowJson) {
257   /**
258    * The HTTP status code.
259    * @const {number}
260    */
261   this.status = status;
263   /**
264    * The HTTP status description.
265    * @const {string}
266    */
267   this.statusText = statusText;
269   /**
270    * The response URL, if any.
271    * @const {?string}
272    */
273   this.url = url;
275   /** @private {string} */
276   this.text_ = text;
278   /** @private @const */
279   this.allowJson_ = allowJson;
281   /** @private {*|undefined}  */
282   this.json_ = undefined;
286  * @param {!XMLHttpRequest} xhr
287  * @param {boolean} allowJson
288  * @return {!remoting.Xhr.Response}
289  */
290 remoting.Xhr.Response.fromXhr_ = function(xhr, allowJson) {
291   return new remoting.Xhr.Response(
292       xhr.status,
293       xhr.statusText,
294       xhr.responseURL,
295       xhr.responseText || '',
296       allowJson);
300  * @return {boolean} True if the response code is outside the 200-299
301  *     range (i.e. success as defined by the HTTP protocol).
302  */
303 remoting.Xhr.Response.prototype.isError = function() {
304   return this.status < 200 || this.status >= 300;
308  * @return {string} The text content of the response.
309  */
310 remoting.Xhr.Response.prototype.getText = function() {
311   return this.text_;
315  * Get the JSON content of the response.  Requires acceptJson to have
316  * been true in the request.
317  * @return {*} The parsed JSON content of the response.
318  */
319 remoting.Xhr.Response.prototype.getJson = function() {
320   console.assert(this.allowJson_, 'getJson() called with |allowJson_| false.');
321   if (this.json_ === undefined) {
322     this.json_ = JSON.parse(this.text_);
323   }
324   return this.json_;
328  * Takes an associative array of parameters and urlencodes it.
330  * @param {Object<string>} paramHash The parameter key/value pairs.
331  * @return {string} URLEncoded version of paramHash.
332  */
333 remoting.Xhr.urlencodeParamHash = function(paramHash) {
334   var paramArray = [];
335   for (var key in paramHash) {
336     var value = paramHash[key];
337     if (value != null) {
338       paramArray.push(encodeURIComponent(key) +
339                       '=' + encodeURIComponent(value));
340     }
341   }
342   if (paramArray.length > 0) {
343     return paramArray.join('&');
344   }
345   return '';
349  * An object that will retry an XHR request upon network failures until
350  * |opt_maxRetryAttempts| is reached.
352  * According to http://www.w3.org/TR/XMLHttpRequest/#the-status-attribute, the
353  * HTTP status would be 0 when the STATE is UNSENT, which occurs when we have
354  * lost network connectivity.
356  * @param {remoting.Xhr.Params} params
357  * @param {number=} opt_maxRetryAttempts
358  * @constructor
359  */
360 remoting.AutoRetryXhr = function(params, opt_maxRetryAttempts) {
361   /** @private */
362   this.xhrParams_ = params;
363   /**
364    * Retry for 60 x 250ms = 15s by default.
365    * @private
366    */
367   this.retryAttemptsRemaining_ = opt_maxRetryAttempts != undefined &&
368       Number.isInteger(opt_maxRetryAttempts) ? opt_maxRetryAttempts : 60;
369   /** @private */
370   this.deferred_ = new base.Deferred();
374  * Calling this method multiple times will return the same promise and will not
375  * start a new request.
377  * @return {!Promise<!remoting.Xhr.Response>}
378  */
379 remoting.AutoRetryXhr.prototype.start = function() {
380   this.doXhr_();
381   return this.deferred_.promise();
384 /** @private */
385 remoting.AutoRetryXhr.prototype.onNetworkFailure_ = function() {
386   if (--this.retryAttemptsRemaining_ > 0) {
387     var timer = new base.OneShotTimer(this.doXhr_.bind(this), 250);
388   } else {
389     this.deferred_.reject(
390         new remoting.Error(remoting.Error.Tag.NETWORK_FAILURE));
391   }
394 /** @private */
395 remoting.AutoRetryXhr.prototype.doXhr_ = function() {
396   if (!base.isOnline()) {
397     this.deferred_.reject(
398         new remoting.Error(remoting.Error.Tag.NETWORK_FAILURE));
399     return;
400   }
402   var that = this;
403   var xhr = new remoting.Xhr(this.xhrParams_);
404   return xhr.start().then(function(response){
405     if (response.status === 0) {
406       that.onNetworkFailure_();
407     } else {
408       that.deferred_.resolve(response);
409     }
410   });
413 })();
416  * Parameters for the 'start' function.  Unless otherwise noted, all
417  * parameters are optional.
419  * method: (required) The HTTP method to use.
421  * url: (required) The URL to request.
423  * urlParams: Parameters to be appended to the URL.  Null-valued
424  *     parameters are omitted.
426  * textContent: Text to be sent as the request body.
428  * formContent: Data to be URL-encoded and sent as the request body.
429  *     Causes Content-type header to be set appropriately.
431  * jsonContent: Data to be JSON-encoded and sent as the request body.
432  *     Causes Content-type header to be set appropriately.
434  * headers: Additional request headers to be sent.  Null-valued
435  *     headers are omitted.
437  * withCredentials: Value of the XHR's withCredentials field.
439  * oauthToken: An OAuth2 token used to construct an Authentication
440  *     header.
442  * useIdentity: Use identity API to get an OAuth2 token.
444  * acceptJson: If true, send an Accept header indicating that a JSON
445  *     response is expected.
447  * @typedef {{
448  *   method: string,
449  *   url:string,
450  *   urlParams:(string|Object<?string>|undefined),
451  *   textContent:(string|undefined),
452  *   formContent:(Object|undefined),
453  *   jsonContent:(*|undefined),
454  *   headers:(Object<?string>|undefined),
455  *   withCredentials:(boolean|undefined),
456  *   oauthToken:(string|undefined),
457  *   useIdentity:(boolean|undefined),
458  *   acceptJson:(boolean|undefined)
459  * }}
460  */
461 remoting.Xhr.Params;