resource.js

'use strict';

var _ = require('lodash');
var LinkCollection = require('./link_collection');

/**
 * A predicate to inspect a given {@link Resource} to decide to include or not.
 * @callback Resource~linkPredicate
 * @param {WebLink} link The candidate link
 * @returns {boolean} Whether to include the link in the response(s) or not.
 */

/**
 * A predicate to inspect a given {@link Resource} to decide to include or not.
 * @callback Resource~resourcePredicate
 * @param {Resource} resource The candidate resource
 * @returns {boolean} Whether to include the resource in the response(s) or not.
 */

/**
 * @constructor
 *
 * @classdesc
 * {@link Resource} instances behave like AngularJS' `ngResource`, in that
 * resources are returned directly from calls, and the values in the resource
 * will be merged into the object once the background request(s) complete.
 * Doing so allows a view layer to directly bind to the resource fields. Should
 * you need to do something once the resource is loaded, the `$promise`
 * property of the resource is available.
 *
 * {@link Resource} offers several functions you can use to interact with links,
 * embedded resources, and forms included in the resource.
 */
var Resource = function() {
  /**
   * This property is a ES6 promise that can be used to perform work once the
   * resource is resolved. For resources that were embedded, the promise may already
   * resolved when the resource is initially created.
   * @type {Promise}
   */
  this.$promise = null;

  /**
   * This property is a simple boolean `true/false` value indicating whether
   * the specific resource has been resolved or not.
   * @type {boolean}
   */
  this.$resolved = false;

  /**
   * For embedded/sub-resources, this will point to the immediate parent
   * resource containing this one.
   * @type {?Resource}
   */
  this.$parent = null;

  /**
   * This property will be populated by the HTTP response information when
   * the resource is resolved. For embedded resources, the data portion will
   * be the subsection of the response used to created the embedded resource.
   * @type {?{data: Object, headers: Object.<String, String>, status: number}}
   */
  this.$response = null;

  /**
   * If there is a problem resolving the {@link Resource}, this will contain
   * the error information.
   */
  this.$error = null;

  /**
   * Object containing any format specific properties that don't fall under
   * the standard categories such as forms, fields, or links.
   * @type {Object}
     */
  this.$formatSpecific = {};

  this.$$links = {};
  this.$$embedded = {};
  this.$$forms = {};
  this.$$curiePrefixes = {};

  /**
   * Get the single {@link WebLink} for the given relation.
   *
   * @arg {string} rel The link relation to look up.
   * @arg {Object|Resource~linkPredicate} [filter] The filter object/predicate to filter links to desired one.
   * @returns {WebLink} The link with the given link relation, or null if not found.
   * @throws An error if multiple links are present for the link relation.
   * @example
   * res.$link('next')
   * => WebLink { href: '/posts?page=2' }
   */
  this.$link = function(rel, filter) {
    var ret = this.$links(rel, filter);
    if (ret.length === 0) {
      return null;
    }
    if (ret.length > 1) {
      throw 'Multiple links present';
    }

    return ret[0];
  };

  /**
   * Return a {@link LinkCollection} for the given link relation.
   *
   * @arg {string} [rel] The link relation to look up. If not provided, all
   * links in the resource will be return.
   * @arg {Object|Resource~linkPredicate} [filter] The filter object/predicate to filter matching links.
   * @returns {LinkCollection} The links with the given link relation, or
   * all the links in the resource if a rel is not provided.
   * @example
   * res.$links('posts')
   * => LinkCollection [ WebLink { href: '/posts/123' }, WebLink { href: '/posts/345' } ]
   */
  this.$links = function(rel, filter) {
    if (!rel) {
      return LinkCollection.fromArray(_.flatten(_.values(this.$$links)));
    }

    var links = _.get(this.$$links, rel, []);

    if (filter) {
      links = _.filter(links, filter);
    }

    return links;
  };

  /**
   * Get the single {@link Form} for the given relation. The returned form
   * is a cloned copy of the {@link Form} in the resource. Each call to
   * this function will return a new copy, so that multiple forms can be
   * created, modified, and submitted without reloading the containing
   * {@link Resource}.
   *
   * @arg {string} rel The link relation to look up.
   * @returns {Form} The copy of form with the given link relation, or null if not found.
   * @throws An error if multiple forms are present for the link relation.
   * @example
   * res.$form('create-form')
   * => Form { href: '/posts?page=2', method: 'POST', ... }
   */
  this.$form = function(rel) {
    var ret = _.get(this.$$forms, rel, []);

    if (ret.length === 0) {
      return null;
    }

    if (ret.length > 1) {
      throw 'Multiple forms present';
    }

    return ret[0].clone();
  };

  /**
   * Get the {@link Form} instances for the given relation. The returned forms
   * are a cloned copy of the {@link Form} instances in the resource. Each call
   * to this function will return new copies, so that multiple forms can be
   * created, modified, and submitted without reloading the containing
   * {@link Resource}.
   *
   * @arg {string} [rel] The link relation to look up. If omitted, returns all forms in the resource.
   * @returns {Array} An array of cloned forms, or an empty array if not found.
   * @example
   * res.$forms('create-form')
   * => [Form { href: '/posts?page=2', method: 'POST', ... }]
   * @example
   * res.$forms()
   * => [Form { href: '/posts?page=2, 'method: 'POST", ... }]
   */
  this.$forms = function(rel) {
    if (!rel) {
      return _.invoke(_.flatten(_.values(this.$$forms)), 'clone');
    }

    return _.invoke(_.get(this.$$forms, rel, []), 'clone');
  };

  /**
   * Follows a link relation, if present.  The link relation will be looked for
   * in the embedded resources first, and fall back to checking for the
   * presence of a link and loading those. Depending on whether an embedded
   * version is found, or only a link, will determine whether the resource will
   * already be resolved, or will be so in the future. The optional `options`
   * parameter can be used to pass additional options to the underlying http
   * request.
   *
   * @arg {string} rel The link relation to follow.
   * @arg {Object} [options] Options for following the link. For details, see {@link WebLink#follow}.
   * @arg {Object|Resource~linkPredicate} [options.linkFilter] A matching object or filter function when inspecting links.
   * @arg {Object|Resource~resourcePredicate} [options.subFilter] A matching object or filter function when inspecting sub/embedded resources.
   * @returns {Resource} The linked/embedded resource, or null if the link relation is not found.
   * @throws Will throw an error if multiple instances of the relation are present.
   * @example
   * res.$followOne('next')
   * => Resource { $resolved: false, $promise: $q promise object }
   */
  this.$followOne = function(rel, options) {
    options = options || {};

    if (this.$resolved) {
      var res = this.$sub(rel, options.subFilter);
      if (res !== null) {
        return res;
      }

      var l = this.$link(rel, options.linkFilter);
      if (l === null) {
        return null; // TODO: Return a resource w/ an error?s
      }

      return l.follow(options);
    }

    var ret = new Resource();
    ret.$promise =
        this.$promise.then(function(r) {
          return r.$followOne(rel, options).$promise;
        }).then(function(r) {
          var promise = ret.$promise;
          _.assign(ret, r);
          ret.$promise = promise;
          return ret;
        });

    return ret;
  };

  /**
   * Follow all links for the given relation and return an array of resources.
   * If the link relation is not present, then an empty array will be returned.
   * It will first attempt to locate the link relation in the embedded
   * resources, and fall back to checking for the presence of a link and
   * loading those. Depending on whether an embedded version is found, or only
   * links, will determine whether the resources will already be resolved, or
   * will be so in the future.
   *
   * @arg {string} rel The link relation to follow.
   * @arg {Object} [options] Options for following the link. For details, see {@link WebLink#follow}.
   * @arg {Object|Resource~linkPredicate} [options.linkFilter] Filter object/predicate for filtering candidate links to follow.
   * @arg {Object|Resource~resourcePredicate} [options.subFilter] A matching object or filter function when inspecting sub/embedded resources.
   * @returns {Array} The linked/embedded resources, or an empty array if the link relation is not found.
   * @example
   * res.$followAll('item')
   * => [Resource { $resolved: false, $promise: $q promise object }, Resource { $resolved: false, $promise: $q promise object }]
   */
  this.$followAll = function(rel, options) {
    options = options || {};
    if (this.$resolved) {
      var subs = this.$subs(rel, options.subFilter);
      if (subs.length > 0) {
        return subs;
      }

      return LinkCollection.fromArray(this.$links(rel, options.linkFilter)).follow(options);
    }

    var ret = [];
    ret.$resolved = false;
    ret.$error = null;
    ret.$promise =
      this.$promise.then(function(r) {
        var resources = r.$followAll(rel, options);
        Array.prototype.push.apply(ret, resources);
        return resources.$promise.catch(function(err) {
          ret.$resolved = true;
          ret.$error = { message: 'One or more resources failed to load for $followAll(' + rel + ')', inner: err };
          throw ret;
        });
      }, function(err) {
        ret.$resolved = true;
        ret.$error = { message: 'Parent resolution failed, unable to $followAll(' + rel + ')', inner: err };
        throw ret;
      }).then(function() {
        ret.$resolved = true;
        return ret;
      });

    return ret;
  };
};

/**
 * Expand a CURIE (compact URI) by looking up a prefix binding
 * and processing it according to the media type specific CURIE
 * processing rules.
 * @param {String} curie The compact URI to expand.
 * @returns {String} The CURIE expanded into a final URI.
 * @throws {Error} Raises an error when trying to expand using
 * an unknown CURIE prefix.
 */
Resource.prototype.$expandCurie = function(curie) {
  var pieces = curie.split(':', 2);
  return this.$curiePrefix(pieces[0]).expand(pieces[1]);
};

/**
 * Locate a media-type specific registered CURIE (compact URI)
 * prefix ({@link CuriePrefix}).
 * @param {String} curiePrefix The CURIE prefix for look up.
 * @returns {CuriePrefix} The media-type specific CURIE prefix.
 * @throws {Error} Raises an error when looking for an unknown
 * CURIE prefix.
 */
Resource.prototype.$curiePrefix = function(curiePrefix) {
  var res = this;
  var prefix = null;

  while (!prefix && res) {
    prefix = res.$$curiePrefixes[curiePrefix];
    res = res.$parent;
  }

  if (!prefix) {
    throw new Error('Unknown CURIE prefix');
  }

  return prefix;
};

/**
 * Expand a CURIE (compact URI) by looking up a prefix binding
 * and processing it according to the media type specific CURIE
 * processing rules, and then follow the final URI.
 * @param {String} curie The compact URI to follow
 * @param {Object} options The options to pass when following
 * the expanded URI.
 * @returns {Resource} The resource from following the expanded URI.
 * @throws {Error} Raises an error when looking for an unknown
 * CURIE prefix.
 */
