kinto
Version:
An Offline-First JavaScript client for Kinto.
764 lines (763 loc) • 31.1 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __decorate = (this && this.__decorate) || function (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;
};
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SUPPORTED_PROTOCOL_VERSION = void 0;
const utils_1 = require("../utils");
const http_1 = __importDefault(require("./http"));
const endpoints_1 = __importDefault(require("./endpoints"));
const requests = __importStar(require("./requests"));
const batch_1 = require("./batch");
const bucket_1 = __importDefault(require("./bucket"));
/**
* Currently supported protocol version.
* @type {String}
*/
exports.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_1.default;
const { fetchFunc, requestMode, timeout } = options;
/**
* The HTTP instance.
* @ignore
* @type {HTTP}
*/
this.http = new http_1.default(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 !== exports.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_1.default(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_1.default.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 = (0, utils_1.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_1.default.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 (0, batch_1.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 + (0, utils_1.addEndpointOptions)(request.path, options);
const result = await this.http.request(uri, (0, utils_1.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 = (0, utils_1.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 = requests.deleteRequest(path, {
headers,
safe: safe ? safe : false,
last_modified,
});
return this.paginatedOperation(path, params, {
...options,
headers: deleteRequest.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_1.default.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_1.default.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_1.default.bucket(_data.id) : endpoints_1.default.bucket();
return this.execute(requests.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 = (0, utils_1.toDataBody)(bucket);
if (!bucketObj.id) {
throw new Error("A bucket id is required.");
}
const path = endpoints_1.default.bucket(bucketObj.id);
const { last_modified } = { ...bucketObj, ...options };
return this.execute(requests.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_1.default.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(requests.createRequest(`/accounts/${username}`, { data: { password } }, { method: "PUT" }));
}
}
exports.default = KintoClientBase;
__decorate([
(0, utils_1.nobatch)("This operation is not supported within a batch operation.")
], KintoClientBase.prototype, "fetchServerSettings", null);
__decorate([
(0, utils_1.nobatch)("This operation is not supported within a batch operation.")
], KintoClientBase.prototype, "fetchServerCapabilities", null);
__decorate([
(0, utils_1.nobatch)("This operation is not supported within a batch operation.")
], KintoClientBase.prototype, "fetchUser", null);
__decorate([
(0, utils_1.nobatch)("This operation is not supported within a batch operation.")
], KintoClientBase.prototype, "fetchHTTPApiVersion", null);
__decorate([
(0, utils_1.nobatch)("Can't use batch within a batch!")
], KintoClientBase.prototype, "batch", null);
__decorate([
(0, utils_1.capable)(["permissions_endpoint"])
], KintoClientBase.prototype, "listPermissions", null);
__decorate([
(0, utils_1.support)("1.4", "2.0")
], KintoClientBase.prototype, "deleteBuckets", null);
__decorate([
(0, utils_1.capable)(["accounts"])
], KintoClientBase.prototype, "createAccount", null);