rest.sys.mjs (19367B)
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/. */ 4 5 import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs"; 6 7 import { Log } from "resource://gre/modules/Log.sys.mjs"; 8 9 import { CommonUtils } from "resource://services-common/utils.sys.mjs"; 10 11 const lazy = {}; 12 13 ChromeUtils.defineESModuleGetters(lazy, { 14 CryptoUtils: "moz-src:///services/crypto/modules/utils.sys.mjs", 15 }); 16 17 function decodeString(data, charset) { 18 if (!data || !charset) { 19 return data; 20 } 21 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.setByteStringData(data); 28 29 let converterStream = Cc[ 30 "@mozilla.org/intl/converter-input-stream;1" 31 ].createInstance(Ci.nsIConverterInputStream); 32 33 converterStream.init( 34 stringStream, 35 charset, 36 0, 37 converterStream.DEFAULT_REPLACEMENT_CHARACTER 38 ); 39 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; 52 } 53 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; 80 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; 87 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"); 92 } 93 94 RESTRequest.prototype = { 95 _logName: "Services.Common.RESTRequest", 96 97 QueryInterface: ChromeUtils.generateQI([ 98 "nsIInterfaceRequestor", 99 "nsIChannelEventSink", 100 ]), 101 102 /** Public API: */ 103 104 /** 105 * URI for the request (an nsIURI object). 106 */ 107 uri: null, 108 109 /** 110 * HTTP method (e.g. "GET") 111 */ 112 method: null, 113 114 /** 115 * RESTResponse object 116 */ 117 response: null, 118 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, 127 128 /** 129 * nsIHttpChannel 130 */ 131 channel: null, 132 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, 139 140 NOT_SENT: 0, 141 SENT: 1, 142 IN_PROGRESS: 2, 143 COMPLETED: 4, 144 ABORTED: 8, 145 146 /** 147 * HTTP status text of response 148 */ 149 statusText: null, 150 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, 159 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", 167 168 /** 169 * Set a request header. 170 */ 171 setHeader(name, value) { 172 this._headers[name.toLowerCase()] = value; 173 }, 174 175 /** 176 * Perform an HTTP GET. 177 * 178 * @return Promise<RESTResponse> 179 */ 180 async get() { 181 return this.dispatch("GET", null); 182 }, 183 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 }, 196 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 }, 209 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 }, 222 223 /** 224 * Perform an HTTP DELETE. 225 * 226 * @return Promise<RESTResponse> 227 */ 228 async delete() { 229 return this.dispatch("DELETE", null); 230 }, 231 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 } 239 240 this.status = this.ABORTED; 241 this.channel.cancel(Cr.NS_BINDING_ABORTED); 242 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 }, 251 252 /** Implementation stuff */ 253 254 async dispatch(method, data) { 255 if (this.status != this.NOT_SENT) { 256 throw new Error("Request has already been sent!"); 257 } 258 259 this.method = method; 260 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; 271 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 } 283 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 } 292 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 } 320 321 this._log.debug(method + " Length: " + data.length); 322 if (this._log.level <= Log.Level.Trace) { 323 this._log.trace(method + " Body: " + data); 324 } 325 326 let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( 327 Ci.nsIStringInputStream 328 ); 329 stream.setByteStringData(data); 330 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; 337 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; 341 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 }, 354 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 }, 368 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 }, 380 381 /** nsIStreamListener */ 382 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 } 394 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 } 404 405 this.status = this.IN_PROGRESS; 406 407 this._log.trace( 408 "onStartRequest: " + channel.requestMethod + " " + channel.URI.spec 409 ); 410 411 // Create a new response object. 412 this.response = new RESTResponse(this); 413 414 this.delayTimeout(); 415 }, 416 417 onStopRequest(channel, statusCode) { 418 if (this.timeoutTimer) { 419 // Clear the abort timer now that the channel is done. 420 this.timeoutTimer.clear(); 421 } 422 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 } 435 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 } 444 445 this.status = this.COMPLETED; 446 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 } 461 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 ); 472 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 } 489 490 this._log.debug(this.method + " " + uri + " " + this.response.status); 491 492 // Note that for privacy/security reasons we don't log this response body 493 494 delete this._inputStream; 495 496 this._deferred.resolve(this.response); 497 }, 498 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 } 508 509 if (channel.contentCharset) { 510 this.response.charset = channel.contentCharset; 511 } else { 512 this.response.charset = null; 513 } 514 515 if (!this._inputStream) { 516 this._inputStream = Cc[ 517 "@mozilla.org/scriptableinputstream;1" 518 ].createInstance(Ci.nsIScriptableInputStream); 519 } 520 this._inputStream.init(stream); 521 522 this.response._rawBody += this._inputStream.read(count); 523 524 this.delayTimeout(); 525 }, 526 527 /** nsIInterfaceRequestor */ 528 529 getInterface(aIID) { 530 return this.QueryInterface(aIID); 531 }, 532 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 }, 551 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 ); 561 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 } 569 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 } 581 582 this.channel = newChannel; 583 584 // We let all redirects proceed. 585 callback.onRedirectVerifyCallback(Cr.NS_OK); 586 }, 587 }; 588 589 /** 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"); 599 } 600 601 RESTResponse.prototype = { 602 _logName: "Services.Common.RESTResponse", 603 604 /** 605 * Corresponding REST request 606 */ 607 request: null, 608 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 }, 623 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 }, 638 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 }, 653 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 } 669 670 Object.defineProperty(this, "headers", { value: headers }); 671 return headers; 672 }, 673 674 /** 675 * HTTP body (string) 676 */ 677 body: null, 678 }; 679 680 /** 681 * Single use MAC authenticated HTTP requests to RESTish resources. 682 * 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 || {}; 699 } 700 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 ); 710 711 this.setHeader("Authorization", sig.getHeader()); 712 713 return super.dispatch(method, data); 714 }, 715 }; 716 717 Object.setPrototypeOf( 718 TokenAuthenticatedRESTRequest.prototype, 719 RESTRequest.prototype 720 );