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 import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs";
7 import { Log } from "resource://gre/modules/Log.sys.mjs";
8 import { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs";
10 import { CommonUtils } from "resource://services-common/utils.sys.mjs";
14 ChromeUtils.defineESModuleGetters(lazy, {
15 CryptoUtils: "resource://services-crypto/utils.sys.mjs",
18 function decodeString(data, charset) {
19 if (!data || !charset) {
23 // This could be simpler if we assumed the charset is only ever UTF-8.
24 // It's unclear to me how willing we are to assume this, though...
25 let stringStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
26 Ci.nsIStringInputStream
28 stringStream.setData(data, data.length);
30 let converterStream = Cc[
31 "@mozilla.org/intl/converter-input-stream;1"
32 ].createInstance(Ci.nsIConverterInputStream);
38 converterStream.DEFAULT_REPLACEMENT_CHARACTER
41 let remaining = data.length;
43 while (remaining > 0) {
45 let num = converterStream.readString(remaining, str);
56 * Single use HTTP requests to RESTish resources.
59 * URI for the request. This can be an nsIURI object or a string
60 * that can be used to create one. An exception will be thrown if
61 * the string is not a valid URI.
65 * (1) Quick GET request:
67 * let response = await new RESTRequest("http://server/rest/resource").get();
68 * if (!response.success) {
69 * // Bail out if we're not getting an HTTP 2xx code.
70 * processHTTPError(response.status);
73 * processData(response.body);
75 * (2) Quick PUT request (non-string data is automatically JSONified)
77 * let response = await new RESTRequest("http://server/rest/resource").put(data);
79 export function RESTRequest(uri) {
80 this.status = this.NOT_SENT;
82 // If we don't have an nsIURI object yet, make one. This will throw if
83 // 'uri' isn't a valid URI string.
84 if (!(uri instanceof Ci.nsIURI)) {
85 uri = Services.io.newURI(uri);
90 this._deferred = PromiseUtils.defer();
91 this._log = Log.repository.getLogger(this._logName);
92 this._log.manageLevelFromPref("services.common.log.logger.rest.request");
95 RESTRequest.prototype = {
96 _logName: "Services.Common.RESTRequest",
98 QueryInterface: ChromeUtils.generateQI([
99 "nsIInterfaceRequestor",
100 "nsIChannelEventSink",
106 * URI for the request (an nsIURI object).
111 * HTTP method (e.g. "GET")
116 * RESTResponse object
121 * nsIRequest load flags. Don't do any caching by default. Don't send user
122 * cookies and such over the wire (Bug 644734).
125 Ci.nsIRequest.LOAD_BYPASS_CACHE |
126 Ci.nsIRequest.INHIBIT_CACHING |
127 Ci.nsIRequest.LOAD_ANONYMOUS,
135 * Flag to indicate the status of the request.
137 * One of NOT_SENT, SENT, IN_PROGRESS, COMPLETED, ABORTED.
148 * HTTP status text of response
153 * Request timeout (in seconds, though decimal values can be used for
154 * up to millisecond granularity.)
156 * 0 for no timeout. Default is 300 seconds (5 minutes), the same as Sync uses
162 * The encoding with which the response to this request must be treated.
163 * If a charset parameter is available in the HTTP Content-Type header for
164 * this response, that will always be used, and this value is ignored. We
165 * default to UTF-8 because that is a reasonable default.
170 * Set a request header.
172 setHeader(name, value) {
173 this._headers[name.toLowerCase()] = value;
177 * Perform an HTTP GET.
179 * @return Promise<RESTResponse>
182 return this.dispatch("GET", null);
186 * Perform an HTTP PATCH.
189 * Data to be used as the request body. If this isn't a string
190 * it will be JSONified automatically.
192 * @return Promise<RESTResponse>
195 return this.dispatch("PATCH", data);
199 * Perform an HTTP PUT.
202 * Data to be used as the request body. If this isn't a string
203 * it will be JSONified automatically.
205 * @return Promise<RESTResponse>
208 return this.dispatch("PUT", data);
212 * Perform an HTTP POST.
215 * Data to be used as the request body. If this isn't a string
216 * it will be JSONified automatically.
218 * @return Promise<RESTResponse>
221 return this.dispatch("POST", data);
225 * Perform an HTTP DELETE.
227 * @return Promise<RESTResponse>
230 return this.dispatch("DELETE", null);
234 * Abort an active request.
236 abort(rejectWithError = null) {
237 if (this.status != this.SENT && this.status != this.IN_PROGRESS) {
238 throw new Error("Can only abort a request that has been sent.");
241 this.status = this.ABORTED;
242 this.channel.cancel(Cr.NS_BINDING_ABORTED);
244 if (this.timeoutTimer) {
245 // Clear the abort timer now that the channel is done.
246 this.timeoutTimer.clear();
248 if (rejectWithError) {
249 this._deferred.reject(rejectWithError);
253 /** Implementation stuff **/
255 async dispatch(method, data) {
256 if (this.status != this.NOT_SENT) {
257 throw new Error("Request has already been sent!");
260 this.method = method;
262 // Create and initialize HTTP channel.
263 let channel = NetUtil.newChannel({
265 loadUsingSystemPrincipal: true,
267 .QueryInterface(Ci.nsIRequest)
268 .QueryInterface(Ci.nsIHttpChannel);
269 this.channel = channel;
270 channel.loadFlags |= this.loadFlags;
271 channel.notificationCallbacks = this;
273 this._log.debug(`${method} request to ${this.uri.spec}`);
274 // Set request headers.
275 let headers = this._headers;
276 for (let key in headers) {
277 if (key == "authorization" || key == "x-client-state") {
278 this._log.trace("HTTP Header " + key + ": ***** (suppressed)");
280 this._log.trace("HTTP Header " + key + ": " + headers[key]);
282 channel.setRequestHeader(key, headers[key], false);
285 // REST requests accept JSON by default
286 if (!headers.accept) {
287 channel.setRequestHeader(
289 "application/json;q=0.9,*/*;q=0.2",
294 // Set HTTP request body.
295 if (method == "PUT" || method == "POST" || method == "PATCH") {
296 // Convert non-string bodies into JSON with utf-8 encoding. If a string
297 // is passed we assume they've already encoded it.
298 let contentType = headers["content-type"];
299 if (typeof data != "string") {
300 data = JSON.stringify(data);
302 contentType = "application/json";
304 if (!contentType.includes("charset")) {
305 data = CommonUtils.encodeUTF8(data);
306 contentType += "; charset=utf-8";
308 // If someone handed us an object but also a custom content-type
309 // it's probably confused. We could go to even further lengths to
310 // respect it, but this shouldn't happen in practice.
312 "rest.js found an object to JSON.stringify but also a " +
313 "content-type header with a charset specification. " +
314 "This probably isn't going to do what you expect"
319 contentType = "text/plain";
322 this._log.debug(method + " Length: " + data.length);
323 if (this._log.level <= Log.Level.Trace) {
324 this._log.trace(method + " Body: " + data);
327 let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
328 Ci.nsIStringInputStream
330 stream.setData(data, data.length);
332 channel.QueryInterface(Ci.nsIUploadChannel);
333 channel.setUploadStream(stream, contentType, data.length);
335 // We must set this after setting the upload stream, otherwise it
336 // will always be 'PUT'. Yeah, I know.
337 channel.requestMethod = method;
339 // Before opening the channel, set the charset that serves as a hint
340 // as to what the response might be encoded as.
341 channel.contentCharset = this.charset;
345 channel.asyncOpen(this);
347 // asyncOpen can throw in a bunch of cases -- e.g., a forbidden port.
348 this._log.warn("Caught an error in asyncOpen", ex);
349 this._deferred.reject(ex);
351 this.status = this.SENT;
353 return this._deferred.promise;
357 * Create or push back the abort timer that kills this request.
361 CommonUtils.namedTimer(
371 * Abort the request based on a timeout.
375 Components.Exception(
376 "Aborting due to channel inactivity.",
377 Cr.NS_ERROR_NET_TIMEOUT
382 /** nsIStreamListener **/
384 onStartRequest(channel) {
385 if (this.status == this.ABORTED) {
387 "Not proceeding with onStartRequest, request was aborted."
389 // We might have already rejected, but just in case.
390 this._deferred.reject(
391 Components.Exception("Request aborted", Cr.NS_BINDING_ABORTED)
397 channel.QueryInterface(Ci.nsIHttpChannel);
399 this._log.error("Unexpected error: channel is not a nsIHttpChannel!");
400 this.status = this.ABORTED;
401 channel.cancel(Cr.NS_BINDING_ABORTED);
402 this._deferred.reject(ex);
406 this.status = this.IN_PROGRESS;
409 "onStartRequest: " + channel.requestMethod + " " + channel.URI.spec
412 // Create a new response object.
413 this.response = new RESTResponse(this);
418 onStopRequest(channel, statusCode) {
419 if (this.timeoutTimer) {
420 // Clear the abort timer now that the channel is done.
421 this.timeoutTimer.clear();
424 // We don't want to do anything for a request that's already been aborted.
425 if (this.status == this.ABORTED) {
427 "Not proceeding with onStopRequest, request was aborted."
429 // We might not have already rejected if the user called reject() manually.
430 // If we have already rejected, then this is a no-op
431 this._deferred.reject(
432 Components.Exception("Request aborted", Cr.NS_BINDING_ABORTED)
438 channel.QueryInterface(Ci.nsIHttpChannel);
440 this._log.error("Unexpected error: channel not nsIHttpChannel!");
441 this.status = this.ABORTED;
442 this._deferred.reject(ex);
446 this.status = this.COMPLETED;
449 this.response.body = decodeString(
450 this.response._rawBody,
451 this.response.charset
453 this.response._rawBody = null;
456 `Exception decoding response - ${this.method} ${channel.URI.spec}`,
459 this._deferred.reject(ex);
463 let statusSuccess = Components.isSuccessCode(statusCode);
464 let uri = (channel && channel.URI && channel.URI.spec) || "<unknown>";
467 channel.requestMethod +
470 " returned status code " +
474 // Throw the failure code and stop execution. Use Components.Exception()
475 // instead of Error() so the exception is QI-able and can be passed across
476 // XPCOM borders while preserving the status code.
477 if (!statusSuccess) {
478 let message = Components.Exception("", statusCode).name;
479 let error = Components.Exception(message, statusCode);
481 this.method + " " + uri + " failed: " + statusCode + " - " + message
483 // Additionally give the full response body when Trace logging.
484 if (this._log.level <= Log.Level.Trace) {
485 this._log.trace(this.method + " body", this.response.body);
487 this._deferred.reject(error);
491 this._log.debug(this.method + " " + uri + " " + this.response.status);
493 // Note that for privacy/security reasons we don't log this response body
495 delete this._inputStream;
497 this._deferred.resolve(this.response);
500 onDataAvailable(channel, stream, off, count) {
501 // We get an nsIRequest, which doesn't have contentCharset.
503 channel.QueryInterface(Ci.nsIHttpChannel);
505 this._log.error("Unexpected error: channel not nsIHttpChannel!");
510 if (channel.contentCharset) {
511 this.response.charset = channel.contentCharset;
513 this.response.charset = null;
516 if (!this._inputStream) {
517 this._inputStream = Cc[
518 "@mozilla.org/scriptableinputstream;1"
519 ].createInstance(Ci.nsIScriptableInputStream);
521 this._inputStream.init(stream);
523 this.response._rawBody += this._inputStream.read(count);
528 /** nsIInterfaceRequestor **/
531 return this.QueryInterface(aIID);
535 * Returns true if headers from the old channel should be
536 * copied to the new channel. Invoked when a channel redirect
539 shouldCopyOnRedirect(oldChannel, newChannel, flags) {
540 let isInternal = !!(flags & Ci.nsIChannelEventSink.REDIRECT_INTERNAL);
541 let isSameURI = newChannel.URI.equals(oldChannel.URI);
543 "Channel redirect: " +
544 oldChannel.URI.spec +
546 newChannel.URI.spec +
550 return isInternal && isSameURI;
553 /** nsIChannelEventSink **/
554 asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
556 oldChannel && oldChannel.URI ? oldChannel.URI.spec : "<undefined>";
558 newChannel && newChannel.URI ? newChannel.URI.spec : "<undefined>";
560 "Channel redirect: " + oldSpec + ", " + newSpec + ", " + flags
564 newChannel.QueryInterface(Ci.nsIHttpChannel);
566 this._log.error("Unexpected error: channel not nsIHttpChannel!");
567 callback.onRedirectVerifyCallback(Cr.NS_ERROR_NO_INTERFACE);
571 // For internal redirects, copy the headers that our caller set.
573 if (this.shouldCopyOnRedirect(oldChannel, newChannel, flags)) {
574 this._log.trace("Copying headers for safe internal redirect.");
575 for (let key in this._headers) {
576 newChannel.setRequestHeader(key, this._headers[key], false);
580 this._log.error("Error copying headers", ex);
583 this.channel = newChannel;
585 // We let all redirects proceed.
586 callback.onRedirectVerifyCallback(Cr.NS_OK);
591 * Response object for a RESTRequest. This will be created automatically by
594 export function RESTResponse(request = null) {
597 this.request = request;
598 this._log = Log.repository.getLogger(this._logName);
599 this._log.manageLevelFromPref("services.common.log.logger.rest.response");
602 RESTResponse.prototype = {
603 _logName: "Services.Common.RESTResponse",
606 * Corresponding REST request
616 status = this.request.channel.responseStatus;
618 this._log.debug("Caught exception fetching HTTP status code", ex);
621 Object.defineProperty(this, "status", { value: status });
631 statusText = this.request.channel.responseStatusText;
633 this._log.debug("Caught exception fetching HTTP status text", ex);
636 Object.defineProperty(this, "statusText", { value: statusText });
641 * Boolean flag that indicates whether the HTTP status code is 2xx or not.
646 success = this.request.channel.requestSucceeded;
648 this._log.debug("Caught exception fetching HTTP success flag", ex);
651 Object.defineProperty(this, "success", { value: success });
656 * Object containing HTTP headers (keyed as lower case)
661 this._log.trace("Processing response headers.");
662 let channel = this.request.channel.QueryInterface(Ci.nsIHttpChannel);
663 channel.visitResponseHeaders(function (header, value) {
664 headers[header.toLowerCase()] = value;
667 this._log.debug("Caught exception processing response headers", ex);
671 Object.defineProperty(this, "headers", { value: headers });
682 * Single use MAC authenticated HTTP requests to RESTish resources.
685 * URI going to the RESTRequest constructor.
687 * (Object) An auth token of the form {id: (string), key: (string)}
688 * from which the MAC Authentication header for this request will be
689 * derived. A token as obtained from
690 * TokenServerClient.getTokenUsingOAuth is accepted.
692 * (Object) Optional extra parameters. Valid keys are: nonce_bytes, ts,
693 * nonce, and ext. See CrytoUtils.computeHTTPMACSHA1 for information on
694 * the purpose of these values.
696 export function TokenAuthenticatedRESTRequest(uri, authToken, extra) {
697 RESTRequest.call(this, uri);
698 this.authToken = authToken;
699 this.extra = extra || {};
702 TokenAuthenticatedRESTRequest.prototype = {
703 async dispatch(method, data) {
704 let sig = await lazy.CryptoUtils.computeHTTPMACSHA1(
712 this.setHeader("Authorization", sig.getHeader());
714 return super.dispatch(method, data);
718 Object.setPrototypeOf(
719 TokenAuthenticatedRESTRequest.prototype,
720 RESTRequest.prototype