yahoo/elide-js

View on GitHub
lib/datastores/jsonapidatastore.js

Summary

Maintainability
F
1 wk
Test Coverage
/*********************************************************************************
 * Copyright 2015 Yahoo Inc.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 ********************************************************************************/
'use strict';

import debug from 'debug';
import agent from 'superagent';
import wrap from 'superagent-promise';

import clone from '../helpers/clone';
import Datastore from './datastore';

let SuperAgent;
const UUID = /[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}/;

// housekeeping for SuperAgent
const JSONAPI_MIME_TYPE = 'application/vnd.api+json';
const JSONPATCH_MIME_TYPE = 'application/vnd.api+json; ext=jsonpatch';
agent.serialize[JSONAPI_MIME_TYPE] = JSON.stringify;
agent.serialize[JSONPATCH_MIME_TYPE] = JSON.stringify;

// jscs:disable maximumLineLength
export const ERROR_UNKNOWN_MODEL = 'The model ${model} does not exist in this store.';
export const ERROR_NO_LINKED_PROPERTY = 'The model ${model} does not have a linked property ${property}.';
export const ERROR_CANNOT_ROOT_QUERY = 'Query cannot be fulfilled because "${model}":${id} cannot be associated with a "${nextModel}".';
export const ERROR_CANNOT_ROOT_OBJECT = 'Cannot root ${model}:${modelState} through ${parentModel}.';
export const ERROR_CANNOT_FIND_OBJECT = 'Could not find ${model}.';
export const ERROR_CANNOT_CREATE_OBJECT = 'Could not create ${model}:${modelState}.';
export const ERROR_CANNOT_UPDATE_OBJECT = 'Could not update ${model}:${modelState}.';
export const ERROR_CANNOT_DELETE_OBJECT = 'Could not delete ${model}:${modelState}.';
export const ERROR_SCHEMA_INCONSISTENCY = 'Internal inconsistency in schema ${model} url does not have ${model} as the final model.';
// jscs:enable maximumLineLength

const log = debug('elide:jsonapistore');
const logUrl = debug('elide:jsonapistore:url');
const MAX_BODY_TEXT_LENGTH = 1000;

function printDebug(error) {
  let requestData;
  let responseError;
  let responseStatus;
  let errorText;
  try {
    responseStatus = error.status;
    responseError = error.response.error;
    requestData = error.response.request._data;

    if (responseError.text.length > MAX_BODY_TEXT_LENGTH) {
      errorText = responseError.text;
      delete responseError.text;
    }
  } catch (e) {
    log(e);
  }

  log('code:', responseStatus);
  log('error:', responseError);
  log('request.data:', JSON.stringify(requestData, null, 2));

  try {
    responseError.text = errorText;
  } catch (e) {}
}

function buildQueryString(parameters) {
  return Object.keys(parameters)
    .map(param => `${param}=${parameters[param]}`)
    .join('&');
}

function printRequest(url, parameters = {}, data = undefined) {
  logUrl(`${url}?${buildQueryString(parameters)}`, JSON.stringify(data));
}

class JsonApiDatastore extends Datastore {
  constructor(Promise, ttl, baseURL, models) {
    super(Promise, ttl, baseURL, models);

    this._urlTemplateCache = {};
    this._relationCache = {};
    this._aliasIds = {};

    this._headers = {};
    this._queryParameters = {};
    this._defaultSparseFields = {};

    this._rootModels(models);
    this._annotateModels(models);
    this._modelHierarchy = this._buildModelHierarchy();
    this._initSuperAgent(Promise);
    this._computeDefaultSparseFields(models);
  }

  /**
   * generates a set of URIs for the model objects
   *
   * @param  {Object}    models - the models passed to the constructor
   */
  _rootModels(models) {
    let topLevel = [];
    Object.keys(models).forEach((modelName) => {
      let model = models[modelName];
      if (model.meta.isRootObject) {
        topLevel.push(modelName);
        this._urlTemplateCache[modelName] =
          `/${modelName}/:${modelName}Id|${modelName}`;
      }
    });
    topLevel.forEach((modelName) => {
      this._rootChildModels(modelName);
    });
  }

  /**
   * generates URL templates for models recursively
   * @param  {String}            modelName - the name of a model to root
   * NB: this is a naïve way to do it, if a model is linked from many
   * places we only keep the deepest one
   */
  _rootChildModels(modelName) {
    let linkNames = Object.keys(this._models[modelName].links || {});
    let parentURI = this._urlTemplateCache[modelName].split('|');
    let parentUrl = parentURI.shift();
    let parentModels = parentURI.join('|');

    linkNames.forEach((linkName) => {
      let link = this._models[modelName].links[linkName];
      let thisModels = `${parentModels}|${link.model}`;
      // TODO: this should probably turn into a tree of routes that we can use
      //       to select the best url for a given model (in case there may be
      //       more than one URL that can express the same model).
      //       EG. use /people/2 not /people/1/spouse/2
      if (this._urlTemplateCache.hasOwnProperty(link.model)) { return; }
      this._urlTemplateCache[link.model] =
        `${parentUrl}/${linkName}/:${link.model}Id|${thisModels}`;

      // recurse to get to all models
      this._rootChildModels(link.model);
    });
  }

