services/common/bagheeraclient.js

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/services/common/bagheeraclient.js	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,277 @@
     1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public
     1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this
     1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     1.7 +
     1.8 +/**
     1.9 + * This file contains a client API for the Bagheera data storage service.
    1.10 + *
    1.11 + * Information about Bagheera is available at
    1.12 + * https://github.com/mozilla-metrics/bagheera
    1.13 + */
    1.14 +
    1.15 +"use strict";
    1.16 +
    1.17 +#ifndef MERGED_COMPARTMENT
    1.18 +
    1.19 +this.EXPORTED_SYMBOLS = [
    1.20 +  "BagheeraClient",
    1.21 +  "BagheeraClientRequestResult",
    1.22 +];
    1.23 +
    1.24 +const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
    1.25 +
    1.26 +#endif
    1.27 +
    1.28 +Cu.import("resource://gre/modules/Promise.jsm");
    1.29 +Cu.import("resource://gre/modules/Services.jsm");
    1.30 +Cu.import("resource://gre/modules/Log.jsm");
    1.31 +Cu.import("resource://services-common/rest.js");
    1.32 +Cu.import("resource://services-common/utils.js");
    1.33 +
    1.34 +
    1.35 +/**
    1.36 + * Represents the result of a Bagheera request.
    1.37 + */
    1.38 +this.BagheeraClientRequestResult = function BagheeraClientRequestResult() {
    1.39 +  this.transportSuccess = false;
    1.40 +  this.serverSuccess = false;
    1.41 +  this.request = null;
    1.42 +};
    1.43 +
    1.44 +Object.freeze(BagheeraClientRequestResult.prototype);
    1.45 +
    1.46 +
    1.47 +/**
    1.48 + * Wrapper around RESTRequest so logging is sane.
    1.49 + */
    1.50 +function BagheeraRequest(uri) {
    1.51 +  RESTRequest.call(this, uri);
    1.52 +
    1.53 +  this._log = Log.repository.getLogger("Services.BagheeraClient");
    1.54 +  this._log.level = Log.Level.Debug;
    1.55 +}
    1.56 +
    1.57 +BagheeraRequest.prototype = Object.freeze({
    1.58 +  __proto__: RESTRequest.prototype,
    1.59 +});
    1.60 +
    1.61 +
    1.62 +/**
    1.63 + * Create a new Bagheera client instance.
    1.64 + *
    1.65 + * Each client is associated with a specific Bagheera HTTP URI endpoint.
    1.66 + *
    1.67 + * @param baseURI
    1.68 + *        (string) The base URI of the Bagheera HTTP endpoint.
    1.69 + */
    1.70 +this.BagheeraClient = function BagheeraClient(baseURI) {
    1.71 +  if (!baseURI) {
    1.72 +    throw new Error("baseURI argument must be defined.");
    1.73 +  }
    1.74 +
    1.75 +  this._log = Log.repository.getLogger("Services.BagheeraClient");
    1.76 +  this._log.level = Log.Level.Debug;
    1.77 +
    1.78 +  this.baseURI = baseURI;
    1.79 +
    1.80 +  if (!baseURI.endsWith("/")) {
    1.81 +    this.baseURI += "/";
    1.82 +  }
    1.83 +};
    1.84 +
    1.85 +BagheeraClient.prototype = Object.freeze({
    1.86 +  /**
    1.87 +   * Channel load flags for all requests.
    1.88 +   *
    1.89 +   * Caching is not applicable, so we bypass and disable it. We also
    1.90 +   * ignore any cookies that may be present for the domain because
    1.91 +   * Bagheera does not utilize cookies and the release of cookies may
    1.92 +   * inadvertantly constitute unncessary information disclosure.
    1.93 +   */
    1.94 +  _loadFlags: Ci.nsIRequest.LOAD_BYPASS_CACHE |
    1.95 +              Ci.nsIRequest.INHIBIT_CACHING |
    1.96 +              Ci.nsIRequest.LOAD_ANONYMOUS,
    1.97 +
    1.98 +  DEFAULT_TIMEOUT_MSEC: 5 * 60 * 1000, // 5 minutes.
    1.99 +
   1.100 +  _RE_URI_IDENTIFIER: /^[a-zA-Z0-9_-]+$/,
   1.101 +
   1.102 +  /**
   1.103 +   * Upload a JSON payload to the server.
   1.104 +   *
   1.105 +   * The return value is a Promise which will be resolved with a
   1.106 +   * BagheeraClientRequestResult when the request has finished.
   1.107 +   *
   1.108 +   * @param namespace
   1.109 +   *        (string) The namespace to post this data to.
   1.110 +   * @param id
   1.111 +   *        (string) The ID of the document being uploaded. This is typically
   1.112 +   *        a UUID in hex form.
   1.113 +   * @param payload
   1.114 +   *        (string|object) Data to upload. Can be specified as a string (which
   1.115 +   *        is assumed to be JSON) or an object. If an object, it will be fed into
   1.116 +   *        JSON.stringify() for serialization.
   1.117 +   * @param options
   1.118 +   *        (object) Extra options to control behavior. Recognized properties:
   1.119 +   *
   1.120 +   *          deleteIDs -- (array) Old document IDs to delete as part of
   1.121 +   *            upload. If not specified, no old documents will be deleted as
   1.122 +   *            part of upload. The array values are typically UUIDs in hex
   1.123 +   *            form.
   1.124 +   *
   1.125 +   *          telemetryCompressed -- (string) Telemetry histogram to record
   1.126 +   *            compressed size of payload under. If not defined, no telemetry
   1.127 +   *            data for the compressed size will be recorded.
   1.128 +   *
   1.129 +   * @return Promise<BagheeraClientRequestResult>
   1.130 +   */
   1.131 +  uploadJSON: function uploadJSON(namespace, id, payload, options={}) {
   1.132 +    if (!namespace) {
   1.133 +      throw new Error("namespace argument must be defined.");
   1.134 +    }
   1.135 +
   1.136 +    if (!id) {
   1.137 +      throw new Error("id argument must be defined.");
   1.138 +    }
   1.139 +
   1.140 +    if (!payload) {
   1.141 +      throw new Error("payload argument must be defined.");
   1.142 +    }
   1.143 +
   1.144 +    if (options && typeof(options) != "object") {
   1.145 +      throw new Error("Unexpected type for options argument. Expected object. " +
   1.146 +                      "Got: " + typeof(options));
   1.147 +    }
   1.148 +
   1.149 +    let uri = this._submitURI(namespace, id);
   1.150 +
   1.151 +    let data = payload;
   1.152 +
   1.153 +    if (typeof(payload) == "object") {
   1.154 +      data = JSON.stringify(payload);
   1.155 +    }
   1.156 +
   1.157 +    if (typeof(data) != "string") {
   1.158 +      throw new Error("Unknown type for payload: " + typeof(data));
   1.159 +    }
   1.160 +
   1.161 +    this._log.info("Uploading data to " + uri);
   1.162 +
   1.163 +    let request = new BagheeraRequest(uri);
   1.164 +    request.loadFlags = this._loadFlags;
   1.165 +    request.timeout = this.DEFAULT_TIMEOUT_MSEC;
   1.166 +
   1.167 +    // Since API changed, throw on old API usage.
   1.168 +    if ("deleteID" in options) {
   1.169 +      throw new Error("API has changed, use (array) deleteIDs instead");
   1.170 +    }
   1.171 +
   1.172 +    let deleteIDs;
   1.173 +    if (options.deleteIDs && options.deleteIDs.length > 0) {
   1.174 +      deleteIDs = options.deleteIDs;
   1.175 +      this._log.debug("Will delete " + deleteIDs.join(", "));
   1.176 +      request.setHeader("X-Obsolete-Document", deleteIDs.join(","));
   1.177 +    }
   1.178 +
   1.179 +    let deferred = Promise.defer();
   1.180 +
   1.181 +    data = CommonUtils.convertString(data, "uncompressed", "deflate");
   1.182 +    if (options.telemetryCompressed) {
   1.183 +      try {
   1.184 +        let h = Services.telemetry.getHistogramById(options.telemetryCompressed);
   1.185 +        h.add(data.length);
   1.186 +      } catch (ex) {
   1.187 +        this._log.warn("Unable to record telemetry for compressed payload size: " +
   1.188 +                       CommonUtils.exceptionStr(ex));
   1.189 +      }
   1.190 +    }
   1.191 +
   1.192 +    // TODO proper header per bug 807134.
   1.193 +    request.setHeader("Content-Type", "application/json+zlib; charset=utf-8");
   1.194 +
   1.195 +    this._log.info("Request body length: " + data.length);
   1.196 +
   1.197 +    let result = new BagheeraClientRequestResult();
   1.198 +    result.namespace = namespace;
   1.199 +    result.id = id;
   1.200 +    result.deleteIDs = deleteIDs ? deleteIDs.slice(0) : null;
   1.201 +
   1.202 +    request.onComplete = this._onComplete.bind(this, request, deferred, result);
   1.203 +    request.post(data);
   1.204 +
   1.205 +    return deferred.promise;
   1.206 +  },
   1.207 +
   1.208 +  /**
   1.209 +   * Delete the specified document.
   1.210 +   *
   1.211 +   * @param namespace
   1.212 +   *        (string) Namespace from which to delete the document.
   1.213 +   * @param id
   1.214 +   *        (string) ID of document to delete.
   1.215 +   *
   1.216 +   * @return Promise<BagheeraClientRequestResult>
   1.217 +   */
   1.218 +  deleteDocument: function deleteDocument(namespace, id) {
   1.219 +    let uri = this._submitURI(namespace, id);
   1.220 +
   1.221 +    let request = new BagheeraRequest(uri);
   1.222 +    request.loadFlags = this._loadFlags;
   1.223 +    request.timeout = this.DEFAULT_TIMEOUT_MSEC;
   1.224 +
   1.225 +    let result = new BagheeraClientRequestResult();
   1.226 +    result.namespace = namespace;
   1.227 +    result.id = id;
   1.228 +    let deferred = Promise.defer();
   1.229 +
   1.230 +    request.onComplete = this._onComplete.bind(this, request, deferred, result);
   1.231 +    request.delete();
   1.232 +
   1.233 +    return deferred.promise;
   1.234 +  },
   1.235 +
   1.236 +  _submitURI: function _submitURI(namespace, id) {
   1.237 +    if (!this._RE_URI_IDENTIFIER.test(namespace)) {
   1.238 +      throw new Error("Illegal namespace name. Must be alphanumeric + [_-]: " +
   1.239 +                      namespace);
   1.240 +    }
   1.241 +
   1.242 +    if (!this._RE_URI_IDENTIFIER.test(id)) {
   1.243 +      throw new Error("Illegal id value. Must be alphanumeric + [_-]: " + id);
   1.244 +    }
   1.245 +
   1.246 +    return this.baseURI + "1.0/submit/" + namespace + "/" + id;
   1.247 +  },
   1.248 +
   1.249 +  _onComplete: function _onComplete(request, deferred, result, error) {
   1.250 +    result.request = request;
   1.251 +
   1.252 +    if (error) {
   1.253 +      this._log.info("Transport failure on request: " +
   1.254 +                     CommonUtils.exceptionStr(error));
   1.255 +      result.transportSuccess = false;
   1.256 +      deferred.resolve(result);
   1.257 +      return;
   1.258 +    }
   1.259 +
   1.260 +    result.transportSuccess = true;
   1.261 +
   1.262 +    let response = request.response;
   1.263 +
   1.264 +    switch (response.status) {
   1.265 +      case 200:
   1.266 +      case 201:
   1.267 +        result.serverSuccess = true;
   1.268 +        break;
   1.269 +
   1.270 +      default:
   1.271 +        result.serverSuccess = false;
   1.272 +
   1.273 +        this._log.info("Received unexpected status code: " + response.status);
   1.274 +        this._log.debug("Response body: " + response.body);
   1.275 +    }
   1.276 +
   1.277 +    deferred.resolve(result);
   1.278 +  },
   1.279 +});
   1.280 +

mercurial