@next-box/appsettings 0.0.0-stage → 0.0.4
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/command.cjs +2 -0
- package/dist/cjs/cli/command.cjs.map +1 -0
- package/dist/cjs/cli/handler.cjs +2 -0
- package/dist/cjs/cli/handler.cjs.map +1 -0
- package/dist/cjs/cli.cjs +2 -0
- package/dist/cjs/cli.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/ConfigMerger.cjs +2 -0
- package/dist/cjs/config/ConfigMerger.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/ConfigValidator.cjs +2 -0
- package/dist/cjs/config/ConfigValidator.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/createAppSettings.cjs +2 -0
- package/dist/cjs/config/createAppSettings.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.cjs +2 -0
- package/dist/cjs/server.cjs.map +1 -0
- package/dist/cjs/sources/AppSettingsSource.cjs +2 -0
- package/dist/cjs/sources/AppSettingsSource.cjs.map +1 -0
- package/dist/cjs/sources/EnvironmentSource.cjs +2 -0
- package/dist/cjs/sources/EnvironmentSource.cjs.map +1 -0
- package/dist/cjs/sources/MemorySource.cjs +2 -0
- package/dist/cjs/sources/MemorySource.cjs.map +1 -0
- package/dist/cjs/sources/RemoteSource.cjs +2 -0
- package/dist/cjs/sources/RemoteSource.cjs.map +1 -0
- package/dist/cjs/sources/helpers/loadJson.cjs +2 -0
- package/dist/cjs/sources/helpers/loadJson.cjs.map +1 -0
- package/dist/esm/cli/command.mjs +2 -0
- package/dist/esm/cli/command.mjs.map +1 -0
- package/dist/esm/cli/handler.mjs +2 -0
- package/dist/esm/cli/handler.mjs.map +1 -0
- package/dist/esm/cli.mjs +2 -0
- package/dist/esm/cli.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/ConfigMerger.mjs +2 -0
- package/dist/esm/config/ConfigMerger.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/ConfigValidator.mjs +2 -0
- package/dist/esm/config/ConfigValidator.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/createAppSettings.mjs +2 -0
- package/dist/esm/config/createAppSettings.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.mjs +2 -0
- package/dist/esm/server.mjs.map +1 -0
- package/dist/esm/sources/AppSettingsSource.mjs +2 -0
- package/dist/esm/sources/AppSettingsSource.mjs.map +1 -0
- package/dist/esm/sources/EnvironmentSource.mjs +2 -0
- package/dist/esm/sources/EnvironmentSource.mjs.map +1 -0
- package/dist/esm/sources/MemorySource.mjs +2 -0
- package/dist/esm/sources/MemorySource.mjs.map +1 -0
- package/dist/esm/sources/RemoteSource.mjs +2 -0
- package/dist/esm/sources/RemoteSource.mjs.map +1 -0
- package/dist/esm/sources/helpers/loadJson.mjs +2 -0
- package/dist/esm/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.d.cts +2 -0
- package/dist/types/cjs/cli.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.d.ts +2 -0
- package/dist/types/esm/cli.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 +17 -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.test.ts +45 -0
- package/src/cli.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
package/src/server.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createAppSettings } from '#config/createAppSettings.ts';
|
|
2
|
+
export { AppSettingsSource, type AppSettingsSourceOptions } from '#sources/AppSettingsSource.ts';
|
|
3
|
+
export { EnvironmentSource, type EnvironmentSourceOptions } from '#sources/EnvironmentSource.ts';
|
|
4
|
+
export { RemoteSource, type RemoteSourceOptions } from '#sources/RemoteSource.ts';
|
|
5
|
+
export { MemorySource, type MemorySourceOptions } from '#sources/MemorySource.ts';
|
|
6
|
+
export { type CreateAppSettingsOptions } from '#types/AppSettings.types.ts';
|
|
7
|
+
export type { SettingsSource } from '#types/Source.types.ts';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from '@jest/globals';
|
|
4
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { AppSettingsSource } from './AppSettingsSource.ts';
|
|
8
|
+
|
|
9
|
+
describe('AppSettingsSource', () => {
|
|
10
|
+
let directory: string | undefined;
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
if (!directory) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
await rm(directory, { force: true, recursive: true });
|
|
18
|
+
directory = undefined;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('When base and environment files exist, then the environment file overrides the base recursively', async () => {
|
|
22
|
+
// Arrange
|
|
23
|
+
directory = await mkdtemp(join(tmpdir(), 'appsettings-source-'));
|
|
24
|
+
await writeFile(join(directory, 'appsettings.json'), JSON.stringify({ Api: { Retry: 3, Timeout: 1000 } }));
|
|
25
|
+
await writeFile(join(directory, 'appsettings.test.json'), JSON.stringify({ Api: { Timeout: 5000 } }));
|
|
26
|
+
const source = new AppSettingsSource({ directory, environment: 'test' });
|
|
27
|
+
|
|
28
|
+
// Act
|
|
29
|
+
const result = await source.load();
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
32
|
+
expect(result).toEqual({ Api: { Retry: 3, Timeout: 5000 } });
|
|
33
|
+
expect(source.name).toBe('appsettings:test');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('When the environment file is absent, then it returns the base file', async () => {
|
|
37
|
+
// Arrange
|
|
38
|
+
directory = await mkdtemp(join(tmpdir(), 'appsettings-source-'));
|
|
39
|
+
await writeFile(join(directory, 'appsettings.json'), JSON.stringify({ App: { Name: 'Example' } }));
|
|
40
|
+
const source = new AppSettingsSource({ directory, environment: 'test', name: 'custom' });
|
|
41
|
+
|
|
42
|
+
// Act
|
|
43
|
+
const result = await source.load();
|
|
44
|
+
|
|
45
|
+
// Assert
|
|
46
|
+
expect(result).toEqual({ App: { Name: 'Example' } });
|
|
47
|
+
expect(source.name).toBe('custom');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('When the base file is absent, then loading rejects', async () => {
|
|
51
|
+
// Arrange
|
|
52
|
+
directory = await mkdtemp(join(tmpdir(), 'appsettings-source-'));
|
|
53
|
+
const source = new AppSettingsSource({ directory, environment: 'test' });
|
|
54
|
+
|
|
55
|
+
// Act
|
|
56
|
+
const result = source.load();
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
59
|
+
await expect(result).rejects.toMatchObject({ code: 'ENOENT' });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { ConfigMerger } from '#config/ConfigMerger.ts';
|
|
3
|
+
import { loadJson } from '#sources/helpers/loadJson.ts';
|
|
4
|
+
import { type SettingsSource } from '../types/Source.types.ts';
|
|
5
|
+
|
|
6
|
+
export interface AppSettingsSourceOptions {
|
|
7
|
+
directory?: string;
|
|
8
|
+
environment?: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class AppSettingsSource implements SettingsSource {
|
|
13
|
+
private directory: string;
|
|
14
|
+
private environment: string;
|
|
15
|
+
public name: string;
|
|
16
|
+
|
|
17
|
+
public constructor(options: AppSettingsSourceOptions) {
|
|
18
|
+
this.directory = options.directory ?? process.cwd();
|
|
19
|
+
this.environment = options.environment ?? process.env.NODE_ENV ?? 'development';
|
|
20
|
+
this.name = options.name ?? `appsettings:${this.environment}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async load(): Promise<Record<string, unknown>> {
|
|
24
|
+
const basePath = resolve(this.directory, 'appsettings.json');
|
|
25
|
+
const environmentPath = resolve(this.directory, `appsettings.${this.environment}.json`);
|
|
26
|
+
const base = await loadJson(basePath, false);
|
|
27
|
+
const override = (await loadJson(environmentPath, true)) ?? {};
|
|
28
|
+
return new ConfigMerger().merge(base, override);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { EnvironmentSource } from './EnvironmentSource.ts';
|
|
3
|
+
|
|
4
|
+
describe('EnvironmentSource', () => {
|
|
5
|
+
it('When matching variables are loaded, then it maps nested keys and parses booleans and numbers', async () => {
|
|
6
|
+
// Arrange
|
|
7
|
+
const source = new EnvironmentSource({
|
|
8
|
+
envs: {
|
|
9
|
+
APP_API__BASE_URL: 'https://api.example.com',
|
|
10
|
+
APP_API__TIMEOUT: '5000',
|
|
11
|
+
APP_FEATURES__DARK_MODE: 'true',
|
|
12
|
+
APP_FEATURES__NEW_DASHBOARD: 'false',
|
|
13
|
+
APP_UNSET: undefined,
|
|
14
|
+
OTHER_API__TIMEOUT: '1000',
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Act
|
|
19
|
+
const result = await source.load();
|
|
20
|
+
|
|
21
|
+
// Assert
|
|
22
|
+
expect(result).toEqual({
|
|
23
|
+
Api: { BaseUrl: 'https://api.example.com', Timeout: 5000 },
|
|
24
|
+
Features: { DarkMode: true, NewDashboard: false },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(source.name).toBe('environment:APP_');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('When a custom prefix and name are provided, then it uses them and preserves non-numeric values', async () => {
|
|
31
|
+
// Arrange
|
|
32
|
+
const source = new EnvironmentSource({
|
|
33
|
+
envs: { SERVICE_MODE: 'Development', SERVICE_PORT: 'not-a-number' },
|
|
34
|
+
name: 'service-env',
|
|
35
|
+
prefix: 'SERVICE_',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Act
|
|
39
|
+
const result = await source.load();
|
|
40
|
+
|
|
41
|
+
// Assert
|
|
42
|
+
expect(result).toEqual({ Mode: 'Development', Port: 'not-a-number' });
|
|
43
|
+
expect(source.name).toBe('service-env');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { capitalize, isPlainObject } from 'remeda';
|
|
2
|
+
import { type SettingsSource } from '../types/Source.types.ts';
|
|
3
|
+
|
|
4
|
+
export interface EnvironmentSourceOptions {
|
|
5
|
+
envs: Record<string, string | undefined>;
|
|
6
|
+
name?: string;
|
|
7
|
+
prefix?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class EnvironmentSource implements SettingsSource {
|
|
11
|
+
private readonly envs: Record<string, string | undefined>;
|
|
12
|
+
private readonly prefix: string;
|
|
13
|
+
|
|
14
|
+
private normalizeSegment = (value: string): string =>
|
|
15
|
+
value
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.split('_')
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.map(part => capitalize(part))
|
|
20
|
+
.join('');
|
|
21
|
+
|
|
22
|
+
public readonly name: string;
|
|
23
|
+
|
|
24
|
+
public constructor(options: EnvironmentSourceOptions) {
|
|
25
|
+
this.envs = options.envs;
|
|
26
|
+
this.prefix = options.prefix ?? 'APP_';
|
|
27
|
+
this.name = options.name ?? `environment:${this.prefix}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private parseEnvironmentValue(value: string): unknown {
|
|
31
|
+
if (value === 'true') {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (value === 'false') {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (value.trim() !== '' && Number.isFinite(Number(value))) {
|
|
40
|
+
return Number(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
load(): Promise<Record<string, unknown>> {
|
|
47
|
+
const result: Record<string, unknown> = {};
|
|
48
|
+
|
|
49
|
+
for (const [key, value] of Object.entries(this.envs)) {
|
|
50
|
+
if (value === undefined || !key.startsWith(this.prefix)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const path = key
|
|
55
|
+
.slice(this.prefix.length)
|
|
56
|
+
.split('__')
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map(part => this.normalizeSegment(part));
|
|
59
|
+
|
|
60
|
+
const leaf = path.pop();
|
|
61
|
+
|
|
62
|
+
if (!leaf) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let current = result;
|
|
67
|
+
|
|
68
|
+
for (const part of path) {
|
|
69
|
+
const next = current[part];
|
|
70
|
+
|
|
71
|
+
if (!isPlainObject(next)) {
|
|
72
|
+
current[part] = {};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const currentPart = current[part];
|
|
76
|
+
|
|
77
|
+
if (isPlainObject(currentPart)) {
|
|
78
|
+
current = currentPart;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
current[leaf] = this.parseEnvironmentValue(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Promise.resolve(result);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { MemorySource } from './MemorySource.ts';
|
|
3
|
+
|
|
4
|
+
describe('MemorySource', () => {
|
|
5
|
+
it('When loaded, then it returns a copy of the supplied values', async () => {
|
|
6
|
+
// Arrange
|
|
7
|
+
const values = { Api: { Timeout: 5000 } };
|
|
8
|
+
const source = new MemorySource({ values });
|
|
9
|
+
|
|
10
|
+
// Act
|
|
11
|
+
const result = await source.load();
|
|
12
|
+
result.Additional = true;
|
|
13
|
+
|
|
14
|
+
// Assert
|
|
15
|
+
expect(result).not.toBe(values);
|
|
16
|
+
expect(result.Api).not.toBe(values.Api);
|
|
17
|
+
expect(values.Api.Timeout).toBe(5000);
|
|
18
|
+
expect(source.name).toBe('memory');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('When a custom name is provided, then it exposes that name', () => {
|
|
22
|
+
// Arrange
|
|
23
|
+
const source = new MemorySource({ name: 'runtime-overrides', values: {} });
|
|
24
|
+
|
|
25
|
+
// Act
|
|
26
|
+
const name = source.name;
|
|
27
|
+
|
|
28
|
+
// Assert
|
|
29
|
+
expect(name).toBe('runtime-overrides');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { clone } from 'remeda';
|
|
2
|
+
import { type SettingsSource } from '../types/Source.types.ts';
|
|
3
|
+
|
|
4
|
+
export interface MemorySourceOptions {
|
|
5
|
+
name?: string;
|
|
6
|
+
values: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class MemorySource implements SettingsSource {
|
|
10
|
+
private readonly values: Record<string, unknown>;
|
|
11
|
+
public readonly name: string;
|
|
12
|
+
|
|
13
|
+
public constructor(options: MemorySourceOptions) {
|
|
14
|
+
this.name = options.name ?? 'memory';
|
|
15
|
+
this.values = options.values;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
load(): Promise<Record<string, unknown>> {
|
|
19
|
+
return Promise.resolve(clone(this.values));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from '@jest/globals';
|
|
4
|
+
import { RemoteSource } from './RemoteSource.ts';
|
|
5
|
+
|
|
6
|
+
describe('RemoteSource', () => {
|
|
7
|
+
it('When the response is successful, then it returns its JSON object', async () => {
|
|
8
|
+
// Arrange
|
|
9
|
+
const response = new Response(undefined, { status: 200 });
|
|
10
|
+
response.json = () => Promise.resolve({ Api: { Timeout: 5000 } });
|
|
11
|
+
const fetch = () => Promise.resolve(response);
|
|
12
|
+
const source = new RemoteSource({ fetch, url: 'https://config.example.com/settings.json' });
|
|
13
|
+
|
|
14
|
+
// Act
|
|
15
|
+
const result = await source.load();
|
|
16
|
+
|
|
17
|
+
// Assert
|
|
18
|
+
expect(result).toEqual({ Api: { Timeout: 5000 } });
|
|
19
|
+
expect(source.name).toBe('remote:https://config.example.com/settings.json');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('When a response path is provided, then it returns the selected nested object', async () => {
|
|
23
|
+
// Arrange
|
|
24
|
+
const response = new Response();
|
|
25
|
+
response.json = () => Promise.resolve({ data: { settings: { Api: { Timeout: 5000 } } } });
|
|
26
|
+
const fetch = () => Promise.resolve(response);
|
|
27
|
+
const source = new RemoteSource({ fetch, responsePath: 'data.settings', url: 'https://config.example.com' });
|
|
28
|
+
|
|
29
|
+
// Act
|
|
30
|
+
const result = await source.load();
|
|
31
|
+
|
|
32
|
+
// Assert
|
|
33
|
+
expect(result).toEqual({ Api: { Timeout: 5000 } });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('When the response is not successful, then it throws an error containing the response status', async () => {
|
|
37
|
+
// Arrange
|
|
38
|
+
const fetch = () => Promise.resolve(new Response('Unavailable', { status: 503, statusText: 'Unavailable' }));
|
|
39
|
+
const source = new RemoteSource({ fetch, url: 'https://config.example.com' });
|
|
40
|
+
|
|
41
|
+
// Act
|
|
42
|
+
const result = source.load();
|
|
43
|
+
|
|
44
|
+
// Assert
|
|
45
|
+
await expect(result).rejects.toThrow('503 Unavailable');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('When the JSON root or selected response path is not an object, then it rejects', async () => {
|
|
49
|
+
// Arrange
|
|
50
|
+
const fetchArray = (): Promise<Response> => Promise.resolve(new Response('[]'));
|
|
51
|
+
const fetchMissingPath = (): Promise<Response> => Promise.resolve(new Response('{"data": {"settings": []}}'));
|
|
52
|
+
const rootSource = new RemoteSource({ fetch: fetchArray, url: 'https://config.example.com' });
|
|
53
|
+
|
|
54
|
+
const nestedSource = new RemoteSource({
|
|
55
|
+
fetch: fetchMissingPath,
|
|
56
|
+
responsePath: 'data.settings',
|
|
57
|
+
url: 'https://config.example.com',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Act
|
|
61
|
+
const root = rootSource.load();
|
|
62
|
+
const nested = nestedSource.load();
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
65
|
+
await expect(root).rejects.toThrow('must contain an object at the root');
|
|
66
|
+
await expect(nested).rejects.toThrow('must contain an object at the root');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('When a response path is used on a primitive JSON value, then it rejects before selecting the path', async () => {
|
|
70
|
+
// Arrange
|
|
71
|
+
const fetch = () => Promise.resolve(new Response('42'));
|
|
72
|
+
const source = new RemoteSource({ fetch, responsePath: 'data', url: 'https://config.example.com' });
|
|
73
|
+
|
|
74
|
+
// Act
|
|
75
|
+
const result = source.load();
|
|
76
|
+
|
|
77
|
+
// Assert
|
|
78
|
+
await expect(result).rejects.toThrow('must be an object type if response path is provided');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { isObjectType, isPlainObject, prop, stringToPath } from 'remeda';
|
|
2
|
+
import { type SettingsSource } from '#types/Source.types.ts';
|
|
3
|
+
|
|
4
|
+
export interface RemoteSourceOptions {
|
|
5
|
+
fetch: typeof globalThis.fetch;
|
|
6
|
+
name?: string;
|
|
7
|
+
responsePath?: string;
|
|
8
|
+
url: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class RemoteSource implements SettingsSource {
|
|
12
|
+
private fetch: typeof globalThis.fetch;
|
|
13
|
+
private responsePath?: string;
|
|
14
|
+
private url: string;
|
|
15
|
+
public name: string;
|
|
16
|
+
|
|
17
|
+
public constructor(options: RemoteSourceOptions) {
|
|
18
|
+
this.fetch = options.fetch;
|
|
19
|
+
this.url = options.url;
|
|
20
|
+
this.name = options.name ?? `remote:${this.url}`;
|
|
21
|
+
this.responsePath = options.responsePath;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async load(): Promise<Record<string, unknown>> {
|
|
25
|
+
if (typeof this.fetch !== 'function') {
|
|
26
|
+
throw new TypeError('RemoteSource requires fetch, which is unavailable in this runtime.');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const response = await this.fetch(this.url);
|
|
30
|
+
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
throw new Error(`Failed to load settings from "${this.url}": ${String(response.status)} ${response.statusText}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let value: unknown = await response.json();
|
|
36
|
+
|
|
37
|
+
if (this.responsePath && !isObjectType(value)) {
|
|
38
|
+
throw new Error(`Fetched value must be an object type if response path is provided, but was ${typeof value}.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// @ts-expect-error Unable to statically type string literal
|
|
42
|
+
value = this.responsePath ? prop(value, ...stringToPath(this.responsePath)) : value;
|
|
43
|
+
|
|
44
|
+
if (!isPlainObject(value)) {
|
|
45
|
+
throw new Error(`JSON settings from "${this.url}" must contain an object at the root.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from '@jest/globals';
|
|
4
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { loadJson } from './loadJson.ts';
|
|
8
|
+
|
|
9
|
+
describe('loadJson', () => {
|
|
10
|
+
let directory: string | undefined;
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
if (!directory) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
await rm(directory, { force: true, recursive: true });
|
|
18
|
+
directory = undefined;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('When the file contains a JSON object, then it returns the parsed object', async () => {
|
|
22
|
+
// Arrange
|
|
23
|
+
directory = await mkdtemp(join(tmpdir(), 'appsettings-json-'));
|
|
24
|
+
const path = join(directory, 'settings.json');
|
|
25
|
+
await writeFile(path, JSON.stringify({ Api: { Timeout: 5000 } }));
|
|
26
|
+
|
|
27
|
+
// Act
|
|
28
|
+
const result = await loadJson(path, false);
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
31
|
+
expect(result).toEqual({ Api: { Timeout: 5000 } });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('When an optional file does not exist, then it returns undefined', async () => {
|
|
35
|
+
// Arrange
|
|
36
|
+
directory = await mkdtemp(join(tmpdir(), 'appsettings-json-'));
|
|
37
|
+
const path = join(directory, 'missing.json');
|
|
38
|
+
|
|
39
|
+
// Act
|
|
40
|
+
const result = await loadJson(path, true);
|
|
41
|
+
|
|
42
|
+
// Assert
|
|
43
|
+
expect(result).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('When a required file does not exist, then it propagates the filesystem error', async () => {
|
|
47
|
+
// Arrange
|
|
48
|
+
directory = await mkdtemp(join(tmpdir(), 'appsettings-json-'));
|
|
49
|
+
const path = join(directory, 'missing.json');
|
|
50
|
+
|
|
51
|
+
// Act
|
|
52
|
+
const result = loadJson(path, false);
|
|
53
|
+
|
|
54
|
+
// Assert
|
|
55
|
+
await expect(result).rejects.toMatchObject({ code: 'ENOENT' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('When the file contains malformed JSON or a non-object root, then it rejects', async () => {
|
|
59
|
+
// Arrange
|
|
60
|
+
directory = await mkdtemp(join(tmpdir(), 'appsettings-json-'));
|
|
61
|
+
const malformedPath = join(directory, 'malformed.json');
|
|
62
|
+
const arrayPath = join(directory, 'array.json');
|
|
63
|
+
await writeFile(malformedPath, '{');
|
|
64
|
+
await writeFile(arrayPath, '[]');
|
|
65
|
+
|
|
66
|
+
// Act
|
|
67
|
+
const malformed = loadJson(malformedPath, false);
|
|
68
|
+
const array = loadJson(arrayPath, false);
|
|
69
|
+
|
|
70
|
+
// Assert
|
|
71
|
+
await expect(malformed).rejects.toBeInstanceOf(SyntaxError);
|
|
72
|
+
await expect(array).rejects.toThrow('must contain an object at the root');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { isPlainObject } from 'remeda';
|
|
3
|
+
|
|
4
|
+
export function loadJson(path: string, isOptional: false): Promise<Record<string, unknown>>;
|
|
5
|
+
export function loadJson(path: string, isOptional: true): Promise<Record<string, unknown> | undefined>;
|
|
6
|
+
|
|
7
|
+
// Required for overloads
|
|
8
|
+
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
|
9
|
+
export async function loadJson(path: string, isOptional: boolean): Promise<Record<string, unknown> | undefined> {
|
|
10
|
+
let contents: string;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
contents = await readFile(path, 'utf8');
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (isOptional && typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const value: unknown = JSON.parse(contents);
|
|
23
|
+
|
|
24
|
+
if (!isPlainObject(value)) {
|
|
25
|
+
throw new Error(`JSON settings from "${path}" must contain an object at the root.`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { AppSettingsImplementation } from '../config/AppSettingsImplementation.ts';
|
|
4
|
+
import { createAppSettings } from '../config/createAppSettings.ts';
|
|
5
|
+
import { type AppSettings, type CreateAppSettingsOptions, type ScopedSettings } from './AppSettings.types.ts';
|
|
6
|
+
import { type SettingsSource } from './Source.types.ts';
|
|
7
|
+
|
|
8
|
+
type ExampleSettings = { Api: { Retry?: number; Timeout: number } };
|
|
9
|
+
|
|
10
|
+
const assertType = <Expected>(_value: Expected): void => undefined;
|
|
11
|
+
|
|
12
|
+
describe('AppSettings types', () => {
|
|
13
|
+
it('When configuration is loaded, then settings and scoped readers expose correctly typed values', async () => {
|
|
14
|
+
// Arrange
|
|
15
|
+
const source: SettingsSource = {
|
|
16
|
+
load: () => Promise.resolve({ Api: { Timeout: 5000 } }),
|
|
17
|
+
name: 'example',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const options: CreateAppSettingsOptions<ExampleSettings> = {
|
|
21
|
+
defaults: { Api: { Timeout: 1000 } },
|
|
22
|
+
schema: z.object({ Api: z.object({ Retry: z.number().optional(), Timeout: z.number() }) }),
|
|
23
|
+
sources: [source],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Act
|
|
27
|
+
const config = await createAppSettings(options);
|
|
28
|
+
const settings: AppSettings<ExampleSettings> = new AppSettingsImplementation(config);
|
|
29
|
+
const scoped: ScopedSettings<{ Retry?: number; Timeout: number }> = settings.scope('Api');
|
|
30
|
+
const timeout = settings.get('Api.Timeout');
|
|
31
|
+
const retry = settings.get('Api.Retry');
|
|
32
|
+
const requiredTimeout = settings.getOrThrow('Api.Timeout');
|
|
33
|
+
const all = settings.getAll();
|
|
34
|
+
const section = settings.getSection('Api');
|
|
35
|
+
const hasTimeout = settings.has('Api.Timeout');
|
|
36
|
+
const scopedTimeout = scoped.get('Timeout');
|
|
37
|
+
const scopedRequiredTimeout = scoped.getOrThrow('Timeout');
|
|
38
|
+
const scopedAll = scoped.getAll();
|
|
39
|
+
|
|
40
|
+
assertType<number | undefined>(timeout);
|
|
41
|
+
assertType<number | undefined>(retry);
|
|
42
|
+
assertType<number>(requiredTimeout);
|
|
43
|
+
assertType<Readonly<ExampleSettings>>(all);
|
|
44
|
+
assertType<ExampleSettings['Api'] | undefined>(section);
|
|
45
|
+
assertType<boolean>(hasTimeout);
|
|
46
|
+
assertType<number | undefined>(scopedTimeout);
|
|
47
|
+
assertType<number>(scopedRequiredTimeout);
|
|
48
|
+
assertType<Readonly<ExampleSettings['Api']>>(scopedAll);
|
|
49
|
+
|
|
50
|
+
// Assert
|
|
51
|
+
expect(timeout).toBe(5000);
|
|
52
|
+
expect(retry).toBeUndefined();
|
|
53
|
+
expect(requiredTimeout).toBe(5000);
|
|
54
|
+
expect(all).toEqual({ Api: { Timeout: 5000 } });
|
|
55
|
+
expect(section).toEqual({ Timeout: 5000 });
|
|
56
|
+
expect(hasTimeout).toBe(true);
|
|
57
|
+
expect(scopedTimeout).toBe(5000);
|
|
58
|
+
expect(scopedRequiredTimeout).toBe(5000);
|
|
59
|
+
expect(scopedAll).toEqual({ Timeout: 5000 });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Get } from 'type-fest';
|
|
2
|
+
import { type ZodType } from 'zod';
|
|
3
|
+
import { type SettingPath, type SettingSectionPath } from './Config.types.ts';
|
|
4
|
+
import { type SettingsSource } from './Source.types.ts';
|
|
5
|
+
|
|
6
|
+
export interface AppSettings<T extends object> {
|
|
7
|
+
get<P extends SettingPath<T>>(path: P): Get<T, P> | undefined;
|
|
8
|
+
getAll(): Readonly<T>;
|
|
9
|
+
getOrThrow<P extends SettingPath<T>>(path: P): Get<T, P>;
|
|
10
|
+
getSection<P extends SettingSectionPath<T>>(path: P): Get<T, P> | undefined;
|
|
11
|
+
has(path: SettingPath<T> | SettingSectionPath<T>): boolean;
|
|
12
|
+
scope<P extends SettingSectionPath<T>>(path: P): ScopedSettings<Extract<Get<T, P>, object>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Internal dynamic lookup used by React bindings.
|
|
16
|
+
export interface InternalAppSettings<T extends object> extends AppSettings<T> {
|
|
17
|
+
read(path: string): unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ScopedSettings<T extends object = Record<string, unknown>> {
|
|
21
|
+
get<P extends SettingPath<T>>(path: P): Get<T, P> | undefined;
|
|
22
|
+
getAll(): Readonly<T>;
|
|
23
|
+
getOrThrow<P extends SettingPath<T>>(path: P): Get<T, P>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CreateAppSettingsOptions<T extends object> {
|
|
27
|
+
defaults: Partial<T>;
|
|
28
|
+
schema: ZodType<T>;
|
|
29
|
+
sources?: readonly SettingsSource[];
|
|
30
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { type SettingPath, type SettingSectionPath, type SettingValue } from './Config.types.ts';
|
|
3
|
+
|
|
4
|
+
type ExampleSettings = {
|
|
5
|
+
Api: { BaseUrl: string; Retry: { Count: number }; Timeout: number };
|
|
6
|
+
Features: { DarkMode: boolean };
|
|
7
|
+
Tags: readonly string[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
describe('configuration path types', () => {
|
|
11
|
+
it('When paths are declared, then leaf and section paths accept the appropriate settings paths', () => {
|
|
12
|
+
// Arrange
|
|
13
|
+
const leafPaths: SettingPath<ExampleSettings>[] = [
|
|
14
|
+
'Api.BaseUrl',
|
|
15
|
+
'Api.Retry.Count',
|
|
16
|
+
'Api.Timeout',
|
|
17
|
+
'Features.DarkMode',
|
|
18
|
+
'Tags',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const sectionPaths: SettingSectionPath<ExampleSettings>[] = ['Api', 'Api.Retry', 'Features'];
|
|
22
|
+
const timeout: SettingValue<ExampleSettings, 'Api.Timeout'> = 5000;
|
|
23
|
+
const retryCount: SettingValue<ExampleSettings['Api']['Retry'], 'Count'> = 3;
|
|
24
|
+
|
|
25
|
+
// Act
|
|
26
|
+
const paths = { leafPaths, retryCount, sectionPaths, timeout };
|
|
27
|
+
|
|
28
|
+
// Assert
|
|
29
|
+
expect(paths).toEqual({
|
|
30
|
+
leafPaths: ['Api.BaseUrl', 'Api.Retry.Count', 'Api.Timeout', 'Features.DarkMode', 'Tags'],
|
|
31
|
+
retryCount: 3,
|
|
32
|
+
sectionPaths: ['Api', 'Api.Retry', 'Features'],
|
|
33
|
+
timeout: 5000,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Get } from 'type-fest';
|
|
2
|
+
|
|
3
|
+
type Primitive = string | number | boolean | bigint | symbol | null | undefined;
|
|
4
|
+
|
|
5
|
+
type PreviousDepth = [never, 0, 1, 2, 3, 4, 5];
|
|
6
|
+
|
|
7
|
+
export type SettingPath<T extends object, Depth extends number = 5> = Depth extends 0
|
|
8
|
+
? never
|
|
9
|
+
: {
|
|
10
|
+
[K in Extract<keyof T, string>]: T[K] extends Primitive | readonly Primitive[]
|
|
11
|
+
? K
|
|
12
|
+
: T[K] extends object
|
|
13
|
+
? `${K}.${SettingPath<T[K], PreviousDepth[Depth]>}`
|
|
14
|
+
: K;
|
|
15
|
+
}[Extract<keyof T, string>];
|
|
16
|
+
|
|
17
|
+
export type SettingSectionPath<T extends object, Depth extends number = 5> = Depth extends 0
|
|
18
|
+
? never
|
|
19
|
+
: {
|
|
20
|
+
[K in Extract<keyof T, string>]: T[K] extends Primitive | readonly Primitive[]
|
|
21
|
+
? never
|
|
22
|
+
: T[K] extends object
|
|
23
|
+
? K | `${K}.${SettingSectionPath<T[K], PreviousDepth[Depth]>}`
|
|
24
|
+
: never;
|
|
25
|
+
}[Extract<keyof T, string>];
|
|
26
|
+
|
|
27
|
+
export type SettingValue<T extends object, P extends SettingPath<T>> = Get<T, P>;
|