  /**
   * put inverse annotations on the store's models
   * @param  {Object}        models - the models passed to the constructor
   */
  _annotateModels(models) {
    Object.keys(models).forEach((modelName) => {
      this._models[modelName].meta.inverse = {};
    });

    Object.keys(models).forEach((modelName) => {
      let model = models[modelName];
      Object.keys(model.links).forEach((linkName) => {
        let link = model.links[linkName];
        if (link.hasOwnProperty('inverse')) {
          this._models[link.model].meta.inverse[link.inverse] = modelName;
        }
      });
    });
  }

  /**
   * Compute the default sparse field string for each model in the schema.
   *
   * @param models - the models to compute the sparse fields for
   * @private
   */
  _computeDefaultSparseFields(models) {
    Object.keys(models).forEach((modelName) => {
      let model = models[modelName];
      let fieldParams = [];

      // Add attribute fields
      Object.keys(model).forEach((key) => {
        if ((key !== 'meta') && (key !== 'links')) {
          fieldParams.push(key);
        }
      });

      // Add inverse fields
      Object.keys(model.meta.inverse).forEach((inverse) => {
        if (inverse !== 'meta' && inverse !== 'links') {
          fieldParams.push(inverse);
        }
      });

      // Add relationship fields
      Object.keys(model.links).forEach((linkName) => {
        fieldParams.push(linkName);
      });

      this._defaultSparseFields[modelName] = fieldParams.join(',');
    });
  }

  /**
   * build an array of models, starting with the root models and working to the
   * most deeply nested models
   * @return {Array}             The models in order of nesting depth
   */
  _buildModelHierarchy() {
    let models = [];
    let inList = {};
    let hierarchy = [];

    // start from the root level
    Object.keys(this._models).forEach((model) => {
      let template = this._models[model];
      if (!template.links) { return; }
      if (!template.meta.isRootObject) { return; }

      models.push(model);
      inList[model] = true;
      Object.keys(template.links).forEach((linkName) => {
        let link = template.links[linkName];
        if (!inList[link.model]) {
          models.push(link.model);
          inList[link.model] = true;
        }
      });
    });

    hierarchy = hierarchy.concat(models);
    while (models.length > 0) {
      let newModels = [];
      for (let i = 0; i < models.length; i++) {
        let template = this._models[models[i]];
        if (!template.links) { continue; }

        Object.keys(template.links).forEach((linkName) => {
          let link = template.links[linkName];
          if (!inList[link.model]) {
            newModels.push(link.model);
            inList[link.model] = true;
          }
        });
      }
      hierarchy = hierarchy.concat(newModels);
      models = newModels;
    }

    return hierarchy;
  }

  /**
   * wrap SuperAgent to use promises
   * @param  {Promise}        Promise - the Promise that superagent should return
   */
  _initSuperAgent(Promise) {
    if (!SuperAgent) {
      SuperAgent = wrap(agent, Promise);
    }
  }

  /**
   * get an absolute URL for a resource
   * @param  {String}     resourceUrl - the relative URL for the resource
   * @return {String}                 - the fully specified URL
   */
  _absoluteUrl(resourceUrl) {
    return `${this._baseURL}${resourceUrl}`;
  }

  /**
   * for debugging
   */
  _uriForModel(model, id) {
    let willReject;
    let withReason;
    let requiredModels = this._urlTemplateCache[model].split('|');
    let modelUrl = requiredModels.shift();

    let fromModel = {
      model: requiredModels.pop(),
      id,
    };

    [
      willReject,
      withReason,
      modelUrl
    ] = this._rootModel(modelUrl, requiredModels, fromModel);
    modelUrl = modelUrl.replace(`:${model}Id`, id);

    if (willReject) {
      throw new Error(`Could not root ${model}:${id} because '${withReason}'` +
                      `Wound up with ${modelUrl}`);
    }
    return modelUrl;
  }

