server/services/configuration_service/service.js

'use strict';

/* eslint-disable security/detect-object-injection */
/* eslint-disable security/detect-non-literal-require */

/**
 * Module dependencies, required for ALL Twyr' modules
 * @ignore
 */

/**
 * Module dependencies, required for this module
 * @ignore
 */
const TwyrBaseService = require('twyr-base-service').TwyrBaseService;
const TwyrSrvcError = require('twyr-service-error').TwyrServiceError;

const ConfigurationServiceLoader = require('./loader').loader;

/**
 * @class   ConfigurationService
 * @extends {TwyrBaseService}
 * @classdesc The Twyr Web Application Server Configuration Service.
 *
 * @description
 * Serves as the "single source of truth" for configuration related operations across the rest of the codebase.
 *
 */
class ConfigurationService extends TwyrBaseService {
	// #region Constructor
	constructor(parent, loader) {
		super(parent, null);

		loader = new ConfigurationServiceLoader(this);
		this.$loader = loader;
	}
	// #endregion

	// #region Lifecycle hooks
	/**
	 * @async
	 * @function
	 * @override
	 * @instance
	 * @memberof ConfigurationService
	 * @name     load
	 *
	 * @param    {ConfigurationService} configSrvc - Instance of the {@link ConfigurationService} that supplies configuration.
	 *
	 * @returns  {Object} - The aggregated status returned by sub-modules (if any) once they complete their loading.
	 *
	 * @summary  Loads sub-modules, if any.
	 */
	async load(configSrvc) {
		try {
			const path = require('path');
			this.$config = require(path.join(path.dirname(path.dirname(require.main.filename)), `config/${twyrEnv}/server/services/configuration_service`)).config;

			this.on('new-config', this._processConfigChange.bind(this));
			this.on('update-config', this._processConfigChange.bind(this));
			this.on('delete-config', this._processConfigChange.bind(this));

			this.on('update-state', this._processStateChange.bind(this));

			const status = await super.load(configSrvc);

			if(!this.$prioritizedSubServices) {
				this.$prioritizedSubServices = [].concat(Object.keys(this.$services));

				this.$prioritizedSubServices.forEach((prioritizedService) => {
					this.$config.priorities[prioritizedService] = this.$config.priorities[prioritizedService] || 0;
				});

				this.$prioritizedSubServices.sort((left, right) => {
					return this.$config.priorities[left] - this.$config.priorities[right];
				});
			}

			return status;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::load error`, err);
		}
	}
	// #endregion

	// #region API
	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof ConfigurationService
	 * @name     loadConfiguration
	 *
	 * @param    {TwyrBaseModule} twyrModule - Instance of the {@link TwyrBaseModule} that requires configuration.
	 *
	 * @returns  {Object} - The merged configurations returned by sub-modules.
	 *
	 * @summary  Retrieves the configuration of the twyrModule requesting for it.
	 */
	async loadConfiguration(twyrModule) {
		const deepmerge = require('deepmerge');
		const emptyTarget = value => Array.isArray(value) ? [] : {};
		const clone = (value, options) => deepmerge(emptyTarget(value), value, options);

		const oldArrayMerge = (target, source, options) => {
			const destination = target.slice();

			source.forEach(function(e, i) {
				if(typeof destination[i] === 'undefined') {
					const cloneRequested = options.clone !== false;
					const shouldClone = cloneRequested && options.isMergeableObject(e);

					destination[i] = shouldClone ? clone(e, options) : e;
				}
				else if(options.isMergeableObject(e)) {
					destination[i] = deepmerge(target[i], e, options);
				}
				else if(target.indexOf(e) === -1) {
					destination.push(e);
				}
			});

			return destination;
		};

		try {
			const loadedConfigs = [];
			for(const subService of this.$prioritizedSubServices) {
				const twyrModuleConfig = await this.$services[subService].loadConfiguration(twyrModule);
				loadedConfigs.push(twyrModuleConfig);
			}

			let mergedConfig = {};
			loadedConfigs.forEach((loadedConfig) => {
				if(!loadedConfig) return;
				mergedConfig = deepmerge(mergedConfig, loadedConfig, {
					'arrayMerge': oldArrayMerge
				});
			});

			await this.saveConfiguration(twyrModule, mergedConfig);
			const enabled = await this.getModuleState(twyrModule);

			return { 'configuration': mergedConfig, 'state': enabled };
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::loadConfiguration::${twyrModule.name} error`, err);
		}
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof ConfigurationService
	 * @name     saveConfiguration
	 *
	 * @param    {TwyrBaseModule} twyrModule - Instance of the {@link TwyrBaseModule} that requires configuration.
	 * @param    {Object} config - The {@link TwyrBaseModule}'s' configuration that should be persisted.
	 *
	 * @returns  {Object} - The twyrModule configuration.
	 *
	 * @summary  Saves the configuration of the twyrModule requesting for it.
	 */
	async saveConfiguration(twyrModule, config) {
		try {
			const subServiceNames = Object.keys(this.$services);

			// eslint-disable-next-line curly
			for(const subServiceName of subServiceNames) {
				await this.$services[subServiceName].saveConfiguration(twyrModule, config);
			}

			return config;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::saveConfiguration::${twyrModule.name} error`, err);
		}
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof ConfigurationService
	 * @name     getModuleState
	 *
	 * @param    {TwyrBaseModule} twyrModule - Instance of the {@link TwyrBaseModule} that requires its state.
	 *
	 * @returns  {Object} - The merged states returned by sub-modules.
	 *
	 * @summary  Retrieves the state of the twyrModule requesting for it.
	 */
	async getModuleState(twyrModule) {
		try {
			const subServiceNames = Object.keys(this.$services);

			const moduleStates = [];
			for(const subServiceName of subServiceNames) {
				const twyrModuleState = await this.$services[subServiceName].getModuleState(twyrModule);
				moduleStates.push(twyrModuleState);
			}

			let moduleState = true;
			moduleStates.forEach((state) => {
				moduleState = moduleState && !!state;
			});

			return moduleState;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::getModuleState::${twyrModule.name} error`, err);
		}
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof ConfigurationService
	 * @name     setModuleState
	 *
	 * @param    {TwyrBaseModule} twyrModule - Instance of the {@link TwyrBaseModule} that requires its state.
	 * @param    {boolean} enabled - State of the module.
	 *
	 * @returns  {Object} - The state of the twyrModule.
	 *
	 * @summary  Saves the state of the twyrModule requesting for it.
	 */
	async setModuleState(twyrModule, enabled) {
		try {
			const subServiceNames = Object.keys(this.$services);

			const moduleStates = [];
			for(const subServiceName of subServiceNames) {
				const twyrModuleState = await this.$services[subServiceName].setModuleState(twyrModule, enabled);
				moduleStates.push(twyrModuleState);
			}

			let moduleState = true;
			moduleStates.forEach((state) => {
				moduleState = moduleState && !!state;
			});

			return moduleState;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::setModuleState::${twyrModule.name} error`, err);
		}
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof ConfigurationService
	 * @name     getModuleId
	 *
	 * @param    {TwyrBaseModule} twyrModule - Instance of the {@link TwyrBaseModule} that requires its id.
	 *
	 * @returns  {Object} - The id of the twyrModule.
	 *
	 * @summary  Retrieves the id of the twyrModule requesting for it.
	 */
	async getModuleId(twyrModule) {
		try {
			const subServiceNames = Object.keys(this.$services);

			const moduleIds = [];
			for(const subServiceName of subServiceNames) {
				const twyrModuleId = await this.$services[subServiceName].getModuleId(twyrModule);
				if(twyrModuleId) moduleIds.push(twyrModuleId);
			}

			let moduleId = null;
			moduleIds.forEach((twyrModuleId) => {
				moduleId = twyrModuleId;
			});

			return moduleId;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::getModuleId::${twyrModule.name} error`, err);
		}
	}
	// #endregion

	// #region Private Methods
	/**
	 * @async
	 * @function
	 * @instance
	 * @private
	 * @memberof ConfigurationService
	 * @name     _processConfigChange
	 *
	 * @param    {TwyrBaseService} eventFirerModule - The sub-configuration service that detected the config change.
	 * @param    {TwyrBaseModule} configUpdateModule - The twyrModule for which the configuration changed.
	 * @param    {Object} config - The updated configuration.
	 *
	 * @returns  {null} - Nothing.
	 *
	 * @summary  Syncs the configuration across all the configuration sources, and then tells the twyrModule to reconfigure itself.
	 */
	_processConfigChange(eventFirerModule, configUpdateModule, config) {
		try {
			Object.keys(this.$services).forEach((subService) => {
				if(subService === eventFirerModule)
					return;

				this.$services[subService]._processConfigChange(configUpdateModule, config);
			});

			const currentModule = this._getModuleFromPath(configUpdateModule);
			if(currentModule) currentModule._reconfigure(config);
		}
		catch(err) {
			console.error(`${this.name}::_processConfigChange error: ${err.message}\n${err.stack}`);
		}
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @private
	 * @memberof ConfigurationService
	 * @name     _processStateChange
	 *
	 * @param    {TwyrBaseService} eventFirerModule - The sub-configuration service that detected the state change.
	 * @param    {TwyrBaseModule} stateUpdateModule - The twyrModule for which the state changed.
	 * @param    {boolean} state - The updated state of the twyrModule.
	 *
	 * @returns  {null} - Nothing.
	 *
	 * @summary  Syncs the state across all the configuration sources, and then tells the twyrModule to change its own state.
	 */
	_processStateChange(eventFirerModule, stateUpdateModule, state) {
		try {
			Object.keys(this.$services).forEach((subService) => {
				if(subService === eventFirerModule)
					return;

				this.$services[subService]._processStateChange(stateUpdateModule, state);
			});

			const currentModule = this._getModuleFromPath(stateUpdateModule);
			if(currentModule) currentModule._changeState(state);
		}
		catch(err) {
			console.error(`${this.name}::_processStateChange error: ${err.message}\n${err.stack}`);
		}
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @private
	 * @memberof ConfigurationService
	 * @name     _getModuleFromPath
	 *
	 * @param    {string} pathFromRoot - The loaded path (from the application class) of the module.
	 *
	 * @returns  {TwyrBaseModule} - The module at the given path.
	 *
	 * @summary  Given a path relative to the Application Class instance, retrieves the loaded twyrModule object.
	 */
	_getModuleFromPath(pathFromRoot) {
		try {
			const inflection = require('inflection');
			const path = require('path');

			let currentModule = this.$parent,
				pathSegments = null;

			while(currentModule.$parent) currentModule = currentModule.$parent;

			pathSegments = pathFromRoot.split(path.sep);
			while(pathSegments.length) {
				let pathSegment = pathSegments.shift();
				if((pathSegment === 'server') || !currentModule)
					continue;

				let nextModule = currentModule[`${inflection.camelize(pathSegment)}`] || currentModule[`$${pathSegment}`] || currentModule[`${pathSegment}`];
				if(nextModule) {
					currentModule = nextModule;
					continue;
				}

				while(pathSegments.length) {
					pathSegment = `${pathSegment}_${pathSegments.shift()}`;
					nextModule = currentModule[`${inflection.camelize(pathSegment)}`] || currentModule[`$${pathSegment}`] || currentModule[`${pathSegment}`];

					if(nextModule) {
						currentModule = nextModule;
						break;
					}
				}
			}

			return currentModule;
		}
		catch(err) {
			console.error(`${this.name}::_getModuleFromPath error: ${err.message}\n${err.stack}`);
			return null;
		}
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @private
	 * @memberof ConfigurationService
	 * @name     _getPathForModule
	 *
	 * @param    {TwyrBaseModule} twyrModule - The module for which to generate the path.
	 *
	 * @returns  {string} - The path of the module, relative to the Application Class.
	 *
	 * @summary  Given a loaded twyrModule object, return the path relative to the Application Class instance.
	 */
	_getPathForModule(twyrModule) {
		try {
			if(!twyrModule.$parent) return 'server';

			const inflection = require('inflection');
			const path = require('path');

			let currentModule = twyrModule;

			const pathSegments = [];
			pathSegments.push(inflection.underscore(currentModule.name));

			while(currentModule.$parent) {
				const modulePrototype = Object.getPrototypeOf(Object.getPrototypeOf(currentModule)).name;
				const parentModule = currentModule.$parent;

				const twyrModuleType = `${modulePrototype.replace('TwyrBase', '').toLowerCase()}s`;
				pathSegments.unshift(twyrModuleType);

				currentModule = parentModule;
				if(currentModule && currentModule.$parent) pathSegments.unshift(inflection.underscore(currentModule.name));
			}

			pathSegments.unshift('server');
			return pathSegments.join(path.sep);
		}
		catch(err) {
			console.error(`${this.name}::_getPathForModule error: ${err.message}\n${err.stack}`);
			return null;
		}
	}
	// #endregion

	// #region Properties
	/**
	 * @override
	 */
	get Interface() {
		return {
			'loadConfiguration': this.loadConfiguration.bind(this),
			'saveConfiguration': this.loadConfiguration.bind(this),
			'getModuleState': this.getModuleState.bind(this),
			'setModuleState': this.setModuleState.bind(this),
			'getModuleID': this.getModuleId.bind(this)
		};
	}

	/**
	 * @override
	 */
	get basePath() {
		return __dirname;
	}
	// #endregion
}

exports.service = ConfigurationService;