EntryStore.js

import Auth from './Auth.js';
import Cache from './Cache.js';
import factory from './factory.js';
import PrototypeEntry from './PrototypeEntry.js';
import Resource from './Resource.js';
import Rest from './Rest.js';
import SolrQuery from './SolrQuery.js';
import types from './types.js';
import User from './User.js';
import { isBrowser } from './utils.js';

/**
 * EntryStore is the main class that is used to connect to a running server-side EntryStore
 * repository.
 * @exports store/EntryStore
 */
export default class EntryStore {
  /**
   * @param {String=} baseURI - URL to the EntryStore repository we should communicate with,
   * may be left out and
   * guessed if run in a browser environment (appends "/" to the window.location.origin)
   * @param {Object=} credentials - same as provided in the {@link EntryStore#auth auth}
   * method.
   */
  constructor(baseURI, credentials) {
    if (isBrowser() && baseURI == null) {
      this._baseURI = `${window.location.origin}/`;
    } else {
      this._baseURI = baseURI;
      if (this._baseURI[this._baseURI.length - 1] !== '/') {
        this._baseURI = `${this._baseURI}/`;
      }
    }

    this._cache = new Cache();
    this._auth = new Auth(this);
    if (credentials) {
      this._auth.login(...credentials);
    }
    this._contexts = {};
    this._rest = new Rest();
    this._requestCache = false;
  }

  /**
   * Provides a listener that will be called for every asynchronous call being made.
   * The handler is invoked with the promise from the asynchronous call
   * and a callType parameter indicating which asynchronous call that has been made.
   *
   * The callType parameter can take the following values:
   * - getEntry        - an entry is retrieved (EntryStore.getEntry)
   * - createEntry     - an entry is created   (EntryStore.createEntry)
   * - createGroupAndContext - a group and context pair is created
   * (EntryStore.createGroupAndContext)
   * - loadViaProxy    - data is requested via repository proxy (EntryStore.loadViaProxy)
   * - commitMetadata  - changes to metadata is pushed (Entry.commitMetadata)
   * - commitCachedExternalMetadata - changes to cached external metadata is pushed
   * (Entry.commitCachedExternalMetadata)
   * - getResource     - the entry's resource has been requested (Entry.getResource)
   * - getLinkedEntry  - a linked entry is requested (Entry.getLinkedEntry)
   * - delEntry        - an entry is deleted (Entry.del)
   * - refresh         - an entry is refreshed (Entry.refresh)
   * - setContextName  - the name of a context is changed (Context.setName)
   * - getUserInfo     - the user information is requested (auth.getUserInfo)
   * - getUserEntry    - the user entry is requested (auth.getUserEntry)
   * - login           - logging in (auth.login)
   * - logout          - logging out (auth.logout)
   * - commitEntryInfo - pushing changes in entry information (EntryInfo.commit)
   * - getFile         - the contents of a file resource is requested (File.get*)
   * - putFile         - the contents of a file is pushed (File.put*)
   * - commitGraph     - a graph resource is pushed (Graph.commit)
   * - commitString    - a string resource is pushed (String.commit)
   * - setGroupName    - a new name of a group is pushed (Group.setName)
   * - setUserName     - a new name of a user is pushed (User.setName)
   * - setUserDisabled - a new disabled state of a user is pushed (User.setDisabled)
   * - setUserLanguage - a new preferred language of the user is pushed (User.setLanguage)
   * - setUserPassword - a new password for the user is pushed (User.setPassword)
   * - setUserHomeContext - a new home context for the user is pushed (User.setHomeContext)
   * - setUserCustomProperties - new custom properties for the user (User.setCustomProperties)
   * - loadListEntries - members of a list are requested (List.getEntries)
   * - setList         - the list members are changed via a list
   * - addToList       - See List.addEntry
   * - removeFromList  - See List.removeEntry
   * .removeEntry)
   * - search          - a search is being performed (SearchList.getEntries)
   * - execute         - a pipeline is executed (Pipeline.execute)
   *
   * @param {Promise.<string>} listener
   */
  addAsyncListener(listener) {
    if (this.asyncListeners) {
      this.asyncListeners.push(listener);
    } else {
      this.asyncListeners = [listener];
    }
  }

  /**
   * Removes a previously added listener for asynchronous calls.
   * @param {string} listener
   */
  removeAsyncListener(listener) {
    if (this.asyncListeners) {
      this.asyncListeners.splice(this.asyncListeners.indexOf(listener), 1);
    }
  }

  /**
   *
   * @param {Promise} promise
   * @param {string} context
   * @return {Promise}
   */
  handleAsync(promise, context) {
    if (this.asyncListeners) {
      for (let i = 0; i < this.asyncListeners.length; i++) {
        this.asyncListeners[i](promise, context);
      }
    }

    return promise;
  }

  /**
   * @returns {Auth} where functionality related to authorization are located,
   * including a listener infrastructure.
   */
  getAuth() {
    return this._auth;
  }

  /**
   * Yields information about who currently is authenticated against the EntryStore repository.
   * @returns {Promise.<EntryInfo>} - upon success an object containing attributes "user" being
   * the username, "id" of the user entry, and "homecontext" being the entry-id of the
   * home context is provided.
   * @see {@link EntryStore#auth auth}
   * @see {@link EntryStore#logout logout}
   * @deprecated use corresponding method on auth object instead.
   */
  getUserInfo() {
    return this._auth.getUserInfo();
  }

  /**
   * @returns {Promise.<Entry>} on success the entry for the currently signed in user is provided.
   * @deprecated use corresponding method on auth object instead.
   */
  getUserEntry() {
    return this._auth.getUserEntry();
  }

  /**
   * Authenticate using credentials containing a user, a password and an optional maxAge given
   * in seconds.
   *
   * @param {{user, password, maxAge}} credentials as a parameter object
   * @deprecated use corresponding method on auth object instead.
   */
  auth(credentials) {
    if (credentials == null) {
      return this._auth.logout();
    }
    return this._auth.login(credentials.user, credentials.password, credentials.maxAge);
  }

  /**
   * Logout the currently authorized user.
   * @returns {Promise}
   * @deprecated use corresponding method on auth object instead.
   */
  logout() {
    return this._auth.logout();
  }

  /**
   * Fetches an entry given an entryURI. If the entry is already loaded and available in the
   * cache it will be returned directly, otherwise it will be loaded from the repository.
   * If the entry is already loaded but marked as in need of a refresh it will be refreshed
   * first.
   *
   * The optional load parameters are provided in a single parameter object with six possible
   * attributes. Below we outline these attributes, the first two (forceLoad and direct) applies
   * to all kind of entries while the following three (limit, offset and sort) only applies if
   * the entry is a list:
   *
   * forceLoad - ignores if the entry is already in cache and fetches from the repository
   * loadResource - makes sure that entry.getResource(true) will not return null
   *     (does not work in combination with direct).
   * direct - returns the entry from the cache directly rather than returning a promise,
   *    if the entry is not in the cache an undefined value will be returned.
   * limit - only a limited number of children are loaded, -1 means no limit, 0, undefined
   *    or if the attribute is not provided means that the default limit of 20 is used.
   * offset - only children from offest and forward is returned, must be positive.
   * sort - information on how to sort the children:
   *     * if sort is not provided at all or an empty object is provided the members of the
   *       list will not be sorted, instead the list's natural order will be used
   *     * if sort is given as null the defaults will be used ({sortBy: "title", prio: "List"}).
   *     * if sort is given as a non emtpy object the following attributes are considered:
   *       ** sortBy - the attribute instructs which metadata field to sort the children by,
   *          i.e., title, created, modified, or size.
   *       ** lang - if sort is title and the title is provided in several languages a
   *          prioritized language can be given.
   *       ** prio - allows specific graphtypes to be prioritized
   *          (e.g. show up in the top of the list).
   *       ** descending - if true the children are shown in descending order.
   *
   *
   * **Note** - in the case where the entry is a list it is possible to change the limit,
   * offset and sort later by calling the corresponding methods on the {@link List}
   * resource, e.g. {@link List#setSort}. However, setting the values already in this
   * method call has as a consequence that one less request to the repository is made as you
   * will get members (in the right amount and order) in the same request as you get metadata
   * and other information.
   *
   * A request of a list entry can look like:
   *
   *     var euri = entrystore.getEntryURI("1", "1");
   *     entrystore.getEntry(euri, {
   *          forceLoad: true,
   *          limit: 10,
   *          offset: 20,
   *          sort: {
   *             sortBy: "modified",
   *             prio: types.GT_LIST
   *          }
   *      });
   *
   * The optional params here says that we force a load from the repository, that we want the
   * results to be paginated with a limit of 10 entries per page and that we want page 3.
   * We also indicate that we want the list to be sorted by latest modification date and that
   * if there are member entries that are lists they should be sorted to the top.
   *
   * @param {string} entryURI - the entryURI for the entry to retrieve.
   * @param {{forceLoad, direct, loadResource, limit, offset, sort, asyncContext}} optionalLoadParams - parameters for how to load an entry.
   * @return {Promise.<Entry> | Entry | undefined} - by default a promise is returned,
   * if the direct parameter is specified the entry is returned directly or undefined if the
   * entry is not in cache.
   * @see {@link EntryStore#getEntryURI getEntryURI} for help to construct entry URIs.
   * @see {@link Context#getEntryById} for loading entries relative to a context.
   */
  getEntry(entryURI, optionalLoadParams = {}) {
    const forceLoad = optionalLoadParams ? optionalLoadParams.forceLoad === true : false;
    const e = this._cache.get(entryURI);
    let asyncContext = 'getEntry';
    if (optionalLoadParams != null) {
      if (optionalLoadParams.asyncContext) {
        asyncContext = optionalLoadParams.asyncContext;
      }
      if (optionalLoadParams.direct === true) {
        return e;
      }
    }
    const checkResourceLoaded = (entry) => {
      if (optionalLoadParams != null && optionalLoadParams.loadResource
        && entry.getResource() == null) {
        return entry.getResource().then(() => entry);
      }
      return entry;
    };
    if (e && !forceLoad) {
      if ((e.isList() || e.isGroup()) && optionalLoadParams != null) {
        const list = e.getResource(true); // Direct access works for lists and groups.
        list.setLimit(optionalLoadParams.limit);
        list.setSort(optionalLoadParams.sort);
      }

      // Will only refresh if needed, a promise is returned in any case
      return this.handleAsync(e.refresh().then(checkResourceLoaded), asyncContext);
    }

    const entryPromise = this._cache.getPromise(entryURI);
    if (entryPromise) {
      return entryPromise;
    }
    const self = this;
    const entryLoadURI = factory.getEntryLoadURI(entryURI, optionalLoadParams);
    const loadEntryPromise = this.handleAsync(this._rest.get(entryLoadURI).then((data) => {
      // The entry, will always be there.
      const entry = factory.updateOrCreate(entryURI, data, self);
      return checkResourceLoaded(entry);
    }, (err) => {
      throw new Error(`Failed fetching entry. ${err}`);
    }), asyncContext).finally(() => {
      this._cache.removePromise(entryURI);
    });
    this._cache.addPromise(entryURI, loadEntryPromise);
    return loadEntryPromise;
  }

  /**
   * Retrieves entries from a list. One way to see it is that this is a convenience method
   * that retrieves a list entry, its member entries and returns those in an array.
   *
   * @param {string} entryURI - URI of the list entry to load entries from.
   * @param {Object} sort - same sort object as provided in the optionalLoadParams to
   * {@see EntryStore#getEntry getEntry} method.
   * @param {Object} limit - same limit as provided in the optionalLoadParams to
   * {@see EntryStore#getEntry getEntry} method.
   * @param {integer} page - unless limit is set to -1 (no pagination) we need to specify which
   * page to load, first page is 0.
   * @returns {Promise.<Entry[]>} upon success the promise returns an array of entries.
   */
  getListEntries(entryURI, sort, limit, page) {
    return new Promise((resolve, reject) => {
      const op = {};
      if (sort != null) {
        op.sort = sort;
      }
      if (limit % 1 === 0) {
        op.limit = limit;
      }
      if (page % 1 === 0) {
        if (limit % 1 === 0) {
          op.offset = limit * page;
        } else {
          op.offset = factory.getDefaultLimit() * page;
        }
      }
      this.getEntryStore().getEntry(entryURI, op)
        .then((entry) => {
          const list = entry.getResource(true);
          list.getEntries(page).then(resolve, reject);
        }, reject);
    });
  }

  /**
   * Retrieves a Context instance via its id. Note that this method returns directly without
   * checking with the EntryStore repository that the context exists. Hence successive
   * operations via this context instance may fail if the context does not exist in the
   * EntryStore
   * repository.
   *
   * Note that in EntryStore everything is connected to entries. Hence a context is nothing else
   * than a special kind of resource maintained by an entry. This entry provides metadata about
   * the context as well as the default ownership and access control that applies to all entries
   * inside of this context.
   *
   * To get a hold of the contexts own entry use the {@link Resource#getEntry}
   * method on the context (inherited from the generic {@link Resource} class.
   *
   * Advanced: Entrys corresponding to contexts are stored in the special _contexts
   * context which, since it is a context, contains its own entry.
   *
   * @param {string} contextId - identifier for the context (not necessarily the same as the
   * alias/name for the context)
   * @return {Context}
   */
  getContextById(contextId) {
    return factory.getContext(this, `${this._baseURI}_contexts/entry/${contextId}`);
  }

  /**
   * Retrieves a Context instance via its entry's URI.
   *
   * @param {String} contextEntryURI - URI to the context's entry, e.g. base/_contexts/entry/1.
   * @returns {Context}
   * @see {@link EntryStore#getContextById getContextById}
   */
  getContext(contextEntryURI) {
    return factory.getContext(this, contextEntryURI);
  }

  /**
   * Retrieves a paginated list of all contexts in the EntryStore repository.
   * @return {List} - the list contains entries which have contexts as resources.
   */
  getContextList() {
    return this.newSolrQuery().graphType(types.GT_CONTEXT).list();
  }

  /**
   * Retrieves a paginated list of all users and groups in the EntryStore repository
   * @return {List} the list contains entries that have principals as resources.
   * @todo May include folders and other entries as well...
   */
  getPrincipalList() {
    return this.newSolrQuery().graphType([types.GT_USER, types.GT_GROUP]).list();
  }

  /**
   * Creates a new entry according to information in the provided {@link PrototypeEntry}.
   * The information specifies the type of entry, which context it should reside in,
   * initial metadata etc. This method is seldom called explicitly, instead it is called
   * indirectly via the {@link PrototypeEntry#commit} method. E.g.:
   *
   *     context.newEntry().commit().then(function(newlyCreatedEntry) {...}
   *
   * @param {PrototypeEntry} prototypeEntry - information about the entry to create.
   * @return {Promise}
   * @see PrototypeEntry#commit
   * @see EntryStore#newContext
   * @see EntryStore#newUser
   * @see EntryStore#newGroup
   * @see Context#newEntry
   * @see Context#newLink
   * @see Context#newLinkRef
   * @see Context#newRef
   * @see Context#newList
   * @see Context#newGraph
   * @see Context#newString
   */
  async createEntry(prototypeEntry) {
    const postURI = factory.getEntryCreateURI(prototypeEntry, prototypeEntry.getParentList());
    const postParams = factory.getEntryCreatePostData(prototypeEntry);
    let entryURI;
    try {
      entryURI = await this.handleAsync(this._rest.create(postURI, postParams), 'createEntry');
    } catch (err) {
      return Promise.reject(err);
    }

    // var euri = factory.getURIFromCreated(data, prototypeEntry.getContext());
    const parentList = prototypeEntry.getParentList();
    if (parentList != null) {
      const res = parentList.getResource(true);
      if (res != null && res.needRefresh) {
        parentList.getResource(true).needRefresh();
      }
    }
    return this.getEntry(entryURI);
  }

  /**
   * Provides a PrototypeEntry for creating a new context.
   * @param {string=} contextName - optional name for the context, can be changed later,
   * must be unique in the _principals context
   * @param {string=} id - optional requested identifier (entryId) for the context,
   * cannot be changed later, must be unique in the _principals context
   * @returns {PrototypeEntry}
   */
  newContext(contextName, id) {
    const _contexts = factory.getContext(this, `${this._baseURI}_contexts/entry/_contexts`);
    const prototypeEntry = new PrototypeEntry(_contexts, id).setGraphType(types.GT_CONTEXT);
    if (contextName != null) {
      const ei = prototypeEntry.getEntryInfo();
      const resource = new Resource(ei.getEntryURI(), ei.getResourceURI(), this);
      resource._update({ name: contextName });
      prototypeEntry._resource = resource;
    }
    return prototypeEntry;
  }

  /**
   *
   * @param name
   * @return {Promise.<Entry>}
   * @async
   */
  async createGroupAndContext(name) {
    let uri = `${this._baseURI}_principals/groups`;
    if (name != null) {
      uri += `?name=${encodeURIComponent(name)}`;
    }
    const location = await this.handleAsync(this._rest.create(uri), 'createGroupAndContext');
    return this.getEntry(location);
  }

  /**
   * Provides a PrototypeEntry for creating a new user.
   * @param {string=} username - the name the user will use to authenticate himself
   * @param {string=} password - the password the user will use to authenticate himself
   * @param {string=} homeContext - a specific context the user will consider his own home
   * @param {string=} id - requested identifier for the user
   * @returns {PrototypeEntry}
   */
  newUser(username, password, homeContext, id) {
    const _principals = factory.getContext(this, `${this._baseURI}_contexts/entry/_principals`);
    const prototypeEntry = new PrototypeEntry(_principals, id).setGraphType(types.GT_USER);
    const entryInfo = prototypeEntry.getEntryInfo();
    const data = {};
    if (username != null) {
      data.name = username;
    }
    if (password != null) {
      data.password = password;
    }
    if (homeContext != null) {
      data.homecontext = homeContext;
    }
    prototypeEntry._resource = new User(entryInfo.getEntryURI(), entryInfo.getResourceURI(), this, data);
    return prototypeEntry;
  }

  /**
   * @param {string=} groupName - optional name for the group, can be changed later,
   * must be unique in the _principals context
   * @param {string=} id - optional requested identifier (entryId) for the group,
   * cannot be changed later, must be unique in the _principals context
   * @returns {PrototypeEntry}
   */
  newGroup(groupName, id) {
    const _principals = factory.getContext(this, `${this._baseURI}_contexts/entry/_principals`);
    const prototypeEntry = new PrototypeEntry(_principals, id).setGraphType(types.GT_GROUP);
    if (groupName != null) {
      const ei = prototypeEntry.getEntryInfo();
      const resource = new Resource(ei.getEntryURI(), ei.getResourceURI(), this);
      resource._update({ name: groupName });
      prototypeEntry._resource = resource;
    }
    return prototypeEntry;
  }

  /**
   * Move an entry from one list to another.
   *
   * @param {Entry} entry - entry to move
   * @param {Entry} fromList - source list where the entry is currently residing.
   * @param {Entry} toList - destination list where the entry is supposed to end up.
   * @returns {Promise}
   */
  moveEntry(entry, fromList, toList) {
    const uri = factory.getMoveURI(entry, fromList, toList, this._baseURI);
    return this.handleAsync(this.getREST().post(uri, ''), 'moveEntry');
  }

  /**
   * Loads data via the EntryStore repository's own proxy.
   *
   * @param {string} uri indicates the resource to load.
   * @param {string} formatHint indicates that you want data back in the format specified
   * (e.g. by specifiying a suitable accept header).
   * @returns {Promise}
   */
  loadViaProxy(uri, formatHint) {
    const url = factory.getProxyURI(this._baseURI, uri);
    return this.handleAsync(this.getREST().get(url, formatHint, true), 'loadViaProxy');
  }

  /**
   * Performing searches against an EntryStore repository is achieved by creating a
   * {@link SearchList} which is similar to a regular {@link List}.
   * From this list it is possible to get paginated results in form of matching entries.
   * For example:
   *
   *     var personType = "http://xmlns.com/foaf/0.1/Person";
   *     var searchList = entrystore.newSolrQuery().rdfType(personType).list();
   *     searchList.setLimit(20).getEntries().then(function(results) {...});
   *
   * @returns {SolrQuery}
   */
  newSolrQuery() {
    return new SolrQuery(this);
  }

  /**
   * @deprecated use {@link #newSolrQuery} instead.
   */
  createSearchList(query) {
    return factory.createSearchList(this, query);
  }

  /**
   * Constructs an metadata URI from the id for the context and the specific entry.
   * @param {string} contextId - an identifier for the context the entry belongs to
   * @param {string} entryId - an identifier for the entry
   * @returns {String} - an entry URI
   */
  getMetadataURI(contextId, entryId) {
    return factory.getMetadataURI(this, contextId, entryId);
  }

  /**
   * Constructs an entry URI from the id for the context and the specific entry.
   * @param {string} contextId - an identifier for the context the entry belongs to
   * @param {string} entryId - an identifier for the entry
   * @returns {String} - an entry URI
   */
  getEntryURI(contextId, entryId) {
    return factory.getEntryURI(this, contextId, entryId);
  }

  /**
   * Constructs an entry URI from a normal repository URI, e.g. any URI from which is possible
   * to deduce a contextId and an entryId. Equivalent to calling:
   * es.getEntryURI(es.getContextId(uri), es.getEntryId(uri))
   *
   * @param {string} uri - a URI for the entry, can be a entryURI (obviously), resourceURI
   * (if local), metadataURI, or relationsURI.
   * @returns {String} - an entry URI
   */
  getEntryURIFromURI(uri) {
    return factory.getEntryURIFromURI(this, uri);
  }

  /**
   * Constructs an entry resource URI (local URI, not a link obviously) from the id for the
   * context and the specific entry.
   *
   * @param {string} contextId - an identifier for the context the resource belongs to
   * @param {string} entryId - an identifier for the entry the resource belongs to
   * @returns {String} a resource URI
   */
  getResourceURI(contextId, entryId) {
    return factory.getResourceURI(this, contextId, entryId);
  }

  /**
   * The base URI of the EntryStore repository we have connected to.
   *
   * @returns {String}
   */
  getBaseURI() {
    return this._baseURI;
  }

  /**
   * The entry id of this entry, resource or metadata uri.
   *
   * @param {string} uri
   * @returns {string}
   */
  getEntryId(uri) {
    return factory.getEntryId(uri, this.getBaseURI());
  }

  /**
   * The context id of this entry, resource or metadata uri.
   *
   * @param {string} uri
   * @returns {string}
   */
  getContextId(uri) {
    return factory.getContextId(uri, this.getBaseURI());
  }

  /**
   *  To get status resource
   *
   * @returns {Promise}
   */
  getStatus() {
    const uri = `${this._baseURI}management/status?extended`;
    return this.handleAsync(this.getREST().get(uri));
  }

  /**
   * The cache where all entries are cached after loading.
   *
   * @returns {Cache}
   */
  getCache() {
    return this._cache;
  }

  /**
   * The loading mechanism are performed via REST calls, this REST module can be
   * used for doing manual lookups outside of the scope of this API.
   *
   * @returns {Rest}
   */
  getREST() {
    return this._rest;
  }

  /**
   * Requests to EntryStore instances may be cached to increase performance, by setting the requestCachePrevention to
   * true that cache is circumvented by appending unique parameters to the URI.
   * By default the requestCachePrevention is disabled.
   *
   * @param {boolean} prevent pass true to enable the prevention.
   */
  setRequestCachePrevention(prevent) {
    this._requestCache = prevent;
  }

  /**
   * Weather the cache of requests made to EntryStore instances are circumvented by appending a random parameter.
   * @return {boolean}
   */
  getRequestCachePrevention() {
    return this._requestCache;
  }
  //= =============Non-public methods==============

  /**
   * @returns {Object}
   */
  getCachedContextsIdx() {
    return this._contexts;
  }

  /**
   * Provides information about version of EntryStore repository, the javascript API,
   * status of services etc.
   * @todo Needs support from EntryStore REST API
   * @todo Document promise
   */
  static info() {
    const packageJSON = require('../package.json');
    return { version: packageJSON.version };
  }
};