Bug 1852754: part 9) Add tests for dynamically loading <link rel="prefetch"> elements...
[gecko.git] / services / common / rest.sys.mjs
blob4d1e1940579e822727b768e391c6fab15576e992
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";
12 const lazy = {};
14 ChromeUtils.defineESModuleGetters(lazy, {
15   CryptoUtils: "resource://services-crypto/utils.sys.mjs",
16 });
18 function decodeString(data, charset) {
19   if (!data || !charset) {
20     return data;
21   }
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
27   );
28   stringStream.setData(data, data.length);
30   let converterStream = Cc[
31     "@mozilla.org/intl/converter-input-stream;1"
32   ].createInstance(Ci.nsIConverterInputStream);
34   converterStream.init(
35     stringStream,
36     charset,
37     0,
38     converterStream.DEFAULT_REPLACEMENT_CHARACTER
39   );
41   let remaining = data.length;
42   let body = "";
43   while (remaining > 0) {
44     let str = {};
45     let num = converterStream.readString(remaining, str);
46     if (!num) {
47       break;
48     }
49     remaining -= num;
50     body += str.value;
51   }
52   return body;
55 /**
56  * Single use HTTP requests to RESTish resources.
57  *
58  * @param uri
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.
62  *
63  * Examples:
64  *
65  * (1) Quick GET request:
66  *
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);
71  *     return;
72  *   }
73  *   processData(response.body);
74  *
75  * (2) Quick PUT request (non-string data is automatically JSONified)
76  *
77  *   let response = await new RESTRequest("http://server/rest/resource").put(data);
78  */
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);
86   }
87   this.uri = uri;
89   this._headers = {};
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",
101   ]),
103   /** Public API: **/
105   /**
106    * URI for the request (an nsIURI object).
107    */
108   uri: null,
110   /**
111    * HTTP method (e.g. "GET")
112    */
113   method: null,
115   /**
116    * RESTResponse object
117    */
118   response: null,
120   /**
121    * nsIRequest load flags. Don't do any caching by default. Don't send user
122    * cookies and such over the wire (Bug 644734).
123    */
124   loadFlags:
125     Ci.nsIRequest.LOAD_BYPASS_CACHE |
126     Ci.nsIRequest.INHIBIT_CACHING |
127     Ci.nsIRequest.LOAD_ANONYMOUS,
129   /**
130    * nsIHttpChannel
131    */
132   channel: null,
134   /**
135    * Flag to indicate the status of the request.
136    *
137    * One of NOT_SENT, SENT, IN_PROGRESS, COMPLETED, ABORTED.
138    */
139   status: null,
141   NOT_SENT: 0,
142   SENT: 1,
143   IN_PROGRESS: 2,
144   COMPLETED: 4,
145   ABORTED: 8,
147   /**
148    * HTTP status text of response
149    */
150   statusText: null,
152   /**
153    * Request timeout (in seconds, though decimal values can be used for
154    * up to millisecond granularity.)
155    *
156    * 0 for no timeout. Default is 300 seconds (5 minutes), the same as Sync uses
157    * in resource.js.
158    */
159   timeout: 300,
161   /**
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.
166    */
167   charset: "utf-8",
169   /**
170    * Set a request header.
171    */
172   setHeader(name, value) {
173     this._headers[name.toLowerCase()] = value;
174   },
176   /**
177    * Perform an HTTP GET.
178    *
179    * @return Promise<RESTResponse>
180    */
181   async get() {
182     return this.dispatch("GET", null);
183   },
185   /**
186    * Perform an HTTP PATCH.
187    *
188    * @param data
189    *        Data to be used as the request body. If this isn't a string
190    *        it will be JSONified automatically.
191    *
192    * @return Promise<RESTResponse>
193    */
194   async patch(data) {
195     return this.dispatch("PATCH", data);
196   },
198   /**
199    * Perform an HTTP PUT.
200    *
201    * @param data
202    *        Data to be used as the request body. If this isn't a string
203    *        it will be JSONified automatically.
204    *
205    * @return Promise<RESTResponse>
206    */
207   async put(data) {
208     return this.dispatch("PUT", data);
209   },
211   /**
212    * Perform an HTTP POST.
213    *
214    * @param data
215    *        Data to be used as the request body. If this isn't a string
216    *        it will be JSONified automatically.
217    *
218    * @return Promise<RESTResponse>
219    */
220   async post(data) {
221     return this.dispatch("POST", data);
222   },
224   /**
225    * Perform an HTTP DELETE.
226    *
227    * @return Promise<RESTResponse>
228    */
229   async delete() {
230     return this.dispatch("DELETE", null);
231   },
233   /**
234    * Abort an active request.
235    */
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.");
239     }
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();
247     }
248     if (rejectWithError) {
249       this._deferred.reject(rejectWithError);
250     }
251   },
253   /** Implementation stuff **/
255   async dispatch(method, data) {
256     if (this.status != this.NOT_SENT) {
257       throw new Error("Request has already been sent!");
258     }
260     this.method = method;
262     // Create and initialize HTTP channel.
263     let channel = NetUtil.newChannel({
264       uri: this.uri,
265       loadUsingSystemPrincipal: true,
266     })
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)");
279       } else {
280         this._log.trace("HTTP Header " + key + ": " + headers[key]);
281       }
282       channel.setRequestHeader(key, headers[key], false);
283     }
285     // REST requests accept JSON by default
286     if (!headers.accept) {
287       channel.setRequestHeader(
288         "accept",
289         "application/json;q=0.9,*/*;q=0.2",
290         false
291       );
292     }
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);
301         if (!contentType) {
302           contentType = "application/json";
303         }
304         if (!contentType.includes("charset")) {
305           data = CommonUtils.encodeUTF8(data);
306           contentType += "; charset=utf-8";
307         } else {
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.
311           console.error(
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"
315           );
316         }
317       }
318       if (!contentType) {
319         contentType = "text/plain";
320       }
322       this._log.debug(method + " Length: " + data.length);
323       if (this._log.level <= Log.Level.Trace) {
324         this._log.trace(method + " Body: " + data);
325       }
327       let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
328         Ci.nsIStringInputStream
329       );
330       stream.setData(data, data.length);
332       channel.QueryInterface(Ci.nsIUploadChannel);
333       channel.setUploadStream(stream, contentType, data.length);
334     }
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;
343     // Blast off!
344     try {
345       channel.asyncOpen(this);
346     } catch (ex) {
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);
350     }
351     this.status = this.SENT;
352     this.delayTimeout();
353     return this._deferred.promise;
354   },
356   /**
357    * Create or push back the abort timer that kills this request.
358    */
359   delayTimeout() {
360     if (this.timeout) {
361       CommonUtils.namedTimer(
362         this.abortTimeout,
363         this.timeout * 1000,
364         this,
365         "timeoutTimer"
366       );
367     }
368   },
370   /**
371    * Abort the request based on a timeout.
372    */
373   abortTimeout() {
374     this.abort(
375       Components.Exception(
376         "Aborting due to channel inactivity.",
377         Cr.NS_ERROR_NET_TIMEOUT
378       )
379     );
380   },
382   /** nsIStreamListener **/
384   onStartRequest(channel) {
385     if (this.status == this.ABORTED) {
386       this._log.trace(
387         "Not proceeding with onStartRequest, request was aborted."
388       );
389       // We might have already rejected, but just in case.
390       this._deferred.reject(
391         Components.Exception("Request aborted", Cr.NS_BINDING_ABORTED)
392       );
393       return;
394     }
396     try {
397       channel.QueryInterface(Ci.nsIHttpChannel);
398     } catch (ex) {
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);
403       return;
404     }
406     this.status = this.IN_PROGRESS;
408     this._log.trace(
409       "onStartRequest: " + channel.requestMethod + " " + channel.URI.spec
410     );
412     // Create a new response object.
413     this.response = new RESTResponse(this);
415     this.delayTimeout();
416   },
418   onStopRequest(channel, statusCode) {
419     if (this.timeoutTimer) {
420       // Clear the abort timer now that the channel is done.
421       this.timeoutTimer.clear();
422     }
424     // We don't want to do anything for a request that's already been aborted.
425     if (this.status == this.ABORTED) {
426       this._log.trace(
427         "Not proceeding with onStopRequest, request was aborted."
428       );
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)
433       );
434       return;
435     }
437     try {
438       channel.QueryInterface(Ci.nsIHttpChannel);
439     } catch (ex) {
440       this._log.error("Unexpected error: channel not nsIHttpChannel!");
441       this.status = this.ABORTED;
442       this._deferred.reject(ex);
443       return;
444     }
446     this.status = this.COMPLETED;
448     try {
449       this.response.body = decodeString(
450         this.response._rawBody,
451         this.response.charset
452       );
453       this.response._rawBody = null;
454     } catch (ex) {
455       this._log.warn(
456         `Exception decoding response - ${this.method} ${channel.URI.spec}`,
457         ex
458       );
459       this._deferred.reject(ex);
460       return;
461     }
463     let statusSuccess = Components.isSuccessCode(statusCode);
464     let uri = (channel && channel.URI && channel.URI.spec) || "<unknown>";
465     this._log.trace(
466       "Channel for " +
467         channel.requestMethod +
468         " " +
469         uri +
470         " returned status code " +
471         statusCode
472     );
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);
480       this._log.debug(
481         this.method + " " + uri + " failed: " + statusCode + " - " + message
482       );
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);
486       }
487       this._deferred.reject(error);
488       return;
489     }
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);
498   },
500   onDataAvailable(channel, stream, off, count) {
501     // We get an nsIRequest, which doesn't have contentCharset.
502     try {
503       channel.QueryInterface(Ci.nsIHttpChannel);
504     } catch (ex) {
505       this._log.error("Unexpected error: channel not nsIHttpChannel!");
506       this.abort(ex);
507       return;
508     }
510     if (channel.contentCharset) {
511       this.response.charset = channel.contentCharset;
512     } else {
513       this.response.charset = null;
514     }
516     if (!this._inputStream) {
517       this._inputStream = Cc[
518         "@mozilla.org/scriptableinputstream;1"
519       ].createInstance(Ci.nsIScriptableInputStream);
520     }
521     this._inputStream.init(stream);
523     this.response._rawBody += this._inputStream.read(count);
525     this.delayTimeout();
526   },
528   /** nsIInterfaceRequestor **/
530   getInterface(aIID) {
531     return this.QueryInterface(aIID);
532   },
534   /**
535    * Returns true if headers from the old channel should be
536    * copied to the new channel. Invoked when a channel redirect
537    * is in progress.
538    */
539   shouldCopyOnRedirect(oldChannel, newChannel, flags) {
540     let isInternal = !!(flags & Ci.nsIChannelEventSink.REDIRECT_INTERNAL);
541     let isSameURI = newChannel.URI.equals(oldChannel.URI);
542     this._log.debug(
543       "Channel redirect: " +
544         oldChannel.URI.spec +
545         ", " +
546         newChannel.URI.spec +
547         ", internal = " +
548         isInternal
549     );
550     return isInternal && isSameURI;
551   },
553   /** nsIChannelEventSink **/
554   asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
555     let oldSpec =
556       oldChannel && oldChannel.URI ? oldChannel.URI.spec : "<undefined>";
557     let newSpec =
558       newChannel && newChannel.URI ? newChannel.URI.spec : "<undefined>";
559     this._log.debug(
560       "Channel redirect: " + oldSpec + ", " + newSpec + ", " + flags
561     );
563     try {
564       newChannel.QueryInterface(Ci.nsIHttpChannel);
565     } catch (ex) {
566       this._log.error("Unexpected error: channel not nsIHttpChannel!");
567       callback.onRedirectVerifyCallback(Cr.NS_ERROR_NO_INTERFACE);
568       return;
569     }
571     // For internal redirects, copy the headers that our caller set.
572     try {
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);
577         }
578       }
579     } catch (ex) {
580       this._log.error("Error copying headers", ex);
581     }
583     this.channel = newChannel;
585     // We let all redirects proceed.
586     callback.onRedirectVerifyCallback(Cr.NS_OK);
587   },
591  * Response object for a RESTRequest. This will be created automatically by
592  * the RESTRequest.
593  */
594 export function RESTResponse(request = null) {
595   this.body = "";
596   this._rawBody = "";
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",
605   /**
606    * Corresponding REST request
607    */
608   request: null,
610   /**
611    * HTTP status code
612    */
613   get status() {
614     let status;
615     try {
616       status = this.request.channel.responseStatus;
617     } catch (ex) {
618       this._log.debug("Caught exception fetching HTTP status code", ex);
619       return null;
620     }
621     Object.defineProperty(this, "status", { value: status });
622     return status;
623   },
625   /**
626    * HTTP status text
627    */
628   get statusText() {
629     let statusText;
630     try {
631       statusText = this.request.channel.responseStatusText;
632     } catch (ex) {
633       this._log.debug("Caught exception fetching HTTP status text", ex);
634       return null;
635     }
636     Object.defineProperty(this, "statusText", { value: statusText });
637     return statusText;
638   },
640   /**
641    * Boolean flag that indicates whether the HTTP status code is 2xx or not.
642    */
643   get success() {
644     let success;
645     try {
646       success = this.request.channel.requestSucceeded;
647     } catch (ex) {
648       this._log.debug("Caught exception fetching HTTP success flag", ex);
649       return null;
650     }
651     Object.defineProperty(this, "success", { value: success });
652     return success;
653   },
655   /**
656    * Object containing HTTP headers (keyed as lower case)
657    */
658   get headers() {
659     let headers = {};
660     try {
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;
665       });
666     } catch (ex) {
667       this._log.debug("Caught exception processing response headers", ex);
668       return null;
669     }
671     Object.defineProperty(this, "headers", { value: headers });
672     return headers;
673   },
675   /**
676    * HTTP body (string)
677    */
678   body: null,
682  * Single use MAC authenticated HTTP requests to RESTish resources.
684  * @param uri
685  *        URI going to the RESTRequest constructor.
686  * @param authToken
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.
691  * @param extra
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.
695  */
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(
705       this.authToken.id,
706       this.authToken.key,
707       method,
708       this.uri,
709       this.extra
710     );
712     this.setHeader("Authorization", sig.getHeader());
714     return super.dispatch(method, data);
715   },
718 Object.setPrototypeOf(
719   TokenAuthenticatedRESTRequest.prototype,
720   RESTRequest.prototype