UNPKG

farmos

Version:

A JavaScript library for working with farmOS data structures and interacting with farmOS servers.

1,382 lines (1,280 loc) 116 kB
'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') {