UNPKG

config-srv

Version:

API and REST interface for editing a structured set of parameters

366 lines (336 loc) 13.6 kB
/* eslint-disable class-methods-use-this, max-len, max-classes-per-file, no-prototype-builtins, no-bitwise,no-await-in-loop */ const __ = require('./lib.js'); const Schema = require('./Schema.js'); const { getStorageService } = require('./storage-service'); const ee = require('./ee'); const _isRootNode_ = Symbol.for('_isRootNode_'); const _v_ = Symbol.for('_v_'); const _isSection_ = Symbol.for('_isSection_'); const _isProp_ = Symbol.for('_isProp_'); const _onChange_ = Symbol.for('_onChange_'); const _callerId_ = Symbol.for('_callerId_'); const _updatedBy_ = Symbol.for('_updatedBy_'); const _payload_ = Symbol.for('_payload_'); /** * Initialization: * - Filling a Schema object (this.schema) * - Filling the 'defaults' (this.defaults) - This is a Schema structure with default property values. * - Filling the Schema with actual values * - Re-saving parts of the configuration after their normalization. * * During the initialization process, checks are carried out: * - presence of a configuration directory * - the presence of a Schema file * - data in the Schema file * - validity of Schema file and configuration files (js/json validity) */ module.exports = class Params extends Schema { constructor (serviceOptions = {}) { super(serviceOptions); const { onSaveNamedConfig, jsonStringifySpace } = serviceOptions; this.serviceOptions = serviceOptions; this.onSaveNamedConfig = typeof onSaveNamedConfig === 'function' ? onSaveNamedConfig : () => null; this.jsonStringifySpace = Number(jsonStringifySpace) || 2; } async init () { await super.init(); this.storageService = getStorageService(this.serviceOptions); this.initCsLeafChangeListener(); const noReloadSchema = true; await this._reloadConfig(noReloadSchema); this.defaults = this._getDefaults(); } // ============================ GET VALUES ============================= /** * Returns a configuration object with values collected from the passed Schema * If value === undefined in the scheme, defaultValue is returned. * * @param {schemaFragmentType} schemaFragment * @param {Object} valuesContainer * @param {String[]} pathArr * @return {Object} */ // TODO _getValuesFromSchemaFragment (schemaFragment = this.schema, valuesContainer = {}, pathArr = []) { let container = valuesContainer; let schemaValue = schemaFragment; if (__.isSchemaItem(schemaFragment)) { const { id, value, type } = schemaFragment; pathArr.push(id); if (type !== 'section') { valuesContainer[id] = value; return valuesContainer; } valuesContainer[id] = {}; schemaValue = schemaFragment.value; container = valuesContainer[id]; } if (Array.isArray(schemaValue)) { schemaValue.forEach((schemaItem) => { const { id, value, defaultValue, type } = schemaItem; const isSection = type === 'section'; if (value === undefined) { return defaultValue; } if (value === null) { container[id] = null; return; } if (isSection) { const sectionValue = this._getValuesFromSchemaFragment(value, container[id], [...pathArr, id]); container[id] = __.isNonEmptyObject(sectionValue) ? sectionValue : {}; // Empty section values - null !! } else if (type === 'array') { // eslint-disable-next-line no-nested-ternary container[id] = Array.isArray(value) ? value : (Array.isArray(defaultValue) ? defaultValue : null); } else if (type === 'json') { container[id] = this._normalizeJSON(value); } else { container[id] = value; } }); } return valuesContainer; } /** * Get the value of a configuration parameter along its path * * @param {propPathType} paramPath * @param {Object} options * @return {*} */ _getValues (paramPath, options = {}) { options.callFrom = options.callFrom || '_getValues'; const { schemaItem, lastParamName } = this._parseParamPath(paramPath, options); const values = this._getValuesFromSchemaFragment(schemaItem); return lastParamName ? values[lastParamName] : values.__root__; } // ============================ FILL SCHEMA BY VALUES ============================= __addNewValueCallback (schemaItem, { absentPaths, appliedPaths }) { // eslint-disable-next-line camelcase,prefer-const const { value, [_v_]: currentValue, [_onChange_]: onChange, [_isSection_]: isSection, [_isProp_]: isProp, [_updatedBy_]: updatedBy, path: paramPath, } = schemaItem || {}; if (isSection && Array.isArray(value)) { if (__.canDeepDive(currentValue)) { const sIds = value.map(({ id }) => id); const vIds = Object.keys(currentValue); vIds.filter((id) => !sIds.includes(id)).forEach((v) => { absentPaths.add(`${paramPath}.${v}`); }); vIds.filter((id) => sIds.includes(id)).forEach((v) => { appliedPaths.add(`${paramPath}.${v}`); }); } else if (currentValue !== undefined && currentValue !== null && !__.isObject(currentValue)) { throw this._error(`Cannot set a value «${currentValue}» for a 'section' «${paramPath}»`); } value.forEach((childSchemaItem) => { if (__.isSchemaItem(childSchemaItem)) { childSchemaItem[_v_] = __.canDeepDive(currentValue) ? currentValue[childSchemaItem.id] : undefined; if (onChange !== undefined) { childSchemaItem[_onChange_] = onChange; } if (updatedBy !== undefined) { childSchemaItem[_updatedBy_] = updatedBy; } } }); } else if (isProp) { if (onChange !== undefined) { schemaItem.value = { value: currentValue, [_onChange_]: onChange }; } else { schemaItem.value = currentValue; } delete schemaItem[_onChange_]; delete schemaItem[_callerId_]; delete schemaItem[_updatedBy_]; } } /** * Fills the Schema with actual values */ _fillSchemaWithValues (paramPath, newValues, options = {}) { const { callFrom = '_fillSchemaWithValues', onChange, updatedBy } = options; options.callFrom = callFrom; const { pathArr, schemaItem } = this._parseParamPath(paramPath, options); const absentPaths = new Set(); const appliedPaths = new Set(); const traverseOptions = { pathArr, absentPaths, appliedPaths, updatedBy }; schemaItem[_v_] = newValues; if (onChange !== undefined) { schemaItem[_onChange_] = onChange; } schemaItem[_callerId_] = options.callerId; schemaItem[_updatedBy_] = options.updatedBy; schemaItem[_payload_] = options.payload; this._traverseSchema(schemaItem, traverseOptions, this.__addNewValueCallback); if (absentPaths.size) { // console.log(`Missed:\n${[...absentPaths].join('\n')}`); } } // ============================ PARAMETERS ============================= /** * Saving array of configs to several files * Data must be pre-checked and normalized and stored in the configuration object. * * @param {String[]} configNames */ async _saveNamedConfigArr (configNames) { const uniqConfigNames = [...new Set(configNames)]; const promiseArr = uniqConfigNames.map((configName) => this._saveNamedConfig(configName)); await Promise.all(promiseArr); } /** * Saving named configuration data to a file * Data must be pre-checked and normalized and stored in the configuration object. * * @param {String} configName */ async _saveNamedConfig (configName) { let configValue = this._getValues(configName); // The named configuration must be an object !!! if (!__.isObject(configValue)) { configValue = {}; } let jsonStr; try { jsonStr = JSON.stringify(configValue, undefined, this.jsonStringifySpace); } catch (err) { throw this._error(`Could not save named configuration «${ this._expectedConfigDir}/${configName}.json»`, err); // Not covered with tests } await this.storageService.saveConfig(configName, jsonStr); this.onSaveNamedConfig(configName, this); } /** * re-/loading named configuration file. */ async _readNamedConfig (configName) { return this.storageService.getNamedConfig(configName); } /** * Parse the path to the parameter into parts. Casting to an array. Validation * * The presence and correspondence of the type in the Schema for the specified path is checked. * Information is cached. * * @private * @param {propPathType} paramPath * @param {Object} options * @return {Object} */ _parseParamPath (paramPath = '', options = {}) { options.callFrom = options.callFrom || '_parseParamPath'; // function name to substitute in error message const { pathArr, paramPath: paramPath_, configName, pathParent, lastParamName, } = this._parseParamPathFragment(paramPath, options); const schemaItem = this._getSchemaFragment(pathArr, this.schema, options); return { paramPath: paramPath_, pathArr: [...pathArr], pathParent, lastParamName: schemaItem[_isRootNode_] ? '__root__' : lastParamName, configName, schemaItem, schemaDataType: schemaItem.type, }; } // ################################################################################################################################### /** * Set new named configuration data and saves it to a file. * The default parameter structure is superimposed on the passed parameter structure. * Data is completely updated (old data is lost) * * @param {String} configName * @param {Object} configValue * @param {Boolean} refreshSchema - Values loaded from the previous config that are not in the * @param {Object} options * new one remain if the refreshSchema flag is specified */ async _updateAndSaveNamedConfig (configName, configValue, refreshSchema = false, options = {}) { options.callFrom = options.callFrom || '_updateAndSaveNamedConfig'; // function name to substitute in error message if (refreshSchema) { await this.reloadSchema(); } this._fillSchemaWithValues(configName, configValue, options); await this._saveNamedConfig(configName); // VVQ сделать функцию асинхронной. Для файлов - сохранение в файл. Для БД - принудительный _flashUpdateSchedule } // =============================== INIT ================================== // TODO async _reloadConfig (noReloadSchema = false) { if (!noReloadSchema) { await this.reloadSchema(); } const promiseArr = this.configNames.map(async (configName) => { const configValue = await this._readNamedConfig(configName); this._fillSchemaWithValues(configName, configValue, { updatedBy: 'initialConfig' }); // Не оповещаем об обновлении для только что считанных данных }); await Promise.all(promiseArr); } // ####################################################################################################### // =============================== DEFAULTS ================================= /** * Creates and return a configuration defaults, populating with default values * * @param {schemaValueType} schemaValue * @param {Object} valuesContainer - for collecting properties * @param {String[]} pathArr * @return {Object} - reference configuration object */ _getDefaults (schemaValue = undefined, valuesContainer = {}, pathArr = []) { if (!schemaValue) { schemaValue = this.schema?.value; } if (Array.isArray(schemaValue)) { schemaValue.forEach((schemaItem, index) => { const { id, value: childValue, defaultValue: childDefaultValue, type: schemaDataType } = schemaItem; const isSection = schemaDataType === 'section'; const value = isSection ? childValue : childDefaultValue; if (value === undefined) { return; } if (value === null) { valuesContainer[id] = null; return; } this._validateSchemaItem(schemaItem, pathArr, index); if (isSection) { const sectionValue = this._getDefaults(value, valuesContainer[id], [...pathArr, id]); valuesContainer[id] = __.isNonEmptyObject(sectionValue) ? sectionValue : null; // Empty section values - null !! } else if (schemaDataType === 'array') { valuesContainer[id] = Array.isArray(childDefaultValue) ? value : null; } else if (schemaDataType === 'json') { valuesContainer[id] = this._normalizeJSON(value); } else { valuesContainer[id] = value; } }); } return valuesContainer; } initCsLeafChangeListener () { ee.on('remote-config-changed', (config) => { // VVR В случае изменения конфига другим инстансом this._fillSchemaWithValues('', config); }); ee.on('cs-leaf-change', ({ paramPath, newValue, updatedBy }) => { const configName = paramPath.split('.')[0]; if (!configName) { return; } // VVA в случе файлового хранилища тут будет ошибка, т.к scheduleUpdate не будет this.storageService.scheduleUpdate({ configName, paramPath, updatedBy, value: newValue }); }); } };