  /**
   * resolve a query's url
   *
   * @param  {String}   modelUrl        - the query url with missing parameters
   * @param  {Array}    missingModels   - the array of models we haven't resolved yet
   * @param  {Object}   fromModel
   * @param  {Number}   fromModel.id    - the id of shallowest model we have resolved
   * @param  {String}   fromModel.model - the name of the shallowest model we have resolved
   * @return {Array}   [
   *                                     willReject,    - `true` if we failed to fully resolve the query
   *                                      withReason,    - the reason we failed to fully resolve the query
   *                                   modelUrl       - the fully resolved query url
   *                                 ]
   */
  _rootModel(modelUrl, missingModels, fromModel) {
    let willReject = false;
    let withReason;

    let model = fromModel.model;
    let objId = fromModel.id;
    modelUrl = modelUrl.replace(`:${fromModel.model}Id`, objId || '');
    while (missingModels.length > 0) {
      let nextModel = missingModels.pop();
      let link = this._findLinkingObject(nextModel, model, objId);
      if (!link) {
        willReject = true;
        withReason = ERROR_CANNOT_ROOT_QUERY
                      .replace('${nextModel}', nextModel)
                      .replace('${model}', model)
                      .replace('${id}', objId);
        break;
      }

      objId = link;
      model = nextModel;
      modelUrl = modelUrl.replace(`:${nextModel}Id`, objId);
    }

    return [
      willReject,
      withReason,
      modelUrl
    ];
  }

  /**
   * Add a id alias, used when we create objects with uuids
   *
   * @param {String} newId - the newly generated ID
   * @param {String} oldId - the ID from creation time
   */
  _setAlias(newId, oldId) {
    this._aliasIds[newId] = oldId;
  }

  /**
   * Associate two models so that we can use them to root other queries
   *
   * @param  {String}    fromModel - the parent model
   * @param  {Number}    fromId    - the id of the parent model
   * @param  {String}    toModel   - the child model
   * @param  {Number}    toId      - the id of the child model
   */
  _linkModels(fromModel, fromId, toModel, toId) {
    if (!this._relationCache.hasOwnProperty(fromModel)) {
      this._relationCache[fromModel] = {};
    }
    if (!this._relationCache[fromModel].hasOwnProperty(toModel)) {
      this._relationCache[fromModel][toModel] = {};
    }

    this._relationCache[fromModel][toModel][toId] = fromId;
    if (!this._relationCache.hasOwnProperty(toModel)) {
      this._relationCache[toModel] = {};
    }
    if (!this._relationCache[toModel].hasOwnProperty(fromModel)) {
      this._relationCache[toModel][fromModel] = {};
    }
    this._relationCache[toModel][fromModel][fromId] = toId;
  }

  /**
   * find an object of type `fromModel` that is parent to `toModel`:`toId`
   *
   * @param  {String}           fromModel - the parent model
   * @param  {String}           toModel   - the child model
   * @param  {Number}           toId      - the child model's Id
   * @return {Number}                     - the id of the parent model
   */
  _findLinkingObject(fromModel, toModel, toId) {
    let fromId;
    let alias = this._aliasIds[toId];

    /*
     * Link up the tree
     */
    if (!this._relationCache.hasOwnProperty(fromModel) ||
        !this._relationCache[fromModel].hasOwnProperty(toModel)) {

      if (this._relationCache.hasOwnProperty(toModel) &&
          this._relationCache[toModel].hasOwnProperty(fromModel)) {
        Object.keys(this._relationCache[toModel][fromModel]).some((key) => {
          if (this._relationCache[toModel][fromModel][key] === toId) {
            fromId = key;
            return true;
          }
          if (this._relationCache[toModel][fromModel][key] === alias) {
            this._relationCache[toModel][fromModel][key] = toId;
            fromId = key;
            return true;
          }
        });
      }
      return fromId;

    }

    /*
     * Link down the tree
     */
    fromId = this._relationCache[fromModel][toModel][toId];
    if (!fromId && this._relationCache[fromModel][toModel].hasOwnProperty(alias)) {
      this._relationCache[fromModel][toModel][toId] =
        this._relationCache[fromModel][toModel][alias];
      delete this._relationCache[fromModel][toModel][alias];
    }
    return this._relationCache[fromModel][toModel][toId];
  }

  /**
   * find the name of a property that links parent to child
   *
   * @param  {String}             parentModel - the parent model
   * @param  {String}             childModel  - the child model
   * @return {String}                         - the name of the inverse property on childModel
   */
  _findLinkingProperty(parentModel, childModel) {
    let prop;
    let template = this._models[parentModel];
    Object.keys(template.links).some(function(linkName) {
      let link = template.links[linkName];
      if (link.model === childModel && link.hasOwnProperty('inverse')) {
        prop = link.inverse;
        return true;
      }
    });

    return prop;
  }

