Source: resourceful_endpoint.js

import Query from './query';
import ResourceCollection from './resource_collection';
import Resource from './resource';

/**
 * ResourceFulEndpoint is the main interaction class against sequoia MDS endpoints.
 * This is what you'll be using to present data to users, for example content items, registered users etc.
 *
 * This class should not be used directly, but should instead be obtained
 * from {@link ServiceDescriptor#resourcefulEndpoint}
 *
 * @param {Transport} transport - Transport instance to use for fetching
 * @param {Object} resourceful - JSON object describing this resourceful endpoint
 * (fetched from sequoia services 'descriptor')
 *
 * @example
 * // `contents` is used in the rest of the examples as our reference
 * // to a ResourcefulEndpoint
 * let contents;
 *
 * client.login('username', 'password').then(session => {
 *   client.service('metadata').then(service => {
 *     // Get a resourceful endpoint (this is synchronous as the service passed
 *     // all the necessary data):
 *     contents = service.resourcefulEndpoint('contents');
 *     // whatever
 *   });
 * });
 *
 * @private
 */
class ResourcefulEndpoint {
  /**
   * @param {Object} resourceful - the Sequoia descriptor part that describes this endpoint
   *
   * @returns {ResourcefulEndpoint}
   */
  constructor(transport, resourceful) {
    this.transport = transport;
    this.resourceful = resourceful;
  }

  get owner() {
    if (this.resourceful) {
      return this.resourceful.tenant;
    }

    return undefined;
  }

  /**
   * Turn the first item in the Sequoia response for this endpoint
   * into a {@link Resource}. This is a convenience for when operating
   * on individual Resources (read, update, store) where the sequoia response
   * is of the form
   * ```
   * {
   *   <pluralName>: [<Resource>],
   *   meta: { ... }
   * }
   * ```
   *
   * and we want to just operate on `Resource`
   *
   * @private
   *
   * @returns {Resource}
   */
  responseToResource(json) {
    const resource = json[this.pluralName][0];
    resource.linked = json.linked;

    return new Resource(resource, this);
  }

  /**
   * Obtain a new {@link Resource} for this endpoint.
   * A *new* resource is something that has not yet been created remotely.
   *
   * The `owner` property will be pre-populated with the current tenancy. Provding a
   * a value for this will override the current tenancy. This is useful when using
   * root tenancy but populating data on behalf of non-root tenancies.
   * See {@link Client#setTenancy} for more information.
   *
   * When a `name` property is provided, the `ref` (unique id) of this resource will
   * also be populated. This is useful for allowing linking different kinds of resources
   * together (see {@link Resource#link}) before saving the resources.
   *
   * There is a potential that `name`s you choose will conflict with already stored resources.
   * In this case, your changes will override the remote resource. See the below example
   * for how to handle these situations.
   *
   * @param {Object} data -data to populate the {@link Resource} with
   * @param {string=} data.owner -Defaults to the current tenancy name
   *
   * @example <caption>Create a new resource</caption>
   *
   * const contentItem = contents.newResource({
   *  name: 'my-new-content',
   *  title: 'something',
   *  synopsis: 'a really long synopsis'
   * });
   *
   * // You can now call methods on it, for example, a usual flow for creating
   * // a Resource would be:
   *
   * contentItem.validate()
   * .then(() => contentItem.save())
   * .then(() => {
   *   // Success, do something (redirect to a new view?)
   * }).catch((error) => {
   *   // Validation error (from Resource object or the server), show the user what went wrong
   * });
   *
   * @example <caption>Update or create a piece of content</caption>
   *
   * const potentiallyNewContentItem = contents.newResource({
   *  name: 'my-potentially-new-content',
   *  title: 'something new',
   *  synopsis: 'a really long synopsis'
   * });
   *
   * function findOrCreateResource(resourceful, data) {
   *   return resourceful.readOne(`${resourceful.owner}:${data.name}`).catch(() => resourceful.newResource(data));
   * }
   *
   * // You can now call methods on it, for example, a usual flow for creating
   * // a Resource would be:
   *
   * findOrCreateResource(contents, potentiallyNewContentItem)
   * .then((contentItem) => {
   *   // Update with new data in case it was an existing resource
   *   Object.assign(contentItem, potentiallyNewContentItem);
   *   contentItem.duration = 'PT75M';
   *
   *   return contentItem.save())
   * .then(() => {
   *   // Success, do something (redirect to a new view?)
   * }).catch((error) => {
   *   // Validation error (from the server), show the user what went wrong
   * });
   *
   * @returns {Resource}
   */
  newResource(data = { owner: this.owner }) {
    data.owner = data.owner || this.owner;

    if (data.ref === undefined && data.owner && data.name) {
      data.ref = `${data.owner}:${data.name}`;
    }

    return new Resource(Object.assign({ is_new: true }, data), this);
  }

