Bug 1845134 - Part 4: Update existing ui-icons to use the latest source from acorn...
[gecko.git] / services / common / rest.sys.mjs
blobed32047bcd6c65da35ab2f543586adf6b1d8e2be
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";
11 const lazy = {};
13 ChromeUtils.defineESModuleGetters(lazy, {
14   CryptoUtils: "resource://services-crypto/utils.sys.mjs",
15 });
17 function decodeString(data, charset) {
18   if (!data || !charset) {
19     return data;
20   }
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
26   );
27   stringStream.setData(data, data.length);
29   let converterStream = Cc[
30     "@mozilla.org/intl/converter-input-stream;1"
31   ].createInstance(Ci.nsIConverterInputStream);
33   converterStream.init(
34     stringStream,
35     charset,
36     0,
37     converterStream.DEFAULT_REPLACEMENT_CHARACTER
38   );
40   let remaining = data.length;
41   let body = "";
42   while (remaining > 0) {
43     let str = {};
44     let num = converterStream.readString(remaining, str);
45     if (!num) {
46       break;
47     }
48     remaining -= num;
49     body += str.value;
50   }
51   return body;
54 /**
55  * Single use HTTP requests to RESTish resources.
56  *
57  * @param uri
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.
61  *
62  * Examples:
63  *
64  * (1) Quick GET request:
65  *
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);
70  *     return;
71  *   }
72  *   processData(response.body);
73  *
74  * (2) Quick PUT request (non-string data is automatically JSONified)
75  *
76  *   let response = await new RESTRequest("http://server/rest/resource").put(data);
77  */
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);
85   }
86   this.uri = uri;
88   this._headers = {};
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",
100   ]),
102   /** Public API: **/
104   /**
105    * URI for the request (an nsIURI object).
106    */
107   uri: null,
109   /**
110    * HTTP method (e.g. "GET")
111    */
112   method: null,
114   /**
115    * RESTResponse object
116    */
117   response: null,
119   /**
120    * nsIRequest load flags. Don't do any caching by default. Don't send user
121    * cookies and such over the wire (Bug 644734).
122    */
123   loadFlags:
124     Ci.nsIRequest.LOAD_BYPASS_CACHE |
125     Ci.nsIRequest.INHIBIT_CACHING |
126     Ci.nsIRequest.LOAD_ANONYMOUS,
128   /**
129    * nsIHttpChannel
130    */
131   channel: null,
133   /**
134    * Flag to indicate the status of the request.
135    *
136    * One of NOT_SENT, SENT, IN_PROGRESS, COMPLETED, ABORTED.
137    */
138   status: null,
140   NOT_SENT: 0,
141   SENT: 1,
142   IN_PROGRESS: 2,
143   COMPLETED: 4,
144   ABORTED: 8,
146   /**
147    * HTTP status text of response
148    */
149   statusText: null,
151   /**
152    * Request timeout (in seconds, though decimal values can be used for
153    * up to millisecond granularity.)
154    *
155    * 0 for no timeout. Default is 300 seconds (5 minutes), the same as Sync uses
156    * in resource.js.
157    */
158   timeout: 300,
160   /**
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.
165    */
166   charset: "utf-8",
168   /**
169    * Set a request header.
170    */
171   setHeader(name, value) {
172     this._headers[name.toLowerCase()] = value;
173   },
175   /**
176    * Perform an HTTP GET.
177    *
178    * @return Promise<RESTResponse>
179    */
180   async get() {
181     return this.dispatch("GET", null);
182   },
184   /**
185    * Perform an HTTP PATCH.
186    *
187    * @param data
188    *        Data to be used as the request body. If this isn't a string
189    *        it will be JSONified automatically.
190    *
191    * @return Promise<RESTResponse>
192    */
193   async patch(data) {
194     return this.dispatch("PATCH", data);
195   },
197   /**
198    * Perform an HTTP PUT.
199    *
200    * @param data
201    *        Data to be used as the request body. If this isn't a string
202    *        it will be JSONified automatically.
203    *
204    * @return Promise<RESTResponse>
205    */
206   async put(data) {
207     return this.dispatch("PUT", data);
208   },
210   /**
211    * Perform an HTTP POST.
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    *
217    * @return Promise<RESTResponse>
218    */
219   async post(data) {
220     return this.dispatch("POST", data);
221   },
223   /**
224    * Perform an HTTP DELETE.
225    *
226    * @return Promise<RESTResponse>
227    */
228   async delete() {
229     return this.dispatch("DELETE", null);
230   },
232   /**
233    * Abort an active request.
234    */
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.");
238     }
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();
246     }
247     if (rejectWithError) {
248       this._deferred.reject(rejectWithError);
249     }
250   },
252   /** Implementation stuff **/
254   async dispatch(method, data) {
255     if (this.status != this.NOT_SENT) {
256       throw new Error("Request has already been sent!");
257     }
259     this.method = method;
261     // Create and initialize HTTP channel.
262     let channel = NetUtil.newChannel({
263       uri: this.uri,
264       loadUsingSystemPrincipal: true,
265     })
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)");
278       } else {
279         this._log.trace("HTTP Header " + key + ": " + headers[key]);
280       }
281       channel.setRequestHeader(key, headers[key], false);
282     }
284     // REST requests accept JSON by default
285     if (!headers.accept) {
286       channel.setRequestHeader(
287         "accept",
288         "application/json;q=0.9,*/*;q=0.2",
289         false
290       );
291     }
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);
300         if (!contentType) {
301           contentType = "application/json";
302         }
303         if (!contentType.includes("charset")) {
304           data = CommonUtils.encodeUTF8(data);
305           contentType += "; charset=utf-8";
306         } else {
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.
310           console.error(
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"
314           );
315         }
316       }
317       if (!contentType) {
318         contentType = "text/plain";
319       }
321       this._log.debug(method + " Length: " + data.length);
322       if (this._log.level <= Log.Level.Trace) {
323         this._log.trace(method + " Body: " + data);
324       }
326       let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
327         Ci.nsIStringInputStream
328       );
329       stream.setData(data, data.length);
331       channel.QueryInterface(Ci.nsIUploadChannel);
332       channel.setUploadStream(stream, contentType, data.length);
333     }
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;
342     // Blast off!
343     try {
344       channel.asyncOpen(this);
345     } catch (ex) {
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);
349     }
350     this.status = this.SENT;
351     this.delayTimeout();
352     return this._deferred.promise;
353   },
355   /**
356    * Create or push back the abort timer that kills this request.
357    */
358   delayTimeout() {
359     if (this.timeout) {
360       CommonUtils.namedTimer(
361         this.abortTimeout,
362         this.timeout * 1000,
363         this,
364         "timeoutTimer"
365       );
366     }
367   },
369   /**
370    * Abort the request based on a timeout.
371    */
372   abortTimeout() {
373     this.abort(
374       Components.Exception(
375         "Aborting due to channel inactivity.",
376         Cr.NS_ERROR_NET_TIMEOUT
377       )
378     );
379   },
381   /** nsIStreamListener **/
383   onStartRequest(channel) {
384     if (this.status == this.ABORTED) {
385       this._log.trace(
386         "Not proceeding with onStartRequest, request was aborted."
387       );
388       // We might have already rejected, but just in case.
389       this._deferred.reject(
390         Components.Exception("Request aborted", Cr.NS_BINDING_ABORTED)
391       );
392       return;
393     }
395     try {
396       channel.QueryInterface(Ci.nsIHttpChannel);
397     } catch (ex) {
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);
402       return;
403     }
405     this.status = this.IN_PROGRESS;
407     this._log.trace(
408       "onStartRequest: " + channel.requestMethod + " " + channel.URI.spec
409     );
411     // Create a new response object.
412     this.response = new RESTResponse(this);
414     this.delayTimeout();
415   },
417   onStopRequest(channel, statusCode) {
418     if (this.timeoutTimer) {
419       // Clear the abort timer now that the channel is done.
420       this.timeoutTimer.clear();
421     }
423     // We don't want to do anything for a request that's already been aborted.
424     if (this.status == this.ABORTED) {
425       this._log.trace(
426         "Not proceeding with onStopRequest, request was aborted."
427       );
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)
432       );
433       return;
434     }
436     try {
437       channel.QueryInterface(Ci.nsIHttpChannel);
438     } catch (ex) {
439       this._log.error("Unexpected error: channel not nsIHttpChannel!");
440       this.status = this.ABORTED;
441       this._deferred.reject(ex);
442       return;
443     }
445     this.status = this.COMPLETED;
447     try {
448       this.response.body = decodeString(
449         this.response._rawBody,
450         this.response.charset
451       );
452       this.response._rawBody = null;
453     } catch (ex) {
454       this._log.warn(
455         `Exception decoding response - ${this.method} ${channel.URI.spec}`,
456         ex
457       );
458       this._deferred.reject(ex);
459       return;
460     }
462     let statusSuccess = Components.isSuccessCode(statusCode);
463     let uri = (channel && channel.URI && channel.URI.spec) || "<unknown>";
464     this._log.trace(
465       "Channel for " +
466         channel.requestMethod +
467         " " +
468         uri +
469         " returned status code " +
470         statusCode
471     );
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);
479       this._log.debug(
480         this.method + " " + uri + " failed: " + statusCode + " - " + message
481       );
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);
485       }
486       this._deferred.reject(error);
487       return;
488     }
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);
497   },
499   onDataAvailable(channel, stream, off, count) {
500     // We get an nsIRequest, which doesn't have contentCharset.
501     try {
502       channel.QueryInterface(Ci.nsIHttpChannel);
503     } catch (ex) {
504       this._log.error("Unexpected error: channel not nsIHttpChannel!");
505       this.abort(ex);
506       return;
507     }
509     if (channel.contentCharset) {
510       this.response.charset = channel.contentCharset;
511     } else {
512       this.response.charset = null;
513     }
515     if (!this._inputStream) {
516       this._inputStream = Cc[
517         "@mozilla.org/scriptableinputstream;1"
518       ].createInstance(Ci.nsIScriptableInputStream);
519     }
520     this._inputStream.init(stream);
522     this.response._rawBody += this._inputStream.read(count);
524     this.delayTimeout();
525   },
527   /** nsIInterfaceRequestor **/
529   getInterface(aIID) {
530     return this.QueryInterface(aIID);
531   },
533   /**
534    * Returns true if headers from the old channel should be
535    * copied to the new channel. Invoked when a channel redirect
536    * is in progress.
537    */
538   shouldCopyOnRedirect(oldChannel, newChannel, flags) {
539     let isInternal = !!(flags & Ci.nsIChannelEventSink.REDIRECT_INTERNAL);
540     let isSameURI = newChannel.URI.equals(oldChannel.URI);
541     this._log.debug(
542       "Channel redirect: " +
543         oldChannel.URI.spec +
544         ", " +
545         newChannel.URI.spec +
546         ", internal = " +
547         isInternal
548     );
549     return isInternal && isSameURI;
550   },
552   /** nsIChannelEventSink **/
553   asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
554     let oldSpec =
555       oldChannel && oldChannel.URI ? oldChannel.URI.spec : "<undefined>";
556     let newSpec =
557       newChannel && newChannel.URI ? newChannel.URI.spec : "<undefined>";
558     this._log.debug(
559       "Channel redirect: " + oldSpec + ", " + newSpec + ", " + flags
560     );
562     try {
563       newChannel.QueryInterface(Ci.nsIHttpChannel);
564     } catch (ex) {
565       this._log.error("Unexpected error: channel not nsIHttpChannel!");
566       callback.onRedirectVerifyCallback(Cr.NS_ERROR_NO_INTERFACE);
567       return;
568     }
570     // For internal redirects, copy the headers that our caller set.
571     try {
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);
576         }
577       }
578     } catch (ex) {
579       this._log.error("Error copying headers", ex);
580     }
582     this.channel = newChannel;
584     // We let all redirects proceed.
585     callback.onRedirectVerifyCallback(Cr.NS_OK);
586   },
590  * Response object for a RESTRequest. This will be created automatically by
591  * the RESTRequest.
592  */
593 export function RESTResponse(request = null) {
594   this.body = "";
595   this._rawBody = "";
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",
604   /**
605    * Corresponding REST request
606    */
607   request: null,
609   /**
610    * HTTP status code
611    */
612   get status() {
613     let status;
614     try {
615       status = this.request.channel.responseStatus;
616     } catch (ex) {
617       this._log.debug("Caught exception fetching HTTP status code", ex);
618       return null;
619     }
620     Object.defineProperty(this, "status", { value: status });
621     return status;
622   },
624   /**
625    * HTTP status text
626    */
627   get statusText() {
628     let statusText;
629     try {
630       statusText = this.request.channel.responseStatusText;
631     } catch (ex) {
632       this._log.debug("Caught exception fetching HTTP status text", ex);
633       return null;
634     }
635     Object.defineProperty(this, "statusText", { value: statusText });
636     return statusText;
637   },
639   /**
640    * Boolean flag that indicates whether the HTTP status code is 2xx or not.
641    */
642   get success() {
643     let success;
644     try {
645       success = this.request.channel.requestSucceeded;
646     } catch (ex) {
647       this._log.debug("Caught exception fetching HTTP success flag", ex);
648       return null;
649     }
650     Object.defineProperty(this, "success", { value: success });
651     return success;
652   },
654   /**
655    * Object containing HTTP headers (keyed as lower case)
656    */
657   get headers() {
658     let headers = {};
659     try {
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;
664       });
665     } catch (ex) {
666       this._log.debug("Caught exception processing response headers", ex);
667       return null;
668     }
670     Object.defineProperty(this, "headers", { value: headers });
671     return headers;
672   },
674   /**
675    * HTTP body (string)
676    */
677   body: null,
681  * Single use MAC authenticated HTTP requests to RESTish resources.
683  * @param uri
684  *        URI going to the RESTRequest constructor.
685  * @param authToken
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.
690  * @param extra
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.
694  */
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(
704       this.authToken.id,
705       this.authToken.key,
706       method,
707       this.uri,
708       this.extra
709     );
711     this.setHeader("Authorization", sig.getHeader());
713     return super.dispatch(method, data);
714   },
717 Object.setPrototypeOf(
718   TokenAuthenticatedRESTRequest.prototype,
719   RESTRequest.prototype