@travetto/config
Version:
Configuration support
166 lines (145 loc) • 5.89 kB
text/typescript
import util from 'node:util';
import { AppError, toConcrete, castTo, Class, ClassInstance, Env, Runtime, RuntimeResources } from '@travetto/runtime';
import { DependencyRegistry, Injectable } from '@travetto/di';
import { BindUtil, DataUtil, SchemaRegistry, SchemaValidator, ValidationResultError } from '@travetto/schema';
import { ParserManager } from './parser/parser.ts';
import { ConfigData } from './parser/types.ts';
import { ConfigSource, ConfigSpec } from './source/types.ts';
import { FileConfigSource } from './source/file.ts';
import { OverrideConfigSource } from './source/override.ts';
type ConfigSpecSimple = Omit<ConfigSpec, 'data'>;
/**
* Common Type for all configuration classes
*/
export class ConfigBaseType { }
/**
* Manager for application configuration
*/
()
export class ConfigurationService {
#storage: Record<string, unknown> = {}; // Lowered, and flattened
#specs: ConfigSpecSimple[] = [];
#secrets: (RegExp | string)[] = [/secure(-|_|[a-z])|password|private|secret|salt|(\bkey|key\b)|serviceAccount|(api(-|_)?key)/i];
/**
* Get a sub tree of the config, or everything if namespace is not passed
* @param ns The namespace of the config to search for, can be dotted for accessing sub namespaces
*/
#get(ns?: string): Record<string, unknown> {
const parts = (ns ? ns.split('.') : []);
let sub: Record<string, unknown> = this.#storage;
while (parts.length && sub) {
const next = parts.shift()!;
sub = castTo(sub[next]);
}
return sub;
}
/**
* Load configurations for active profiles. Load order is defined by:
* - First in order of profile names (application, ...specified, override)
* - When dealing with two profiles of the same name, they are then sorted by priority
* - If of the same priority, then alpha sort on the source
*/
async postConstruct(): Promise<void> {
const providers = await DependencyRegistry.getCandidateTypes(toConcrete<ConfigSource>());
const configs = await Promise.all(
providers.map(async (el) => await DependencyRegistry.getInstance(el.class, el.qualifier))
);
const parser = await DependencyRegistry.getInstance(ParserManager);
const possible = await Promise.all([
new FileConfigSource(parser),
...configs,
new OverrideConfigSource()
].map(src => src.get()));
const specs = possible
.flat()
.filter(x => !!x)
.toSorted((a, b) => a.priority - b.priority);
for (const spec of specs) {
DataUtil.deepAssign(this.#storage, BindUtil.expandPaths(spec.data), 'coerce');
}
this.#specs = specs.map(({ data: _, ...v }) => v);
// Initialize Secrets
const userSpecified = (this.#get('config')?.secrets ?? []);
for (const el of Array.isArray(userSpecified) ? userSpecified : [userSpecified]) {
if (el !== undefined && el !== null && typeof el === 'string') {
if (el.startsWith('/')) {
this.#secrets.push(DataUtil.coerceType(el, RegExp, true));
} else {
this.#secrets.push(DataUtil.coerceType(el, String, true));
}
}
}
}
/**
* Export all active configuration, useful for displaying active state
* - Will not show fields marked as secret
*/
async exportActive(): Promise<{ sources: ConfigSpecSimple[], active: ConfigData }> {
const configTargets = await DependencyRegistry.getCandidateTypes(ConfigBaseType);
const configs = await Promise.all(
configTargets
.filter(el => el.qualifier === DependencyRegistry.get(el.class).qualifier) // Is primary?
.toSorted((a, b) => a.class.name.localeCompare(b.class.name))
.map(async el => {
const inst = await DependencyRegistry.getInstance<ClassInstance>(el.class, el.qualifier);
return [el, inst] as const;
})
);
const out: Record<string, ConfigData> = {};
for (const [el, inst] of configs) {
const data = BindUtil.bindSchemaToObject<ConfigData>(
inst.constructor, {}, inst, { filterField: f => !f.secret, filterValue: v => v !== undefined }
);
out[el.class.name] = DataUtil.filterByKeys(data, this.#secrets);
}
return { sources: this.#specs, active: out };
}
/**
* Bind and validate configuration into class instance
*/
async bindTo<T>(cls: Class<T>, item: T, namespace: string, validate = true): Promise<T> {
const classId = cls.Ⲑid;
if (!SchemaRegistry.has(cls)) {
throw new AppError(`${classId} is not a valid schema class, config is not supported`);
}
BindUtil.bindSchemaToObject(cls, item, this.#get(namespace));
if (validate) {
try {
await SchemaValidator.validate(cls, item);
} catch (err) {
if (err instanceof ValidationResultError) {
const ogMessage = err.message;
err.message = `Failed to construct ${classId} as validation errors have occurred`;
err.stack = err.stack?.replace(ogMessage, err.message);
const imp = Runtime.getImport(cls);
Object.defineProperty(err, 'details', { value: { class: classId, import: imp, ...(err.details ?? {}) } });
}
throw err;
}
}
return item;
}
/**
* Log current configuration state
*/
async initBanner(): Promise<void> {
const ogDepth = util.inspect.defaultOptions.depth;
util.inspect.defaultOptions.depth = 100;
console.log('Initialized', {
manifest: {
main: Runtime.main,
workspace: Runtime.workspace
},
runtime: {
env: Runtime.env,
debug: Runtime.debug,
production: Runtime.production,
dynamic: Runtime.dynamic,
resourcePaths: RuntimeResources.searchPaths,
profiles: Env.TRV_PROFILES.list ?? []
},
config: await this.exportActive()
});
util.inspect.defaultOptions.depth = ogDepth;
}
}