Source: app/actions/resources/apiActions/apiGetEntity.js

import compose from 'redux/lib/compose';
import { setEntityViewRef } from '../../entities/entityViewActions';
import { apiGet } from './apiRequest';
import { setEntity } from '../../entities/entityActions';
import getDefaultApiEntityTransform from './getDefaultApiEntityTransform';

/**
 * @module apiGetEntity
 * @tutorial get-single-entity
 */

/**
 * Default caching for apiGetEntity. If the requested entity already exists (in any form), will
 * abort the call.
 * @function entityCachingDefault
 * @param {object} [entity] The entity that already exists in state, if it exists
 * @returns {boolean} False if the caching is valid and the call should be aborted
 */
const entityCachingDefault = entity => !entity;

/**
 * Performs an API request to get a single entity. Checks if the given entity type already
 * exists in the state. If so, this action will skip the request and resolve immediately (unless
 * the _useCache_ parameter says otherwise). When the API responds, will automatically put
 * the response on the relevant redux state.
 *
 * @function apiGetEntity
 * @param {string} actionType The 'type' property of the dispatched api request action is set
 * to this value
 * @param {string} gatewayType The type of gateway to use. These should be one of the strings
 * defined in _Injectables.js_
 * @param {string} path The path to the endpoint to request
 * @param {string} entityType The type of entity we expect to retrieve from the API
 * @param {string|object} entityId Either of the following:
 *  - The string id of the entity we expect to retrieve from the API
 *  - An object with the shape `{ findEntity: function, getId: function }`. The `getId` should
 *  get the id from the new entity. The `findEntity` is used to lookup if the entity already
 *  exists in state. It receives a current entity and should return `true` if and only if the
 *  entity matches the target entity.
 * @param {object} options Object with optional options
 * @param {object} [options.requestData=null] Object with data to send with the API request
 * @param {object} [options.requestOptions={}] Object with additional options passed to the gateway.
 * Please see gateway documentation for more info
 * @param {string|object} [options.updateEntityView] If set, will update a entityViewReducer
 * to match the entityId and entityType passed to this action. Can be a string path to the
 * entityViewReducer, or an object with the following properties:
 *  - `target` Path to the entityViewReducer
 *  - `immediateUpdate` If true, will perform the update immediately, even if the request has
 *  not yet completed. Defaults to `true`. Ignored if `entityId` is an object
 * @param {boolean|function} [options.caching=entityCachingDefault] A function that determines if
 * there is still a valid cached entity for the requested type and id.
 * Takes the following parameters:
 *  - `entity` An entity in the existing redux state (if it exists)
 *  - `entityType` The entityType passed to this call
 *  - `entityId` The entityId passed to this call
 * Should return false if the call should be aborted, true if the request should be executed
 * normally.
 * @returns {Promise} A Promise that resolves with an object of the following shape:
 *  - *entity* The entity data
 *  - *fromCache* `true` if the entity data was pulled from cache, false if it comes from a new
 *  api response
 * @category api-calls
 */
const apiGetEntity = (
  actionType,
  gatewayType,
  path,
  entityType,
  entityId,
  {
    requestData = null,
    requestOptions = {},
    caching = entityCachingDefault,
    updateEntityView = null,
    transformResponse = null,
    transformEntity,
    mergeExisting = true,
  } = {},
) => (dispatch, getState) => {
  const dynamicEntityId = typeof entityId === 'object';
  const requiredParams = { actionType, gatewayType, path, entityId, entityType };
  Object.entries(requiredParams).forEach(([name, value]) => {
    if (value === undefined) {
      const definedParams = Object.entries(requiredParams)
        .filter(([, definedValue]) => !!definedValue)
        .map(([definedName]) => definedName)
        .join(', ');
      throw new ReferenceError(
        `Expected ${name} to be passed to apiGetEntity, but "undefined" was passed. (${definedParams})`,
      );
    }
  });

  let targetEntityView = updateEntityView;
  let immediateUpdateEntityView = true;
  if (typeof updateEntityView === 'object' && updateEntityView !== null) {
    immediateUpdateEntityView = updateEntityView.immediateUpdate;
    targetEntityView = updateEntityView.target;
  }
  // we cannot immediately update the view if the entity id is dynamic
  immediateUpdateEntityView = immediateUpdateEntityView && !dynamicEntityId;

  if (targetEntityView && immediateUpdateEntityView) {
    dispatch(setEntityViewRef(targetEntityView, { id: entityId, type: entityType }));
  }

  const { entities } = getState();
  const entityTypeState = entities[entityType] || {};
  let existingEntity;
  if (dynamicEntityId) {
    if (typeof entityId.getId !== 'function' || typeof entityId.findEntity !== 'function') {
      throw new ReferenceError(
        'Unexpected object passed to entityId param of apiGetEntity. Expected shape { findEntity: function, getId: function }',
      );
    }
    existingEntity = Object.values(entityTypeState).find(entityId.findEntity);
  } else {
    existingEntity = entityTypeState[entityId];
  }
  const shouldExecuteRequest = caching ? caching(existingEntity, entityType, entityId) : true;
  const requestPromise = shouldExecuteRequest
    ? dispatch(apiGet(actionType, gatewayType, path, requestData, requestOptions))
    : Promise.resolve(false);

  return requestPromise.then(rawResult => {
    if (rawResult) {
      if (transformResponse && typeof transformResponse !== 'function') {
        throw new ReferenceError('Expected the transformResponse is a function');
      }
      const result = transformResponse ? transformResponse(rawResult) : rawResult;

      if (!result.data) {
        throw new ReferenceError('Expected the API response to have a data key');
      }
      const defaultApiEntityTransform = getDefaultApiEntityTransform(path, actionType);
      const transformer = compose(...[transformEntity, defaultApiEntityTransform].filter(_ => _));
      const newEntity = transformer(result.data);
      const newEntityId = dynamicEntityId ? entityId.getId(newEntity) : entityId;

      dispatch(setEntity(entityType, newEntityId || newEntity.id, newEntity, mergeExisting));

      if (targetEntityView && !immediateUpdateEntityView) {
        dispatch(setEntityViewRef(targetEntityView, { id: newEntityId, type: entityType }));
      }

      return { entity: result.data, fromCache: false };
    }

    if (targetEntityView && !immediateUpdateEntityView) {
      const newEntityId = dynamicEntityId ? entityId.getId(existingEntity) : entityId;
      dispatch(setEntityViewRef(targetEntityView, { id: newEntityId, type: entityType }));
    }

    return { entity: existingEntity, fromCache: true };
  });
};

export default apiGetEntity;