gecko/services/common/storageservice.js

2223 lines
68 KiB
JavaScript
Raw Normal View History

/* 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://services-common/log4moz.js");
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 = Log4Moz.repository.getLogger("Sync.StorageService.Request");
this._log.level = Log4Moz.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 = Log4Moz.repository.getLogger("Services.Common.StorageServiceClient");
this._log.level = Log4Moz.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);
},
};