  /**
   * convert a JSON API representation of an object back into a the local format
   *
   * @param  {Object}       apiObject - a JSON API representation of a model
   * @return {Object}                 - the local representation of the model
   */
  _fromApiObject(apiObject) {
    let template = this._models[apiObject.type];
    let projection = {
      id: apiObject.id
    };

    if (apiObject.attributes) {
      Object.keys(apiObject.attributes).forEach(function(attr) {
        if (!template.hasOwnProperty(attr)) { return; }
        projection[attr] = apiObject.attributes[attr];
      });
    }

    if (!template.links) {
      return projection;
    }

    let relationships = apiObject.relationships;
    let setEmptyLink = function(object, link, type) {
      if (type === 'hasOne') {
        object[link] = null;
      } else {
        object[link] = [];
      }
    };
    if (relationships) {
      Object.keys(template.links).forEach((rel) => {
        let type = template.links[rel].type;
        if (!relationships.hasOwnProperty(rel)) {
          setEmptyLink(projection, rel, type);
          return;
        }

        let relationship = relationships[rel].data;
        if (relationship === null || relationship === []) {
          setEmptyLink(projection, rel, type);

        } else if (type === 'hasOne') {
          projection[rel] = relationship.id;
          this._linkModels(apiObject.type, apiObject.id,
                            relationship.type, relationship.id);

        } else {
          projection[rel] = relationship.map((linkage) => {
            this._linkModels(apiObject.type, apiObject.id,
                              linkage.type, linkage.id);
            return linkage.id;
          });
          // TODO: Remove this once it gets implemented in Elide-WS
          projection[rel].sort();
        }
      });
      Object.keys(template.meta.inverse).forEach((inverse) => {
        if (!relationships.hasOwnProperty(inverse) || !relationships[inverse].data) {
          setEmptyLink(projection, inverse, 'hasOne');
          return;
        }

        projection[inverse] = relationships[inverse].data.id;
        this._linkModels(apiObject.type, apiObject.id,
          template.meta.inverse[inverse], projection[inverse]);
      });
    } else {
      Object.keys(template.links).forEach(function(rel) {
        setEmptyLink(projection, rel, template.links[rel].type);
      });
      Object.keys(template.meta.inverse).forEach(function(inverse) {
        setEmptyLink(projection, inverse, 'hasOne');
      });
    }

    return projection;
  }

  /**
   * transforms a response from the server
   *
   * @param  {SuperAgent.response} response - the response from the server
   * @return {Object || Array}     the object or set of objects returned from the server
   */
  _transformResult(response) {
    if (response.status === 204) {
      return;
    }

    let jsonData = response.body || JSON.parse(response.text);
    let results = {};

    if (jsonData.data instanceof Array) {
      results.data = [];
      jsonData.data.forEach((record) => {
        results.data.push(this._fromApiObject(record));
      });
    } else {
      results.data = this._fromApiObject(jsonData.data);
    }

    if (jsonData.hasOwnProperty('included')) {
      results.included = {};
      jsonData.included.forEach((include) => {
        if (!results.included.hasOwnProperty(include.type)) {
          results.included[include.type] = []
        }
        results.included[include.type].push(this._fromApiObject(include));
      });
    }

    return results;
  }

  /**
   * transforms a local representation of a model and transforms it into the
   * corresponding JSON API format
   *
   * @param  {String}          model  - the name of the model being passed in
   * @param  {Object}          toSend - the local representation
   * @return {Object}                 - the JSON API formatted object
   */
  _transformRequest(model, toSend, fillBlanks) {
    let payload = {
      data: {
        type: model,
        id: toSend.id,
        attributes: {},
        relationships: {}
      }
    };
    let template = this._models[model];
    Object.keys(template).forEach((key) => {
      if (key === 'links' || key === 'meta') {
        return;
      }
      if (toSend.hasOwnProperty(key) || fillBlanks) {
        payload.data.attributes[key] = toSend.hasOwnProperty(key) ?
                                        toSend[key] :
                                        null;
      }
    });
    Object.keys(template.links).forEach((linkName) => {
      if (!toSend.hasOwnProperty(linkName) && !fillBlanks) {
        // if we are creating an object directly (not via the MemoryDataStore)
        // then we may hit this case
        return;
      }

      let link = template.links[linkName];
      let data;
      if (link.type === 'hasOne') {
        if (!toSend[linkName]) {
          data = null;

        } else {
          data = {
            type: link.model,
            id: toSend[linkName]
          };
        }

      } else if (link.type === 'hasMany') {
        if (!toSend[linkName] || toSend[linkName].length === 0) {
          data = [];

        } else {
          data = toSend[linkName].map(function(member) {
            return {
              type: link.model,
              id: member
            };
          });
        }
      }

      payload.data.relationships[linkName] = {data};
    });

    Object.keys(template.meta.inverse).forEach((inverse) => {
      if (!toSend.hasOwnProperty(inverse) && !fillBlanks) {
        // if we are creating an object directly (not via the MemoryDataStore)
        // then we may hit this case
        return;
      }

      let data = null;
      if (toSend[inverse]) {
        data = {
          type: template.meta.inverse[inverse],
          id: toSend[inverse]
        };
      }

      payload.data.relationships[inverse] = {data};
    });

    return payload;
  }

