mkreiser/ESPN-Fantasy-Football-API

View on GitHub
src/base-classes/base-object/base-object.js

Summary

Maintainability
A
0 mins
Test Coverage
import _ from 'lodash';

import { flattenObjectSansNumericKeys } from '../../utils.js';

/**
 * The base class for all project objects. Provides data mapping functionality.
 */
class BaseObject {
  static get responseMap() {
    return this._responseMap;
  }

  static set responseMap(_responseMap) {
    this._responseMap = _.assignWith({}, this._responseMap, _responseMap);
  }


  /**
   * @param {Object} options Properties to be assigned to the BaseObject. Must match the keys of the
   *                         BaseObject's `responseMap` or valid options defined by the class's
   *                         `constructor`.
   */
  constructor(options = {}) {
    if (!_.isEmpty(options)) {
      this.constructor._populateObject({
        data: options,
        instance: this,
        isDataFromServer: false
      });
    }
  }

  /**
   * The class name. Minification will break `this.constructor.name`; this allows for readable
   * logging even in minified code.
   * @type {String}
   */
  static displayName = 'BaseObject';

  /**
   * Helper for processing items on `responseMap`s that are objects.
   * @private
   *
   * @param  {Object} options.data
   * @param  {BaseObject} options.instance The instance to populate. This instance will be mutated.
   * @param  {Object} options.constructorParams Params to be passed to the instance's constructor.
   *                                            Useful for passing parent data, such as `leagueId`.
   * @param  {String} options.value The value of the responseMap entry being parsed.
   * @return {*}
   */
  static _processObjectValue({
    data, rawData, constructorParams, instance, value
  }) {
    if (!value.key) {
      throw new Error(
        `${this.displayName}: _populateObject: Invalid responseMap object. Object must define ` +
        'key. See docs for typedef of ResponseMapValueObject.'
      );
    }

    const responseData = _.get(data, value.key);
    if (_.isFunction(value.manualParse)) {
      return value.manualParse(responseData, data, rawData, constructorParams, instance);
    } else if (value.BaseObject) {
      const buildInstance = (passedData) => (
        value.BaseObject.buildFromServer(passedData, constructorParams, rawData)
      );

      return value.isArray ? _.map(responseData, buildInstance) : buildInstance(responseData);
    }

    throw new Error(
      `${this.displayName}: _populateObject: Invalid responseMap object. Object must define ` +
      '`BaseObject` or `manualParse`. See docs for typedef of ResponseMapValueObject.'
    );
  }

