/* * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ "use strict"; /* * This file is generated from kinto-http.js - do not modify directly. */ const global = this; var EXPORTED_SYMBOLS = ["KintoHttpClient"]; const { setTimeout, clearTimeout } = ChromeUtils.import("resource://gre/modules/Timer.jsm"); const { XPCOMUtils } = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyGlobalGetters(global, ["fetch"]); /* * Version 5.1.1 - 30c540a */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = global || self, global.KintoHttpClient = factory()); }(this, (function () { 'use strict'; /*! ***************************************************************************** Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABLITY OR NON-INFRINGEMENT. See the Apache Version 2.0 License for specific language governing permissions and limitations under the License. ***************************************************************************** */ function __decorate(decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; } /** * Chunks an array into n pieces. * * @private * @param {Array} array * @param {Number} n * @return {Array} */ function partition(array, n) { if (n <= 0) { return [array]; } return array.reduce((acc, x, i) => { if (i === 0 || i % n === 0) { acc.push([x]); } else { acc[acc.length - 1].push(x); } return acc; }, []); } /** * Returns a Promise always resolving after the specified amount in milliseconds. * * @return Promise */ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Always returns a resource data object from the provided argument. * * @private * @param {Object|String} resource * @return {Object} */ function toDataBody(resource) { if (isObject(resource)) { return resource; } if (typeof resource === "string") { return { id: resource }; } throw new Error("Invalid argument."); } /** * Transforms an object into an URL query string, stripping out any undefined * values. * * @param {Object} obj * @return {String} */ function qsify(obj) { const encode = (v) => encodeURIComponent(typeof v === "boolean" ? String(v) : v); const stripped = cleanUndefinedProperties(obj); return Object.keys(stripped) .map((k) => { const ks = encode(k) + "="; if (Array.isArray(stripped[k])) { return ks + stripped[k].map((v) => encode(v)).join(","); } else { return ks + encode(stripped[k]); } }) .join("&"); } /** * Checks if a version is within the provided range. * * @param {String} version The version to check. * @param {String} minVersion The minimum supported version (inclusive). * @param {String} maxVersion The minimum supported version (exclusive). * @throws {Error} If the version is outside of the provided range. */ function checkVersion(version, minVersion, maxVersion) { const extract = (str) => str.split(".").map((x) => parseInt(x, 10)); const [verMajor, verMinor] = extract(version); const [minMajor, minMinor] = extract(minVersion); const [maxMajor, maxMinor] = extract(maxVersion); const checks = [ verMajor < minMajor, verMajor === minMajor && verMinor < minMinor, verMajor > maxMajor, verMajor === maxMajor && verMinor >= maxMinor, ]; if (checks.some((x) => x)) { throw new Error(`Version ${version} doesn't satisfy ${minVersion} <= x < ${maxVersion}`); } } /** * Generates a decorator function ensuring a version check is performed against * the provided requirements before executing it. * * @param {String} min The required min version (inclusive). * @param {String} max The required max version (inclusive). * @return {Function} */ function support(min, max) { return function ( // @ts-ignore target, key, descriptor) { const fn = descriptor.value; return { configurable: true, get() { const wrappedMethod = (...args) => { // "this" is the current instance which its method is decorated. const client = this.client ? this.client : this; return client .fetchHTTPApiVersion() .then((version) => checkVersion(version, min, max)) .then(() => fn.apply(this, args)); }; Object.defineProperty(this, key, { value: wrappedMethod, configurable: true, writable: true, }); return wrappedMethod; }, }; }; } /** * Generates a decorator function ensuring that the specified capabilities are * available on the server before executing it. * * @param {Array} capabilities The required capabilities. * @return {Function} */ function capable(capabilities) { return function ( // @ts-ignore target, key, descriptor) { const fn = descriptor.value; return { configurable: true, get() { const wrappedMethod = (...args) => { // "this" is the current instance which its method is decorated. const client = this.client ? this.client : this; return client .fetchServerCapabilities() .then((available) => { const missing = capabilities.filter((c) => !(c in available)); if (missing.length > 0) { const missingStr = missing.join(", "); throw new Error(`Required capabilities ${missingStr} not present on server`); } }) .then(() => fn.apply(this, args)); }; Object.defineProperty(this, key, { value: wrappedMethod, configurable: true, writable: true, }); return wrappedMethod; }, }; }; } /** * Generates a decorator function ensuring an operation is not performed from * within a batch request. * * @param {String} message The error message to throw. * @return {Function} */ function nobatch(message) { return function ( // @ts-ignore target, key, descriptor) { const fn = descriptor.value; return { configurable: true, get() { const wrappedMethod = (...args) => { // "this" is the current instance which its method is decorated. if (this._isBatch) { throw new Error(message); } return fn.apply(this, args); }; Object.defineProperty(this, key, { value: wrappedMethod, configurable: true, writable: true, }); return wrappedMethod; }, }; }; } /** * Returns true if the specified value is an object (i.e. not an array nor null). * @param {Object} thing The value to inspect. * @return {bool} */ function isObject(thing) { return typeof thing === "object" && thing !== null && !Array.isArray(thing); } /** * Parses a data url. * @param {String} dataURL The data url. * @return {Object} */ function parseDataURL(dataURL) { const regex = /^data:(.*);base64,(.*)/; const match = dataURL.match(regex); if (!match) { throw new Error(`Invalid data-url: ${String(dataURL).substr(0, 32)}...`); } const props = match[1]; const base64 = match[2]; const [type, ...rawParams] = props.split(";"); const params = rawParams.reduce((acc, param) => { const [key, value] = param.split("="); return Object.assign(Object.assign({}, acc), { [key]: value }); }, {}); return Object.assign(Object.assign({}, params), { type, base64 }); } /** * Extracts file information from a data url. * @param {String} dataURL The data url. * @return {Object} */ function extractFileInfo(dataURL) { const { name, type, base64 } = parseDataURL(dataURL); const binary = atob(base64); const array = []; for (let i = 0; i < binary.length; i++) { array.push(binary.charCodeAt(i)); } const blob = new Blob([new Uint8Array(array)], { type }); return { blob, name }; } /** * Creates a FormData instance from a data url and an existing JSON response * body. * @param {String} dataURL The data url. * @param {Object} body The response body. * @param {Object} [options={}] The options object. * @param {Object} [options.filename] Force attachment file name. * @return {FormData} */ function createFormData(dataURL, body, options = {}) { const { filename = "untitled" } = options; const { blob, name } = extractFileInfo(dataURL); const formData = new FormData(); formData.append("attachment", blob, name || filename); for (const property in body) { if (typeof body[property] !== "undefined") { formData.append(property, JSON.stringify(body[property])); } } return formData; } /** * Clones an object with all its undefined keys removed. * @private */ function cleanUndefinedProperties(obj) { const result = {}; for (const key in obj) { if (typeof obj[key] !== "undefined") { result[key] = obj[key]; } } return result; } /** * Handle common query parameters for Kinto requests. * * @param {String} [path] The endpoint base path. * @param {Array} [options.fields] Fields to limit the * request to. * @param {Object} [options.query={}] Additional query arguments. */ function addEndpointOptions(path, options = {}) { const query = Object.assign({}, options.query); if (options.fields) { query._fields = options.fields; } const queryString = qsify(query); if (queryString) { return path + "?" + queryString; } return path; } /** * Replace authorization header with an obscured version */ function obscureAuthorizationHeader(headers) { const h = new Headers(headers); if (h.has("authorization")) { h.set("authorization", "**** (suppressed)"); } const obscuredHeaders = {}; for (const [header, value] of h.entries()) { obscuredHeaders[header] = value; } return obscuredHeaders; } /** * Kinto server error code descriptors. */ const ERROR_CODES = { 104: "Missing Authorization Token", 105: "Invalid Authorization Token", 106: "Request body was not valid JSON", 107: "Invalid request parameter", 108: "Missing request parameter", 109: "Invalid posted data", 110: "Invalid Token / id", 111: "Missing Token / id", 112: "Content-Length header was not provided", 113: "Request body too large", 114: "Resource was created, updated or deleted meanwhile", 115: "Method not allowed on this end point (hint: server may be readonly)", 116: "Requested version not available on this server", 117: "Client has sent too many requests", 121: "Resource access is forbidden for this user", 122: "Another resource violates constraint", 201: "Service Temporary unavailable due to high load", 202: "Service deprecated", 999: "Internal Server Error", }; class NetworkTimeoutError extends Error { constructor(url, options) { super(`Timeout while trying to access ${url} with ${JSON.stringify(options)}`); if (Error.captureStackTrace) { Error.captureStackTrace(this, NetworkTimeoutError); } this.url = url; this.options = options; } } class UnparseableResponseError extends Error { constructor(response, body, error) { const { status } = response; super(`Response from server unparseable (HTTP ${status || 0}; ${error}): ${body}`); if (Error.captureStackTrace) { Error.captureStackTrace(this, UnparseableResponseError); } this.status = status; this.response = response; this.stack = error.stack; this.error = error; } } /** * "Error" subclass representing a >=400 response from the server. * * Whether or not this is an error depends on your application. * * The `json` field can be undefined if the server responded with an * empty response body. This shouldn't generally happen. Most "bad" * responses come with a JSON error description, or (if they're * fronted by a CDN or nginx or something) occasionally non-JSON * responses (which become UnparseableResponseErrors, above). */ class ServerResponse extends Error { constructor(response, json) { const { status } = response; let { statusText } = response; let errnoMsg; if (json) { // Try to fill in information from the JSON error. statusText = json.error || statusText; // Take errnoMsg from either ERROR_CODES or json.message. if (json.errno && json.errno in ERROR_CODES) { errnoMsg = ERROR_CODES[json.errno]; } else if (json.message) { errnoMsg = json.message; } // If we had both ERROR_CODES and json.message, and they differ, // combine them. if (errnoMsg && json.message && json.message !== errnoMsg) { errnoMsg += ` (${json.message})`; } } let message = `HTTP ${status} ${statusText}`; if (errnoMsg) { message += `: ${errnoMsg}`; } super(message.trim()); if (Error.captureStackTrace) { Error.captureStackTrace(this, ServerResponse); } this.response = response; this.data = json; } } var errors = /*#__PURE__*/Object.freeze({ __proto__: null, 'default': ERROR_CODES, NetworkTimeoutError: NetworkTimeoutError, ServerResponse: ServerResponse, UnparseableResponseError: UnparseableResponseError }); /** * Enhanced HTTP client for the Kinto protocol. * @private */ class HTTP { /** * Constructor. * * @param {EventEmitter} events The event handler. * @param {Object} [options={}} The options object. * @param {Number} [options.timeout=null] The request timeout in ms, if any (default: `null`). * @param {String} [options.requestMode="cors"] The HTTP request mode (default: `"cors"`). */ constructor(events, options = {}) { // public properties /** * The event emitter instance. * @type {EventEmitter} */ this.events = events; /** * The request mode. * @see https://fetch.spec.whatwg.org/#requestmode * @type {String} */ this.requestMode = options.requestMode || HTTP.defaultOptions.requestMode; /** * The request timeout. * @type {Number} */ this.timeout = options.timeout || HTTP.defaultOptions.timeout; } /** * Default HTTP request headers applied to each outgoing request. * * @type {Object} */ static get DEFAULT_REQUEST_HEADERS() { return { Accept: "application/json", "Content-Type": "application/json", }; } /** * Default options. * * @type {Object} */ static get defaultOptions() { return { timeout: null, requestMode: "cors" }; } /** * @private */ timedFetch(url, options) { let hasTimedout = false; return new Promise((resolve, reject) => { // Detect if a request has timed out. let _timeoutId; if (this.timeout) { _timeoutId = setTimeout(() => { hasTimedout = true; if (options && options.headers) { options = Object.assign(Object.assign({}, options), { headers: obscureAuthorizationHeader(options.headers) }); } reject(new NetworkTimeoutError(url, options)); }, this.timeout); } function proceedWithHandler(fn) { return (arg) => { if (!hasTimedout) { if (_timeoutId) { clearTimeout(_timeoutId); } fn(arg); } }; } fetch(url, options) .then(proceedWithHandler(resolve)) .catch(proceedWithHandler(reject)); }); } /** * @private */ async processResponse(response) { const { status, headers } = response; const text = await response.text(); // Check if we have a body; if so parse it as JSON. let json; if (text.length !== 0) { try { json = JSON.parse(text); } catch (err) { throw new UnparseableResponseError(response, text, err); } } if (status >= 400) { throw new ServerResponse(response, json); } return { status, json: json, headers }; } /** * @private */ async retry(url, retryAfter, request, options) { await delay(retryAfter); return this.request(url, request, Object.assign(Object.assign({}, options), { retry: options.retry - 1 })); } /** * Performs an HTTP request to the Kinto server. * * Resolves with an objet containing the following HTTP response properties: * - `{Number} status` The HTTP status code. * - `{Object} json` The JSON response body. * - `{Headers} headers` The response headers object; see the ES6 fetch() spec. * * @param {String} url The URL. * @param {Object} [request={}] The request object, passed to * fetch() as its options object. * @param {Object} [request.headers] The request headers object (default: {}) * @param {Object} [options={}] Options for making the * request * @param {Number} [options.retry] Number of retries (default: 0) * @return {Promise} */ async request(url, request = { headers: {} }, options = { retry: 0 }) { // Ensure default request headers are always set request.headers = Object.assign(Object.assign({}, HTTP.DEFAULT_REQUEST_HEADERS), request.headers); // If a multipart body is provided, remove any custom Content-Type header as // the fetch() implementation will add the correct one for us. if (request.body && request.body instanceof FormData) { if (request.headers instanceof Headers) { request.headers.delete("Content-Type"); } else if (!Array.isArray(request.headers)) { delete request.headers["Content-Type"]; } } request.mode = this.requestMode; const response = await this.timedFetch(url, request); const { headers } = response; this._checkForDeprecationHeader(headers); this._checkForBackoffHeader(headers); // Check if the server summons the client to retry after a while. const retryAfter = this._checkForRetryAfterHeader(headers); // If number of allowed of retries is not exhausted, retry the same request. if (retryAfter && options.retry > 0) { return this.retry(url, retryAfter, request, options); } else { return this.processResponse(response); } } _checkForDeprecationHeader(headers) { const alertHeader = headers.get("Alert"); if (!alertHeader) { return; } let alert; try { alert = JSON.parse(alertHeader); } catch (err) { console.warn("Unable to parse Alert header message", alertHeader); return; } console.warn(alert.message, alert.url); if (this.events) { this.events.emit("deprecated", alert); } } _checkForBackoffHeader(headers) { let backoffMs; const backoffHeader = headers.get("Backoff"); const backoffSeconds = backoffHeader ? parseInt(backoffHeader, 10) : 0; if (backoffSeconds > 0) { backoffMs = new Date().getTime() + backoffSeconds * 1000; } else { backoffMs = 0; } if (this.events) { this.events.emit("backoff", backoffMs); } } _checkForRetryAfterHeader(headers) { const retryAfter = headers.get("Retry-After"); if (!retryAfter) { return; } const delay = parseInt(retryAfter, 10) * 1000; const tryAgainAfter = new Date().getTime() + delay; if (this.events) { this.events.emit("retry-after", tryAgainAfter); } return delay; } } /** * Endpoints templates. * @type {Object} */ const ENDPOINTS = { root: () => "/", batch: () => "/batch", permissions: () => "/permissions", bucket: (bucket) => "/buckets" + (bucket ? `/${bucket}` : ""), history: (bucket) => `${ENDPOINTS.bucket(bucket)}/history`, collection: (bucket, coll) => `${ENDPOINTS.bucket(bucket)}/collections` + (coll ? `/${coll}` : ""), group: (bucket, group) => `${ENDPOINTS.bucket(bucket)}/groups` + (group ? `/${group}` : ""), record: (bucket, coll, id) => `${ENDPOINTS.collection(bucket, coll)}/records` + (id ? `/${id}` : ""), attachment: (bucket, coll, id) => `${ENDPOINTS.record(bucket, coll, id)}/attachment`, }; const requestDefaults = { safe: false, // check if we should set default content type here headers: {}, patch: false, }; /** * @private */ function safeHeader(safe, last_modified) { if (!safe) { return {}; } if (last_modified) { return { "If-Match": `"${last_modified}"` }; } return { "If-None-Match": "*" }; } /** * @private */ function createRequest(path, { data, permissions }, options = {}) { const { headers, safe } = Object.assign(Object.assign({}, requestDefaults), options); const method = options.method || (data && data.id) ? "PUT" : "POST"; return { method, path, headers: Object.assign(Object.assign({}, headers), safeHeader(safe)), body: { data, permissions }, }; } /** * @private */ function updateRequest(path, { data, permissions }, options = {}) { const { headers, safe, patch } = Object.assign(Object.assign({}, requestDefaults), options); const { last_modified } = Object.assign(Object.assign({}, data), options); const hasNoData = data && Object.keys(data).filter((k) => k !== "id" && k !== "last_modified") .length === 0; if (hasNoData) { data = undefined; } return { method: patch ? "PATCH" : "PUT", path, headers: Object.assign(Object.assign({}, headers), safeHeader(safe, last_modified)), body: { data, permissions }, }; } /** * @private */ function jsonPatchPermissionsRequest(path, permissions, opType, options = {}) { const { headers, safe, last_modified } = Object.assign(Object.assign({}, requestDefaults), options); const ops = []; for (const [type, principals] of Object.entries(permissions)) { if (principals) { for (const principal of principals) { ops.push({ op: opType, path: `/permissions/${type}/${principal}`, }); } } } return { method: "PATCH", path, headers: Object.assign(Object.assign(Object.assign({}, headers), safeHeader(safe, last_modified)), { "Content-Type": "application/json-patch+json" }), body: ops, }; } /** * @private */ function deleteRequest(path, options = {}) { const { headers, safe, last_modified } = Object.assign(Object.assign({}, requestDefaults), options); if (safe && !last_modified) { throw new Error("Safe concurrency check requires a last_modified value."); } return { method: "DELETE", path, headers: Object.assign(Object.assign({}, headers), safeHeader(safe, last_modified)), }; } /** * @private */ function addAttachmentRequest(path, dataURI, { data, permissions } = {}, options = {}) { const { headers, safe, gzipped } = Object.assign(Object.assign({}, requestDefaults), options); const { last_modified } = Object.assign(Object.assign({}, data), options); const body = { data, permissions }; const formData = createFormData(dataURI, body, options); const customPath = `${path}${gzipped !== null ? "?gzipped=" + (gzipped ? "true" : "false") : ""}`; return { method: "POST", path: customPath, headers: Object.assign(Object.assign({}, headers), safeHeader(safe, last_modified)), body: formData, }; } /** * Exports batch responses as a result object. * * @private * @param {Array} responses The batch subrequest responses. * @param {Array} requests The initial issued requests. * @return {Object} */ function aggregate(responses = [], requests = []) { if (responses.length !== requests.length) { throw new Error("Responses length should match requests one."); } const results = { errors: [], published: [], conflicts: [], skipped: [], }; return responses.reduce((acc, response, index) => { const { status } = response; const request = requests[index]; if (status >= 200 && status < 400) { acc.published.push(response.body); } else if (status === 404) { // Extract the id manually from request path while waiting for Kinto/kinto#818 const regex = /(buckets|groups|collections|records)\/([^/]+)$/; const extracts = request.path.match(regex); const id = extracts && extracts.length === 3 ? extracts[2] : undefined; acc.skipped.push({ id, path: request.path, error: response.body, }); } else if (status === 412) { acc.conflicts.push({ // XXX: specifying the type is probably superfluous type: "outgoing", local: request.body, remote: (response.body.details && response.body.details.existing) || null, }); } else { acc.errors.push({ path: request.path, sent: request, error: response.body, }); } return acc; }, results); } // Unique ID creation requires a high quality random # generator. In the browser we therefore // require the crypto API and do not support built-in fallback to lower quality random number // generators (like Math.random()). // getRandomValues needs to be invoked in a context where "this" is a Crypto implementation. Also, // find the complete implementation of crypto (msCrypto) on IE11. var getRandomValues = typeof crypto != 'undefined' && crypto.getRandomValues && crypto.getRandomValues.bind(crypto) || typeof msCrypto != 'undefined' && typeof msCrypto.getRandomValues == 'function' && msCrypto.getRandomValues.bind(msCrypto); var rnds8 = new Uint8Array(16); // eslint-disable-line no-undef function rng() { if (!getRandomValues) { throw new Error('crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported'); } return getRandomValues(rnds8); } /** * Convert array of 16 byte values to UUID string format of the form: * XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX */ var byteToHex = []; for (var i = 0; i < 256; ++i) { byteToHex[i] = (i + 0x100).toString(16).substr(1); } function bytesToUuid(buf, offset) { var i = offset || 0; var bth = byteToHex; // join used to fix memory issue caused by concatenation: https://bugs.chromium.org/p/v8/issues/detail?id=3175#c4 return [bth[buf[i++]], bth[buf[i++]], bth[buf[i++]], bth[buf[i++]], '-', bth[buf[i++]], bth[buf[i++]], '-', bth[buf[i++]], bth[buf[i++]], '-', bth[buf[i++]], bth[buf[i++]], '-', bth[buf[i++]], bth[buf[i++]], bth[buf[i++]], bth[buf[i++]], bth[buf[i++]], bth[buf[i++]]].join(''); } function v4(options, buf, offset) { var i = buf && offset || 0; if (typeof options == 'string') { buf = options === 'binary' ? new Array(16) : null; options = null; } options = options || {}; var rnds = options.random || (options.rng || rng)(); // Per 4.4, set bits for version and `clock_seq_hi_and_reserved` rnds[6] = rnds[6] & 0x0f | 0x40; rnds[8] = rnds[8] & 0x3f | 0x80; // Copy bytes to buffer, if provided if (buf) { for (var ii = 0; ii < 16; ++ii) { buf[i + ii] = rnds[ii]; } } return buf || bytesToUuid(rnds); } /** * Abstract representation of a selected collection. * */ class Collection { /** * Constructor. * * @param {KintoClient} client The client instance. * @param {Bucket} bucket The bucket instance. * @param {String} name The collection name. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.retry] The retry option. * @param {Boolean} [options.batch] (Private) Whether this * Collection is operating as part of a batch. */ constructor(client, bucket, name, options = {}) { /** * @ignore */ this.client = client; /** * @ignore */ this.bucket = bucket; /** * The collection name. * @type {String} */ this.name = name; this._endpoints = client.endpoints; /** * @ignore */ this._retry = options.retry || 0; this._safe = !!options.safe; // FIXME: This is kind of ugly; shouldn't the bucket be responsible // for doing the merge? this._headers = Object.assign(Object.assign({}, this.bucket.headers), options.headers); } get execute() { return this.client.execute.bind(this.client); } /** * Get the value of "headers" for a given request, merging the * per-request headers with our own "default" headers. * * @private */ _getHeaders(options) { return Object.assign(Object.assign({}, this._headers), options.headers); } /** * Get the value of "safe" for a given request, using the * per-request option if present or falling back to our default * otherwise. * * @private * @param {Object} options The options for a request. * @returns {Boolean} */ _getSafe(options) { return Object.assign({ safe: this._safe }, options).safe; } /** * As _getSafe, but for "retry". * * @private */ _getRetry(options) { return Object.assign({ retry: this._retry }, options).retry; } /** * Retrieves the total number of records in this collection. * * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async getTotalRecords(options = {}) { const path = this._endpoints.record(this.bucket.name, this.name); const request = { headers: this._getHeaders(options), path, method: "HEAD", }; const { headers } = await this.client.execute(request, { raw: true, retry: this._getRetry(options), }); return parseInt(headers.get("Total-Records"), 10); } /** * Retrieves the ETag of the records list, for use with the `since` filtering option. * * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async getRecordsTimestamp(options = {}) { const path = this._endpoints.record(this.bucket.name, this.name); const request = { headers: this._getHeaders(options), path, method: "HEAD", }; const { headers } = (await this.client.execute(request, { raw: true, retry: this._getRetry(options), })); return headers.get("ETag"); } /** * Retrieves collection data. * * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Object} [options.query] Query parameters to pass in * the request. This might be useful for features that aren't * yet supported by this library. * @param {Array} [options.fields] Limit response to * just some fields. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async getData(options = {}) { const path = this._endpoints.collection(this.bucket.name, this.name); const request = { headers: this._getHeaders(options), path }; const { data } = (await this.client.execute(request, { retry: this._getRetry(options), query: options.query, fields: options.fields, })); return data; } /** * Set collection data. * @param {Object} data The collection data object. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Boolean} [options.safe] The safe option. * @param {Boolean} [options.patch] The patch option. * @param {Number} [options.last_modified] The last_modified option. * @return {Promise} */ async setData(data, options = {}) { if (!isObject(data)) { throw new Error("A collection object is required."); } const { patch, permissions } = options; const { last_modified } = Object.assign(Object.assign({}, data), options); const path = this._endpoints.collection(this.bucket.name, this.name); const request = updateRequest(path, { data, permissions }, { last_modified, patch, headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Retrieves the list of permissions for this collection. * * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async getPermissions(options = {}) { const path = this._endpoints.collection(this.bucket.name, this.name); const request = { headers: this._getHeaders(options), path }; const { permissions } = (await this.client.execute(request, { retry: this._getRetry(options), })); return permissions; } /** * Replaces all existing collection permissions with the ones provided. * * @param {Object} permissions The permissions object. * @param {Object} [options={}] The options object * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.last_modified] The last_modified option. * @return {Promise} */ async setPermissions(permissions, options = {}) { if (!isObject(permissions)) { throw new Error("A permissions object is required."); } const path = this._endpoints.collection(this.bucket.name, this.name); const data = { last_modified: options.last_modified }; const request = updateRequest(path, { data, permissions }, { headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Append principals to the collection permissions. * * @param {Object} permissions The permissions object. * @param {Object} [options={}] The options object * @param {Boolean} [options.safe] The safe option. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Object} [options.last_modified] The last_modified option. * @return {Promise} */ async addPermissions(permissions, options = {}) { if (!isObject(permissions)) { throw new Error("A permissions object is required."); } const path = this._endpoints.collection(this.bucket.name, this.name); const { last_modified } = options; const request = jsonPatchPermissionsRequest(path, permissions, "add", { last_modified, headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Remove principals from the collection permissions. * * @param {Object} permissions The permissions object. * @param {Object} [options={}] The options object * @param {Boolean} [options.safe] The safe option. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Object} [options.last_modified] The last_modified option. * @return {Promise} */ async removePermissions(permissions, options = {}) { if (!isObject(permissions)) { throw new Error("A permissions object is required."); } const path = this._endpoints.collection(this.bucket.name, this.name); const { last_modified } = options; const request = jsonPatchPermissionsRequest(path, permissions, "remove", { last_modified, headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Creates a record in current collection. * * @param {Object} record The record to create. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Boolean} [options.safe] The safe option. * @param {Object} [options.permissions] The permissions option. * @return {Promise} */ async createRecord(record, options = {}) { const { permissions } = options; const path = this._endpoints.record(this.bucket.name, this.name, record.id); const request = createRequest(path, { data: record, permissions }, { headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Adds an attachment to a record, creating the record when it doesn't exist. * * @param {String} dataURL The data url. * @param {Object} [record={}] The record data. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.last_modified] The last_modified option. * @param {Object} [options.permissions] The permissions option. * @param {String} [options.filename] Force the attachment filename. * @param {String} [options.gzipped] Force the attachment to be gzipped or not. * @return {Promise} */ async addAttachment(dataURI, record = {}, options = {}) { const { permissions } = options; const id = record.id || v4(); const path = this._endpoints.attachment(this.bucket.name, this.name, id); const { last_modified } = Object.assign(Object.assign({}, record), options); const addAttachmentRequest$1 = addAttachmentRequest(path, dataURI, { data: record, permissions }, { last_modified, filename: options.filename, gzipped: options.gzipped, headers: this._getHeaders(options), safe: this._getSafe(options), }); await this.client.execute(addAttachmentRequest$1, { stringify: false, retry: this._getRetry(options), }); return this.getRecord(id); } /** * Removes an attachment from a given record. * * @param {Object} recordId The record id. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.last_modified] The last_modified option. */ async removeAttachment(recordId, options = {}) { const { last_modified } = options; const path = this._endpoints.attachment(this.bucket.name, this.name, recordId); const request = deleteRequest(path, { last_modified, headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Updates a record in current collection. * * @param {Object} record The record to update. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.last_modified] The last_modified option. * @param {Object} [options.permissions] The permissions option. * @return {Promise} */ async updateRecord(record, options = {}) { if (!isObject(record)) { throw new Error("A record object is required."); } if (!record.id) { throw new Error("A record id is required."); } const { permissions } = options; const { last_modified } = Object.assign(Object.assign({}, record), options); const path = this._endpoints.record(this.bucket.name, this.name, record.id); const request = updateRequest(path, { data: record, permissions }, { headers: this._getHeaders(options), safe: this._getSafe(options), last_modified, patch: !!options.patch, }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Deletes a record from the current collection. * * @param {Object|String} record The record to delete. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.last_modified] The last_modified option. * @return {Promise} */ async deleteRecord(record, options = {}) { const recordObj = toDataBody(record); if (!recordObj.id) { throw new Error("A record id is required."); } const { id } = recordObj; const { last_modified } = Object.assign(Object.assign({}, recordObj), options); const path = this._endpoints.record(this.bucket.name, this.name, id); const request = deleteRequest(path, { last_modified, headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Retrieves a record from the current collection. * * @param {String} id The record id to retrieve. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Object} [options.query] Query parameters to pass in * the request. This might be useful for features that aren't * yet supported by this library. * @param {Array} [options.fields] Limit response to * just some fields. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async getRecord(id, options = {}) { const path = this._endpoints.record(this.bucket.name, this.name, id); const request = { headers: this._getHeaders(options), path }; return this.client.execute(request, { retry: this._getRetry(options), query: options.query, fields: options.fields, }); } /** * Lists records from the current collection. * * Sorting is done by passing a `sort` string option: * * - The field to order the results by, prefixed with `-` for descending. * Default: `-last_modified`. * * @see http://kinto.readthedocs.io/en/stable/api/1.x/sorting.html * * Filtering is done by passing a `filters` option object: * * - `{fieldname: "value"}` * - `{min_fieldname: 4000}` * - `{in_fieldname: "1,2,3"}` * - `{not_fieldname: 0}` * - `{exclude_fieldname: "0,1"}` * * @see http://kinto.readthedocs.io/en/stable/api/1.x/filtering.html * * Paginating is done by passing a `limit` option, then calling the `next()` * method from the resolved result object to fetch the next page, if any. * * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Object} [options.filters={}] The filters object. * @param {String} [options.sort="-last_modified"] The sort field. * @param {String} [options.at] The timestamp to get a snapshot at. * @param {String} [options.limit=null] The limit field. * @param {String} [options.pages=1] The number of result pages to aggregate. * @param {Number} [options.since=null] Only retrieve records modified since the provided timestamp. * @param {Array} [options.fields] Limit response to just some fields. * @return {Promise} */ async listRecords(options = {}) { const path = this._endpoints.record(this.bucket.name, this.name); if (options.at) { return this.getSnapshot(options.at); } else { return this.client.paginatedList(path, options, { headers: this._getHeaders(options), retry: this._getRetry(options), }); } } /** * @private */ async isHistoryComplete() { // We consider that if we have the collection creation event part of the // history, then all records change events have been tracked. const { data: [oldestHistoryEntry], } = await this.bucket.listHistory({ limit: 1, filters: { action: "create", resource_name: "collection", collection_id: this.name, }, }); return !!oldestHistoryEntry; } /** * @private */ async listChangesBackTo(at) { // Ensure we have enough history data to retrieve the complete list of // changes. if (!(await this.isHistoryComplete())) { throw new Error("Computing a snapshot is only possible when the full history for a " + "collection is available. Here, the history plugin seems to have " + "been enabled after the creation of the collection."); } const { data: changes } = await this.bucket.listHistory({ pages: Infinity, sort: "-target.data.last_modified", filters: { resource_name: "record", collection_id: this.name, "max_target.data.last_modified": String(at), }, }); return changes; } /** * @private */ async getSnapshot(at) { if (!at || !Number.isInteger(at) || at <= 0) { throw new Error("Invalid argument, expected a positive integer."); } // Retrieve history and check it covers the required time range. const changes = await this.listChangesBackTo(at); // Replay changes to compute the requested snapshot. const seenIds = new Set(); let snapshot = []; for (const { action, target: { data: record }, } of changes) { if (action == "delete") { seenIds.add(record.id); // ensure not reprocessing deleted entries snapshot = snapshot.filter((r) => r.id !== record.id); } else if (!seenIds.has(record.id)) { seenIds.add(record.id); snapshot.push(record); } } return { last_modified: String(at), data: snapshot.sort((a, b) => b.last_modified - a.last_modified), next: () => { throw new Error("Snapshots don't support pagination"); }, hasNextPage: false, totalRecords: snapshot.length, }; } /** * Performs batch operations at the current collection level. * * @param {Function} fn The batch operation function. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.retry] The retry option. * @param {Boolean} [options.aggregate] Produces a grouped result object. * @return {Promise} */ async batch(fn, options = {}) { return this.client.batch(fn, { bucket: this.bucket.name, collection: this.name, headers: this._getHeaders(options), retry: this._getRetry(options), safe: this._getSafe(options), aggregate: !!options.aggregate, }); } } __decorate([ capable(["attachments"]) ], Collection.prototype, "addAttachment", null); __decorate([ capable(["attachments"]) ], Collection.prototype, "removeAttachment", null); __decorate([ capable(["history"]) ], Collection.prototype, "getSnapshot", null); /** * Abstract representation of a selected bucket. * */ class Bucket { /** * Constructor. * * @param {KintoClient} client The client instance. * @param {String} name The bucket name. * @param {Object} [options={}] The headers object option. * @param {Object} [options.headers] The headers object option. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.retry] The retry option. */ constructor(client, name, options = {}) { /** * @ignore */ this.client = client; /** * The bucket name. * @type {String} */ this.name = name; this._endpoints = client.endpoints; /** * @ignore */ this._headers = options.headers || {}; this._retry = options.retry || 0; this._safe = !!options.safe; } get execute() { return this.client.execute.bind(this.client); } get headers() { return this._headers; } /** * Get the value of "headers" for a given request, merging the * per-request headers with our own "default" headers. * * @private */ _getHeaders(options) { return Object.assign(Object.assign({}, this._headers), options.headers); } /** * Get the value of "safe" for a given request, using the * per-request option if present or falling back to our default * otherwise. * * @private * @param {Object} options The options for a request. * @returns {Boolean} */ _getSafe(options) { return Object.assign({ safe: this._safe }, options).safe; } /** * As _getSafe, but for "retry". * * @private */ _getRetry(options) { return Object.assign({ retry: this._retry }, options).retry; } /** * Selects a collection. * * @param {String} name The collection name. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Boolean} [options.safe] The safe option. * @return {Collection} */ collection(name, options = {}) { return new Collection(this.client, this, name, { headers: this._getHeaders(options), retry: this._getRetry(options), safe: this._getSafe(options), }); } /** * Retrieves the ETag of the collection list, for use with the `since` filtering option. * * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async getCollectionsTimestamp(options = {}) { const path = this._endpoints.collection(this.name); const request = { headers: this._getHeaders(options), path, method: "HEAD", }; const { headers } = (await this.client.execute(request, { raw: true, retry: this._getRetry(options), })); return headers.get("ETag"); } /** * Retrieves the ETag of the group list, for use with the `since` filtering option. * * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async getGroupsTimestamp(options = {}) { const path = this._endpoints.group(this.name); const request = { headers: this._getHeaders(options), path, method: "HEAD", }; const { headers } = (await this.client.execute(request, { raw: true, retry: this._getRetry(options), })); return headers.get("ETag"); } /** * Retrieves bucket data. * * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Object} [options.query] Query parameters to pass in * the request. This might be useful for features that aren't * yet supported by this library. * @param {Array} [options.fields] Limit response to * just some fields. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async getData(options = {}) { const path = this._endpoints.bucket(this.name); const request = { headers: this._getHeaders(options), path, }; const { data } = (await this.client.execute(request, { retry: this._getRetry(options), query: options.query, fields: options.fields, })); return data; } /** * Set bucket data. * @param {Object} data The bucket data object. * @param {Object} [options={}] The options object. * @param {Object} [options.headers={}] The headers object option. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Boolean} [options.patch] The patch option. * @param {Number} [options.last_modified] The last_modified option. * @return {Promise} */ async setData(data, options = {}) { if (!isObject(data)) { throw new Error("A bucket object is required."); } const bucket = Object.assign(Object.assign({}, data), { id: this.name }); // For default bucket, we need to drop the id from the data object. // Bug in Kinto < 3.1.1 const bucketId = bucket.id; if (bucket.id === "default") { delete bucket.id; } const path = this._endpoints.bucket(bucketId); const { patch, permissions } = options; const { last_modified } = Object.assign(Object.assign({}, data), options); const request = updateRequest(path, { data: bucket, permissions }, { last_modified, patch, headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Retrieves the list of history entries in the current bucket. * * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise, Error>} */ async listHistory(options = {}) { const path = this._endpoints.history(this.name); return this.client.paginatedList(path, options, { headers: this._getHeaders(options), retry: this._getRetry(options), }); } /** * Retrieves the list of collections in the current bucket. * * @param {Object} [options={}] The options object. * @param {Object} [options.filters={}] The filters object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Array} [options.fields] Limit response to * just some fields. * @return {Promise, Error>} */ async listCollections(options = {}) { const path = this._endpoints.collection(this.name); return this.client.paginatedList(path, options, { headers: this._getHeaders(options), retry: this._getRetry(options), }); } /** * Creates a new collection in current bucket. * * @param {String|undefined} id The collection id. * @param {Object} [options={}] The options object. * @param {Boolean} [options.safe] The safe option. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Object} [options.permissions] The permissions object. * @param {Object} [options.data] The data object. * @return {Promise} */ async createCollection(id, options = {}) { const { permissions, data = {} } = options; data.id = id; const path = this._endpoints.collection(this.name, id); const request = createRequest(path, { data, permissions }, { headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Deletes a collection from the current bucket. * * @param {Object|String} collection The collection to delete. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.last_modified] The last_modified option. * @return {Promise} */ async deleteCollection(collection, options = {}) { const collectionObj = toDataBody(collection); if (!collectionObj.id) { throw new Error("A collection id is required."); } const { id } = collectionObj; const { last_modified } = Object.assign(Object.assign({}, collectionObj), options); const path = this._endpoints.collection(this.name, id); const request = deleteRequest(path, { last_modified, headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Retrieves the list of groups in the current bucket. * * @param {Object} [options={}] The options object. * @param {Object} [options.filters={}] The filters object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Array} [options.fields] Limit response to * just some fields. * @return {Promise, Error>} */ async listGroups(options = {}) { const path = this._endpoints.group(this.name); return this.client.paginatedList(path, options, { headers: this._getHeaders(options), retry: this._getRetry(options), }); } /** * Fetches a group in current bucket. * * @param {String} id The group id. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Object} [options.query] Query parameters to pass in * the request. This might be useful for features that aren't * yet supported by this library. * @param {Array} [options.fields] Limit response to * just some fields. * @return {Promise} */ async getGroup(id, options = {}) { const path = this._endpoints.group(this.name, id); const request = { headers: this._getHeaders(options), path, }; return this.client.execute(request, { retry: this._getRetry(options), query: options.query, fields: options.fields, }); } /** * Creates a new group in current bucket. * * @param {String|undefined} id The group id. * @param {Array} [members=[]] The list of principals. * @param {Object} [options={}] The options object. * @param {Object} [options.data] The data object. * @param {Object} [options.permissions] The permissions object. * @param {Boolean} [options.safe] The safe option. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async createGroup(id, members = [], options = {}) { const data = Object.assign(Object.assign({}, options.data), { id, members }); const path = this._endpoints.group(this.name, id); const { permissions } = options; const request = createRequest(path, { data, permissions }, { headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Updates an existing group in current bucket. * * @param {Object} group The group object. * @param {Object} [options={}] The options object. * @param {Object} [options.data] The data object. * @param {Object} [options.permissions] The permissions object. * @param {Boolean} [options.safe] The safe option. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Number} [options.last_modified] The last_modified option. * @return {Promise} */ async updateGroup(group, options = {}) { if (!isObject(group)) { throw new Error("A group object is required."); } if (!group.id) { throw new Error("A group id is required."); } const data = Object.assign(Object.assign({}, options.data), group); const path = this._endpoints.group(this.name, group.id); const { patch, permissions } = options; const { last_modified } = Object.assign(Object.assign({}, data), options); const request = updateRequest(path, { data, permissions }, { last_modified, patch, headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Deletes a group from the current bucket. * * @param {Object|String} group The group to delete. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.last_modified] The last_modified option. * @return {Promise} */ async deleteGroup(group, options = {}) { const groupObj = toDataBody(group); const { id } = groupObj; const { last_modified } = Object.assign(Object.assign({}, groupObj), options); const path = this._endpoints.group(this.name, id); const request = deleteRequest(path, { last_modified, headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Retrieves the list of permissions for this bucket. * * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async getPermissions(options = {}) { const request = { headers: this._getHeaders(options), path: this._endpoints.bucket(this.name), }; const { permissions } = (await this.client.execute(request, { retry: this._getRetry(options), })); return permissions; } /** * Replaces all existing bucket permissions with the ones provided. * * @param {Object} permissions The permissions object. * @param {Object} [options={}] The options object * @param {Boolean} [options.safe] The safe option. * @param {Object} [options.headers={}] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Object} [options.last_modified] The last_modified option. * @return {Promise} */ async setPermissions(permissions, options = {}) { if (!isObject(permissions)) { throw new Error("A permissions object is required."); } const path = this._endpoints.bucket(this.name); const { last_modified } = options; const data = { last_modified }; const request = updateRequest(path, { data, permissions }, { headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Append principals to the bucket permissions. * * @param {Object} permissions The permissions object. * @param {Object} [options={}] The options object * @param {Boolean} [options.safe] The safe option. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Object} [options.last_modified] The last_modified option. * @return {Promise} */ async addPermissions(permissions, options = {}) { if (!isObject(permissions)) { throw new Error("A permissions object is required."); } const path = this._endpoints.bucket(this.name); const { last_modified } = options; const request = jsonPatchPermissionsRequest(path, permissions, "add", { last_modified, headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Remove principals from the bucket permissions. * * @param {Object} permissions The permissions object. * @param {Object} [options={}] The options object * @param {Boolean} [options.safe] The safe option. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Object} [options.last_modified] The last_modified option. * @return {Promise} */ async removePermissions(permissions, options = {}) { if (!isObject(permissions)) { throw new Error("A permissions object is required."); } const path = this._endpoints.bucket(this.name); const { last_modified } = options; const request = jsonPatchPermissionsRequest(path, permissions, "remove", { last_modified, headers: this._getHeaders(options), safe: this._getSafe(options), }); return this.client.execute(request, { retry: this._getRetry(options), }); } /** * Performs batch operations at the current bucket level. * * @param {Function} fn The batch operation function. * @param {Object} [options={}] The options object. * @param {Object} [options.headers] The headers object option. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.retry=0] The retry option. * @param {Boolean} [options.aggregate] Produces a grouped result object. * @return {Promise} */ async batch(fn, options = {}) { return this.client.batch(fn, { bucket: this.name, headers: this._getHeaders(options), retry: this._getRetry(options), safe: this._getSafe(options), aggregate: !!options.aggregate, }); } } __decorate([ capable(["history"]) ], Bucket.prototype, "listHistory", null); /** * Currently supported protocol version. * @type {String} */ const SUPPORTED_PROTOCOL_VERSION = "v1"; /** * High level HTTP client for the Kinto API. * * @example * const client = new KintoClient("https://kinto.dev.mozaws.net/v1"); * client.bucket("default") * .collection("my-blog") * .createRecord({title: "First article"}) * .then(console.log.bind(console)) * .catch(console.error.bind(console)); */ class KintoClientBase { /** * Constructor. * * @param {String} remote The remote URL. * @param {Object} [options={}] The options object. * @param {Boolean} [options.safe=true] Adds concurrency headers to every requests. * @param {EventEmitter} [options.events=EventEmitter] The events handler instance. * @param {Object} [options.headers={}] The key-value headers to pass to each request. * @param {Object} [options.retry=0] Number of retries when request fails (default: 0) * @param {String} [options.bucket="default"] The default bucket to use. * @param {String} [options.requestMode="cors"] The HTTP request mode (from ES6 fetch spec). * @param {Number} [options.timeout=null] The request timeout in ms, if any. */ constructor(remote, options) { 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; this._requests = []; this._isBatch = !!options.batch; this._retry = options.retry || 0; this._safe = !!options.safe; this._headers = options.headers || {}; // public properties /** * The remote server base URL. * @type {String} */ this.remote = remote; /** * Current server information. * @ignore * @type {Object|null} */ this.serverInfo = null; /** * The event emitter instance. Should comply with the `EventEmitter` * interface. * @ignore * @type {Class} */ this.events = options.events; this.endpoints = ENDPOINTS; const { requestMode, timeout } = options; /** * The HTTP instance. * @ignore * @type {HTTP} */ this.http = new HTTP(this.events, { requestMode, timeout }); this._registerHTTPEvents(); } /** * The remote endpoint base URL. Setting the value will also extract and * validate the version. * @type {String} */ get remote() { return this._remote; } /** * @ignore */ set remote(url) { let version; try { version = url.match(/\/(v\d+)\/?$/)[1]; } catch (err) { throw new Error("The remote URL must contain the version: " + url); } if (version !== SUPPORTED_PROTOCOL_VERSION) { throw new Error(`Unsupported protocol version: ${version}`); } this._remote = url; this._version = version; } /** * The current server protocol version, eg. `v1`. * @type {String} */ get version() { return this._version; } /** * Backoff remaining time, in milliseconds. Defaults to zero if no backoff is * ongoing. * * @type {Number} */ get backoff() { const currentTime = new Date().getTime(); if (this._backoffReleaseTime && currentTime < this._backoffReleaseTime) { return this._backoffReleaseTime - currentTime; } return 0; } /** * Registers HTTP events. * @private */ _registerHTTPEvents() { // Prevent registering event from a batch client instance if (!this._isBatch && this.events) { this.events.on("backoff", (backoffMs) => { this._backoffReleaseTime = backoffMs; }); } } /** * Retrieve a bucket object to perform operations on it. * * @param {String} name The bucket name. * @param {Object} [options={}] The request options. * @param {Boolean} [options.safe] The resulting safe option. * @param {Number} [options.retry] The resulting retry option. * @param {Object} [options.headers] The extended headers object option. * @return {Bucket} */ bucket(name, options = {}) { return new Bucket(this, name, { headers: this._getHeaders(options), safe: this._getSafe(options), retry: this._getRetry(options), }); } /** * Set client "headers" for every request, updating previous headers (if any). * * @param {Object} headers The headers to merge with existing ones. */ setHeaders(headers) { this._headers = Object.assign(Object.assign({}, this._headers), headers); this.serverInfo = null; } /** * Get the value of "headers" for a given request, merging the * per-request headers with our own "default" headers. * * Note that unlike other options, headers aren't overridden, but * merged instead. * * @private * @param {Object} options The options for a request. * @returns {Object} */ _getHeaders(options) { return Object.assign(Object.assign({}, this._headers), options.headers); } /** * Get the value of "safe" for a given request, using the * per-request option if present or falling back to our default * otherwise. * * @private * @param {Object} options The options for a request. * @returns {Boolean} */ _getSafe(options) { return Object.assign({ safe: this._safe }, options).safe; } /** * As _getSafe, but for "retry". * * @private */ _getRetry(options) { return Object.assign({ retry: this._retry }, options).retry; } /** * Retrieves the server's "hello" endpoint. This endpoint reveals * server capabilities and settings as well as telling the client * "who they are" according to their given authorization headers. * * @private * @param {Object} [options={}] The request options. * @param {Object} [options.headers={}] Headers to use when making * this request. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async _getHello(options = {}) { const path = this.remote + ENDPOINTS.root(); const { json } = await this.http.request(path, { headers: this._getHeaders(options) }, { retry: this._getRetry(options) }); return json; } /** * Retrieves server information and persist them locally. This operation is * usually performed a single time during the instance lifecycle. * * @param {Object} [options={}] The request options. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async fetchServerInfo(options = {}) { if (this.serverInfo) { return this.serverInfo; } this.serverInfo = await this._getHello({ retry: this._getRetry(options) }); return this.serverInfo; } /** * Retrieves Kinto server settings. * * @param {Object} [options={}] The request options. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async fetchServerSettings(options = {}) { const { settings } = await this.fetchServerInfo(options); return settings; } /** * Retrieve server capabilities information. * * @param {Object} [options={}] The request options. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async fetchServerCapabilities(options = {}) { const { capabilities } = await this.fetchServerInfo(options); return capabilities; } /** * Retrieve authenticated user information. * * @param {Object} [options={}] The request options. * @param {Object} [options.headers={}] Headers to use when making * this request. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async fetchUser(options = {}) { const { user } = await this._getHello(options); return user; } /** * Retrieve authenticated user information. * * @param {Object} [options={}] The request options. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async fetchHTTPApiVersion(options = {}) { const { http_api_version } = await this.fetchServerInfo(options); return http_api_version; } /** * Process batch requests, chunking them according to the batch_max_requests * server setting when needed. * * @param {Array} requests The list of batch subrequests to perform. * @param {Object} [options={}] The options object. * @return {Promise} */ async _batchRequests(requests, options = {}) { const headers = this._getHeaders(options); if (!requests.length) { return []; } const serverSettings = await this.fetchServerSettings({ retry: this._getRetry(options), }); const maxRequests = serverSettings["batch_max_requests"]; if (maxRequests && requests.length > maxRequests) { const chunks = partition(requests, maxRequests); const results = []; for (const chunk of chunks) { const result = await this._batchRequests(chunk, options); results.push(...result); } return results; } const { responses } = (await this.execute({ // FIXME: is this really necessary, since it's also present in // the "defaults"? headers, path: ENDPOINTS.batch(), method: "POST", body: { defaults: { headers }, requests, }, }, { retry: this._getRetry(options) })); return responses; } /** * Sends batch requests to the remote server. * * Note: Reserved for internal use only. * * @ignore * @param {Function} fn The function to use for describing batch ops. * @param {Object} [options={}] The options object. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.retry] The retry option. * @param {String} [options.bucket] The bucket name option. * @param {String} [options.collection] The collection name option. * @param {Object} [options.headers] The headers object option. * @param {Boolean} [options.aggregate=false] Produces an aggregated result object. * @return {Promise} */ async batch(fn, options = {}) { const rootBatch = new KintoClientBase(this.remote, { events: this.events, batch: true, safe: this._getSafe(options), retry: this._getRetry(options), }); if (options.bucket && options.collection) { fn(rootBatch.bucket(options.bucket).collection(options.collection)); } else if (options.bucket) { fn(rootBatch.bucket(options.bucket)); } else { fn(rootBatch); } const responses = await this._batchRequests(rootBatch._requests, options); if (options.aggregate) { return aggregate(responses, rootBatch._requests); } else { return responses; } } /** * Executes an atomic HTTP request. * * @param {Object} request The request object. * @param {String} request.path The path to fetch, relative * to the Kinto server root. * @param {String} [request.method="GET"] The method to use in the * request. * @param {Body} [request.body] The request body. * @param {Object} [request.headers={}] The request headers. * @param {Object} [options={}] The options object. * @param {Boolean} [options.raw=false] If true, resolve with full response * @param {Boolean} [options.stringify=true] If true, serialize body data to * @param {Number} [options.retry=0] The number of times to * retry a request if the server responds with Retry-After. * JSON. * @return {Promise} */ async execute(request, options = {}) { const { raw = false, stringify = true } = options; // If we're within a batch, add the request to the stack to send at once. if (this._isBatch) { this._requests.push(request); // Resolve with a message in case people attempt at consuming the result // from within a batch operation. const msg = ("This result is generated from within a batch " + "operation and should not be consumed."); return raw ? { status: 0, json: msg, headers: new Headers() } : msg; } const uri = this.remote + addEndpointOptions(request.path, options); const result = await this.http.request(uri, cleanUndefinedProperties({ // Limit requests to only those parts that would be allowed in // a batch request -- don't pass through other fancy fetch() // options like integrity, redirect, mode because they will // break on a batch request. A batch request only allows // headers, method, path (above), and body. method: request.method, headers: request.headers, body: stringify ? JSON.stringify(request.body) : request.body, }), { retry: this._getRetry(options) }); return raw ? result : result.json; } /** * Fetch some pages from a paginated list, following the `next-page` * header automatically until we have fetched the requested number * of pages. Return a response with a `.next()` method that can be * called to fetch more results. * * @private * @param {String} path * The path to make the request to. * @param {Object} params * The parameters to use when making the request. * @param {String} [params.sort="-last_modified"] * The sorting order to use when fetching. * @param {Object} [params.filters={}] * The filters to send in the request. * @param {Number} [params.limit=undefined] * The limit to send in the request. Undefined means no limit. * @param {Number} [params.pages=undefined] * The number of pages to fetch. Undefined means one page. Pass * Infinity to fetch everything. * @param {String} [params.since=undefined] * The ETag from which to start fetching. * @param {Array} [params.fields] * Limit response to just some fields. * @param {Object} [options={}] * Additional request-level parameters to use in all requests. * @param {Object} [options.headers={}] * Headers to use during all requests. * @param {Number} [options.retry=0] * Number of times to retry each request if the server responds * with Retry-After. */ async paginatedList(path, params = {}, options = {}) { // FIXME: this is called even in batch requests, which doesn't // make any sense (since all batch requests get a "dummy" // response; see execute() above). const { sort, filters, limit, pages, since, fields } = Object.assign({ sort: "-last_modified" }, params); // Safety/Consistency check on ETag value. if (since && typeof since !== "string") { throw new Error(`Invalid value for since (${since}), should be ETag value.`); } const query = Object.assign(Object.assign({}, filters), { _sort: sort, _limit: limit, _since: since }); if (fields) { query._fields = fields; } const querystring = qsify(query); let results = [], current = 0; const next = async function (nextPage) { if (!nextPage) { throw new Error("Pagination exhausted."); } return processNextPage(nextPage); }; const processNextPage = async (nextPage) => { const { headers } = options; return handleResponse(await this.http.request(nextPage, { headers })); }; const pageResults = (results, nextPage, etag) => { // ETag string is supposed to be opaque and stored «as-is». // ETag header values are quoted (because of * and W/"foo"). return { last_modified: etag ? etag.replace(/"/g, "") : etag, data: results, next: next.bind(null, nextPage), hasNextPage: !!nextPage, totalRecords: -1, }; }; const handleResponse = async function ({ headers, json, }) { const nextPage = headers.get("Next-Page"); const etag = headers.get("ETag"); if (!pages) { return pageResults(json.data, nextPage, etag); } // Aggregate new results with previous ones results = results.concat(json.data); current += 1; if (current >= pages || !nextPage) { // Pagination exhausted return pageResults(results, nextPage, etag); } // Follow next page return processNextPage(nextPage); }; return handleResponse((await this.execute( // N.B.: This doesn't use _getHeaders, because all calls to // `paginatedList` are assumed to come from calls that already // have headers merged at e.g. the bucket or collection level. { headers: options.headers ? options.headers : {}, path: path + "?" + querystring, }, // N.B. This doesn't use _getRetry, because all calls to // `paginatedList` are assumed to come from calls that already // used `_getRetry` at e.g. the bucket or collection level. { raw: true, retry: options.retry || 0 }))); } /** * Lists all permissions. * * @param {Object} [options={}] The options object. * @param {Object} [options.headers={}] Headers to use when making * this request. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async listPermissions(options = {}) { const path = ENDPOINTS.permissions(); // Ensure the default sort parameter is something that exists in permissions // entries, as `last_modified` doesn't; here, we pick "id". const paginationOptions = Object.assign({ sort: "id" }, options); return this.paginatedList(path, paginationOptions, { headers: this._getHeaders(options), retry: this._getRetry(options), }); } /** * Retrieves the list of buckets. * * @param {Object} [options={}] The options object. * @param {Object} [options.headers={}] Headers to use when making * this request. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Object} [options.filters={}] The filters object. * @param {Array} [options.fields] Limit response to * just some fields. * @return {Promise} */ async listBuckets(options = {}) { const path = ENDPOINTS.bucket(); return this.paginatedList(path, options, { headers: this._getHeaders(options), retry: this._getRetry(options), }); } /** * Creates a new bucket on the server. * * @param {String|null} id The bucket name (optional). * @param {Object} [options={}] The options object. * @param {Boolean} [options.data] The bucket data option. * @param {Boolean} [options.safe] The safe option. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @return {Promise} */ async createBucket(id, options = {}) { const { data, permissions } = options; const _data = Object.assign(Object.assign({}, data), { id: id ? id : undefined }); const path = _data.id ? ENDPOINTS.bucket(_data.id) : ENDPOINTS.bucket(); return this.execute(createRequest(path, { data: _data, permissions }, { headers: this._getHeaders(options), safe: this._getSafe(options), }), { retry: this._getRetry(options) }); } /** * Deletes a bucket from the server. * * @ignore * @param {Object|String} bucket The bucket to delete. * @param {Object} [options={}] The options object. * @param {Boolean} [options.safe] The safe option. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.retry=0] Number of retries to make * when faced with transient errors. * @param {Number} [options.last_modified] The last_modified option. * @return {Promise} */ async deleteBucket(bucket, options = {}) { const bucketObj = toDataBody(bucket); if (!bucketObj.id) { throw new Error("A bucket id is required."); } const path = ENDPOINTS.bucket(bucketObj.id); const { last_modified } = Object.assign(Object.assign({}, bucketObj), options); return this.execute(deleteRequest(path, { last_modified, headers: this._getHeaders(options), safe: this._getSafe(options), }), { retry: this._getRetry(options) }); } /** * Deletes all buckets on the server. * * @ignore * @param {Object} [options={}] The options object. * @param {Boolean} [options.safe] The safe option. * @param {Object} [options.headers] The headers object option. * @param {Number} [options.last_modified] The last_modified option. * @return {Promise} */ async deleteBuckets(options = {}) { const path = ENDPOINTS.bucket(); return this.execute(deleteRequest(path, { last_modified: options.last_modified, headers: this._getHeaders(options), safe: this._getSafe(options), }), { retry: this._getRetry(options) }); } async createAccount(username, password) { return this.execute(createRequest(`/accounts/${username}`, { data: { password } }, { method: "PUT" })); } } __decorate([ nobatch("This operation is not supported within a batch operation.") ], KintoClientBase.prototype, "fetchServerSettings", null); __decorate([ nobatch("This operation is not supported within a batch operation.") ], KintoClientBase.prototype, "fetchServerCapabilities", null); __decorate([ nobatch("This operation is not supported within a batch operation.") ], KintoClientBase.prototype, "fetchUser", null); __decorate([ nobatch("This operation is not supported within a batch operation.") ], KintoClientBase.prototype, "fetchHTTPApiVersion", null); __decorate([ nobatch("Can't use batch within a batch!") ], KintoClientBase.prototype, "batch", null); __decorate([ capable(["permissions_endpoint"]) ], KintoClientBase.prototype, "listPermissions", null); __decorate([ support("1.4", "2.0") ], KintoClientBase.prototype, "deleteBuckets", null); __decorate([ capable(["accounts"]) ], KintoClientBase.prototype, "createAccount", null); /* * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const { EventEmitter } = ChromeUtils.import("resource://gre/modules/EventEmitter.jsm"); class KintoHttpClient extends KintoClientBase { constructor(remote, options = {}) { const events = {}; EventEmitter.decorate(events); super(remote, Object.assign({ events: events }, options)); } } KintoHttpClient.errors = errors; return KintoHttpClient; })));