  /**
   * determine the URI for a new non-root model
   *
   * @param  {String}      model - the model
   * @param  {String}      id    - the id of the new thing
   * @param  {Object}      state - the new state of the new thing (in JSON API format)
   * @return @see #_rootModel
   */
  _rootNewModel(model, id, state) {
    let rootPath;

    let requiredModels = this._urlTemplateCache[model].split('|');
    rootPath = requiredModels.shift();
    requiredModels.pop(); // drop the last model (which == `model`)
    rootPath = rootPath.replace(`:${model}Id`, ''); // remove id in uri
    if (requiredModels.length === 0) {
      return [
        false,
        '',
        rootPath
      ];
    }

    let lastModel = requiredModels.pop();
    let prop = this._findLinkingProperty(lastModel, model);
    if (!prop) {
      return [
        true,
        ERROR_CANNOT_ROOT_OBJECT.replace('${model}', model)
          .replace('${modelState}', JSON.stringify(state))
          .replace('${parentModel}', lastModel),
        ''
      ];
    } else if (!state.relationships.hasOwnProperty(prop)) {
      return [
        true,
        `Missing linking property ${prop} to root model`,
        ''
      ];
    }
    let fromModel = {
      model: lastModel,
      id: state.relationships[prop].data.id
    };

    return this._rootModel(rootPath, requiredModels, fromModel);
  }

  /**
   * return the URI for a request about the object withData
   *
   * @param  {String}        model         - the model
   * @param  {Boolean}       getCollection - if we should return the collection endpoint
   * @param  {Object}        withData      - the state of the model
   * @return @see #_rootModel
   */
  _getUrlForModel(model, getCollection, withData) {
    let willReject = false;
    let withReason;

    let modelUrl = this._urlTemplateCache[model];
    let requiredModels = modelUrl.split('|');
    modelUrl = requiredModels.shift();

    if (requiredModels.pop() !== model) {
      willReject = true;
      withReason = ERROR_SCHEMA_INCONSISTENCY
                    .replace('${model}', model)
                    .replace('${model}', model);
    }
    modelUrl = modelUrl.replace(`:${model}Id`, getCollection ?
                                                  '' :
                                                  withData.id);

    if (!willReject && requiredModels.length > 0) {
      // find the next top level model
      let parentModel = requiredModels.pop();
      let linkingProp = this._findLinkingProperty(parentModel, model);
      let linkId = withData[linkingProp] ||
                    this._findLinkingObject(parentModel, model, withData.id);
      if (!linkingProp || !linkId) {
        willReject = true;
        withReason = ERROR_CANNOT_ROOT_OBJECT
                      .replace('${model}', model)
                      .replace('${modelState}', JSON.stringify(withData))
                      .replace('${parentModel}', parentModel);

      } else {
        let fromModel = {
          model: parentModel,
          id: linkId
        };

        // get the full query
        [
          willReject,
          withReason,
          modelUrl
        ] = this._rootModel(modelUrl, requiredModels, fromModel);
      }
    }

    return [
      willReject,
      withReason,
      modelUrl
    ];
  }

  /**
   * transform a list of json patches and group them by object
   *
   * @param  {Array}      patches - the patches to group
   * @return {Object}             - the grouped patches
   */
  _parseObjects(patches) {
    let objects = {};

    patches.forEach((patch) => {
      let [ , model, id, ...path] = patch.path.split('/');
      let template = this._models[model];
      path = path.join('/');
      patch.path = path;

      // transform value into jsonapi thingy
      if (patch.op === 'add' && patch.path === '') {
        patch.value = this._transformRequest(model, patch.value, true).data;
      } else if (template.hasOwnProperty(path)) {
        let value = patch.value;
        patch.path = '';
        patch.value = {
          id,
          type: model,
          attributes: {}
        };
        patch.value.attributes[path] = value;
      }

      if (!objects.hasOwnProperty(model)) {
        objects[model] = {};
      }
      if (!objects[model].hasOwnProperty(id)) {
        objects[model][id] = [];
      }
      objects[model][id].push(patch);
    });

    return objects;
  }

  /**
   * turn a flat grouping of patches into a relational tree
   *
   * @param  {Object}     objectDiffs - the grouping of patches
   * @param  {String}     model       - the model to pull out of the grouping
   * @param  {Object}     fromObjects - the collection where we will nest the patches we pull out
   * @return `fromObjects` is mutated and constitues the return value
   */
  _rollUpDiffs(objectDiffs, model, fromObjects) {
    let template = this._models[model];
    if (!template.links) { return; }

    Object.keys(fromObjects).forEach((id) => {
      let diffs = fromObjects[id].diffs;
      let newState;
      // new objects should only have one diff
      if (id.search(UUID) !== -1) {
        newState = diffs[0].value;
      } else {
        let relationships = {};
        for (let i = 0; i < diffs.length; i++) {
          let diff = diffs[i];
          if (template.links.hasOwnProperty(diff.path)) {
            relationships[diff.path] = {
              data: {
                type: template.links[diff.path].model,
                id: diff.value
              }
            };
          }
        }
        newState = {relationships};
      }

      Object.keys(template.links).forEach((linkName) => {
        let link = template.links[linkName];
        let linked = {};
        if (!newState.relationships.hasOwnProperty(linkName)) { return; }

        let linkage = newState.relationships[linkName].data;
        if (!linkage) { return; }

        if (linkage instanceof Array) {
          for (let i = 0; i < linkage.length; i++) {
            let cur = linkage[i];
            if (!objectDiffs[cur.type][cur.id]) { continue; }

            linked[cur.id] = {
              diffs: objectDiffs[cur.type][cur.id]
            };
            delete objectDiffs[cur.type][cur.id];
          }

        } else {
          if (!objectDiffs[linkage.type] ||
              !objectDiffs[linkage.type][linkage.id]) { return;}

          linked[linkage.id] = {
            diffs: objectDiffs[linkage.type][linkage.id]
          };
          delete objectDiffs[linkage.type][linkage.id];
        }

        this._rollUpDiffs(objectDiffs, link.model, linked);
        fromObjects[id][linkName] = linked;
      });

    });
  }

  /**
   * take a tree of patches and return an array of patches with `path`s that
   * mirror their ordering in the tree
   *
   * @param  {Object}   diffTree - the tree of diffs to flatten
   * @return {Array}             - the diffs whose `path` property has been modified
   */
  _rootDiffs(diffTree) {
    let rootedDiffs = [];

    let rootDiffs = function rootDiffs(parentPath, objectId, object) {
      let diffs = [];

      for (let i = 0; i < object.diffs.length; i++) {
        let diff = object.diffs[i];
        let path = diff.path ? `/${diff.path}` : '';
        if (diff.op === 'remove' && diff.path === '') {
          path = `/${objectId}`;
        }
        diff.path = `${parentPath}${path}`;
        diffs.push(diff);
      }
      Object.keys(object).forEach(function(key) {
        if (key === 'diffs') { return; }
        Object.keys(object[key]).forEach(function(id) {
          diffs = diffs.concat(
            rootDiffs(`${parentPath}/${objectId}/${key}`, id, object[key][id])
          );
        });
      });

      return diffs;
    };

    Object.keys(diffTree).forEach((model) => {
      let template = this._models[model];
      let isRoot = template.meta.isRootObject;

      let models = diffTree[model];
      if (isRoot) {
        Object.keys(models).forEach((id) => {
          rootedDiffs = rootedDiffs.concat(rootDiffs(model, id, models[id]));
        });

      } else {
        Object.keys(models).forEach((id) => {
          let willReject;
          let withReason;
          let rootPath;
          let diff = models[id].diffs[0];

          if (models[id].diffs.length === 1 && diff.path === '') {
            // we are creating an object
            [
              willReject,
              withReason,
              rootPath
            ] = this._rootNewModel(model, id, diff.value);
          } else {
            // we are modifiying an object
            let requiredModels = this._urlTemplateCache[model].split('|');
            let modelURI = requiredModels.shift();
            requiredModels.pop();
            [
              willReject,
              withReason,
              rootPath
            ] = this._rootModel(modelURI, requiredModels, {model, id});
          }

          rootedDiffs = rootedDiffs.concat(rootDiffs(rootPath, id, models[id]));
        });
      }
    });

    return rootedDiffs;
  }

  _createError(withMessage, modelUrl, error) {
    let err = new Error(withMessage);

    let resourceUrl;
    let requestUrl;
    let requestData;
    let responseStatus;
    let responseText;
    try {
      resourceUrl = modelUrl;
      requestUrl = this._absoluteUrl(modelUrl);
      err.stack = error.stack;
      responseStatus = error.status;
      requestData = error.response.request._data;
      if (error.status < 500) {
        responseText = error.response.text;
      }
    } catch (e) { /* pass */ }

    err.requestUrl = requestUrl;
    err.resourceUrl = resourceUrl;
    err.requestData = requestData;
    err.responseText = responseText;
    err.responseStatus = responseStatus;

    return err;
  }

  _request(method, url, queryParams, data, mimeType) {
    const request = SuperAgent(method, url)
      .query(queryParams)
      .set(clone(this._headers))
      .set('Accept', mimeType)
      .set('Content-Type', mimeType);

    if (data) {
      request.send(data);
    }

    printRequest(url, queryParams, data);
    return request;
  }

  //
  // Datastore methods
  //
  find(query) {
    let willReject = false;
    let withReason;
    let params = query._params;
    let options = query._opts;
    let fieldsModel;

    let modelFromParam = (curModel, param) => {
      let willReject = false;
      let withReason;
      let model = param.model;
      if (model !== undefined) {
        if (!this._models[model]) {
          willReject = true;
          withReason = ERROR_UNKNOWN_MODEL.replace('${model}', model);

        }

      } else {
        let field = param.field;
        if (!this._models[curModel].links ||
            !this._models[curModel].links[field]) {
          willReject = true;
          withReason = ERROR_NO_LINKED_PROPERTY
                        .replace('${model}', curModel)
                        .replace('${property}', field);
        } else {
          model = this._models[curModel].links[field].model;
        }
      }
      fieldsModel = model;
      return [willReject, withReason, model];
    };

    let queryModel;
    params.some((param) => {
      [
        willReject,
        withReason,
        queryModel
      ] = modelFromParam(queryModel, param);

      return willReject;
    });
    if (willReject) {
      return this._promise.reject(withReason);
    }

    let modelUrl = this._urlTemplateCache[queryModel];
    let requiredModels = modelUrl.split('|');
    modelUrl = requiredModels.shift();

    let rootFrom;
    queryModel = undefined;
    params.some((param) => {
      [
        willReject,
        withReason,
        queryModel
      ] = modelFromParam(queryModel, param);
      let id = param.id || '';

      if (!rootFrom && queryModel !== requiredModels[0]) {
        rootFrom = {
          id: id,
          model: queryModel
        };
      } else {
        modelUrl = modelUrl.replace(`:${queryModel}Id`, id);
      }
      requiredModels = requiredModels.filter((value) => {
        return value !== queryModel;
      });

      return id === '' || willReject;
    });

    if (requiredModels.length > 0 || rootFrom) {
      [
        willReject,
        withReason,
        modelUrl
      ] = this._rootModel(modelUrl, requiredModels, rootFrom);
    }

    if (willReject) {
      return this._promise.reject(withReason);
    }

    let queryParams = clone(this._queryParameters);

    // If the user provided fields option
    if (options && options.fields && Object.keys(options.fields).length > 0) {
      Object.keys(query._opts.fields).forEach(function(model) {
        queryParams[`fields[${model}]`] = query._opts.fields[model].join(',');
      });
    } else if (options && options.include && options.include.length > 0) {
      Object.keys(this._defaultSparseFields).forEach((modelName) => {
        queryParams[`fields[${modelName}]`] = this._defaultSparseFields[modelName];
      });
    } else {
      queryParams[`fields[${fieldsModel}]`] = this._defaultSparseFields[fieldsModel];
    }

    // If the user provided filter option
    if (options && options.filters instanceof Object) {
      Object.keys(query._opts.filters).forEach(function(modelName) {
        let modelFilters = query._opts.filters[modelName];
        modelFilters.forEach((filterDef) => {
          if (typeof filterDef.operator === 'string' && filterDef.operator !== '=') {
            queryParams[`filter[${modelName}.${filterDef.attribute}][${filterDef.operator}]`] = filterDef.value;
          } else {
            queryParams[`filter[${modelName}.${filterDef.attribute}]`] = filterDef.value;
          }
        });
      });
    }

    // If the user provided include option
    if (options && options.include && options.include.length) {
      queryParams.include = options.include.join(',');
    }

    let url = this._absoluteUrl(modelUrl);
    return this._request('GET', url, queryParams, undefined, JSONAPI_MIME_TYPE)
      .then(this._transformResult.bind(this), (error) => {
        printDebug(error);

        let message = ERROR_CANNOT_FIND_OBJECT
                      .replace('${model}', queryModel);
        throw this._createError(message, modelUrl, error);
      });
  }

  create(model, toCreate) {
    // transform the data before we send it to the server
    let data = this._transformRequest(model, toCreate, true);
    let [
      willReject,
      withReason,
      modelUrl
    ] = this._rootNewModel(model, data.data.id, data.data);

    if (willReject) {
      return this._promise.reject(withReason);
    }

    let url = this._absoluteUrl(modelUrl);
    let queryParams = clone(this._queryParameters);
    return this._request('POST', url, queryParams, data, JSONAPI_MIME_TYPE)
      .then(this._transformResult.bind(this), (error) => {
        printDebug(error);
        let message = ERROR_CANNOT_CREATE_OBJECT
                      .replace('${model}', model)
                      .replace('${modelState}', JSON.stringify(toCreate));
        throw this._createError(message, modelUrl, error);

      });
  }

