UNPKG

kinto

Version:

An Offline-First JavaScript client for Kinto.

764 lines (763 loc) 31.1 kB
"use strict"; 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);