diff -r 000000000000 -r 6474c204b198 services/common/storageservice.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/services/common/storageservice.js Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,2222 @@ +/* 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/. */ + +/** + * This file contains APIs for interacting with the Storage Service API. + * + * The specification for the service is available at. + * http://docs.services.mozilla.com/storage/index.html + * + * Nothing about the spec or the service is Sync-specific. And, that is how + * these APIs are implemented. Instead, it is expected that consumers will + * create a new type inheriting or wrapping those provided by this file. + * + * STORAGE SERVICE OVERVIEW + * + * The storage service is effectively a key-value store where each value is a + * well-defined envelope that stores specific metadata along with a payload. + * These values are called Basic Storage Objects, or BSOs. BSOs are organized + * into named groups called collections. + * + * The service also provides ancillary APIs not related to storage, such as + * looking up the set of stored collections, current quota usage, etc. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "BasicStorageObject", + "StorageServiceClient", + "StorageServiceRequestError", +]; + +const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components; + +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://services-common/async.js"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-common/rest.js"); +Cu.import("resource://services-common/utils.js"); + +const Prefs = new Preferences("services.common.storageservice."); + +/** + * The data type stored in the storage service. + * + * A Basic Storage Object (BSO) is the primitive type stored in the storage + * service. BSO's are simply maps with a well-defined set of keys. + * + * BSOs belong to named collections. + * + * A single BSO consists of the following fields: + * + * id - An identifying string. This is how a BSO is uniquely identified within + * a single collection. + * modified - Integer milliseconds since Unix epoch BSO was modified. + * payload - String contents of BSO. The format of the string is undefined + * (although JSON is typically used). + * ttl - The number of seconds to keep this record. + * sortindex - Integer indicating relative importance of record within the + * collection. + * + * The constructor simply creates an empty BSO having the specified ID (which + * can be null or undefined). It also takes an optional collection. This is + * purely for convenience. + * + * This type is meant to be a dumb container and little more. + * + * @param id + * (string) ID of BSO. Can be null. + * (string) Collection BSO belongs to. Can be null; + */ +this.BasicStorageObject = + function BasicStorageObject(id=null, collection=null) { + this.data = {}; + this.id = id; + this.collection = collection; +} +BasicStorageObject.prototype = { + id: null, + collection: null, + data: null, + + // At the time this was written, the convention for constructor arguments + // was not adopted by Harmony. It could break in the future. We have test + // coverage that will break if SpiderMonkey changes, just in case. + _validKeys: new Set(["id", "payload", "modified", "sortindex", "ttl"]), + + /** + * Get the string payload as-is. + */ + get payload() { + return this.data.payload; + }, + + /** + * Set the string payload to a new value. + */ + set payload(value) { + this.data.payload = value; + }, + + /** + * Get the modified time of the BSO in milliseconds since Unix epoch. + * + * You can convert this to a native JS Date instance easily: + * + * let date = new Date(bso.modified); + */ + get modified() { + return this.data.modified; + }, + + /** + * Sets the modified time of the BSO in milliseconds since Unix epoch. + * + * Please note that if this value is sent to the server it will be ignored. + * The server will use its time at the time of the operation when storing the + * BSO. + */ + set modified(value) { + this.data.modified = value; + }, + + get sortindex() { + if (this.data.sortindex) { + return this.data.sortindex || 0; + } + + return 0; + }, + + set sortindex(value) { + if (!value && value !== 0) { + delete this.data.sortindex; + return; + } + + this.data.sortindex = value; + }, + + get ttl() { + return this.data.ttl; + }, + + set ttl(value) { + if (!value && value !== 0) { + delete this.data.ttl; + return; + } + + this.data.ttl = value; + }, + + /** + * Deserialize JSON or another object into this instance. + * + * The argument can be a string containing serialized JSON or an object. + * + * If the JSON is invalid or if the object contains unknown fields, an + * exception will be thrown. + * + * @param json + * (string|object) Value to construct BSO from. + */ + deserialize: function deserialize(input) { + let data; + + if (typeof(input) == "string") { + data = JSON.parse(input); + if (typeof(data) != "object") { + throw new Error("Supplied JSON is valid but is not a JS-Object."); + } + } + else if (typeof(input) == "object") { + data = input; + } else { + throw new Error("Argument must be a JSON string or object: " + + typeof(input)); + } + + for each (let key in Object.keys(data)) { + if (key == "id") { + this.id = data.id; + continue; + } + + if (!this._validKeys.has(key)) { + throw new Error("Invalid key in object: " + key); + } + + this.data[key] = data[key]; + } + }, + + /** + * Serialize the current BSO to JSON. + * + * @return string + * The JSON representation of this BSO. + */ + toJSON: function toJSON() { + let obj = {}; + + for (let [k, v] in Iterator(this.data)) { + obj[k] = v; + } + + if (this.id) { + obj.id = this.id; + } + + return obj; + }, + + toString: function toString() { + return "{ " + + "id: " + this.id + " " + + "modified: " + this.modified + " " + + "ttl: " + this.ttl + " " + + "index: " + this.sortindex + " " + + "payload: " + this.payload + + " }"; + }, +}; + +/** + * Represents an error encountered during a StorageServiceRequest request. + * + * Instances of this will be passed to the onComplete callback for any request + * that did not succeed. + * + * This type effectively wraps other error conditions. It is up to the client + * to determine the appropriate course of action for each error type + * encountered. + * + * The following error "classes" are defined by properties on each instance: + * + * serverModified - True if the request to modify data was conditional and + * the server rejected the request because it has newer data than the + * client. + * + * notFound - True if the requested URI or resource does not exist. + * + * conflict - True if the server reported that a resource being operated on + * was in conflict. If this occurs, the client should typically wait a + * little and try the request again. + * + * requestTooLarge - True if the request was too large for the server. If + * this happens on batch requests, the client should retry the request with + * smaller batches. + * + * network - A network error prevented this request from succeeding. If set, + * it will be an Error thrown by the Gecko network stack. If set, it could + * mean that the request could not be performed or that an error occurred + * when the request was in flight. It is also possible the request + * succeeded on the server but the response was lost in transit. + * + * authentication - If defined, an authentication error has occurred. If + * defined, it will be an Error instance. If seen, the client should not + * retry the request without first correcting the authentication issue. + * + * client - An error occurred which was the client's fault. This typically + * means the code in this file is buggy. + * + * server - An error occurred on the server. In the ideal world, this should + * never happen. But, it does. If set, this will be an Error which + * describes the error as reported by the server. + */ +this.StorageServiceRequestError = function StorageServiceRequestError() { + this.serverModified = false; + this.notFound = false; + this.conflict = false; + this.requestToolarge = false; + this.network = null; + this.authentication = null; + this.client = null; + this.server = null; +} + +/** + * Represents a single request to the storage service. + * + * Instances of this type are returned by the APIs on StorageServiceClient. + * They should not be created outside of StorageServiceClient. + * + * This type encapsulates common storage API request and response handling. + * Metadata required to perform the request is stored inside each instance and + * should be treated as invisible by consumers. + * + * A number of "public" properties are exposed to allow clients to further + * customize behavior. These are documented below. + * + * Some APIs in StorageServiceClient define their own types which inherit from + * this one. Read the API documentation to see which types those are and when + * they apply. + * + * This type wraps RESTRequest rather than extending it. The reason is mainly + * to avoid the fragile base class problem. We implement considerable extra + * functionality on top of RESTRequest and don't want this to accidentally + * trample on RESTRequest's members. + * + * If this were a C++ class, it and StorageServiceClient would be friend + * classes. Each touches "protected" APIs of the other. Thus, each should be + * considered when making changes to the other. + * + * Usage + * ===== + * + * When you obtain a request instance, it is waiting to be dispatched. It may + * have additional settings available for tuning. See the documentation in + * StorageServiceClient for more. + * + * There are essentially two types of requests: "basic" and "streaming." + * "Basic" requests encapsulate the traditional request-response paradigm: + * a request is issued and we get a response later once the full response + * is available. Most of the APIs in StorageServiceClient issue these "basic" + * requests. Streaming requests typically involve the transport of multiple + * BasicStorageObject instances. When a new BSO instance is available, a + * callback is fired. + * + * For basic requests, the general flow looks something like: + * + * // Obtain a new request instance. + * let request = client.getCollectionInfo(); + * + * // Install a handler which provides callbacks for request events. The most + * // important is `onComplete`, which is called when the request has + * // finished and the response is completely received. + * request.handler = { + * onComplete: function onComplete(error, request) { + * // Do something. + * } + * }; + * + * // Send the request. + * request.dispatch(); + * + * Alternatively, we can install the onComplete handler when calling dispatch: + * + * let request = client.getCollectionInfo(); + * request.dispatch(function onComplete(error, request) { + * // Handle response. + * }); + * + * Please note that installing an `onComplete` handler as the argument to + * `dispatch()` will overwrite an existing `handler`. + * + * In both of the above example, the two `request` variables are identical. The + * original `StorageServiceRequest` is passed into the callback so callers + * don't need to rely on closures. + * + * Most of the complexity for onComplete handlers is error checking. + * + * The first thing you do in your onComplete handler is ensure no error was + * seen: + * + * function onComplete(error, request) { + * if (error) { + * // Handle error. + * } + * } + * + * If `error` is defined, it will be an instance of + * `StorageServiceRequestError`. An error will be set if the request didn't + * complete successfully. This means the transport layer must have succeeded + * and the application protocol (HTTP) must have returned a successful status + * code (2xx and some 3xx). Please see the documentation for + * `StorageServiceRequestError` for more. + * + * A robust error handler would look something like: + * + * function onComplete(error, request) { + * if (error) { + * if (error.network) { + * // Network error encountered! + * } else if (error.server) { + * // Something went wrong on the server (HTTP 5xx). + * } else if (error.authentication) { + * // Server rejected request due to bad credentials. + * } else if (error.serverModified) { + * // The conditional request was rejected because the server has newer + * // data than what the client reported. + * } else if (error.conflict) { + * // The server reported that the operation could not be completed + * // because another client is also updating it. + * } else if (error.requestTooLarge) { + * // The server rejected the request because it was too large. + * } else if (error.notFound) { + * // The requested resource was not found. + * } else if (error.client) { + * // Something is wrong with the client's request. You should *never* + * // see this, as it means this client is likely buggy. It could also + * // mean the server is buggy or misconfigured. Either way, something + * // is buggy. + * } + * + * return; + * } + * + * // Handle successful case. + * } + * + * If `error` is null, the request completed successfully. There may or may not + * be additional data available on the request instance. + * + * For requests that obtain data, this data is typically made available through + * the `resultObj` property on the request instance. The API that was called + * will install its own response hander and ensure this property is decoded to + * what you expect. + * + * Conditional Requests + * -------------------- + * + * Many of the APIs on `StorageServiceClient` support conditional requests. + * That is, the client defines the last version of data it has (the version + * comes from a previous response from the server) and sends this as part of + * the request. + * + * For query requests, if the server hasn't changed, no new data will be + * returned. If issuing a conditional query request, the caller should check + * the `notModified` property on the request in the response callback. If this + * property is true, the server has no new data and there is obviously no data + * on the response. + * + * For example: + * + * let request = client.getCollectionInfo(); + * request.locallyModifiedVersion = Date.now() - 60000; + * request.dispatch(function onComplete(error, request) { + * if (error) { + * // Handle error. + * return; + * } + * + * if (request.notModified) { + * return; + * } + * + * let info = request.resultObj; + * // Do stuff. + * }); + * + * For modification requests, if the server has changed, the request will be + * rejected. When this happens, `error`will be defined and the `serverModified` + * property on it will be true. + * + * For example: + * + * let request = client.setBSO(bso); + * request.locallyModifiedVersion = bso.modified; + * request.dispatch(function onComplete(error, request) { + * if (error) { + * if (error.serverModified) { + * // Server data is newer! We should probably fetch it and apply + * // locally. + * } + * + * return; + * } + * + * // Handle success. + * }); + * + * Future Features + * --------------- + * + * The current implementation does not support true streaming for things like + * multi-BSO retrieval. However, the API supports it, so we should be able + * to implement it transparently. + */ +function StorageServiceRequest() { + this._log = Log.repository.getLogger("Sync.StorageService.Request"); + this._log.level = Log.Level[Prefs.get("log.level")]; + + this.notModified = false; + + this._client = null; + this._request = null; + this._method = null; + this._handler = {}; + this._data = null; + this._error = null; + this._resultObj = null; + this._locallyModifiedVersion = null; + this._allowIfModified = false; + this._allowIfUnmodified = false; +} +StorageServiceRequest.prototype = { + /** + * The StorageServiceClient this request came from. + */ + get client() { + return this._client; + }, + + /** + * The underlying RESTRequest instance. + * + * This should be treated as read only and should not be modified + * directly by external callers. While modification would probably work, this + * would defeat the purpose of the API and the abstractions it is meant to + * provide. + * + * If a consumer needs to modify the underlying request object, it is + * recommended for them to implement a new type that inherits from + * StorageServiceClient and override the necessary APIs to modify the request + * there. + * + * This accessor may disappear in future versions. + */ + get request() { + return this._request; + }, + + /** + * The RESTResponse that resulted from the RESTRequest. + */ + get response() { + return this._request.response; + }, + + /** + * HTTP status code from response. + */ + get statusCode() { + let response = this.response; + return response ? response.status : null; + }, + + /** + * Holds any error that has occurred. + * + * If a network error occurred, that will be returned. If no network error + * occurred, the client error will be returned. If no error occurred (yet), + * null will be returned. + */ + get error() { + return this._error; + }, + + /** + * The result from the request. + * + * This stores the object returned from the server. The type of object depends + * on the request type. See the per-API documentation in StorageServiceClient + * for details. + */ + get resultObj() { + return this._resultObj; + }, + + /** + * Define the local version of the entity the client has. + * + * This is used to enable conditional requests. Depending on the request + * type, the value set here could be reflected in the X-If-Modified-Since or + * X-If-Unmodified-Since headers. + * + * This attribute is not honoured on every request. See the documentation + * in the client API to learn where it is valid. + */ + set locallyModifiedVersion(value) { + // Will eventually become a header, so coerce to string. + this._locallyModifiedVersion = "" + value; + }, + + /** + * Object which holds callbacks and state for this request. + * + * The handler is installed by users of this request. It is simply an object + * containing 0 or more of the following properties: + * + * onComplete - A function called when the request has completed and all + * data has been received from the server. The function receives the + * following arguments: + * + * (StorageServiceRequestError) Error encountered during request. null + * if no error was encountered. + * (StorageServiceRequest) The request that was sent (this instance). + * Response information is available via properties and functions. + * + * Unless the call to dispatch() throws before returning, this callback + * is guaranteed to be invoked. + * + * Every client almost certainly wants to install this handler. + * + * onDispatch - A function called immediately before the request is + * dispatched. This hook can be used to inspect or modify the request + * before it is issued. + * + * The called function receives the following arguments: + * + * (StorageServiceRequest) The request being issued (this request). + * + * onBSORecord - When retrieving multiple BSOs from the server, this + * function is invoked when a new BSO record has been read. This function + * will be invoked 0 to N times before onComplete is invoked. onComplete + * signals that the last BSO has been processed or that an error + * occurred. The function receives the following arguments: + * + * (StorageServiceRequest) The request that was sent (this instance). + * (BasicStorageObject|string) The received BSO instance (when in full + * mode) or the string ID of the BSO (when not in full mode). + * + * Callers are free to (and encouraged) to store extra state in the supplied + * handler. + */ + set handler(value) { + if (typeof(value) != "object") { + throw new Error("Invalid handler. Must be an Object."); + } + + this._handler = value; + + if (!value.onComplete) { + this._log.warn("Handler does not contain an onComplete callback!"); + } + }, + + get handler() { + return this._handler; + }, + + //--------------- + // General APIs | + //--------------- + + /** + * Start the request. + * + * The request is dispatched asynchronously. The installed handler will have + * one or more of its callbacks invoked as the state of the request changes. + * + * The `onComplete` argument is optional. If provided, the supplied function + * will be installed on a *new* handler before the request is dispatched. This + * is equivalent to calling: + * + * request.handler = {onComplete: value}; + * request.dispatch(); + * + * Please note that any existing handler will be replaced if onComplete is + * provided. + * + * @param onComplete + * (function) Callback to be invoked when request has completed. + */ + dispatch: function dispatch(onComplete) { + if (onComplete) { + this.handler = {onComplete: onComplete}; + } + + // Installing the dummy callback makes implementation easier in _onComplete + // because we can then blindly call. + this._dispatch(function _internalOnComplete(error) { + this._onComplete(error); + this.completed = true; + }.bind(this)); + }, + + /** + * This is a synchronous version of dispatch(). + * + * THIS IS AN EVIL FUNCTION AND SHOULD NOT BE CALLED. It is provided for + * legacy reasons to support evil, synchronous clients. + * + * Please note that onComplete callbacks are executed from this JS thread. + * We dispatch the request, spin the event loop until it comes back. Then, + * we execute callbacks ourselves then return. In other words, there is no + * potential for spinning between callback execution and this function + * returning. + * + * The `onComplete` argument has the same behavior as for `dispatch()`. + * + * @param onComplete + * (function) Callback to be invoked when request has completed. + */ + dispatchSynchronous: function dispatchSynchronous(onComplete) { + if (onComplete) { + this.handler = {onComplete: onComplete}; + } + + let cb = Async.makeSyncCallback(); + this._dispatch(cb); + let error = Async.waitForSyncCallback(cb); + + this._onComplete(error); + this.completed = true; + }, + + //------------------------------------------------------------------------- + // HIDDEN APIS. DO NOT CHANGE ANYTHING UNDER HERE FROM OUTSIDE THIS TYPE. | + //------------------------------------------------------------------------- + + /** + * Data to include in HTTP request body. + */ + _data: null, + + /** + * StorageServiceRequestError encountered during dispatchy. + */ + _error: null, + + /** + * Handler to parse response body into another object. + * + * This is installed by the client API. It should return the value the body + * parses to on success. If a failure is encountered, an exception should be + * thrown. + */ + _completeParser: null, + + /** + * Dispatch the request. + * + * This contains common functionality for dispatching requests. It should + * ideally be part of dispatch, but since dispatchSynchronous exists, we + * factor out common code. + */ + _dispatch: function _dispatch(onComplete) { + // RESTRequest throws if the request has already been dispatched, so we + // need not bother checking. + + // Inject conditional headers into request if they are allowed and if a + // value is set. Note that _locallyModifiedVersion is always a string and + // if("0") is true. + if (this._allowIfModified && this._locallyModifiedVersion) { + this._log.trace("Making request conditional."); + this._request.setHeader("X-If-Modified-Since", + this._locallyModifiedVersion); + } else if (this._allowIfUnmodified && this._locallyModifiedVersion) { + this._log.trace("Making request conditional."); + this._request.setHeader("X-If-Unmodified-Since", + this._locallyModifiedVersion); + } + + // We have both an internal and public hook. + // If these throw, it is OK since we are not in a callback. + if (this._onDispatch) { + this._onDispatch(); + } + + if (this._handler.onDispatch) { + this._handler.onDispatch(this); + } + + this._client.runListeners("onDispatch", this); + + this._log.info("Dispatching request: " + this._method + " " + + this._request.uri.asciiSpec); + + this._request.dispatch(this._method, this._data, onComplete); + }, + + /** + * RESTRequest onComplete handler for all requests. + * + * This provides common logic for all response handling. + */ + _onComplete: function(error) { + let onCompleteCalled = false; + + let callOnComplete = function callOnComplete() { + onCompleteCalled = true; + + if (!this._handler.onComplete) { + this._log.warn("No onComplete installed in handler!"); + return; + } + + try { + this._handler.onComplete(this._error, this); + } catch (ex) { + this._log.warn("Exception when invoking handler's onComplete: " + + CommonUtils.exceptionStr(ex)); + throw ex; + } + }.bind(this); + + try { + if (error) { + this._error = new StorageServiceRequestError(); + this._error.network = error; + this._log.info("Network error during request: " + error); + this._client.runListeners("onNetworkError", this._client, this, error); + callOnComplete(); + return; + } + + let response = this._request.response; + this._log.info(response.status + " " + this._request.uri.asciiSpec); + + this._processHeaders(); + + if (response.status == 200) { + this._resultObj = this._completeParser(response); + callOnComplete(); + return; + } + + if (response.status == 201) { + callOnComplete(); + return; + } + + if (response.status == 204) { + callOnComplete(); + return; + } + + if (response.status == 304) { + this.notModified = true; + callOnComplete(); + return; + } + + // TODO handle numeric response code from server. + if (response.status == 400) { + this._error = new StorageServiceRequestError(); + this._error.client = new Error("Client error!"); + callOnComplete(); + return; + } + + if (response.status == 401) { + this._error = new StorageServiceRequestError(); + this._error.authentication = new Error("401 Received."); + this._client.runListeners("onAuthFailure", this._error.authentication, + this); + callOnComplete(); + return; + } + + if (response.status == 404) { + this._error = new StorageServiceRequestError(); + this._error.notFound = true; + callOnComplete(); + return; + } + + if (response.status == 409) { + this._error = new StorageServiceRequestError(); + this._error.conflict = true; + callOnComplete(); + return; + } + + if (response.status == 412) { + this._error = new StorageServiceRequestError(); + this._error.serverModified = true; + callOnComplete(); + return; + } + + if (response.status == 413) { + this._error = new StorageServiceRequestError(); + this._error.requestTooLarge = true; + callOnComplete(); + return; + } + + // If we see this, either the client or the server is buggy. We should + // never see this. + if (response.status == 415) { + this._log.error("415 HTTP response seen from server! This should " + + "never happen!"); + this._error = new StorageServiceRequestError(); + this._error.client = new Error("415 Unsupported Media Type received!"); + callOnComplete(); + return; + } + + if (response.status >= 500 && response.status <= 599) { + this._log.error(response.status + " seen from server!"); + this._error = new StorageServiceRequestError(); + this._error.server = new Error(response.status + " status code."); + callOnComplete(); + return; + } + + callOnComplete(); + + } catch (ex) { + this._clientError = ex; + this._log.info("Exception when processing _onComplete: " + ex); + + if (!onCompleteCalled) { + this._log.warn("Exception in internal response handling logic!"); + try { + callOnComplete(); + } catch (ex) { + this._log.warn("An additional exception was encountered when " + + "calling the handler's onComplete: " + ex); + } + } + } + }, + + _processHeaders: function _processHeaders() { + let headers = this._request.response.headers; + + if (headers["x-timestamp"]) { + this.serverTime = parseFloat(headers["x-timestamp"]); + } + + if (headers["x-backoff"]) { + this.backoffInterval = 1000 * parseInt(headers["x-backoff"], 10); + } + + if (headers["retry-after"]) { + this.backoffInterval = 1000 * parseInt(headers["retry-after"], 10); + } + + if (this.backoffInterval) { + let failure = this._request.response.status == 503; + this._client.runListeners("onBackoffReceived", this._client, this, + this.backoffInterval, !failure); + } + + if (headers["x-quota-remaining"]) { + this.quotaRemaining = parseInt(headers["x-quota-remaining"], 10); + this._client.runListeners("onQuotaRemaining", this._client, this, + this.quotaRemaining); + } + }, +}; + +/** + * Represents a request to fetch from a collection. + * + * These requests are highly configurable so they are given their own type. + * This type inherits from StorageServiceRequest and provides additional + * controllable parameters. + * + * By default, requests are issued in "streaming" mode. As the client receives + * data from the server, it will invoke the caller-supplied onBSORecord + * callback for each record as it is ready. When all records have been received, + * it will invoke onComplete as normal. To change this behavior, modify the + * "streaming" property before the request is dispatched. + */ +function StorageCollectionGetRequest() { + StorageServiceRequest.call(this); +} +StorageCollectionGetRequest.prototype = { + __proto__: StorageServiceRequest.prototype, + + _namedArgs: {}, + + _streaming: true, + + /** + * Control whether streaming mode is in effect. + * + * Read the type documentation above for more details. + */ + set streaming(value) { + this._streaming = !!value; + }, + + /** + * Define the set of IDs to fetch from the server. + */ + set ids(value) { + this._namedArgs.ids = value.join(","); + }, + + /** + * Only retrieve BSOs that were modified strictly before this time. + * + * Defined in milliseconds since UNIX epoch. + */ + set older(value) { + this._namedArgs.older = value; + }, + + /** + * Only retrieve BSOs that were modified strictly after this time. + * + * Defined in milliseconds since UNIX epoch. + */ + set newer(value) { + this._namedArgs.newer = value; + }, + + /** + * If set to a truthy value, return full BSO information. + * + * If not set (the default), the request will only return the set of BSO + * ids. + */ + set full(value) { + if (value) { + this._namedArgs.full = "1"; + } else { + delete this._namedArgs["full"]; + } + }, + + /** + * Limit the max number of returned BSOs to this integer number. + */ + set limit(value) { + this._namedArgs.limit = value; + }, + + /** + * If set with any value, sort the results based on modification time, oldest + * first. + */ + set sortOldest(value) { + this._namedArgs.sort = "oldest"; + }, + + /** + * If set with any value, sort the results based on modification time, newest + * first. + */ + set sortNewest(value) { + this._namedArgs.sort = "newest"; + }, + + /** + * If set with any value, sort the results based on sortindex value, highest + * first. + */ + set sortIndex(value) { + this._namedArgs.sort = "index"; + }, + + _onDispatch: function _onDispatch() { + let qs = this._getQueryString(); + if (!qs.length) { + return; + } + + this._request.uri = CommonUtils.makeURI(this._request.uri.asciiSpec + "?" + + qs); + }, + + _getQueryString: function _getQueryString() { + let args = []; + for (let [k, v] in Iterator(this._namedArgs)) { + args.push(encodeURIComponent(k) + "=" + encodeURIComponent(v)); + } + + return args.join("&"); + }, + + _completeParser: function _completeParser(response) { + let obj = JSON.parse(response.body); + let items = obj.items; + + if (!Array.isArray(items)) { + throw new Error("Unexpected JSON response. items is missing or not an " + + "array!"); + } + + if (!this.handler.onBSORecord) { + return; + } + + for (let bso of items) { + this.handler.onBSORecord(this, bso); + } + }, +}; + +/** + * Represents a request that sets data in a collection + * + * Instances of this type are returned by StorageServiceClient.setBSOs(). + */ +function StorageCollectionSetRequest() { + StorageServiceRequest.call(this); + + this.size = 0; + + // TODO Bug 775781 convert to Set and Map once iterable. + this.successfulIDs = []; + this.failures = {}; + + this._lines = []; +} +StorageCollectionSetRequest.prototype = { + __proto__: StorageServiceRequest.prototype, + + get count() { + return this._lines.length; + }, + + /** + * Add a BasicStorageObject to this request. + * + * Please note that the BSO content is retrieved when the BSO is added to + * the request. If the BSO changes after it is added to a request, those + * changes will not be reflected in the request. + * + * @param bso + * (BasicStorageObject) BSO to add to the request. + */ + addBSO: function addBSO(bso) { + if (!bso instanceof BasicStorageObject) { + throw new Error("argument must be a BasicStorageObject instance."); + } + + if (!bso.id) { + throw new Error("Passed BSO must have id defined."); + } + + this.addLine(JSON.stringify(bso)); + }, + + /** + * Add a BSO (represented by its serialized newline-delimited form). + * + * You probably shouldn't use this. It is used for batching. + */ + addLine: function addLine(line) { + // This is off by 1 in the larger direction. We don't care. + this.size += line.length + 1; + this._lines.push(line); + }, + + _onDispatch: function _onDispatch() { + this._data = this._lines.join("\n"); + this.size = this._data.length; + }, + + _completeParser: function _completeParser(response) { + let result = JSON.parse(response.body); + + for (let id of result.success) { + this.successfulIDs.push(id); + } + + this.allSucceeded = true; + + for (let [id, reasons] in Iterator(result.failed)) { + this.failures[id] = reasons; + this.allSucceeded = false; + } + }, +}; + +/** + * Represents a batch upload of BSOs to an individual collection. + * + * This is a more intelligent way to upload may BSOs to the server. It will + * split the uploaded data into multiple requests so size limits, etc aren't + * exceeded. + * + * Once a client obtains an instance of this type, it calls `addBSO` for each + * BSO to be uploaded. When the client is done providing BSOs to be uploaded, + * it calls `finish`. When `finish` is called, no more BSOs can be added to the + * batch. When all requests created from this batch have finished, the callback + * provided to `finish` will be invoked. + * + * Clients can also explicitly flush pending outgoing BSOs via `flush`. This + * allows callers to control their own batching/chunking. + * + * Interally, this maintains a queue of StorageCollectionSetRequest to be + * issued. At most one request is allowed to be in-flight at once. This is to + * avoid potential conflicts on the server. And, in the case of conditional + * requests, it prevents requests from being declined due to the server being + * updated by another request issued by us. + * + * If a request errors for any reason, all queued uploads are abandoned and the + * `finish` callback is invoked as soon as possible. The `successfulIDs` and + * `failures` properties will contain data from all requests that had this + * response data. In other words, the IDs have BSOs that were never sent to the + * server are not lumped in to either property. + * + * Requests can be made conditional by setting `locallyModifiedVersion` to the + * most recent version of server data. As responses from the server are seen, + * the last server version is carried forward to subsequent requests. + * + * The server version from the last request is available in the + * `serverModifiedVersion` property. It should only be accessed during or + * after the callback passed to `finish`. + * + * @param client + * (StorageServiceClient) Client instance to use for uploading. + * + * @param collection + * (string) Collection the batch operation will upload to. + */ +function StorageCollectionBatchedSet(client, collection) { + this.client = client; + this.collection = collection; + + this._log = client._log; + + this.locallyModifiedVersion = null; + this.serverModifiedVersion = null; + + // TODO Bug 775781 convert to Set and Map once iterable. + this.successfulIDs = []; + this.failures = {}; + + // Request currently being populated. + this._stagingRequest = client.setBSOs(this.collection); + + // Requests ready to be sent over the wire. + this._outgoingRequests = []; + + // Whether we are waiting for a response. + this._requestInFlight = false; + + this._onFinishCallback = null; + this._finished = false; + this._errorEncountered = false; +} +StorageCollectionBatchedSet.prototype = { + /** + * Add a BSO to be uploaded as part of this batch. + */ + addBSO: function addBSO(bso) { + if (this._errorEncountered) { + return; + } + + let line = JSON.stringify(bso); + + if (line.length > this.client.REQUEST_SIZE_LIMIT) { + throw new Error("BSO is larger than allowed limit: " + line.length + + " > " + this.client.REQUEST_SIZE_LIMIT); + } + + if (this._stagingRequest.size + line.length > this.client.REQUEST_SIZE_LIMIT) { + this._log.debug("Sending request because payload size would be exceeded"); + this._finishStagedRequest(); + + this._stagingRequest.addLine(line); + return; + } + + // We are guaranteed to fit within size limits. + this._stagingRequest.addLine(line); + + if (this._stagingRequest.count >= this.client.REQUEST_BSO_COUNT_LIMIT) { + this._log.debug("Sending request because BSO count threshold reached."); + this._finishStagedRequest(); + return; + } + }, + + finish: function finish(cb) { + if (this._finished) { + throw new Error("Batch request has already been finished."); + } + + this.flush(); + + this._onFinishCallback = cb; + this._finished = true; + this._stagingRequest = null; + }, + + flush: function flush() { + if (this._finished) { + throw new Error("Batch request has been finished."); + } + + if (!this._stagingRequest.count) { + return; + } + + this._finishStagedRequest(); + }, + + _finishStagedRequest: function _finishStagedRequest() { + this._outgoingRequests.push(this._stagingRequest); + this._sendOutgoingRequest(); + this._stagingRequest = this.client.setBSOs(this.collection); + }, + + _sendOutgoingRequest: function _sendOutgoingRequest() { + if (this._requestInFlight || this._errorEncountered) { + return; + } + + if (!this._outgoingRequests.length) { + return; + } + + let request = this._outgoingRequests.shift(); + + if (this.locallyModifiedVersion) { + request.locallyModifiedVersion = this.locallyModifiedVersion; + } + + request.dispatch(this._onBatchComplete.bind(this)); + this._requestInFlight = true; + }, + + _onBatchComplete: function _onBatchComplete(error, request) { + this._requestInFlight = false; + + this.serverModifiedVersion = request.serverTime; + + // Only update if we had a value before. Otherwise, this breaks + // unconditional requests! + if (this.locallyModifiedVersion) { + this.locallyModifiedVersion = request.serverTime; + } + + for (let id of request.successfulIDs) { + this.successfulIDs.push(id); + } + + for (let [id, reason] in Iterator(request.failures)) { + this.failures[id] = reason; + } + + if (request.error) { + this._errorEncountered = true; + } + + this._checkFinish(); + }, + + _checkFinish: function _checkFinish() { + if (this._outgoingRequests.length && !this._errorEncountered) { + this._sendOutgoingRequest(); + return; + } + + if (!this._onFinishCallback) { + return; + } + + try { + this._onFinishCallback(this); + } catch (ex) { + this._log.warn("Exception when calling finished callback: " + + CommonUtils.exceptionStr(ex)); + } + }, +}; +Object.freeze(StorageCollectionBatchedSet.prototype); + +/** + * Manages a batch of BSO deletion requests. + * + * A single instance of this virtual request allows deletion of many individual + * BSOs without having to worry about server limits. + * + * Instances are obtained by calling `deleteBSOsBatching` on + * StorageServiceClient. + * + * Usage is roughly the same as StorageCollectionBatchedSet. Callers obtain + * an instance and select individual BSOs for deletion by calling `addID`. + * When the caller is finished marking BSOs for deletion, they call `finish` + * with a callback which will be invoked when all deletion requests finish. + * + * When the finished callback is invoked, any encountered errors will be stored + * in the `errors` property of this instance (which is passed to the callback). + * This will be an empty array if no errors were encountered. Else, it will + * contain the errors from the `onComplete` handler of request instances. The + * set of succeeded and failed IDs is not currently available. + * + * Deletes can be made conditional by setting `locallyModifiedVersion`. The + * behavior is the same as request types. The only difference is that the + * updated version from the server as a result of requests is carried forward + * to subsequent requests. + * + * The server version from the last request is stored in the + * `serverModifiedVersion` property. It is not safe to access this until the + * callback from `finish`. + * + * Like StorageCollectionBatchedSet, requests are issued serially to avoid + * race conditions on the server. + * + * @param client + * (StorageServiceClient) Client request is associated with. + * @param collection + * (string) Collection being operated on. + */ +function StorageCollectionBatchedDelete(client, collection) { + this.client = client; + this.collection = collection; + + this._log = client._log; + + this.locallyModifiedVersion = null; + this.serverModifiedVersion = null; + this.errors = []; + + this._pendingIDs = []; + this._requestInFlight = false; + this._finished = false; + this._finishedCallback = null; +} +StorageCollectionBatchedDelete.prototype = { + addID: function addID(id) { + if (this._finished) { + throw new Error("Cannot add IDs to a finished instance."); + } + + // If we saw errors already, don't do any work. This is an optimization + // and isn't strictly required, as _sendRequest() should no-op. + if (this.errors.length) { + return; + } + + this._pendingIDs.push(id); + + if (this._pendingIDs.length >= this.client.REQUEST_BSO_DELETE_LIMIT) { + this._sendRequest(); + } + }, + + /** + * Finish this batch operation. + * + * No more IDs can be added to this operation. Existing IDs are flushed as + * a request. The passed callback will be called when all requests have + * finished. + */ + finish: function finish(cb) { + if (this._finished) { + throw new Error("Batch delete instance has already been finished."); + } + + this._finished = true; + this._finishedCallback = cb; + + if (this._pendingIDs.length) { + this._sendRequest(); + } + }, + + _sendRequest: function _sendRequest() { + // Only allow 1 active request at a time and don't send additional + // requests if one has failed. + if (this._requestInFlight || this.errors.length) { + return; + } + + let ids = this._pendingIDs.splice(0, this.client.REQUEST_BSO_DELETE_LIMIT); + let request = this.client.deleteBSOs(this.collection, ids); + + if (this.locallyModifiedVersion) { + request.locallyModifiedVersion = this.locallyModifiedVersion; + } + + request.dispatch(this._onRequestComplete.bind(this)); + this._requestInFlight = true; + }, + + _onRequestComplete: function _onRequestComplete(error, request) { + this._requestInFlight = false; + + if (error) { + // We don't currently track metadata of what failed. This is an obvious + // feature that could be added. + this._log.warn("Error received from server: " + error); + this.errors.push(error); + } + + this.serverModifiedVersion = request.serverTime; + + // If performing conditional requests, carry forward the new server version + // so subsequent conditional requests work. + if (this.locallyModifiedVersion) { + this.locallyModifiedVersion = request.serverTime; + } + + if (this._pendingIDs.length && !this.errors.length) { + this._sendRequest(); + return; + } + + if (!this._finishedCallback) { + return; + } + + try { + this._finishedCallback(this); + } catch (ex) { + this._log.warn("Exception when invoking finished callback: " + + CommonUtils.exceptionStr(ex)); + } + }, +}; +Object.freeze(StorageCollectionBatchedDelete.prototype); + +/** + * Construct a new client for the SyncStorage API, version 2.0. + * + * Clients are constructed against a base URI. This URI is typically obtained + * from the token server via the endpoint component of a successful token + * response. + * + * The purpose of this type is to serve as a middleware between a client's core + * logic and the HTTP API. It hides the details of how the storage API is + * implemented but exposes important events, such as when auth goes bad or the + * server requests the client to back off. + * + * All request APIs operate by returning a StorageServiceRequest instance. The + * caller then installs the appropriate callbacks on each instance and then + * dispatches the request. + * + * Each client instance also serves as a controller and coordinator for + * associated requests. Callers can install listeners for common events on the + * client and take the appropriate action whenever any associated request + * observes them. For example, you will only need to register one listener for + * backoff observation as opposed to one on each request. + * + * While not currently supported, a future goal of this type is to support + * more advanced transport channels - such as SPDY - to allow for faster and + * more efficient API calls. The API is thus designed to abstract transport + * specifics away from the caller. + * + * Storage API consumers almost certainly have added functionality on top of the + * storage service. It is encouraged to create a child type which adds + * functionality to this layer. + * + * @param baseURI + * (string) Base URI for all requests. + */ +this.StorageServiceClient = function StorageServiceClient(baseURI) { + this._log = Log.repository.getLogger("Services.Common.StorageServiceClient"); + this._log.level = Log.Level[Prefs.get("log.level")]; + + this._baseURI = baseURI; + + if (this._baseURI[this._baseURI.length-1] != "/") { + this._baseURI += "/"; + } + + this._log.info("Creating new StorageServiceClient under " + this._baseURI); + + this._listeners = []; +} +StorageServiceClient.prototype = { + /** + * The user agent sent with every request. + * + * You probably want to change this. + */ + userAgent: "StorageServiceClient", + + /** + * Maximum size of entity bodies. + * + * TODO this should come from the server somehow. See bug 769759. + */ + REQUEST_SIZE_LIMIT: 512000, + + /** + * Maximum number of BSOs in requests. + * + * TODO this should come from the server somehow. See bug 769759. + */ + REQUEST_BSO_COUNT_LIMIT: 100, + + /** + * Maximum number of BSOs that can be deleted in a single DELETE. + * + * TODO this should come from the server. See bug 769759. + */ + REQUEST_BSO_DELETE_LIMIT: 100, + + _baseURI: null, + _log: null, + + _listeners: null, + + //---------------------------- + // Event Listener Management | + //---------------------------- + + /** + * Adds a listener to this client instance. + * + * Listeners allow other parties to react to and influence execution of the + * client instance. + * + * An event listener is simply an object that exposes functions which get + * executed during client execution. Objects can expose 0 or more of the + * following keys: + * + * onDispatch - Callback notified immediately before a request is + * dispatched. This gets called for every outgoing request. The function + * receives as its arguments the client instance and the outgoing + * StorageServiceRequest. This listener is useful for global + * authentication handlers, which can modify the request before it is + * sent. + * + * onAuthFailure - This is called when any request has experienced an + * authentication failure. + * + * This callback receives the following arguments: + * + * (StorageServiceClient) Client that encountered the auth failure. + * (StorageServiceRequest) Request that encountered the auth failure. + * + * onBackoffReceived - This is called when a backoff request is issued by + * the server. Backoffs are issued either when the service is completely + * unavailable (and the client should abort all activity) or if the server + * is under heavy load (and has completed the current request but is + * asking clients to be kind and stop issuing requests for a while). + * + * This callback receives the following arguments: + * + * (StorageServiceClient) Client that encountered the backoff. + * (StorageServiceRequest) Request that received the backoff. + * (number) Integer milliseconds the server is requesting us to back off + * for. + * (bool) Whether the request completed successfully. If false, the + * client should cease sending additional requests immediately, as + * they will likely fail. If true, the client is allowed to continue + * to put the server in a proper state. But, it should stop and heed + * the backoff as soon as possible. + * + * onNetworkError - This is called for every network error that is + * encountered. + * + * This callback receives the following arguments: + * + * (StorageServiceClient) Client that encountered the network error. + * (StorageServiceRequest) Request that encountered the error. + * (Error) Error passed in to RESTRequest's onComplete handler. It has + * a result property, which is a Components.Results enumeration. + * + * onQuotaRemaining - This is called if any request sees updated quota + * information from the server. This provides an update mechanism so + * listeners can immediately find out quota changes as soon as they + * are made. + * + * This callback receives the following arguments: + * + * (StorageServiceClient) Client that encountered the quota change. + * (StorageServiceRequest) Request that received the quota change. + * (number) Integer number of kilobytes remaining for the user. + */ + addListener: function addListener(listener) { + if (!listener) { + throw new Error("listener argument must be an object."); + } + + if (this._listeners.indexOf(listener) != -1) { + return; + } + + this._listeners.push(listener); + }, + + /** + * Remove a previously-installed listener. + */ + removeListener: function removeListener(listener) { + this._listeners = this._listeners.filter(function(a) { + return a != listener; + }); + }, + + /** + * Invoke listeners for a specific event. + * + * @param name + * (string) The name of the listener to invoke. + * @param args + * (array) Arguments to pass to listener functions. + */ + runListeners: function runListeners(name, ...args) { + for (let listener of this._listeners) { + try { + if (name in listener) { + listener[name].apply(listener, args); + } + } catch (ex) { + this._log.warn("Listener threw an exception during " + name + ": " + + ex); + } + } + }, + + //----------------------------- + // Information/Metadata APIs | + //----------------------------- + + /** + * Obtain a request that fetches collection info. + * + * On successful response, the result is placed in the resultObj property + * of the request object. + * + * The result value is a map of strings to numbers. The string keys represent + * collection names. The number values are integer milliseconds since Unix + * epoch that hte collection was last modified. + * + * This request can be made conditional by defining `locallyModifiedVersion` + * on the returned object to the last known version on the client. + * + * Example Usage: + * + * let request = client.getCollectionInfo(); + * request.dispatch(function onComplete(error, request) { + * if (!error) { + * return; + * } + * + * for (let [collection, milliseconds] in Iterator(this.resultObj)) { + * // ... + * } + * }); + */ + getCollectionInfo: function getCollectionInfo() { + return this._getJSONGETRequest("info/collections"); + }, + + /** + * Fetch quota information. + * + * The result in the callback upon success is a map containing quota + * metadata. It will have the following keys: + * + * usage - Number of bytes currently utilized. + * quota - Number of bytes available to account. + * + * The request can be made conditional by populating `locallyModifiedVersion` + * on the returned request instance with the most recently known version of + * server data. + */ + getQuota: function getQuota() { + return this._getJSONGETRequest("info/quota"); + }, + + /** + * Fetch information on how much data each collection uses. + * + * The result on success is a map of strings to numbers. The string keys + * are collection names. The values are numbers corresponding to the number + * of kilobytes used by that collection. + */ + getCollectionUsage: function getCollectionUsage() { + return this._getJSONGETRequest("info/collection_usage"); + }, + + /** + * Fetch the number of records in each collection. + * + * The result on success is a map of strings to numbers. The string keys are + * collection names. The values are numbers corresponding to the integer + * number of items in that collection. + */ + getCollectionCounts: function getCollectionCounts() { + return this._getJSONGETRequest("info/collection_counts"); + }, + + //-------------------------- + // Collection Interaction | + // ------------------------- + + /** + * Obtain a request to fetch collection information. + * + * The returned request instance is a StorageCollectionGetRequest instance. + * This is a sub-type of StorageServiceRequest and offers a number of setters + * to control how the request is performed. See the documentation for that + * type for more. + * + * The request can be made conditional by setting `locallyModifiedVersion` + * on the returned request instance. + * + * Example usage: + * + * let request = client.getCollection("testcoll"); + * + * // Obtain full BSOs rather than just IDs. + * request.full = true; + * + * // Only obtain BSOs modified in the last minute. + * request.newer = Date.now() - 60000; + * + * // Install handler. + * request.handler = { + * onBSORecord: function onBSORecord(request, bso) { + * let id = bso.id; + * let payload = bso.payload; + * + * // Do something with BSO. + * }, + * + * onComplete: function onComplete(error, req) { + * if (error) { + * // Handle error. + * return; + * } + * + * // Your onBSORecord handler has processed everything. Now is where + * // you typically signal that everything has been processed and to move + * // on. + * } + * }; + * + * request.dispatch(); + * + * @param collection + * (string) Name of collection to operate on. + */ + getCollection: function getCollection(collection) { + if (!collection) { + throw new Error("collection argument must be defined."); + } + + let uri = this._baseURI + "storage/" + collection; + + let request = this._getRequest(uri, "GET", { + accept: "application/json", + allowIfModified: true, + requestType: StorageCollectionGetRequest + }); + + return request; + }, + + /** + * Fetch a single Basic Storage Object (BSO). + * + * On success, the BSO may be available in the resultObj property of the + * request as a BasicStorageObject instance. + * + * The request can be made conditional by setting `locallyModifiedVersion` + * on the returned request instance.* + * + * Example usage: + * + * let request = client.getBSO("meta", "global"); + * request.dispatch(function onComplete(error, request) { + * if (!error) { + * return; + * } + * + * if (request.notModified) { + * return; + * } + * + * let bso = request.bso; + * let payload = bso.payload; + * + * ... + * }; + * + * @param collection + * (string) Collection to fetch from + * @param id + * (string) ID of BSO to retrieve. + * @param type + * (constructor) Constructor to call to create returned object. This + * is optional and defaults to BasicStorageObject. + */ + getBSO: function fetchBSO(collection, id, type=BasicStorageObject) { + if (!collection) { + throw new Error("collection argument must be defined."); + } + + if (!id) { + throw new Error("id argument must be defined."); + } + + let uri = this._baseURI + "storage/" + collection + "/" + id; + + return this._getRequest(uri, "GET", { + accept: "application/json", + allowIfModified: true, + completeParser: function completeParser(response) { + let record = new type(id, collection); + record.deserialize(response.body); + + return record; + }, + }); + }, + + /** + * Add or update a BSO in a collection. + * + * To make the request conditional (i.e. don't allow server changes if the + * server has a newer version), set request.locallyModifiedVersion to the + * last known version of the BSO. While this could be done automatically by + * this API, it is intentionally omitted because there are valid conditions + * where a client may wish to forcefully update the server. + * + * If a conditional request fails because the server has newer data, the + * StorageServiceRequestError passed to the callback will have the + * `serverModified` property set to true. + * + * Example usage: + * + * let bso = new BasicStorageObject("foo", "coll"); + * bso.payload = "payload"; + * bso.modified = Date.now(); + * + * let request = client.setBSO(bso); + * request.locallyModifiedVersion = bso.modified; + * + * request.dispatch(function onComplete(error, req) { + * if (error) { + * if (error.serverModified) { + * // Handle conditional set failure. + * return; + * } + * + * // Handle other errors. + * return; + * } + * + * // Record that set worked. + * }); + * + * @param bso + * (BasicStorageObject) BSO to upload. The BSO instance must have the + * `collection` and `id` properties defined. + */ + setBSO: function setBSO(bso) { + if (!bso) { + throw new Error("bso argument must be defined."); + } + + if (!bso.collection) { + throw new Error("BSO instance does not have collection defined."); + } + + if (!bso.id) { + throw new Error("BSO instance does not have ID defined."); + } + + let uri = this._baseURI + "storage/" + bso.collection + "/" + bso.id; + let request = this._getRequest(uri, "PUT", { + contentType: "application/json", + allowIfUnmodified: true, + data: JSON.stringify(bso), + }); + + return request; + }, + + /** + * Add or update multiple BSOs. + * + * This is roughly equivalent to calling setBSO multiple times except it is + * much more effecient because there is only 1 round trip to the server. + * + * The request can be made conditional by setting `locallyModifiedVersion` + * on the returned request instance. + * + * This function returns a StorageCollectionSetRequest instance. This type + * has additional functions and properties specific to this operation. See + * its documentation for more. + * + * Most consumers interested in submitting multiple BSOs to the server will + * want to use `setBSOsBatching` instead. That API intelligently splits up + * requests as necessary, etc. + * + * Example usage: + * + * let request = client.setBSOs("collection0"); + * let bso0 = new BasicStorageObject("id0"); + * bso0.payload = "payload0"; + * + * let bso1 = new BasicStorageObject("id1"); + * bso1.payload = "payload1"; + * + * request.addBSO(bso0); + * request.addBSO(bso1); + * + * request.dispatch(function onComplete(error, req) { + * if (error) { + * // Handle error. + * return; + * } + * + * let successful = req.successfulIDs; + * let failed = req.failed; + * + * // Do additional processing. + * }); + * + * @param collection + * (string) Collection to operate on. + * @return + * (StorageCollectionSetRequest) Request instance. + */ + setBSOs: function setBSOs(collection) { + if (!collection) { + throw new Error("collection argument must be defined."); + } + + let uri = this._baseURI + "storage/" + collection; + let request = this._getRequest(uri, "POST", { + requestType: StorageCollectionSetRequest, + contentType: "application/newlines", + accept: "application/json", + allowIfUnmodified: true, + }); + + return request; + }, + + /** + * This is a batching variant of setBSOs. + * + * Whereas `setBSOs` is a 1:1 mapping between function calls and HTTP + * requests issued, this one is a 1:N mapping. It will intelligently break + * up outgoing BSOs into multiple requests so size limits, etc aren't + * exceeded. + * + * Please see the documentation for `StorageCollectionBatchedSet` for + * usage info. + * + * @param collection + * (string) Collection to operate on. + * @return + * (StorageCollectionBatchedSet) Batched set instance. + */ + setBSOsBatching: function setBSOsBatching(collection) { + if (!collection) { + throw new Error("collection argument must be defined."); + } + + return new StorageCollectionBatchedSet(this, collection); + }, + + /** + * Deletes a single BSO from a collection. + * + * The request can be made conditional by setting `locallyModifiedVersion` + * on the returned request instance. + * + * @param collection + * (string) Collection to operate on. + * @param id + * (string) ID of BSO to delete. + */ + deleteBSO: function deleteBSO(collection, id) { + if (!collection) { + throw new Error("collection argument must be defined."); + } + + if (!id) { + throw new Error("id argument must be defined."); + } + + let uri = this._baseURI + "storage/" + collection + "/" + id; + return this._getRequest(uri, "DELETE", { + allowIfUnmodified: true, + }); + }, + + /** + * Delete multiple BSOs from a specific collection. + * + * This is functional equivalent to calling deleteBSO() for every ID but + * much more efficient because it only results in 1 round trip to the server. + * + * The request can be made conditional by setting `locallyModifiedVersion` + * on the returned request instance. + * + * If the number of BSOs to delete is potentially large, it is preferred to + * use `deleteBSOsBatching`. That API automatically splits the operation into + * multiple requests so server limits aren't exceeded. + * + * @param collection + * (string) Name of collection to delete BSOs from. + * @param ids + * (iterable of strings) Set of BSO IDs to delete. + */ + deleteBSOs: function deleteBSOs(collection, ids) { + // In theory we should URL encode. However, IDs are supposed to be URL + // safe. If we get garbage in, we'll get garbage out and the server will + // reject it. + let s = ids.join(","); + + let uri = this._baseURI + "storage/" + collection + "?ids=" + s; + + return this._getRequest(uri, "DELETE", { + allowIfUnmodified: true, + }); + }, + + /** + * Bulk deletion of BSOs with no size limit. + * + * This allows a large amount of BSOs to be deleted easily. It will formulate + * multiple `deleteBSOs` queries so the client does not exceed server limits. + * + * @param collection + * (string) Name of collection to delete BSOs from. + * @return StorageCollectionBatchedDelete + */ + deleteBSOsBatching: function deleteBSOsBatching(collection) { + if (!collection) { + throw new Error("collection argument must be defined."); + } + + return new StorageCollectionBatchedDelete(this, collection); + }, + + /** + * Deletes a single collection from the server. + * + * The request can be made conditional by setting `locallyModifiedVersion` + * on the returned request instance. + * + * @param collection + * (string) Name of collection to delete. + */ + deleteCollection: function deleteCollection(collection) { + let uri = this._baseURI + "storage/" + collection; + + return this._getRequest(uri, "DELETE", { + allowIfUnmodified: true + }); + }, + + /** + * Deletes all collections data from the server. + */ + deleteCollections: function deleteCollections() { + let uri = this._baseURI + "storage"; + + return this._getRequest(uri, "DELETE", {}); + }, + + /** + * Helper that wraps _getRequest for GET requests that return JSON. + */ + _getJSONGETRequest: function _getJSONGETRequest(path) { + let uri = this._baseURI + path; + + return this._getRequest(uri, "GET", { + accept: "application/json", + allowIfModified: true, + completeParser: this._jsonResponseParser, + }); + }, + + /** + * Common logic for obtaining an HTTP request instance. + * + * @param uri + * (string) URI to request. + * @param method + * (string) HTTP method to issue. + * @param options + * (object) Additional options to control request and response + * handling. Keys influencing behavior are: + * + * completeParser - Function that parses a HTTP response body into a + * value. This function receives the RESTResponse object and + * returns a value that is added to a StorageResponse instance. + * If the response cannot be parsed or is invalid, this function + * should throw an exception. + * + * data - Data to be sent in HTTP request body. + * + * accept - Value for Accept request header. + * + * contentType - Value for Content-Type request header. + * + * requestType - Function constructor for request type to initialize. + * Defaults to StorageServiceRequest. + * + * allowIfModified - Whether to populate X-If-Modified-Since if the + * request contains a locallyModifiedVersion. + * + * allowIfUnmodified - Whether to populate X-If-Unmodified-Since if + * the request contains a locallyModifiedVersion. + */ + _getRequest: function _getRequest(uri, method, options) { + if (!options.requestType) { + options.requestType = StorageServiceRequest; + } + + let request = new RESTRequest(uri); + + if (Prefs.get("sendVersionInfo", true)) { + let ua = this.userAgent + Prefs.get("client.type", "desktop"); + request.setHeader("user-agent", ua); + } + + if (options.accept) { + request.setHeader("accept", options.accept); + } + + if (options.contentType) { + request.setHeader("content-type", options.contentType); + } + + let result = new options.requestType(); + result._request = request; + result._method = method; + result._client = this; + result._data = options.data; + + if (options.completeParser) { + result._completeParser = options.completeParser; + } + + result._allowIfModified = !!options.allowIfModified; + result._allowIfUnmodified = !!options.allowIfUnmodified; + + return result; + }, + + _jsonResponseParser: function _jsonResponseParser(response) { + let ct = response.headers["content-type"]; + if (!ct) { + throw new Error("No Content-Type response header! Misbehaving server!"); + } + + if (ct != "application/json" && ct.indexOf("application/json;") != 0) { + throw new Error("Non-JSON media type: " + ct); + } + + return JSON.parse(response.body); + }, +};