UNPKG

farmos

Version:

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

811 lines (747 loc) 25.7 kB
import axios from 'axios'; import compose from 'ramda/src/compose'; import concat from 'ramda/src/concat'; import defaultTo from 'ramda/src/defaultTo'; import isEmpty from 'ramda/src/isEmpty'; import reduce from 'ramda/src/reduce'; import unless from 'ramda/src/unless'; import curryN from 'ramda/src/curryN'; import mergeDeepWithKey from 'ramda/src/mergeDeepWithKey'; import path from 'ramda/src/path'; import pick from 'ramda/src/pick'; import match from 'ramda/src/match'; import unary from 'ramda/src/unary'; import append from 'ramda/src/append.js'; import curry from 'ramda/src/curry.js'; import evolve from 'ramda/src/evolve.js'; import reduce$1 from 'ramda/src/reduce.js'; /** * 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( unless(isEmpty, concat('&sort=')), reduce(concatSortParams, ''), Object.entries, defaultTo({}), ); /** @type {(sort: Array) => String} */ const parseIncludeArray = reduce( (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(compose( ([type, entity, bundle]) => ({ type, entity, bundle }), match(entityTypeRegEx), defaultTo(''), )); /** * @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 }; } const toResourceId = compose( pick(['id', 'type']), path(['data', 'data']), ); const mapResponseData = response => (Array.isArray(response) ? response.map(toResourceId) : toResourceId(response)); const concatRelationshp = (original, updated) => (Array.isArray(original) ? concat(original, updated) : updated); const updateRelationship = mergeDeepWithKey((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((entity, [field, response]) => { const relationship = { relationships: { [field]: { data: mapResponseData(response), }, }, }; return updateRelationship(entity, relationship); }); const toRequestConfig = curryN(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(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.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(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(originalRequest)); }, reject, ); }); return requestSubscribers; } throw err; }); return { authorize: (user, password) => axios(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, }; } const reduceObjIndexed = curry((fn, init, obj) => reduce$1( (acc, [key, val]) => fn(acc, val, key), init, Object.entries(obj), )); /** * @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>} */ curry((transform, initData, promises) => Promise.allSettled(promises || []).then(reduce$1((all, result) => { const { reason, value, status } = result; if (status === 'fulfilled') { return evolve({ data: d => transform(value, d), fulfilled: append(value), }, all); } return evolve({ rejected: append(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.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; } export { client as default };