Resource.prototype.$followCurie = function(curie, options) {
  var pieces = curie.split(':', 2);
  return this.$curiePrefix(pieces[0]).follow(pieces[1], options);
};

/**
 * Look up the embedded/sub resources for the given link relation.
 *
 * @arg {string} rel The link relation to follow.
 * @arg {Object|Resource~resourcePredicate} [filter] A match object/predicate to limit returned sub-resources.
 * @returns {Array} Array of embedded resources, or empty array if the link relation is not found.
 * @example
 * res.$subs('item')
 * => [Resource { $resolved: true, $promise: resolved $q promise, ... various properties }]
 */
Resource.prototype.$subs = function(rel, filter) {
  if (!this.$$embedded.hasOwnProperty(rel)) {
    return [];
  }

  var subs = this.$$embedded[rel];
  if (filter) {
    subs = _.filter(subs, filter);
  }

  return subs;
};


/**
 * Look up the embedded/sub resource for the given link relation.
 *
 * @arg {string} rel The link relation to follow.
 * @arg {Object|Resource~resourcePredicate} [filter} The matching object/predicate to filter sub-resources.
 * @returns {Resource} The embedded resource, or null if the link relation is not found.
 * @throws Will throw an error if multiple instances of the relation are present.
 * @example
 * res.$sub('item')
 * => Resource { $resolved: true, $promise: resolved $q promise, ... various properties }
 */
