Commit 7448d3f4 authored by nanahira's avatar nanahira

add configurer

parent c1d07104
......@@ -11,3 +11,5 @@ export * from './src/observe-diff';
export * from './src/memorize';
export * from './src/may-be-array';
export * from './src/app-context';
export * from './src/configurer';
export class ConfigurerInstance<T extends Record<string, string>> {
constructor(
public config: T,
private readonly defaultConfig: T,
) {}
getString<K extends keyof T>(key: K): string {
return (this.config[key] || this.defaultConfig[key]) as string;
}
getInt<K extends keyof T>(key: K): number {
return parseInt(this.getString(key));
}
getFloat<K extends keyof T>(key: K): number {
return parseFloat(this.getString(key));
}
getBoolean<K extends keyof T>(key: K): boolean {
const defaultBoolean = parseConfigBoolean(this.defaultConfig[key], false);
return parseConfigBoolean(this.getString(key), defaultBoolean);
}
getStringArray<K extends keyof T>(key: K): string[] {
return convertStringArray(this.getString(key));
}
getIntArray<K extends keyof T>(key: K): number[] {
return convertIntArray(this.getString(key));
}
getFloatArray<K extends keyof T>(key: K): number[] {
return convertFloatArray(this.getString(key));
}
getBooleanArray<K extends keyof T>(key: K): boolean[] {
const defaultBoolean = parseConfigBoolean(this.defaultConfig[key], false);
return convertBooleanArray(this.getString(key), defaultBoolean);
}
}
export class Configurer<T extends Record<string, string>> {
constructor(public defaultConfig: T) {}
loadConfig(
options: { env?: Record<string, string>; obj?: any } = {},
): ConfigurerInstance<T> {
const readConfig =
options?.obj && typeof options.obj === 'object' ? options.obj : {};
const normalizedConfig = normalizeConfigByDefaultKeys(
readConfig,
this.defaultConfig,
);
return new ConfigurerInstance<T>(
{
...this.defaultConfig,
...normalizedConfig,
...(options?.env || {}),
} as T,
this.defaultConfig,
);
}
generateExampleObject(): Record<
string,
string | number | string[] | number[]
> {
return Object.fromEntries(
Object.entries(this.defaultConfig).map(([key, value]) => {
if (value.includes(',')) {
return [
toCamelCaseKey(key),
value.split(',').map((v) => toTypedValue(v)),
];
}
return [toCamelCaseKey(key), toTypedValue(value)];
}),
);
}
}
export type TypeFromConfigurer<C extends ConfigurerInstance<any>> =
C extends ConfigurerInstance<infer T> ? T : never;
function toCamelCaseKey(key: string): string {
const lower = key.toLowerCase();
return lower.replace(/_([a-z0-9])/g, (_, ch: string) => ch.toUpperCase());
}
function normalizeConfigValue(value: unknown): string | undefined {
if (value == null) {
return undefined;
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number') {
return value.toString();
}
if (typeof value === 'boolean') {
return value ? '1' : '0';
}
if (Array.isArray(value)) {
return value.map((item) => normalizeArrayItem(item)).join(',');
}
return String(value);
}
function normalizeArrayItem(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number') {
return value.toString();
}
if (typeof value === 'boolean') {
return value ? '1' : '0';
}
return String(value);
}
function normalizeConfigByDefaultKeys<T extends Record<string, string>>(
readConfig: Record<string, unknown>,
defaultConfig: T,
): Partial<T> {
const normalizedConfig: Partial<T> = {};
for (const key of Object.keys(defaultConfig) as Array<keyof T>) {
const rawKey = key as string;
const camelKey = toCamelCaseKey(rawKey);
const value =
readConfig[camelKey] !== undefined
? readConfig[camelKey]
: readConfig[rawKey];
const normalized = normalizeConfigValue(value);
if (normalized !== undefined) {
normalizedConfig[key] = normalized as T[typeof key];
}
}
return normalizedConfig;
}
function parseConfigBoolean(value: unknown, defaultValue = false): boolean {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (defaultValue) {
return !(
normalized === '0' ||
normalized === 'false' ||
normalized === 'null'
);
}
return !(
normalized === '' ||
normalized === '0' ||
normalized === 'false' ||
normalized === 'null'
);
}
if (value == null) {
return defaultValue;
}
return Boolean(value);
}
function convertStringArray(str: string): string[] {
return (
str
?.split(',')
.map((s) => s.trim())
.filter((s) => s) || []
);
}
function convertIntArray(str: string): number[] {
return (
str
?.split(',')
.map((s) => parseInt(s.trim()))
.filter((n) => !isNaN(n)) || []
);
}
function convertFloatArray(str: string): number[] {
return (
str
?.split(',')
.map((s) => parseFloat(s.trim()))
.filter((n) => !isNaN(n)) || []
);
}
function convertBooleanArray(str: string, defaultValue = false): boolean[] {
return (
str
?.split(',')
.map((s) => parseConfigBoolean(s.trim(), defaultValue))
.filter((item) => typeof item === 'boolean') || []
);
}
function toTypedValue(value: string): string | number {
const trimmed = value.trim();
if (/^\d+$/.test(trimmed)) {
return Number.parseInt(trimmed, 10);
}
return trimmed;
}
export * from './configurer';
import { Configurer } from '../src/configurer/configurer';
type TestConfig = {
HOST: string;
PORT: string;
ENABLE_RECONNECT: string;
NO_CONNECT_COUNT_LIMIT: string;
ALT_VERSIONS: string;
FLOAT_VALUES: string;
BOOL_VALUES: string;
};
const defaultConfig: TestConfig = {
HOST: '::',
PORT: '7911',
ENABLE_RECONNECT: '1',
NO_CONNECT_COUNT_LIMIT: '',
ALT_VERSIONS: '2330,2331',
FLOAT_VALUES: '1.5,2.75',
BOOL_VALUES: '1,0,true,false,null',
};
describe('Configurer', () => {
test('loadConfig merges with priority: default < obj < env', () => {
const configurer = new Configurer(defaultConfig);
const instance = configurer.loadConfig({
obj: {
host: '0.0.0.0',
PORT: 9000,
},
env: {
PORT: '10000',
},
});
expect(instance.getString('HOST')).toBe('0.0.0.0');
expect(instance.getString('PORT')).toBe('10000');
});
test('getBoolean uses boolean default parsed from default config with defaultValue=false', () => {
const configurer = new Configurer(defaultConfig);
const withEmptyReconnect = configurer.loadConfig({
env: {
ENABLE_RECONNECT: '',
},
});
expect(withEmptyReconnect.getBoolean('ENABLE_RECONNECT')).toBe(true);
const withEmptyCountLimit = configurer.loadConfig({
env: {
NO_CONNECT_COUNT_LIMIT: '',
},
});
expect(withEmptyCountLimit.getBoolean('NO_CONNECT_COUNT_LIMIT')).toBe(
false,
);
});
test('getInt/getFloat/get arrays parse from getString', () => {
const configurer = new Configurer(defaultConfig);
const instance = configurer.loadConfig({
env: {
PORT: '8080',
ALT_VERSIONS: '3000,abc,3002',
FLOAT_VALUES: '3.5,NaN,4.25',
BOOL_VALUES: '1,0,true,false,,null',
},
});
expect(instance.getInt('PORT')).toBe(8080);
expect(instance.getFloat('PORT')).toBe(8080);
expect(instance.getStringArray('ALT_VERSIONS')).toEqual([
'3000',
'abc',
'3002',
]);
expect(instance.getIntArray('ALT_VERSIONS')).toEqual([3000, 3002]);
expect(instance.getFloatArray('FLOAT_VALUES')).toEqual([3.5, 4.25]);
expect(instance.getBooleanArray('BOOL_VALUES')).toEqual([
true,
false,
true,
false,
true,
false,
]);
});
test('generateExampleObject converts key to camelCase and parses number/array values', () => {
const configurer = new Configurer(defaultConfig);
const example = configurer.generateExampleObject();
expect(example.host).toBe('::');
expect(example.port).toBe(7911);
expect(example.altVersions).toEqual([2330, 2331]);
expect(example.floatValues).toEqual(['1.5', '2.75']);
});
});
......@@ -7,7 +7,8 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"declaration": true,
"sourceMap": true
"sourceMap": true,
"types": ["jest"]
},
"compileOnSave": true,
"allowJs": true,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment