node/appConfiguration.js

const path = require('path');
const ObjectUtils = require('../shared/objectUtils');
const { deepAssign } = require('../shared/deepAssign');
const { providerCreator } = require('../shared/jimpleFns');
/**
 * @module node/appConfiguration
 */

/**
 * @typedef {import('./environmentUtils').EnvironmentUtils} EnvironmentUtils
 * @typedef {import('./rootRequire').RootRequireFn} RootRequireFn
 */

/**
 * @typedef {import('../shared/jimpleFns').ProviderCreator<O>} ProviderCreator
 * @template O
 */

/**
 * @typedef {Object} AppConfigurationOptions
 * @property {string} [defaultConfigurationName='default']
 * The name of the default configuration.
 * @property {string} [environmentVariable='APP_CONFIG']
 * The name of the variable it will read in order to determine which configuration to
 * load.
 * @property {string} [path='./config/[app-name]']
 * The path to the configurations directory, relative to the project root path.
 * @property {string} [filenameFormat='[app-name].[name].config.js']
 * The name format of the configuration files. You need to use the `[name]`
 * placeholder so the service can replace it with the name of the configuration.
 * @parent module:node/appConfiguration
 */

/**
 * @typedef {Object} AppConfigurationServiceMap
 * @property {string | EnvironmentUtils} [environmentUtils]
 * The name of the service for {@link EnvironmentUtils} or an instance of it.
 * `environmentUtils` by default.
 * @property {string | RootRequireFn} [rootRequire]
 * The name of the service for {@link RootRequireFn} or an instance of it. `rootRequire`
 * by default.
 * @parent module:node/appConfiguration
 */

/**
 * @typedef {Object} AppConfigurationProviderOptions
 * @property {string} serviceName
 * The name that will be used to register an instance of {@link AppConfiguration}. Its
 * default value is `appConfiguration`.
 * @property {string} appName
 * The name of the application.
 * @property {Object} defaultConfiguration
 * The service default configuration.
 * @property {Partial<AppConfigurationOptions>} options
 * Overwrites for the service customization options.
 * @property {AppConfigurationServiceMap} services
 * A dictionary with the services that need to be injected on the class.
 * @parent module:node/appConfiguration
 */

/**
 * This is a service to manage applications configurations. It takes care of loading,
 * activating,
 * switching and merging configuration files.
 *
 * @parent module:node/appConfiguration
 * @tutorial appConfiguration
 */
class AppConfiguration {
  /**
   * @param {EnvironmentUtils} environmentUtils
   * Required to read the environment variables and determine which configuration to use.
   * @param {RootRequireFn} rootRequire
   * Necessary to be able to require the configuration files with paths relative to the
   * app root directory.
   * @param {string} [appName='app']
   * The name of the app using this service. It's also used as part of the name of the
   * configuration files.
   * @param {Object} [defaultConfiguration={}]
   * The default configuration the others will extend.
   * @param {Partial<AppConfigurationOptions>} [options={}]
   * Options to customize the service.
   */
  constructor(
    environmentUtils,
    rootRequire,
    appName = 'app',
    defaultConfiguration = {},
    options = {},
  ) {
    /**
     * A local reference for the `environmentUtils` service.
     *
     * @type {EnvironmentUtils}
     * @access protected
     * @ignore
     */
    this._environmentUtils = environmentUtils;
    /**
     * The function that allows the service to `require` a configuration file with a path
     * relative to the app root directory.
     *
     * @type {RootRequireFn}
     * @access protected
     * @ignore
     */
    this._rootRequire = rootRequire;
    /**
     * The service customizable options.
     *
     * @type {AppConfigurationOptions}
     * @access protected
     * @ignore
     */
    this._options = ObjectUtils.merge(
      {
        defaultConfigurationName: 'default',
        environmentVariable: 'APP_CONFIG',
        path: `./config/${appName}`,
        filenameFormat: `${appName}.[name].config.js`,
      },
      options,
    );
    /**
     * A dictionary with all the loaded configurations. It uses the names of the
     * configurations as keys.
     *
     * @type {Object.<string, Object>}
     * @access protected
     * @ignore
     */
    this._configurations = {
      [this._options.defaultConfigurationName]: defaultConfiguration,
    };
    /**
     * The name of the active configuration.
     *
     * @type {string}
     * @access protected
     * @ignore
     */
    this._activeConfiguration = this._options.defaultConfigurationName;
    /**
     * Whether or not the configuration can be switched.
     *
     * @type {boolean}
     * @access protected
     * @ignore
     */
    this._allowConfigurationSwitch = !!this.get('allowConfigurationSwitch');
  }
  /**
   * Gets a setting or settings from the active configuration.
   *
   * @param {string | string[]} setting          A setting path or a list of them.
   * @param {boolean}           [asArray=false]  When `setting` is an Array, if this is
   *                                             `true`,
   *                                             instead of returning an object, it will
   *                                             return an array of settings.
   * @returns {*}
   * @example
   *
   *   // To get a single setting
   *   const value = appConfiguration.get('some-setting');
   *
   *   // To get multiple values
   *   const { settingOne, settingTwo } = appConfiguration.get([
   *     'settingOne',
   *     'settingTwo',
   *   ]);
   *
   *   // Use paths
   *   const subValue = appConfiguration.get('settingOne.subSetting');
   *
   */
  get(setting, asArray = false) {
    let result;
    if (Array.isArray(setting)) {
      result = asArray
        ? setting.map((name) => this.get(name))
        : setting.reduce((current, name) => ({ ...current, [name]: this.get(name) }), {});
    } else if (setting === 'name') {
      result = this._activeConfiguration;
    } else {
      result = ObjectUtils.get(this.getConfig(), setting);
    }

    return result;
  }
  /**
   * Gets a configuration settings. If no name is specified, it will return the settings
   * of the active configuration.
   *
   * @param {string} [name='']  The name of the configuration.
   * @returns {?Object}
   */
  getConfig(name = '') {
    const existing = this._configurations[name || this._activeConfiguration];
    return existing ? ObjectUtils.copy(existing) : null;
  }
  /**
   * Load a new configuration.
   *
   * @param {string}  name             The configuration name.
   * @param {Object}  settings         The configuration settings.
   * @param {boolean} [switchTo=true]  If the service should switch to the new
   *                                   configuration after adding it.
   * @returns {Object} The settings of the new configuration.
   * @throws {Error} If the configuration tries to extend a configuration that doesn't
   *                 exist.
   */
  load(name, settings, switchTo = true) {
    // Get the name of the configuration it will extend.
    const extendsFrom = settings.extends || this._options.defaultConfigurationName;
    // Get the settings of the configuration to extend.
    const baseConfiguration = this.getConfig(extendsFrom);
    // If the base configuration exists...
    if (baseConfiguration) {
      // ...add the new configuration with the merged settings.
      this._addConfiguration(
        name,
        ObjectUtils.merge(baseConfiguration, settings),
        true,
        switchTo,
      );
    } else {
      // ...otherwise, fail with an error.
      throw new Error(`The base configuration for ${name} doesn't exist: ${extendsFrom}`);
    }
    // Return the loaded configuration.
    return this.getConfig(name);
  }
  /**
   * Checks if there's a configuration name on the environment variable and if there is,
   * try to load the configuration file for it.
   *
   * @returns {Object} The loaded configuration or an empty object if the variable was
   *                   empty.
   */
  loadFromEnvironment() {
    const name = this._environmentUtils.get(this._options.environmentVariable);
    let result = {};
    if (name) {
      result = this.loadFromFile(name);
    }

    return result;
  }
  /**
   * Loads a configuration from a file.
   *
   * @param {string}  name                    The name of the configuration.
   * @param {boolean} [switchTo=true]         If the service should switch to the new
   *                                          configuration after adding it.
   * @param {boolean} [checkSwitchFlag=true]  If `true`, the service will update the value
   *                                          of `allowConfigurationSwitch` based on the
   *                                          loaded configuration setting.
   * @returns {Object} The settings of the loaded configuration.
   * @throws {Error} If the configuration file can't be loaded.
   */
  loadFromFile(name, switchTo = true, checkSwitchFlag = true) {
    // Format the name of the configuration file.
    const filename = this._options.filenameFormat.replace(/\[name\]/g, name);
    // Build the path to the configuration file.
    const filepath = path.join(this._options.path, filename);

    let settings = {};
    // Try to require it.
    try {
      settings = this._rootRequire(filepath);
    } catch (error) {
      throw new Error(`The configuration file couldn't be loaded: ${filepath}`);
    }

    // Get the name of the configuration it will extend.
    const extendsFrom = settings.extends || this._options.defaultConfigurationName;
    // Get the base configuration from either the service or by loading it.
    const baseConfiguration =
      this.getConfig(extendsFrom) || this.loadFromFile(extendsFrom, false);
    // Add the new configuration with the merged settings.
    this._addConfiguration(
      name,
      ObjectUtils.merge(baseConfiguration, settings),
      checkSwitchFlag,
      switchTo,
    );
    // Return the loaded configuration.
    return this.getConfig(name);
  }
  /**
   * Sets the value of a setting or settings from the active configuration.
   * If both the current and the new value of a setting are objects, then instead of
   * overwriting it, the method will merge them.
   *
   * @param {string | Object.<string, any>} setting  The name of the setting to update or
   *                                                 a dictionary of settings and their
   *                                                 values.
   * @param {*}                             [value]  The value of the setting. This is
   *                                                 only used when `setting` is a string.
   * @throws {Error} If `setting` is not a dictionary and `value` is undefined.
   * @example
   *
   *   // To set a single setting value
   *   appConfiguration.set('some-setting', 'some-setting-value');
   *   // To set the value of multiple settings
   *   appConfiguration.set({
   *     settingOne: 'valueOne',
   *     settingTwo: 'valueTwo',
   *   });
   *
   */
  set(setting, value) {
    if (typeof setting === 'object') {
      Object.keys(setting).forEach((name) => {
        this.set(name, setting[name]);
      });
    } else if (typeof value !== 'undefined') {
      const currentValue = this.get(setting);
      let newValue = value;
      if (typeof value === 'object' && typeof currentValue !== 'undefined') {
        newValue = ObjectUtils.merge(currentValue, value);
      }

      this.setConfig(ObjectUtils.set({}, setting, newValue));
    } else {
      throw new Error('You need to send a value in order to update a setting');
    }
  }
  /**
   * Overwrites all the settings for a configuration. If the name is not specified, it
   * will overwrite the active configuration.
   *
   * @param {Object}  config        The new configuration settings.
   * @param {string}  [name='']     The name of the configuration.
   * @param {boolean} [merge=true]  Whether or not to merge the new settings with the
   *                                existing ones.
   * @returns {Object} The updated configuration.
   */
  setConfig(config, name = '', merge = true) {
    const key = name || this._activeConfiguration;
    this._configurations[key] = merge
      ? ObjectUtils.merge(this._configurations[key], config)
      : config;
    return this._configurations[key];
  }
  /**
   * Switchs to a different configuration. If the configuration is not registered, it will
   * try to load from a file.
   *
   * @param {string}  name           The new of the configuration to switch to.
   * @param {boolean} [force=false]  A way to force the service to switch even if the
   *                                 `allowConfigurationSwitch` property if `false`.
   * @returns {Object} The new active configuration.
   * @throws {Error} If `force` is `false` and the `allowConfigurationSwitch` property
   *                 is `false`.
   */
  switch(name, force = false) {
    if (!this._allowConfigurationSwitch && !force) {
      throw new Error(
        `You can't switch the configuration to '${name}', the feature is disabled`,
      );
    } else if (!this._configurations[name]) {
      this.loadFromFile(name, true, false);
    } else {
      this._activeConfiguration = name;
    }

    return this.getConfig();
  }
  /**
   * The name of the active configuration.
   *
   * @type {string}
   */
  get activeConfiguration() {
    return this._activeConfiguration;
  }
  /**
   * Whether or not the active configuration can be switched.
   *
   * @type {boolean}
   */
  get canSwitch() {
    return this._allowConfigurationSwitch;
  }
  /**
   * A dictionary with all the loaded configurations. It uses the names of the
   * configurations as keys.
   *
   * @type {Object.<string, Object>}
   */
  get configurations() {
    return ObjectUtils.copy(this._configurations);
  }
  /**
   * The service customizable options.
   *
   * @type {AppConfigurationOptions}
   */
  get options() {
    return { ...this._options };
  }
  /**
   * Add a new configuration to the service.
   *
   * @param {string}  name             The name of the new configuration.
   * @param {Object}  settings         The configuration settings.
   * @param {boolean} checkSwitchFlag  Whether or not the `allowConfigurationSwitch`
   *                                   should be updated with the value of this new
   *                                   configuration setting.
   * @param {boolean} switchTo         Whether or not to switch it to the active
   *                                   configuration after adding it.
   * @access protected
   * @ignore
   */
  _addConfiguration(name, settings, checkSwitchFlag, switchTo) {
    const newSettings = ObjectUtils.copy(settings);
    delete newSettings.extends;

    if (checkSwitchFlag && typeof newSettings.allowConfigurationSwitch === 'boolean') {
      this._allowConfigurationSwitch = newSettings.allowConfigurationSwitch;
    }

    this._configurations[name] = newSettings;
    if (switchTo) {
      this.switch(name, true);
    }
  }
}
/**
 * The service provider to register an instance of {@link AppConfiguration} on the
 * container.
 *
 * @type {ProviderCreator<AppConfigurationProviderOptions>}
 * @tutorial appConfiguration
 */
const appConfiguration = providerCreator((options = {}) => (app) => {
  app.set(options.serviceName || 'appConfiguration', () => {
    /**
     * @type {AppConfigurationProviderOptions}
     * @ignore
     */
    const useOptions = deepAssign(
      {
        services: {
          environmentUtils: 'environmentUtils',
          rootRequire: 'rootRequire',
        },
      },
      options,
    );

    const services = Object.keys(useOptions.services).reduce((acc, key) => {
      const value = useOptions.services[key];
      const service = typeof value === 'string' ? app.get(value) : value;
      return {
        ...acc,
        [key]: service,
      };
    }, {});

    return new AppConfiguration(
      services.environmentUtils,
      services.rootRequire,
      useOptions.appName,
      useOptions.defaultConfiguration,
      useOptions.options,
    );
  });
});

module.exports.AppConfiguration = AppConfiguration;
module.exports.appConfiguration = appConfiguration;