endpoints/endpoints

View on GitHub
src/format-jsonapi/index.js

Summary

Maintainability
D
2 days
Test Coverage
/**
 * Generate JSON API compatible response objects from any data.
 *
 * TODO: Performance work is needed in this module. For example, the
 * relationships object is populated separately from the includes
 * array. This means that all relationship data is iterated through
 * multiple times.
 *
 * TODO: Add a unit testing suite--updates for changes around how
 * JSON-API manages `include` have introduced several edge cases.
 *
 */
class JsonApiFormat {

  /**
   * The constructor.
   *
   * @constructs JsonApiFormat
   * @param {Object} opts - configuration options
   * @param {Object} opts.store - the store to extract data with
   * @param {Object} opts.baseUrl - the baseUrl for hypermedia links
   */
  constructor(opts={}) {
    if (!opts.store) {
      throw new Error('No store specified.');
    }
    this.store = opts.store;
    this.baseUrl = opts.baseUrl || '';
  }

  /**
   * Generate self-referencing url.
   *
   * @param {*} model - a model
   * @return {String}
   */
  selfUrl (model) {
    return `${this.baseUrl}/${this.store.id(model)}`;
  }

  /**
   * Generate a related link.
   *
   * @param {*} model - a model
   * @return {String}
   */
  relatedUrl (model, relation) {
    return `${this.baseUrl}/${this.store.id(model)}/${relation}`;
  }

  /**
   * Generate a relation url.
   *
   * @param {*} model - a model
   * @return {String}
   */
  relationUrl (model, relation) {
    return `${this.baseUrl}/${this.store.id(model)}/relationships/${relation}`;
  }

  /**
   * Generate JSON API compatible response objects from any data source
   * using the provided store.
   *
   * @param {*} input - the input data
   * @param {opts} opts - configurable options (@todo: cleanup)
   */
  process (input, opts={}) {
    const {singleResult, relations, mode} = opts;
    let links;
    if (mode === 'relation') {
      links = this._relationshipLinks(input.sourceModel, input.relationName);
    }
    let data;
    if (this.store.isMany(input)) {
      data = input.map((input) => this.format(input, relations, mode));
    } else {
      data = this.format(input, relations, mode);
    }
    if (singleResult && Array.isArray(data)) {
      data = data.length ? data[0] : null;
    }
    let included;
    if (relations && relations.length) {
      included = this.include(input, relations);
    }
    return {
      data,
      links,
      included
    };
  }

  /**
   * Format a model to JSON-API compliance.
   *
   * @param {*} model - a model
   * @param {Array} includedRelations - an array of relation names to sideload
   * @param {String} mode - the mode to format for (null, related, relation)
   * @return {Object}
   */
  format(model, includedRelations=[], mode='read') {
    const store = this.store;
    const id = store.id(model);
    const type = store.type(model);
    // relation mode only cares about id/type, return early
    if (mode === 'relation') {
      return { id, type };
    }
    const links = {
      self: this.selfUrl(model)
    };
    // build a links object for this model and get sideloaded (included) models
    const relationships = this._relationships(model, includedRelations);
    // start json-api serialization
    const attributes = store.serialize(model);
    // FIXME: these delete calls are probably a performance killer
    // remove to-one foreign key attributes, they should appear in links
    const toOneRelations = store.toOneRelations(model);
    for (let rel in toOneRelations) {
      delete attributes[toOneRelations[rel]];
    }
    // remove id/type, they cannot be members of attributes
    delete attributes.id;
    delete attributes.type;
    // return the formatted model
    return {
      id,
      type,
      attributes,
      relationships,
      links
    };
  }

  /**
   * Format and inter-link all models related to a provided input and
   * ensure there are no duplicates.
   *
   * @param {*} input - the model(s) being related to
   * @param {String} relations - the relations to include
   * @return {Array}
   */
  include(input, relations) {
    const includedIndex = [];
    return relations.reduce((result, relation) => {
      return result.concat(this._include(input, relation));
    }, []).reduce((result, doc) => {
      // remove duplicates.
      // FIXME: this can produce invalid documents when there is
      // a nesting level higher than three.
      const indexKey = `${doc.id}${doc.type}`;
      const skip = includedIndex.some((entry) => entry == indexKey);
      if (!skip) {
        result.push(doc);
        includedIndex.push(indexKey);
      }
      return result;
    }, []);
  }

  /**
   * Generate an array of included models, inter-linking nested includes.
   *
   * @param {*} input - the model(s) being related to
   * @param {String} relation - the relations to include
   * @return {Array}
   */
  _include (input, relation) {
    const store = this.store;
    const format = this.format.bind(this);
    const nodes = relation.split('.');
    const nodeMap = {};
    return nodes.reduce((result, node, idx) => {
      const prevNode = nodes[idx - 1];
      const nextNode = nodes[idx + 1];
      const relateTo = idx === 0 ? input : nodeMap[prevNode];
      const related = nodeMap[node] = store.related(relateTo, node);
      const current = store.isMany(related) ?
        store.modelsFromCollection(related) : [related];
      const nestedInclude = nextNode ? [nextNode] : [];
      return result.concat(
        current.map((model) => format(model, nestedInclude))
      );
    }, []);
  }

  /**
   * Generate a relationships object.
   *
   * @param {*} model - the model(s) being related to
   * @param {Array} includedRelations - the relations to include
   * @return {Object}
   */
  _relationships(model, includedRelations) {
    const store = this.store;
    const allRelations = store.allRelations(model);
    const toOneRelationMap = store.toOneRelations(model);
    const toOneRelations = Object.keys(toOneRelationMap);
    return allRelations.reduce((result, relation) => {
      const relationNodes = relation.split('.');
      const isNested = relationNodes.length > 1;
      if (isNested) {
        relation = relationNodes[0];
      }
      const isToOne = toOneRelations.indexOf(relation) !== -1;
      const isIncluded = includedRelations.some((includeRelation) => {
        return includeRelation.split('.')[0] === relation;
      });
      if (isToOne) {
        result[relation] =
          this._relateToOne(model, relation, toOneRelationMap[relation]);
      } else {
        result[relation] =
          this._relateToMany(model, relation, isIncluded);
      }
      return result;
    }, {});
  }

  /**
   * Generate data for a to-one relationship object.
   *
   * @param {*} model - the model being related to
   * @param {String} relation - the relation name to populate
   * @param {String} property - the property the relationship value is under.
   * @return {Object}
   */
  _relateToOne (model, relation, property) {
    const store = this.store;
    const links = this._relationshipLinks(model, relation);
    const id = store.prop(model, property);
    const type = store.type(store.relatedModel(model, relation));
    const data = {
      id: String(id),
      type
    };
    return {
      links,
      data: id ? data : null
    };
  }

  /**
   * Generate data for a to-many relationship object.
   *
   * @param {*} model - the model being related to
   * @param {String} relation - the relation name to populate
   * @param {Boolean} included - indicates if this should include data
   * @return {Object}
   */
  _relateToMany (model, relation, included) {
    const relationNodes = relation.split('.');
    const links = this._relationshipLinks(model, relationNodes[0]);
    if (!included) {
      return { links };
    }
    return {
      links,
      data: this._relationshipData(model, relationNodes[0])
    };
  }

  /**
   * Generate a links object for a relationship.
   *
   * @param {*} model - the model the relationships object is being created for
   * @param {String} relation - the name of the relation to link
   * @return {Object}
   */
  _relationshipLinks(model, relation) {
    return {
      self: this.relationUrl(model, relation),
      related: this.relatedUrl(model, relation)
    };
  }

  /**
   * Generate relationship data.
   *
   * @param {*} model - the model the relationship data is being created for
   * @param {String} relation - the name of the relation to load data for
   * @return {Object}
   */
  _relationshipData(model, relation) {
    const store = this.store;
    const included = store.related(model, relation);
    let data = null;
    if (store.isMany(included)) {
      data = included.reduce(function (result, model) {
        const id = store.id(model);
        if (id) {
          result.push({
            id,
            type: store.type(model)
          });
        }
        return result;
      }, []);
    } else {
      const id = store.id(included);
      if (id) {
        data = {
          id: store.id(included),
          type: store.type(included)
        };
      }
    }
    return data;
  }

}

export default JsonApiFormat;