xhr.js

// 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.TimeoutError');

goog.require('goog.Promise');
goog.require('goog.debug.Error');
goog.require('goog.json');
goog.require('goog.net.HttpStatus');
goog.require('goog.net.XmlHttp');
goog.require('goog.string');
goog.require('goog.uri.utils');



goog.scope(function() {
var _ = 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://dev.w3.org/2006/webapi/XMLHttpRequest-2/#the-withcredentials-attribute
 * - mimeType: allows the caller to override the content-type and charset for
 *      the request, which is useful when requesting binary data.  See:
 *      http://dev.w3.org/2006/webapi/XMLHttpRequest-2/#dom-xmlhttprequest-overridemimetype
 * - xssiPrefix: Prefix used for protecting against XSSI attacks, which should
 *      be removed before parsing the response as JSON.
 *
 * @typedef {{
 *   headers: (Object.<string>|undefined),
 *   timeoutMs: (number|undefined),
 *   withCredentials: (boolean|undefined),
 *   mimeType: (string|undefined),
 *   xssiPrefix: (string|undefined)
 * }}
 */
_.Options;


/**
 * Defines the types that are allowed as post data.
 * @typedef {(ArrayBuffer|Blob|Document|FormData|null|string|undefined)}
 */
_.PostData;


/**
 * The Content-Type HTTP header name.
 * @type {string}
 */
_.CONTENT_TYPE_HEADER = 'Content-Type';


/**
 * The Content-Type HTTP header value for a url-encoded form.
 * @type {string}
 */
_.FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded;charset=utf-8';


/**
 * 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 {_.Options=} opt_options Configuration options for the request.
 * @return {!goog.Promise.<string>} A promise that will be resolved with the
 *     response text once the request completes.
 */
_.get = function(url, opt_options) {
  return _.send('GET', url, null, opt_options).then(function(xhr) {
    return xhr.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 {_.PostData} data The body of the post request.
 * @param {_.Options=} opt_options Configuration options for the request.
 * @return {!goog.Promise.<string>} A promise that will be resolved with the
 *     response text once the request completes.
 */
_.post = function(url, data, opt_options) {
  return _.send('POST', url, data, opt_options).then(function(xhr) {
    return xhr.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 {_.Options=} opt_options Configuration options for the request.
 * @return {!goog.Promise.<Object>} A promise that will be resolved with the
 *     response JSON once the request completes.
 */
_.getJson = function(url, opt_options) {
  return _.send('GET', url, null, opt_options).then(function(xhr) {
    return _.parseJson_(xhr.responseText, opt_options);
  });
};


/**
 * 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 {_.PostData} data The body of the post request.
 * @param {_.Options=} opt_options Configuration options for the request.
 * @return {!goog.Promise.<Object>} A promise that will be resolved with the
 *     response JSON once the request completes.
 */
_.postJson = function(url, data, opt_options) {
  return _.send('POST', url, data, opt_options).then(function(xhr) {
    return _.parseJson_(xhr.responseText, opt_options);
  });
};


/**
 * Sends a request, returning a promise that will be resolved
 * with the XHR object once the request completes.
 *
 * @param {string} method The HTTP method for the request.
 * @param {string} url The URL to request.
 * @param {_.PostData} data The body of the post request.
 * @param {_.Options=} opt_options Configuration options for the request.
 * @return {!goog.Promise.<!goog.net.XhrLike.OrNative>} A promise that will be
 *     resolved with the XHR object once the request completes.
 */
_.send = function(method, url, data, opt_options) {
  return new goog.Promise(function(resolve, reject) {
    var options = opt_options || {};
    var timer;

    var xhr = goog.net.XmlHttp();
    try {
      xhr.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 _.Error('Error opening XHR: ' + e.message, url, xhr));
    }

    // So sad that IE doesn't support onload and onerror.
    xhr.onreadystatechange = function() {
      if (xhr.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(xhr.status) ||
            xhr.status === 0 && !_.isEffectiveSchemeHttp_(url)) {
          resolve(xhr);
        } else {
          reject(new _.HttpError(xhr.status, url, xhr));
        }
      }
    };
    xhr.onerror = function() {
      reject(new _.Error('Network error', url, xhr));
    };

    // Set the headers.
    var contentTypeIsSet = false;
    if (options.headers) {
      for (var key in options.headers) {
        xhr.setRequestHeader(key, options.headers[key]);
      }
      contentTypeIsSet = _.CONTENT_TYPE_HEADER in options.headers;
    }

    // If a content type hasn't been set, 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' && !contentTypeIsSet) {
      xhr.setRequestHeader(_.CONTENT_TYPE_HEADER, _.FORM_CONTENT_TYPE);
    }

    // Set whether to pass cookies on cross-domain requests (if applicable).
    // @see http://dev.w3.org/2006/webapi/XMLHttpRequest-2/#the-withcredentials-attribute
    if (options.withCredentials) {
      xhr.withCredentials = options.withCredentials;
    }

    // Allow the request to override the mime type, useful for getting binary
    // data from the server.  e.g. 'text/plain; charset=x-user-defined'.
    // @see http://dev.w3.org/2006/webapi/XMLHttpRequest-2/#dom-xmlhttprequest-overridemimetype
    if (options.mimeType) {
      xhr.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.
        xhr.onreadystatechange = goog.nullFunction;
        xhr.abort();
        reject(new _.TimeoutError(url, xhr));
      }, options.timeoutMs);
    }

    // Trigger the send.
    try {
      xhr.send(data);
    } catch (e) {
      // XMLHttpRequest.send is known to throw on some versions of FF,
      // for example if a cross-origin request is disallowed.
      xhr.onreadystatechange = goog.nullFunction;
      goog.global.clearTimeout(timer);
      reject(new _.Error('Error sending XHR: ' + e.message, url, xhr));
    }
  });
};


/**
 * @param {string} url The URL to test.
 * @return {boolean} Whether the effective scheme is HTTP or HTTPs.
 * @private
 */
_.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 {_.Options|undefined} options The options object.
 * @return {Object} The JSON-parsed value of the original responseText.
 * @private
 */
_.parseJson_ = function(responseText, options) {
  var prefixStrippedResult = responseText;
  if (options && options.xssiPrefix) {
    prefixStrippedResult = _.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
 */
_.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} xhr The XHR that failed.
 * @extends {goog.debug.Error}
 * @constructor
 */
_.Error = function(message, url, 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 = xhr;
};
goog.inherits(_.Error, goog.debug.Error);


/** @override */
_.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} xhr The XHR that failed.
 * @extends {_.Error}
 * @constructor
 * @final
 */
_.HttpError = function(status, url, xhr) {
  _.HttpError.base(
      this, 'constructor', 'Request Failed, status=' + status, url, xhr);

  /**
   * The HTTP status code for the error.
   * @type {number}
   */
  this.status = status;
};
goog.inherits(_.HttpError, _.Error);


/** @override */
_.HttpError.prototype.name = 'XhrHttpError';



/**
 * Class for Timeout errors.
 *
 * @param {string} url The URL that timed out.
 * @param {!goog.net.XhrLike.OrNative} xhr The XHR that failed.
 * @extends {_.Error}
 * @constructor
 * @final
 */
_.TimeoutError = function(url, xhr) {
  _.TimeoutError.base(this, 'constructor', 'Request timed out', url, xhr);
};
goog.inherits(_.TimeoutError, _.Error);


/** @override */
_.TimeoutError.prototype.name = 'XhrTimeoutError';

});  // goog.scope