-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #63 from Gnuxie/gnuxie/config-properties `Persiste…
…ntConfigData` `PersistentConfigData` with recovery options for deserialization and usage errors. the-draupnir-project/planning#1
- Loading branch information
Showing
9 changed files
with
763 additions
and
9 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
// SPDX-FileCopyrightText: 2024 Gnuxie <[email protected]> | ||
// | ||
// SPDX-License-Identifier: AFL-3.0 | ||
|
||
import { TObject, TProperties, TSchema } from '@sinclair/typebox'; | ||
import { EDStatic } from '../Interface/Static'; | ||
import { Ok, Result } from '@gnuxie/typescript-result'; | ||
import { Value as TBValue } from '@sinclair/typebox/value'; | ||
import { ConfigParseError, ConfigPropertyError } from './ConfigParseError'; | ||
import { ConfigMirror, StandardConfigMirror } from './ConfigMirror'; | ||
|
||
type StaticProperties<T extends TSchema, P extends unknown[] = []> = (T & { | ||
params: P; | ||
})['params']; | ||
|
||
export type UnknownProperties<T extends TSchema> = { | ||
[K in keyof StaticProperties<T>]: unknown; | ||
}; | ||
|
||
export type ConfigPropertyDescription = { | ||
path: string; | ||
name: string; | ||
description: string | undefined; | ||
default: unknown; | ||
}; | ||
|
||
export type ConfigDescription<TConfigSchema extends TObject> = { | ||
readonly schema: TConfigSchema; | ||
parseConfig( | ||
config: unknown | ||
): Result<EDStatic<TConfigSchema>, ConfigParseError>; | ||
parseJSONConfig(config: unknown): Result<UnknownProperties<TConfigSchema>>; | ||
properties(): ConfigPropertyDescription[]; | ||
getPropertyDescription(key: string): ConfigPropertyDescription; | ||
toMirror(): ConfigMirror<TConfigSchema>; | ||
getDefaultConfig(): EDStatic<TConfigSchema>; | ||
}; | ||
|
||
export class StandardConfigDescription<TConfigSchema extends TObject> | ||
implements ConfigDescription<TConfigSchema> | ||
{ | ||
constructor(public readonly schema: TConfigSchema) {} | ||
|
||
public parseConfig( | ||
config: unknown | ||
): Result<EDStatic<TConfigSchema>, ConfigParseError> { | ||
const withDefaults = TBValue.Default(this.schema, config); | ||
const errors = [...TBValue.Errors(this.schema, config)]; | ||
if (errors.length > 0) { | ||
return ConfigParseError.Result('Unable to parse this config', { | ||
errors: errors.map( | ||
(error) => | ||
new ConfigPropertyError(error.message, error.path, error.value) | ||
), | ||
}); | ||
} else { | ||
return Ok(withDefaults as EDStatic<TConfigSchema>); | ||
} | ||
} | ||
|
||
public parseJSONConfig( | ||
config: unknown | ||
): Result<UnknownProperties<TConfigSchema>> { | ||
return Ok(config as UnknownProperties<TConfigSchema>); | ||
} | ||
|
||
public properties(): ConfigPropertyDescription[] { | ||
return Object.entries(this.schema.properties).map(([name, schema]) => ({ | ||
name, | ||
path: '/' + name, | ||
description: schema.description, | ||
default: schema.default as unknown, | ||
})); | ||
} | ||
|
||
public getPropertyDescription(key: string): ConfigPropertyDescription { | ||
const schema = this.schema.properties[key as keyof TProperties]; | ||
if (schema === undefined) { | ||
throw new TypeError(`Property ${key} does not exist on this schema`); | ||
} | ||
return { | ||
name: key, | ||
path: '/' + key, | ||
description: schema.description, | ||
default: schema.default as unknown, | ||
}; | ||
} | ||
|
||
public toMirror(): ConfigMirror<TConfigSchema> { | ||
return new StandardConfigMirror(this); | ||
} | ||
public getDefaultConfig(): EDStatic<TConfigSchema> { | ||
return TBValue.Default(this.schema, {}) as EDStatic<TConfigSchema>; | ||
} | ||
} |
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,167 @@ | ||
// SPDX-FileCopyrightText: 2024 Gnuxie <[email protected]> | ||
// | ||
// SPDX-License-Identifier: AFL-3.0 | ||
|
||
import { | ||
Evaluate, | ||
StaticDecode, | ||
TArray, | ||
TObject, | ||
TProperties, | ||
} from '@sinclair/typebox'; | ||
import { ConfigDescription } from './ConfigDescription'; | ||
import { EDStatic } from '../Interface/Static'; | ||
import { ConfigPropertyError } from './ConfigParseError'; | ||
import { Ok, Result } from '@gnuxie/typescript-result'; | ||
import { Value as TBValue } from '@sinclair/typebox/value'; | ||
|
||
export interface ConfigMirror<TSchema extends TObject> { | ||
readonly description: ConfigDescription<TSchema>; | ||
setValue( | ||
config: EDStatic<TSchema>, | ||
key: keyof EDStatic<TSchema>, | ||
value: unknown | ||
): Result<EDStatic<TSchema>, ConfigPropertyError>; | ||
addItem( | ||
config: EDStatic<TSchema>, | ||
key: keyof EDStatic<TSchema>, | ||
value: unknown | ||
): Result<EDStatic<TSchema>, ConfigPropertyError>; | ||
// needed for when additionalProperties is true. | ||
removeProperty<TKey extends string>( | ||
key: TKey, | ||
config: Record<TKey, unknown> | ||
): Record<TKey, unknown>; | ||
removeItem<TKey extends string>( | ||
config: Record<TKey, unknown[]>, | ||
key: TKey, | ||
index: number | ||
): Record<TKey, unknown[]>; | ||
filterItems<TKey extends string>( | ||
config: Record<TKey, unknown[]>, | ||
key: TKey, | ||
callbackFn: Parameters<Array<unknown>['filter']>[0] | ||
): Record<TKey, unknown[]>; | ||
} | ||
|
||
export class StandardConfigMirror<TSchema extends TObject> | ||
implements ConfigMirror<TSchema> | ||
{ | ||
public constructor(public readonly description: ConfigDescription<TSchema>) { | ||
// nothing to do. | ||
} | ||
setValue( | ||
config: Evaluate<StaticDecode<TSchema>>, | ||
key: keyof Evaluate<StaticDecode<TSchema>>, | ||
value: unknown | ||
): Result<Evaluate<StaticDecode<TSchema>>, ConfigPropertyError> { | ||
const schema = this.description.schema.properties[key as keyof TProperties]; | ||
if (schema === undefined) { | ||
throw new TypeError( | ||
`Property ${key.toString()} does not exist in schema` | ||
); | ||
} | ||
const errors = [...TBValue.Errors(schema, value)]; | ||
if (errors[0] !== undefined) { | ||
return ConfigPropertyError.Result(errors[0].message, { | ||
path: `/${key.toString()}`, | ||
value, | ||
}); | ||
} | ||
const newConfig = { | ||
...config, | ||
[key]: TBValue.Decode(schema, value), | ||
}; | ||
return Ok(newConfig as EDStatic<TSchema>); | ||
} | ||
private addUnparsedItem( | ||
config: Evaluate<StaticDecode<TSchema>>, | ||
key: keyof Evaluate<StaticDecode<TSchema>>, | ||
value: unknown | ||
): Evaluate<StaticDecode<TSchema>> { | ||
const schema = this.description.schema.properties[key as keyof TProperties]; | ||
if (schema === undefined) { | ||
throw new TypeError( | ||
`Property ${key.toString()} does not exist in schema` | ||
); | ||
} | ||
if (!('items' in schema)) { | ||
throw new TypeError(`Property ${key.toString()} is not an array`); | ||
} | ||
const isSet = 'uniqueItems' in schema && schema.uniqueItems === true; | ||
if (isSet) { | ||
const set = new Set(config[key] as unknown[]); | ||
set.add(TBValue.Decode((schema as TArray).items, value)); | ||
return { | ||
...config, | ||
[key]: [...set], | ||
}; | ||
} else { | ||
return { | ||
...config, | ||
[key]: [...(config[key] as unknown[]), TBValue.Decode(schema, value)], | ||
}; | ||
} | ||
} | ||
addItem( | ||
config: Evaluate<StaticDecode<TSchema>>, | ||
key: keyof Evaluate<StaticDecode<TSchema>>, | ||
value: unknown | ||
): Result<Evaluate<StaticDecode<TSchema>>, ConfigPropertyError> { | ||
const schema = this.description.schema.properties[key as keyof TProperties]; | ||
if (schema === undefined) { | ||
throw new TypeError( | ||
`Property ${key.toString()} does not exist in schema` | ||
); | ||
} | ||
const currentItems = config[key]; | ||
if (!Array.isArray(currentItems)) { | ||
throw new TypeError(`Property ${key.toString()} is not an array`); | ||
} | ||
const errors = [ | ||
...TBValue.Errors(schema, [...(config[key] as unknown[]), value]), | ||
]; | ||
if (errors[0] !== undefined) { | ||
return ConfigPropertyError.Result(errors[0].message, { | ||
path: `/${key.toString()}${errors[0].path}`, | ||
value, | ||
}); | ||
} | ||
return Ok(this.addUnparsedItem(config, key, value)); | ||
} | ||
removeProperty<TKey extends string>( | ||
key: TKey, | ||
config: Record<TKey, unknown> | ||
): Record<string, unknown> { | ||
return Object.entries(config).reduce<Record<string, unknown>>( | ||
(acc, [k, v]) => { | ||
if (k !== key) { | ||
acc[k as TKey] = v; | ||
} | ||
return acc; | ||
}, | ||
{} | ||
); | ||
} | ||
removeItem<TKey extends string>( | ||
config: Record<TKey, unknown[]>, | ||
key: TKey, | ||
index: number | ||
): Record<TKey, unknown[]> { | ||
return { | ||
...config, | ||
[key]: config[key].filter((_, i) => i !== index), | ||
}; | ||
} | ||
|
||
filterItems<TKey extends string>( | ||
config: Record<TKey, unknown[]>, | ||
key: TKey, | ||
callbackFn: Parameters<Array<unknown>['filter']>[0] | ||
): Record<TKey, unknown[]> { | ||
return { | ||
...config, | ||
[key]: config[key].filter(callbackFn), | ||
}; | ||
} | ||
} |
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,114 @@ | ||
// SPDX-FileCopyrightText: 2024 Gnuxie <[email protected]> | ||
// | ||
// SPDX-License-Identifier: AFL-3.0 | ||
|
||
import { Err, ResultError } from '@gnuxie/typescript-result'; | ||
import { ConfigRecoveryOption } from './PersistentConfigData'; | ||
|
||
export class ConfigRecoverableError extends ResultError { | ||
public readonly recoveryOptions: ConfigRecoveryOption[] = []; | ||
|
||
addRecoveryOptions(options: ConfigRecoveryOption[]): this { | ||
this.recoveryOptions.push(...options); | ||
return this; | ||
} | ||
} | ||
|
||
// others that could be missing: Missing porperties, completely different schema? | ||
// We call them problematic because we can get errors once they are used too rather | ||
// than just during parsing. | ||
export enum ConfigErrorDiagnosis { | ||
ProblematicValue = 'ProblematicValue', | ||
ProblematicArrayItem = 'ProblematicArrayItem', | ||
} | ||
|
||
export class ConfigParseError extends ConfigRecoverableError { | ||
constructor( | ||
message: string, | ||
public readonly errors: ConfigPropertyError[] | ||
) { | ||
super(message); | ||
} | ||
|
||
public static Result( | ||
message: string, | ||
options: { errors: ConfigPropertyError[] } | ||
) { | ||
return Err(new ConfigParseError(message, options.errors)); | ||
} | ||
} | ||
|
||
// This doesn't have to appear just during parsing, it can appear | ||
// later on while processing the configuration file to display a problem | ||
// with a particular property. | ||
export class ConfigPropertyError extends ConfigRecoverableError { | ||
public readonly diagnosis: ConfigErrorDiagnosis; | ||
constructor( | ||
message: string, | ||
public readonly path: string, | ||
public readonly value: unknown | ||
) { | ||
super(message); | ||
if (/\d+$/.test(path)) { | ||
this.diagnosis = ConfigErrorDiagnosis.ProblematicArrayItem; | ||
} else { | ||
this.diagnosis = ConfigErrorDiagnosis.ProblematicValue; | ||
} | ||
} | ||
|
||
public static Result( | ||
message: string, | ||
options: { path: string; value: unknown } | ||
) { | ||
return Err(new ConfigPropertyError(message, options.path, options.value)); | ||
} | ||
|
||
public toReadableString(): string { | ||
return `Property at ${this.path} has the following diagnosis: ${this.diagnosis}, problem: ${this.message}, and value: ${String(this.value)}`; | ||
} | ||
|
||
public itemIndex(): number { | ||
const match = this.path.match(/\/(\d+)$/)?.[1]; | ||
if (match === undefined) { | ||
throw new TypeError('Invalid path was given to ConfigPropertyError'); | ||
} | ||
return parseInt(match, 10); | ||
} | ||
|
||
public topLevelProperty(): string { | ||
const key = this.path.split('/')[1]; | ||
if (key === undefined) { | ||
throw new TypeError('Invalid path was given to ConfigPropertyError'); | ||
} | ||
return key; | ||
} | ||
} | ||
|
||
export class ConfigPropertyUseError extends ConfigPropertyError { | ||
constructor( | ||
message: string, | ||
path: string, | ||
value: unknown, | ||
public readonly cause: ResultError | ||
) { | ||
super(message, path, value); | ||
} | ||
|
||
public static Result( | ||
message: string, | ||
options: { path: string; value: unknown; cause: ResultError } | ||
) { | ||
return Err( | ||
new ConfigPropertyUseError( | ||
message, | ||
options.path, | ||
options.value, | ||
options.cause | ||
) | ||
); | ||
} | ||
|
||
public toReadableString(): string { | ||
return `${super.toReadableString()}\ncaused by: ${this.cause.toReadableString()}`; | ||
} | ||
} |
Oops, something went wrong.