services/common/rest.js

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

     1 /* This Source Code Form is subject to the terms of the Mozilla Public
     2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
     3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
     5 #ifndef MERGED_COMPARTMENT
     7 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
     9 this.EXPORTED_SYMBOLS = [
    10   "RESTRequest",
    11   "RESTResponse",
    12   "TokenAuthenticatedRESTRequest",
    13 ];
    15 #endif
    17 Cu.import("resource://gre/modules/Preferences.jsm");
    18 Cu.import("resource://gre/modules/Services.jsm");
    19 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
    20 Cu.import("resource://gre/modules/Log.jsm");
    21 Cu.import("resource://services-common/utils.js");
    23 XPCOMUtils.defineLazyModuleGetter(this, "CryptoUtils",
    24                                   "resource://services-crypto/utils.js");
    26 const Prefs = new Preferences("services.common.rest.");
    28 /**
    29  * Single use HTTP requests to RESTish resources.
    30  *
    31  * @param uri
    32  *        URI for the request. This can be an nsIURI object or a string
    33  *        that can be used to create one. An exception will be thrown if
    34  *        the string is not a valid URI.
    35  *
    36  * Examples:
    37  *
    38  * (1) Quick GET request:
    39  *
    40  *   new RESTRequest("http://server/rest/resource").get(function (error) {
    41  *     if (error) {
    42  *       // Deal with a network error.
    43  *       processNetworkErrorCode(error.result);
    44  *       return;
    45  *     }
    46  *     if (!this.response.success) {
    47  *       // Bail out if we're not getting an HTTP 2xx code.
    48  *       processHTTPError(this.response.status);
    49  *       return;
    50  *     }
    51  *     processData(this.response.body);
    52  *   });
    53  *
    54  * (2) Quick PUT request (non-string data is automatically JSONified)
    55  *
    56  *   new RESTRequest("http://server/rest/resource").put(data, function (error) {
    57  *     ...
    58  *   });
    59  *
    60  * (3) Streaming GET
    61  *
    62  *   let request = new RESTRequest("http://server/rest/resource");
    63  *   request.setHeader("Accept", "application/newlines");
    64  *   request.onComplete = function (error) {
    65  *     if (error) {
    66  *       // Deal with a network error.
    67  *       processNetworkErrorCode(error.result);
    68  *       return;
    69  *     }
    70  *     callbackAfterRequestHasCompleted()
    71  *   });
    72  *   request.onProgress = function () {
    73  *     if (!this.response.success) {
    74  *       // Bail out if we're not getting an HTTP 2xx code.
    75  *       return;
    76  *     }
    77  *     // Process body data and reset it so we don't process the same data twice.
    78  *     processIncrementalData(this.response.body);
    79  *     this.response.body = "";
    80  *   });
    81  *   request.get();
    82  */
    83 this.RESTRequest = function RESTRequest(uri) {
    84   this.status = this.NOT_SENT;
    86   // If we don't have an nsIURI object yet, make one. This will throw if
    87   // 'uri' isn't a valid URI string.
    88   if (!(uri instanceof Ci.nsIURI)) {
    89     uri = Services.io.newURI(uri, null, null);
    90   }
    91   this.uri = uri;
    93   this._headers = {};
    94   this._log = Log.repository.getLogger(this._logName);
    95   this._log.level =
    96     Log.Level[Prefs.get("log.logger.rest.request")];
    97 }
    98 RESTRequest.prototype = {
   100   _logName: "Services.Common.RESTRequest",
   102   QueryInterface: XPCOMUtils.generateQI([
   103     Ci.nsIBadCertListener2,
   104     Ci.nsIInterfaceRequestor,
   105     Ci.nsIChannelEventSink
   106   ]),
   108   /*** Public API: ***/
   110   /**
   111    * URI for the request (an nsIURI object).
   112    */
   113   uri: null,
   115   /**
   116    * HTTP method (e.g. "GET")
   117    */
   118   method: null,
   120   /**
   121    * RESTResponse object
   122    */
   123   response: null,
   125   /**
   126    * nsIRequest load flags. Don't do any caching by default. Don't send user
   127    * cookies and such over the wire (Bug 644734).
   128    */
   129   loadFlags: Ci.nsIRequest.LOAD_BYPASS_CACHE | Ci.nsIRequest.INHIBIT_CACHING | Ci.nsIRequest.LOAD_ANONYMOUS,
   131   /**
   132    * nsIHttpChannel
   133    */
   134   channel: null,
   136   /**
   137    * Flag to indicate the status of the request.
   138    *
   139    * One of NOT_SENT, SENT, IN_PROGRESS, COMPLETED, ABORTED.
   140    */
   141   status: null,
   143   NOT_SENT:    0,
   144   SENT:        1,
   145   IN_PROGRESS: 2,
   146   COMPLETED:   4,
   147   ABORTED:     8,
   149   /**
   150    * HTTP status text of response
   151    */
   152   statusText: null,
   154   /**
   155    * Request timeout (in seconds, though decimal values can be used for
   156    * up to millisecond granularity.)
   157    *
   158    * 0 for no timeout.
   159    */
   160   timeout: null,
   162   /**
   163    * The encoding with which the response to this request must be treated.
   164    * If a charset parameter is available in the HTTP Content-Type header for
   165    * this response, that will always be used, and this value is ignored. We
   166    * default to UTF-8 because that is a reasonable default.
   167    */
   168   charset: "utf-8",
   170   /**
   171    * Called when the request has been completed, including failures and
   172    * timeouts.
   173    *
   174    * @param error
   175    *        Error that occurred while making the request, null if there
   176    *        was no error.
   177    */
   178   onComplete: function onComplete(error) {
   179   },
   181   /**
   182    * Called whenever data is being received on the channel. If this throws an
   183    * exception, the request is aborted and the exception is passed as the
   184    * error to onComplete().
   185    */
   186   onProgress: function onProgress() {
   187   },
   189   /**
   190    * Set a request header.
   191    */
   192   setHeader: function setHeader(name, value) {
   193     this._headers[name.toLowerCase()] = value;
   194   },
   196   /**
   197    * Perform an HTTP GET.
   198    *
   199    * @param onComplete
   200    *        Short-circuit way to set the 'onComplete' method. Optional.
   201    * @param onProgress
   202    *        Short-circuit way to set the 'onProgress' method. Optional.
   203    *
   204    * @return the request object.
   205    */
   206   get: function get(onComplete, onProgress) {
   207     return this.dispatch("GET", null, onComplete, onProgress);
   208   },
   210   /**
   211    * Perform an HTTP PUT.
   212    *
   213    * @param data
   214    *        Data to be used as the request body. If this isn't a string
   215    *        it will be JSONified automatically.
   216    * @param onComplete
   217    *        Short-circuit way to set the 'onComplete' method. Optional.
   218    * @param onProgress
   219    *        Short-circuit way to set the 'onProgress' method. Optional.
   220    *
   221    * @return the request object.
   222    */
   223   put: function put(data, onComplete, onProgress) {
   224     return this.dispatch("PUT", data, onComplete, onProgress);
   225   },
   227   /**
   228    * Perform an HTTP POST.
   229    *
   230    * @param data
   231    *        Data to be used as the request body. If this isn't a string
   232    *        it will be JSONified automatically.
   233    * @param onComplete
   234    *        Short-circuit way to set the 'onComplete' method. Optional.
   235    * @param onProgress
   236    *        Short-circuit way to set the 'onProgress' method. Optional.
   237    *
   238    * @return the request object.
   239    */
   240   post: function post(data, onComplete, onProgress) {
   241     return this.dispatch("POST", data, onComplete, onProgress);
   242   },
   244   /**
   245    * Perform an HTTP DELETE.
   246    *
   247    * @param onComplete
   248    *        Short-circuit way to set the 'onComplete' method. Optional.
   249    * @param onProgress
   250    *        Short-circuit way to set the 'onProgress' method. Optional.
   251    *
   252    * @return the request object.
   253    */
   254   delete: function delete_(onComplete, onProgress) {
   255     return this.dispatch("DELETE", null, onComplete, onProgress);
   256   },
   258   /**
   259    * Abort an active request.
   260    */
   261   abort: function abort() {
   262     if (this.status != this.SENT && this.status != this.IN_PROGRESS) {
   263       throw "Can only abort a request that has been sent.";
   264     }
   266     this.status = this.ABORTED;
   267     this.channel.cancel(Cr.NS_BINDING_ABORTED);
   269     if (this.timeoutTimer) {
   270       // Clear the abort timer now that the channel is done.
   271       this.timeoutTimer.clear();
   272     }
   273   },
   275   /*** Implementation stuff ***/
   277   dispatch: function dispatch(method, data, onComplete, onProgress) {
   278     if (this.status != this.NOT_SENT) {
   279       throw "Request has already been sent!";
   280     }
   282     this.method = method;
   283     if (onComplete) {
   284       this.onComplete = onComplete;
   285     }
   286     if (onProgress) {
   287       this.onProgress = onProgress;
   288     }
   290     // Create and initialize HTTP channel.
   291     let channel = Services.io.newChannelFromURI(this.uri, null, null)
   292                           .QueryInterface(Ci.nsIRequest)
   293                           .QueryInterface(Ci.nsIHttpChannel);
   294     this.channel = channel;
   295     channel.loadFlags |= this.loadFlags;
   296     channel.notificationCallbacks = this;
   298     // Set request headers.
   299     let headers = this._headers;
   300     for (let key in headers) {
   301       if (key == 'authorization') {
   302         this._log.trace("HTTP Header " + key + ": ***** (suppressed)");
   303       } else {
   304         this._log.trace("HTTP Header " + key + ": " + headers[key]);
   305       }
   306       channel.setRequestHeader(key, headers[key], false);
   307     }
   309     // Set HTTP request body.
   310     if (method == "PUT" || method == "POST") {
   311       // Convert non-string bodies into JSON.
   312       if (typeof data != "string") {
   313         data = JSON.stringify(data);
   314       }
   316       this._log.debug(method + " Length: " + data.length);
   317       if (this._log.level <= Log.Level.Trace) {
   318         this._log.trace(method + " Body: " + data);
   319       }
   321       let stream = Cc["@mozilla.org/io/string-input-stream;1"]
   322                      .createInstance(Ci.nsIStringInputStream);
   323       stream.setData(data, data.length);
   325       let type = headers["content-type"] || "text/plain";
   326       channel.QueryInterface(Ci.nsIUploadChannel);
   327       channel.setUploadStream(stream, type, data.length);
   328     }
   329     // We must set this after setting the upload stream, otherwise it
   330     // will always be 'PUT'. Yeah, I know.
   331     channel.requestMethod = method;
   333     // Before opening the channel, set the charset that serves as a hint
   334     // as to what the response might be encoded as.
   335     channel.contentCharset = this.charset;
   337     // Blast off!
   338     try {
   339       channel.asyncOpen(this, null);
   340     } catch (ex) {
   341       // asyncOpen can throw in a bunch of cases -- e.g., a forbidden port.
   342       this._log.warn("Caught an error in asyncOpen: " + CommonUtils.exceptionStr(ex));
   343       CommonUtils.nextTick(onComplete.bind(this, ex));
   344     }
   345     this.status = this.SENT;
   346     this.delayTimeout();
   347     return this;
   348   },
   350   /**
   351    * Create or push back the abort timer that kills this request.
   352    */
   353   delayTimeout: function delayTimeout() {
   354     if (this.timeout) {
   355       CommonUtils.namedTimer(this.abortTimeout, this.timeout * 1000, this,
   356                              "timeoutTimer");
   357     }
   358   },
   360   /**
   361    * Abort the request based on a timeout.
   362    */
   363   abortTimeout: function abortTimeout() {
   364     this.abort();
   365     let error = Components.Exception("Aborting due to channel inactivity.",
   366                                      Cr.NS_ERROR_NET_TIMEOUT);
   367     if (!this.onComplete) {
   368       this._log.error("Unexpected error: onComplete not defined in " +
   369                       "abortTimeout.")
   370       return;
   371     }
   372     this.onComplete(error);
   373   },
   375   /*** nsIStreamListener ***/
   377   onStartRequest: function onStartRequest(channel) {
   378     if (this.status == this.ABORTED) {
   379       this._log.trace("Not proceeding with onStartRequest, request was aborted.");
   380       return;
   381     }
   383     try {
   384       channel.QueryInterface(Ci.nsIHttpChannel);
   385     } catch (ex) {
   386       this._log.error("Unexpected error: channel is not a nsIHttpChannel!");
   387       this.status = this.ABORTED;
   388       channel.cancel(Cr.NS_BINDING_ABORTED);
   389       return;
   390     }
   392     this.status = this.IN_PROGRESS;
   394     this._log.trace("onStartRequest: " + channel.requestMethod + " " +
   395                     channel.URI.spec);
   397     // Create a response object and fill it with some data.
   398     let response = this.response = new RESTResponse();
   399     response.request = this;
   400     response.body = "";
   402     this.delayTimeout();
   403   },
   405   onStopRequest: function onStopRequest(channel, context, statusCode) {
   406     if (this.timeoutTimer) {
   407       // Clear the abort timer now that the channel is done.
   408       this.timeoutTimer.clear();
   409     }
   411     // We don't want to do anything for a request that's already been aborted.
   412     if (this.status == this.ABORTED) {
   413       this._log.trace("Not proceeding with onStopRequest, request was aborted.");
   414       return;
   415     }
   417     try {
   418       channel.QueryInterface(Ci.nsIHttpChannel);
   419     } catch (ex) {
   420       this._log.error("Unexpected error: channel not nsIHttpChannel!");
   421       this.status = this.ABORTED;
   422       return;
   423     }
   424     this.status = this.COMPLETED;
   426     let statusSuccess = Components.isSuccessCode(statusCode);
   427     let uri = channel && channel.URI && channel.URI.spec || "<unknown>";
   428     this._log.trace("Channel for " + channel.requestMethod + " " + uri +
   429                     " returned status code " + statusCode);
   431     if (!this.onComplete) {
   432       this._log.error("Unexpected error: onComplete not defined in " +
   433                       "abortRequest.");
   434       this.onProgress = null;
   435       return;
   436     }
   438     // Throw the failure code and stop execution.  Use Components.Exception()
   439     // instead of Error() so the exception is QI-able and can be passed across
   440     // XPCOM borders while preserving the status code.
   441     if (!statusSuccess) {
   442       let message = Components.Exception("", statusCode).name;
   443       let error = Components.Exception(message, statusCode);
   444       this.onComplete(error);
   445       this.onComplete = this.onProgress = null;
   446       return;
   447     }
   449     this._log.debug(this.method + " " + uri + " " + this.response.status);
   451     // Additionally give the full response body when Trace logging.
   452     if (this._log.level <= Log.Level.Trace) {
   453       this._log.trace(this.method + " body: " + this.response.body);
   454     }
   456     delete this._inputStream;
   458     this.onComplete(null);
   459     this.onComplete = this.onProgress = null;
   460   },
   462   onDataAvailable: function onDataAvailable(channel, cb, stream, off, count) {
   463     // We get an nsIRequest, which doesn't have contentCharset.
   464     try {
   465       channel.QueryInterface(Ci.nsIHttpChannel);
   466     } catch (ex) {
   467       this._log.error("Unexpected error: channel not nsIHttpChannel!");
   468       this.abort();
   470       if (this.onComplete) {
   471         this.onComplete(ex);
   472       }
   474       this.onComplete = this.onProgress = null;
   475       return;
   476     }
   478     if (channel.contentCharset) {
   479       this.response.charset = channel.contentCharset;
   481       if (!this._converterStream) {
   482         this._converterStream = Cc["@mozilla.org/intl/converter-input-stream;1"]
   483                                    .createInstance(Ci.nsIConverterInputStream);
   484       }
   486       this._converterStream.init(stream, channel.contentCharset, 0,
   487                                  this._converterStream.DEFAULT_REPLACEMENT_CHARACTER);
   489       try {
   490         let str = {};
   491         let num = this._converterStream.readString(count, str);
   492         if (num != 0) {
   493           this.response.body += str.value;
   494         }
   495       } catch (ex) {
   496         this._log.warn("Exception thrown reading " + count + " bytes from " +
   497                        "the channel.");
   498         this._log.warn(CommonUtils.exceptionStr(ex));
   499         throw ex;
   500       }
   501     } else {
   502       this.response.charset = null;
   504       if (!this._inputStream) {
   505         this._inputStream = Cc["@mozilla.org/scriptableinputstream;1"]
   506                               .createInstance(Ci.nsIScriptableInputStream);
   507       }
   509       this._inputStream.init(stream);
   511       this.response.body += this._inputStream.read(count);
   512     }
   514     try {
   515       this.onProgress();
   516     } catch (ex) {
   517       this._log.warn("Got exception calling onProgress handler, aborting " +
   518                      this.method + " " + channel.URI.spec);
   519       this._log.debug("Exception: " + CommonUtils.exceptionStr(ex));
   520       this.abort();
   522       if (!this.onComplete) {
   523         this._log.error("Unexpected error: onComplete not defined in " +
   524                         "onDataAvailable.");
   525         this.onProgress = null;
   526         return;
   527       }
   529       this.onComplete(ex);
   530       this.onComplete = this.onProgress = null;
   531       return;
   532     }
   534     this.delayTimeout();
   535   },
   537   /*** nsIInterfaceRequestor ***/
   539   getInterface: function(aIID) {
   540     return this.QueryInterface(aIID);
   541   },
   543   /*** nsIBadCertListener2 ***/
   545   notifyCertProblem: function notifyCertProblem(socketInfo, sslStatus, targetHost) {
   546     this._log.warn("Invalid HTTPS certificate encountered!");
   547     // Suppress invalid HTTPS certificate warnings in the UI.
   548     // (The request will still fail.)
   549     return true;
   550   },
   552   /**
   553    * Returns true if headers from the old channel should be
   554    * copied to the new channel. Invoked when a channel redirect
   555    * is in progress.
   556    */
   557   shouldCopyOnRedirect: function shouldCopyOnRedirect(oldChannel, newChannel, flags) {
   558     let isInternal = !!(flags & Ci.nsIChannelEventSink.REDIRECT_INTERNAL);
   559     let isSameURI  = newChannel.URI.equals(oldChannel.URI);
   560     this._log.debug("Channel redirect: " + oldChannel.URI.spec + ", " +
   561                     newChannel.URI.spec + ", internal = " + isInternal);
   562     return isInternal && isSameURI;
   563   },
   565   /*** nsIChannelEventSink ***/
   566   asyncOnChannelRedirect:
   567     function asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
   569     try {
   570       newChannel.QueryInterface(Ci.nsIHttpChannel);
   571     } catch (ex) {
   572       this._log.error("Unexpected error: channel not nsIHttpChannel!");
   573       callback.onRedirectVerifyCallback(Cr.NS_ERROR_NO_INTERFACE);
   574       return;
   575     }
   577     // For internal redirects, copy the headers that our caller set.
   578     try {
   579       if (this.shouldCopyOnRedirect(oldChannel, newChannel, flags)) {
   580         this._log.trace("Copying headers for safe internal redirect.");
   581         for (let key in this._headers) {
   582           newChannel.setRequestHeader(key, this._headers[key], false);
   583         }
   584       }
   585     } catch (ex) {
   586       this._log.error("Error copying headers: " + CommonUtils.exceptionStr(ex));
   587     }
   589     this.channel = newChannel;
   591     // We let all redirects proceed.
   592     callback.onRedirectVerifyCallback(Cr.NS_OK);
   593   }
   594 };
   596 /**
   597  * Response object for a RESTRequest. This will be created automatically by
   598  * the RESTRequest.
   599  */
   600 this.RESTResponse = function RESTResponse() {
   601   this._log = Log.repository.getLogger(this._logName);
   602   this._log.level =
   603     Log.Level[Prefs.get("log.logger.rest.response")];
   604 }
   605 RESTResponse.prototype = {
   607   _logName: "Sync.RESTResponse",
   609   /**
   610    * Corresponding REST request
   611    */
   612   request: null,
   614   /**
   615    * HTTP status code
   616    */
   617   get status() {
   618     let status;
   619     try {
   620       status = this.request.channel.responseStatus;
   621     } catch (ex) {
   622       this._log.debug("Caught exception fetching HTTP status code:" +
   623                       CommonUtils.exceptionStr(ex));
   624       return null;
   625     }
   626     delete this.status;
   627     return this.status = status;
   628   },
   630   /**
   631    * HTTP status text
   632    */
   633   get statusText() {
   634     let statusText;
   635     try {
   636       statusText = this.request.channel.responseStatusText;
   637     } catch (ex) {
   638       this._log.debug("Caught exception fetching HTTP status text:" +
   639                       CommonUtils.exceptionStr(ex));
   640       return null;
   641     }
   642     delete this.statusText;
   643     return this.statusText = statusText;
   644   },
   646   /**
   647    * Boolean flag that indicates whether the HTTP status code is 2xx or not.
   648    */
   649   get success() {
   650     let success;
   651     try {
   652       success = this.request.channel.requestSucceeded;
   653     } catch (ex) {
   654       this._log.debug("Caught exception fetching HTTP success flag:" +
   655                       CommonUtils.exceptionStr(ex));
   656       return null;
   657     }
   658     delete this.success;
   659     return this.success = success;
   660   },
   662   /**
   663    * Object containing HTTP headers (keyed as lower case)
   664    */
   665   get headers() {
   666     let headers = {};
   667     try {
   668       this._log.trace("Processing response headers.");
   669       let channel = this.request.channel.QueryInterface(Ci.nsIHttpChannel);
   670       channel.visitResponseHeaders(function (header, value) {
   671         headers[header.toLowerCase()] = value;
   672       });
   673     } catch (ex) {
   674       this._log.debug("Caught exception processing response headers:" +
   675                       CommonUtils.exceptionStr(ex));
   676       return null;
   677     }
   679     delete this.headers;
   680     return this.headers = headers;
   681   },
   683   /**
   684    * HTTP body (string)
   685    */
   686   body: null
   688 };
   690 /**
   691  * Single use MAC authenticated HTTP requests to RESTish resources.
   692  *
   693  * @param uri
   694  *        URI going to the RESTRequest constructor.
   695  * @param authToken
   696  *        (Object) An auth token of the form {id: (string), key: (string)}
   697  *        from which the MAC Authentication header for this request will be
   698  *        derived. A token as obtained from
   699  *        TokenServerClient.getTokenFromBrowserIDAssertion is accepted.
   700  * @param extra
   701  *        (Object) Optional extra parameters. Valid keys are: nonce_bytes, ts,
   702  *        nonce, and ext. See CrytoUtils.computeHTTPMACSHA1 for information on
   703  *        the purpose of these values.
   704  */
   705 this.TokenAuthenticatedRESTRequest =
   706  function TokenAuthenticatedRESTRequest(uri, authToken, extra) {
   707   RESTRequest.call(this, uri);
   708   this.authToken = authToken;
   709   this.extra = extra || {};
   710 }
   711 TokenAuthenticatedRESTRequest.prototype = {
   712   __proto__: RESTRequest.prototype,
   714   dispatch: function dispatch(method, data, onComplete, onProgress) {
   715     let sig = CryptoUtils.computeHTTPMACSHA1(
   716       this.authToken.id, this.authToken.key, method, this.uri, this.extra
   717     );
   719     this.setHeader("Authorization", sig.getHeader());
   721     return RESTRequest.prototype.dispatch.call(
   722       this, method, data, onComplete, onProgress
   723     );
   724   },
   725 };

mercurial