  update(model, toUpdate) {
    let [
      willReject,
      withReason,
      modelUrl
    ] = this._getUrlForModel(model, false, toUpdate);


    if (willReject) {
      return this._promise.reject(withReason);
    }

    let url = this._absoluteUrl(modelUrl);
    let queryParams = clone(this._queryParameters);
    let data = this._transformRequest(model, toUpdate, false);
    return this._request('PATCH', url, queryParams, data, JSONAPI_MIME_TYPE)
      .then(this._transformResult.bind(this), (error) => {
        printDebug(error);
        let message = ERROR_CANNOT_UPDATE_OBJECT
                      .replace('${model}', model)
                      .replace('${modelState}', JSON.stringify(toUpdate));
        throw this._createError(message, modelUrl, error);
      });
  }

  delete(model, toDelete) {
    let [
      willReject,
      withReason,
      modelUrl
    ] = this._getUrlForModel(model, false, toDelete);

    if (willReject) {
      return this._promise.reject(withReason);
    }

    let url = this._absoluteUrl(modelUrl);
    let queryParams = clone(this._queryParameters);
    return this._request('DELETE', url, queryParams, undefined, JSONAPI_MIME_TYPE)
      .then(null, (error) => {
        printDebug(error);
        let message = ERROR_CANNOT_DELETE_OBJECT
                      .replace('${model}', model)
                      .replace('${modelState}', JSON.stringify(toDelete));
        throw this._createError(message, modelUrl, error);
      });
  }

  /**
   * make the changes specified in the `patches`
   *
   * @param  {Array} patches - an array of patches whose path begins with `/${model}/${id}`
   * @return {Promise}       - a promise containing the results of the commit, only those items
   *                           that the server modified beyond what was specified in the patch
   *                           will be in the array passed to `resolve`. The contents of the
   *                           array will have the form:
   *                           {
   *                               type: string
   *                               oldId: uuid
   *                               data: Object
   *                           }
   */
  commit(patches) {
    let unrootedDiffs = this._parseObjects(patches);

    let diffTree = {};
    for (let i = 0; i < this._modelHierarchy.length; i++) {
      let model = this._modelHierarchy[i];
      if (!unrootedDiffs[model]) { continue; }

      if (Object.keys(unrootedDiffs[model]).length > 0) {
        diffTree[model] = {};
      }

      Object.keys(unrootedDiffs[model]).forEach((id) => {
        diffTree[model][id] = {
          diffs: unrootedDiffs[model][id]
        };
        delete unrootedDiffs[model][id];
      });

      this._rollUpDiffs(unrootedDiffs, model, diffTree[model]);

      // clean up models we've completely rooted
      Object.keys(unrootedDiffs).forEach((model) => {
        if (Object.keys(unrootedDiffs[model]).length === 0) {
          delete unrootedDiffs[model];
        }
      });
    }

    let url = this._absoluteUrl('/');
    let queryParams = clone(this._queryParameters);
    let data = this._rootDiffs(diffTree);
    return this._request('PATCH', url, queryParams, data, JSONPATCH_MIME_TYPE)
      .then((result) => {
        // responses are 200 (OK) or 204 (No content)
        // the patch extension returns an array of responses
        if (result.status !== 204) {
          let results = [];
          for (let i = 0; i < result.body.length; i++) {
            let jsonAPIdoc = result.body[i];
            let diff = data[i];
            if (jsonAPIdoc === null) { continue; }

            let obj = this._transformResult({body: jsonAPIdoc});
            let oldId = diff.op === 'add' ? diff.value.id : undefined;
            let newId = obj.id;

            this._setAlias(newId, oldId);

            results.push({
              type: jsonAPIdoc.data.type,
              oldId,
              data: obj.data
            });
          }

          return results;
        }
      }, (error) => {
        printDebug(error);
        throw this._createError('Could not commit changes', '/', error);
      });
  }

  addQueryParameter(key, value) {
    this._queryParameters[key] = value;
  }

  addRequestHeader(key, value) {
    this._headers[key] = value;
  }

  _clearAuthData() {
    this._headers = {};
    this._queryParameters = {};
  }

  _dehydrate() {
    let aliasIds = JSON.stringify(this._aliasIds);
    let relationCache = JSON.stringify(this._relationCache);

    let headers = JSON.stringify(this._headers);
    let queryParameters = JSON.stringify(this._queryParameters);

    return {
      aliasIds,
      relationCache,
      headers,
      queryParameters
    };
  }

  _rehydrate(state) {
    let {
      aliasIds,
      relationCache,
      headers,
      queryParameters
    } = state;

    this._aliasIds = JSON.parse(aliasIds);
    this._relationCache = JSON.parse(relationCache);
    this._headers = JSON.parse(headers);
    this._queryParameters = JSON.parse(queryParameters);
  }
}

export default JsonApiDatastore;