  /**
   * Create a new {@link ResourceCollection} for this endpoint.
   *
   * Useful for creating many resources for ingest.
   *
   * @param {Array<Object>} data -data to populate the {@link ResourceCollection} with
   *
   * @example <caption>Create a new resource</caption>
   *
   * const contentItems = contents.newResourceCollection([{
   *  name: 'one',
   *  title: 'something',
   *  synopsis: 'a really long synopsis'
   * },
   * {
   *  name: 'two',
   *  title: 'something else',
   *  synopsis: 'another really long synopsis'
   * }]);
   *
   * contentItems.validate()
   * .then(() => contentItems.save())
   * .then(() => {
   *   // Success, do something (redirect to a new view?)
   * }).catch((error) => {
   *   // Validation error (from a nested Resource object or the server), show the user what went wrong
   * });
   *
   * @returns {ResourceCollection}
   */
  newResourceCollection(data = []) {
    const { pluralName } = this.resourceful;

    let collectionData;

    if (Array.isArray(data)) {
      collectionData = {
        [pluralName]: data
      };
    } else {
      collectionData = data;
      console.warn(
        'Using newResourceCollection with an object is deprecated - pass an array instead'
      );
    }

    return new ResourceCollection(collectionData, '', this);
  }

  /**
   * Perform a browse (a GET for all items on this resourceful endpoint, with
   * optional `criteria`). The collection returned is the first page of results as
   * specified by `perPage` (or the Resourceful default e.g. 100)
   *
   * @param {(string|Query)} criteria - A query string to append to the request
   * @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
   *
   * @see {@link Trasport#get}
   *
   * @returns {Promise} - A {@link ResourceCollection}
   */
  browse(criteria, options) {
    const query = (criteria || '').hasOwnProperty('query')
      ? criteria
      : new Query(criteria);

    query.addRelatedThroughFields(
      this.resourceful.relationships || {},
      Object.keys(this.resourceful.fields || [])
    );

    return this.transport
      .get(this.endPointUrl(null, query, options))
      .then(json => new ResourceCollection(json, criteria, this));
  }

  /**
   * The same as {@link ResourcefulEndpoint#browse} but will fetch *all* pages as a single
   * {@link ResourceCollection}
   *
   * @param {(string|Query)} criteria - A query string to append to the request
   * @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
   *
   * @see {@link Transport#get}
   *
   * @returns {Promise} - A {@link ResourceCollection}
   */
  async all(criteria, options) {
    const { pluralName } = this.resourceful;
    const rawData = {
      meta: {},
      [pluralName]: []
    };

    // This is to avoid memory issues caused by recursion of Promises
    // See https://alexn.org/blog/2017/10/11/javascript-promise-leaks-memory.html
    // And https://github.com/promises-aplus/promises-spec/issues/179
    let collection = await this.browse(criteria, options);
    let hasNext = true;
    while (hasNext) {
      hasNext = collection.hasNextPage();

      // Things rely on rawData, not the derived collections
      rawData[pluralName].push(...collection.rawData[pluralName]);
      if (collection.rawData.linked) {
        Object.entries(collection.rawData.linked).forEach(([key, value]) => {
          if (!rawData.linked) rawData.linked = {};
          if (!rawData.linked[key]) rawData.linked[key] = [];
          rawData.linked[key].push(...value);
        });
      }

      if (hasNext) {
        // eslint-disable-next-line no-await-in-loop
        collection = await collection.nextPage();
      }
    }

    rawData.meta.totalCount = rawData[pluralName].length;
    return new ResourceCollection(rawData, criteria, this);
  }

