mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-23 21:01:08 +00:00
712ef17f9e
Differential Revision: https://phabricator.services.mozilla.com/D181066
3062 lines
100 KiB
JavaScript
3062 lines
100 KiB
JavaScript
/*
|
|
*
|
|
* 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 };
|