/** * @file Configuration classes * @author Hornwitser */ "use strict"; const { basicType } = require("lib/helpers"); /** * Invalid Value exception * * Exception class for when invalid values are attempted to be set on a * field. */ class InvalidValue extends Error { }; /** * Invalid Field exception * * Exception class for when a group or field that does not exist is * attempted to be accessed. */ class InvalidField extends Error { }; /** * Split the given string on the first instance of separator * * Splits @p string on the first instance of @p separator and returns an * array consisting of the string up to the separator and the string * after the separator. Returns an array with the string and an empty * string if the separator is not present. * * @returns {Array} string split on separator. */ function splitOn(separator, string) { let index = string.indexOf(separator); if (index === -1) { return [string, '']; } return [string.slice(0, index), string.slice(index + separator.length)]; } /** * Collection of config groups * * Represents a collection of ConfigGroup instances that can be * managed as one. */ class Config { constructor() { if (!this.constructor._finalized) { throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`) } this._groups = new Map(); this._unknownGroups = new Map(); } async init() { for (let [name, GroupClass] of this.constructor._groups) { if (!this._groups.has(name)) { let group = new GroupClass(); await group.init(); this._groups.set(name, group); } } this._initialized = true; } _validate(serializedConfig) { if (basicType(serializedConfig) !== "object") { throw new Error(`Expected object, not ${basicType(serializedConfig)} for config`); } if (basicType(serializedConfig.groups) !== "array") { throw new Error(`Expected groups to be an array, not ${basicType(serializedConfig.groups)}`); } } async load(serializedConfig) { this._validate(serializedConfig); for (let serializedGroup of serializedConfig.groups) { let GroupClass = this.constructor._groups.get(serializedGroup.name); if (!GroupClass) { this._unknownGroups.set(serializedGroup.name, serializedGroup); continue; } let group = new GroupClass(); await group.load(serializedGroup); this._groups.set(GroupClass.groupName, group); } await this.init(); } /** * Serialize the config to a plain JavaScript object */ serialize() { let groups = [...this._unknownGroups.values()]; for (let group of this._groups.values()) { groups.push(group.serialize()); } return { groups }; } /** * Update the configuration based on a serialized config * * Updates all groups in the config with groups in the serialized * config passed. */ update(serializedConfig) { this._validate(serializedConfig); for (let serializedGroup of serializedConfig.groups) { let group = this._groups.get(serializedGroup.name); if (group) { group.update(serializedGroup); } else { this._unknownGroups.set(serializedGroup.name, serializedGroup); } } } /** * Get the value for a config field */ get(name) { let [group, field] = splitOn('.', name); return this.group(group).get(field); } /** * Set the value for a config field */ set(name, value) { let [group, field] = splitOn('.', name); return this.group(group).set(field, value); } /** * Get the config group instance with the given name */ group(name) { if (!this._initialized) { throw new Error(`${this.constructor.name} instance is uninitialized`) } let group = this._groups.get(name); if (!group) { throw new InvalidField(`No config group named '${name}'`); } return group; } static _initSubclass() { // These properties are initialized here in order to ensure that // they end up on the sub-class. if (!this._initialized) { this._initialized = true; this._finalized = false; this._groups = new Map(); } } /** * Mapping of group name to group class * * Returns the internal mapping of group names to group classes that * mave been registered with this config. Note that mutating this * object will lead to unexpected behaviour. * * @returns {Map} config groups */ static get groups() { this._initSubclass(); return this._groups; } /** * Locks this Config class from adding more groups. */ static finalize() { this._initSubclass(); this._finalized = true; } /** * Register a ConfigGroup with this Config * * Adds the ConfigGroup to this config as a group that will get * initialized, loaded and serialized with this config class. */ static registerGroup(group) { this._initSubclass(); if (this._finalized) { throw new Error("Cannot register group on finalized config"); } if (!group._finalized) { throw new Error("Group must be finalized before it can be registered"); } if (this._groups.has(group.groupName)) { throw new Error(`${group.groupName} has already been registered`); } this._groups.set(group.groupName, group); } } /** * Collection of related config entries */ class ConfigGroup { /** * Creates a new config group. * * After the creation of the new config group you have to call * either .init() or .load() on it in order to fully initialize it. */ constructor(serializedGroup) { if (!this.constructor._finalized) { throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`) } this._fields = new Map(); this._unknownFields = new Map(); } /** * Initialize new config group * * Computes and assigns the initial values for all fields of the * config group. */ async init() { for (let [name, def] of this.constructor._definitions) { if (this._fields.has(name)) { continue; } let value = null; if (typeof def.initial_value === "function") { value = await def.initial_value() } else if (def.initial_value !== undefined) { value = def.initial_value; } this._fields.set(name, value); } } /** * Load from serialized group * * Loads all the values from the passed serialized version of the * group and then initializes any possible missing fields from it to * their default values. */ async load(serializedGroup) { this.update(serializedGroup); await this.init(); } /** * Get value for field */ get(name) { if (!this.constructor._definitions.has(name)) { throw new InvalidField(`No field named '${name}'`); } return this._fields.get(name); } /** * Set value of field * * The field must be defined for the config group and the value is * type checked against the field definition. * * @throws {InvalidValue} if value is not allowed for the field. */ set(name, value) { let def = this.constructor._definitions.get(name); if (!def) { throw new Error(`No field named '${name}'`); } // Empty strings are treates as null if (value === "") { value = null; } if (def.enum && !def.enum.includes(value)) { throw new InvalidValue(`Expected one of [${def.enum.join(', ')}], not ${value}`); } if (basicType(value) === "string") { if (def.type === "boolean") { if (value === "true") { value = true; } else if (value === "false") { value = false; } } else if (def.type === "number") { let numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/; if (numberRegExp.test(value.trim())) { value = Number.parseFloat(value); } } else if (def.type === "object" ) { try { value = JSON.parse(value); } catch (err) { throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`) } } } if (value === null) { if (!def.optional) { throw new InvalidValue(`Field ${name} cannot be null`) } } else { if (basicType(value) !== def.type) { throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`); } } this._fields.set(name, value); } /** * Update a config group from a serialized group * * Overwrites the values of all the fields in this config group that * has a valid value in the passed serialized group with that value. */ update(serializedGroup) { if (basicType(serializedGroup) !== "object") { throw new Error(`Expected object, not ${basicType(serializedGroup)} for ConfigGroup`); } if (serializedGroup.name !== this.constructor.groupName) { throw new Error(`Expected group name ${this.constructor.groupName}, not ${serializedGroup.name}`); } if (basicType(serializedGroup.fields) !== "object") { throw new Error(`Expected fields to be an object, not ${basicType(serializedGroup.fields)}`); } for (let [name, value] of Object.entries(serializedGroup.fields)) { let def = this.constructor._definitions.get(name); if (!def) { this._unknownFields.set(name, value); continue; } if (def.optional) { if (value !== null && basicType(value) !== def.type) { continue; } } else { if (basicType(value) !== def.type) { continue; } } if (def.enum && !def.enum.includes(value)) { continue; } this._fields.set(name, value); } } /** * Serialize the config to a plain JavaScript object */ serialize() { let fields = {}; for (let [name, value] of this._fields) { fields[name] = value; } for (let [name, value] of this._unknownFields) { fields[name] = value; } return { name: this.constructor.groupName, fields, }; } static _initSubclass() { // These properties are initialized here in order to ensure that // they end up on the sub-class. if (!this._initialized) { this._initialized = true; this._definitions = new Map(); } } /** * Mapping of config name to field meta data * * Returns the internal meta data for the fields of this class. * Note that mutating this object will lead to unexpected behaviour. * * @returns {Map} field meta data */ static get definitions() { this._initSubclass(); return this._definitions; } /** * Define a new configuration field * * The following properties are supported: * - type: * string declaring the type of the config value, supports * boolean, string, number and object. * - name: * Name of the configuration field. Should follow the * lower_case_underscore style. * - title: * Text used to identify the config field in user interfaces. * - description: * Text used to describe the config field in user interfaces. * - enum (optional): * Array of all values that are acceptible. Useful for making * enumerated values. * - optional (optional): * True if this field can be set to null. Defaults to false. * - initial_value (optional): * Value this config field should take in a newly initialized * config. This can also be an async function returning the * value to use. */ static define(item) { if (this._finalized) { throw new Error(`Cannot define field for ConfigGroup class ${this.name} after it has been finalized`) } if (basicType(this.groupName) !== "string") { throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`) } this._initSubclass(); let itemKeys = Object.keys(item); // Check for required properties ["name", "type"].forEach(prop => { if (!itemKeys.includes(prop)) { throw new Error(`${prop} is required when defining an field`); } }) // Validate properties let validTypes = ["boolean", "string", "number", "object"]; Object.keys(item).forEach(key => { let value = item[key]; let valueType = basicType(value); switch (key) { case "type": if (!validTypes.includes(value)) { throw new Error(`${value} is not a valid type`); } break; case "enum": if (valueType !== "array") { throw new Error("enum must be an array"); } break; case "name": if (valueType !== "string") { throw new Error("name must be a string"); } break; case "title": if (valueType !== "string") { throw new Error("title must be a string"); } break; case "description": if (valueType !== "string") { throw new Error("description must be a string"); } break; case "optional": if (valueType !== "boolean") { throw new Error("optional must be a boolean"); } break; case "initial_value": if (valueType !== item.type && valueType !== "function") { throw new Error("initial_value must match the type or be a function"); } break; default: throw new Error(`Unknown property ${key}`); } }); if (this._definitions.has(item.name)) { throw new Error(`Config field ${item.name} has already been defined`); } let computed = Object.assign({ optional: false, fullName: `${this.groupName}.${item.name}`, }, item) if (!computed.optional && computed.initial_value === undefined) { throw new Error(`Non-optional field ${item.name} needs an initial_value`); } this._definitions.set(item.name, computed); } /** * Locks this ConfigGroup class from adding more definitons. */ static finalize() { if (basicType(this.groupName) !== "string") { throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`) } this._initSubclass(); this._finalized = true; } } /** * Config group for plugins * * Grouping that includes per plugin fields clusterio uses to manage * plugins. This is currently only the enabled field. It is otherwise * identical to the ConfigGroup class. */ class PluginConfigGroup extends ConfigGroup { static _initSubclass() { if (!this._initialized) { super._initSubclass(); this.define({ name: "enabled", description: "Enable plugin", type: "boolean", initial_value: true, }); } } } module.exports = { InvalidField, InvalidValue, Config, ConfigGroup, PluginConfigGroup, }