  /**
   * Fetch (performs an HTTP GET on) an individual item, with optional criteria
   *
   * @param {string} ref - The unique `reference` for this item
   * @param {(string|Query)} criteria - A query string to append to the request
   * @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
   *
   * @see {@link Transport#get}
   *
   * @returns {Promise} - A {@link Resource}
   */
  readOne(ref, criteria, options) {
    return this.transport
      .get(this.endPointUrl(ref, criteria), options)
      .then(json => this.responseToResource(json));
  }

  /**
   * Fetch (performs an HTTP GET on) many items, with optional criteria
   *
   * @param {Array<String>} refs - The unique `references` for the items
   * @param {(string|Query)} criteria - A query string to append to the request
   * @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
   *
   * @see {@link Transport#get}
   *
   * @returns {Promise} - A {@link ResourceCollection}
   */
  readMany(refs, criteria, options) {
    return this.transport
      .get(this.endPointUrl(refs.join(','), criteria), options)
      .then(json => new ResourceCollection(json, criteria, this));
  }

  /**
   * Store (perform an HTTP POST for) a new item
   *
   * @param {Resource} resource - A {@link Resource} corresponding to the new resource you wish to save
   * @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
   *
   * @see {@link Transport#post}
   *
   * @todo Validate against the descriptor that this is a valid item to store
   *
   * @returns {Promise}
   */
  store(resource, options) {
    return this.transport
      .post(
        this.endPointUrl(),
        Object.assign({ body: resource.serialise() }, options)
      )
      .then(json => this.responseToResource(json));
  }

  /**
   * Update (perform an HTTP PUT for) an existing item
   *
   * @param {Object} resource - An Object corresponding to the new resouce you wish to save
   * @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
   *
   * @see {@link Transport#put}
   *
   * @todo Validate against the descriptor that this is a valid item to store
   *
   * @returns {Promise}
   */
  update(resource, options) {
    return this.transport
      .put(
        this.endPointUrl(resource.ref),
        Object.assign({ body: resource.serialise() }, options)
      )
      .then(json => this.responseToResource(json));
  }

  /**
   * Destroy (perform an HTTP DELETE for) an existing item
   *
   * @param {Object} resource - An Object corresponding to the resouce you wish to delete
   * @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
   *
   * @see {@link Transport#destroy}
   *
   * @returns {Promise}
   */
  destroy(resource, options) {
    let { ref } = resource;

    if (typeof resource === 'string') {
      ref = resource;
    }

    return this.transport.destroy(this.endPointUrl(ref), options);
  }

  /**
   * Get the relationship info for `relationshipName` from the descriptor
   *
   * @param {string} relationshipName - The name of the relationship e.g. 'assets'
   *
   * @throws {Error} - Throws when the relationship doesn't exist
   *
   * @example
   *
   * contents.relationshipFor('categories');
   *
   * // Returns:
   *
   * {
   *   "description": "The associated categories",
   *   "type": "direct",
   *   "resourceType": "categories",
   *   "fieldNamePath": "categoryRefs",
   *   "fields": [
   *     "ref",
   *     "title",
   *     "parentRef",
   *     "scheme",
   *     "value"
   *   ],
   *   "name": "categories",
   *   "batchSize": 10
   * }
   *
   * @returns {Object}
   */
  relationshipFor(relationshipName) {
    const [name] = Object.entries(this.relationships).find(
      ([, relationship]) => relationship.resourceType === relationshipName
    ) || [undefined];

    if (name) {
      return this.relationships[name];
    }

    throw new Error(`Relationship '${relationshipName}' does not exist`);
  }

  /**
   * Get the relationship info from the descriptor.
   *
   * @example
   *
   * contents.relationships
   *
   * // Returns:
   *
   * {
   *   "children": {
   *     "description": "The associated child contents",
   *     "type": "indirect",
   *     "resourceType": "contents",
   *     "filterName": "withParentRef",
   *     "fields": [
   *       "ref",
   *       "title",
   *       "parentRef",
   *       "type"
   *     ],
   *     "batchSize": 10,
   *     "name": "children"
   *   },
   *   "assets": {
   *     "description": "The associated assets",
   *     "type": "indirect",
   *     "resourceType": "assets",
   *     "filterName": "withContentRef",
   *     "fields": [
   *       "ref",
   *       "name",
   *       "contentRef",
   *       "type",
   *       "url",
   *       "fileFormat",
   *       "title",
   *       "fileSize",
   *       "tags"
   *     ],
   *     "batchSize": 10,
   *     "name": "assets"
   *   }
   * }
   *
   * @type {object}
   */
  get relationships() {
    return this.resourceful.relationships;
  }

  /**
   * Get the `pluralName` from the descriptor. The plural name is used to identify
   * the content array from the JSON response from Sequoia. e.g. for `assets`, the
   * response will be of the form:
   *
   * ```json
   * {
   *   assets: [ asset resource, asset resource ... ],
   *   linked: [ linked resource, linked resource ... ],
   *   meta: { ...meta info }
   * }
   * ```

   * @type {string}
   */
  get pluralName() {
    return this.resourceful.pluralName;
  }

  /**
   * Get the `singularName` from the descriptor. The singular name is used to identify
   * what the specific resourceful resource is identified as.
   *
   * @type {string}
   */
  get singularName() {
    return this.resourceful.singularName;
  }

  /**
   * Get the `hyphenatedPluralName` from the descriptor. The hyphenated plural name
   * is used to identify what the resourceful endpoint is called. e.g. a camelCased
   * resource (`pluralName`) like `contentSegments` will live under an HTTP endpoint
   * of `content-segments`
   *
   * @type {string}
   */
  get hyphenatedPluralName() {
    return this.resourceful.hyphenatedPluralName;
  }

  /**
   * Get the `serviceName` from the descriptor.
   *
   * @type {string}
   */
  get serviceName() {
    return this.resourceful.serviceName;
  }

  /**
   * Get the `batchSize` from the descriptor's `storeBatch` operation.
   * This is how many resources can be saved at once when saving a {@link ResourceCollection}
   *
   * @type {number}
   */
  get batchSize() {
    const defaultBatchSize = 100;
    const { operations } = this.resourceful;

    if (operations && operations.storeBatch) {
      return operations.storeBatch.limit || defaultBatchSize;
    }

    return defaultBatchSize;
  }

  // Private methods

  /**
   * Return a query string to append to the HTTP call. Will default to appending
   * `?owner=<owner>`
   *
   * @param {(string|Query)?} criteria - A (potential) query string to append to the request
   *
   * @private
   *
   * @returns {string}
   */
  criteriaToQuery(criteria) {
    let stringifiedCriteria = criteria || '';

    if (criteria instanceof Query) {
      stringifiedCriteria = criteria.toQueryString();
    }

    let query = `?${stringifiedCriteria}`;
    const hasOwner = stringifiedCriteria.match(/&?owner=[^&]+/);

    if (!hasOwner) {
      query = stringifiedCriteria.length ? `?owner=${this.owner}&${stringifiedCriteria}` : `?owner=${this.owner}`;
    }

    return query;
  }

  /**
   * Get the full URL to the resourceful endpoint/item we will send the request to
   *
   * @param {string?} ref - An optional unique ref for performing actions on a unique item (rather than browsing)
   * @param {(string|Query)?} criteria - A (potential) query string to append to the request
   *
   * @see {@link ResourcefulEndpoint#criteriaToQuery}
   *
   * @private
   *
   * @returns {string}
   */
  endPointUrl(ref, criteria) {
    let resourceRef = '';

    if (ref) {
      resourceRef = `/${ref}`;
    }

    return `${this.resourceful.location}/${
      this.resourceful.hyphenatedPluralName
    }${resourceRef}${this.criteriaToQuery(criteria)}`;
  }
}

export default ResourcefulEndpoint;