@freemework/common
Version:
Common library of the Freemework Project.
582 lines (515 loc) • 19 kB
text/typescript
import { FExceptionArgument, FExceptionInvalidOperation } from "../exception/index.js";
import { FUtilUnReadonly } from "../util/index.js";
import { FConfigurationException } from "./f_configuration_exception.js";
import { FConfigurationValue } from "./f_configuration_value.js";
/**
* The `FConfiguration` provides contract to access key-value configuration sources
*/
export abstract class FConfiguration {
/**
* Construct configuration from json object
*
* Complex json object expands into plain dictionary of key-values.
*
* @example
* ```js
* const config: FConfiguration = FConfiguration.factoryJson({ a: { b: { c: 42 } } });
* assert.equal(config.get("a.b.c").asInteger, 42);
* ```
*
* @example
* ```js
* const config: FConfiguration = FConfiguration.factoryJson({ a: { b: [ { c: 40 }, { c: 41 }, { c: 42 } ] } });
*
* assert.equal(config.get("a.b.0.c").asString, "40");
* assert.equal(config.get("a.b.1.c").asString, "41");
* assert.equal(config.get("a.b.2.c").asString, "42");
*
* const array: ReadonlyArray<FConfiguration> = config.getArray("a.b");
* assert.equal(array.length, 3);
* assert.equal(array[0].get("c").asInteger, 40);
* assert.equal(array[1].get("c").asInteger, 41);
* assert.equal(array[2].get("c").asInteger, 42);
* ```
*/
public static factoryJson(jsonObject: any, indexFieldName: string = FConfiguration.DEFAULT_INDEX_KEY): FConfiguration {
/**
* Expand json to key-value dict
*
* @example
* ```js
* const jsonObject = {"a":{"b":{"c":"42"}}};
* const targetDict: { [key: string]: boolean | number | string; } = {};
* expandJson(jsonObject, targetDict);
* console.log(JSON.stringify(targetDict)); // {"a.b.c":"42"}
* ```
*/
function expandJson(jsonObject: any, targetDict: { [key: string]: boolean | number | string; }, parentName?: string): void {
for (let [name, value] of Object.entries(jsonObject)) {
if (Array.isArray(jsonObject)) {
if (typeof value === "object" && value !== null && indexFieldName in value) {
const { [indexFieldName]: index, ...rest } = value as any;
if (typeof index !== "string") {
const itemKeyName: string = parentName !== undefined ? `${parentName}.${name}` : name;
throw new FExceptionArgument(`Unreadable data. Unsupported index '${index}' for value of key '${itemKeyName}'. Expected index type of string.`, "jsonObject");
} else {
name = index;
value = rest;
}
}
}
const fullKeyName: string = parentName !== undefined ? `${parentName}.${name}` : name;
switch (typeof value) {
case "boolean":
case "number":
case "string":
targetDict[fullKeyName] = value;
break;
case "object":
// it is applicable for arrays too, due to Array.prototype.keys() returns indexes.
expandJson(value, targetDict, fullKeyName);
break;
default:
throw new FExceptionArgument(`Unreadable data. Unsupported type for value of key '${fullKeyName}'`, "jsonObject");
}
}
}
const jsonDict: { [key: string]: boolean | number | string; } = {};
expandJson(jsonObject, jsonDict);
const dict: FUtilUnReadonly<FConfigurationDictionary.Data> = {};
for (const [name, value] of Object.entries(jsonDict)) {
if (typeof value === "string") {
dict[name] = value === "" ? null : value;
} else if (value === null) {
dict[name] = null;
} else {
dict[name] = value.toString();
}
}
const encodedJson: string = encodeURIComponent(JSON.stringify(jsonObject));
const sourceURI: string = `configuration:json?data=${encodedJson}`;
return new FConfigurationDictionary(
new URL(sourceURI),
Object.freeze(dict)
);
}
protected static readonly DEFAULT_INDEXES_KEY: string = "indexes";
protected static readonly DEFAULT_INDEX_KEY: string = "index";
/**
* Gets configuration source URI.
*
* @remarks This value mostly need to debug purposes to understand source of a value.
*
* @see https://docs.freemework.org/configuration/FConfiguration#sourceURI?lang=typescript
*
* Each configuration source should represents an URI.
* For example:
* - configuration+file+properies:///path/to/file.properies
* - configuration+file+ini:///path/to/file.ini
* - configuration+file+json:///path/to/file.json
* - configuration+file+toml:///path/to/file.toml
* - configuration+file+yaml:///path/to/file.yml
* - configuration+consul://my.consul.host:????
* - configuration+redis://my.redis.host:6379/1
* - configuration+directory+keyperfile:///run/secrets
* - configuration:json?data=%7B%22a%22%3A42%7D
* - configuration:properies?data=...
* - configuration:toml?data=...
* - configuration:chain?sources=configuration:env,configuration%3Ajson%3Fdata%3D...,...
* - configuration:env
* - etc.
*/
public abstract get sourceURI(): URL;
/**
* Gets fully qualified configuration namespace name
*
* @example
* ```js
* const appCfg = FConfiguration.factoryJson({
* "runtime.server.ONE.listenAddress": "localhost",
* "runtime.server.ONE.listenPort": "8081",
* "runtime.server.TWO.listenAddress": "localhost",
* "runtime.server.TWO.listenPort": "8082",
* });
*
* console.log(appCfg.namespaceFull); // null
*
* const runtimeCfg = appCfg.getNamespace("runtime");
* console.log(runtimeCfg.namespaceFull); // "runtime"
*
* const serverTwoCfg = runtimeCfg.getNamespace("server.TWO");
* console.log(serverTwoCfg.namespaceFull); // "runtime.server.TWO"
* ```
*/
public abstract get namespaceFull(): string | null;
/**
* Gets parent configuration namespace name
*
* @example
* ```js
* const appCfg = FConfiguration.factoryJson({
* "runtime.server.ONE.listenAddress": "localhost",
* "runtime.server.ONE.listenPort": "8081",
* "runtime.server.TWO.listenAddress": "localhost",
* "runtime.server.TWO.listenPort": "8082",
* });
*
* console.log(appCfg.namespaceParent); // null
*
* const runtimeCfg = appCfg.getNamespace("runtime");
* console.log(runtimeCfg.namespaceParent); // "runtime"
*
* const serverTwoCfg = runtimeCfg.getNamespace("server.TWO");
* console.log(serverTwoCfg.namespaceParent); // "TWO"
* ```
*/
public abstract get namespaceParent(): string | null;
/**
* Gets list of keys available in current configuration
*
* @example
* ```js
* const appCfg = FConfiguration.factoryJson({
* "runtime.server.ONE.listenAddress": "localhost",
* "runtime.server.ONE.listenPort": "8081",
* "runtime.server.TWO.listenAddress": "localhost",
* "runtime.server.TWO.listenPort": "8082",
* });
*
* console.log(appCfg.keys); // ["runtime.server.ONE.listenAddress", "runtime.server.ONE.listenPort", "runtime.server.TWO.listenAddress", "runtime.server.TWO.listenPort"]
*
* const runtimeCfg = appCfg.getNamespace("runtime");
* console.log(runtimeCfg.keys); // ["server.ONE.listenAddress", "server.ONE.listenPort", "server.TWO.listenAddress", "server.TWO.listenPort"]
*
* const serverTwoCfg = appCfg.getNamespace("runtime.server.TWO");
* console.log(serverTwoCfg.keys); // ["listenAddress","listenPort"]
* ```
*/
public abstract get keys(): ReadonlyArray<string>;
/**
* Obtain array of sub-configurations
*
* @param key Name of array key
* @param indexesName Name of indexes key. Default: "indexes".
*
* @example
* ```js
* const appCfg = FConfiguration.factoryJson({
* "runtime.server.ONE.listenAddress": "localhost",
* "runtime.server.ONE.listenPort": "8081",
* "runtime.server.TWO.listenAddress": "localhost",
* "runtime.server.TWO.listenPort": "8082",
* "runtime.server.THREE.listenAddress": "localhost",
* "runtime.server.THREE.listenPort": "8083",
* "runtime.server.indexes": "ONE THREE",
* });
*
* const runtimeCfg = appCfg.getNamespace("runtime");
* const serverCfgs = runtimeCfg.getArray("server", "indexes");
*
* console.log(serverCfgs.length); // 2
*
* console.log(serverCfgs[0].keys); // ["listenAddress","listenPort"]
* console.log(serverCfgs[1].keys); // ["listenAddress","listenPort"]
*
* console.log(serverCfgs[0].namespaceFull); // runtime.server.ONE
* console.log(serverCfgs[0].get("listenAddress").asString); // localhost
* console.log(serverCfgs[0].get("listenPort").asInteger); // 8081
*
* console.log(serverCfgs[1].namespaceFull); // runtime.server.THREE
* console.log(serverCfgs[1].get("listenAddress").asString); // localhost
* console.log(serverCfgs[1].get("listenPort").asInteger); // 8083
* ```
*/
public abstract getArray(key: string, indexesName?: string): Array<FConfiguration>;
/**
* Get inner configuration for specific namespace.
*
* @throw `FConfigurationException` if no specific namespace found
* @returns Inner configuration
*
* @example
* ```js
* const appCfg = FConfiguration.factoryJson({
* "runtime.server.ONE.listenAddress": "localhost",
* "runtime.server.ONE.listenPort": "8081",
* "runtime.server.TWO.listenAddress": "localhost",
* "runtime.server.TWO.listenPort": "8082",
* });
*
* console.log(appCfg.namespaceFull); // ""
*
* const runtimeCfg = appCfg.getNamespace("runtime");
* console.log(runtimeCfg.namespaceFull); // "runtime"
*
* const serverTwoCfg = runtimeCfg.getNamespace("server.TWO");
* console.log(serverTwoCfg.namespaceFull); // "runtime.server.TWO"
* ```
*/
public abstract getNamespace(namespaceFull: string): FConfiguration;
/**
* @throws `FConfigurationException` if the `key` not found and no `defaultData` provided
*/
public abstract get(key: string, defaultData?: string | null): FConfigurationValue;
/**
* Find inner configuration for specific namespace.
*
* @returns Inner configuration or `null` if no specific namespace found
*
* @example
* ```js
* const appCfg = FConfiguration.factoryJson({
* "runtime.server.ONE.listenAddress": "localhost",
* "runtime.server.ONE.listenPort": "8081",
* "runtime.server.TWO.listenAddress": "localhost",
* "runtime.server.TWO.listenPort": "8082",
* });
*
* const setupCfg = appCfg.findNamespace("setup");
* console.log(setupCfg === null); // true
*
* const runtimeCfg = appCfg.findNamespace("runtime");
* console.log(runtimeCfg !== null); // true
* ```
*/
public abstract findNamespace(namespaceFull: string): FConfiguration | null;
public abstract find(key: string): FConfigurationValue | null;
public abstract hasNamespace(namespaceFull: string): boolean;
/**
* Checks whether key is presented in current configuration
*
* @param key Name of key
* @returns `true` is the key is presented, otherwise `false`
*
* @example
* ```js
* const appCfg = FConfiguration.factoryJson({
* "runtime.port": "8080",
* "runtime.loglevel": "DEBUG",
* });
*
* const runtimeCfg = appCfg.getNamespace("runtime");
* const isLogLevelPresented = runtimeCfg.has("loglevel")
*
* console.log(isLogLevelPresented); // true
* ```
*/
public abstract has(key: string): boolean;
public toDynamicView(
opts: {
readonly strict: boolean;
} = {
strict: true,
}
): any {
function keyWalker(rootObj: any, keys: ReadonlyArray<string>, sourceConfig: FConfiguration, parentObject: any) {
const target: any = {};
if (rootObj === null) {
rootObj = target;
}
target["$root"] = rootObj;
const dottedKeys = new Map();
for (const key of keys) {
const dotIndex = key.indexOf(".");
if (dotIndex === -1) {
target[key] = sourceConfig.get(key).asString;
} else {
const parentKey = key.substring(0, dotIndex);
const subKey = key.substring(dotIndex + 1);
if (dottedKeys.has(parentKey)) {
dottedKeys.get(parentKey).push(subKey);
} else {
dottedKeys.set(parentKey, [subKey]);
}
}
}
for (const [parentKey, subKeys] of dottedKeys.entries()) {
const configNamespaceParent = sourceConfig.namespaceParent
const isSingle = configNamespaceParent !== null && sourceConfig.keys.length === 1;
const inner = keyWalker(rootObj, subKeys, sourceConfig.getNamespace(parentKey), target);
target[parentKey] = inner;
target[`?${parentKey}`] = inner;
const array = Object.keys(inner).filter(key => !key.startsWith("?") && key !== "$parent" && key !== "$root").map(key => {
const innerObj = inner[key];
if (typeof innerObj === "string" || typeof innerObj === "number" || typeof innerObj === "boolean") {
return innerObj;
}
const wrap = { ...innerObj };
Object.defineProperty(wrap, "$parent", {
get: function () {
return innerObj["$parent"]["$parent"];
}
});
return wrap;
});
inner["$array"] = array;
inner["$single"] = function () {
if (!isSingle) {
throw new Error(`Single constraint violation for property '${parentKey}' in namespace '${configNamespaceParent}'`);
}
const { "$single": _, ...rest } = inner;
return rest;
}
}
if (parentObject !== null) {
target["$parent"] = parentObject;
}
return target;
}
const objectConfig = keyWalker(null, this.keys, this, null);
function makeProxyAdapter(ns: ReadonlyArray<string>, obj: any) {
return new Proxy(obj, {
get(_, property) {
if (typeof property === "string") {
let objProperty = property;
if (property.startsWith("?")) {
objProperty = property.substring(1);
}
if (objProperty in obj) {
const value = obj[objProperty];
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
} else {
return makeProxyAdapter([
...ns,
property, // yep, we need original property name (with ? prefix for optional) to make Mustache work correctly
], value);
}
}
const fullProperty = [...ns, property].join(".");
const isOptionalProperty = fullProperty.includes("?");
if (!isOptionalProperty) {
if (opts.strict) {
throw new FExceptionInvalidOperation(`Non-existing property request '${fullProperty}'.`);
} else {
return null;
}
}
}
return null;
},
});
}
const proxyConfig = makeProxyAdapter([], objectConfig);
return proxyConfig;
}
}
// import { FConfigurationDictionary } from "./f_configuration_dictionary.js"; // Import here due to cyclic dependencies
export class FConfigurationDictionary extends FConfiguration {
private static readonly NAMESPACE_DELIMITER_SYMBOL = ".";
private readonly _dict: FConfigurationDictionary.Data;
private readonly _sourceURI: URL;
private readonly _configurationNamespace: string | null;
private _keys: ReadonlyArray<string> | null;
public constructor(sourceURI: URL, dict: FConfigurationDictionary.Data, namespaceFull?: string) {
super();
this._dict = Object.freeze({ ...dict });
this._sourceURI = sourceURI;
this._configurationNamespace = namespaceFull !== undefined ? namespaceFull : null;
this._keys = null;
}
public get namespaceFull(): string | null {
return this._configurationNamespace;
}
public get namespaceParent(): string | null {
const configurationNamespace = this._configurationNamespace;
if (configurationNamespace === null) { return null; }
const indexOfLastDelimiter: number = configurationNamespace.lastIndexOf(FConfigurationDictionary.NAMESPACE_DELIMITER_SYMBOL);
if (indexOfLastDelimiter === -1) {
return configurationNamespace;
}
return configurationNamespace.substring(indexOfLastDelimiter + 1);
}
public get keys(): ReadonlyArray<string> {
return this._keys !== null ? this._keys : (this._keys = Object.freeze(Object.keys(this._dict)));
}
public get sourceURI(): URL {
return this._sourceURI;
}
public findNamespace(_: string): FConfiguration | null {
throw new Error("Method not implemented.");
}
public find(key: string): FConfigurationValue | null {
if (key in this._dict) {
const valueData = this._dict[key]!;
const namespaceFull = this.namespaceFull;
const fullKey = namespaceFull !== null ? `${namespaceFull}.${key}` : key;
const value: FConfigurationValue = FConfigurationValue.factory(
fullKey,
valueData,
this.sourceURI,
null
);
return value;
} else {
return null;
}
}
public getNamespace(namespaceFull: string): FConfiguration {
const innerDict: FUtilUnReadonly<FConfigurationDictionary.Data> = {};
const criteria = namespaceFull + FConfigurationDictionary.NAMESPACE_DELIMITER_SYMBOL;
const criteriaLen = criteria.length;
Object.keys(this._dict).forEach((key) => {
if (key.length > criteriaLen && key.startsWith(criteria)) {
const value = this._dict[key]!;
innerDict[key.substring(criteriaLen)] = value;
}
});
const innerConfigurationNamespace = this._configurationNamespace !== null ?
`${this._configurationNamespace}.${namespaceFull}` : namespaceFull;
if (Object.keys(innerDict).length === 0) {
throw new FConfigurationException(
`Namespace '${innerConfigurationNamespace}' was not found in the configuration.`,
innerConfigurationNamespace
);
}
return new FConfigurationDictionary(this.sourceURI, innerDict, innerConfigurationNamespace);
}
public get(key: string, defaultData?: string | null): FConfigurationValue {
const namespaceFull = this.namespaceFull;
const fullKey = namespaceFull !== null ? `${namespaceFull}.${key}` : key;
if (key in this._dict) {
let valueData = this._dict[key];
if (valueData === null && defaultData !== undefined) {
valueData = defaultData;
}
const value: FConfigurationValue = FConfigurationValue.factory(
fullKey, valueData!, this.sourceURI, null
);
return value;
} else if (defaultData !== undefined) {
const value: FConfigurationValue = FConfigurationValue.factory(
fullKey, defaultData, this.sourceURI, null
);
return value;
} else {
throw new FConfigurationException("Current configuration does not have such key. Check your configuration.", key);
}
}
public getArray(key: string, indexesName: string = FConfiguration.DEFAULT_INDEXES_KEY): Array<FConfiguration> {
const arrayNS = this.getNamespace(key);
const arrayIndexes: Array<string> = arrayNS.get(indexesName).asString
.split(" ")
.filter(s => s !== "");
const arrayNamespaces: Array<FConfiguration> = arrayIndexes.map(s => {
return arrayNS.getNamespace(s);
});
return arrayNamespaces;
}
public hasNamespace(namespaceFull: string): boolean {
const criteria = namespaceFull + FConfigurationDictionary.NAMESPACE_DELIMITER_SYMBOL;
const criteriaLen = criteria.length;
for (const key of Object.keys(this._dict)) {
if (key.length > criteriaLen && key.startsWith(criteria)) {
return true;
}
}
return false;
}
public has(key: string): boolean {
return key in this._dict;
}
}
export namespace FConfigurationDictionary {
export type Data = { readonly [key: string]: string | null };
}