Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
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 #ifndef MERGED_COMPARTMENT
7 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
9 this.EXPORTED_SYMBOLS = [
10 "RESTRequest",
11 "RESTResponse",
12 "TokenAuthenticatedRESTRequest",
13 ];
15 #endif
17 Cu.import("resource://gre/modules/Preferences.jsm");
18 Cu.import("resource://gre/modules/Services.jsm");
19 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
20 Cu.import("resource://gre/modules/Log.jsm");
21 Cu.import("resource://services-common/utils.js");
23 XPCOMUtils.defineLazyModuleGetter(this, "CryptoUtils",
24 "resource://services-crypto/utils.js");
26 const Prefs = new Preferences("services.common.rest.");
28 /**
29 * Single use HTTP requests to RESTish resources.
30 *
31 * @param uri
32 * URI for the request. This can be an nsIURI object or a string
33 * that can be used to create one. An exception will be thrown if
34 * the string is not a valid URI.
35 *
36 * Examples:
37 *
38 * (1) Quick GET request:
39 *
40 * new RESTRequest("http://server/rest/resource").get(function (error) {
41 * if (error) {
42 * // Deal with a network error.
43 * processNetworkErrorCode(error.result);
44 * return;
45 * }
46 * if (!this.response.success) {
47 * // Bail out if we're not getting an HTTP 2xx code.
48 * processHTTPError(this.response.status);
49 * return;
50 * }
51 * processData(this.response.body);
52 * });
53 *
54 * (2) Quick PUT request (non-string data is automatically JSONified)
55 *
56 * new RESTRequest("http://server/rest/resource").put(data, function (error) {
57 * ...
58 * });
59 *
60 * (3) Streaming GET
61 *
62 * let request = new RESTRequest("http://server/rest/resource");
63 * request.setHeader("Accept", "application/newlines");
64 * request.onComplete = function (error) {
65 * if (error) {
66 * // Deal with a network error.
67 * processNetworkErrorCode(error.result);
68 * return;
69 * }
70 * callbackAfterRequestHasCompleted()
71 * });
72 * request.onProgress = function () {
73 * if (!this.response.success) {
74 * // Bail out if we're not getting an HTTP 2xx code.
75 * return;
76 * }
77 * // Process body data and reset it so we don't process the same data twice.
78 * processIncrementalData(this.response.body);
79 * this.response.body = "";
80 * });
81 * request.get();
82 */
83 this.RESTRequest = function RESTRequest(uri) {
84 this.status = this.NOT_SENT;
86 // If we don't have an nsIURI object yet, make one. This will throw if
87 // 'uri' isn't a valid URI string.
88 if (!(uri instanceof Ci.nsIURI)) {
89 uri = Services.io.newURI(uri, null, null);
90 }
91 this.uri = uri;
93 this._headers = {};
94 this._log = Log.repository.getLogger(this._logName);
95 this._log.level =
96 Log.Level[Prefs.get("log.logger.rest.request")];
97 }
98 RESTRequest.prototype = {
100 _logName: "Services.Common.RESTRequest",
102 QueryInterface: XPCOMUtils.generateQI([
103 Ci.nsIBadCertListener2,
104 Ci.nsIInterfaceRequestor,
105 Ci.nsIChannelEventSink
106 ]),
108 /*** Public API: ***/
110 /**
111 * URI for the request (an nsIURI object).
112 */
113 uri: null,
115 /**
116 * HTTP method (e.g. "GET")
117 */
118 method: null,
120 /**
121 * RESTResponse object
122 */
123 response: null,
125 /**
126 * nsIRequest load flags. Don't do any caching by default. Don't send user
127 * cookies and such over the wire (Bug 644734).
128 */
129 loadFlags: Ci.nsIRequest.LOAD_BYPASS_CACHE | Ci.nsIRequest.INHIBIT_CACHING | Ci.nsIRequest.LOAD_ANONYMOUS,
131 /**
132 * nsIHttpChannel
133 */
134 channel: null,
136 /**
137 * Flag to indicate the status of the request.
138 *
139 * One of NOT_SENT, SENT, IN_PROGRESS, COMPLETED, ABORTED.
140 */
141 status: null,
143 NOT_SENT: 0,
144 SENT: 1,
145 IN_PROGRESS: 2,
146 COMPLETED: 4,
147 ABORTED: 8,
149 /**
150 * HTTP status text of response
151 */
152 statusText: null,
154 /**
155 * Request timeout (in seconds, though decimal values can be used for
156 * up to millisecond granularity.)
157 *
158 * 0 for no timeout.
159 */
160 timeout: null,
162 /**
163 * The encoding with which the response to this request must be treated.
164 * If a charset parameter is available in the HTTP Content-Type header for
165 * this response, that will always be used, and this value is ignored. We
166 * default to UTF-8 because that is a reasonable default.
167 */
168 charset: "utf-8",
170 /**
171 * Called when the request has been completed, including failures and
172 * timeouts.
173 *
174 * @param error
175 * Error that occurred while making the request, null if there
176 * was no error.
177 */
178 onComplete: function onComplete(error) {
179 },
181 /**
182 * Called whenever data is being received on the channel. If this throws an
183 * exception, the request is aborted and the exception is passed as the
184 * error to onComplete().
185 */
186 onProgress: function onProgress() {
187 },
189 /**
190 * Set a request header.
191 */
192 setHeader: function setHeader(name, value) {
193 this._headers[name.toLowerCase()] = value;
194 },
196 /**
197 * Perform an HTTP GET.
198 *
199 * @param onComplete
200 * Short-circuit way to set the 'onComplete' method. Optional.
201 * @param onProgress
202 * Short-circuit way to set the 'onProgress' method. Optional.
203 *
204 * @return the request object.
205 */
206 get: function get(onComplete, onProgress) {
207 return this.dispatch("GET", null, onComplete, onProgress);
208 },
210 /**
211 * Perform an HTTP PUT.
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 * @param onComplete
217 * Short-circuit way to set the 'onComplete' method. Optional.
218 * @param onProgress
219 * Short-circuit way to set the 'onProgress' method. Optional.
220 *
221 * @return the request object.
222 */
223 put: function put(data, onComplete, onProgress) {
224 return this.dispatch("PUT", data, onComplete, onProgress);
225 },
227 /**
228 * Perform an HTTP POST.
229 *
230 * @param data
231 * Data to be used as the request body. If this isn't a string
232 * it will be JSONified automatically.
233 * @param onComplete
234 * Short-circuit way to set the 'onComplete' method. Optional.
235 * @param onProgress
236 * Short-circuit way to set the 'onProgress' method. Optional.
237 *
238 * @return the request object.
239 */
240 post: function post(data, onComplete, onProgress) {
241 return this.dispatch("POST", data, onComplete, onProgress);
242 },
244 /**
245 * Perform an HTTP DELETE.
246 *
247 * @param onComplete
248 * Short-circuit way to set the 'onComplete' method. Optional.
249 * @param onProgress
250 * Short-circuit way to set the 'onProgress' method. Optional.
251 *
252 * @return the request object.
253 */
254 delete: function delete_(onComplete, onProgress) {
255 return this.dispatch("DELETE", null, onComplete, onProgress);
256 },
258 /**
259 * Abort an active request.
260 */
261 abort: function abort() {
262 if (this.status != this.SENT && this.status != this.IN_PROGRESS) {
263 throw "Can only abort a request that has been sent.";
264 }
266 this.status = this.ABORTED;
267 this.channel.cancel(Cr.NS_BINDING_ABORTED);
269 if (this.timeoutTimer) {
270 // Clear the abort timer now that the channel is done.
271 this.timeoutTimer.clear();
272 }
273 },
275 /*** Implementation stuff ***/
277 dispatch: function dispatch(method, data, onComplete, onProgress) {
278 if (this.status != this.NOT_SENT) {
279 throw "Request has already been sent!";
280 }
282 this.method = method;
283 if (onComplete) {
284 this.onComplete = onComplete;
285 }
286 if (onProgress) {
287 this.onProgress = onProgress;
288 }
290 // Create and initialize HTTP channel.
291 let channel = Services.io.newChannelFromURI(this.uri, null, null)
292 .QueryInterface(Ci.nsIRequest)
293 .QueryInterface(Ci.nsIHttpChannel);
294 this.channel = channel;
295 channel.loadFlags |= this.loadFlags;
296 channel.notificationCallbacks = this;
298 // Set request headers.
299 let headers = this._headers;
300 for (let key in headers) {
301 if (key == 'authorization') {
302 this._log.trace("HTTP Header " + key + ": ***** (suppressed)");
303 } else {
304 this._log.trace("HTTP Header " + key + ": " + headers[key]);
305 }
306 channel.setRequestHeader(key, headers[key], false);
307 }
309 // Set HTTP request body.
310 if (method == "PUT" || method == "POST") {
311 // Convert non-string bodies into JSON.
312 if (typeof data != "string") {
313 data = JSON.stringify(data);
314 }
316 this._log.debug(method + " Length: " + data.length);
317 if (this._log.level <= Log.Level.Trace) {
318 this._log.trace(method + " Body: " + data);
319 }
321 let stream = Cc["@mozilla.org/io/string-input-stream;1"]
322 .createInstance(Ci.nsIStringInputStream);
323 stream.setData(data, data.length);
325 let type = headers["content-type"] || "text/plain";
326 channel.QueryInterface(Ci.nsIUploadChannel);
327 channel.setUploadStream(stream, type, data.length);
328 }
329 // We must set this after setting the upload stream, otherwise it
330 // will always be 'PUT'. Yeah, I know.
331 channel.requestMethod = method;
333 // Before opening the channel, set the charset that serves as a hint
334 // as to what the response might be encoded as.
335 channel.contentCharset = this.charset;
337 // Blast off!
338 try {
339 channel.asyncOpen(this, null);
340 } catch (ex) {
341 // asyncOpen can throw in a bunch of cases -- e.g., a forbidden port.
342 this._log.warn("Caught an error in asyncOpen: " + CommonUtils.exceptionStr(ex));
343 CommonUtils.nextTick(onComplete.bind(this, ex));
344 }
345 this.status = this.SENT;
346 this.delayTimeout();
347 return this;
348 },
350 /**
351 * Create or push back the abort timer that kills this request.
352 */
353 delayTimeout: function delayTimeout() {
354 if (this.timeout) {
355 CommonUtils.namedTimer(this.abortTimeout, this.timeout * 1000, this,
356 "timeoutTimer");
357 }
358 },
360 /**
361 * Abort the request based on a timeout.
362 */
363 abortTimeout: function abortTimeout() {
364 this.abort();
365 let error = Components.Exception("Aborting due to channel inactivity.",
366 Cr.NS_ERROR_NET_TIMEOUT);
367 if (!this.onComplete) {
368 this._log.error("Unexpected error: onComplete not defined in " +
369 "abortTimeout.")
370 return;
371 }
372 this.onComplete(error);
373 },
375 /*** nsIStreamListener ***/
377 onStartRequest: function onStartRequest(channel) {
378 if (this.status == this.ABORTED) {
379 this._log.trace("Not proceeding with onStartRequest, request was aborted.");
380 return;
381 }
383 try {
384 channel.QueryInterface(Ci.nsIHttpChannel);
385 } catch (ex) {
386 this._log.error("Unexpected error: channel is not a nsIHttpChannel!");
387 this.status = this.ABORTED;
388 channel.cancel(Cr.NS_BINDING_ABORTED);
389 return;
390 }
392 this.status = this.IN_PROGRESS;
394 this._log.trace("onStartRequest: " + channel.requestMethod + " " +
395 channel.URI.spec);
397 // Create a response object and fill it with some data.
398 let response = this.response = new RESTResponse();
399 response.request = this;
400 response.body = "";
402 this.delayTimeout();
403 },
405 onStopRequest: function onStopRequest(channel, context, statusCode) {
406 if (this.timeoutTimer) {
407 // Clear the abort timer now that the channel is done.
408 this.timeoutTimer.clear();
409 }
411 // We don't want to do anything for a request that's already been aborted.
412 if (this.status == this.ABORTED) {
413 this._log.trace("Not proceeding with onStopRequest, request was aborted.");
414 return;
415 }
417 try {
418 channel.QueryInterface(Ci.nsIHttpChannel);
419 } catch (ex) {
420 this._log.error("Unexpected error: channel not nsIHttpChannel!");
421 this.status = this.ABORTED;
422 return;
423 }
424 this.status = this.COMPLETED;
426 let statusSuccess = Components.isSuccessCode(statusCode);
427 let uri = channel && channel.URI && channel.URI.spec || "<unknown>";
428 this._log.trace("Channel for " + channel.requestMethod + " " + uri +
429 " returned status code " + statusCode);
431 if (!this.onComplete) {
432 this._log.error("Unexpected error: onComplete not defined in " +
433 "abortRequest.");
434 this.onProgress = null;
435 return;
436 }
438 // Throw the failure code and stop execution. Use Components.Exception()
439 // instead of Error() so the exception is QI-able and can be passed across
440 // XPCOM borders while preserving the status code.
441 if (!statusSuccess) {
442 let message = Components.Exception("", statusCode).name;
443 let error = Components.Exception(message, statusCode);
444 this.onComplete(error);
445 this.onComplete = this.onProgress = null;
446 return;
447 }
449 this._log.debug(this.method + " " + uri + " " + this.response.status);
451 // Additionally give the full response body when Trace logging.
452 if (this._log.level <= Log.Level.Trace) {
453 this._log.trace(this.method + " body: " + this.response.body);
454 }
456 delete this._inputStream;
458 this.onComplete(null);
459 this.onComplete = this.onProgress = null;
460 },
462 onDataAvailable: function onDataAvailable(channel, cb, stream, off, count) {
463 // We get an nsIRequest, which doesn't have contentCharset.
464 try {
465 channel.QueryInterface(Ci.nsIHttpChannel);
466 } catch (ex) {
467 this._log.error("Unexpected error: channel not nsIHttpChannel!");
468 this.abort();
470 if (this.onComplete) {
471 this.onComplete(ex);
472 }
474 this.onComplete = this.onProgress = null;
475 return;
476 }
478 if (channel.contentCharset) {
479 this.response.charset = channel.contentCharset;
481 if (!this._converterStream) {
482 this._converterStream = Cc["@mozilla.org/intl/converter-input-stream;1"]
483 .createInstance(Ci.nsIConverterInputStream);
484 }
486 this._converterStream.init(stream, channel.contentCharset, 0,
487 this._converterStream.DEFAULT_REPLACEMENT_CHARACTER);
489 try {
490 let str = {};
491 let num = this._converterStream.readString(count, str);
492 if (num != 0) {
493 this.response.body += str.value;
494 }
495 } catch (ex) {
496 this._log.warn("Exception thrown reading " + count + " bytes from " +
497 "the channel.");
498 this._log.warn(CommonUtils.exceptionStr(ex));
499 throw ex;
500 }
501 } else {
502 this.response.charset = null;
504 if (!this._inputStream) {
505 this._inputStream = Cc["@mozilla.org/scriptableinputstream;1"]
506 .createInstance(Ci.nsIScriptableInputStream);
507 }
509 this._inputStream.init(stream);
511 this.response.body += this._inputStream.read(count);
512 }
514 try {
515 this.onProgress();
516 } catch (ex) {
517 this._log.warn("Got exception calling onProgress handler, aborting " +
518 this.method + " " + channel.URI.spec);
519 this._log.debug("Exception: " + CommonUtils.exceptionStr(ex));
520 this.abort();
522 if (!this.onComplete) {
523 this._log.error("Unexpected error: onComplete not defined in " +
524 "onDataAvailable.");
525 this.onProgress = null;
526 return;
527 }
529 this.onComplete(ex);
530 this.onComplete = this.onProgress = null;
531 return;
532 }
534 this.delayTimeout();
535 },
537 /*** nsIInterfaceRequestor ***/
539 getInterface: function(aIID) {
540 return this.QueryInterface(aIID);
541 },
543 /*** nsIBadCertListener2 ***/
545 notifyCertProblem: function notifyCertProblem(socketInfo, sslStatus, targetHost) {
546 this._log.warn("Invalid HTTPS certificate encountered!");
547 // Suppress invalid HTTPS certificate warnings in the UI.
548 // (The request will still fail.)
549 return true;
550 },
552 /**
553 * Returns true if headers from the old channel should be
554 * copied to the new channel. Invoked when a channel redirect
555 * is in progress.
556 */
557 shouldCopyOnRedirect: function shouldCopyOnRedirect(oldChannel, newChannel, flags) {
558 let isInternal = !!(flags & Ci.nsIChannelEventSink.REDIRECT_INTERNAL);
559 let isSameURI = newChannel.URI.equals(oldChannel.URI);
560 this._log.debug("Channel redirect: " + oldChannel.URI.spec + ", " +
561 newChannel.URI.spec + ", internal = " + isInternal);
562 return isInternal && isSameURI;
563 },
565 /*** nsIChannelEventSink ***/
566 asyncOnChannelRedirect:
567 function asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
569 try {
570 newChannel.QueryInterface(Ci.nsIHttpChannel);
571 } catch (ex) {
572 this._log.error("Unexpected error: channel not nsIHttpChannel!");
573 callback.onRedirectVerifyCallback(Cr.NS_ERROR_NO_INTERFACE);
574 return;
575 }
577 // For internal redirects, copy the headers that our caller set.
578 try {
579 if (this.shouldCopyOnRedirect(oldChannel, newChannel, flags)) {
580 this._log.trace("Copying headers for safe internal redirect.");
581 for (let key in this._headers) {
582 newChannel.setRequestHeader(key, this._headers[key], false);
583 }
584 }
585 } catch (ex) {
586 this._log.error("Error copying headers: " + CommonUtils.exceptionStr(ex));
587 }
589 this.channel = newChannel;
591 // We let all redirects proceed.
592 callback.onRedirectVerifyCallback(Cr.NS_OK);
593 }
594 };
596 /**
597 * Response object for a RESTRequest. This will be created automatically by
598 * the RESTRequest.
599 */
600 this.RESTResponse = function RESTResponse() {
601 this._log = Log.repository.getLogger(this._logName);
602 this._log.level =
603 Log.Level[Prefs.get("log.logger.rest.response")];
604 }
605 RESTResponse.prototype = {
607 _logName: "Sync.RESTResponse",
609 /**
610 * Corresponding REST request
611 */
612 request: null,
614 /**
615 * HTTP status code
616 */
617 get status() {
618 let status;
619 try {
620 status = this.request.channel.responseStatus;
621 } catch (ex) {
622 this._log.debug("Caught exception fetching HTTP status code:" +
623 CommonUtils.exceptionStr(ex));
624 return null;
625 }
626 delete this.status;
627 return this.status = status;
628 },
630 /**
631 * HTTP status text
632 */
633 get statusText() {
634 let statusText;
635 try {
636 statusText = this.request.channel.responseStatusText;
637 } catch (ex) {
638 this._log.debug("Caught exception fetching HTTP status text:" +
639 CommonUtils.exceptionStr(ex));
640 return null;
641 }
642 delete this.statusText;
643 return this.statusText = statusText;
644 },
646 /**
647 * Boolean flag that indicates whether the HTTP status code is 2xx or not.
648 */
649 get success() {
650 let success;
651 try {
652 success = this.request.channel.requestSucceeded;
653 } catch (ex) {
654 this._log.debug("Caught exception fetching HTTP success flag:" +
655 CommonUtils.exceptionStr(ex));
656 return null;
657 }
658 delete this.success;
659 return this.success = success;
660 },
662 /**
663 * Object containing HTTP headers (keyed as lower case)
664 */
665 get headers() {
666 let headers = {};
667 try {
668 this._log.trace("Processing response headers.");
669 let channel = this.request.channel.QueryInterface(Ci.nsIHttpChannel);
670 channel.visitResponseHeaders(function (header, value) {
671 headers[header.toLowerCase()] = value;
672 });
673 } catch (ex) {
674 this._log.debug("Caught exception processing response headers:" +
675 CommonUtils.exceptionStr(ex));
676 return null;
677 }
679 delete this.headers;
680 return this.headers = headers;
681 },
683 /**
684 * HTTP body (string)
685 */
686 body: null
688 };
690 /**
691 * Single use MAC authenticated HTTP requests to RESTish resources.
692 *
693 * @param uri
694 * URI going to the RESTRequest constructor.
695 * @param authToken
696 * (Object) An auth token of the form {id: (string), key: (string)}
697 * from which the MAC Authentication header for this request will be
698 * derived. A token as obtained from
699 * TokenServerClient.getTokenFromBrowserIDAssertion is accepted.
700 * @param extra
701 * (Object) Optional extra parameters. Valid keys are: nonce_bytes, ts,
702 * nonce, and ext. See CrytoUtils.computeHTTPMACSHA1 for information on
703 * the purpose of these values.
704 */
705 this.TokenAuthenticatedRESTRequest =
706 function TokenAuthenticatedRESTRequest(uri, authToken, extra) {
707 RESTRequest.call(this, uri);
708 this.authToken = authToken;
709 this.extra = extra || {};
710 }
711 TokenAuthenticatedRESTRequest.prototype = {
712 __proto__: RESTRequest.prototype,
714 dispatch: function dispatch(method, data, onComplete, onProgress) {
715 let sig = CryptoUtils.computeHTTPMACSHA1(
716 this.authToken.id, this.authToken.key, method, this.uri, this.extra
717 );
719 this.setHeader("Authorization", sig.getHeader());
721 return RESTRequest.prototype.dispatch.call(
722 this, method, data, onComplete, onProgress
723 );
724 },
725 };