diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /services/common/kinto-http-client.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'services/common/kinto-http-client.sys.mjs')
-rw-r--r-- | services/common/kinto-http-client.sys.mjs | 3061 |
1 files changed, 3061 insertions, 0 deletions
diff --git a/services/common/kinto-http-client.sys.mjs b/services/common/kinto-http-client.sys.mjs new file mode 100644 index 0000000000..6ccaa520c1 --- /dev/null +++ b/services/common/kinto-http-client.sys.mjs @@ -0,0 +1,3061 @@ +/* + * + * 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. + * + * This file is generated from kinto.js - do not modify directly. + */ + +import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +/* + * Version 15.0.0 - c8775d9 + */ + +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +/****************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ +/* global Reflect, Promise */ + +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<void> + */ +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(","); + } + 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<String>} 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) { + 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).substring(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 { ...acc, [key]: value }; + }, {}); + return { ...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 = { ...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, + NetworkTimeoutError, + ServerResponse, + UnparseableResponseError, + default: ERROR_CODES, +}); + +/** + * Enhanced HTTP client for the Kinto protocol. + * @private + */ +class HTTP { + /** + * 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" }; + } + /** + * 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; + /** + * The fetch() function. + * @type {Function} + */ + this.fetchFunc = options.fetchFunc || globalThis.fetch.bind(globalThis); + } + /** + * @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 = { + ...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); + } + }; + } + this.fetchFunc(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, { + ...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 = { ...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); + } + 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 null; + } + 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 } = { + ...requestDefaults, + ...options, + }; + const method = options.method || (data && data.id) ? "PUT" : "POST"; + return { + method, + path, + headers: { ...headers, ...safeHeader(safe) }, + body: { data, permissions }, + }; +} +/** + * @private + */ +function updateRequest(path, { data, permissions }, options = {}) { + const { headers, safe, patch } = { ...requestDefaults, ...options }; + const { last_modified } = { ...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: { ...headers, ...safeHeader(safe, last_modified) }, + body: { data, permissions }, + }; +} +/** + * @private + */ +function jsonPatchPermissionsRequest(path, permissions, opType, options = {}) { + const { headers, safe, last_modified } = { ...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: { + ...headers, + ...safeHeader(safe, last_modified), + "Content-Type": "application/json-patch+json", + }, + body: ops, + }; +} +/** + * @private + */ +function deleteRequest(path, options = {}) { + const { headers, safe, last_modified } = { + ...requestDefaults, + ...options, + }; + if (safe && !last_modified) { + throw new Error("Safe concurrency check requires a last_modified value."); + } + return { + method: "DELETE", + path, + headers: { ...headers, ...safeHeader(safe, last_modified) }, + }; +} +/** + * @private + */ +function addAttachmentRequest( + path, + dataURI, + { data, permissions } = {}, + options = {} +) { + const { headers, safe, gzipped } = { ...requestDefaults, ...options }; + const { last_modified } = { ...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: { ...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()). +let getRandomValues; +const rnds8 = new Uint8Array(16); +function rng() { + // lazy load so that environments that need to polyfill have a chance to do so + if (!getRandomValues) { + // getRandomValues needs to be invoked in a context where "this" is a Crypto implementation. + getRandomValues = + typeof crypto !== "undefined" && + crypto.getRandomValues && + crypto.getRandomValues.bind(crypto); + + 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 + */ + +const byteToHex = []; + +for (let i = 0; i < 256; ++i) { + byteToHex.push((i + 0x100).toString(16).slice(1)); +} + +function unsafeStringify(arr, offset = 0) { + // Note: Be careful editing this code! It's been tuned for performance + // and works in ways you may not expect. See https://github.com/uuidjs/uuid/pull/434 + return ( + byteToHex[arr[offset + 0]] + + byteToHex[arr[offset + 1]] + + byteToHex[arr[offset + 2]] + + byteToHex[arr[offset + 3]] + + "-" + + byteToHex[arr[offset + 4]] + + byteToHex[arr[offset + 5]] + + "-" + + byteToHex[arr[offset + 6]] + + byteToHex[arr[offset + 7]] + + "-" + + byteToHex[arr[offset + 8]] + + byteToHex[arr[offset + 9]] + + "-" + + byteToHex[arr[offset + 10]] + + byteToHex[arr[offset + 11]] + + byteToHex[arr[offset + 12]] + + byteToHex[arr[offset + 13]] + + byteToHex[arr[offset + 14]] + + byteToHex[arr[offset + 15]] + ).toLowerCase(); +} + +const randomUUID = + typeof crypto !== "undefined" && + crypto.randomUUID && + crypto.randomUUID.bind(crypto); +var native = { + randomUUID, +}; + +function v4(options, buf, offset) { + if (native.randomUUID && !buf && !options) { + return native.randomUUID(); + } + + options = options || {}; + const 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) { + offset = offset || 0; + + for (let i = 0; i < 16; ++i) { + buf[offset + i] = rnds[i]; + } + + return buf; + } + + return unsafeStringify(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 = { + ...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 { + ...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 { safe: this._safe, ...options }.safe; + } + /** + * As _getSafe, but for "retry". + * + * @private + */ + _getRetry(options) { + return { 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<Number, Error>} + */ + 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<String, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + async setData(data, options = {}) { + if (!isObject(data)) { + throw new Error("A collection object is required."); + } + const { patch, permissions } = options; + const { last_modified } = { ...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<Object, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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 } = { ...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<Object, Error>} + */ + 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 } = { ...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<Object, Error>} + */ + 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 } = { ...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), + }); + } + /** + * Deletes 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 + * + * @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<Object, Error>} + */ + async deleteRecords(options = {}) { + const path = this._endpoints.record(this.bucket.name, this.name); + return this.client.paginatedDelete(path, options, { + headers: this._getHeaders(options), + 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<Object, Error>} + */ + 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<Object, Error>} + */ + async listRecords(options = {}) { + const path = this._endpoints.record(this.bucket.name, this.name); + if (options.at) { + return this.getSnapshot(options.at); + } + 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 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. + // 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." + ); + } + // Because of https://github.com/Kinto/kinto-http.js/issues/963 + // we cannot simply rely on the history endpoint. + // Our strategy here is to clean-up the history entries from the + // records that were deleted via the plural endpoint. + // We will detect them by comparing the current state of the collection + // and the full history of the collection since its genesis. + // List full history of collection. + const { data: fullHistory } = await this.bucket.listHistory({ + pages: Infinity, + sort: "last_modified", + filters: { + resource_name: "record", + collection_id: this.name, + }, + }); + // Keep latest entry ever, and latest within snapshot window. + // (history is sorted chronologically) + const latestEver = new Map(); + const latestInSnapshot = new Map(); + for (const entry of fullHistory) { + if (entry.target.data.last_modified <= at) { + // Snapshot includes changes right on timestamp. + latestInSnapshot.set(entry.record_id, entry); + } + latestEver.set(entry.record_id, entry); + } + // Current records ids in the collection. + const { data: current } = await this.listRecords({ + pages: Infinity, + fields: ["id"], // we don't need attributes. + }); + const currentIds = new Set(current.map(record => record.id)); + // If a record is not in the current collection, and its + // latest history entry isn't a delete then this means that + // it was deleted via the plural endpoint (and that we lost track + // of this deletion because of bug #963) + const deletedViaPlural = new Set(); + for (const entry of latestEver.values()) { + if (entry.action != "delete" && !currentIds.has(entry.record_id)) { + deletedViaPlural.add(entry.record_id); + } + } + // Now reconstruct the collection based on latest version in snapshot + // filtering all deleted records. + const reconstructed = []; + for (const entry of latestInSnapshot.values()) { + if (entry.action != "delete" && !deletedViaPlural.has(entry.record_id)) { + reconstructed.push(entry.target.data); + } + } + return { + last_modified: String(at), + data: Array.from(reconstructed).sort( + (a, b) => b.last_modified - a.last_modified + ), + next: () => { + throw new Error("Snapshots don't support pagination"); + }, + hasNextPage: false, + totalRecords: reconstructed.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<Object, Error>} + */ + 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 { + ...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 { safe: this._safe, ...options }.safe; + } + /** + * As _getSafe, but for "retry". + * + * @private + */ + _getRetry(options) { + return { 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<String, Error>} + */ + 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<String, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + async setData(data, options = {}) { + if (!isObject(data)) { + throw new Error("A bucket object is required."); + } + const bucket = { + ...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 } = { ...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<Array<Object>, 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<Array<Object>, 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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 } = { ...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), + }); + } + /** + * Deletes collections from 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<Array<Object>, Error>} + */ + async deleteCollections(options = {}) { + const path = this._endpoints.collection(this.name); + return this.client.paginatedDelete(path, options, { + headers: this._getHeaders(options), + 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<Array<Object>, 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<Object, Error>} + */ + 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<String>} [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<Object, Error>} + */ + async createGroup(id, members = [], options = {}) { + const data = { + ...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<Object, Error>} + */ + 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 = { + ...options.data, + ...group, + }; + const path = this._endpoints.group(this.name, group.id); + const { patch, permissions } = options; + const { last_modified } = { ...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<Object, Error>} + */ + async deleteGroup(group, options = {}) { + const groupObj = toDataBody(group); + const { id } = groupObj; + const { last_modified } = { ...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), + }); + } + /** + * Deletes groups from 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<Array<Object>, Error>} + */ + async deleteGroups(options = {}) { + const path = this._endpoints.group(this.name); + return this.client.paginatedDelete(path, options, { + headers: this._getHeaders(options), + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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://demo.kinto-storage.org/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. + * @param {Function} [options.fetchFunc=fetch] The function to be used to execute HTTP requests. + */ + 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 { fetchFunc, requestMode, timeout } = options; + /** + * The HTTP instance. + * @ignore + * @type {HTTP} + */ + this.http = new HTTP(this.events, { fetchFunc, 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 = { + ...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 { + ...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 { safe: this._safe, ...options }.safe; + } + /** + * As _getSafe, but for "retry". + * + * @private + */ + _getRetry(options) { + return { 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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<Object, Error>} + */ + 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); + } + return responses; + } + 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; + } + /** + * Perform an operation with a given HTTP method on some pages from + * a paginated list, following the `next-page` header automatically + * until we have processed the requested number of pages. Return a + * response with a `.next()` method that can be called to perform + * the requested HTTP method on 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 doing operation on pages. + * @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 operate on. Undefined means one page. Pass + * Infinity to operate on everything. + * @param {String} [params.since=undefined] + * The ETag from which to start doing operation on pages. + * @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. + * @param {String} [options.method="GET"] + * The method to use in the request. + */ + async paginatedOperation(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 } = { + 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 = { + ...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 = new 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, + method: options.method, + }, + // 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 } + ) + ); + } + /** + * 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 = {}) { + return this.paginatedOperation(path, params, options); + } + /** + * Delete multiple objects, following the pagination if the number of + * objects exceeds the page limit until we have deleted the requested + * number of pages. Return a response with a `.next()` method that can + * be called to delete 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 deleting. + * @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 delete. Undefined means one page. Pass + * Infinity to delete everything. + * @param {String} [params.since=undefined] + * The ETag from which to start deleting. + * @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. + */ + paginatedDelete(path, params = {}, options = {}) { + const { headers, safe, last_modified } = options; + const deleteRequest$1 = deleteRequest(path, { + headers, + safe: safe ? safe : false, + last_modified, + }); + return this.paginatedOperation(path, params, { + ...options, + headers: deleteRequest$1.headers, + method: "DELETE", + }); + } + /** + * 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<Object[], Error>} + */ + 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 = { 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<Object[], Error>} + */ + 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<Object, Error>} + */ + async createBucket(id, options = {}) { + const { data, permissions } = options; + const _data = { ...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<Object, Error>} + */ + 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 } = { ...bucketObj, ...options }; + return this.execute( + deleteRequest(path, { + last_modified, + headers: this._getHeaders(options), + safe: this._getSafe(options), + }), + { retry: this._getRetry(options) } + ); + } + /** + * Deletes buckets. + * + * @param {Object} [options={}] The options object. + * @param {Boolean} [options.safe] The safe option. + * @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. + * @param {Number} [options.last_modified] The last_modified option. + * @return {Promise<Object[], Error>} + */ + async deleteBuckets(options = {}) { + const path = ENDPOINTS.bucket(); + return this.paginatedDelete(path, options, { + headers: this._getHeaders(options), + retry: this._getRetry(options), + safe: options.safe, + last_modified: options.last_modified, + }); + } + 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. + */ +/* @ts-ignore */ +class KintoHttpClient extends KintoClientBase { + constructor(remote, options = {}) { + const events = {}; + EventEmitter.decorate(events); + super(remote, { events: events, ...options }); + } +} +KintoHttpClient.errors = errors; + +export { KintoHttpClient }; |