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.
7 * Utility class for making XHRs more pleasant.
9 * Note: a mock version of this API exists in mock_xhr.js.
12 /** @suppress {duplicate} */
13 var remoting
= remoting
|| {};
21 * @param {remoting.Xhr.Params} params
23 remoting
.Xhr = function(params
) {
24 remoting
.Xhr
.checkParams_(params
);
26 // Apply URL parameters.
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
));
35 if (parameterString
) {
36 url
+= '?' + parameterString
;
39 // Prepare the build modified headers.
41 this.headers_
= base
.copyWithoutNullFields(params
.headers
);
43 // Convert the content fields to a single text content variable.
44 /** @private {?string} */
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
);
57 // Apply the oauthToken field.
58 if (params
.oauthToken
!== undefined) {
59 this.setAuthToken_(params
.oauthToken
);
62 /** @private @const {boolean} */
63 this.acceptJson_
= params
.acceptJson
|| false;
64 if (this.acceptJson_
) {
65 this.maybeSetHeader_('Accept', 'application/json');
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;
83 * Starts and HTTP request and gets a promise that is resolved when
84 * the request completes.
86 * Any error that prevents sending the request causes the promise to
89 * NOTE: Calling this method more than once will return the same
90 * promise and not start a new request, despite what the name
93 * @return {!Promise<!remoting.Xhr.Response>}
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.
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
);
107 }).catch(function(error
) {
108 that
.deferred_
.reject(error
);
114 return this.deferred_
.promise();
118 * The set of possible fields in remoting.Xhr.Params.
121 var ALLOWED_PARAMS
= [
136 * @param {remoting.Xhr.Params} params
137 * @throws {Error} if params are invalid
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
);
149 if (params
.urlParams
) {
150 if (params
.url
.indexOf('?') != -1) {
151 throw new Error('URL may not contain "?" when urlParams is set');
153 if (params
.url
.indexOf('#') != -1) {
154 throw new Error('URL may not contain "#" when urlParams is set');
158 if ((Number(params
.textContent
!== undefined) +
159 Number(params
.formContent
!== undefined) +
160 Number(params
.jsonContent
!== undefined)) > 1) {
162 'may only specify one of textContent, formContent, and jsonContent');
165 if (params
.useIdentity
&& params
.oauthToken
!== undefined) {
166 throw new Error('may not specify both useIdentity and oauthToken');
169 if ((params
.useIdentity
|| params
.oauthToken
!== undefined) &&
171 params
.headers
['Authorization'] != null) {
173 'may not specify useIdentity or oauthToken ' +
174 'with an Authorization header');
179 * @param {string} token
182 remoting
.Xhr
.prototype.setAuthToken_ = function(token
) {
183 this.setHeader_('Authorization', 'Bearer ' + token
);
187 * @param {string} type
190 remoting
.Xhr
.prototype.maybeSetContentType_ = function(type
) {
191 this.maybeSetHeader_('Content-type', type
+ '; charset=UTF-8');
195 * @param {string} key
196 * @param {string} value
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
210 remoting
.Xhr
.prototype.maybeSetHeader_ = function(key
, value
) {
211 if (!(key
in this.headers_
)) {
212 this.headers_
[key
] = value
;
219 remoting
.Xhr
.prototype.sendXhr_ = function() {
220 for (var key
in this.headers_
) {
221 this.nativeXhr_
.setRequestHeader(
222 key
, /** @type {string} */ (this.headers_
[key
]));
224 this.nativeXhr_
.send(this.content_
);
225 this.content_
= null; // for gc
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_
));
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
249 * @param {number} status
250 * @param {string} statusText
251 * @param {?string} url
252 * @param {string} text
253 * @param {boolean} allowJson
255 remoting
.Xhr
.Response = function(
256 status
, statusText
, url
, text
, allowJson
) {
258 * The HTTP status code.
261 this.status
= status
;
264 * The HTTP status description.
267 this.statusText
= statusText
;
270 * The response URL, if any.
275 /** @private {string} */
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}
290 remoting
.Xhr
.Response
.fromXhr_ = function(xhr
, allowJson
) {
291 return new remoting
.Xhr
.Response(
295 xhr
.responseText
|| '',
300 * @return {boolean} True if the response code is outside the 200-299
301 * range (i.e. success as defined by the HTTP protocol).
303 remoting
.Xhr
.Response
.prototype.isError = function() {
304 return this.status
< 200 || this.status
>= 300;
308 * @return {string} The text content of the response.
310 remoting
.Xhr
.Response
.prototype.getText = function() {
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.
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_
);
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.
333 remoting
.Xhr
.urlencodeParamHash = function(paramHash
) {
335 for (var key
in paramHash
) {
336 var value
= paramHash
[key
];
338 paramArray
.push(encodeURIComponent(key
) +
339 '=' + encodeURIComponent(value
));
342 if (paramArray
.length
> 0) {
343 return paramArray
.join('&');
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
360 remoting
.AutoRetryXhr = function(params
, opt_maxRetryAttempts
) {
362 this.xhrParams_
= params
;
364 * Retry for 60 x 250ms = 15s by default.
367 this.retryAttemptsRemaining_
= opt_maxRetryAttempts
!= undefined &&
368 Number
.isInteger(opt_maxRetryAttempts
) ? opt_maxRetryAttempts
: 60;
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>}
379 remoting
.AutoRetryXhr
.prototype.start = function() {
381 return this.deferred_
.promise();
385 remoting
.AutoRetryXhr
.prototype.onNetworkFailure_ = function() {
386 if (--this.retryAttemptsRemaining_
> 0) {
387 var timer
= new base
.OneShotTimer(this.doXhr_
.bind(this), 250);
389 this.deferred_
.reject(
390 new remoting
.Error(remoting
.Error
.Tag
.NETWORK_FAILURE
));
395 remoting
.AutoRetryXhr
.prototype.doXhr_ = function() {
396 if (!base
.isOnline()) {
397 this.deferred_
.reject(
398 new remoting
.Error(remoting
.Error
.Tag
.NETWORK_FAILURE
));
403 var xhr
= new remoting
.Xhr(this.xhrParams_
);
404 return xhr
.start().then(function(response
){
405 if (response
.status
=== 0) {
406 that
.onNetworkFailure_();
408 that
.deferred_
.resolve(response
);
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
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.
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)