From 001db4befe81f90539bd34032986a4d2396fcaae Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Thu, 14 Dec 2023 19:27:31 +0100 Subject: [PATCH] Refactor config to JSON data class pattern Refactor the Config and ConfigGroup classes to match the JSON data class pattern with toJSON, toRemote and fromJSON methods replacing the load, init, and serialize methods. Since fromJSON requires extra information the messages structures passes around the plain JSON representation of the configs instead of serialising instances of the config classes automatically. --- packages/controller/controller.ts | 13 +- packages/controller/src/ControlConnection.ts | 16 +- packages/controller/src/Controller.ts | 15 +- packages/controller/src/HostConnection.ts | 8 +- packages/controller/src/InstanceInfo.ts | 15 +- packages/ctl/ctl.ts | 7 +- packages/ctl/src/commands.ts | 31 ++- packages/host/host.ts | 7 +- packages/host/src/Host.ts | 15 +- packages/lib/src/config/classes.ts | 246 +++++++++--------- packages/lib/src/config/definitions.ts | 4 + packages/lib/src/data/composites.ts | 11 +- packages/lib/src/data/messages_controller.ts | 8 +- packages/lib/src/data/messages_instance.ts | 22 +- packages/lib/src/shared_commands.ts | 2 +- .../web_ui/src/components/BaseConfigTree.tsx | 8 +- .../src/components/ControllerConfigTree.tsx | 3 +- .../src/components/InstanceConfigTree.tsx | 3 +- .../web_ui/src/components/InstancesPage.tsx | 3 +- test/controller/InstanceInfo.js | 15 +- test/host.js | 1 - test/host/Instance.js | 1 - test/integration/clusterio.js | 4 +- test/lib/config/classes.js | 145 ++++------- 24 files changed, 269 insertions(+), 334 deletions(-) diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index bb3ee970..c84f791c 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -156,7 +156,6 @@ async function handleBootstrapCommand( return; } let controlConfig = new lib.ControlConfig("control"); - controlConfig.init(); controlConfig.set("control.controller_url", Controller.calculateControllerUrl(controllerConfig)); controlConfig.set( @@ -164,7 +163,7 @@ async function handleBootstrapCommand( userManager.signUserToken(admin.name), ); - let content = JSON.stringify(controlConfig.serialize(), null, "\t"); + let content = JSON.stringify(controlConfig, null, "\t"); if (args.output === "-") { // eslint-disable-next-line no-console console.log(content); @@ -322,15 +321,15 @@ async function initialize(): Promise { const controllerConfigPath = args.config; logger.info(`Loading config from ${controllerConfigPath}`); - const controllerConfig = new lib.ControllerConfig("controller"); + let controllerConfig = new lib.ControllerConfig("controller"); try { - let fileData = await fs.readFile(controllerConfigPath, { encoding: "utf8" }); - controllerConfig.load(JSON.parse(fileData)); + let jsonConfig = JSON.parse(await fs.readFile(controllerConfigPath, { encoding: "utf8" })); + controllerConfig = lib.ControllerConfig.fromJSON(jsonConfig, "controller"); } catch (err: any) { if (err.code === "ENOENT") { logger.info("Config not found, initializing new config"); - controllerConfig.init(); + controllerConfig = new lib.ControllerConfig("controller"); } else { throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.message}`); @@ -343,7 +342,7 @@ async function initialize(): Promise { let bytes = await asyncRandomBytes(256); controllerConfig.set("controller.auth_secret", bytes.toString("base64")); await lib.safeOutputFile( - controllerConfigPath, JSON.stringify(controllerConfig.serialize(), null, "\t") + controllerConfigPath, JSON.stringify(controllerConfig, null, "\t") ); } diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 7242bd6b..990934ec 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -169,8 +169,8 @@ export default class ControlConnection extends BaseConnection { throw new Error("Should be unreachable"); } - async handleControllerConfigGetRequest(): Promise { - return new lib.RawConfig(this._controller.config.serialize("control")); + async handleControllerConfigGetRequest() { + return this._controller.config.toRemote("control"); } async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) { @@ -211,8 +211,7 @@ export default class ControlConnection extends BaseConnection { } async handleHostConfigCreateRequest(request: lib.HostConfigCreateRequest) { - let hostConfig = new lib.HostConfig("control"); - hostConfig.init(); + const hostConfig = new lib.HostConfig("host"); hostConfig.set("host.controller_url", this._controller.getControllerUrl()); if (request.id !== undefined) { @@ -225,7 +224,7 @@ export default class ControlConnection extends BaseConnection { this.user.checkPermission("core.host.generate_token"); hostConfig.set("host.controller_token", this._controller.generateHostToken(hostConfig.get("host.id"))); } - return new lib.RawConfig(hostConfig.serialize()); + return hostConfig.toJSON(); } async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) { @@ -236,10 +235,9 @@ export default class ControlConnection extends BaseConnection { return [...this._controller.instances.values()].map(instance => instance.toInstanceDetails()); } - // XXX should probably add a hook for host reuqests? async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) { - let instanceConfig = new lib.InstanceConfig("controller"); - instanceConfig.load(request.config as lib.SerializedConfig); + const instanceConfig = new lib.InstanceConfig("controller"); + instanceConfig.update(request.config, false, "control"); await this._controller.instanceCreate(instanceConfig); } @@ -249,7 +247,7 @@ export default class ControlConnection extends BaseConnection { async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) { let instance = this._controller.getRequestInstance(request.instanceId); - return new lib.InstanceConfigGetRequest.Response(instance.config.serialize("control")); + return instance.config.toRemote("control"); } async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) { diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index ef557bf9..6db5d9df 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -421,7 +421,7 @@ export default class Controller { private async _saveDataInternal() { if (this.config.dirty) { this.config.dirty = false; - await lib.safeOutputFile(this.configPath, JSON.stringify(this.config.serialize(), null, "\t")); + await lib.safeOutputFile(this.configPath, JSON.stringify(this.config, null, "\t")); } let databaseDirectory = this.config.get("controller.database_directory"); @@ -495,11 +495,9 @@ export default class Controller { ) as Static[]; for (let json of serialized) { if (!json.config) { // migrate: from pre Alpha 14 format. - json = { config: json, status: "running" }; // Use running to force updatedAt + json = { config: json as any, status: "running" }; // Use running to force updatedAt } - let instanceConfig = new lib.InstanceConfig("controller"); - instanceConfig.load(json.config as lib.SerializedConfig); - let instance = InstanceInfo.fromJSON(json, instanceConfig); + const instance = InstanceInfo.fromJSON(json, "controller"); const status = instance.config.get("instance.assigned_host") === null ? "unassigned" : "unknown"; if (instance.status !== status) { instance.status = status; @@ -519,7 +517,7 @@ export default class Controller { } static async saveInstances(filePath: string, instances: Map) { - let serialized = [...instances.values()].map(instance => instance.toJSON()); + let serialized = [...instances.values()]; await lib.safeOutputFile(filePath, JSON.stringify(serialized, null, "\t")); } @@ -690,7 +688,6 @@ export default class Controller { * * @example * let instanceConfig = new lib.InstanceConfig("controller"); - * instanceConfig.init(); * instanceConfig.set("instance.name", "My instance"); * let instance = await controller.instanceAssign(instanceConfig); * await controller.instanceAssign(instance.id, hostId); @@ -806,7 +803,7 @@ export default class Controller { // "fieldChanged" event handler will set this.instancesDirty if (hostId !== undefined && newHostConnection) { await newHostConnection.send( - new lib.InstanceAssignInternalRequest(instanceId, instance.config.serialize("host")) + new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")) ); } else { instance.status = "unassigned"; @@ -855,7 +852,7 @@ export default class Controller { let connection = this.wsServer.hostConnections.get(hostId); if (connection) { await connection.send( - new lib.InstanceAssignInternalRequest(instance.id, instance.config.serialize("host")) + new lib.InstanceAssignInternalRequest(instance.id, instance.config.toRemote("host")) ); } } diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index d95198dc..037dabc8 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -189,7 +189,7 @@ export default class HostConnection extends BaseConnection { for (let instance of this._controller.instances.values()) { if (instance.config.get("instance.assigned_host") === this._id) { await this.send( - new lib.InstanceAssignInternalRequest(instance.id, instance.config.serialize("host")) + new lib.InstanceAssignInternalRequest(instance.id, instance.config.toRemote("host")) ); } } @@ -197,8 +197,8 @@ export default class HostConnection extends BaseConnection { // Assign instances the host has but controller does not const instanceUpdates: InstanceInfo[] = []; for (let instanceData of request.instances) { - let instanceConfig = new lib.InstanceConfig("controller"); - instanceConfig.load(instanceData.config as lib.SerializedConfig, "host"); + const instanceConfig = new lib.InstanceConfig("controller"); + instanceConfig.update(instanceData.config, false, "host"); let controllerInstance = this._controller.instances.get(instanceConfig.get("instance.id")); if (controllerInstance) { @@ -237,7 +237,7 @@ export default class HostConnection extends BaseConnection { this._controller.addInstanceHooks(newInstance); await this.send( new lib.InstanceAssignInternalRequest( - instanceConfig.get("instance.id"), instanceConfig.serialize("host") + instanceConfig.get("instance.id"), instanceConfig.toRemote("host") ) ); instanceUpdates.push(newInstance); diff --git a/packages/controller/src/InstanceInfo.ts b/packages/controller/src/InstanceInfo.ts index 320fa32c..e7db6cbf 100644 --- a/packages/controller/src/InstanceInfo.ts +++ b/packages/controller/src/InstanceInfo.ts @@ -18,30 +18,21 @@ export default class InstanceInfo { } static jsonSchema = Type.Object({ - "config": Type.Object({}), + "config": lib.InstanceConfig.jsonSchema, "status": lib.InstanceStatus, "gamePort": Type.Optional(Type.Number()), "updatedAt": Type.Optional(Type.Number()), }); - static fromJSON(json: Static, config: lib.InstanceConfig) { + static fromJSON(json: Static, location: lib.ConfigLocation) { return new this( - config, + lib.InstanceConfig.fromJSON(json.config, location), json.status, json.gamePort, json.updatedAt, ); } - toJSON(): Static { - return { - config: this.config.serialize(), - status: this.status, - gamePort: this.gamePort, - updatedAt: this.updatedAt, - }; - } - toInstanceDetails() { return new lib.InstanceDetails( this.config.get("instance.name"), diff --git a/packages/ctl/ctl.ts b/packages/ctl/ctl.ts index bf9e6175..8134964c 100755 --- a/packages/ctl/ctl.ts +++ b/packages/ctl/ctl.ts @@ -219,14 +219,15 @@ async function startControl() { ; logger.verbose(`Loading config from ${args.config}`); - let controlConfig = new lib.ControlConfig("control"); + let controlConfig; try { - controlConfig.load(JSON.parse(await fs.readFile(args.config, "utf8"))); + const jsonConfig = JSON.parse(await fs.readFile(args.config, "utf8")); + controlConfig = lib.ControlConfig.fromJSON(jsonConfig, "control"); } catch (err: any) { if (err.code === "ENOENT") { logger.verbose("Config not found, initializing new config"); - controlConfig.init(); + controlConfig = new lib.ControlConfig("control"); } else { throw new lib.StartupError(`Failed to load ${args.config}: ${err.message}`); diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 4cc5ca5c..9096680f 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -56,8 +56,8 @@ async function configToKeyVal(data: string) { return final; } -async function serializedConfigToString( - serializedConfig: any, +function serializedConfigToString( + serializedConfig: lib.SerializedConfig, configGroup: typeof lib.Config, disallowedList: Record, ) { @@ -91,9 +91,9 @@ const controllerConfigCommands = new lib.CommandTree({ controllerConfigCommands.add(new lib.Command({ definition: ["list", "List controller configuration"], handler: async function(args: object, control: Control) { - let response = await control.send(new lib.ControllerConfigGetRequest()); + let config = await control.send(new lib.ControllerConfigGetRequest()); - for (let group of (response.serializedConfig as any).groups) { + for (let group of config.groups) { for (let [name, value] of Object.entries(group.fields)) { print(`${group.name}.${name} ${JSON.stringify(value)}`); } @@ -156,15 +156,15 @@ controllerConfigCommands.add(new lib.Command({ }); }], handler: async function(args: { editor: string }, control: Control) { - let response = await control.send(new lib.ControllerConfigGetRequest()); + let config = await control.send(new lib.ControllerConfigGetRequest()); let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir()); let editor = await getEditor(args.editor); if (editor === undefined) { throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars Try "ctl controller config edit "`); } - let allConfigElements = await serializedConfigToString( - response.serializedConfig, lib.ControllerConfig, {} + let allConfigElements = serializedConfigToString( + config, lib.ControllerConfig, {} ); await fs.writeFile(tmpFile, allConfigElements); let editorSpawn = child_process.spawn(editor, [tmpFile], { @@ -272,11 +272,11 @@ hostCommands.add(new lib.Command({ args: { id?: number, name?: string, generateToken: boolean, output: string }, control: Control ) { - let rawConfig = await control.send( + let config = await control.send( new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken) ); - let content = JSON.stringify(rawConfig.serializedConfig, null, "\t"); + let content = JSON.stringify(config, null, "\t"); if (args.output === "-") { print(content); } else { @@ -315,12 +315,11 @@ instanceCommands.add(new lib.Command({ }], handler: async function(args: { name: string, id?: number }, control: Control) { let instanceConfig = new lib.InstanceConfig("control"); - instanceConfig.init(); if (args.id !== undefined) { instanceConfig.set("instance.id", args.id); } instanceConfig.set("instance.name", args.name); - let serializedConfig = instanceConfig.serialize("controller"); + const serializedConfig = instanceConfig.toRemote("controller"); await control.send(new lib.InstanceCreateRequest(serializedConfig)); }, })); @@ -334,9 +333,9 @@ instanceConfigCommands.add(new lib.Command({ }], handler: async function(args: { instance: string }, control: Control) { let instanceId = await lib.resolveInstance(control, args.instance); - let response = await control.send(new lib.InstanceConfigGetRequest(instanceId)); + let config = await control.send(new lib.InstanceConfigGetRequest(instanceId)); - for (let group of response.serializedConfig.groups) { + for (let group of config.groups) { for (let [name, value] of Object.entries(group.fields)) { print(`${group.name}.${name} ${JSON.stringify(value)}`); } @@ -427,7 +426,7 @@ instanceConfigCommands.add(new lib.Command({ }], handler: async function(args: { instance: string, editor: string }, control: Control) { let instanceId = await lib.resolveInstance(control, args.instance); - let response = await control.send(new lib.InstanceConfigGetRequest(instanceId)); + let config = await control.send(new lib.InstanceConfigGetRequest(instanceId)); let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir()); let editor = await getEditor(args.editor); if (editor === undefined) { @@ -435,8 +434,8 @@ instanceConfigCommands.add(new lib.Command({ Try "ctl controller config edit "`); } let disallowedList = {"instance.id": 0, "instance.assigned_host": 0, "factorio.settings": 0}; - let allConfigElements = await serializedConfigToString( - response.serializedConfig, + let allConfigElements = serializedConfigToString( + config, lib.InstanceConfig, disallowedList ); diff --git a/packages/host/host.ts b/packages/host/host.ts index 119183a5..867c1234 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -146,14 +146,15 @@ async function startHost() { lib.finalizeConfigs(); logger.info(`Loading config from ${args.config}`); - let hostConfig = new lib.HostConfig("host"); + let hostConfig; try { - hostConfig.load(JSON.parse(await fs.readFile(args.config, "utf8"))); + const jsonConfig = JSON.parse(await fs.readFile(args.config, "utf8")); + hostConfig = lib.HostConfig.fromJSON(jsonConfig, "host"); } catch (err: any) { if (err.code === "ENOENT") { logger.info("Config not found, initializing new config"); - hostConfig.init(); + hostConfig = new lib.HostConfig("host"); } else { throw new lib.StartupError(`Failed to load ${args.config}: ${err.message}`); diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index da047013..19a0c48f 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -47,7 +47,8 @@ async function discoverInstances(instancesDir: string) { let configPath = path.join(instancesDir, entry.name, "instance.json"); try { - instanceConfig.load(JSON.parse(await fs.readFile(configPath, "utf8"))); + const jsonConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + instanceConfig = lib.InstanceConfig.fromJSON(jsonConfig, "host"); } catch (err: any) { if (err.code === "ENOENT") { @@ -564,17 +565,17 @@ export default class Host extends lib.Link { let { instanceId, config } = request; let instanceInfo = this.instanceInfos.get(instanceId); if (instanceInfo) { - instanceInfo.config.update(config as any, true, "controller"); + instanceInfo.config.update(config, true, "controller"); logger.verbose(`Updated config for ${instanceInfo.path}`, this.instanceLogMeta(instanceId, instanceInfo)); } else { instanceInfo = this.discoveredInstanceInfos.get(instanceId); if (instanceInfo) { - instanceInfo.config.update(config as any, true, "controller"); + instanceInfo.config.update(config, true, "controller"); } else { let instanceConfig = new lib.InstanceConfig("host"); - instanceConfig.load(config as any, "controller"); + instanceConfig.update(config, false, "controller"); let instanceDir = await this._createNewInstanceDir(instanceConfig.get("instance.name")); @@ -608,7 +609,7 @@ export default class Host extends lib.Link { // save a copy of the instance config let warnedOutput = { _warning: "Changes to this file will be overwritten by the controller's copy.", - ...instanceInfo.config.serialize(), + ...instanceInfo.config.toJSON(), }; await lib.safeOutputFile( path.join(instanceInfo.path, "instance.json"), @@ -920,7 +921,7 @@ export default class Host extends lib.Link { for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) { let instanceConnection = this.instanceConnections.get(instanceId); list.push(new lib.RawInstanceInfo( - instanceInfo.config.serialize("controller"), + instanceInfo.config.toRemote("controller"), instanceConnection ? instanceConnection.instance.status : "stopped", instanceConnection ? instanceConnection.instance.server.gamePort : undefined, )); @@ -984,7 +985,7 @@ export default class Host extends lib.Link { } logger.info("Saving config"); - await lib.safeOutputFile(this.configPath, JSON.stringify(this.config.serialize(), null, "\t")); + await lib.safeOutputFile(this.configPath, JSON.stringify(this.config, null, "\t")); try { // Clear silly interval in pidfile library. diff --git a/packages/lib/src/config/classes.ts b/packages/lib/src/config/classes.ts index 4c7d6c96..b513cd89 100644 --- a/packages/lib/src/config/classes.ts +++ b/packages/lib/src/config/classes.ts @@ -4,9 +4,12 @@ import events from "events"; import isDeepStrictEqual from "../is_deep_strict_equal"; import { basicType } from "../helpers"; +import * as libSchema from "../schema"; +import { StringEnum } from "../data"; -export type ConfigLocation = "controller" | "host" | "instance" | "control"; +const ConfigLocation = StringEnum(["controller", "host", "instance", "control"]); +export type ConfigLocation = Static; /** * Invalid Access exception @@ -59,8 +62,18 @@ function splitOn(separator: string, string: string) { return [string.slice(0, index), string.slice(index + separator.length)]; } -export type SerializedConfig = ReturnType; -type SerializedGroup = ReturnType; + +const ConfigGroupSchema = Type.Object({ + "name": Type.String(), + "fields": Type.Record(Type.String(), Type.Unknown()), +}); +type SerializedGroup = Static; + +const ConfigSchema = Type.Object({ + "groups": Type.Array(ConfigGroupSchema), +}); +export type SerializedConfig = Static + /** * Collection of config groups @@ -80,10 +93,10 @@ export class Config extends events.EventEmitter { declare static _finalized: boolean; declare static _initialized: boolean; declare static _groups: Map; + declare ["constructor"]: typeof Config; - _initialized = false; _groups = new Map(); - _unknownGroups = new Map(); + _unknownGroups = new Map>(); /** Set to true when a field in the config is changed. */ dirty = false; @@ -93,66 +106,64 @@ export class Config extends events.EventEmitter { * * @param location - * Location to evaluate access for this instance from + * @param groups - + * Serialized representation of groups to load. */ constructor( public location: ConfigLocation, + groups: Static["groups"] = [], ) { - super(); - - if (!(this.constructor as typeof Config)._finalized) { - throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`); - } - if (typeof location !== "string") { throw new Error("location must be a string"); } - } + super(); - init() { - for (let [name, GroupClass] of (this.constructor as typeof Config)._groups) { - if (!this._groups.has(name)) { - let group = new GroupClass(this); - group.init(); - this._groups.set(name, group); - } - } - this._initialized = true; - } - - _validate(serializedConfig: SerializedConfig) { - if (basicType(serializedConfig) !== "object") { - throw new Error(`Expected object, not ${basicType(serializedConfig)} for config`); + if (!this.constructor._finalized) { + throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`); } - if (basicType(serializedConfig.groups) !== "array") { - throw new Error(`Expected groups to be an array, not ${basicType(serializedConfig.groups)}`); - } - } - - /** - * Load config from a serialized object - * - * @param serializedConfig - Serialized config to load. - * @param location - Location used for access control. - */ - load( - serializedConfig: SerializedConfig, - location = this.location - ) { - this._validate(serializedConfig); - for (let serializedGroup of serializedConfig.groups) { - let GroupClass = (this.constructor as typeof Config)._groups.get(serializedGroup.name); + for (let serializedGroup of groups) { + let GroupClass = this.constructor._groups.get(serializedGroup.name); if (!GroupClass) { this._unknownGroups.set(serializedGroup.name, serializedGroup); continue; } - let group = new GroupClass(this); - group.load(serializedGroup, location); + let group = GroupClass.fromJSON(serializedGroup, this); this._groups.set(group.name, group); } - this.init(); + for (let [name, GroupClass] of this.constructor._groups) { + if (!this._groups.has(name)) { + this._groups.set(name, new GroupClass(this)); + } + } + } + + static jsonSchema = ConfigSchema; + + static validate = libSchema.compile(this.jsonSchema as any); + + /** + * Create config from a serialized object + * + * @param json - Serialized config to load. + * @param location - Location used for access control. + * @returns Instance of this config + */ + static fromJSON( + json: Static, + location: ConfigLocation, + ) { + const valid = Config.validate(json); + if (!valid) { + throw new Error("Invalid config"); + } + return new this(location, json.groups); + } + + toJSON() { + return this.toRemote(this.location); } /** @@ -161,10 +172,10 @@ export class Config extends events.EventEmitter { * @param location - Location used for access control. * @returns JSON serializable representation of the config. */ - serialize(location = this.location) { - let groups: SerializedGroup[] = [...this._unknownGroups.values()]; + toRemote(location: ConfigLocation): Static { + let groups: Static[] = [...this._unknownGroups.values()]; for (let group of this._groups.values()) { - groups.push(group.serialize(location)); + groups.push(group.toRemote(location)); } return { groups }; @@ -176,16 +187,19 @@ export class Config extends events.EventEmitter { * Updates all groups in the config with groups in the serialized * config passed. * - * @param serializedConfig - Output from .serialize(). + * @param serializedConfig - Output from .toRemote(). * @param notify - Invoke fieldChanged events if true. * @param location - Location used for access control. */ update( - serializedConfig: SerializedConfig, + serializedConfig: Static, notify: boolean, location = this.location ) { - this._validate(serializedConfig); + const valid = Config.validate(serializedConfig); + if (!valid) { + throw new Error("Invalid config"); + } for (let serializedGroup of serializedConfig.groups) { let group = this._groups.get(serializedGroup.name); if (group) { @@ -252,9 +266,6 @@ export class Config extends events.EventEmitter { * @returns config group. */ group(name: string) { - 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}'`); @@ -321,32 +332,6 @@ export class Config extends events.EventEmitter { } } -// TODO: remove after config refactor -export class RawConfig { - constructor( - public serializedConfig: SerializedConfig - ) { } - - static jsonSchema = Type.Object({ - "serializedConfig": Type.Object({ - "groups": Type.Array( - Type.Object({ - "name": Type.String(), - "fields": Type.Record( - Type.String(), - Type.Unknown(), - ), - }), - ), - }), - }); - - static fromJSON(json: Static) { - return new this(json.serializedConfig); - } -} - - type FieldType = "boolean" | "string" | "number" | "object"; export interface FieldDefinition { type: FieldType; @@ -376,6 +361,7 @@ export class ConfigGroup { declare static _definitions: Map; declare static groupName: string; declare static defaultAccess: ConfigLocation[]; + declare ["constructor"]: typeof ConfigGroup; _fields = new Map(); _unknownFields = new Map(); @@ -384,18 +370,41 @@ export 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. - * * @param config - * Parent config for this group instance. + * @param serializedGroup - + * Serialized representation of group to load. */ constructor( - public config: Config + public config: Config, + serializedGroup?: Static, ) { - if (!(this.constructor as typeof ConfigGroup)._finalized) { + if (!this.constructor._finalized) { throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`); } + + if (serializedGroup) { + this.update(serializedGroup, false, config.location); + } + + for (let [name, def] of this.constructor._definitions) { + if (this._fields.has(name)) { + continue; + } + if (!def.access.includes(this.config.location)) { + continue; + } + + let value = null; + if (typeof def.initial_value === "function") { + value = def.initial_value(); + + } else if (def.initial_value !== undefined) { + value = def.initial_value; + } + + this._fields.set(name, value); + } } /** @@ -422,32 +431,7 @@ export class ConfigGroup { return true; } - /** - * Initialize new config group - * - * Computes and assigns the initial values for all fields of the - * config group. - */ - init() { - for (let [name, def] of (this.constructor as typeof ConfigGroup)._definitions) { - if (this._fields.has(name)) { - continue; - } - if (!def.access.includes(this.config.location)) { - continue; - } - - let value = null; - if (typeof def.initial_value === "function") { - value = def.initial_value(); - - } else if (def.initial_value !== undefined) { - value = def.initial_value; - } - - this._fields.set(name, value); - } - } + static jsonSchema = ConfigGroupSchema; /** * Load from serialized group @@ -456,13 +440,13 @@ export class ConfigGroup { * group and then initializes any possible missing fields from it to * their default values. * - * @param serializedGroup - - * Result from a previous call to .serialize(). - * @param location - Location used for access control. + * @param json - + * Result from a previous call to .toJSON(). + * @param config - Parent config for this group instance. + * @returns Instance of this group. */ - load(serializedGroup: SerializedGroup, location = this.config.location) { - this.update(serializedGroup, false, location); - this.init(); + static fromJSON(json: Static, config: Config) { + return new this(config, json); } /** @@ -471,7 +455,7 @@ export class ConfigGroup { * Shortcut for `group.constructor.groupName` */ get name() { - return (this.constructor as typeof ConfigGroup).groupName; + return this.constructor.groupName; } /** @@ -482,7 +466,7 @@ export class ConfigGroup { * @returns true if field is accessible */ canAccess(name: string, location = this.config.location) { - let def = (this.constructor as typeof ConfigGroup)._definitions.get(name); + let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } @@ -497,7 +481,7 @@ export class ConfigGroup { * @returns Value stored for the field. */ get(name: string, location = this.config.location) { - let def = (this.constructor as typeof ConfigGroup)._definitions.get(name); + let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } @@ -519,7 +503,7 @@ export class ConfigGroup { * @throws {InvalidValue} if value is not allowed for the field. */ set(name: string, value: unknown, location = this.config.location) { - let def = (this.constructor as typeof ConfigGroup)._definitions.get(name); + let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } @@ -588,7 +572,7 @@ export class ConfigGroup { * @throws {InvalidValue} if field is not an object. */ setProp(name: string, prop: string, value: unknown, location = this.config.location) { - let def = (this.constructor as typeof ConfigGroup)._definitions.get(name); + let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } @@ -620,12 +604,12 @@ export class ConfigGroup { * has a valid value in the passed serialized group with that value. * * @param serializedGroup - - * Result from a previous call to .serialize(). + * Result from a previous call to .toRemote(). * @param notify - Invoke fieldChanged events if true. * @param location - Location used for access control. */ update( - serializedGroup: SerializedGroup, + serializedGroup: Static, notify: boolean, location = this.config.location ) { @@ -642,7 +626,7 @@ export class ConfigGroup { } for (let [name, value] of Object.entries(serializedGroup.fields)) { - let def = (this.constructor as typeof ConfigGroup)._definitions.get(name); + let def = this.constructor._definitions.get(name); if (!def) { this._unknownFields.set(name, value); continue; @@ -674,16 +658,20 @@ export class ConfigGroup { } } + toJSON() { + return this.toRemote(this.config.location); + } + /** * Serialize the config to a plain JavaScript object * * @param location - Location used for access control. * @returns JSON serializable representation of the group. */ - serialize(location: ConfigLocation = this.config.location) { + toRemote(location: ConfigLocation): Static { let fields: Record = {}; for (let [name, value] of this._fields) { - let def = (this.constructor as typeof ConfigGroup)._definitions.get(name)!; + let def = this.constructor._definitions.get(name)!; if (!this._checkAccess(def, location, false)) { continue; } diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 098e8522..ad388a58 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -149,6 +149,7 @@ ControllerGroup.finalize(); * @extends classes.Config */ export class ControllerConfig extends classes.Config { + declare static fromJSON: (json: classes.SerializedConfig, location: classes.ConfigLocation) => ControllerConfig; get(name: "controller.name"): string; get(name: "controller.mods_directory"): string; get(name: "controller.database_directory"): string; @@ -255,6 +256,7 @@ HostGroup.finalize(); * @extends classes.Config */ export class HostConfig extends classes.Config { + declare static fromJSON: (json: classes.SerializedConfig, location: classes.ConfigLocation) => HostConfig; get(name: "host.name"): string; get(name: "host.id"): number; get(name: "host.factorio_directory"): string; @@ -433,6 +435,8 @@ FactorioGroup.finalize(); * @extends classes.Config */ export class InstanceConfig extends classes.Config { + declare static fromJSON: (json: classes.SerializedConfig, location: classes.ConfigLocation) => InstanceConfig; + get(name: "instance.name"): string; get(name: "instance.id"): number; get(name: "instance.assigned_host"): number | null; diff --git a/packages/lib/src/data/composites.ts b/packages/lib/src/data/composites.ts index c4f912ef..ccf28b9f 100644 --- a/packages/lib/src/data/composites.ts +++ b/packages/lib/src/data/composites.ts @@ -1,4 +1,4 @@ -import { Type } from "@sinclair/typebox"; +import { Static, TSchema, Type } from "@sinclair/typebox"; export function StringEnum(values: [...T]) { return Type.Unsafe({ type: "string", enum: values }); @@ -32,3 +32,12 @@ export function jsonPrimitive(type: U) { export const JsonBoolean = jsonPrimitive("boolean"); export const JsonNumber = jsonPrimitive("number"); export const JsonString = jsonPrimitive("string"); + +export function plainJson(schema: T) { + return { + jsonSchema: schema, + fromJSON(json: Static) { + return json; + }, + }; +} diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index 5a0aefc8..d4a7d6f5 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -1,7 +1,7 @@ import { Type, Static } from "@sinclair/typebox"; -import { JsonString, StringEnum } from "./composites"; +import { JsonString, StringEnum, plainJson } from "./composites"; import { levels } from "../logging"; -import { RawConfig } from "../config"; +import { ControllerConfig, HostConfig } from "../config"; export class ControllerConfigGetRequest { declare ["constructor"]: typeof ControllerConfigGetRequest; @@ -9,7 +9,7 @@ export class ControllerConfigGetRequest { static src = "control" as const; static dst = "controller" as const; static permission = "core.controller.get_config" as const; - static Response = RawConfig; + static Response = plainJson(ControllerConfig.jsonSchema); } export class ControllerConfigSetFieldRequest { @@ -103,7 +103,7 @@ export class HostConfigCreateRequest { return new this(json.id, json.name, json.generateToken); } - static Response = RawConfig; + static Response = plainJson(HostConfig.jsonSchema); } export class LogSetSubscriptionsRequest { diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index dbc32be1..00af08e7 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -1,7 +1,7 @@ import { Type, Static } from "@sinclair/typebox"; import PlayerStats from "./PlayerStats"; -import { JsonString, StringEnum, jsonArray } from "./composites"; -import { RawConfig } from "../config"; +import { JsonString, StringEnum, jsonArray, plainJson } from "./composites"; +import { InstanceConfig } from "../config"; import type { IControllerUser } from "./User"; import type { MessageRequest } from "./messages_core"; import { CollectorResultSerialized } from "../prometheus"; @@ -133,15 +133,14 @@ export class InstanceCreateRequest { static permission = "core.instance.create" as const; constructor( - public config: object, + public config: Static, ) { } static jsonSchema = Type.Object({ - "config": Type.Object({}), + "config": InstanceConfig.jsonSchema, }); static fromJSON(json: Static) { - // TODO deserialise config here after config refactor return new this(json.config); } } @@ -152,8 +151,7 @@ export class InstanceConfigGetRequest { static src = "control" as const; static dst = "controller" as const; static permission = "core.instance.get_config" as const; - // TODO replace with InstanceConfig after config refactor - static Response = RawConfig; + static Response = plainJson(InstanceConfig.jsonSchema); constructor( public instanceId: number, @@ -716,15 +714,14 @@ export class InstanceSendRconRequest { // TODO remove this after config refactor export class RawInstanceInfo { - constructor( - public config: object, + public config: Static, public status: InstanceStatus, public gamePort: undefined | number, ) { } static jsonSchema = Type.Object({ - "config": Type.Object({}), + "config": InstanceConfig.jsonSchema, "status": StringEnum([ "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", ]), @@ -765,16 +762,15 @@ export class InstanceAssignInternalRequest { constructor( public instanceId: number, - public config: object, + public config: Static, ) { } static jsonSchema = Type.Object({ "instanceId": Type.Integer(), - "config": Type.Object({}), + "config": InstanceConfig.jsonSchema, }); static fromJSON(json: Static) { - // TODO deserialise config here after config refactor return new this(json.instanceId, json.config); } } diff --git a/packages/lib/src/shared_commands.ts b/packages/lib/src/shared_commands.ts index 801f54e0..fdc5a251 100644 --- a/packages/lib/src/shared_commands.ts +++ b/packages/lib/src/shared_commands.ts @@ -162,7 +162,7 @@ export async function handleConfigCommand( try { instance.set(args.field as string, args.value); - await libFileOps.safeOutputFile(configPath, JSON.stringify(instance.serialize(), null, "\t")); + await libFileOps.safeOutputFile(configPath, JSON.stringify(instance, null, "\t")); } catch (err) { if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) { logger.error(err.message); diff --git a/packages/web_ui/src/components/BaseConfigTree.tsx b/packages/web_ui/src/components/BaseConfigTree.tsx index 0a92fcb8..a3dcd638 100644 --- a/packages/web_ui/src/components/BaseConfigTree.tsx +++ b/packages/web_ui/src/components/BaseConfigTree.tsx @@ -48,8 +48,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { async function updateConfig() { let serializedConfig = await props.retrieveConfig(); - let newConfig = new props.ConfigClass("control"); - newConfig.load(serializedConfig); + const newConfig = props.ConfigClass.fromJSON(serializedConfig, "control"); setConfig(newConfig); return newConfig; } @@ -197,12 +196,11 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {