-
-
Notifications
You must be signed in to change notification settings - Fork 351
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Extracts config.json into its own module (#1061)
This adds a config file that's loaded very early on during startup. It enables us to save/load settings from within Grist's admin panel, that affect the startup of the FlexServer. The config file loading: - Is type-safe, - Validates the config file on startup - Provides a path to upgrade to future versions. It should be extensible from other versions of Grist (such as desktop), by overriding `getGlobalConfig` in stubs. ---- Some minor refactors needed to occur to make this possible. This includes: - Extracting config loading into its own module (out of FlexServer). - Cleaning up the `loadConfig` function in FlexServer into `loadLoginSystem` (which is what its main purpose was before).
- Loading branch information
Showing
14 changed files
with
484 additions
and
33 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
import * as fse from "fs-extra"; | ||
|
||
// Export dependencies for stubbing in tests. | ||
export const Deps = { | ||
readFile: fse.readFile, | ||
writeFile: fse.writeFile, | ||
pathExists: fse.pathExists, | ||
}; | ||
|
||
/** | ||
* Readonly config value - no write access. | ||
*/ | ||
export interface IReadableConfigValue<T> { | ||
get(): T; | ||
} | ||
|
||
/** | ||
* Writeable config value. Write behaviour is asynchronous and defined by the implementation. | ||
*/ | ||
export interface IWritableConfigValue<T> extends IReadableConfigValue<T> { | ||
set(value: T): Promise<void>; | ||
} | ||
|
||
type FileContentsValidator<T> = (value: any) => T | null; | ||
|
||
export class MissingConfigFileError extends Error { | ||
public name: string = "MissingConfigFileError"; | ||
|
||
constructor(message: string) { | ||
super(message); | ||
} | ||
} | ||
|
||
export class ConfigValidationError extends Error { | ||
public name: string = "ConfigValidationError"; | ||
|
||
constructor(message: string) { | ||
super(message); | ||
} | ||
} | ||
|
||
export interface ConfigAccessors<ValueType> { | ||
get: () => ValueType, | ||
set?: (value: ValueType) => Promise<void> | ||
} | ||
|
||
/** | ||
* Provides type safe access to an underlying JSON file. | ||
* | ||
* Multiple FileConfigs for the same file shouldn't be used, as they risk going out of sync. | ||
*/ | ||
export class FileConfig<FileContents> { | ||
/** | ||
* Creates a new type-safe FileConfig, by loading and checking the contents of the file with `validator`. | ||
* @param configPath - Path to load. | ||
* @param validator - Validates the contents are in the correct format, and converts to the correct type. | ||
* Should throw an error or return null if not valid. | ||
*/ | ||
public static async create<CreateConfigFileContents>( | ||
configPath: string, | ||
validator: FileContentsValidator<CreateConfigFileContents> | ||
): Promise<FileConfig<CreateConfigFileContents>> { | ||
// Start with empty object, as it can be upgraded to a full config. | ||
let rawFileContents: any = {}; | ||
|
||
if (await Deps.pathExists(configPath)) { | ||
rawFileContents = JSON.parse(await Deps.readFile(configPath, 'utf8')); | ||
} | ||
|
||
let fileContents = null; | ||
|
||
try { | ||
fileContents = validator(rawFileContents); | ||
} catch (error) { | ||
const configError = | ||
new ConfigValidationError(`Config at ${configPath} failed validation: ${error.message}`); | ||
configError.cause = error; | ||
throw configError; | ||
} | ||
|
||
if (!fileContents) { | ||
throw new ConfigValidationError(`Config at ${configPath} failed validation - check the format?`); | ||
} | ||
|
||
return new FileConfig<CreateConfigFileContents>(configPath, fileContents); | ||
} | ||
|
||
constructor(private _filePath: string, private _rawConfig: FileContents) { | ||
} | ||
|
||
public get<Key extends keyof FileContents>(key: Key): FileContents[Key] { | ||
return this._rawConfig[key]; | ||
} | ||
|
||
public async set<Key extends keyof FileContents>(key: Key, value: FileContents[Key]) { | ||
this._rawConfig[key] = value; | ||
await this.persistToDisk(); | ||
} | ||
|
||
public async persistToDisk(): Promise<void> { | ||
await Deps.writeFile(this._filePath, JSON.stringify(this._rawConfig, null, 2)); | ||
} | ||
} | ||
|
||
/** | ||
* Creates a function for creating accessors for a given key. | ||
* Propagates undefined values, so if no file config is available, accessors are undefined. | ||
* @param fileConfig - Config to load/save values to. | ||
*/ | ||
export function fileConfigAccessorFactory<FileContents>( | ||
fileConfig?: FileConfig<FileContents> | ||
): <Key extends keyof FileContents>(key: Key) => ConfigAccessors<FileContents[Key]> | undefined | ||
{ | ||
if (!fileConfig) { return (key) => undefined; } | ||
return (key) => ({ | ||
get: () => fileConfig.get(key), | ||
set: (value) => fileConfig.set(key, value) | ||
}); | ||
} | ||
|
||
/** | ||
* Creates a config value optionally backed by persistent storage. | ||
* Can be used as an in-memory value without persistent storage. | ||
* @param defaultValue - Value to use if no persistent value is available. | ||
* @param persistence - Accessors for saving/loading persistent value. | ||
*/ | ||
export function createConfigValue<ValueType>( | ||
defaultValue: ValueType, | ||
persistence?: ConfigAccessors<ValueType> | ConfigAccessors<ValueType | undefined>, | ||
): IWritableConfigValue<ValueType> { | ||
let inMemoryValue = (persistence && persistence.get()); | ||
return { | ||
get(): ValueType { | ||
return inMemoryValue ?? defaultValue; | ||
}, | ||
async set(value: ValueType) { | ||
if (persistence && persistence.set) { | ||
await persistence.set(value); | ||
} | ||
inMemoryValue = value; | ||
} | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
import { | ||
createConfigValue, | ||
FileConfig, | ||
fileConfigAccessorFactory, | ||
IWritableConfigValue | ||
} from "./config"; | ||
import { convertToCoreFileContents, IGristCoreConfigFileLatest } from "./configCoreFileFormats"; | ||
|
||
export type Edition = "core" | "enterprise"; | ||
|
||
/** | ||
* Config options for Grist Core. | ||
*/ | ||
export interface IGristCoreConfig { | ||
edition: IWritableConfigValue<Edition>; | ||
} | ||
|
||
export async function loadGristCoreConfigFile(configPath?: string): Promise<IGristCoreConfig> { | ||
const fileConfig = configPath ? await FileConfig.create(configPath, convertToCoreFileContents) : undefined; | ||
return loadGristCoreConfig(fileConfig); | ||
} | ||
|
||
export function loadGristCoreConfig(fileConfig?: FileConfig<IGristCoreConfigFileLatest>): IGristCoreConfig { | ||
const fileConfigValue = fileConfigAccessorFactory(fileConfig); | ||
return { | ||
edition: createConfigValue<Edition>("core", fileConfigValue("edition")) | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
/** | ||
* This module was automatically generated by `ts-interface-builder` | ||
*/ | ||
import * as t from "ts-interface-checker"; | ||
// tslint:disable:object-literal-key-quotes | ||
|
||
export const IGristCoreConfigFileLatest = t.name("IGristCoreConfigFileV1"); | ||
|
||
export const IGristCoreConfigFileV1 = t.iface([], { | ||
"version": t.lit("1"), | ||
"edition": t.opt(t.union(t.lit("core"), t.lit("enterprise"))), | ||
}); | ||
|
||
export const IGristCoreConfigFileV0 = t.iface([], { | ||
"version": "undefined", | ||
}); | ||
|
||
const exportedTypeSuite: t.ITypeSuite = { | ||
IGristCoreConfigFileLatest, | ||
IGristCoreConfigFileV1, | ||
IGristCoreConfigFileV0, | ||
}; | ||
export default exportedTypeSuite; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import configCoreTI from './configCoreFileFormats-ti'; | ||
import { CheckerT, createCheckers } from "ts-interface-checker"; | ||
|
||
/** | ||
* Latest core config file format | ||
*/ | ||
export type IGristCoreConfigFileLatest = IGristCoreConfigFileV1; | ||
|
||
/** | ||
* Format of config files on disk - V1 | ||
*/ | ||
export interface IGristCoreConfigFileV1 { | ||
version: "1" | ||
edition?: "core" | "enterprise" | ||
} | ||
|
||
/** | ||
* Format of config files on disk - V0 | ||
*/ | ||
export interface IGristCoreConfigFileV0 { | ||
version: undefined; | ||
} | ||
|
||
export const checkers = createCheckers(configCoreTI) as | ||
{ | ||
IGristCoreConfigFileV0: CheckerT<IGristCoreConfigFileV0>, | ||
IGristCoreConfigFileV1: CheckerT<IGristCoreConfigFileV1>, | ||
IGristCoreConfigFileLatest: CheckerT<IGristCoreConfigFileLatest>, | ||
}; | ||
|
||
function upgradeV0toV1(config: IGristCoreConfigFileV0): IGristCoreConfigFileV1 { | ||
return { | ||
...config, | ||
version: "1", | ||
}; | ||
} | ||
|
||
export function convertToCoreFileContents(input: any): IGristCoreConfigFileLatest | null { | ||
if (!(input instanceof Object)) { | ||
return null; | ||
} | ||
|
||
let configObject = { ...input }; | ||
|
||
if (checkers.IGristCoreConfigFileV0.test(configObject)) { | ||
configObject = upgradeV0toV1(configObject); | ||
} | ||
|
||
// This will throw an exception if the config object is still not in the correct format. | ||
checkers.IGristCoreConfigFileLatest.check(configObject); | ||
|
||
return configObject; | ||
} |
Oops, something went wrong.