UNPKG

kinto

Version:

An Offline-First JavaScript client for Kinto.

476 lines (475 loc) 14.6 kB
export const RE_RECORD_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; /** * Checks if a value is undefined. * @param {Any} value * @return {Boolean} */ function _isUndefined(value) { return typeof value === "undefined"; } /** * Sorts records in a list according to a given ordering. * * @param {String} order The ordering, eg. `-last_modified`. * @param {Array} list The collection to order. * @return {Array} */ export function sortObjects(order, list) { const hasDash = order[0] === "-"; const field = hasDash ? order.slice(1) : order; const direction = hasDash ? -1 : 1; return list.slice().sort((a, b) => { if (a[field] && _isUndefined(b[field])) { return direction; } if (b[field] && _isUndefined(a[field])) { return -direction; } if (_isUndefined(a[field]) && _isUndefined(b[field])) { return 0; } return a[field] > b[field] ? direction : -direction; }); } /** * Test if a single object matches all given filters. * * @param {Object} filters The filters object. * @param {Object} entry The object to filter. * @return {Boolean} */ export function filterObject(filters, entry) { return Object.keys(filters).every((filter) => { const value = filters[filter]; if (Array.isArray(value)) { return value.some((candidate) => candidate === entry[filter]); } else if (typeof value === "object") { return filterObject(value, entry[filter]); } else if (!Object.prototype.hasOwnProperty.call(entry, filter)) { console.error(`The property ${filter} does not exist`); return false; } return entry[filter] === value; }); } /** * Filters records in a list matching all given filters. * * @param {Object} filters The filters object. * @param {Array} list The collection to filter. * @return {Array} */ export function filterObjects(filters, list) { return list.filter((entry) => { return filterObject(filters, entry); }); } /** * Resolves a list of functions sequentially, which can be sync or async; in * case of async, functions must return a promise. * * @param {Array} fns The list of functions. * @param {Any} init The initial value. * @return {Promise} */ export function waterfall(fns, init) { if (!fns.length) { return Promise.resolve(init); } return fns.reduce((promise, nextFn) => { return promise.then(nextFn); }, Promise.resolve(init)); } /** * Simple deep object comparison function. This only supports comparison of * serializable JavaScript objects. * * @param {Object} a The source object. * @param {Object} b The compared object. * @return {Boolean} */ export function deepEqual(a, b) { if (a === b) { return true; } if (typeof a !== typeof b) { return false; } if (!(a && typeof a === "object") || !(b && typeof b === "object")) { return false; } if (Object.keys(a).length !== Object.keys(b).length) { return false; } for (const k in a) { if (!deepEqual(a[k], b[k])) { return false; } } return true; } /** * Return an object without the specified keys. * * @param {Object} obj The original object. * @param {Array} keys The list of keys to exclude. * @return {Object} A copy without the specified keys. */ export function omitKeys(obj, keys = []) { const result = { ...obj }; for (const key of keys) { delete result[key]; } return result; } export function arrayEqual(a, b) { if (a.length !== b.length) { return false; } for (let i = a.length; i--;) { if (a[i] !== b[i]) { return false; } } return true; } function makeNestedObjectFromArr(arr, val, nestedFiltersObj) { const last = arr.length - 1; return arr.reduce((acc, cv, i) => { if (i === last) { return (acc[cv] = val); } else if (Object.prototype.hasOwnProperty.call(acc, cv)) { return acc[cv]; } return (acc[cv] = {}); }, nestedFiltersObj); } export function transformSubObjectFilters(filtersObj) { const transformedFilters = {}; for (const key in filtersObj) { const keysArr = key.split("."); const val = filtersObj[key]; makeNestedObjectFromArr(keysArr, val, transformedFilters); } return transformedFilters; } /** * Deeply access an object's properties * @param obj - The object whose property you want to compare * @param key - A dot notation path to the property you want to compare */ export function getDeepKey(obj, key) { const segments = key.split("."); let result = obj; for (let p = 0; p < segments.length; p++) { result = result ? result[segments[p]] : undefined; } return result ?? undefined; } /** * Chunks an array into n pieces. * * @private * @param {Array} array * @param {Number} n * @return {Array} */ export 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> */ export 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} */ export 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} */ export 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. */ export 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} */ export 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} */ export 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} */ export 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} */ export function isObject(thing) { return typeof thing === "object" && thing !== null && !Array.isArray(thing); } /** * Parses a data url. * @param {String} dataURL The data url. * @return {Object} */ export 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} */ export 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} */ export 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 */ export 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. */ export 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 */ export 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; }