summaryrefslogtreecommitdiffstats
path: root/services/common/rest.js
diff options
context:
space:
mode:
Diffstat (limited to 'services/common/rest.js')
-rw-r--r--services/common/rest.js764
1 files changed, 764 insertions, 0 deletions
diff --git a/services/common/rest.js b/services/common/rest.js
new file mode 100644
index 000000000..5474dd947
--- /dev/null
+++ b/services/common/rest.js
@@ -0,0 +1,764 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+this.EXPORTED_SYMBOLS = [
+ "RESTRequest",
+ "RESTResponse",
+ "TokenAuthenticatedRESTRequest",
+];
+
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/NetUtil.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://services-common/utils.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "CryptoUtils",
+ "resource://services-crypto/utils.js");
+
+const Prefs = new Preferences("services.common.");
+
+/**
+ * Single use HTTP requests to RESTish resources.
+ *
+ * @param uri
+ * URI for the request. This can be an nsIURI object or a string
+ * that can be used to create one. An exception will be thrown if
+ * the string is not a valid URI.
+ *
+ * Examples:
+ *
+ * (1) Quick GET request:
+ *
+ * new RESTRequest("http://server/rest/resource").get(function (error) {
+ * if (error) {
+ * // Deal with a network error.
+ * processNetworkErrorCode(error.result);
+ * return;
+ * }
+ * if (!this.response.success) {
+ * // Bail out if we're not getting an HTTP 2xx code.
+ * processHTTPError(this.response.status);
+ * return;
+ * }
+ * processData(this.response.body);
+ * });
+ *
+ * (2) Quick PUT request (non-string data is automatically JSONified)
+ *
+ * new RESTRequest("http://server/rest/resource").put(data, function (error) {
+ * ...
+ * });
+ *
+ * (3) Streaming GET
+ *
+ * let request = new RESTRequest("http://server/rest/resource");
+ * request.setHeader("Accept", "application/newlines");
+ * request.onComplete = function (error) {
+ * if (error) {
+ * // Deal with a network error.
+ * processNetworkErrorCode(error.result);
+ * return;
+ * }
+ * callbackAfterRequestHasCompleted()
+ * });
+ * request.onProgress = function () {
+ * if (!this.response.success) {
+ * // Bail out if we're not getting an HTTP 2xx code.
+ * return;
+ * }
+ * // Process body data and reset it so we don't process the same data twice.
+ * processIncrementalData(this.response.body);
+ * this.response.body = "";
+ * });
+ * request.get();
+ */
+this.RESTRequest = function RESTRequest(uri) {
+ this.status = this.NOT_SENT;
+
+ // If we don't have an nsIURI object yet, make one. This will throw if
+ // 'uri' isn't a valid URI string.
+ if (!(uri instanceof Ci.nsIURI)) {
+ uri = Services.io.newURI(uri, null, null);
+ }
+ this.uri = uri;
+
+ this._headers = {};
+ this._log = Log.repository.getLogger(this._logName);
+ this._log.level =
+ Log.Level[Prefs.get("log.logger.rest.request")];
+}
+RESTRequest.prototype = {
+
+ _logName: "Services.Common.RESTRequest",
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIBadCertListener2,
+ Ci.nsIInterfaceRequestor,
+ Ci.nsIChannelEventSink
+ ]),
+
+ /*** Public API: ***/
+
+ /**
+ * A constant boolean that indicates whether this object will automatically
+ * utf-8 encode request bodies passed as an object. Used for feature detection
+ * so, eg, loop can use the same source code for old and new Firefox versions.
+ */
+ willUTF8EncodeObjectRequests: true,
+
+ /**
+ * URI for the request (an nsIURI object).
+ */
+ uri: null,
+
+ /**
+ * HTTP method (e.g. "GET")
+ */
+ method: null,
+
+ /**
+ * RESTResponse object
+ */
+ response: null,
+
+ /**
+ * nsIRequest load flags. Don't do any caching by default. Don't send user
+ * cookies and such over the wire (Bug 644734).
+ */
+ loadFlags: Ci.nsIRequest.LOAD_BYPASS_CACHE | Ci.nsIRequest.INHIBIT_CACHING | Ci.nsIRequest.LOAD_ANONYMOUS,
+
+ /**
+ * nsIHttpChannel
+ */
+ channel: null,
+
+ /**
+ * Flag to indicate the status of the request.
+ *
+ * One of NOT_SENT, SENT, IN_PROGRESS, COMPLETED, ABORTED.
+ */
+ status: null,
+
+ NOT_SENT: 0,
+ SENT: 1,
+ IN_PROGRESS: 2,
+ COMPLETED: 4,
+ ABORTED: 8,
+
+ /**
+ * HTTP status text of response
+ */
+ statusText: null,
+
+ /**
+ * Request timeout (in seconds, though decimal values can be used for
+ * up to millisecond granularity.)
+ *
+ * 0 for no timeout.
+ */
+ timeout: null,
+
+ /**
+ * The encoding with which the response to this request must be treated.
+ * If a charset parameter is available in the HTTP Content-Type header for
+ * this response, that will always be used, and this value is ignored. We
+ * default to UTF-8 because that is a reasonable default.
+ */
+ charset: "utf-8",
+
+ /**
+ * Called when the request has been completed, including failures and
+ * timeouts.
+ *
+ * @param error
+ * Error that occurred while making the request, null if there
+ * was no error.
+ */
+ onComplete: function onComplete(error) {
+ },
+
+ /**
+ * Called whenever data is being received on the channel. If this throws an
+ * exception, the request is aborted and the exception is passed as the
+ * error to onComplete().
+ */
+ onProgress: function onProgress() {
+ },
+
+ /**
+ * Set a request header.
+ */
+ setHeader: function setHeader(name, value) {
+ this._headers[name.toLowerCase()] = value;
+ },
+
+ /**
+ * Perform an HTTP GET.
+ *
+ * @param onComplete
+ * Short-circuit way to set the 'onComplete' method. Optional.
+ * @param onProgress
+ * Short-circuit way to set the 'onProgress' method. Optional.
+ *
+ * @return the request object.
+ */
+ get: function get(onComplete, onProgress) {
+ return this.dispatch("GET", null, onComplete, onProgress);
+ },
+
+ /**
+ * Perform an HTTP PATCH.
+ *
+ * @param data
+ * Data to be used as the request body. If this isn't a string
+ * it will be JSONified automatically.
+ * @param onComplete
+ * Short-circuit way to set the 'onComplete' method. Optional.
+ * @param onProgress
+ * Short-circuit way to set the 'onProgress' method. Optional.
+ *
+ * @return the request object.
+ */
+ patch: function patch(data, onComplete, onProgress) {
+ return this.dispatch("PATCH", data, onComplete, onProgress);
+ },
+
+ /**
+ * Perform an HTTP PUT.
+ *
+ * @param data
+ * Data to be used as the request body. If this isn't a string
+ * it will be JSONified automatically.
+ * @param onComplete
+ * Short-circuit way to set the 'onComplete' method. Optional.
+ * @param onProgress
+ * Short-circuit way to set the 'onProgress' method. Optional.
+ *
+ * @return the request object.
+ */
+ put: function put(data, onComplete, onProgress) {
+ return this.dispatch("PUT", data, onComplete, onProgress);
+ },
+
+ /**
+ * Perform an HTTP POST.
+ *
+ * @param data
+ * Data to be used as the request body. If this isn't a string
+ * it will be JSONified automatically.
+ * @param onComplete
+ * Short-circuit way to set the 'onComplete' method. Optional.
+ * @param onProgress
+ * Short-circuit way to set the 'onProgress' method. Optional.
+ *
+ * @return the request object.
+ */
+ post: function post(data, onComplete, onProgress) {
+ return this.dispatch("POST", data, onComplete, onProgress);
+ },
+
+ /**
+ * Perform an HTTP DELETE.
+ *
+ * @param onComplete
+ * Short-circuit way to set the 'onComplete' method. Optional.
+ * @param onProgress
+ * Short-circuit way to set the 'onProgress' method. Optional.
+ *
+ * @return the request object.
+ */
+ delete: function delete_(onComplete, onProgress) {
+ return this.dispatch("DELETE", null, onComplete, onProgress);
+ },
+
+ /**
+ * Abort an active request.
+ */
+ abort: function abort() {
+ if (this.status != this.SENT && this.status != this.IN_PROGRESS) {
+ throw "Can only abort a request that has been sent.";
+ }
+
+ this.status = this.ABORTED;
+ this.channel.cancel(Cr.NS_BINDING_ABORTED);
+
+ if (this.timeoutTimer) {
+ // Clear the abort timer now that the channel is done.
+ this.timeoutTimer.clear();
+ }
+ },
+
+ /*** Implementation stuff ***/
+
+ dispatch: function dispatch(method, data, onComplete, onProgress) {
+ if (this.status != this.NOT_SENT) {
+ throw "Request has already been sent!";
+ }
+
+ this.method = method;
+ if (onComplete) {
+ this.onComplete = onComplete;
+ }
+ if (onProgress) {
+ this.onProgress = onProgress;
+ }
+
+ // Create and initialize HTTP channel.
+ let channel = NetUtil.newChannel({uri: this.uri, loadUsingSystemPrincipal: true})
+ .QueryInterface(Ci.nsIRequest)
+ .QueryInterface(Ci.nsIHttpChannel);
+ this.channel = channel;
+ channel.loadFlags |= this.loadFlags;
+ channel.notificationCallbacks = this;
+
+ this._log.debug(`${method} request to ${this.uri.spec}`);
+ // Set request headers.
+ let headers = this._headers;
+ for (let key in headers) {
+ if (key == 'authorization') {
+ this._log.trace("HTTP Header " + key + ": ***** (suppressed)");
+ } else {
+ this._log.trace("HTTP Header " + key + ": " + headers[key]);
+ }
+ channel.setRequestHeader(key, headers[key], false);
+ }
+
+ // Set HTTP request body.
+ if (method == "PUT" || method == "POST" || method == "PATCH") {
+ // Convert non-string bodies into JSON with utf-8 encoding. If a string
+ // is passed we assume they've already encoded it.
+ let contentType = headers["content-type"];
+ if (typeof data != "string") {
+ data = JSON.stringify(data);
+ if (!contentType) {
+ contentType = "application/json";
+ }
+ if (!contentType.includes("charset")) {
+ data = CommonUtils.encodeUTF8(data);
+ contentType += "; charset=utf-8";
+ } else {
+ // If someone handed us an object but also a custom content-type
+ // it's probably confused. We could go to even further lengths to
+ // respect it, but this shouldn't happen in practice.
+ Cu.reportError("rest.js found an object to JSON.stringify but also a " +
+ "content-type header with a charset specification. " +
+ "This probably isn't going to do what you expect");
+ }
+ }
+ if (!contentType) {
+ contentType = "text/plain";
+ }
+
+ this._log.debug(method + " Length: " + data.length);
+ if (this._log.level <= Log.Level.Trace) {
+ this._log.trace(method + " Body: " + data);
+ }
+
+ let stream = Cc["@mozilla.org/io/string-input-stream;1"]
+ .createInstance(Ci.nsIStringInputStream);
+ stream.setData(data, data.length);
+
+ channel.QueryInterface(Ci.nsIUploadChannel);
+ channel.setUploadStream(stream, contentType, data.length);
+ }
+ // We must set this after setting the upload stream, otherwise it
+ // will always be 'PUT'. Yeah, I know.
+ channel.requestMethod = method;
+
+ // Before opening the channel, set the charset that serves as a hint
+ // as to what the response might be encoded as.
+ channel.contentCharset = this.charset;
+
+ // Blast off!
+ try {
+ channel.asyncOpen2(this);
+ } catch (ex) {
+ // asyncOpen can throw in a bunch of cases -- e.g., a forbidden port.
+ this._log.warn("Caught an error in asyncOpen", ex);
+ CommonUtils.nextTick(onComplete.bind(this, ex));
+ }
+ this.status = this.SENT;
+ this.delayTimeout();
+ return this;
+ },
+
+ /**
+ * Create or push back the abort timer that kills this request.
+ */
+ delayTimeout: function delayTimeout() {
+ if (this.timeout) {
+ CommonUtils.namedTimer(this.abortTimeout, this.timeout * 1000, this,
+ "timeoutTimer");
+ }
+ },
+
+ /**
+ * Abort the request based on a timeout.
+ */
+ abortTimeout: function abortTimeout() {
+ this.abort();
+ let error = Components.Exception("Aborting due to channel inactivity.",
+ Cr.NS_ERROR_NET_TIMEOUT);
+ if (!this.onComplete) {
+ this._log.error("Unexpected error: onComplete not defined in " +
+ "abortTimeout.");
+ return;
+ }
+ this.onComplete(error);
+ },
+
+ /*** nsIStreamListener ***/
+
+ onStartRequest: function onStartRequest(channel) {
+ if (this.status == this.ABORTED) {
+ this._log.trace("Not proceeding with onStartRequest, request was aborted.");
+ return;
+ }
+
+ try {
+ channel.QueryInterface(Ci.nsIHttpChannel);
+ } catch (ex) {
+ this._log.error("Unexpected error: channel is not a nsIHttpChannel!");
+ this.status = this.ABORTED;
+ channel.cancel(Cr.NS_BINDING_ABORTED);
+ return;
+ }
+
+ this.status = this.IN_PROGRESS;
+
+ this._log.trace("onStartRequest: " + channel.requestMethod + " " +
+ channel.URI.spec);
+
+ // Create a response object and fill it with some data.
+ let response = this.response = new RESTResponse();
+ response.request = this;
+ response.body = "";
+
+ this.delayTimeout();
+ },
+
+ onStopRequest: function onStopRequest(channel, context, statusCode) {
+ if (this.timeoutTimer) {
+ // Clear the abort timer now that the channel is done.
+ this.timeoutTimer.clear();
+ }
+
+ // We don't want to do anything for a request that's already been aborted.
+ if (this.status == this.ABORTED) {
+ this._log.trace("Not proceeding with onStopRequest, request was aborted.");
+ return;
+ }
+
+ try {
+ channel.QueryInterface(Ci.nsIHttpChannel);
+ } catch (ex) {
+ this._log.error("Unexpected error: channel not nsIHttpChannel!");
+ this.status = this.ABORTED;
+ return;
+ }
+ this.status = this.COMPLETED;
+
+ let statusSuccess = Components.isSuccessCode(statusCode);
+ let uri = channel && channel.URI && channel.URI.spec || "<unknown>";
+ this._log.trace("Channel for " + channel.requestMethod + " " + uri +
+ " returned status code " + statusCode);
+
+ if (!this.onComplete) {
+ this._log.error("Unexpected error: onComplete not defined in " +
+ "abortRequest.");
+ this.onProgress = null;
+ return;
+ }
+
+ // Throw the failure code and stop execution. Use Components.Exception()
+ // instead of Error() so the exception is QI-able and can be passed across
+ // XPCOM borders while preserving the status code.
+ if (!statusSuccess) {
+ let message = Components.Exception("", statusCode).name;
+ let error = Components.Exception(message, statusCode);
+ this._log.debug(this.method + " " + uri + " failed: " + statusCode + " - " + message);
+ this.onComplete(error);
+ this.onComplete = this.onProgress = null;
+ return;
+ }
+
+ this._log.debug(this.method + " " + uri + " " + this.response.status);
+
+ // Additionally give the full response body when Trace logging.
+ if (this._log.level <= Log.Level.Trace) {
+ this._log.trace(this.method + " body: " + this.response.body);
+ }
+
+ delete this._inputStream;
+
+ this.onComplete(null);
+ this.onComplete = this.onProgress = null;
+ },
+
+ onDataAvailable: function onDataAvailable(channel, cb, stream, off, count) {
+ // We get an nsIRequest, which doesn't have contentCharset.
+ try {
+ channel.QueryInterface(Ci.nsIHttpChannel);
+ } catch (ex) {
+ this._log.error("Unexpected error: channel not nsIHttpChannel!");
+ this.abort();
+
+ if (this.onComplete) {
+ this.onComplete(ex);
+ }
+
+ this.onComplete = this.onProgress = null;
+ return;
+ }
+
+ if (channel.contentCharset) {
+ this.response.charset = channel.contentCharset;
+
+ if (!this._converterStream) {
+ this._converterStream = Cc["@mozilla.org/intl/converter-input-stream;1"]
+ .createInstance(Ci.nsIConverterInputStream);
+ }
+
+ this._converterStream.init(stream, channel.contentCharset, 0,
+ this._converterStream.DEFAULT_REPLACEMENT_CHARACTER);
+
+ try {
+ let str = {};
+ let num = this._converterStream.readString(count, str);
+ if (num != 0) {
+ this.response.body += str.value;
+ }
+ } catch (ex) {
+ this._log.warn("Exception thrown reading " + count + " bytes from " +
+ "the channel", ex);
+ throw ex;
+ }
+ } else {
+ this.response.charset = null;
+
+ if (!this._inputStream) {
+ this._inputStream = Cc["@mozilla.org/scriptableinputstream;1"]
+ .createInstance(Ci.nsIScriptableInputStream);
+ }
+
+ this._inputStream.init(stream);
+
+ this.response.body += this._inputStream.read(count);
+ }
+
+ try {
+ this.onProgress();
+ } catch (ex) {
+ this._log.warn("Got exception calling onProgress handler, aborting " +
+ this.method + " " + channel.URI.spec, ex);
+ this.abort();
+
+ if (!this.onComplete) {
+ this._log.error("Unexpected error: onComplete not defined in " +
+ "onDataAvailable.");
+ this.onProgress = null;
+ return;
+ }
+
+ this.onComplete(ex);
+ this.onComplete = this.onProgress = null;
+ return;
+ }
+
+ this.delayTimeout();
+ },
+
+ /*** nsIInterfaceRequestor ***/
+
+ getInterface: function(aIID) {
+ return this.QueryInterface(aIID);
+ },
+
+ /*** nsIBadCertListener2 ***/
+
+ notifyCertProblem: function notifyCertProblem(socketInfo, sslStatus, targetHost) {
+ this._log.warn("Invalid HTTPS certificate encountered!");
+ // Suppress invalid HTTPS certificate warnings in the UI.
+ // (The request will still fail.)
+ return true;
+ },
+
+ /**
+ * Returns true if headers from the old channel should be
+ * copied to the new channel. Invoked when a channel redirect
+ * is in progress.
+ */
+ shouldCopyOnRedirect: function shouldCopyOnRedirect(oldChannel, newChannel, flags) {
+ let isInternal = !!(flags & Ci.nsIChannelEventSink.REDIRECT_INTERNAL);
+ let isSameURI = newChannel.URI.equals(oldChannel.URI);
+ this._log.debug("Channel redirect: " + oldChannel.URI.spec + ", " +
+ newChannel.URI.spec + ", internal = " + isInternal);
+ return isInternal && isSameURI;
+ },
+
+ /*** nsIChannelEventSink ***/
+ asyncOnChannelRedirect:
+ function asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
+
+ let oldSpec = (oldChannel && oldChannel.URI) ? oldChannel.URI.spec : "<undefined>";
+ let newSpec = (newChannel && newChannel.URI) ? newChannel.URI.spec : "<undefined>";
+ this._log.debug("Channel redirect: " + oldSpec + ", " + newSpec + ", " + flags);
+
+ try {
+ newChannel.QueryInterface(Ci.nsIHttpChannel);
+ } catch (ex) {
+ this._log.error("Unexpected error: channel not nsIHttpChannel!");
+ callback.onRedirectVerifyCallback(Cr.NS_ERROR_NO_INTERFACE);
+ return;
+ }
+
+ // For internal redirects, copy the headers that our caller set.
+ try {
+ if (this.shouldCopyOnRedirect(oldChannel, newChannel, flags)) {
+ this._log.trace("Copying headers for safe internal redirect.");
+ for (let key in this._headers) {
+ newChannel.setRequestHeader(key, this._headers[key], false);
+ }
+ }
+ } catch (ex) {
+ this._log.error("Error copying headers", ex);
+ }
+
+ this.channel = newChannel;
+
+ // We let all redirects proceed.
+ callback.onRedirectVerifyCallback(Cr.NS_OK);
+ }
+};
+
+/**
+ * Response object for a RESTRequest. This will be created automatically by
+ * the RESTRequest.
+ */
+this.RESTResponse = function RESTResponse() {
+ this._log = Log.repository.getLogger(this._logName);
+ this._log.level =
+ Log.Level[Prefs.get("log.logger.rest.response")];
+}
+RESTResponse.prototype = {
+
+ _logName: "Services.Common.RESTResponse",
+
+ /**
+ * Corresponding REST request
+ */
+ request: null,
+
+ /**
+ * HTTP status code
+ */
+ get status() {
+ let status;
+ try {
+ status = this.request.channel.responseStatus;
+ } catch (ex) {
+ this._log.debug("Caught exception fetching HTTP status code", ex);
+ return null;
+ }
+ Object.defineProperty(this, "status", {value: status});
+ return status;
+ },
+
+ /**
+ * HTTP status text
+ */
+ get statusText() {
+ let statusText;
+ try {
+ statusText = this.request.channel.responseStatusText;
+ } catch (ex) {
+ this._log.debug("Caught exception fetching HTTP status text", ex);
+ return null;
+ }
+ Object.defineProperty(this, "statusText", {value: statusText});
+ return statusText;
+ },
+
+ /**
+ * Boolean flag that indicates whether the HTTP status code is 2xx or not.
+ */
+ get success() {
+ let success;
+ try {
+ success = this.request.channel.requestSucceeded;
+ } catch (ex) {
+ this._log.debug("Caught exception fetching HTTP success flag", ex);
+ return null;
+ }
+ Object.defineProperty(this, "success", {value: success});
+ return success;
+ },
+
+ /**
+ * Object containing HTTP headers (keyed as lower case)
+ */
+ get headers() {
+ let headers = {};
+ try {
+ this._log.trace("Processing response headers.");
+ let channel = this.request.channel.QueryInterface(Ci.nsIHttpChannel);
+ channel.visitResponseHeaders(function (header, value) {
+ headers[header.toLowerCase()] = value;
+ });
+ } catch (ex) {
+ this._log.debug("Caught exception processing response headers", ex);
+ return null;
+ }
+
+ Object.defineProperty(this, "headers", {value: headers});
+ return headers;
+ },
+
+ /**
+ * HTTP body (string)
+ */
+ body: null
+
+};
+
+/**
+ * Single use MAC authenticated HTTP requests to RESTish resources.
+ *
+ * @param uri
+ * URI going to the RESTRequest constructor.
+ * @param authToken
+ * (Object) An auth token of the form {id: (string), key: (string)}
+ * from which the MAC Authentication header for this request will be
+ * derived. A token as obtained from
+ * TokenServerClient.getTokenFromBrowserIDAssertion is accepted.
+ * @param extra
+ * (Object) Optional extra parameters. Valid keys are: nonce_bytes, ts,
+ * nonce, and ext. See CrytoUtils.computeHTTPMACSHA1 for information on
+ * the purpose of these values.
+ */
+this.TokenAuthenticatedRESTRequest =
+ function TokenAuthenticatedRESTRequest(uri, authToken, extra) {
+ RESTRequest.call(this, uri);
+ this.authToken = authToken;
+ this.extra = extra || {};
+}
+TokenAuthenticatedRESTRequest.prototype = {
+ __proto__: RESTRequest.prototype,
+
+ dispatch: function dispatch(method, data, onComplete, onProgress) {
+ let sig = CryptoUtils.computeHTTPMACSHA1(
+ this.authToken.id, this.authToken.key, method, this.uri, this.extra
+ );
+
+ this.setHeader("Authorization", sig.getHeader());
+
+ return RESTRequest.prototype.dispatch.call(
+ this, method, data, onComplete, onProgress
+ );
+ },
+};