  /**
   * Helper method for `_populateObject` that houses the attribute mapping logic. Should never be
   * used by other methods. See {@link ResponseMapValueObject} for `responseMap` documentation.
   * @private
   *
   * @param  {Object} options.data
   * @param  {BaseObject} options.instance The instance to populate. This instance will be mutated.
   * @param  {Object} options.constructorParams Params to be passed to the instance's constructor.
   *                                            Useful for passing parent data, such as `leagueId`.
   * @param  {Boolean} options.isDataFromServer When true, the data came from the ESPN API over the
   *                                            wire. When false, the data came locally.
   * @param  {String} options.key The key of the responseMap entry being parsed.
   * @param  {String} options.value The value of the responseMap entry being parsed.
   */
  static _processResponseMapItem({
    data, rawData, constructorParams, instance, isDataFromServer, key, value
  }) {
    /**
     * @typedef {Object} BaseObject~ResponseMapValueObject
     *
     * The `responseMap` can have two values: a string or a ResponseMapValueObject. When string, the
     * data found on that response is directly mapped to the BaseObject without mutation. When
     * ResponseMapValueObject, the data at the `key` will be used to create BaseObject(s) or
     * manually parsed with a provided `manualParse function`. Either result is attached to the
     * BaseObject being populated.
     *
     * @property {String} key The key on the response data where the data can be found. This must be
     *                        defined.
     * @property {BaseObject} BaseObject The BaseObject to create with the response data.
     * @property {Boolean} isArray Whether or not the response data is an array. Useful for
     *                             attributes such as "teams".
     * @property {Boolean} defer Whether or not to wait to parse the entry until a second pass of
     *                           the map. This is useful for populating items with cached instances
     *                           that are not guaranteed to be parsed/cached during initial parsing.
     *                           Example: Using Team instances on League.
     * @property {function} manualParse A function to manually apply logic to the response. This
     *                                  function must return its result to be attached to the
     *                                  populated BaseObject. The arguments to this function are:
     *                                  (data at the key), (the whole response), (the instance being
     *                                  populated).
     * @example
     * static responseMap = {
     *   teamId: 'teamId',
     *   team: {
     *     key: 'team_on_response',
     *     BaseObject: true
     *   },
     *   teams: {
     *     key: 'teams_on_response',
     *     BaseObject: Team,
     *     isArray: true
     *   },
     *   manualTeams: {
     *     key: 'manual_teams_on_response',
     *     BaseObject: Team,
     *     manualParse: (responseData, response, constructorParams, instance) => (
     *       Team.buildFromServer(responseData)
     *     )
     *   }
     * };
     */

    let item;

    if (!isDataFromServer) {
      item = _.get(data, key);
    } else if (_.isString(value)) {
      item = _.get(data, value);
    } else if (_.isPlainObject(value)) {
      item = this._processObjectValue({
        data, rawData, constructorParams, instance, value
      });
    } else {
      throw new Error(
        `${this.displayName}: _populateObject: Did not recognize responseMap value type for key ` +
        `${key}`
      );
    }

    if (!_.isUndefined(item)) {
      _.set(instance, key, item);
    }
  }

  /**
   * Returns the passed instance of the BaseObject populated with the passed data, mapping the
   * attributes defined in the value of responseMap to the matching key.
   * @private
   *
   * @param  {Object} options.data The data to map onto the passed instance.
   * @param  {BaseObject} options.instance The instance to populate. This instance will be mutated.
   * @param  {Boolean} options.isDataFromServer When true, the data came from ESPN. When false, the
   *                                            data came locally.
   * @return {BaseObject} The mutated BaseObject instance.
   */
  static _populateObject({
    data, rawData, constructorParams, instance, isDataFromServer
  }) {
    if (!instance) {
      throw new Error(`${this.displayName}: _populateObject: Did not receive instance to populate`);
    } else if (_.isEmpty(data)) {
      return instance;
    }

    const deferredMapItems = {};
    _.forEach(this.responseMap, (value, key) => {
      if (_.isPlainObject(value) && value.defer) {
        _.set(deferredMapItems, key, value);
      } else {
        this._processResponseMapItem({
          data, rawData, constructorParams, instance, isDataFromServer, key, value
        });
      }
    });

    _.forEach(deferredMapItems, (value, key) => {
      this._processResponseMapItem({
        data, rawData, constructorParams, instance, isDataFromServer, key, value
      });
    });

    return instance;
  }

  /**
   * Returns a new instance of the BaseObject populated with the passed data that came from ESPN,
   * mapping the attributes defined in the value of responseMap to the matching key. Use this method
   * when constructing BaseObjects with server responses.
   * @param  {Object} data Data originating from the server.
   * @param  {Object} constructorParams Params to be passed to the instance's constructor. Useful
   *                                    for passing parent data, such as `leagueId`.
   * @return {BaseObject} A new instance of the BaseObject populated with the passed data.
   */
  static buildFromServer(data, constructorParams) {
    const instance = new this(constructorParams);

    const flatData = this.flattenResponse ? flattenObjectSansNumericKeys(data) : data;

    this._populateObject({
      data: flatData,
      rawData: data,
      constructorParams,
      instance,
      isDataFromServer: true
    });

    return instance;
  }
}

export default BaseObject;