kinto
Version:
An Offline-First JavaScript client for Kinto.
391 lines (352 loc) • 13 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })();
exports.cleanRecord = cleanRecord;
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
var _utilsJs = require("./utils.js");
var _httpJs = require("./http.js");
var _httpJs2 = _interopRequireDefault(_httpJs);
var RECORD_FIELDS_TO_CLEAN = ["_status", "last_modified"];
/**
* Currently supported protocol version.
* @type {String}
*/
var SUPPORTED_PROTOCOL_VERSION = "v1";
exports.SUPPORTED_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSION;
/**
* Cleans a record object, excluding passed keys.
*
* @param {Object} record The record object.
* @param {Array} excludeFields The list of keys to exclude.
* @return {Object} A clean copy of source record object.
*/
function cleanRecord(record) {
var excludeFields = arguments.length <= 1 || arguments[1] === undefined ? RECORD_FIELDS_TO_CLEAN : arguments[1];
return Object.keys(record).reduce(function (acc, key) {
if (excludeFields.indexOf(key) === -1) {
acc[key] = record[key];
}
return acc;
}, {});
}
/**
* High level HTTP client for the Kinto API.
*/
var Api = (function () {
/**
* Constructor.
*
* Options:
* - {Object} headers The key-value headers to pass to each request.
* - {String} events The HTTP request mode.
*
* @param {String} remote The remote URL.
* @param {EventEmitter} events The events handler
* @param {Object} options The options object.
*/
function Api(remote, events) {
var options = arguments.length <= 2 || arguments[2] === undefined ? {} : arguments[2];
_classCallCheck(this, Api);
if (typeof remote !== "string" || !remote.length) {
throw new Error("Invalid remote URL: " + remote);
}
if (remote[remote.length - 1] === "/") {
remote = remote.slice(0, -1);
}
this._backoffReleaseTime = null;
// public properties
/**
* The remote endpoint base URL.
* @type {String}
*/
this.remote = remote;
/**
* The optional generic headers.
* @type {Object}
*/
this.optionHeaders = options.headers || {};
/**
* Current server settings, retrieved from the server.
* @type {Object}
*/
this.serverSettings = null;
/**
* The even emitter instance.
* @type {EventEmitter}
*/
if (!events) {
throw new Error("No events handler provided");
}
this.events = events;
try {
/**
* The current server protocol version, eg. `v1`.
* @type {String}
*/
this.version = remote.match(/\/(v\d+)\/?$/)[1];
} catch (err) {
throw new Error("The remote URL must contain the version: " + remote);
}
if (this.version !== SUPPORTED_PROTOCOL_VERSION) {
throw new Error("Unsupported protocol version: " + this.version);
}
/**
* The HTTP instance.
* @type {HTTP}
*/
this.http = new _httpJs2["default"](this.events, { requestMode: options.requestMode });
this._registerHTTPEvents();
}
/**
* Backoff remaining time, in milliseconds. Defaults to zero if no backoff is
* ongoing.
*
* @return {Number}
*/
_createClass(Api, [{
key: "_registerHTTPEvents",
/**
* Registers HTTP events.
*/
value: function _registerHTTPEvents() {
var _this = this;
this.events.on("backoff", function (backoffMs) {
_this._backoffReleaseTime = backoffMs;
});
}
/**
* Retrieves available server enpoints.
*
* Options:
* - {Boolean} fullUrl: Retrieve a fully qualified URL (default: true).
*
* @param {Object} options Options object.
* @return {String}
*/
}, {
key: "endpoints",
value: function endpoints() {
var options = arguments.length <= 0 || arguments[0] === undefined ? { fullUrl: true } : arguments[0];
var _root = options.fullUrl ? this.remote : "/" + this.version;
var urls = {
root: function root() {
return _root + "/";
},
batch: function batch() {
return _root + "/batch";
},
bucket: function bucket(_bucket) {
return _root + "/buckets/" + _bucket;
},
collection: function collection(bucket, coll) {
return urls.bucket(bucket) + "/collections/" + coll;
},
records: function records(bucket, coll) {
return urls.collection(bucket, coll) + "/records";
},
record: function record(bucket, coll, id) {
return urls.records(bucket, coll) + "/" + id;
}
};
return urls;
}
/**
* Retrieves Kinto server settings.
*
* @return {Promise}
*/
}, {
key: "fetchServerSettings",
value: function fetchServerSettings() {
var _this2 = this;
if (this.serverSettings) {
return Promise.resolve(this.serverSettings);
}
return this.http.request(this.endpoints().root()).then(function (res) {
_this2.serverSettings = res.json.settings;
return _this2.serverSettings;
});
}
/**
* Fetches latest changes from the remote server.
*
* @param {String} bucketName The bucket name.
* @param {String} collName The collection name.
* @param {Object} options The options object.
* @return {Promise}
*/
}, {
key: "fetchChangesSince",
value: function fetchChangesSince(bucketName, collName) {
var _this3 = this;
var options = arguments.length <= 2 || arguments[2] === undefined ? { lastModified: null, headers: {} } : arguments[2];
var recordsUrl = this.endpoints().records(bucketName, collName);
var queryString = "";
var headers = Object.assign({}, this.optionHeaders, options.headers);
if (options.lastModified) {
queryString = "?_since=" + options.lastModified;
headers["If-None-Match"] = (0, _utilsJs.quote)(options.lastModified);
}
return this.fetchServerSettings().then(function (_) {
return _this3.http.request(recordsUrl + queryString, { headers: headers });
}).then(function (res) {
// If HTTP 304, nothing has changed
if (res.status === 304) {
return {
lastModified: options.lastModified,
changes: []
};
}
// XXX: ETag are supposed to be opaque and stored «as-is».
// Extract response data
var etag = res.headers.get("ETag"); // e.g. '"42"'
etag = etag ? parseInt((0, _utilsJs.unquote)(etag), 10) : options.lastModified;
var records = res.json.data;
// Check if server was flushed
var localSynced = options.lastModified;
var serverChanged = etag > options.lastModified;
var emptyCollection = records ? records.length === 0 : true;
if (localSynced && serverChanged && emptyCollection) {
throw Error("Server has been flushed.");
}
return { lastModified: etag, changes: records };
});
}
/**
* Builds an individual record batch request body.
*
* @param {Object} record The record object.
* @param {String} path The record endpoint URL.
* @param {Boolean} safe Safe update?
* @return {Object} The request body object.
*/
}, {
key: "_buildRecordBatchRequest",
value: function _buildRecordBatchRequest(record, path, safe) {
var isDeletion = record._status === "deleted";
var method = isDeletion ? "DELETE" : "PUT";
var body = isDeletion ? undefined : { data: cleanRecord(record) };
var headers = {};
if (safe) {
if (record.last_modified) {
// Safe replace.
headers["If-Match"] = (0, _utilsJs.quote)(record.last_modified);
} else if (!isDeletion) {
// Safe creation.
headers["If-None-Match"] = "*";
}
}
return { method: method, headers: headers, path: path, body: body };
}
/**
* Process a batch request response.
*
* @param {Object} results The results object.
* @param {Array} records The initial records list.
* @param {Object} response The response HTTP object.
* @return {Promise}
*/
}, {
key: "_processBatchResponses",
value: function _processBatchResponses(results, records, response) {
// Handle individual batch subrequests responses
response.json.responses.forEach(function (response, index) {
// TODO: handle 409 when unicity rule is violated (ex. POST with
// existing id, unique field, etc.)
if (response.status && response.status >= 200 && response.status < 400) {
results.published.push(response.body.data);
} else if (response.status === 404) {
results.skipped.push(records[index]);
} else if (response.status === 412) {
results.conflicts.push({
type: "outgoing",
local: records[index],
remote: response.body.details && response.body.details.existing || null
});
} else {
results.errors.push({
path: response.path,
sent: records[index],
error: response.body
});
}
});
return results;
}
/**
* Sends batch update requests to the remote server.
*
* Options:
* - {Object} headers Headers to attach to main and all subrequests.
* - {Boolean} safe Safe update (default: `true`)
*
* @param {String} bucketName The bucket name.
* @param {String} collName The collection name.
* @param {Array} records The list of record updates to send.
* @param {Object} options The options object.
* @return {Promise}
*/
}, {
key: "batch",
value: function batch(bucketName, collName, records) {
var _this4 = this;
var options = arguments.length <= 3 || arguments[3] === undefined ? { headers: {} } : arguments[3];
var safe = options.safe || true;
var headers = Object.assign({}, this.optionHeaders, options.headers);
var results = {
errors: [],
published: [],
conflicts: [],
skipped: []
};
if (!records.length) {
return Promise.resolve(results);
}
return this.fetchServerSettings().then(function (serverSettings) {
// Kinto 1.6.1 possibly exposes multiple setting prefixes
var maxRequests = serverSettings["batch_max_requests"] || serverSettings["cliquet.batch_max_requests"];
if (maxRequests && records.length > maxRequests) {
return Promise.all((0, _utilsJs.partition)(records, maxRequests).map(function (chunk) {
return _this4.batch(bucketName, collName, chunk, options);
})).then(function (batchResults) {
// Assemble responses of chunked batch results into one single
// result object
return batchResults.reduce(function (acc, batchResult) {
Object.keys(batchResult).forEach(function (key) {
acc[key] = results[key].concat(batchResult[key]);
});
return acc;
}, results);
});
}
return _this4.http.request(_this4.endpoints().batch(), {
method: "POST",
headers: headers,
body: JSON.stringify({
defaults: { headers: headers },
requests: records.map(function (record) {
var path = _this4.endpoints({ full: false }).record(bucketName, collName, record.id);
return _this4._buildRecordBatchRequest(record, path, safe);
})
})
}).then(function (res) {
return _this4._processBatchResponses(results, records, res);
});
});
}
}, {
key: "backoff",
get: function get() {
var currentTime = new Date().getTime();
if (this._backoffReleaseTime && currentTime < this._backoffReleaseTime) {
return this._backoffReleaseTime - currentTime;
}
return 0;
}
}]);
return Api;
})();
exports["default"] = Api;