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 +