farmos
Version:
A JavaScript library for working with farmOS data structures and interacting with farmOS servers.
1,382 lines (1,280 loc) • 116 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var omit = require('ramda/src/omit.js');
var compose$1 = require('ramda/src/compose.js');
var rFilter = require('ramda/src/filter.js');
var is = require('ramda/src/is');
var map = require('ramda/src/map.js');
var path$1 = require('ramda/src/path.js');
var axios = require('axios');
var compose = require('ramda/src/compose');
var concat = require('ramda/src/concat');
var defaultTo = require('ramda/src/defaultTo');
var isEmpty = require('ramda/src/isEmpty');
var reduce = require('ramda/src/reduce');
var unless = require('ramda/src/unless');
var curryN = require('ramda/src/curryN');
var mergeDeepWithKey = require('ramda/src/mergeDeepWithKey');
var path = require('ramda/src/path');
var pick = require('ramda/src/pick');
var match = require('ramda/src/match');
var unary = require('ramda/src/unary');
var append = require('ramda/src/append.js');
var curry = require('ramda/src/curry.js');
var evolve = require('ramda/src/evolve.js');
var reduce$1 = require('ramda/src/reduce.js');
var dissoc = require('ramda/src/dissoc.js');
var has = require('ramda/src/has.js');
var mapObjIndexed$1 = require('ramda/src/mapObjIndexed');
var pick$1 = require('ramda/src/pick.js');
var prop = require('ramda/src/prop.js');
var replace = require('ramda/src/replace.js');
var unless$1 = require('ramda/src/unless.js');
var addIndex = require('ramda/src/addIndex.js');
var clone = require('ramda/src/clone.js');
var identity = require('ramda/src/identity.js');
var mapObjIndexed = require('ramda/src/mapObjIndexed.js');
var anyPass = require('ramda/src/anyPass.js');
var mergeWith = require('ramda/src/mergeWith.js');
var allPass = require('ramda/src/allPass');
var any = require('ramda/src/any');
var assoc = require('ramda/src/assoc');
var chain = require('ramda/src/chain');
var equals = require('ramda/src/equals');
var evolve$1 = require('ramda/src/evolve');
var rFilter$1 = require('ramda/src/filter');
var hasPath = require('ramda/src/hasPath');
var map$1 = require('ramda/src/map');
var mergeRight = require('ramda/src/mergeRight');
var mergeWith$1 = require('ramda/src/mergeWith');
var partition = require('ramda/src/partition');
var pickBy = require('ramda/src/pickBy');
var prop$1 = require('ramda/src/prop');
var sort = require('ramda/src/sort');
var startsWith = require('ramda/src/startsWith');
var test = require('ramda/src/test');
var uniqBy = require('ramda/src/uniqBy');
var uuid = require('uuid');
var dropLast = require('ramda/src/dropLast.js');
var cond = require('ramda/src/cond.js');
var eqBy = require('ramda/src/eqBy.js');
var equals$1 = require('ramda/src/equals.js');
var isNil = require('ramda/src/isNil.js');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var omit__default = /*#__PURE__*/_interopDefaultLegacy(omit);
var compose__default$1 = /*#__PURE__*/_interopDefaultLegacy(compose$1);
var rFilter__default = /*#__PURE__*/_interopDefaultLegacy(rFilter);
var is__default = /*#__PURE__*/_interopDefaultLegacy(is);
var map__default = /*#__PURE__*/_interopDefaultLegacy(map);
var path__default$1 = /*#__PURE__*/_interopDefaultLegacy(path$1);
var axios__default = /*#__PURE__*/_interopDefaultLegacy(axios);
var compose__default = /*#__PURE__*/_interopDefaultLegacy(compose);
var concat__default = /*#__PURE__*/_interopDefaultLegacy(concat);
var defaultTo__default = /*#__PURE__*/_interopDefaultLegacy(defaultTo);
var isEmpty__default = /*#__PURE__*/_interopDefaultLegacy(isEmpty);
var reduce__default = /*#__PURE__*/_interopDefaultLegacy(reduce);
var unless__default = /*#__PURE__*/_interopDefaultLegacy(unless);
var curryN__default = /*#__PURE__*/_interopDefaultLegacy(curryN);
var mergeDeepWithKey__default = /*#__PURE__*/_interopDefaultLegacy(mergeDeepWithKey);
var path__default = /*#__PURE__*/_interopDefaultLegacy(path);
var pick__default = /*#__PURE__*/_interopDefaultLegacy(pick);
var match__default = /*#__PURE__*/_interopDefaultLegacy(match);
var unary__default = /*#__PURE__*/_interopDefaultLegacy(unary);
var append__default = /*#__PURE__*/_interopDefaultLegacy(append);
var curry__default = /*#__PURE__*/_interopDefaultLegacy(curry);
var evolve__default = /*#__PURE__*/_interopDefaultLegacy(evolve);
var reduce__default$1 = /*#__PURE__*/_interopDefaultLegacy(reduce$1);
var dissoc__default = /*#__PURE__*/_interopDefaultLegacy(dissoc);
var has__default = /*#__PURE__*/_interopDefaultLegacy(has);
var mapObjIndexed__default$1 = /*#__PURE__*/_interopDefaultLegacy(mapObjIndexed$1);
var pick__default$1 = /*#__PURE__*/_interopDefaultLegacy(pick$1);
var prop__default = /*#__PURE__*/_interopDefaultLegacy(prop);
var replace__default = /*#__PURE__*/_interopDefaultLegacy(replace);
var unless__default$1 = /*#__PURE__*/_interopDefaultLegacy(unless$1);
var addIndex__default = /*#__PURE__*/_interopDefaultLegacy(addIndex);
var clone__default = /*#__PURE__*/_interopDefaultLegacy(clone);
var identity__default = /*#__PURE__*/_interopDefaultLegacy(identity);
var mapObjIndexed__default = /*#__PURE__*/_interopDefaultLegacy(mapObjIndexed);
var anyPass__default = /*#__PURE__*/_interopDefaultLegacy(anyPass);
var mergeWith__default = /*#__PURE__*/_interopDefaultLegacy(mergeWith);
var allPass__default = /*#__PURE__*/_interopDefaultLegacy(allPass);
var any__default = /*#__PURE__*/_interopDefaultLegacy(any);
var assoc__default = /*#__PURE__*/_interopDefaultLegacy(assoc);
var chain__default = /*#__PURE__*/_interopDefaultLegacy(chain);
var equals__default = /*#__PURE__*/_interopDefaultLegacy(equals);
var evolve__default$1 = /*#__PURE__*/_interopDefaultLegacy(evolve$1);
var rFilter__default$1 = /*#__PURE__*/_interopDefaultLegacy(rFilter$1);
var hasPath__default = /*#__PURE__*/_interopDefaultLegacy(hasPath);
var map__default$1 = /*#__PURE__*/_interopDefaultLegacy(map$1);
var mergeRight__default = /*#__PURE__*/_interopDefaultLegacy(mergeRight);
var mergeWith__default$1 = /*#__PURE__*/_interopDefaultLegacy(mergeWith$1);
var partition__default = /*#__PURE__*/_interopDefaultLegacy(partition);
var pickBy__default = /*#__PURE__*/_interopDefaultLegacy(pickBy);
var prop__default$1 = /*#__PURE__*/_interopDefaultLegacy(prop$1);
var sort__default = /*#__PURE__*/_interopDefaultLegacy(sort);
var startsWith__default = /*#__PURE__*/_interopDefaultLegacy(startsWith);
var test__default = /*#__PURE__*/_interopDefaultLegacy(test);
var uniqBy__default = /*#__PURE__*/_interopDefaultLegacy(uniqBy);
var dropLast__default = /*#__PURE__*/_interopDefaultLegacy(dropLast);
var cond__default = /*#__PURE__*/_interopDefaultLegacy(cond);
var eqBy__default = /*#__PURE__*/_interopDefaultLegacy(eqBy);
var equals__default$1 = /*#__PURE__*/_interopDefaultLegacy(equals$1);
var isNil__default = /*#__PURE__*/_interopDefaultLegacy(isNil);
/**
* farmOS client method for sending an entity to a Drupal JSON:API server
* @typedef {Function} deleteEntity
* @param {String} bundle The bundle type (eg, 'activity', 'equipment', etc).
* @param {Object} entity The entity being sent to the server.
* @returns {Promise}
*/
/**
* @param {String} entity
* @param {Function} request
* @returns {deleteEntity}
*/
const deleteEntity = (entity, request) => (bundle, id) => request(
`/api/${entity}/${bundle}/${id}`,
{ method: 'DELETE' },
);
function mergeParams(p1, p2) {
const params = new URLSearchParams(p1);
new URLSearchParams(p2).forEach((val, key) => {
params.append(key, val);
});
return params.toString();
}
// Helper for determining if a value is a primitive data structure
const isPrim = val =>
['string', 'number', 'boolean'].includes(typeof val) || val === null;
const logical = {
$and: 'AND',
$or: 'OR',
};
const comparison = {
$eq: '%3D',
$ne: '<>',
$gt: '>',
$gte: '>=',
$lt: '<',
$lte: '<=',
$in: 'IN',
$nin: 'NOT%20IN',
};
const truthyForms = [true, 1, 'true', 'TRUE', 'T'];
const falseyForms = [false, 0, 'false', 'FALSE', 'F'];
const booleanForms = [...truthyForms, ...falseyForms];
const booleanTransform = bool => (truthyForms.includes(bool) ? 1 : 0);
function transformRawValue(transform, raw) {
let value = raw;
if (typeof transform === 'function') {
value = transform(value);
}
if (booleanForms.includes(value)) {
value = booleanTransform(value);
}
return value;
}
function parseFilter(filter = {}, options = {}) {
const { filterTransforms = {} } = options;
function parseComparison(path, expr, comGroup = null, index = 0) {
const amp = index > 0 ? '&' : '';
const pre = `filter[${path}-${index}-filter][condition]`;
const membership = comGroup ? `&${pre}[memberOf]=${comGroup}` : '';
const [[op, rawValue], ...tail] = Object.entries(expr);
const val = transformRawValue(filterTransforms[path], rawValue);
if (val === null) {
const pathStr = `${amp}filter[${path}-filter][condition][path]=${path}`;
const opStr = `&filter[${path}-filter][condition][operator]=IS%20NULL`;
return pathStr + opStr + membership;
}
const urlEncodedOp = comparison[op];
if (!urlEncodedOp) throw new Error(`Invalid comparison operator: ${op}`);
const pathStr = `${amp}${pre}[path]=${path}`;
const opStr = `&${pre}[operator]=${urlEncodedOp}`;
const valStr = Array.isArray(val)
? val.reduce((substr, v, i) => `${substr}&${pre}[value][${i}]=${v}`, '')
: `&${pre}[value]=${val}`;
const str = pathStr + opStr + valStr + membership;
if (tail.length === 0) return str;
const nextExpr = Object.fromEntries(tail);
return str + parseComparison(path, nextExpr, comGroup, index + 1);
}
function parseLogic(op, filters, logicGroup, logicDepth) {
const label = `group-${logicDepth}`;
const conjunction = `&filter[${label}][group][conjunction]=${logical[op]}`;
const membership = logicGroup ? `&filter[${label}][condition][memberOf]=${logicGroup}` : '';
return filters.reduce(
// eslint-disable-next-line no-use-before-define
(params, f) => mergeParams(params, parser(f, label, logicDepth + 1)),
conjunction + membership,
);
}
function parseField(path, val, fieldGroup, fieldDepth) {
if (isPrim(val)) {
return parseComparison(path, { $eq: val }, fieldGroup);
}
if (Array.isArray(val) || '$or' in val) {
const arr = Array.isArray(val) ? val : val.$or;
if (!Array.isArray(arr)) {
throw new Error(`The value of \`${path}.$or\` must be an array. `
+ `Invalid constructor: ${arr.constructor.name}`);
}
const filters = arr.map(v => (isPrim(v) ? { [path]: v } : v));
return parseLogic('$or', filters, fieldGroup, fieldDepth + 1);
}
if ('$and' in val) {
if (!Array.isArray(val.$and)) {
throw new Error(`The value of \`${path}.$and\` must be an array. `
+ `Invalid constructor: ${val.$and.constructor.name}`);
}
return parseLogic('$and', val.$and, fieldGroup, fieldDepth + 1);
}
// Otherwise we assume val is an object and all its properties are comparison
// operators; parseComparison will throw if any property is NOT a comp op.
return parseComparison(path, val, fieldGroup);
}
const parser = (_filter, group, depth = 0) => {
if (Array.isArray(_filter)) {
return parseLogic('$or', _filter, group, depth);
}
let params = '';
const entries = Object.entries(_filter);
if (entries.length === 0) return params;
const [[key, val], ...rest] = entries;
if (['$and', '$or'].includes(key)) {
params = parseLogic(key, val, group, depth);
}
if (key && val !== undefined) {
params = parseField(key, val, group, depth);
}
if (rest.length === 0) return params;
const tailParams = parser(Object.fromEntries(rest));
return mergeParams(params, tailParams);
};
return parser(filter);
}
/**
* @typedef {Object} FetchOptions
* @property {Object} [filter]
* @property {Object} [filterTransforms]
* @property {Array|String} [include]
* @property {Number} [limit]
* @property {Object} [sort]
*/
/**
* farmOS client method for fetching entities from a Drupal JSON:API server
* @typedef {Function} fetchEntity
* @param {String} bundle The bundle type (eg, 'activity', 'equipment', etc).
* @param {FetchOptions} [options] Options for the fetch request.
* @returns {Promise}
*/
/** @type {(limit: Number?) => String} */
const parseLimit = limit =>
(Number.isInteger(limit) && limit > 0 ? `&page[limit]=${limit}` : '');
const concatSortParams = (prev, [path, direction]) =>
`${prev !== '' ? ',' : ''}${direction === 'DESC' ? '-' : ''}${path}`;
/** @type {(sort: Object?) => String} */
const parseSort = compose__default["default"](
unless__default["default"](isEmpty__default["default"], concat__default["default"]('&sort=')),
reduce__default["default"](concatSortParams, ''),
Object.entries,
defaultTo__default["default"]({}),
);
/** @type {(sort: Array) => String} */
const parseIncludeArray = reduce__default["default"](
(params, str) => `${params || '&include='}${params ? ',' : ''}${str}`,
'',
);
/** @type {(sort: Array|String?) => String} */
const parseInclude = include => {
if (Array.isArray(include)) return parseIncludeArray(include);
if (!include || typeof include !== 'string') return '';
return `&include=${include}`;
};
/**
* @param {FetchOptions} options
* @returns {String}
*/
function parseFetchParams(options = {}) {
const {
filter, filterTransforms, include, limit, sort,
} = options;
const filterParams = parseFilter(filter, { filterTransforms });
const limitParams = parseLimit(limit);
const sortParams = parseSort(sort);
const includeParams = parseInclude(include);
return filterParams + limitParams + sortParams + includeParams;
}
/**
* @param {String} entity
* @param {Function} request
* @returns {fetchEntity}
*/
const fetchEntity = (entity, request) => (bundle, options) =>
request(`/api/${entity}/${bundle}?${parseFetchParams(options)}`);
/**
* @type {RegExp} Identifies valid format for an entity type (eg 'log--activity)
* and groups matches by type, entity & bundle.
*/
const entityTypeRegEx = /(\w+)--(\w+)/;
/**
* @type {Function} Validates a string as an entity type and parses it.
* @param {String} type A possible entity type (eg, 'log--activity').
* @returns {{ type?: String, entity?: String, bundle?: String }}
* */
const parseEntityType = unary__default["default"](compose__default["default"](
([type, entity, bundle]) => ({ type, entity, bundle }),
match__default["default"](entityTypeRegEx),
defaultTo__default["default"](''),
));
/**
* @type {(fields?: Object) => Object} Takes any object containing entity data,
* such as props or fields, then normalizes the type, bundle & field.
* @param {{ type?: String, entity?: String, bundle?: string }} fields
* @returns {{ type?: String, entity?: String, bundle?: String }}
*/
function parseTypeFromFields(fields = {}) {
let { entity, bundle, type } = fields;
if (type) ({ entity, bundle } = parseEntityType(type));
if (!type && entity && bundle) type = `${entity}--${bundle}`;
return { entity, bundle, type };
}
/**
* @typedef {Array<{ type: String, filter: Object|Array }>} FiltersByType
*/
/**
* @param {Object|Array|Undefined} filter
* @param {Array<String>} validTypes
* @returns {FiltersByType}
*/
function splitFilterByType(filter, validTypes) {
/** @type {FiltersByType} */
const filtersByType = [];
// A plain array is equivalent to an object w/ an array as the `$or` property.
// In both cases, the array must itself contain valid filters, which can be
// evaluated recursively.
if (Array.isArray(filter.$or) || Array.isArray(filter)) {
(filter.$or || filter).forEach((f) => {
splitFilterByType(f, validTypes).forEach((fbtA) => {
// Instead of just adding every filterByType to the list, look for a
// matching type that's already been added.
const fbtB = filtersByType.find(fbtZ => fbtZ.type === fbtA.type);
// If so, combine them into a single object w/ an array of filters.
if (fbtB) {
// Both matching and current filters can be an array or single filter,
// so concat onto an empty array to flatten them and reassign it.
fbtB.filter = [].concat(fbtB.filter, fbtA.filter);
// Otherwise, add the new filterByType to the list entirely as-is.
} else {
filtersByType.push(fbtA);
}
});
});
return filtersByType;
}
// The filter must either be an object (logical $and) or an array (logical $or).
// If it's neither, then it's not a valid filter, so return the empty array.
if (typeof filter !== 'object') return filtersByType;
// Technically any object is equivalent to an object w/ an `$and` property,
// which is itself an object. Also, one type filter is not permitted to be
// nested under another, so we can safely pluck the type and ignore the rest.
const { type, ...rest } = typeof filter.$and === 'object' ? filter.$and : filter;
// The case of filtering by a single type.
if (typeof type === 'string') {
if (!validTypes.includes(type)) return filtersByType;
filtersByType.push({ type, filter: rest });
}
// The case of filtering by multiple types.
if (Array.isArray(type)) {
type.forEach((t) => {
if (validTypes.includes(t)) {
filtersByType.push({ type: t, filter: rest });
}
});
}
// An undefined or null type is interpreted as ALL types, so push the rest of
// the filter properties onto the array for each and every valid type.
if ([undefined, null].includes(type)) {
validTypes.forEach((t) => {
filtersByType.push({ type: t, filter: rest });
});
}
return filtersByType;
}
const toResourceId = compose__default["default"](
pick__default["default"](['id', 'type']),
path__default["default"](['data', 'data']),
);
const mapResponseData = response =>
(Array.isArray(response) ? response.map(toResourceId) : toResourceId(response));
const concatRelationshp = (original, updated) =>
(Array.isArray(original) ? concat__default["default"](original, updated) : updated);
const updateRelationship = mergeDeepWithKey__default["default"]((key, related, original) =>
(key === 'data' ? concatRelationshp(original, related) : original));
// Update the fields on an entity that had files related to it on those fields.
// The responses are from separate requests to send the files, with a special
// file entity's id and type contained in the response data. The updated entity
// is sent in a subsequent request only after all files are sent.
const updateFileField = reduce__default["default"]((entity, [field, response]) => {
const relationship = {
relationships: {
[field]: {
data: mapResponseData(response),
},
},
};
return updateRelationship(entity, relationship);
});
const toRequestConfig = curryN__default["default"](2, (url, { data = null, filename = 'untitled' } = {}) => {
if (!data) return Promise.resolve(null);
const headers = {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `file; filename="${filename}"`,
};
return {
data, headers, method: 'POST', url,
};
});
function sendFiles(request, hostEntity, files = {}) {
const { entity, bundle } = parseTypeFromFields(hostEntity);
const fileRequests = Object.entries(files).map(([field, attributes]) => {
const url = `/api/${entity}/${bundle}/${field}`;
const sendFile = compose__default["default"](request, toRequestConfig(url));
const promise = Array.isArray(attributes)
? Promise.all(attributes.map(sendFile))
: sendFile(attributes);
return promise.then(response => [field, response]);
});
return Promise.all(fileRequests).then(updateFileField(hostEntity));
}
/**
* @typedef {Object} SendOptions
* @property {Object} [files]
*/
/**
* farmOS client method for sending an entity to a Drupal JSON:API server
* @typedef {Function} sendEntity
* @param {String} bundle The bundle type (eg, 'activity', 'equipment', etc).
* @param {Object} entity The entity being sent to the server.
* @param {SendOptions} [options]
* @returns {Promise}
*/
/**
* @type {Function}
* @param {String} entityName
* @param {Function} request
* @returns {sendEntity}
*/
var sendEntity = (entityName, request) => function send(bundle, entity, options = {}) {
const post = data => request({
data: JSON.stringify({ data }),
method: 'POST',
url: `/api/${entityName}/${bundle}`,
});
const patch = data => request({
data: JSON.stringify({ data }),
method: 'PATCH',
url: `/api/${entityName}/${bundle}/${data.id}`,
});
const is404 = error => error.response && error.response.status === 404;
function sendEntity(data) {
if (!data.id) return post(data);
// We assume if an entity has an id it is a PATCH request, but that may not be
// the case if it has a client-generated id. Such a PATCH request will result
// in a 404 (NOT FOUND), since the endpoint includes the id. We handle this
// error with a POST request, but otherwise return a rejected promise.
return patch(data).catch(e => (is404(e) ? post(data) : Promise.reject(e)));
}
if (!options.files) return sendEntity(entity);
return sendFiles(request, entity, options.files).then(sendEntity);
};
/**
* @typedef {Object} OAuthMethods
* @property {Function} authorize
* @property {Function} getToken
*/
/**
* @typedef {Function} OAuthMixin
* @param {import('axios').AxiosInstance} request
* @param {Object} authOptions
* @property {String} authOptions.host
* @property {String} authOptions.clientId
* @property {Function} [authOptions.getToken]
* @property {Function} [authOptions.setToken]
* @returns {Object}
*/
function oAuth(request, authOptions) {
let memToken = {};
const {
host = '',
clientId = '',
getToken = () => memToken,
setToken = (t) => { memToken = t; },
} = authOptions;
const accessTokenUri = `${host}/oauth/token`;
/*
* SUBSCRIBE TO TOKEN REFRESH
* Based on https://gist.github.com/mkjiau/650013a99c341c9f23ca00ccb213db1c
*/
// Keep track if the OAuth token is being refreshed.
let isRefreshing = false;
// Array of callbacks to call once token is refreshed.
let subscribers = [];
// Add to array of callbacks.
function subscribeTokenRefresh(resolve, reject) {
subscribers.push({ resolve, reject });
}
// Call all subscribers.
function onRefreshed(token) {
subscribers.forEach(({ resolve }) => { resolve(token); });
}
// Make sure promises fulfill with a rejection if the refresh fails.
function onFailedRefresh(error) {
subscribers.forEach(({ reject }) => { reject(error); });
}
// Helper function to parse tokens from server.
function parseToken(token) {
// Calculate new expiration time.
const newToken = !token.expires
? { ...token, expires: (Date.now() + token.expires_in * 1000) }
: token;
// Update the token state.
setToken(newToken);
return newToken;
}
// Helper function to refresh OAuth2 token.
function refreshToken(token) {
isRefreshing = true;
const refreshParams = new URLSearchParams();
refreshParams.append('grant_type', 'refresh_token');
refreshParams.append('client_id', clientId);
refreshParams.append('refresh_token', token);
return axios__default["default"].post(accessTokenUri, refreshParams)
.then((res) => {
const newToken = parseToken(res.data);
isRefreshing = false;
onRefreshed(newToken.access_token);
subscribers = [];
return newToken;
})
.catch((error) => {
onFailedRefresh(error);
subscribers = [];
isRefreshing = false;
throw error;
});
}
// Helper function to get an OAuth access token.
// This will attempt to refresh the token if needed.
// Returns a Promise that resvoles as the access token.
function getAccessToken(token) {
// Wait for new access token if currently refreshing.
if (isRefreshing) {
return new Promise(subscribeTokenRefresh);
}
// Refresh if token expired.
// - 1000 ms to factor for tokens that might expire while in flight.
if (!isRefreshing && token.expires - 1000 < Date.now()) {
return new Promise((resolve, reject) => {
refreshToken(token.refresh_token)
.then(t => resolve(t.access_token))
.catch(reject);
});
}
// Else return the current access token.
return Promise.resolve(token.access_token);
}
// Add axios request interceptor to the client.
// This adds the Authorization Bearer token header.
request.interceptors.request.use(
config => getAccessToken(getToken() || {})
.then(accessToken => ({
...config,
headers: {
...config.headers,
// Only add access token to header.
Authorization: `Bearer ${accessToken}`,
},
}))
.catch((error) => { throw error; }),
Promise.reject,
);
// Add axios response interceptor to the client.
// This tries to resolve 403 errors due to expired tokens.
request.interceptors.response.use(undefined, (err) => {
const { config } = err;
const originalRequest = config;
if (err.response && err.response.status === 403) {
// Refresh the token and retry.
if (!isRefreshing) {
isRefreshing = true;
const token = getToken();
return refreshToken(token ? token.refresh_token : {}).then((t) => {
originalRequest.headers.Authorization = `Bearer ${t.access_token}`;
return axios__default["default"](originalRequest);
}).catch((error) => { throw error; });
}
// Else subscribe for new access token after refresh.
const requestSubscribers = new Promise((resolve, reject) => {
subscribeTokenRefresh(
(token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(axios__default["default"](originalRequest));
},
reject,
);
});
return requestSubscribers;
}
throw err;
});
return {
authorize: (user, password) => axios__default["default"](accessTokenUri, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'json',
},
data: `grant_type=password&username=${user}&password=${password}&client_id=${clientId}`,
}).then(res => parseToken(res.data)).catch((error) => { throw error; }),
getToken,
};
}
/** @type {(x: any) => Boolean} */
const isObject = x => typeof x === 'object' && x !== null;
const reduceObjIndexed = curry__default["default"]((fn, init, obj) => reduce__default$1["default"](
(acc, [key, val]) => fn(acc, val, key),
init,
Object.entries(obj),
));
const createObserver = () => {
const listeners = new Map();
const subscribe = ((callback) => {
listeners.set(callback, callback);
return () => {
listeners.delete(callback);
};
});
const next = (event) => {
listeners.forEach((callback) => {
callback(event);
});
};
return { subscribe, next };
};
/**
* @template D
*/
/**
/**
* @typedef {{ data: D, fulfilled: any[], rejected: any[] }} AltogetherResult
* @property {D} data
* @property {any[]} fulfilled
* @property {any[]} rejected
*/
/**
* @typedef {(promises: Promise[]) => AltogetherResult} AltogetherPartial
*/
/**
* Handles a list of promises of compatible type that will be executed in parallel.
* It wraps `Promise.allSettled()` and partitions the results based on their status,
* 'fulfilled' or 'rejected', while also applying a transform function that iterates
* through all fulfilled values and returns the cumulated result as 'data'.
* @typedef {Function} altogether
* @param {Function} transform
* @param {D} [initData=null]
* @param {Promise[]} [promises=[]]
* @returns {Promise<AltogetherPartial|AltogetherResult>}
*/
const altogether = curry__default["default"]((transform, initData, promises) =>
Promise.allSettled(promises || []).then(reduce__default$1["default"]((all, result) => {
const { reason, value, status } = result;
if (status === 'fulfilled') {
return evolve__default["default"]({
data: d => transform(value, d),
fulfilled: append__default["default"](value),
}, all);
}
return evolve__default["default"]({
rejected: append__default["default"](reason),
}, all);
}, { data: initData || null, fulfilled: [], rejected: [] })));
const byType = {
string: () => '',
boolean: () => false,
object: () => null,
array: () => [],
};
const byFormat = {
'date-time': () => new Date().toISOString(),
};
const defaultOptions = { byType, byFormat };
/**
* @typedef {Object} EntityReference
* @property {String} id A v4 UUID as specified by RFC 4122.
* @property {String} type Corresponding to the entity bundle (eg, 'activity').
*/
/**
* @typedef {Object} Entity
* @property {String} id A v4 UUID as specified by RFC 4122.
* @property {String} type The combined form of entity & bundle (eg, 'log--activity').
* @property {Object} attributes Values directly attributable to this entity.
* @property {Object.<String, EntityReference|Array.<EntityReference>>} relationships
* References to other entities that define a one-to-one or one-to-many relationship.
* @property {Object} meta Non-domain information associated with the creation,
* modification, storage and transmission of the entity.
* @property {String} meta.created An ISO 8601 date-time string indicating when
* the entity was first created, either locally or remotely.
* @property {String} meta.changed An ISO 8601 date-time string indicating when
* the entity was last changed, either locally or remotely.
* @property {Object} meta.remote
* @property {Object} meta.fieldChanges
* @property {Array} meta.conflicts
*/
// Configuration objects for the entities supported by this library.
/**
* @typedef {Object} EntityConfig
* @property {Object} nomenclature
* @property {Object} nomenclature.name
* @property {Object} nomenclature.shortName
* @property {Object} nomenclature.plural
* @property {Object} nomenclature.shortPlural
* @property {Object} nomenclature.display
* @property {Object} nomenclature.displayPlural
* @property {Object} defaultOptions
* @property {Object} defaultOptions.byType
* @property {Object} defaultOptions.byFormat
*/
/** @type {Object.<String, EntityConfig>} */
/**
* @typedef {Object.<String, EntityConfig>} DefaultEntities
* @property {EntityConfig} asset
* @property {EntityConfig} log
* @property {EntityConfig} plan
* @property {EntityConfig} quantity
* @property {EntityConfig} taxonomy_term
* @property {EntityConfig} user
*/
var defaultEntities = {
asset: {
nomenclature: {
name: 'asset',
shortName: 'asset',
plural: 'assets',
shortPlural: 'assets',
display: 'Asset',
displayPlural: 'Assets',
},
defaultOptions,
},
file: {
nomenclature: {
name: 'file',
shortName: 'file',
plural: 'files',
shortPlural: 'files',
display: 'File',
displayPlural: 'Files',
},
defaultOptions,
},
log: {
nomenclature: {
name: 'log',
shortName: 'log',
plural: 'logs',
shortPlural: 'logs',
display: 'Log',
displayPlural: 'Logs',
},
defaultOptions,
},
plan: {
nomenclature: {
name: 'plan',
shortName: 'plan',
plural: 'plans',
shortPlural: 'plans',
display: 'Plan',
displayPlural: 'Plans',
},
defaultOptions,
},
quantity: {
nomenclature: {
name: 'quantity',
shortName: 'quantity',
plural: 'quantities',
shortPlural: 'quantities',
display: 'Quantity',
displayPlural: 'Quantities',
},
defaultOptions,
},
taxonomy_term: {
nomenclature: {
name: 'taxonomy_term',
shortName: 'term',
plural: 'taxonomy_terms',
shortPlural: 'terms',
display: 'Taxonomy Term',
displayPlural: 'Taxonomy Terms',
},
defaultOptions,
},
user: {
nomenclature: {
name: 'user',
shortName: 'user',
plural: 'users',
shortPlural: 'users',
display: 'User',
displayPlural: 'Users',
},
defaultOptions,
},
};
const entityMethods = (fn, allConfigs) =>
reduceObjIndexed((methods, config) => ({
...methods,
[config.nomenclature.shortName]: {
...fn(config),
},
}), {}, allConfigs);
/** The methods for transmitting farmOS data structures, such as assets, logs,
* etc, to a farmOS server.
* @typedef {Object} ClientEntityMethods
* @property {import('./fetch.js').fetchEntity} fetch
* @property {import('./send.js').sendEntity} send
* @property {import('./delete.js').deleteEntity} delete
*/
/**
* Fetch JSON Schema documents for farmOS data structures.
* @typedef {Function} FetchSchema
* @param {string} [entity] The farmOS entity for which you wish to retrieve schemata.
* @param {string} [bundle] The entity bundle for which you wish to retrieve schemata.
* @returns {Promise<EntitySchemata|BundleSchemata|JsonSchema>}
*/
/**
* @typedef {Function} AuthMixin
* @param {import('axios').AxiosInstance} request
* @param {Object} authOptions
* @property {String} authOptions.host
* @returns {Object<string,function>}
*/
/** A collection of functions for transmitting farmOS data structures to and
* from a farmOS Drupal 9 server using JSON:API.
* @typedef {Object} FarmClient
* @property {import('axios').AxiosInstance} request
* @property {Function} [authorize]
* @property {Function} [getToken]
* @property {Function} info
* @property {Object} schema
* @property {FetchSchema} schema.fetch
* @property {ClientEntityMethods} asset
* @property {ClientEntityMethods} log
* @property {ClientEntityMethods} plan
* @property {ClientEntityMethods} quantity
* @property {ClientEntityMethods} term
* @property {ClientEntityMethods} user
*/
/**
* @typedef {import('../entities.js').EntityConfig} EntityConfig
*/
/**
* Create a farm client for interacting with farmOS servers.
* @typedef {Function} client
* @param {String} host
* @param {Object} [options]
* @property {AuthMixin} [options.auth=oauth]
* @property {Object<String, EntityConfig>} [options.entities=defaultEntities]
* @property {String} [options.clientId]
* @property {Function} [options.getToken]
* @property {Function} [options.setToken]
* @returns {FarmClient}
*/
function client(host, options) {
const {
auth = oAuth,
entities = defaultEntities,
...authOptions
} = options;
// Instantiate axios client.
const clientOptions = {
baseURL: host,
headers: {
'Content-Type': 'application/vnd.api+json',
Accept: 'application/vnd.api+json',
},
};
const request = axios__default["default"].create(clientOptions);
const authMethods = auth(request, { host, ...authOptions }) || {};
const farm = {
...authMethods,
request,
info() {
return request('/api');
},
schema: {
fetch(entity, bundle) {
return request(`/api/${entity}/${bundle}/resource/schema`);
},
},
...entityMethods(({ nomenclature: { name } }) => ({
delete: deleteEntity(name, request),
fetch: fetchEntity(name, request),
send: sendEntity(name, request),
}), entities),
};
return farm;
}
const URIre = /^(http[s]?:\/\/)?([^/\s:#]+)?(:[0-9]+)?((?:\/?\w?)+(?:\/?[\w\-.]+[^#?\s])?)?(\??[^#?\s]+)?(#(?:\/?[\w\-$])*)?$/;
/**
* @typedef {Object} UriComponents
* @prop {?string} match - The full URI that matched the query.
* @prop {?string} scheme - The protocol, either "http://" or "https://".
* @prop {?string} domain - The domain and/or subdomain (eg, "api.example.com").
* @prop {?string} port - The port if specified (eg, ":80").
* @prop {?string} path - The relative directory path and/or file name (eg, "/foo/index.html").
* @prop {?string} query - Search params (eg, "?foo=42&bar=36").
* @prop {?string} fragment - The hash or JSON pointer (eg, "#Introduction", "#$defs/address").
*/
/**
* Parses a URI into its component strings.
* @param {string} uri
* @returns {UriComponents}
*/
function parseURI(uri) {
const groups = uri.match(URIre) || [];
const [
match, scheme, domain, port, path, query, fragment,
] = groups;
return {
match, scheme, domain, port, path, query, fragment,
};
}
const hasAny = compose__default$1["default"](anyPass__default["default"], map__default["default"](has__default["default"]));
/**
* @typedef {import('./reference').JsonSchema} JsonSchema
* @typedef {import('./reference').JsonSchemaDereferenced} JsonSchemaDereferenced
*/
const logicalKeywords = ['allOf', 'anyOf', 'oneOf', 'not'];
/** @type {(JsonSchema) => boolean} */
const hasLogicalKeyword = hasAny(logicalKeywords);
/** @type {(x: any) => Boolean} */
const boolOrThrow = (x) => {
if (typeof x === 'boolean') return x;
throw new Error(`Invalid schema: ${x}`);
};
/**
* @template T
* @param {(t: T, i: number) => T} transform
* @param {Array.<T>} array
* @returns {Array.<T>}
*/
const mapIndexed = addIndex__default["default"](map__default["default"]);
/**
* JSON Schema: A complete definition can probably be imported from a library.
* @typedef {Object|Boolean} JsonSchema
*/
/**
* JSON Schema Dereferenced: A JSON Schema, but w/o any $ref keywords. As such,
* it may contain circular references that cannot be serialized.
* @typedef {Object|Boolean} JsonSchemaDereferenced
*/
const trimPathRexEx = /^[/#\s]*|[/#\s]*$/g;
/** @type {(path: string) => String} */
const trimPath = path => path.replace(trimPathRexEx, '');
/**
* Resolve a schema definition from a JSON pointer reference.
* @param {JsonSchema} schema
* @param {string} pointer - A relative URI provided as the `$ref` keyword.
* @returns {JsonSchema}
*/
const getDefinition = (schema, pointer) => {
const pathSegments = trimPath(pointer).split('/');
const subschema = path__default$1["default"](pathSegments, schema);
if (subschema === undefined) return true;
return subschema;
};
/**
* Resolve the `$ref` keyword in given schema to its corresponding subschema.
* @param {JsonSchema} root - The root schema that contained the reference.
* @param {string} ref - The URI provided as the `$ref` keyword in the root
* schema or one of its subschemas.
* @param {Object} [options]
* @param {string} [options.retrievalURI] - The URI where the schema was found.
* @param {Object.<string, JsonSchemaDereferenced>} [options.knownReferences] -
* An object mapping known references to their corresponding dereferenced schemas.
* @returns {JsonSchema}
*/
const getReference = (root, ref, options = {}) => {
if (typeof ref !== 'string' || ref === '') {
const submsg = ref === '' ? '[empty string]' : ref;
throw new Error(`Invalid reference: ${submsg}`);
}
const { retrievalURI, knownReferences = {} } = options;
if (ref in knownReferences) return knownReferences[ref];
if (!isObject(root)) return boolOrThrow(root);
// The $id keyword takes precedence according to the JSON Schema spec.
const rootURI = root.$id || retrievalURI || null;
const {
scheme = '', domain = '', port = '', path = '', fragment = '',
} = parseURI(ref);
const baseURI = scheme + domain + port + path;
const baseIsRoot = rootURI === baseURI || ref === fragment;
let refRoot;
if (baseIsRoot) refRoot = root;
if (!baseIsRoot && baseURI in knownReferences) refRoot = knownReferences[baseURI];
if (refRoot === undefined) return true;
if (fragment) return getDefinition(refRoot, fragment);
return refRoot;
};
const setInPlace = (obj, path = [], val) => {
if (path.length < 1) return;
const [i, ...tail] = path;
if (!['string', 'number'].includes(typeof i)) throw new Error('Invalid path');
if (!(i in obj)) throw new Error('Path not found');
if (tail.length === 0) {
obj[i] = val; // eslint-disable-line no-param-reassign
return;
}
setInPlace(obj[i], tail, val);
};
/**
* Takes a schema which may contain the $ref keyword in it or in its subschemas,
* and returns an equivalent schema where those references have been replaced
* with the full schema document.
* @param {JsonSchema} root - The root schema to be dereferenced.
* @param {Object} [options]
* @param {string} [options.retrievalURI] - The URI where the schema was found.
* @param {string[]} [options.ignore] - A list of schemas to ignore. They will
* subsequently be referenced as `true`.
* @param {Object.<string, JsonSchema>} [options.knownReferences] - An object mapping
* known references to their corresponding schemas. They will also be dereferenced.
* @returns {JsonSchemaDereferenced}
*/
const dereference = (root, options = {}) => {
const { retrievalURI, ignore = [], knownReferences = {} } = options;
const knownRefsMap = new Map();
/** @type {(ref: string, refSchema: JsonSchema) => void} */
const setKnownRef = (ref, refSchema) => {
const appliedSchema = ignore.includes(ref) ? true : refSchema;
knownRefsMap.set(ref, appliedSchema);
};
Object.entries(knownReferences).forEach(([ref, refSchema]) => {
// We could just use setKnownRef here, but this prevents unnecessary recursion;
const schema = ignore.includes(ref) ? true : dereference(refSchema);
knownRefsMap.set(ref, schema);
});
// Set ignore refs to true to start, so they don't have to be checked in every
// call to `deref` below.
ignore.forEach((ref) => { knownRefsMap.set(ref, true); });
const baseURI = root.$id || retrievalURI || null;
const _root = clone__default["default"](root);
/** @type {(schema: JsonSchema, path?: Array.<string|number>) => JsonSchemaDereferenced} */
const deref = (schema, path = []) => {
if (!isObject(schema)) return boolOrThrow(schema);
let _schema = schema;
const set = (cb) => {
_schema = cb(_schema);
setInPlace(_root, path, _schema);
};
const derefSubschema = keyword => sub => deref(sub, [...path, keyword]);
const derefSubschemaObject = keyword => mapObjIndexed__default["default"]((sub, prop) =>
deref(sub, [...path, keyword, prop]));
const derefSubschemaArray = keyword => mapIndexed((sub, i) =>
deref(sub, [...path, keyword, i]));
const schemaTypes = {
string: identity__default["default"],
number: identity__default["default"],
integer: identity__default["default"],
object: evolve__default["default"]({
properties: derefSubschemaObject('properties'),
patternProperties: derefSubschemaObject('patternProperties'),
additionalProperties: derefSubschema('additionalProperties'),
}),
array: evolve__default["default"]({
items: derefSubschema('items'),
contains: derefSubschema('contains'),
prefixItems: derefSubschemaArray('prefixItems'),
}),
boolean: identity__default["default"],
null: identity__default["default"],
};
if ('type' in _schema && _schema.type in schemaTypes) {
set(schemaTypes[_schema.type]);
}
if (hasLogicalKeyword(_schema)) {
set(evolve__default["default"]({
allOf: derefSubschemaArray('allOf'),
anyOf: derefSubschemaArray('allOf'),
oneOf: derefSubschemaArray('allOf'),
not: derefSubschema('not'),
}));
}
if ('$ref' in _schema) {
const { $ref } = _schema;
// Anything beginning with # or /, the followed only by # or /.
const rootHashRE = /^[/#]+[/#]?$/;
const refIsRoot = rootHashRE.test($ref) || $ref === baseURI;
const refKey = refIsRoot ? baseURI : $ref;
if (knownRefsMap.has(refKey)) {
set(() => knownRefsMap.get(refKey));
} else if (refIsRoot) {
set(() => _root);
setKnownRef(baseURI, _root);
} else {
const opts = {
knownReferences: Object.fromEntries(knownRefsMap),
retrievalURI,
};
set(() => getReference(_root, $ref, opts));
set(sub => deref(sub, path));
setKnownRef($ref, _schema);
}
}
if (isObject(_schema) && '$id' in _schema) setKnownRef(_schema.$id, _schema);
return _schema;
};
return deref(_root);
};
/**
* @typedef {import('./reference').JsonSchema} JsonSchema
* @typedef {import('./reference').JsonSchemaDereferenced} JsonSchemaDereferenced
*/
/**
* Provide a dereferenced schema and get back the object corresponding to the
* "properties" keyword. A schema of type "array" will also be checked for the
* "items" keyword and any corresponding properties it has. Properties found
* under contitional keywords "allOf", "anyOf", "oneOf" and "not" will be
* merged; however, the "$ref" keyword will NOT be handled and will throw an
* error if encountered.
* @param {JsonSchemaDereferenced} schema - Must NOT contain the "$ref" keyword,
* nor subschemas containing "$ref".
* @returns {Object.<string, JsonSchemaDereferenced>}
*/
const getProperties = (schema) => {
if (!isObject(schema)) return {};
if ('$ref' in schema) {
// It is the responsibility of the caller to dereference the schema first.
const msg = `Unknown schema reference ($ref): "${schema.$ref}". `
+ 'Try dereferencing the schema before trying to access its properties or defaults.';
throw new Error(msg);
}
if ('properties' in schema) {
return schema.properties;
}
if ('items' in schema && 'properties' in schema.items) {
return schema.items.properties;
}
if (hasLogicalKeyword(schema)) {
const keyword = logicalKeywords.find(k => k in schema);
if (keyword === 'not') {
return map__default["default"](p => ({ not: p }), getProperties(schema.not));
}
return schema[keyword].reduce((props, subschema) => {
const subProps = getProperties(subschema);
const strategy = (b, a) => {
const aList = keyword in a ? a[keyword] : [a];
const bList = keyword in b ? b[keyword] : [b];
return { [keyword]: [...aList, ...bList] };
};
return mergeWith__default["default"](strategy, props, subProps);
}, {});
}
return {};
};
/**
* Provide a dereferenced schema of type 'object', and get back the subschema
* corresponding to the specified property name.
* @param {JsonSchemaDereferenced} schema - Must NOT contain the `$ref` keyword,
* nor subschemas containing `$ref`.
* @param {string} property - The name of a property under the `properties` keyword.
* @returns {JsonSchemaDereferenced}
*/
const getProperty = (schema, property) => {
if (typeof schema === 'boolean') return {};
if (typeof property !== 'string') throw new Error(`Invalid property: ${property}`);
const properties = getProperties(schema);
if (property in properties) {
return properties[property];
}
return {};
};
/**
* Provide a dereferenced schema of type 'object', and get back the subschema
* corresponding to the specified property name, or to the specified path.
* @param {JsonSchemaDereferenced} schema - Must NOT contain the `$ref` keyword,
* nor subschemas containing `$ref`.
* @param {...string|string[]} path - A property name, or array of property names.
* @returns {JsonSchemaDereferenced}
*/
const getPath = (schema, ...path) => {
if (typeof schema === 'boolean') return {};
const pathArray = path.flat();
if (pathArray.length === 0) return schema;
const [head, ...tail] = pathArray;
if (typeof head !== 'string') throw new Error(`Invalid path in subschema: ${head}`);
const subschema = getProperty(schema, head);
if (!isObject(subschema)) return {};
if (tail.length > 0) {
return getPath(subschema, tail);
}
return subschema;
};
/**
* Provide a dereferenced schema of type 'object', and get back a list of all its
* specified properties, or the properties of the subschema indicated by its path.
* @param {JsonSchemaDereferenced} schema - Must NOT contain the `$ref` keyword, nor
* subschemas containing `$ref`.
* @param {...string|string[]} [path] - A property name, or array of property names.
* @returns {string[]}
*/
const listProperties = (schema, ...path) => {
if (typeof schema === 'boolean') return [];
const subschema = path.length > 0 ? getPath(schema, path.flat()) : schema;
if ('properties' in subschema) {
return Object.keys(subschema.properties);
}
return [];
};
/**
* @typedef {import('./reference').JsonSchema} JsonSchema
* @typedef {import('./reference').JsonSchemaDereferenced} JsonSchemaDereferenced
*/
/** Transform function
* @typedef {(JsonSchemaDereferenced) => *} SchemaTransform
*/
/**
* Get the default value at a given path for a given schema.
* @param {JsonSchemaDereferenced} schema
* @param {string[]|string} [path] - A property name or array of property names.
* @param {Object} [options]
* @param {Object.<string, SchemaTransform>} [options.byType]
* @param {Object.<string, SchemaTransform>} [options.byFormat]
* @param {Object.<string, SchemaTransform>|string|boolean} [options.byProperty]
* @param {Object} [options.use]
* @returns {*}
*/
const getDefault = (schema, path = [], options = {}) => {
const subschema = getPath(schema, path);
if (!isObject(subschema)) return undefined;
if ('default' in subschema) return subschema.default;
if ('const' in subschema) return subschema.const;
// For recursive calls
/** @type {(sub: JsonSchemaDereferenced) => *} */
const getDef = sub => getDefault(sub, [], options);
/** @typedef {JsonSchemaDereferenced[]|Object.<string, JsonSchemaDereferenced>} SchemaFunctor */
/** @type {(sub: SchemaFunctor) => Array|Object} */
const mapGetDef = map__default["default"](getDef);
if (hasLogicalKeyword(subschema) && subschema.type === 'object') {
return evolve__default["default"]({
allOf: mapGetDef,
anyOf: mapGetDef,
oneOf: mapGetDef,
not: getDef,
}, subschema);
}
const { type } = subschema;
if (type === 'null') {