UNPKG

redux-grim

Version:

Generator for asynchronous Redux endpoint actions and reducers

308 lines (283 loc) 9.47 kB
import addHooks from './addHooks'; import { getStartType, getSuccessType, getErrorType } from '../util'; const bodyMethods = ['put', 'patch', 'post']; // Regex to match the contents of () brackets const parensReg = /\(([^)]+)\)/g; // // Regex to match the contents of [] brackets const squareParensReg = /\[([^)]+)\]/g; // Actions can be modified using the 'on' method. // E.g. action.on('start', () => ..) // // This is a list of the possible values callback function is called with, and // the parameters they will be passed. // Each function must return the action, modified or otherwise. // // action - the action that was created by makeAction. // _namedParams - an object mapping from parameter name in the url to the value // that was passed to the action. // _restArgs - array of the rest of the params that were passed to the action. // _options - options object that was passed to makeAction const defaultHooks = { start: (action, _namedParams, _restArgs, _options) => action, success: (action, _namedParams, _restArgs, _options) => action, error: (action, _namedParams, _restArgs, _options) => action, // All applies to start, success, and error actions all: (action, _namedParams, _restArgs, _options) => action }; // Some default behaviours when creating actions, that can be overridden. // apiFetch allows the fetch behaviour to be modified. restArgs - the parameters // that were passed to the action after those specified by the url, are also // passed to this function. const defaultFunctions = { apiFetch: (method, url, body, ..._rest) => // eslint-disable-next-line compat/compat fetch(url, { method: method.toUpperCase(), body: JSON.stringify(body) }) }; /** * Return true if the api and action functions require an object parameter * @param {String} method * @param {String} templateUrl * @returns boolean */ function hasBodyParam(method, templateUrl) { return ( bodyMethods.includes(method) || (method === 'delete' && templateUrl.includes('[')) ); } /** * Extract an array of the named parameters in the url. * * E.g '/zone/(zoneId)/pool/[id]' -> ['zoneId', 'body']; * * If * @param {String} method * @param {String} templateUrl * @returns {Array} */ export function getNamedParameters(method, templateUrl) { const params = new Set(); let match; while ((match = parensReg.exec(templateUrl))) { // Urls can reference the same object multiple times. // E.g. /(zone.id)/(zone.organization.id) // This will only add one named parameter to the array ('zone') const str = match[1]; const periodIndex = str.indexOf('.'); params.add(periodIndex === -1 ? str : str.substr(0, str.indexOf('.'))); } if (hasBodyParam(method, templateUrl)) { params.add('body'); } return params.size ? [...params] : []; } /** * Return a function that evaluates the url, using a params object to populate * the variable parts of the url. * * NOTE: Babel can't compile things that are inside of eval() or new Function(), * makeActionCreator() was using back-ticks (string templates) inside of new Function() * which busts IE11. So please use good old '+ foo +' in cases like this! * * E.g. const evaluate = makeUrlEvaluator('/zone/(zoneId)/pool/[id]'); * evaluate({ zoneId: 123, body: { id: 456 }}) * -> '/zone/123/create/456'; * * @param {String} templateUrl * @returns {Function} */ export function makeUrlEvaluator(templateUrl) { let url = templateUrl // E.g. /(foo) -> /${params.foo} .replace(parensReg, "'+params.$1+'") // Adding empty [] to a delete url creates an action which takes a body param .replace('[]', '') // E.g. /[foo] -> /${params.body.foo} .replace(squareParensReg, "'+params.body.$1+'"); return new Function('params', "return '" + url + "';"); } /** * Returns a function which evaluates the tempalte url, with the action * arguments and returns an object containing the evaluated url, an object * mapping the named parameters to their actual values, and the rest of the * arguments supplied to the action. * * @param templateUrl {String} * @param namedParams {Array} * @returns {function} */ export function makeGetApiData(templateUrl, namedParams) { const urlEvaluator = makeUrlEvaluator(templateUrl); return (...args) => { const params = namedParams.reduce((o, param, ii) => { o[param] = args[ii]; return o; }, {}); return { url: urlEvaluator(params), params, restArgs: args.slice(namedParams.length) }; }; } /** * Create an asynchronous actions to access http resources. * * @param entityType {String} - used to create action types and match actions * and reducers * @param method {String} - http method (get, post, etc) * @param templateUrl {String} - defines the url to access and the parameters * which the action will accept. See the readme for for information. * @param options {Object} - An object which will be passed to the various * hook functions. By default, this can only be used to log action creation, * or a mismatch between expected parameters of an action and what is actually * passed, by setting options = { debug: true } * */ export default function makeActionCreator( entityType, method, templateUrl, options = {} ) { const namedParams = getNamedParameters(method, templateUrl); options.debug && logActionCreation(entityType, method, templateUrl, namedParams); let mock; const hooks = { ...defaultHooks }; const functions = { ...defaultFunctions }; const getApiData = makeGetApiData(templateUrl, namedParams); const action = (...args) => async dispatch => { if (options.debug) { validateActionParameters( entityType, method, templateUrl, args, namedParams ); } const { url, params, restArgs } = getApiData(...args); let startAction = { type: getStartType(entityType), meta: { entityType, method } }; startAction = hooks.start(startAction, params, restArgs, options); startAction = hooks.all(startAction, params, restArgs, options); dispatch(startAction); try { let mockValue, response; if (mock !== undefined) { mockValue = typeof mock === 'function' ? mock(...args) : mock; response = mockValue === undefined ? undefined : { body: mockValue }; response && console.info(`Mocking ${method} ${templateUrl}`, mockValue); } response = response || (await functions.apiFetch(method, url, params.body, ...restArgs)); let successAction = { type: getSuccessType(entityType), payload: response && response.body, meta: { entityType, method } }; successAction = hooks.success( successAction, params, restArgs, options, response ); successAction = hooks.all( successAction, params, restArgs, options, response ); const result = successAction.payload; dispatch(successAction); return result; } catch (error) { let errorAction = { type: getErrorType(entityType), payload: error, error: true, meta: { entityType, method } }; errorAction = hooks.error(errorAction, params, restArgs, options, error); errorAction = hooks.all(errorAction, params, restArgs, options, error); dispatch(errorAction); throw error; } }; // All these functions return the action so they can be chained. addHooks(action, hooks); action.apiFetch = fn => ((functions.apiFetch = fn), action); action.mock = fn => ((mock = fn), action); action.unmock = () => ((mock = undefined), action); return action; } /** * Called automatically when options = { debug: true }, to log information * about action creation. */ export function logActionCreation(entityType, method, url, namedParams) { console.log( `Created action ${entityType}, ${url}, ${method}(${namedParams.join(', ')})` ); } /** * Called automatically when options = { debug: true } to warn when there's * mismatch between the expected parameters of an action and what's actually * created. */ export function validateActionParameters( entityType, method, url, args, namedParams ) { const hasBody = url.indexOf('[') > -1 || ['post', 'put', 'patch'].includes(method); // All parameters should be strings or numbers, unless there's a body, in // which case the last parameter is an object. if (args.length < namedParams.length) { console.warn( `For api call ${entityType}, ${method}, ${url}, Expected parameters: ${namedParams.join(', ')} Actual parameters: ${args.join(', ')} ` ); } namedParams.forEach((param, ii) => { const type = typeof args[ii]; if (hasBody && ii === namedParams.length - 1) { if (type !== 'object') { console.warn( `${entityType}, ${method}, ${url}: Expected parameter ${param} to be an object. Actual value: ${ args[ii] } ${type}` ); } } else if (type !== 'string' && type !== 'number') { console.warn( `${entityType}, ${method}, ${url}: Expected parameter ${param} to be a string or number. Actual value: ${ args[ii] }` ); } }); }