UNPKG

kinto

Version:

An Offline-First JavaScript client for Kinto.

391 lines (352 loc) 13 kB
"use strict"; 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;