michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: /** michael@0: * This file contains a client API for the Bagheera data storage service. michael@0: * michael@0: * Information about Bagheera is available at michael@0: * https://github.com/mozilla-metrics/bagheera michael@0: */ michael@0: michael@0: "use strict"; michael@0: michael@0: #ifndef MERGED_COMPARTMENT michael@0: michael@0: this.EXPORTED_SYMBOLS = [ michael@0: "BagheeraClient", michael@0: "BagheeraClientRequestResult", michael@0: ]; michael@0: michael@0: const {classes: Cc, interfaces: Ci, utils: Cu} = Components; michael@0: michael@0: #endif michael@0: michael@0: Cu.import("resource://gre/modules/Promise.jsm"); michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://gre/modules/Log.jsm"); michael@0: Cu.import("resource://services-common/rest.js"); michael@0: Cu.import("resource://services-common/utils.js"); michael@0: michael@0: michael@0: /** michael@0: * Represents the result of a Bagheera request. michael@0: */ michael@0: this.BagheeraClientRequestResult = function BagheeraClientRequestResult() { michael@0: this.transportSuccess = false; michael@0: this.serverSuccess = false; michael@0: this.request = null; michael@0: }; michael@0: michael@0: Object.freeze(BagheeraClientRequestResult.prototype); michael@0: michael@0: michael@0: /** michael@0: * Wrapper around RESTRequest so logging is sane. michael@0: */ michael@0: function BagheeraRequest(uri) { michael@0: RESTRequest.call(this, uri); michael@0: michael@0: this._log = Log.repository.getLogger("Services.BagheeraClient"); michael@0: this._log.level = Log.Level.Debug; michael@0: } michael@0: michael@0: BagheeraRequest.prototype = Object.freeze({ michael@0: __proto__: RESTRequest.prototype, michael@0: }); michael@0: michael@0: michael@0: /** michael@0: * Create a new Bagheera client instance. michael@0: * michael@0: * Each client is associated with a specific Bagheera HTTP URI endpoint. michael@0: * michael@0: * @param baseURI michael@0: * (string) The base URI of the Bagheera HTTP endpoint. michael@0: */ michael@0: this.BagheeraClient = function BagheeraClient(baseURI) { michael@0: if (!baseURI) { michael@0: throw new Error("baseURI argument must be defined."); michael@0: } michael@0: michael@0: this._log = Log.repository.getLogger("Services.BagheeraClient"); michael@0: this._log.level = Log.Level.Debug; michael@0: michael@0: this.baseURI = baseURI; michael@0: michael@0: if (!baseURI.endsWith("/")) { michael@0: this.baseURI += "/"; michael@0: } michael@0: }; michael@0: michael@0: BagheeraClient.prototype = Object.freeze({ michael@0: /** michael@0: * Channel load flags for all requests. michael@0: * michael@0: * Caching is not applicable, so we bypass and disable it. We also michael@0: * ignore any cookies that may be present for the domain because michael@0: * Bagheera does not utilize cookies and the release of cookies may michael@0: * inadvertantly constitute unncessary information disclosure. michael@0: */ michael@0: _loadFlags: Ci.nsIRequest.LOAD_BYPASS_CACHE | michael@0: Ci.nsIRequest.INHIBIT_CACHING | michael@0: Ci.nsIRequest.LOAD_ANONYMOUS, michael@0: michael@0: DEFAULT_TIMEOUT_MSEC: 5 * 60 * 1000, // 5 minutes. michael@0: michael@0: _RE_URI_IDENTIFIER: /^[a-zA-Z0-9_-]+$/, michael@0: michael@0: /** michael@0: * Upload a JSON payload to the server. michael@0: * michael@0: * The return value is a Promise which will be resolved with a michael@0: * BagheeraClientRequestResult when the request has finished. michael@0: * michael@0: * @param namespace michael@0: * (string) The namespace to post this data to. michael@0: * @param id michael@0: * (string) The ID of the document being uploaded. This is typically michael@0: * a UUID in hex form. michael@0: * @param payload michael@0: * (string|object) Data to upload. Can be specified as a string (which michael@0: * is assumed to be JSON) or an object. If an object, it will be fed into michael@0: * JSON.stringify() for serialization. michael@0: * @param options michael@0: * (object) Extra options to control behavior. Recognized properties: michael@0: * michael@0: * deleteIDs -- (array) Old document IDs to delete as part of michael@0: * upload. If not specified, no old documents will be deleted as michael@0: * part of upload. The array values are typically UUIDs in hex michael@0: * form. michael@0: * michael@0: * telemetryCompressed -- (string) Telemetry histogram to record michael@0: * compressed size of payload under. If not defined, no telemetry michael@0: * data for the compressed size will be recorded. michael@0: * michael@0: * @return Promise michael@0: */ michael@0: uploadJSON: function uploadJSON(namespace, id, payload, options={}) { michael@0: if (!namespace) { michael@0: throw new Error("namespace argument must be defined."); michael@0: } michael@0: michael@0: if (!id) { michael@0: throw new Error("id argument must be defined."); michael@0: } michael@0: michael@0: if (!payload) { michael@0: throw new Error("payload argument must be defined."); michael@0: } michael@0: michael@0: if (options && typeof(options) != "object") { michael@0: throw new Error("Unexpected type for options argument. Expected object. " + michael@0: "Got: " + typeof(options)); michael@0: } michael@0: michael@0: let uri = this._submitURI(namespace, id); michael@0: michael@0: let data = payload; michael@0: michael@0: if (typeof(payload) == "object") { michael@0: data = JSON.stringify(payload); michael@0: } michael@0: michael@0: if (typeof(data) != "string") { michael@0: throw new Error("Unknown type for payload: " + typeof(data)); michael@0: } michael@0: michael@0: this._log.info("Uploading data to " + uri); michael@0: michael@0: let request = new BagheeraRequest(uri); michael@0: request.loadFlags = this._loadFlags; michael@0: request.timeout = this.DEFAULT_TIMEOUT_MSEC; michael@0: michael@0: // Since API changed, throw on old API usage. michael@0: if ("deleteID" in options) { michael@0: throw new Error("API has changed, use (array) deleteIDs instead"); michael@0: } michael@0: michael@0: let deleteIDs; michael@0: if (options.deleteIDs && options.deleteIDs.length > 0) { michael@0: deleteIDs = options.deleteIDs; michael@0: this._log.debug("Will delete " + deleteIDs.join(", ")); michael@0: request.setHeader("X-Obsolete-Document", deleteIDs.join(",")); michael@0: } michael@0: michael@0: let deferred = Promise.defer(); michael@0: michael@0: data = CommonUtils.convertString(data, "uncompressed", "deflate"); michael@0: if (options.telemetryCompressed) { michael@0: try { michael@0: let h = Services.telemetry.getHistogramById(options.telemetryCompressed); michael@0: h.add(data.length); michael@0: } catch (ex) { michael@0: this._log.warn("Unable to record telemetry for compressed payload size: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: } michael@0: } michael@0: michael@0: // TODO proper header per bug 807134. michael@0: request.setHeader("Content-Type", "application/json+zlib; charset=utf-8"); michael@0: michael@0: this._log.info("Request body length: " + data.length); michael@0: michael@0: let result = new BagheeraClientRequestResult(); michael@0: result.namespace = namespace; michael@0: result.id = id; michael@0: result.deleteIDs = deleteIDs ? deleteIDs.slice(0) : null; michael@0: michael@0: request.onComplete = this._onComplete.bind(this, request, deferred, result); michael@0: request.post(data); michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: /** michael@0: * Delete the specified document. michael@0: * michael@0: * @param namespace michael@0: * (string) Namespace from which to delete the document. michael@0: * @param id michael@0: * (string) ID of document to delete. michael@0: * michael@0: * @return Promise michael@0: */ michael@0: deleteDocument: function deleteDocument(namespace, id) { michael@0: let uri = this._submitURI(namespace, id); michael@0: michael@0: let request = new BagheeraRequest(uri); michael@0: request.loadFlags = this._loadFlags; michael@0: request.timeout = this.DEFAULT_TIMEOUT_MSEC; michael@0: michael@0: let result = new BagheeraClientRequestResult(); michael@0: result.namespace = namespace; michael@0: result.id = id; michael@0: let deferred = Promise.defer(); michael@0: michael@0: request.onComplete = this._onComplete.bind(this, request, deferred, result); michael@0: request.delete(); michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: _submitURI: function _submitURI(namespace, id) { michael@0: if (!this._RE_URI_IDENTIFIER.test(namespace)) { michael@0: throw new Error("Illegal namespace name. Must be alphanumeric + [_-]: " + michael@0: namespace); michael@0: } michael@0: michael@0: if (!this._RE_URI_IDENTIFIER.test(id)) { michael@0: throw new Error("Illegal id value. Must be alphanumeric + [_-]: " + id); michael@0: } michael@0: michael@0: return this.baseURI + "1.0/submit/" + namespace + "/" + id; michael@0: }, michael@0: michael@0: _onComplete: function _onComplete(request, deferred, result, error) { michael@0: result.request = request; michael@0: michael@0: if (error) { michael@0: this._log.info("Transport failure on request: " + michael@0: CommonUtils.exceptionStr(error)); michael@0: result.transportSuccess = false; michael@0: deferred.resolve(result); michael@0: return; michael@0: } michael@0: michael@0: result.transportSuccess = true; michael@0: michael@0: let response = request.response; michael@0: michael@0: switch (response.status) { michael@0: case 200: michael@0: case 201: michael@0: result.serverSuccess = true; michael@0: break; michael@0: michael@0: default: michael@0: result.serverSuccess = false; michael@0: michael@0: this._log.info("Received unexpected status code: " + response.status); michael@0: this._log.debug("Response body: " + response.body); michael@0: } michael@0: michael@0: deferred.resolve(result); michael@0: }, michael@0: }); michael@0: