shared/jimpleFns.js

/* eslint-disable jsdoc/require-jsdoc */
/**
 * @module shared/jimpleFns
 */

/**
 * @typedef {import('jimple').default} Jimple
 * @external Jimple
 * @see https://yarnpkg.com/en/package/jimple
 */

/**
 * @external PropertyDescriptor
 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
 */

/**
 * @callback ProviderRegisterFn
 * @param {Jimple} app  A reference to the dependency injection container.
 * @returns {Void}
 * @parent module:shared/jimpleFns
 */

/**
 * @typedef {Object} Provider
 * @property {ProviderRegisterFn} register  The method that gets called when registering
 *                                          the provider.
 * @example
 *
 *   container.register(myProvider);
 *
 * @parent module:shared/jimpleFns
 */

/**
 * A function called in order to generate a {@link Provider}. They usually have different
 * options that will be sent to the provider creation.
 *
 * @callback ProviderCreatorFn
 * @returns {Provider}
 * @parent module:shared/jimpleFns
 */

/**
 * A special kind of {@link Provider} that can be used as a regular provider, or it can
 * also be called as a function with custom options in order to obtain a "configured
 * {@link Provider}".
 *
 * @callback ProviderCreator
 * @param {Partial<O>} [options={}]  The options to create the provider.
 * @returns {Provider}
 * @template O
 * @example
 *
 *   // Register it with its default options.
 *   container.register(myProvider);
 *   // Register it with custom options.
 *   container.register(myProvider({ enabled: true }));
 *
 * @parent module:shared/jimpleFns
 */

/**
 * @typedef {Object.<string, Provider>} ProvidersDictionary
 */

/**
 * @typedef {ProvidersDictionary & ProvidersProperties} Providers
 */

/**
 * @typedef {Object} ProvidersProperties
 * @property {ProviderRegisterFn} register  The function that will register all the
 *                                          providers on the container.
 */

/**
 * Generates a collection of {@link Provider} objects that can be used to register all of
 * them at once.
 *
 * @callback ProvidersCreator
 * @param {Object.<string, Provider>} providers  The dictionary of providers to add to the
 *                                               collection.
 * @returns {Providers}
 * @example
 *
 *   // Generate the collection
 *   const myProviders = providers({ oneProvider, otherProvider });
 *   // Register all of them
 *   container.register(myProviders);
 *   // Register only one
 *   container.register(myProviders.otherProvider);
 *
 */

/**
 * @typedef {Object} Resource
 * @property {Function} key   The function (`fn`) sent to the
 *                            {@link module:shared/jimpleFns~resource|resource} function.
 * @property {boolean}  name  This will always be `true` and it's the `name` sent to the
 *                            {@link module:shared/jimpleFns~resource|resource} function.
 * @parent module:shared/jimpleFns
 */

/**
 * @typedef {Function} ResourceCreator
 * @property {Function} key   The result of the `creatorFn` sent to the
 *                            {@link module:shared/jimpleFns~resourceCreator|resourceCreator}
 *                            function.
 * @property {boolean}  name  This will always be `true` and it's the `name` sent to the
 *                            {@link module:shared/jimpleFns~resourceCreator|resourceCreator}
 *                            function.
 * @parent module:shared/jimpleFns
 */

/**
 * @callback ResourcesCollectionCreator
 * @param {Object.<string, R>} items  The dictionary of items to add to the collection.
 * @returns {Object.<string, R>} In additions to the original dictionary of items, the
 *                               returned object will have the collection flag (`name`),
 *                               and the `key`
 *                               function (`fn`) to interact with the collection.
 * @template R
 * @throws {Error} If one of the items key is the collection `name` or `key`.
 * @throws {Error} If one of the items doesn't have a `key` function.
 * @parent module:shared/jimpleFns
 */

/**
 * @callback ResourcesCollectionFn
 * @param {Object.<string, R>} items  The dictionary of items to add to the collection.
 * @param {...*}               args   The arguments that were sent to the collection `key`
 *                                    function.
 * @template R
 * @parent module:shared/jimpleFns
 */

/**
 * Generates a resource entity with a specific function Jimple or an abstraction of jimple
 * can make use of.
 *
 * @param {string}   name  The name of the resource. The generated object will have a
 *                         property with its name and the value `true`.
 * @param {string}   key   The name of the key that will have the function on the
 *                         generated object.
 * @param {Function} fn    The function to interact with the resource.
 * @returns {Resource}
 * @example
 *
 * <caption>
 *   The `provider` shorthand function is an _entity_ with a `register` function:
 * </caption>
 *
 *   const someProvider = resource('provider', 'register', (app) => {
 *     // ...
 *   });
 *
 */
const resource = (name, key, fn) => ({
  [key]: fn,
  [name]: true,
});
/**
 * This is a helper to dynamically configure a resource before creating it. The idea here
 * is that the returned object can be used as a function, to configure the resource, or as
 * a resource.
 * The difference with {@link module:shared/jimpleFns~resource|resource} is that, instead
 * of providing the function to interact with the generated resource, the `creatorFn`
 * parameter is a function that returns a function like the one you would use on
 * {@link module:shared/jimpleFns~resource|resource}. What this function actually returns
 * is a {@link Proxy}, that when used as a function, it will return a resource; but when
 * used as a resource, it will internally call `creatorFn` (and cache it) without
 * parameters. It's very important that all the parameters of the `creatorFn` are
 * optional, otherwise it will cause an error if called as a resource.
 *
 * @param {string}   name       The name of the resource. The generated object will have a
 *                              property with its name and the value `true`.
 * @param {string}   key        The name of the key that will have the function on the
 *                              generated object.
 * @param {Function} creatorFn  The function that will generate the 'resource function'.
 * @returns {ResourceCreator}
 * @example
 *
 * <caption>Let's use `provider` again, that requires a `register` function:</caption>
 *
 *   const someProvider = resourceCreator(
 *     'provider',
 *     'register',
 *     (options = {}) => (app) => {
 *       // ...
 *     },
 *   );
 *
 *   // Register it as a resource
 *   container.register(someProvider);
 *
 *   // Register it after creating a configured resource
 *   container.register(someProvider({ enabled: false }));
 *
 */
const resourceCreator = (name, key, creatorFn) =>
  new Proxy((...args) => resource(name, key, creatorFn(...args)), {
    name,
    resource: null,
    get(target, property) {
      let result;
      if (property === this.name) {
        result = true;
      } else if (property === key) {
        if (this.resource === null) {
          this.resource = creatorFn();
        }
        result = this.resource;
      } else {
        result = target[property];
      }

      return result;
    },
  });
/**
 * Generates a collection of resources that can be called individually or all together via
 * the `key` function.
 *
 * @param {string}                 name       The name of the collection. When a
 *                                            collection is generated, the returned object
 *                                            will have a property with its name and the
 *                                            value `true`.
 * @param {string}                 key        The name of the key that will have the
 *                                            function to interact with the collection on
 *                                            the final object.
 * @param {?ResourcesCollectionFn} [fn=null]  By default, if the `key` function of the
 *                                            collection gets called, all the items of the
 *                                            collection will be called with the same
 *                                            arguments. But you can specify a function
 *                                            that will receive all the items and all the
 *                                            arguments to customize the interaction.
 * @returns {ResourcesCollectionCreator<Resource>}
 * @example
 *
 * <caption>
 *   Following all the other examples(and the implementations), let's create a a providers
 *   collection.
 * </caption>
 *
 *   const firstProvider = resource('provider', 'register', (app) => {
 *     // ...
 *   });
 *   const secondProvider = resourceCreator(
 *     'provider',
 *     'register',
 *     (options = {}) => (app) => {
 *       // ...
 *     },
 *   );
 *
 *   const bothProviders = resourcesCollection(
 *     'providers',
 *     'register',
 *   )({
 *     firstProvider,
 *     secondProvider,
 *   });
 *
 *   // Register all at once
 *   container.register(bothProviders);
 *   // Register only one
 *   container.register(bothProviders.firstProvider);
 *   // Register only one, after configuring it
 *   container.register(bothProviders.secondProvider({ enabled: false }));
 *
 */
const resourcesCollection = (name, key, fn = null) => (items) => {
  const invalidKeys = [name, key];
  const itemsKeys = Object.keys(items);
  const invalidKey = itemsKeys.some((itemKey) => invalidKeys.includes(itemKey));
  if (invalidKey) {
    throw new Error(
      `No item on the collection can have the keys \`${name}\` nor \`${key}\``,
    );
  }

  const invalidItem = itemsKeys.find(
    (itemKey) => typeof items[itemKey][key] !== 'function',
  );
  if (invalidItem) {
    throw new Error(
      `The item \`${invalidItem}\` is invalid: it doesn't have a \`${key}\` function`,
    );
  }

  const useFn = fn
    ? (...args) => fn(items, ...args)
    : (...args) =>
        itemsKeys.forEach((item) => {
          items[item][key](...args);
        });

  return {
    ...resource(name, key, useFn),
    ...items,
  };
};
/**
 * Creates a resource provider.
 *
 * @param {ProviderRegisterFn} registerFn  The function the container will call in order
 *                                         to register the provider.
 * @returns {Provider}
 * @example
 *
 *   // Define the provider
 *   const myService = provider((app) => {
 *     app.set('myService', () => new MyService());
 *   });
 *
 *   // Register it on the container
 *   container.register(myService);
 *
 */
const provider = (registerFn) => resource('provider', 'register', registerFn);
/**
 * Creates a configurable provider. It's configurable because the creator, instead of just
 * being sent to the container to register, it can also be called as a function with
 * custom options and generate a new provider.
 *
 * @param {ProviderCreatorFn} creatorFn  The function that generates the provider.
 * @returns {ProviderCreator<any>}
 * @example
 *
 *   // Define the provider creator
 *   const myProvider = providerCreator((options = {}) => (app) => {
 *     app.set('myService', () => new MyService(options));
 *   });
 *   // Register it with the default options
 *   container.register(myProvider);
 *   // Register it with custom options
 *   container.register(myProvider({ enabled: true }));
 *
 */
const providerCreator = (creatorFn) => resourceCreator('provider', 'register', creatorFn);
/**
 * Creates a collection of providers.
 *
 * @type {ProvidersCreator}
 */
const providers = resourcesCollection('providers', 'register');
/**
 * Helper function for {@link proxyContainer} that gets all the keys of an object, going
 * recursively over its prototype chain.
 *
 * @param {Object} target  The target object.
 * @returns {string[]}
 * @ignore
 */
const getKeys = (target) => {
  let obj = target;
  const keys = [];
  while (obj) {
    keys.push(...Object.getOwnPropertyNames(obj));
    obj = Object.getPrototypeOf(obj);
  }

  return keys.reduce((acc, key) => (acc.includes(key) ? acc : [...acc, key]), []).sort();
};
/**
 * Takes a Jimple container and creates a proxy for it so resouces can be accessed and
 * registered like if they were its properties.
 *
 * @param {Jimple} container  The Jimpex container the proxy will be created for.
 * @returns {Jimple} The proxied version of the container.
 * @example
 *
 *   const container = proxyContainer(new Jimple());
 *   container.service = () => new MyService();
 *   container.service.doSomething();
 *
 */
const proxyContainer = (container) => {
  const keys = getKeys(container);
  const fns = keys
    .filter((key) => typeof container[key] === 'function')
    .reduce(
      (acc, key) => ({
        ...acc,
        [key]: container[key].bind(container),
      }),
      {},
    );

  let proxy;
  /**
   * Registers a resource provider on the container.
   * This version exists so the providers will receive the proxied version.
   *
   * @param {Provider} rProvider  The provider that will register one of more resources on
   *                              the container.
   * @ignore
   */
  const registerWithProxy = (rProvider) => {
    rProvider.register(proxy);
  };

  proxy = new Proxy(container, {
    /**
     * This is the trap called when a property of the proxied object is being accessed. In
     * this case, the trap will check if it's one of the bunded functions, the register
     * function, one of the real properties, or one of the resources.
     *
     * @param {Jimple}          target  The target object.
     * @param {string | symbol} key     The name of the requested property.
     * @returns {*}
     * @ignore
     */
    get: (target, key) => {
      let result;
      if (key === 'proxy') {
        // Add the proxy flag to indicate that properties can be accesed as properties.
        result = true;
      } else if (key === 'register') {
        // If it's the `register` function, set to return the one that uses the proxy...
        result = registerWithProxy;
      } else if (fns[key]) {
        // If it's one of the bounded functions...
        result = fns[key];
      } else if (keys.includes(key)) {
        // If it's one of thebase properties...
        result = container[key];
      } else if (key.startsWith('$')) {
        /**
         * It it starts with `$`, it can be an actual resource, or a 'try-get': try to access a
         * resource, and if it's not registered, return `null`.
         */
        if (container.has(key)) {
          result = container.get(key);
        } else {
          const useKey = key.substr(1);
          result = container.has(useKey) ? container.get(useKey) : null;
        }
      } else {
        // Finally, assume it's a resource.
        result = container.get(key);
      }

      return result;
    },
    /**
     * This is the trap called when the value of a property of the proxied object is being
     * set. In this case, the trap will check that the name is not one of the base keys of
     * the class, to avoid functionality errors, and then call the container `set` method.
     *
     * @param {Jimple}          target  The target object.
     * @param {string | symbol} key     The name of the property.
     * @param {*}               value   The new value of the property.
     * @throws {Error} If `key` is the name of one of the base properties of the class.
     * @ignore
     */
    set: (target, key, value) => {
      if (keys.includes(key)) {
        throw new Error(`The key '${key}' is reserved and cannot be used`);
      }

      container.set(key, value);
    },
    /**
     * This is the trap called when the properties' keys of the proxied object need to be
     * listed.
     * In this case, the trap will list the base properties, plus the resources' keys.
     *
     * @returns {string[]}
     * @ignore
     */
    ownKeys: () => [...keys, ...container.keys()],
    /**
     * This is the trap called when there's a check to see if the proxied object has a
     * specific property (`prop in obj`). In this case, the trap will first check in the
     * container and if it's not present, it will use the container `has` method to check
     * the resources.
     *
     * @param {Jimple}          target  The target object.
     * @param {string | symbol} key     The name of the property.
     * @returns {boolean}
     * @ignore
     */
    has: (target, key) => key in container || container.has(key),
    /**
     * This is the trap called when the description of one of the properties of the
     * proxied object is rquested. In this case, the trap will not only describe the base
     * properties, but also the registered resources.
     *
     * @param {Jimple}          target  The target object.
     * @param {string | symbol} key     The name of the property.
     * @returns {?PropertyDescriptor} It will only return the description if the container
     *                                `has`
     *                                the property.
     * @ignore
     */
    getOwnPropertyDescriptor: (target, key) => {
      let result;
      if (keys.includes(key) || container.has(key)) {
        const isPrivate = keys.includes(key);
        result = {
          configurable: true,
          enumerable: !isPrivate,
          value: isPrivate ? target[key] : target.get(key),
          writable: !isPrivate,
        };
      }

      return result;
    },
  });

  return proxy;
};

module.exports.resource = resource;
module.exports.resourceCreator = resourceCreator;
module.exports.resourcesCollection = resourcesCollection;
module.exports.provider = provider;
module.exports.providerCreator = providerCreator;
module.exports.providers = providers;
module.exports.proxyContainer = proxyContainer;