Resource.prototype.$sub = function(rel, filter) {
  var ret = this.$subs(rel, filter);
  if (ret.length === 0) {
    return null;
  }
  if (ret.length > 1) {
    throw 'Multiple sub-resources present';
  }

  return ret[0];
};

/**
 * Alias for {@link Resource#$sub}.
 * @function
 */
Resource.prototype.$embedded = Resource.prototype.$sub;

/**
 * Alias for {@link Resource#$subs}.
 * @function
 */
Resource.prototype.$embeddeds = Resource.prototype.$subs;


/**
 * Check for existence of a linked or embedded resource for the given link
 * relation. The function does _not_ take into account whether the resource is
 * resolved or not, so the return value may be different once the resource is
 * resolved.
 * @arg {string} rel The link relation to check for.
 * @arg {Object} [filter] The link/sub-resource filter
 * @arg {Object|Resource~linkPredicate} [filter.linkFilter] A matching object or filter function when inspecting links.
 * @arg {Object|Resource~resourcePredicate} [filter.subFilter] A matching object or filter function when inspecting sub/embedded resources.
 * @return {boolean} True if the link relation is found in links or embedded, otherwise false.
 */
Resource.prototype.$has = function(rel, filter) {
  var links = this.$links(rel);
  if (filter && filter.linkFilter) {
    links = _.filter(links, filter.linkFilter);
  }
  return links.length > 0 || this.$subs(rel).length > 0;
};

/**
 * Send an HTTP DELETE request to the resource's 'self' link.
 * @return {Resource} A resources with the response of the DELETE request.
 */
Resource.prototype.$delete = function() {
  return this.$followOne('self', { protocol: {method: 'DELETE'} });
};

var defaultParser = _.constant({});

Resource.prototype.$$resolve = function(response, context) {
  var data = response.data, headers = response.headers;
  this.$response = response;

  _.forEach(context.extensions, function(e) {
    if (!e.applies(data, headers, context)) {
      return;
    }

    var fields = (e.dataParser || _.constant([])).apply(e, [data, headers, context]);

    _.assign(this, _.reduce(fields, function(result, val) {
      result[val.name] = val.value;
      return result;
    }, {}));

    _.assign(this.$$links, (e.linkParser || defaultParser).apply(e, [data, headers, context]));
    _.assign(this.$$forms, (e.formParser || defaultParser).apply(e, [data, headers, context]));
    _.assign(this.$$embedded, (e.embeddedParser || defaultParser).apply(e, [data, headers, context, this]));
    _.assign(this.$$curiePrefixes, (e.curiePrefixParser || defaultParser).apply(e, [data, headers, context]));
    _.assign(this.$formatSpecific, (e.formatSpecificParser || defaultParser).apply(e, [data, headers, context]));
  }, this);

  this.$resolved = true;
};

Resource.prototype.$$reject = function(error) {
  this.$error = error;
  this.$resolved = true;
};

Resource.embedded = function(raw, headers, context, parent) {
  var ret = new Resource();
  ret.$$resolve({ data: raw, headers: headers }, context);
  ret.$parent = parent;
  ret.$promise = Promise.resolve(ret);
  return ret;
};

Resource.embeddedCollection = function(items, headers, context, parent) {
  var embeds = items.map(function(e) { return Resource.embedded(e, headers, context, parent); }, this);

  embeds.$promise = Promise.resolve(embeds);
  embeds.$resolved = true;

  return embeds;
};

Resource.fromRequest = function(request, context) {
  var res = new Resource();
  res.$promise =
    request.then(function(response) {
      context = context.baseline();
      if (response.config && response.config.url) {
        context = context.forResource({
          url: response.config.url,
          headers: response.headers
        });
      }
      res.$$resolve(response, context);
      return res;
    }, function(response) {
      res.$$reject({message: 'HTTP request to load resource failed', inner: response });
      throw res;
    });

  return res;
};

module.exports = Resource;