// Copyright 2011 The Closure Library Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS-IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /** * @fileoverview Offered as an alternative to XhrIo as a way for making requests * via XMLHttpRequest. Instead of mirroring the XHR interface and exposing * events, results are used as a way to pass a "promise" of the response to * interested parties. * */ goog.provide('goog.labs.net.xhr'); goog.provide('goog.labs.net.xhr.Error'); goog.provide('goog.labs.net.xhr.HttpError'); goog.provide('goog.labs.net.xhr.Options'); goog.provide('goog.labs.net.xhr.PostData'); goog.provide('goog.labs.net.xhr.ResponseType'); goog.provide('goog.labs.net.xhr.TimeoutError'); goog.require('goog.Promise'); goog.require('goog.asserts'); goog.require('goog.debug.Error'); goog.require('goog.json'); goog.require('goog.net.HttpStatus'); goog.require('goog.net.XmlHttp'); goog.require('goog.object'); goog.require('goog.string'); goog.require('goog.uri.utils'); goog.require('goog.userAgent'); goog.scope(function() { var userAgent = goog.userAgent; var xhr = goog.labs.net.xhr; var HttpStatus = goog.net.HttpStatus; /** * Configuration options for an XMLHttpRequest. * - headers: map of header key/value pairs. * - timeoutMs: number of milliseconds after which the request will be timed * out by the client. Default is to allow the browser to handle timeouts. * - withCredentials: whether user credentials are to be included in a * cross-origin request. See: * http://www.w3.org/TR/XMLHttpRequest/#the-withcredentials-attribute * - mimeType: allows the caller to override the content-type and charset for * the request. See: * http://www.w3.org/TR/XMLHttpRequest/#dom-xmlhttprequest-overridemimetype * - responseType: may be set to change the response type to an arraybuffer or * blob for downloading binary data. See: * http://www.w3.org/TR/XMLHttpRequest/#dom-xmlhttprequest-responsetype] * - xmlHttpFactory: allows the caller to override the factory used to create * XMLHttpRequest objects. * - xssiPrefix: Prefix used for protecting against XSSI attacks, which should * be removed before parsing the response as JSON. * * @typedef {{ * headers: (Object|undefined), * mimeType: (string|undefined), * responseType: (xhr.ResponseType|undefined), * timeoutMs: (number|undefined), * withCredentials: (boolean|undefined), * xmlHttpFactory: (goog.net.XmlHttpFactory|undefined), * xssiPrefix: (string|undefined) * }} */ xhr.Options; /** * Defines the types that are allowed as post data. * @typedef {(ArrayBuffer|ArrayBufferView|Blob|Document|FormData|null|string|undefined)} */ xhr.PostData; /** * The Content-Type HTTP header name. * @type {string} */ xhr.CONTENT_TYPE_HEADER = 'Content-Type'; /** * The Content-Type HTTP header value for a url-encoded form. * @type {string} */ xhr.FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded;charset=utf-8'; /** * Supported data types for the responseType field. * See: http://www.w3.org/TR/XMLHttpRequest/#dom-xmlhttprequest-response * @enum {string} */ xhr.ResponseType = { ARRAYBUFFER: 'arraybuffer', BLOB: 'blob', DOCUMENT: 'document', JSON: 'json', TEXT: 'text' }; /** * Sends a get request, returning a promise that will be resolved * with the response text once the request completes. * * @param {string} url The URL to request. * @param {xhr.Options=} opt_options Configuration options for the request. * @return {!goog.Promise} A promise that will be resolved with the * response text once the request completes. */ xhr.get = function(url, opt_options) { return xhr.send('GET', url, null, opt_options).then(function(request) { return request.responseText; }); }; /** * Sends a post request, returning a promise that will be resolved * with the response text once the request completes. * * @param {string} url The URL to request. * @param {xhr.PostData} data The body of the post request. * @param {xhr.Options=} opt_options Configuration options for the request. * @return {!goog.Promise} A promise that will be resolved with the * response text once the request completes. */ xhr.post = function(url, data, opt_options) { return xhr.send('POST', url, data, opt_options).then(function(request) { return request.responseText; }); }; /** * Sends a get request, returning a promise that will be resolved with * the parsed response text once the request completes. * * @param {string} url The URL to request. * @param {xhr.Options=} opt_options Configuration options for the request. * @return {!goog.Promise} A promise that will be resolved with the * response JSON once the request completes. */ xhr.getJson = function(url, opt_options) { return xhr.send('GET', url, null, opt_options).then(function(request) { return xhr.parseJson_(request.responseText, opt_options); }); }; /** * Sends a get request, returning a promise that will be resolved with the * response as a Blob. * * @param {string} url The URL to request. * @param {xhr.Options=} opt_options Configuration options for the request. If * responseType is set, it will be ignored for this request. * @return {!goog.Promise} A promise that will be resolved with an * immutable Blob representing the file once the request completes. */ xhr.getBlob = function(url, opt_options) { goog.asserts.assert( 'Blob' in goog.global, 'getBlob is not supported in this browser.'); var options = opt_options ? goog.object.clone(opt_options) : {}; options.responseType = xhr.ResponseType.BLOB; return xhr.send('GET', url, null, options).then(function(request) { return /** @type {!Blob} */ (request.response); }); }; /** * Sends a get request, returning a promise that will be resolved with the * response as an array of bytes. * * Supported in all XMLHttpRequest level 2 browsers, as well as IE9. IE8 and * earlier are not supported. * * @param {string} url The URL to request. * @param {xhr.Options=} opt_options Configuration options for the request. If * responseType is set, it will be ignored for this request. * @return {!goog.Promise>} A promise that will be * resolved with an array of bytes once the request completes. */ xhr.getBytes = function(url, opt_options) { goog.asserts.assert( !userAgent.IE || userAgent.isDocumentModeOrHigher(9), 'getBytes is not supported in this browser.'); var options = opt_options ? goog.object.clone(opt_options) : {}; options.responseType = xhr.ResponseType.ARRAYBUFFER; return xhr.send('GET', url, null, options).then(function(request) { // Use the ArrayBuffer response in browsers that support XMLHttpRequest2. // This covers nearly all modern browsers: http://caniuse.com/xhr2 if (request.response) { return new Uint8Array(/** @type {!ArrayBuffer} */ (request.response)); } // Fallback for IE9: the response may be accessed as an array of bytes with // the non-standard responseBody property, which can only be accessed as a // VBArray. IE7 and IE8 require significant amounts of VBScript to extract // the bytes. // See: http://stackoverflow.com/questions/1919972/ if (goog.global['VBArray']) { return new goog.global['VBArray'](request['responseBody']).toArray(); } // Nearly all common browsers are covered by the cases above. If downloading // binary files in older browsers is necessary, the MDN article "Sending and // Receiving Binary Data" provides techniques that may work with // XMLHttpRequest level 1 browsers: http://goo.gl/7lEuGN throw new xhr.Error( 'getBytes is not supported in this browser.', url, request); }); }; /** * Sends a post request, returning a promise that will be resolved with * the parsed response text once the request completes. * * @param {string} url The URL to request. * @param {xhr.PostData} data The body of the post request. * @param {xhr.Options=} opt_options Configuration options for the request. * @return {!goog.Promise} A promise that will be resolved with the * response JSON once the request completes. */ xhr.postJson = function(url, data, opt_options) { return xhr.send('POST', url, data, opt_options).then(function(request) { return xhr.parseJson_(request.responseText, opt_options); }); }; /** * Sends a request, returning a promise that will be resolved * with the XHR object once the request completes. * * If content type hasn't been set in opt_options headers, and hasn't been * explicitly set to null, default to form-urlencoded/UTF8 for POSTs. * * @param {string} method The HTTP method for the request. * @param {string} url The URL to request. * @param {xhr.PostData} data The body of the post request. * @param {xhr.Options=} opt_options Configuration options for the request. * @return {!goog.Promise} A promise that will be * resolved with the XHR object once the request completes. */ xhr.send = function(method, url, data, opt_options) { return new goog.Promise(function(resolve, reject) { var options = opt_options || {}; var timer; var request = options.xmlHttpFactory ? options.xmlHttpFactory.createInstance() : goog.net.XmlHttp(); try { request.open(method, url, true); } catch (e) { // XMLHttpRequest.open may throw when 'open' is called, for example, IE7 // throws "Access Denied" for cross-origin requests. reject(new xhr.Error('Error opening XHR: ' + e.message, url, request)); } // So sad that IE doesn't support onload and onerror. request.onreadystatechange = function() { if (request.readyState == goog.net.XmlHttp.ReadyState.COMPLETE) { goog.global.clearTimeout(timer); // Note: When developing locally, XHRs to file:// schemes return // a status code of 0. We mark that case as a success too. if (HttpStatus.isSuccess(request.status) || request.status === 0 && !xhr.isEffectiveSchemeHttp_(url)) { resolve(request); } else { reject(new xhr.HttpError(request.status, url, request)); } } }; request.onerror = function() { reject(new xhr.Error('Network error', url, request)); }; // Set the headers. var contentType; if (options.headers) { for (var key in options.headers) { var value = options.headers[key]; if (goog.isDefAndNotNull(value)) { request.setRequestHeader(key, value); } } contentType = options.headers[xhr.CONTENT_TYPE_HEADER]; } // Browsers will automatically set the content type to multipart/form-data // when passed a FormData object. var dataIsFormData = (goog.global['FormData'] && (data instanceof goog.global['FormData'])); // If a content type hasn't been set, it hasn't been explicitly set to null, // and the data isn't a FormData, default to form-urlencoded/UTF8 for POSTs. // This is because some proxies have been known to reject posts without a // content-type. if (method == 'POST' && contentType === undefined && !dataIsFormData) { request.setRequestHeader(xhr.CONTENT_TYPE_HEADER, xhr.FORM_CONTENT_TYPE); } // Set whether to include cookies with cross-domain requests. See: // http://www.w3.org/TR/XMLHttpRequest/#the-withcredentials-attribute if (options.withCredentials) { request.withCredentials = options.withCredentials; } // Allows setting an alternative response type, such as an ArrayBuffer. See: // http://www.w3.org/TR/XMLHttpRequest/#dom-xmlhttprequest-responsetype if (options.responseType) { request.responseType = options.responseType; } // Allow the request to override the MIME type of the response. See: // http://www.w3.org/TR/XMLHttpRequest/#dom-xmlhttprequest-overridemimetype if (options.mimeType) { request.overrideMimeType(options.mimeType); } // Handle timeouts, if requested. if (options.timeoutMs > 0) { timer = goog.global.setTimeout(function() { // Clear event listener before aborting so the errback will not be // called twice. request.onreadystatechange = goog.nullFunction; request.abort(); reject(new xhr.TimeoutError(url, request)); }, options.timeoutMs); } // Trigger the send. try { request.send(data); } catch (e) { // XMLHttpRequest.send is known to throw on some versions of FF, // for example if a cross-origin request is disallowed. request.onreadystatechange = goog.nullFunction; goog.global.clearTimeout(timer); reject(new xhr.Error('Error sending XHR: ' + e.message, url, request)); } }); }; /** * @param {string} url The URL to test. * @return {boolean} Whether the effective scheme is HTTP or HTTPs. * @private */ xhr.isEffectiveSchemeHttp_ = function(url) { var scheme = goog.uri.utils.getEffectiveScheme(url); // NOTE(user): Empty-string is for the case under FF3.5 when the location // is not defined inside a web worker. return scheme == 'http' || scheme == 'https' || scheme == ''; }; /** * JSON-parses the given response text, returning an Object. * * @param {string} responseText Response text. * @param {xhr.Options|undefined} options The options object. * @return {Object} The JSON-parsed value of the original responseText. * @private */ xhr.parseJson_ = function(responseText, options) { var prefixStrippedResult = responseText; if (options && options.xssiPrefix) { prefixStrippedResult = xhr.stripXssiPrefix_(options.xssiPrefix, prefixStrippedResult); } return goog.json.parse(prefixStrippedResult); }; /** * Strips the XSSI prefix from the input string. * * @param {string} prefix The XSSI prefix. * @param {string} string The string to strip the prefix from. * @return {string} The input string without the prefix. * @private */ xhr.stripXssiPrefix_ = function(prefix, string) { if (goog.string.startsWith(string, prefix)) { string = string.substring(prefix.length); } return string; }; /** * Generic error that may occur during a request. * * @param {string} message The error message. * @param {string} url The URL that was being requested. * @param {!goog.net.XhrLike.OrNative} request The XHR that failed. * @extends {goog.debug.Error} * @constructor */ xhr.Error = function(message, url, request) { xhr.Error.base(this, 'constructor', message + ', url=' + url); /** * The URL that was requested. * @type {string} */ this.url = url; /** * The XMLHttpRequest corresponding with the failed request. * @type {!goog.net.XhrLike.OrNative} */ this.xhr = request; }; goog.inherits(xhr.Error, goog.debug.Error); /** @override */ xhr.Error.prototype.name = 'XhrError'; /** * Class for HTTP errors. * * @param {number} status The HTTP status code of the response. * @param {string} url The URL that was being requested. * @param {!goog.net.XhrLike.OrNative} request The XHR that failed. * @extends {xhr.Error} * @constructor * @final */ xhr.HttpError = function(status, url, request) { xhr.HttpError.base( this, 'constructor', 'Request Failed, status=' + status, url, request); /** * The HTTP status code for the error. * @type {number} */ this.status = status; }; goog.inherits(xhr.HttpError, xhr.Error); /** @override */ xhr.HttpError.prototype.name = 'XhrHttpError'; /** * Class for Timeout errors. * * @param {string} url The URL that timed out. * @param {!goog.net.XhrLike.OrNative} request The XHR that failed. * @extends {xhr.Error} * @constructor * @final */ xhr.TimeoutError = function(url, request) { xhr.TimeoutError.base(this, 'constructor', 'Request timed out', url, request); }; goog.inherits(xhr.TimeoutError, xhr.Error); /** @override */ xhr.TimeoutError.prototype.name = 'XhrTimeoutError'; }); // goog.scope