/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { Log } from "resource://gre/modules/Log.sys.mjs"; import { Observers } from "resource://services-common/observers.sys.mjs"; import { CommonUtils } from "resource://services-common/utils.sys.mjs"; import { Utils } from "resource://services-sync/util.sys.mjs"; import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; /* global AbortController */ /* * Resource represents a remote network resource, identified by a URI. * Create an instance like so: * * let resource = new Resource("http://foobar.com/path/to/resource"); * * The 'resource' object has the following methods to issue HTTP requests * of the corresponding HTTP methods: * * get(callback) * put(data, callback) * post(data, callback) * delete(callback) */ export function Resource(uri) { this._log = Log.repository.getLogger(this._logName); this._log.manageLevelFromPref("services.sync.log.logger.network.resources"); this.uri = uri; this._headers = {}; } // (static) Caches the latest server timestamp (X-Weave-Timestamp header). Resource.serverTime = null; XPCOMUtils.defineLazyPreferenceGetter( Resource, "SEND_VERSION_INFO", "services.sync.sendVersionInfo", true ); Resource.prototype = { _logName: "Sync.Resource", /** * Callback to be invoked at request time to add authentication details. * If the callback returns a promise, it will be awaited upon. * * By default, a global authenticator is provided. If this is set, it will * be used instead of the global one. */ authenticator: null, // Wait 5 minutes before killing a request. ABORT_TIMEOUT: 300000, // Headers to be included when making a request for the resource. // Note: Header names should be all lower case, there's no explicit // check for duplicates due to case! get headers() { return this._headers; }, set headers(_) { throw new Error("headers can't be mutated directly. Please use setHeader."); }, setHeader(header, value) { this._headers[header.toLowerCase()] = value; }, // URI representing this resource. get uri() { return this._uri; }, set uri(value) { if (typeof value == "string") { this._uri = CommonUtils.makeURI(value); } else { this._uri = value; } }, // Get the string representation of the URI. get spec() { if (this._uri) { return this._uri.spec; } return null; }, /** * @param {string} method HTTP method * @returns {Headers} */ async _buildHeaders(method) { const headers = new Headers(this._headers); if (Resource.SEND_VERSION_INFO) { headers.append("user-agent", Utils.userAgent); } if (this.authenticator) { const result = await this.authenticator(this, method); if (result && result.headers) { for (const [k, v] of Object.entries(result.headers)) { headers.append(k.toLowerCase(), v); } } } else { this._log.debug("No authenticator found."); } // PUT and POST are treated differently because they have payload data. if (("PUT" == method || "POST" == method) && !headers.has("content-type")) { headers.append("content-type", "text/plain"); } if (this._log.level <= Log.Level.Trace) { for (const [k, v] of headers) { if (k == "authorization" || k == "x-client-state") { this._log.trace(`HTTP Header ${k}: ***** (suppressed)`); } else { this._log.trace(`HTTP Header ${k}: ${v}`); } } } if (!headers.has("accept")) { headers.append("accept", "application/json;q=0.9,*/*;q=0.2"); } return headers; }, /** * @param {string} method HTTP method * @param {string} data HTTP body * @param {object} signal AbortSignal instance * @returns {Request} */ async _createRequest(method, data, signal) { const headers = await this._buildHeaders(method); const init = { cache: "no-store", // No cache. headers, method, signal, mozErrors: true, // Return nsresult error codes instead of a generic // NetworkError when fetch rejects. }; if (data) { if (!(typeof data == "string" || data instanceof String)) { data = JSON.stringify(data); } this._log.debug(`${method} Length: ${data.length}`); this._log.trace(`${method} Body: ${data}`); init.body = data; } return new Request(this.uri.spec, init); }, /** * @param {string} method HTTP method * @param {string} [data] HTTP body * @returns {Response} */ async _doRequest(method, data = null) { const controller = new AbortController(); const request = await this._createRequest(method, data, controller.signal); const responsePromise = fetch(request); // Rejects on network failure. let didTimeout = false; const timeoutId = setTimeout(() => { didTimeout = true; this._log.error( `Request timed out after ${this.ABORT_TIMEOUT}ms. Aborting.` ); controller.abort(); }, this.ABORT_TIMEOUT); let response; try { response = await responsePromise; } catch (e) { this._log.warn(`${method} request to ${this.uri.spec} failed`, e); if (!didTimeout) { throw e; } throw Components.Exception( "Request aborted (timeout)", Cr.NS_ERROR_NET_TIMEOUT ); } finally { clearTimeout(timeoutId); } return this._processResponse(response, method); }, async _processResponse(response, method) { const data = await response.text(); this._logResponse(response, method, data); this._processResponseHeaders(response); const ret = { data, url: response.url, status: response.status, success: response.ok, headers: {}, }; for (const [k, v] of response.headers) { ret.headers[k] = v; } // Make a lazy getter to convert the json response into an object. // Note that this can cause a parse error to be thrown far away from the // actual fetch, so be warned! XPCOMUtils.defineLazyGetter(ret, "obj", () => { try { return JSON.parse(ret.data); } catch (ex) { this._log.warn("Got exception parsing response body", ex); // Stringify to avoid possibly printing non-printable characters. this._log.debug( "Parse fail: Response body starts", (ret.data + "").slice(0, 100) ); throw ex; } }); return ret; }, _logResponse(response, method, data) { const { status, ok: success, url } = response; // Log the status of the request. this._log.debug( `${method} ${success ? "success" : "fail"} ${status} ${url}` ); // Additionally give the full response body when Trace logging. if (this._log.level <= Log.Level.Trace) { this._log.trace(`${method} body`, data); } if (!success) { this._log.warn( `${method} request to ${url} failed with status ${status}` ); } }, _processResponseHeaders({ headers, ok: success }) { if (headers.has("x-weave-timestamp")) { Resource.serverTime = parseFloat(headers.get("x-weave-timestamp")); } // This is a server-side safety valve to allow slowing down // clients without hurting performance. if (headers.has("x-weave-backoff")) { let backoff = headers.get("x-weave-backoff"); this._log.debug(`Got X-Weave-Backoff: ${backoff}`); Observers.notify("weave:service:backoff:interval", parseInt(backoff, 10)); } if (success && headers.has("x-weave-quota-remaining")) { Observers.notify( "weave:service:quota:remaining", parseInt(headers.get("x-weave-quota-remaining"), 10) ); } }, get() { return this._doRequest("GET"); }, put(data) { return this._doRequest("PUT", data); }, post(data) { return this._doRequest("POST", data); }, delete() { return this._doRequest("DELETE"); }, };