Skip to content

Commit

Permalink
Merge pull request #63 from Gnuxie/gnuxie/config-properties `Persiste…
Browse files Browse the repository at this point in the history
…ntConfigData`

`PersistentConfigData` with recovery options for deserialization and usage errors. 
the-draupnir-project/planning#1
  • Loading branch information
Gnuxie authored Sep 30, 2024
2 parents 35c8636 + 3ed4cc5 commit 5886d40
Show file tree
Hide file tree
Showing 9 changed files with 763 additions and 9 deletions.
95 changes: 95 additions & 0 deletions src/Config/ConfigDescription.ts
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>;
}
}
167 changes: 167 additions & 0 deletions src/Config/ConfigMirror.ts
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),
};
}
}
114 changes: 114 additions & 0 deletions src/Config/ConfigParseError.ts
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()}`;
}
}
Loading

0 comments on commit 5886d40

Please sign in to comment.