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";
9 import { CommonUtils } from "resource://services-common/utils.sys.mjs";
13 ChromeUtils.defineESModuleGetters(lazy, {
14 CryptoUtils: "resource://services-crypto/utils.sys.mjs",
17 function decodeString(data, charset) {
18 if (!data || !charset) {
22 // This could be simpler if we assumed the charset is only ever UTF-8.
23 // It's unclear to me how willing we are to assume this, though...
24 let stringStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
25 Ci.nsIStringInputStream
27 stringStream.setData(data, data.length);
29 let converterStream = Cc[
30 "@mozilla.org/intl/converter-input-stream;1"
31 ].createInstance(Ci.nsIConverterInputStream);
37 converterStream.DEFAULT_REPLACEMENT_CHARACTER
40 let remaining = data.length;
42 while (remaining > 0) {
44 let num = converterStream.readString(remaining, str);
55 * Single use HTTP requests to RESTish resources.
58 * URI for the request. This can be an nsIURI object or a string
59 * that can be used to create one. An exception will be thrown if
60 * the string is not a valid URI.
64 * (1) Quick GET request:
66 * let response = await new RESTRequest("http://server/rest/resource").get();
67 * if (!response.success) {
68 * // Bail out if we're not getting an HTTP 2xx code.
69 * processHTTPError(response.status);
72 * processData(response.body);
74 * (2) Quick PUT request (non-string data is automatically JSONified)
76 * let response = await new RESTRequest("http://server/rest/resource").put(data);
78 export function RESTRequest(uri) {
79 this.status = this.NOT_SENT;
81 // If we don't have an nsIURI object yet, make one. This will throw if
82 // 'uri' isn't a valid URI string.
83 if (!(uri instanceof Ci.nsIURI)) {
84 uri = Services.io.newURI(uri);
89 this._deferred = Promise.withResolvers();
90 this._log = Log.repository.getLogger(this._logName);
91 this._log.manageLevelFromPref("services.common.log.logger.rest.request");
94 RESTRequest.prototype = {
95 _logName: "Services.Common.RESTRequest",
97 QueryInterface: ChromeUtils.generateQI([
98 "nsIInterfaceRequestor",
99 "nsIChannelEventSink",
105 * URI for the request (an nsIURI object).
110 * HTTP method (e.g. "GET")
115 * RESTResponse object
120 * nsIRequest load flags. Don't do any caching by default. Don't send user
121 * cookies and such over the wire (Bug 644734).
124 Ci.nsIRequest.LOAD_BYPASS_CACHE |
125 Ci.nsIRequest.INHIBIT_CACHING |
126 Ci.nsIRequest.LOAD_ANONYMOUS,
134 * Flag to indicate the status of the request.
136 * One of NOT_SENT, SENT, IN_PROGRESS, COMPLETED, ABORTED.
147 * HTTP status text of response
152 * Request timeout (in seconds, though decimal values can be used for
153 * up to millisecond granularity.)
155 * 0 for no timeout. Default is 300 seconds (5 minutes), the same as Sync uses
161 * The encoding with which the response to this request must be treated.
162 * If a charset parameter is available in the HTTP Content-Type header for
163 * this response, that will always be used, and this value is ignored. We
164 * default to UTF-8 because that is a reasonable default.
169 * Set a request header.
171 setHeader(name, value) {
172 this._headers[name.toLowerCase()] = value;
176 * Perform an HTTP GET.
178 * @return Promise<RESTResponse>
181 return this.dispatch("GET", null);
185 * Perform an HTTP PATCH.
188 * Data to be used as the request body. If this isn't a string
189 * it will be JSONified automatically.
191 * @return Promise<RESTResponse>
194 return this.dispatch("PATCH", data);
198 * Perform an HTTP PUT.
201 * Data to be used as the request body. If this isn't a string
202 * it will be JSONified automatically.
204 * @return Promise<RESTResponse>
207 return this.dispatch("PUT", data);
211 * Perform an HTTP POST.
214 * Data to be used as the request body. If this isn't a string
215 * it will be JSONified automatically.
217 * @return Promise<RESTResponse>
220 return this.dispatch("POST", data);
224 * Perform an HTTP DELETE.
226 * @return Promise<RESTResponse>
229 return this.dispatch("DELETE", null);
233 * Abort an active request.
235 abort(rejectWithError = null) {
236 if (this.status != this.SENT && this.status != this.IN_PROGRESS) {
237 throw new Error("Can only abort a request that has been sent.");
240 this.status = this.ABORTED;
241 this.channel.cancel(Cr.NS_BINDING_ABORTED);
243 if (this.timeoutTimer) {
244 // Clear the abort timer now that the channel is done.
245 this.timeoutTimer.clear();
247 if (rejectWithError) {
248 this._deferred.reject(rejectWithError);
252 /** Implementation stuff **/
254 async dispatch(method, data) {
255 if (this.status != this.NOT_SENT) {
256 throw new Error("Request has already been sent!");
259 this.method = method;
261 // Create and initialize HTTP channel.
262 let channel = NetUtil.newChannel({
264 loadUsingSystemPrincipal: true,
266 .QueryInterface(Ci.nsIRequest)
267 .QueryInterface(Ci.nsIHttpChannel);
268 this.channel = channel;
269 channel.loadFlags |= this.loadFlags;
270 channel.notificationCallbacks = this;
272 this._log.debug(`${method} request to ${this.uri.spec}`);
273 // Set request headers.
274 let headers = this._headers;
275 for (let key in headers) {
276 if (key == "authorization" || key == "x-client-state") {
277 this._log.trace("HTTP Header " + key + ": ***** (suppressed)");
279 this._log.trace("HTTP Header " + key + ": " + headers[key]);
281 channel.setRequestHeader(key, headers[key], false);
284 // REST requests accept JSON by default
285 if (!headers.accept) {
286 channel.setRequestHeader(
288 "application/json;q=0.9,*/*;q=0.2",
293 // Set HTTP request body.
294 if (method == "PUT" || method == "POST" || method == "PATCH") {
295 // Convert non-string bodies into JSON with utf-8 encoding. If a string
296 // is passed we assume they've already encoded it.
297 let contentType = headers["content-type"];
298 if (typeof data != "string") {
299 data = JSON.stringify(data);
301 contentType = "application/json";
303 if (!contentType.includes("charset")) {
304 data = CommonUtils.encodeUTF8(data);
305 contentType += "; charset=utf-8";
307 // If someone handed us an object but also a custom content-type
308 // it's probably confused. We could go to even further lengths to
309 // respect it, but this shouldn't happen in practice.
311 "rest.js found an object to JSON.stringify but also a " +
312 "content-type header with a charset specification. " +
313 "This probably isn't going to do what you expect"
318 contentType = "text/plain";
321 this._log.debug(method + " Length: " + data.length);
322 if (this._log.level <= Log.Level.Trace) {
323 this._log.trace(method + " Body: " + data);
326 let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
327 Ci.nsIStringInputStream
329 stream.setData(data, data.length);
331 channel.QueryInterface(Ci.nsIUploadChannel);
332 channel.setUploadStream(stream, contentType, data.length);
334 // We must set this after setting the upload stream, otherwise it
335 // will always be 'PUT'. Yeah, I know.
336 channel.requestMethod = method;
338 // Before opening the channel, set the charset that serves as a hint
339 // as to what the response might be encoded as.
340 channel.contentCharset = this.charset;
344 channel.asyncOpen(this);
346 // asyncOpen can throw in a bunch of cases -- e.g., a forbidden port.
347 this._log.warn("Caught an error in asyncOpen", ex);
348 this._deferred.reject(ex);
350 this.status = this.SENT;
352 return this._deferred.promise;
356 * Create or push back the abort timer that kills this request.
360 CommonUtils.namedTimer(
370 * Abort the request based on a timeout.
374 Components.Exception(
375 "Aborting due to channel inactivity.",
376 Cr.NS_ERROR_NET_TIMEOUT
381 /** nsIStreamListener **/
383 onStartRequest(channel) {
384 if (this.status == this.ABORTED) {
386 "Not proceeding with onStartRequest, request was aborted."
388 // We might have already rejected, but just in case.
389 this._deferred.reject(
390 Components.Exception("Request aborted", Cr.NS_BINDING_ABORTED)
396 channel.QueryInterface(Ci.nsIHttpChannel);
398 this._log.error("Unexpected error: channel is not a nsIHttpChannel!");
399 this.status = this.ABORTED;
400 channel.cancel(Cr.NS_BINDING_ABORTED);
401 this._deferred.reject(ex);
405 this.status = this.IN_PROGRESS;
408 "onStartRequest: " + channel.requestMethod + " " + channel.URI.spec
411 // Create a new response object.
412 this.response = new RESTResponse(this);
417 onStopRequest(channel, statusCode) {
418 if (this.timeoutTimer) {
419 // Clear the abort timer now that the channel is done.
420 this.timeoutTimer.clear();
423 // We don't want to do anything for a request that's already been aborted.
424 if (this.status == this.ABORTED) {
426 "Not proceeding with onStopRequest, request was aborted."
428 // We might not have already rejected if the user called reject() manually.
429 // If we have already rejected, then this is a no-op
430 this._deferred.reject(
431 Components.Exception("Request aborted", Cr.NS_BINDING_ABORTED)
437 channel.QueryInterface(Ci.nsIHttpChannel);
439 this._log.error("Unexpected error: channel not nsIHttpChannel!");
440 this.status = this.ABORTED;
441 this._deferred.reject(ex);
445 this.status = this.COMPLETED;
448 this.response.body = decodeString(
449 this.response._rawBody,
450 this.response.charset
452 this.response._rawBody = null;
455 `Exception decoding response - ${this.method} ${channel.URI.spec}`,
458 this._deferred.reject(ex);
462 let statusSuccess = Components.isSuccessCode(statusCode);
463 let uri = (channel && channel.URI && channel.URI.spec) || "<unknown>";
466 channel.requestMethod +
469 " returned status code " +
473 // Throw the failure code and stop execution. Use Components.Exception()
474 // instead of Error() so the exception is QI-able and can be passed across
475 // XPCOM borders while preserving the status code.
476 if (!statusSuccess) {
477 let message = Components.Exception("", statusCode).name;
478 let error = Components.Exception(message, statusCode);
480 this.method + " " + uri + " failed: " + statusCode + " - " + message
482 // Additionally give the full response body when Trace logging.
483 if (this._log.level <= Log.Level.Trace) {
484 this._log.trace(this.method + " body", this.response.body);
486 this._deferred.reject(error);
490 this._log.debug(this.method + " " + uri + " " + this.response.status);
492 // Note that for privacy/security reasons we don't log this response body
494 delete this._inputStream;
496 this._deferred.resolve(this.response);
499 onDataAvailable(channel, stream, off, count) {
500 // We get an nsIRequest, which doesn't have contentCharset.
502 channel.QueryInterface(Ci.nsIHttpChannel);
504 this._log.error("Unexpected error: channel not nsIHttpChannel!");
509 if (channel.contentCharset) {
510 this.response.charset = channel.contentCharset;
512 this.response.charset = null;
515 if (!this._inputStream) {
516 this._inputStream = Cc[
517 "@mozilla.org/scriptableinputstream;1"
518 ].createInstance(Ci.nsIScriptableInputStream);
520 this._inputStream.init(stream);
522 this.response._rawBody += this._inputStream.read(count);
527 /** nsIInterfaceRequestor **/
530 return this.QueryInterface(aIID);
534 * Returns true if headers from the old channel should be
535 * copied to the new channel. Invoked when a channel redirect
538 shouldCopyOnRedirect(oldChannel, newChannel, flags) {
539 let isInternal = !!(flags & Ci.nsIChannelEventSink.REDIRECT_INTERNAL);
540 let isSameURI = newChannel.URI.equals(oldChannel.URI);
542 "Channel redirect: " +
543 oldChannel.URI.spec +
545 newChannel.URI.spec +
549 return isInternal && isSameURI;
552 /** nsIChannelEventSink **/
553 asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
555 oldChannel && oldChannel.URI ? oldChannel.URI.spec : "<undefined>";
557 newChannel && newChannel.URI ? newChannel.URI.spec : "<undefined>";
559 "Channel redirect: " + oldSpec + ", " + newSpec + ", " + flags
563 newChannel.QueryInterface(Ci.nsIHttpChannel);
565 this._log.error("Unexpected error: channel not nsIHttpChannel!");
566 callback.onRedirectVerifyCallback(Cr.NS_ERROR_NO_INTERFACE);
570 // For internal redirects, copy the headers that our caller set.
572 if (this.shouldCopyOnRedirect(oldChannel, newChannel, flags)) {
573 this._log.trace("Copying headers for safe internal redirect.");
574 for (let key in this._headers) {
575 newChannel.setRequestHeader(key, this._headers[key], false);
579 this._log.error("Error copying headers", ex);
582 this.channel = newChannel;
584 // We let all redirects proceed.
585 callback.onRedirectVerifyCallback(Cr.NS_OK);
590 * Response object for a RESTRequest. This will be created automatically by
593 export function RESTResponse(request = null) {
596 this.request = request;
597 this._log = Log.repository.getLogger(this._logName);
598 this._log.manageLevelFromPref("services.common.log.logger.rest.response");
601 RESTResponse.prototype = {
602 _logName: "Services.Common.RESTResponse",
605 * Corresponding REST request
615 status = this.request.channel.responseStatus;
617 this._log.debug("Caught exception fetching HTTP status code", ex);
620 Object.defineProperty(this, "status", { value: status });
630 statusText = this.request.channel.responseStatusText;
632 this._log.debug("Caught exception fetching HTTP status text", ex);
635 Object.defineProperty(this, "statusText", { value: statusText });
640 * Boolean flag that indicates whether the HTTP status code is 2xx or not.
645 success = this.request.channel.requestSucceeded;
647 this._log.debug("Caught exception fetching HTTP success flag", ex);
650 Object.defineProperty(this, "success", { value: success });
655 * Object containing HTTP headers (keyed as lower case)
660 this._log.trace("Processing response headers.");
661 let channel = this.request.channel.QueryInterface(Ci.nsIHttpChannel);
662 channel.visitResponseHeaders(function (header, value) {
663 headers[header.toLowerCase()] = value;
666 this._log.debug("Caught exception processing response headers", ex);
670 Object.defineProperty(this, "headers", { value: headers });
681 * Single use MAC authenticated HTTP requests to RESTish resources.
684 * URI going to the RESTRequest constructor.
686 * (Object) An auth token of the form {id: (string), key: (string)}
687 * from which the MAC Authentication header for this request will be
688 * derived. A token as obtained from
689 * TokenServerClient.getTokenUsingOAuth is accepted.
691 * (Object) Optional extra parameters. Valid keys are: nonce_bytes, ts,
692 * nonce, and ext. See CrytoUtils.computeHTTPMACSHA1 for information on
693 * the purpose of these values.
695 export function TokenAuthenticatedRESTRequest(uri, authToken, extra) {
696 RESTRequest.call(this, uri);
697 this.authToken = authToken;
698 this.extra = extra || {};
701 TokenAuthenticatedRESTRequest.prototype = {
702 async dispatch(method, data) {
703 let sig = await lazy.CryptoUtils.computeHTTPMACSHA1(
711 this.setHeader("Authorization", sig.getHeader());
713 return super.dispatch(method, data);
717 Object.setPrototypeOf(
718 TokenAuthenticatedRESTRequest.prototype,
719 RESTRequest.prototype