kinto
Version:
An Offline-First JavaScript client for Kinto.
503 lines (502 loc) • 15.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.RE_RECORD_ID = void 0;
exports.sortObjects = sortObjects;
exports.filterObject = filterObject;
exports.filterObjects = filterObjects;
exports.waterfall = waterfall;
exports.deepEqual = deepEqual;
exports.omitKeys = omitKeys;
exports.arrayEqual = arrayEqual;
exports.transformSubObjectFilters = transformSubObjectFilters;
exports.getDeepKey = getDeepKey;
exports.partition = partition;
exports.delay = delay;
exports.toDataBody = toDataBody;
exports.qsify = qsify;
exports.checkVersion = checkVersion;
exports.support = support;
exports.capable = capable;
exports.nobatch = nobatch;
exports.isObject = isObject;
exports.parseDataURL = parseDataURL;
exports.extractFileInfo = extractFileInfo;
exports.createFormData = createFormData;
exports.cleanUndefinedProperties = cleanUndefinedProperties;
exports.addEndpointOptions = addEndpointOptions;
exports.obscureAuthorizationHeader = obscureAuthorizationHeader;
exports.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}
*/
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}
*/
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}
*/
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}
*/
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}
*/
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.
*/
function omitKeys(obj, keys = []) {
const result = { ...obj };
for (const key of keys) {
delete result[key];
}
return result;
}
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);
}
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
*/
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}
*/
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;
}