@next-box/appsettings 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +182 -2
- package/bin/appsettings.mjs +7 -0
- package/dist/cjs/cli/cli/command.cjs +2 -0
- package/dist/cjs/cli/cli/command.cjs.map +1 -0
- package/dist/cjs/cli/cli/handler.cjs +2 -0
- package/dist/cjs/cli/cli/handler.cjs.map +1 -0
- package/dist/cjs/cli/cli/index.cjs +2 -0
- package/dist/cjs/cli/cli/index.cjs.map +1 -0
- package/dist/cjs/components/AppSettingsProvider.cjs +3 -0
- package/dist/cjs/components/AppSettingsProvider.cjs.map +1 -0
- package/dist/cjs/components/Setting.cjs +3 -0
- package/dist/cjs/components/Setting.cjs.map +1 -0
- package/dist/cjs/config/AppSettingsImplementation.cjs +2 -0
- package/dist/cjs/config/AppSettingsImplementation.cjs.map +1 -0
- package/dist/cjs/config/ConfigReader.cjs +2 -0
- package/dist/cjs/config/ConfigReader.cjs.map +1 -0
- package/dist/cjs/config/ConfigStore.cjs +2 -0
- package/dist/cjs/config/ConfigStore.cjs.map +1 -0
- package/dist/cjs/config/ScopedSettingsImplementation.cjs +2 -0
- package/dist/cjs/config/ScopedSettingsImplementation.cjs.map +1 -0
- package/dist/cjs/config/helpers/freezeDeep.cjs +2 -0
- package/dist/cjs/config/helpers/freezeDeep.cjs.map +1 -0
- package/dist/cjs/hooks/useAppSetting.cjs +2 -0
- package/dist/cjs/hooks/useAppSetting.cjs.map +1 -0
- package/dist/cjs/hooks/useAppSettings.cjs +2 -0
- package/dist/cjs/hooks/useAppSettings.cjs.map +1 -0
- package/dist/cjs/hooks/useAppSettingsContext.cjs +2 -0
- package/dist/cjs/hooks/useAppSettingsContext.cjs.map +1 -0
- package/dist/cjs/index.cjs +2 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/cjs/server/config/ConfigMerger.cjs +2 -0
- package/dist/cjs/server/config/ConfigMerger.cjs.map +1 -0
- package/dist/cjs/server/config/ConfigValidator.cjs +2 -0
- package/dist/cjs/server/config/ConfigValidator.cjs.map +1 -0
- package/dist/cjs/server/config/createAppSettings.cjs +2 -0
- package/dist/cjs/server/config/createAppSettings.cjs.map +1 -0
- package/dist/cjs/server/server.cjs +2 -0
- package/dist/cjs/server/server.cjs.map +1 -0
- package/dist/cjs/server/sources/AppSettingsSource.cjs +2 -0
- package/dist/cjs/server/sources/AppSettingsSource.cjs.map +1 -0
- package/dist/cjs/server/sources/EnvironmentSource.cjs +2 -0
- package/dist/cjs/server/sources/EnvironmentSource.cjs.map +1 -0
- package/dist/cjs/server/sources/MemorySource.cjs +2 -0
- package/dist/cjs/server/sources/MemorySource.cjs.map +1 -0
- package/dist/cjs/server/sources/RemoteSource.cjs +2 -0
- package/dist/cjs/server/sources/RemoteSource.cjs.map +1 -0
- package/dist/cjs/server/sources/helpers/loadJson.cjs +2 -0
- package/dist/cjs/server/sources/helpers/loadJson.cjs.map +1 -0
- package/dist/esm/cli/cli/command.mjs +2 -0
- package/dist/esm/cli/cli/command.mjs.map +1 -0
- package/dist/esm/cli/cli/handler.mjs +2 -0
- package/dist/esm/cli/cli/handler.mjs.map +1 -0
- package/dist/esm/cli/cli/index.mjs +2 -0
- package/dist/esm/cli/cli/index.mjs.map +1 -0
- package/dist/esm/components/AppSettingsProvider.mjs +3 -0
- package/dist/esm/components/AppSettingsProvider.mjs.map +1 -0
- package/dist/esm/components/Setting.mjs +3 -0
- package/dist/esm/components/Setting.mjs.map +1 -0
- package/dist/esm/config/AppSettingsImplementation.mjs +2 -0
- package/dist/esm/config/AppSettingsImplementation.mjs.map +1 -0
- package/dist/esm/config/ConfigReader.mjs +2 -0
- package/dist/esm/config/ConfigReader.mjs.map +1 -0
- package/dist/esm/config/ConfigStore.mjs +2 -0
- package/dist/esm/config/ConfigStore.mjs.map +1 -0
- package/dist/esm/config/ScopedSettingsImplementation.mjs +2 -0
- package/dist/esm/config/ScopedSettingsImplementation.mjs.map +1 -0
- package/dist/esm/config/helpers/freezeDeep.mjs +2 -0
- package/dist/esm/config/helpers/freezeDeep.mjs.map +1 -0
- package/dist/esm/hooks/useAppSetting.mjs +2 -0
- package/dist/esm/hooks/useAppSetting.mjs.map +1 -0
- package/dist/esm/hooks/useAppSettings.mjs +2 -0
- package/dist/esm/hooks/useAppSettings.mjs.map +1 -0
- package/dist/esm/hooks/useAppSettingsContext.mjs +2 -0
- package/dist/esm/hooks/useAppSettingsContext.mjs.map +1 -0
- package/dist/esm/index.mjs +2 -0
- package/dist/esm/index.mjs.map +1 -0
- package/dist/esm/server/config/ConfigMerger.mjs +2 -0
- package/dist/esm/server/config/ConfigMerger.mjs.map +1 -0
- package/dist/esm/server/config/ConfigValidator.mjs +2 -0
- package/dist/esm/server/config/ConfigValidator.mjs.map +1 -0
- package/dist/esm/server/config/createAppSettings.mjs +2 -0
- package/dist/esm/server/config/createAppSettings.mjs.map +1 -0
- package/dist/esm/server/server.mjs +2 -0
- package/dist/esm/server/server.mjs.map +1 -0
- package/dist/esm/server/sources/AppSettingsSource.mjs +2 -0
- package/dist/esm/server/sources/AppSettingsSource.mjs.map +1 -0
- package/dist/esm/server/sources/EnvironmentSource.mjs +2 -0
- package/dist/esm/server/sources/EnvironmentSource.mjs.map +1 -0
- package/dist/esm/server/sources/MemorySource.mjs +2 -0
- package/dist/esm/server/sources/MemorySource.mjs.map +1 -0
- package/dist/esm/server/sources/RemoteSource.mjs +2 -0
- package/dist/esm/server/sources/RemoteSource.mjs.map +1 -0
- package/dist/esm/server/sources/helpers/loadJson.mjs +2 -0
- package/dist/esm/server/sources/helpers/loadJson.mjs.map +1 -0
- package/dist/production.analysis.txt +89 -0
- package/dist/types/cjs/__testUtils__/components/ErrorBoundary.d.cts +14 -0
- package/dist/types/cjs/__testUtils__/components/ErrorBoundary.d.cts.map +1 -0
- package/dist/types/cjs/cli/command.d.cts +7 -0
- package/dist/types/cjs/cli/command.d.cts.map +1 -0
- package/dist/types/cjs/cli/handler.d.cts +7 -0
- package/dist/types/cjs/cli/handler.d.cts.map +1 -0
- package/dist/types/cjs/cli/index.d.cts +2 -0
- package/dist/types/cjs/cli/index.d.cts.map +1 -0
- package/dist/types/cjs/components/AppSettingsProvider.d.cts +7 -0
- package/dist/types/cjs/components/AppSettingsProvider.d.cts.map +1 -0
- package/dist/types/cjs/components/Setting.d.cts +20 -0
- package/dist/types/cjs/components/Setting.d.cts.map +1 -0
- package/dist/types/cjs/config/AppSettingsImplementation.d.cts +15 -0
- package/dist/types/cjs/config/AppSettingsImplementation.d.cts.map +1 -0
- package/dist/types/cjs/config/ConfigMerger.d.cts +4 -0
- package/dist/types/cjs/config/ConfigMerger.d.cts.map +1 -0
- package/dist/types/cjs/config/ConfigReader.d.cts +14 -0
- package/dist/types/cjs/config/ConfigReader.d.cts.map +1 -0
- package/dist/types/cjs/config/ConfigStore.d.cts +6 -0
- package/dist/types/cjs/config/ConfigStore.d.cts.map +1 -0
- package/dist/types/cjs/config/ConfigValidator.d.cts +7 -0
- package/dist/types/cjs/config/ConfigValidator.d.cts.map +1 -0
- package/dist/types/cjs/config/ScopedSettingsImplementation.d.cts +13 -0
- package/dist/types/cjs/config/ScopedSettingsImplementation.d.cts.map +1 -0
- package/dist/types/cjs/config/createAppSettings.d.cts +3 -0
- package/dist/types/cjs/config/createAppSettings.d.cts.map +1 -0
- package/dist/types/cjs/config/helpers/freezeDeep.d.cts +2 -0
- package/dist/types/cjs/config/helpers/freezeDeep.d.cts.map +1 -0
- package/dist/types/cjs/hooks/useAppSetting.d.cts +6 -0
- package/dist/types/cjs/hooks/useAppSetting.d.cts.map +1 -0
- package/dist/types/cjs/hooks/useAppSettings.d.cts +6 -0
- package/dist/types/cjs/hooks/useAppSettings.d.cts.map +1 -0
- package/dist/types/cjs/hooks/useAppSettingsContext.d.cts +5 -0
- package/dist/types/cjs/hooks/useAppSettingsContext.d.cts.map +1 -0
- package/dist/types/cjs/index.d.cts +10 -0
- package/dist/types/cjs/index.d.cts.map +1 -0
- package/dist/types/cjs/server.d.cts +8 -0
- package/dist/types/cjs/server.d.cts.map +1 -0
- package/dist/types/cjs/sources/AppSettingsSource.d.cts +14 -0
- package/dist/types/cjs/sources/AppSettingsSource.d.cts.map +1 -0
- package/dist/types/cjs/sources/EnvironmentSource.d.cts +16 -0
- package/dist/types/cjs/sources/EnvironmentSource.d.cts.map +1 -0
- package/dist/types/cjs/sources/MemorySource.d.cts +12 -0
- package/dist/types/cjs/sources/MemorySource.d.cts.map +1 -0
- package/dist/types/cjs/sources/RemoteSource.d.cts +16 -0
- package/dist/types/cjs/sources/RemoteSource.d.cts.map +1 -0
- package/dist/types/cjs/sources/helpers/loadJson.d.cts +3 -0
- package/dist/types/cjs/sources/helpers/loadJson.d.cts.map +1 -0
- package/dist/types/cjs/types/AppSettings.types.d.cts +26 -0
- package/dist/types/cjs/types/AppSettings.types.d.cts.map +1 -0
- package/dist/types/cjs/types/Config.types.d.cts +12 -0
- package/dist/types/cjs/types/Config.types.d.cts.map +1 -0
- package/dist/types/cjs/types/Source.types.d.cts +5 -0
- package/dist/types/cjs/types/Source.types.d.cts.map +1 -0
- package/dist/types/esm/__testUtils__/components/ErrorBoundary.d.ts +14 -0
- package/dist/types/esm/__testUtils__/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/types/esm/cli/command.d.ts +7 -0
- package/dist/types/esm/cli/command.d.ts.map +1 -0
- package/dist/types/esm/cli/handler.d.ts +7 -0
- package/dist/types/esm/cli/handler.d.ts.map +1 -0
- package/dist/types/esm/cli/index.d.ts +2 -0
- package/dist/types/esm/cli/index.d.ts.map +1 -0
- package/dist/types/esm/components/AppSettingsProvider.d.ts +7 -0
- package/dist/types/esm/components/AppSettingsProvider.d.ts.map +1 -0
- package/dist/types/esm/components/Setting.d.ts +20 -0
- package/dist/types/esm/components/Setting.d.ts.map +1 -0
- package/dist/types/esm/config/AppSettingsImplementation.d.ts +15 -0
- package/dist/types/esm/config/AppSettingsImplementation.d.ts.map +1 -0
- package/dist/types/esm/config/ConfigMerger.d.ts +4 -0
- package/dist/types/esm/config/ConfigMerger.d.ts.map +1 -0
- package/dist/types/esm/config/ConfigReader.d.ts +14 -0
- package/dist/types/esm/config/ConfigReader.d.ts.map +1 -0
- package/dist/types/esm/config/ConfigStore.d.ts +6 -0
- package/dist/types/esm/config/ConfigStore.d.ts.map +1 -0
- package/dist/types/esm/config/ConfigValidator.d.ts +7 -0
- package/dist/types/esm/config/ConfigValidator.d.ts.map +1 -0
- package/dist/types/esm/config/ScopedSettingsImplementation.d.ts +13 -0
- package/dist/types/esm/config/ScopedSettingsImplementation.d.ts.map +1 -0
- package/dist/types/esm/config/createAppSettings.d.ts +3 -0
- package/dist/types/esm/config/createAppSettings.d.ts.map +1 -0
- package/dist/types/esm/config/helpers/freezeDeep.d.ts +2 -0
- package/dist/types/esm/config/helpers/freezeDeep.d.ts.map +1 -0
- package/dist/types/esm/hooks/useAppSetting.d.ts +6 -0
- package/dist/types/esm/hooks/useAppSetting.d.ts.map +1 -0
- package/dist/types/esm/hooks/useAppSettings.d.ts +6 -0
- package/dist/types/esm/hooks/useAppSettings.d.ts.map +1 -0
- package/dist/types/esm/hooks/useAppSettingsContext.d.ts +5 -0
- package/dist/types/esm/hooks/useAppSettingsContext.d.ts.map +1 -0
- package/dist/types/esm/index.d.ts +10 -0
- package/dist/types/esm/index.d.ts.map +1 -0
- package/dist/types/esm/server.d.ts +8 -0
- package/dist/types/esm/server.d.ts.map +1 -0
- package/dist/types/esm/sources/AppSettingsSource.d.ts +14 -0
- package/dist/types/esm/sources/AppSettingsSource.d.ts.map +1 -0
- package/dist/types/esm/sources/EnvironmentSource.d.ts +16 -0
- package/dist/types/esm/sources/EnvironmentSource.d.ts.map +1 -0
- package/dist/types/esm/sources/MemorySource.d.ts +12 -0
- package/dist/types/esm/sources/MemorySource.d.ts.map +1 -0
- package/dist/types/esm/sources/RemoteSource.d.ts +16 -0
- package/dist/types/esm/sources/RemoteSource.d.ts.map +1 -0
- package/dist/types/esm/sources/helpers/loadJson.d.ts +3 -0
- package/dist/types/esm/sources/helpers/loadJson.d.ts.map +1 -0
- package/dist/types/esm/types/AppSettings.types.d.ts +26 -0
- package/dist/types/esm/types/AppSettings.types.d.ts.map +1 -0
- package/dist/types/esm/types/Config.types.d.ts +12 -0
- package/dist/types/esm/types/Config.types.d.ts.map +1 -0
- package/dist/types/esm/types/Source.types.d.ts +5 -0
- package/dist/types/esm/types/Source.types.d.ts.map +1 -0
- package/dist/types/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +85 -3
- package/rollup.config.mjs +25 -0
- package/src/__testUtils__/AppSettingsSchema.d.ts +9 -0
- package/src/__testUtils__/components/ErrorBoundary.tsx +25 -0
- package/src/cli/command.test.ts +36 -0
- package/src/cli/command.ts +33 -0
- package/src/cli/handler.test.ts +61 -0
- package/src/cli/handler.ts +58 -0
- package/src/cli/index.test.ts +45 -0
- package/src/cli/index.ts +13 -0
- package/src/components/AppSettingsProvider.test.tsx +25 -0
- package/src/components/AppSettingsProvider.tsx +18 -0
- package/src/components/Setting.test.tsx +59 -0
- package/src/components/Setting.tsx +43 -0
- package/src/config/AppSettingsImplementation.test.ts +65 -0
- package/src/config/AppSettingsImplementation.ts +61 -0
- package/src/config/ConfigMerger.test.ts +44 -0
- package/src/config/ConfigMerger.ts +9 -0
- package/src/config/ConfigReader.test.ts +56 -0
- package/src/config/ConfigReader.ts +45 -0
- package/src/config/ConfigStore.test.ts +32 -0
- package/src/config/ConfigStore.ts +12 -0
- package/src/config/ConfigValidator.test.ts +30 -0
- package/src/config/ConfigValidator.ts +13 -0
- package/src/config/ScopedSettingsImplementation.test.ts +31 -0
- package/src/config/ScopedSettingsImplementation.ts +36 -0
- package/src/config/createAppSettings.test.ts +84 -0
- package/src/config/createAppSettings.ts +18 -0
- package/src/config/helpers/freezeDeep.test.ts +41 -0
- package/src/config/helpers/freezeDeep.ts +15 -0
- package/src/hooks/useAppSetting.test.tsx +37 -0
- package/src/hooks/useAppSetting.ts +14 -0
- package/src/hooks/useAppSettings.test.tsx +37 -0
- package/src/hooks/useAppSettings.ts +17 -0
- package/src/hooks/useAppSettingsContext.test.tsx +45 -0
- package/src/hooks/useAppSettingsContext.ts +17 -0
- package/src/index.ts +11 -0
- package/src/server.ts +7 -0
- package/src/sources/AppSettingsSource.test.ts +61 -0
- package/src/sources/AppSettingsSource.ts +30 -0
- package/src/sources/EnvironmentSource.test.ts +45 -0
- package/src/sources/EnvironmentSource.ts +87 -0
- package/src/sources/MemorySource.test.ts +31 -0
- package/src/sources/MemorySource.ts +21 -0
- package/src/sources/RemoteSource.test.ts +80 -0
- package/src/sources/RemoteSource.ts +50 -0
- package/src/sources/helpers/loadJson.test.ts +74 -0
- package/src/sources/helpers/loadJson.ts +29 -0
- package/src/types/AppSettings.types.test.ts +61 -0
- package/src/types/AppSettings.types.ts +30 -0
- package/src/types/Config.types.test.ts +36 -0
- package/src/types/Config.types.ts +27 -0
- package/src/types/Source.types.ts +4 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isPlainObject } from 'remeda';
|
|
2
|
+
import { type Get } from 'type-fest';
|
|
3
|
+
import { ScopedSettingsImplementation } from '#config/ScopedSettingsImplementation.ts';
|
|
4
|
+
import { type InternalAppSettings, type ScopedSettings } from '#types/AppSettings.types.ts';
|
|
5
|
+
import { type SettingPath, type SettingSectionPath } from '#types/Config.types.ts';
|
|
6
|
+
import { ConfigReader } from './ConfigReader.ts';
|
|
7
|
+
import { ConfigStore } from './ConfigStore.ts';
|
|
8
|
+
|
|
9
|
+
export class AppSettingsImplementation<T extends object> implements InternalAppSettings<T> {
|
|
10
|
+
private readonly store: ConfigStore<T>;
|
|
11
|
+
|
|
12
|
+
constructor(settings: T) {
|
|
13
|
+
this.store = new ConfigStore(settings);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get<P extends SettingPath<T>>(path: P): Get<T, P> | undefined;
|
|
17
|
+
get(path: string): unknown {
|
|
18
|
+
return this.read(path);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
read(path: string): unknown {
|
|
22
|
+
return new ConfigReader(this.store.getSnapshot()).read(path);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
getAll(): Readonly<T> {
|
|
26
|
+
return this.store.getSnapshot();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getOrThrow<P extends SettingPath<T>>(path: P): Get<T, P>;
|
|
30
|
+
getOrThrow(path: string): unknown {
|
|
31
|
+
const value = new ConfigReader(this.store.getSnapshot()).read(path);
|
|
32
|
+
|
|
33
|
+
if (value === undefined) {
|
|
34
|
+
throw new Error(`Required app setting "${path}" is missing.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getSection<P extends SettingSectionPath<T>>(path: P): Get<T, P> | undefined;
|
|
41
|
+
getSection(path: string): unknown {
|
|
42
|
+
return new ConfigReader(this.store.getSnapshot()).read(path);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
has(path: SettingPath<T> | SettingSectionPath<T>): boolean;
|
|
46
|
+
has(path: string): boolean {
|
|
47
|
+
return new ConfigReader(this.store.getSnapshot()).has(path);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
scope<P extends SettingSectionPath<T>>(path: P): ScopedSettings<Extract<Get<T, P>, object>>;
|
|
51
|
+
scope(path: string): ScopedSettings<object> {
|
|
52
|
+
const reader = new ConfigReader(this.store.getSnapshot());
|
|
53
|
+
const section = reader.read(path);
|
|
54
|
+
|
|
55
|
+
if (!isPlainObject(section)) {
|
|
56
|
+
throw new Error(`App setting section "${path}" is missing.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return new ScopedSettingsImplementation(section, path);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { ConfigMerger } from './ConfigMerger.ts';
|
|
3
|
+
|
|
4
|
+
describe('ConfigMerger', () => {
|
|
5
|
+
it('When configuration layers contain nested objects, then later values override recursively', () => {
|
|
6
|
+
// Arrange
|
|
7
|
+
const defaults = { Api: { BaseUrl: 'https://default.example.com', Timeout: 1000 }, Enabled: true };
|
|
8
|
+
const overrides = { Api: { Timeout: 5000 } };
|
|
9
|
+
const merger = new ConfigMerger();
|
|
10
|
+
|
|
11
|
+
// Act
|
|
12
|
+
const result = merger.merge(defaults, overrides);
|
|
13
|
+
|
|
14
|
+
// Assert
|
|
15
|
+
expect(result).toEqual({ Api: { BaseUrl: 'https://default.example.com', Timeout: 5000 }, Enabled: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('When later layers contain arrays, then they replace earlier arrays without mutating inputs', () => {
|
|
19
|
+
// Arrange
|
|
20
|
+
const defaults = { Api: { Timeout: 1000 }, Values: [1, 2] };
|
|
21
|
+
const overrides = { Values: [3] };
|
|
22
|
+
const merger = new ConfigMerger();
|
|
23
|
+
|
|
24
|
+
// Act
|
|
25
|
+
const result = merger.merge(defaults, overrides);
|
|
26
|
+
|
|
27
|
+
// Assert
|
|
28
|
+
expect(result).toEqual({ Api: { Timeout: 1000 }, Values: [3] });
|
|
29
|
+
expect(defaults).toEqual({ Api: { Timeout: 1000 }, Values: [1, 2] });
|
|
30
|
+
expect(overrides).toEqual({ Values: [3] });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('When there are no override layers, then it returns the first layer', () => {
|
|
34
|
+
// Arrange
|
|
35
|
+
const defaults = { Enabled: true };
|
|
36
|
+
const merger = new ConfigMerger();
|
|
37
|
+
|
|
38
|
+
// Act
|
|
39
|
+
const result = merger.merge(defaults);
|
|
40
|
+
|
|
41
|
+
// Assert
|
|
42
|
+
expect(result).toBe(defaults);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { mergeDeep } from 'remeda';
|
|
2
|
+
|
|
3
|
+
export class ConfigMerger {
|
|
4
|
+
merge<T extends object>(first: T, ...configs: readonly T[]): T {
|
|
5
|
+
// TypeScript cannot guarantee that merging arbitrary objects produces T
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
7
|
+
return configs.reduce((merged, config) => mergeDeep(merged, config) as T, first);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { ConfigReader } from './ConfigReader.ts';
|
|
3
|
+
|
|
4
|
+
describe('ConfigReader', () => {
|
|
5
|
+
it('When a dotted path points to a leaf, then it returns that value', () => {
|
|
6
|
+
// Arrange
|
|
7
|
+
const reader = new ConfigReader({ Api: { Timeout: 5000 } });
|
|
8
|
+
|
|
9
|
+
// Act
|
|
10
|
+
const result = reader.read('Api.Timeout');
|
|
11
|
+
|
|
12
|
+
// Assert
|
|
13
|
+
expect(result).toBe(5000);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('When a path is missing or traverses a primitive, then it returns undefined and reports no match', () => {
|
|
17
|
+
// Arrange
|
|
18
|
+
const reader = new ConfigReader({ Api: { Timeout: 5000 }, Name: 'App' });
|
|
19
|
+
|
|
20
|
+
// Act
|
|
21
|
+
const missing = reader.read('Api.Retry.Count');
|
|
22
|
+
const throughPrimitive = reader.read('Name.Length');
|
|
23
|
+
|
|
24
|
+
// Assert
|
|
25
|
+
expect(missing).toBeUndefined();
|
|
26
|
+
expect(throughPrimitive).toBeUndefined();
|
|
27
|
+
expect(reader.has('Api.Missing')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('When a path points to a defined value or section, then it reads and reports it', () => {
|
|
31
|
+
// Arrange
|
|
32
|
+
const reader = new ConfigReader({ Api: { Timeout: 5000 }, Empty: undefined });
|
|
33
|
+
|
|
34
|
+
// Act
|
|
35
|
+
const section = reader.getSection('Api');
|
|
36
|
+
const leaf = reader.get('Api.Timeout');
|
|
37
|
+
|
|
38
|
+
// Assert
|
|
39
|
+
expect(section).toEqual({ Timeout: 5000 });
|
|
40
|
+
expect(leaf).toBe(5000);
|
|
41
|
+
expect(reader.has('Api')).toBe(true);
|
|
42
|
+
expect(reader.has('Empty')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('When a section is scoped, then the returned reader reads paths relative to that section', () => {
|
|
46
|
+
// Arrange
|
|
47
|
+
const reader = new ConfigReader({ Api: { Timeout: 5000 } });
|
|
48
|
+
const scopedReader = reader.scope('Api');
|
|
49
|
+
|
|
50
|
+
// Act
|
|
51
|
+
const result = scopedReader.read('Timeout');
|
|
52
|
+
|
|
53
|
+
// Assert
|
|
54
|
+
expect(result).toBe(5000);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { isPlainObject } from 'remeda';
|
|
2
|
+
import { type Get } from 'type-fest';
|
|
3
|
+
import { type Path, type Scope, type ConfigReader as ZcbConfigReader, createConfigReader } from 'zcb';
|
|
4
|
+
|
|
5
|
+
export class ConfigReader<T extends object> {
|
|
6
|
+
private reader: ZcbConfigReader<T>;
|
|
7
|
+
private readonly config: T;
|
|
8
|
+
|
|
9
|
+
constructor(config: T) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.reader = createConfigReader(config);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private getAt(path: string): unknown {
|
|
15
|
+
return path.split('.').reduce<unknown>((current, key) => {
|
|
16
|
+
if (!isPlainObject(current)) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return current[key];
|
|
21
|
+
}, this.config);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public get<P extends Path<T>>(path: P): Get<T, P> {
|
|
25
|
+
return this.reader.read(path);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public getSection<P extends Scope<T>>(path: P): Get<T, P> {
|
|
29
|
+
// Required as underlying getAt needs to be kept permissive
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
31
|
+
return this.getAt(path) as Get<T, P>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public has(path: string): boolean {
|
|
35
|
+
return this.getAt(path) !== undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public read(path: string): unknown {
|
|
39
|
+
return this.getAt(path);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public scope<P extends Scope<T>>(path: P): ZcbConfigReader<Get<T, P>> {
|
|
43
|
+
return this.reader.scope(path);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { ConfigStore } from './ConfigStore.ts';
|
|
3
|
+
|
|
4
|
+
describe('ConfigStore', () => {
|
|
5
|
+
it('When initialized, then it keeps a stable frozen snapshot detached from the input', () => {
|
|
6
|
+
// Arrange
|
|
7
|
+
const initial = { Api: { Timeout: 5000 } };
|
|
8
|
+
const store = new ConfigStore(initial);
|
|
9
|
+
|
|
10
|
+
// Act
|
|
11
|
+
const snapshot = store.getSnapshot();
|
|
12
|
+
initial.Api.Timeout = 100;
|
|
13
|
+
|
|
14
|
+
// Assert
|
|
15
|
+
expect(snapshot).toEqual({ Api: { Timeout: 5000 } });
|
|
16
|
+
expect(store.getSnapshot()).toBe(snapshot);
|
|
17
|
+
expect(Object.isFrozen(snapshot)).toBe(true);
|
|
18
|
+
expect(Object.isFrozen(snapshot.Api)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('When initialized with arrays, then the snapshot array and its entries are frozen', () => {
|
|
22
|
+
// Arrange
|
|
23
|
+
const store = new ConfigStore({ Servers: [{ Host: 'api.example.com' }] });
|
|
24
|
+
|
|
25
|
+
// Act
|
|
26
|
+
const snapshot = store.getSnapshot();
|
|
27
|
+
|
|
28
|
+
// Assert
|
|
29
|
+
expect(Object.isFrozen(snapshot.Servers)).toBe(true);
|
|
30
|
+
expect(Object.isFrozen(snapshot.Servers[0])).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { clone } from 'remeda';
|
|
2
|
+
import { freezeDeep } from '#config/helpers/freezeDeep.ts';
|
|
3
|
+
|
|
4
|
+
export class ConfigStore<T extends object> {
|
|
5
|
+
private snapshot: Readonly<T>;
|
|
6
|
+
|
|
7
|
+
public getSnapshot = (): Readonly<T> => this.snapshot;
|
|
8
|
+
|
|
9
|
+
constructor(initial: T) {
|
|
10
|
+
this.snapshot = freezeDeep(clone(initial));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ConfigValidator } from './ConfigValidator.ts';
|
|
4
|
+
|
|
5
|
+
describe('ConfigValidator', () => {
|
|
6
|
+
it('When a value matches the schema, then it returns the parsed value', () => {
|
|
7
|
+
// Arrange
|
|
8
|
+
const schema = z.object({ Api: z.object({ Timeout: z.number() }) });
|
|
9
|
+
const validator = new ConfigValidator(schema);
|
|
10
|
+
const value = { Api: { Timeout: 5000 } };
|
|
11
|
+
|
|
12
|
+
// Act
|
|
13
|
+
const result = validator.validate(value);
|
|
14
|
+
|
|
15
|
+
// Assert
|
|
16
|
+
expect(result).toEqual(value);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('When a value does not match the schema, then it throws the schema validation error', () => {
|
|
20
|
+
// Arrange
|
|
21
|
+
const schema = z.object({ Api: z.object({ Timeout: z.number() }) });
|
|
22
|
+
const validator = new ConfigValidator(schema);
|
|
23
|
+
|
|
24
|
+
// Act
|
|
25
|
+
const validateInvalidValue = () => validator.validate({ Api: { Timeout: 'slow' } });
|
|
26
|
+
|
|
27
|
+
// Assert
|
|
28
|
+
expect(validateInvalidValue).toThrow();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ZodType, type output } from 'zod';
|
|
2
|
+
|
|
3
|
+
export class ConfigValidator<T extends ZodType> {
|
|
4
|
+
private readonly schema: T;
|
|
5
|
+
|
|
6
|
+
constructor(schema: T) {
|
|
7
|
+
this.schema = schema;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
validate(value: unknown): output<T> {
|
|
11
|
+
return this.schema.parse(value);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { ScopedSettingsImplementation } from './ScopedSettingsImplementation.ts';
|
|
3
|
+
|
|
4
|
+
describe('ScopedSettingsImplementation', () => {
|
|
5
|
+
it('When a path exists within the scope, then it returns the value and scoped data', () => {
|
|
6
|
+
// Arrange
|
|
7
|
+
const section = { Api: { Timeout: 5000 }, Name: 'App', Retry: undefined };
|
|
8
|
+
const settings = new ScopedSettingsImplementation(section, 'Platform.Api');
|
|
9
|
+
|
|
10
|
+
// Act
|
|
11
|
+
const value = settings.get('Api.Timeout');
|
|
12
|
+
const all = settings.getAll();
|
|
13
|
+
|
|
14
|
+
// Assert
|
|
15
|
+
expect(value).toBe(5000);
|
|
16
|
+
expect(all).toBe(section);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('When a required scoped value is missing, then it throws with the full path', () => {
|
|
20
|
+
// Arrange
|
|
21
|
+
const settings = new ScopedSettingsImplementation({ Retry: undefined }, 'Platform.Api');
|
|
22
|
+
|
|
23
|
+
// Act
|
|
24
|
+
const getMissingValue = () => {
|
|
25
|
+
settings.getOrThrow('Retry');
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Assert
|
|
29
|
+
expect(getMissingValue).toThrow('Required app setting "Platform.Api.Retry" is missing.');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Get } from 'type-fest';
|
|
2
|
+
import { ConfigReader } from '#config/ConfigReader.ts';
|
|
3
|
+
import { type ScopedSettings } from '#types/AppSettings.types.ts';
|
|
4
|
+
import { type SettingPath } from '#types/Config.types.ts';
|
|
5
|
+
|
|
6
|
+
export class ScopedSettingsImplementation<T extends object> implements ScopedSettings<T> {
|
|
7
|
+
private readonly reader: ConfigReader<T>;
|
|
8
|
+
private readonly section: T;
|
|
9
|
+
private readonly parentPath: string;
|
|
10
|
+
|
|
11
|
+
constructor(section: T, parentPath: string) {
|
|
12
|
+
this.section = section;
|
|
13
|
+
this.parentPath = parentPath;
|
|
14
|
+
this.reader = new ConfigReader(section);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get<P extends SettingPath<T>>(path: P): Get<T, P> | undefined;
|
|
18
|
+
get(path: string): unknown {
|
|
19
|
+
return this.reader.read(path);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getAll(): Readonly<T> {
|
|
23
|
+
return this.section;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getOrThrow<P extends SettingPath<T>>(path: P): Get<T, P>;
|
|
27
|
+
getOrThrow(path: string): unknown {
|
|
28
|
+
const value = this.reader.read(path);
|
|
29
|
+
|
|
30
|
+
if (value === undefined) {
|
|
31
|
+
throw new Error(`Required app setting "${this.parentPath}.${path}" is missing.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { type SettingsSource } from '#types/Source.types.ts';
|
|
4
|
+
import { createAppSettings } from './createAppSettings.ts';
|
|
5
|
+
|
|
6
|
+
describe('createAppSettings', () => {
|
|
7
|
+
it('When sources are provided, then it loads them in order, merges them, and validates the result', async () => {
|
|
8
|
+
// Arrange
|
|
9
|
+
const schema = z.object({ Api: z.object({ BaseUrl: z.string(), Timeout: z.number() }) });
|
|
10
|
+
const defaults = { Api: { BaseUrl: 'https://default.example.com', Timeout: 1000 } };
|
|
11
|
+
const firstLoad = jest.fn(() => Promise.resolve({ Api: { Timeout: 3000 } }));
|
|
12
|
+
const secondLoad = jest.fn(() => Promise.resolve({ Api: { BaseUrl: 'https://api.example.com' } }));
|
|
13
|
+
const firstSource: SettingsSource = { load: firstLoad, name: 'first' };
|
|
14
|
+
const secondSource: SettingsSource = { load: secondLoad, name: 'second' };
|
|
15
|
+
|
|
16
|
+
// Act
|
|
17
|
+
const result = await createAppSettings({ defaults, schema, sources: [firstSource, secondSource] });
|
|
18
|
+
|
|
19
|
+
// Assert
|
|
20
|
+
expect(firstLoad).toHaveBeenCalledTimes(1);
|
|
21
|
+
expect(secondLoad).toHaveBeenCalledTimes(1);
|
|
22
|
+
expect(result).toEqual({ Api: { BaseUrl: 'https://api.example.com', Timeout: 3000 } });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('When no sources are provided, then it validates the defaults', async () => {
|
|
26
|
+
// Arrange
|
|
27
|
+
const defaults = { Api: { Timeout: 1000 } };
|
|
28
|
+
const schema = z.object({ Api: z.object({ Timeout: z.number() }) });
|
|
29
|
+
|
|
30
|
+
// Act
|
|
31
|
+
const result = await createAppSettings({ defaults, schema });
|
|
32
|
+
|
|
33
|
+
// Assert
|
|
34
|
+
expect(result).toEqual(defaults);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('When a source rejects, then it rejects without continuing to later sources', async () => {
|
|
38
|
+
// Arrange
|
|
39
|
+
const error = new Error('Source failed');
|
|
40
|
+
|
|
41
|
+
const firstLoad = jest.fn(() => {
|
|
42
|
+
throw error;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// @ts-expect-error Fine for test
|
|
46
|
+
const secondLoad: jest.Mock<() => never> = jest.fn(() => ({}));
|
|
47
|
+
|
|
48
|
+
const sources: SettingsSource[] = [
|
|
49
|
+
{ load: firstLoad, name: 'first' },
|
|
50
|
+
{ load: secondLoad, name: 'second' },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const options = { defaults: {}, schema: z.object({}), sources };
|
|
54
|
+
|
|
55
|
+
// Act
|
|
56
|
+
const result = createAppSettings(options);
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
59
|
+
await expect(result).rejects.toBe(error);
|
|
60
|
+
expect(secondLoad).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('When the merged settings fail validation, then it rejects with a schema error', async () => {
|
|
64
|
+
// Arrange
|
|
65
|
+
const defaults = { Api: { Timeout: 1000 } };
|
|
66
|
+
|
|
67
|
+
const invalidSource: SettingsSource = {
|
|
68
|
+
load: () => Promise.resolve({ Api: { Timeout: 'slow' } }),
|
|
69
|
+
name: 'invalid',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const options = {
|
|
73
|
+
defaults,
|
|
74
|
+
schema: z.object({ Api: z.object({ Timeout: z.number() }) }),
|
|
75
|
+
sources: [invalidSource],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Act
|
|
79
|
+
const result = createAppSettings(options);
|
|
80
|
+
|
|
81
|
+
// Assert
|
|
82
|
+
await expect(result).rejects.toThrow();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type CreateAppSettingsOptions } from '#types/AppSettings.types.ts';
|
|
2
|
+
import { ConfigMerger } from './ConfigMerger.ts';
|
|
3
|
+
import { ConfigValidator } from './ConfigValidator.ts';
|
|
4
|
+
|
|
5
|
+
export const createAppSettings = async <T extends object>({
|
|
6
|
+
defaults,
|
|
7
|
+
schema,
|
|
8
|
+
sources = [],
|
|
9
|
+
}: CreateAppSettingsOptions<T>): Promise<Readonly<T>> => {
|
|
10
|
+
const loadedSources: Record<string, unknown>[] = [];
|
|
11
|
+
|
|
12
|
+
for (const source of sources) {
|
|
13
|
+
loadedSources.push(await source.load());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const merged = new ConfigMerger().merge(defaults, ...loadedSources);
|
|
17
|
+
return new ConfigValidator(schema).validate(merged);
|
|
18
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { freezeDeep } from './freezeDeep.ts';
|
|
3
|
+
|
|
4
|
+
describe('freezeDeep', () => {
|
|
5
|
+
it('When given nested plain objects, then it freezes each object and returns the original value', () => {
|
|
6
|
+
// Arrange
|
|
7
|
+
const value = { Api: { Retry: { Count: 3 } } };
|
|
8
|
+
|
|
9
|
+
// Act
|
|
10
|
+
const result = freezeDeep(value);
|
|
11
|
+
|
|
12
|
+
// Assert
|
|
13
|
+
expect(result).toBe(value);
|
|
14
|
+
expect(Object.isFrozen(value)).toBe(true);
|
|
15
|
+
expect(Object.isFrozen(value.Api)).toBe(true);
|
|
16
|
+
expect(Object.isFrozen(value.Api.Retry)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('When given arrays containing objects, then it freezes the array and its nested objects', () => {
|
|
20
|
+
// Arrange
|
|
21
|
+
const value = { Servers: [{ Host: 'api.example.com' }] };
|
|
22
|
+
|
|
23
|
+
// Act
|
|
24
|
+
freezeDeep(value);
|
|
25
|
+
|
|
26
|
+
// Assert
|
|
27
|
+
expect(Object.isFrozen(value.Servers)).toBe(true);
|
|
28
|
+
expect(Object.isFrozen(value.Servers[0])).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('When given a primitive, then it returns the value unchanged', () => {
|
|
32
|
+
// Arrange
|
|
33
|
+
const value = 'setting';
|
|
34
|
+
|
|
35
|
+
// Act
|
|
36
|
+
const result = freezeDeep(value);
|
|
37
|
+
|
|
38
|
+
// Assert
|
|
39
|
+
expect(result).toBe(value);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { isPlainObject } from 'remeda';
|
|
2
|
+
|
|
3
|
+
export const freezeDeep = <T>(value: T): T => {
|
|
4
|
+
if ((isPlainObject(value) || Array.isArray(value)) && !Object.isFrozen(value)) {
|
|
5
|
+
const values: unknown[] = Object.values(value);
|
|
6
|
+
|
|
7
|
+
for (const entry of values) {
|
|
8
|
+
freezeDeep(entry);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
Object.freeze(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return value;
|
|
15
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** @jest-environment @repodog/jest-config/FailOnJsdomConsoleEnvironment.mjs */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from '@jest/globals';
|
|
4
|
+
import { renderHook } from '@testing-library/react';
|
|
5
|
+
import { type ReactNode } from 'react';
|
|
6
|
+
import { AppSettingsProvider } from '../components/AppSettingsProvider.tsx';
|
|
7
|
+
import { useAppSetting } from './useAppSetting.ts';
|
|
8
|
+
|
|
9
|
+
const settings = { Api: { Missing: undefined, Timeout: 5000 } };
|
|
10
|
+
|
|
11
|
+
const createWrapper = ({ children }: { children: ReactNode }) => (
|
|
12
|
+
<AppSettingsProvider settings={settings}>{children}</AppSettingsProvider>
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
describe('useAppSetting', () => {
|
|
16
|
+
it('When used within a provider, then it returns the value at the requested leaf path', () => {
|
|
17
|
+
// Arrange
|
|
18
|
+
const path = 'Api.Timeout';
|
|
19
|
+
|
|
20
|
+
// Act
|
|
21
|
+
const { result } = renderHook(() => useAppSetting(path), { wrapper: createWrapper });
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
24
|
+
expect(result.current).toBe(5000);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('When the requested path is missing, then it returns undefined', () => {
|
|
28
|
+
// Arrange
|
|
29
|
+
const path = 'Api.Missing';
|
|
30
|
+
|
|
31
|
+
// Act
|
|
32
|
+
const { result } = renderHook(() => useAppSetting(path), { wrapper: createWrapper });
|
|
33
|
+
|
|
34
|
+
// Assert
|
|
35
|
+
expect(result.current).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Get } from 'type-fest';
|
|
2
|
+
import { type AppSettingsSchema } from '../index.ts';
|
|
3
|
+
import { type SettingPath } from '../types/Config.types.ts';
|
|
4
|
+
import { useAppSettingsContext } from './useAppSettingsContext.ts';
|
|
5
|
+
|
|
6
|
+
export function useAppSetting<P extends SettingPath<AppSettingsSchema>>(path: P): Get<AppSettingsSchema, P> | undefined;
|
|
7
|
+
export function useAppSetting<T extends object, P extends SettingPath<T>>(path: P): Get<T, P> | undefined;
|
|
8
|
+
|
|
9
|
+
// Required for overloads
|
|
10
|
+
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
|
11
|
+
export function useAppSetting(path: string): unknown {
|
|
12
|
+
const settings = useAppSettingsContext();
|
|
13
|
+
return settings.read(path);
|
|
14
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** @jest-environment @repodog/jest-config/FailOnJsdomConsoleEnvironment.mjs */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from '@jest/globals';
|
|
4
|
+
import { renderHook } from '@testing-library/react';
|
|
5
|
+
import { type ReactNode } from 'react';
|
|
6
|
+
import { AppSettingsProvider } from '../components/AppSettingsProvider.tsx';
|
|
7
|
+
import { useAppSettings } from './useAppSettings.ts';
|
|
8
|
+
|
|
9
|
+
const settings = { Api: { Timeout: 5000 } };
|
|
10
|
+
|
|
11
|
+
const createWrapper = ({ children }: { children: ReactNode }) => (
|
|
12
|
+
<AppSettingsProvider settings={settings}>{children}</AppSettingsProvider>
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
describe('useAppSettings', () => {
|
|
16
|
+
it('When used within a provider, then it returns the requested object section', () => {
|
|
17
|
+
// Arrange
|
|
18
|
+
const path = 'Api';
|
|
19
|
+
|
|
20
|
+
// Act
|
|
21
|
+
const { result } = renderHook(() => useAppSettings(path), { wrapper: createWrapper });
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
24
|
+
expect(result.current).toEqual({ Timeout: 5000 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('When the requested section is missing, then it returns undefined', () => {
|
|
28
|
+
// Arrange
|
|
29
|
+
const path = 'Missing';
|
|
30
|
+
|
|
31
|
+
// Act
|
|
32
|
+
const { result } = renderHook(() => useAppSettings(path), { wrapper: createWrapper });
|
|
33
|
+
|
|
34
|
+
// Assert
|
|
35
|
+
expect(result.current).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Get } from 'type-fest';
|
|
2
|
+
import { type SettingSectionPath } from '#types/Config.types.ts';
|
|
3
|
+
import { type AppSettingsSchema } from '../index.ts';
|
|
4
|
+
import { useAppSettingsContext } from './useAppSettingsContext.ts';
|
|
5
|
+
|
|
6
|
+
export function useAppSettings<P extends SettingSectionPath<AppSettingsSchema>>(
|
|
7
|
+
path: P,
|
|
8
|
+
): Get<AppSettingsSchema, P> | undefined;
|
|
9
|
+
|
|
10
|
+
export function useAppSettings<T extends object, P extends SettingSectionPath<T>>(path: P): Get<T, P> | undefined;
|
|
11
|
+
|
|
12
|
+
// Required for overloads
|
|
13
|
+
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
|
14
|
+
export function useAppSettings(path: string): unknown {
|
|
15
|
+
const settings = useAppSettingsContext();
|
|
16
|
+
return settings.read(path);
|
|
17
|
+
}
|