@travetto/config 3.0.0-rc.4 → 3.0.0-rc.7
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/README.md +34 -35
- package/__index__.ts +10 -0
- package/package.json +10 -12
- package/src/configuration.ts +119 -0
- package/src/decorator.ts +27 -10
- package/src/internal/types.ts +10 -0
- package/src/parser/json.ts +9 -0
- package/src/parser/properties.ts +54 -0
- package/src/parser/types.ts +9 -0
- package/src/parser/yaml.ts +10 -0
- package/src/source/file.ts +61 -0
- package/src/source/memory.ts +26 -0
- package/src/source/override.ts +39 -0
- package/src/source/types.ts +11 -0
- package/index.ts +0 -2
- package/src/internal/util.ts +0 -129
- package/src/manager.ts +0 -133
- package/support/phase.init.ts +0 -11
package/README.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
<!-- This file was generated by @travetto/doc and should not be modified directly -->
|
|
2
|
-
<!-- Please modify https://github.com/travetto/travetto/tree/main/module/config/
|
|
2
|
+
<!-- Please modify https://github.com/travetto/travetto/tree/main/module/config/DOC.ts and execute "npx trv doc" to rebuild -->
|
|
3
3
|
# Configuration
|
|
4
|
-
##
|
|
4
|
+
## Configuration support
|
|
5
5
|
|
|
6
6
|
**Install: @travetto/config**
|
|
7
7
|
```bash
|
|
8
8
|
npm install @travetto/config
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
The config module provides support for loading application config on startup. Configuration values support the common [YAML](https://en.wikipedia.org/wiki/YAML) constructs as defined in [YAML](https://github.com/travetto/travetto/tree/main/module/yaml#readme "Simple YAML support, provides only clean subset of yaml"). Additionally, the configuration is built upon the [Schema](https://github.com/travetto/travetto/tree/main/module/schema#readme "Data type registry for runtime validation, reflection and binding.
|
|
12
|
-
entrypoint into the application. Given that all [@Config](https://github.com/travetto/travetto/tree/main/module/config/src/decorator.ts#
|
|
11
|
+
The config module provides support for loading application config on startup. Configuration values support the common [YAML](https://en.wikipedia.org/wiki/YAML) constructs as defined in [YAML](https://github.com/travetto/travetto/tree/main/module/yaml#readme "Simple YAML support, provides only clean subset of yaml"). Additionally, the configuration is built upon the [Schema](https://github.com/travetto/travetto/tree/main/module/schema#readme "Data type registry for runtime validation, reflection and binding.") module, to enforce type correctness, and allow for validation of configuration as an
|
|
12
|
+
entrypoint into the application. Given that all [@Config](https://github.com/travetto/travetto/tree/main/module/config/src/decorator.ts#L13) classes are [@Schema](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/schema.ts#L14)-based classes, all the standard [@Schema](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/schema.ts#L14) and [@Field](https://github.com/travetto/travetto/tree/main/module/schema/src/decorator/field.ts#L38) functionality applies.
|
|
13
13
|
|
|
14
14
|
## Resolution
|
|
15
15
|
|
|
@@ -66,32 +66,40 @@ At runtime the resolved config would be:
|
|
|
66
66
|
|
|
67
67
|
**Terminal: Runtime Resolution**
|
|
68
68
|
```bash
|
|
69
|
-
$
|
|
69
|
+
$ trv main support/main.resolve.ts
|
|
70
70
|
|
|
71
71
|
Config {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
sources: [
|
|
73
|
+
'application.1 - file://application.yml',
|
|
74
|
+
'override.3 - memory://override'
|
|
75
|
+
],
|
|
76
|
+
active: {
|
|
77
|
+
DBConfig: {
|
|
78
|
+
host: 'localhost',
|
|
79
|
+
port: 2000,
|
|
80
|
+
creds: creds_7_45Ⲑsyn { user: 'test', password: 'test' }
|
|
81
|
+
}
|
|
76
82
|
}
|
|
77
83
|
}
|
|
84
|
+
[s[r[u
|
|
78
85
|
```
|
|
79
86
|
|
|
80
87
|
## Secrets
|
|
81
88
|
By default, when in production mode, the application startup will request redacted secrets to log out. These secrets follow a standard set of rules, but can be amended by listing regular expressions under `config.redacted`.
|
|
82
89
|
|
|
83
90
|
## Consuming
|
|
84
|
-
The [
|
|
91
|
+
The [Configuration](https://github.com/travetto/travetto/tree/main/module/config/src/configuration.ts#L14) service provides injectable access to all of the loaded configuration. For simplicity, a decorator, [@Config](https://github.com/travetto/travetto/tree/main/module/config/src/decorator.ts#L13) allows for classes to automatically be bound with config information on post construction via the [Dependency Injection](https://github.com/travetto/travetto/tree/main/module/di#readme "Dependency registration/management and injection support.") module. The decorator will install a `postConstruct` method if not already defined, that performs the binding of configuration. This is due to the fact that we cannot rewrite the constructor, and order of operation matters.
|
|
85
92
|
|
|
86
93
|
The decorator takes in a namespace, of what part of the resolved configuration you want to bind to your class. Given the following class:
|
|
87
94
|
|
|
88
95
|
**Code: Database config object**
|
|
89
96
|
```typescript
|
|
90
|
-
import { Config } from '@travetto/config';
|
|
97
|
+
import { Config, EnvVar } from '@travetto/config';
|
|
91
98
|
|
|
92
99
|
@Config('database')
|
|
93
100
|
export class DBConfig {
|
|
94
101
|
host: string;
|
|
102
|
+
@EnvVar('DATABASE_PORT')
|
|
95
103
|
port: number;
|
|
96
104
|
creds: {
|
|
97
105
|
user: string;
|
|
@@ -104,25 +112,9 @@ Using the above config files, you'll notice that the port is not specified (its
|
|
|
104
112
|
|
|
105
113
|
**Terminal: Resolved database config**
|
|
106
114
|
```bash
|
|
107
|
-
$
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
message: 'Failed to construct @trv:config/doc/dbconfig○DBConfig as validation errors have occurred',
|
|
111
|
-
category: 'data',
|
|
112
|
-
type: 'ValidationResultError',
|
|
113
|
-
at: 2022-03-14T04:00:00.618Z,
|
|
114
|
-
class: '@trv:config/doc/dbconfig○DBConfig',
|
|
115
|
-
file: '@trv:config/doc/dbconfig.ts',
|
|
116
|
-
errors: [
|
|
117
|
-
{
|
|
118
|
-
kind: 'required',
|
|
119
|
-
value: undefined,
|
|
120
|
-
message: 'port is required',
|
|
121
|
-
path: 'port',
|
|
122
|
-
type: undefined
|
|
123
|
-
}
|
|
124
|
-
]
|
|
125
|
-
}
|
|
115
|
+
$ trv main support/main.dbconfig-run.ts
|
|
116
|
+
|
|
117
|
+
[s[r[u
|
|
126
118
|
```
|
|
127
119
|
|
|
128
120
|
What you see, is that the configuration structure must be honored and the application will fail to start if the constraints do not hold true. This helps to ensure that the configuration, as input to the system, is verified and correct.
|
|
@@ -131,13 +123,20 @@ By passing in the port via the environment variable, the config will construct p
|
|
|
131
123
|
|
|
132
124
|
**Terminal: Resolved database config**
|
|
133
125
|
```bash
|
|
134
|
-
$
|
|
126
|
+
$ trv main support/main.dbconfig-run.ts
|
|
135
127
|
|
|
136
128
|
Config {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
129
|
+
sources: [
|
|
130
|
+
'application.1 - file://application.yml',
|
|
131
|
+
'override.3 - memory://override'
|
|
132
|
+
],
|
|
133
|
+
active: {
|
|
134
|
+
DBConfig: {
|
|
135
|
+
host: 'localhost',
|
|
136
|
+
port: 200,
|
|
137
|
+
creds: creds_7_45Ⲑsyn { user: 'test', password: 'test' }
|
|
138
|
+
}
|
|
141
139
|
}
|
|
142
140
|
}
|
|
141
|
+
[s[r[u
|
|
143
142
|
```
|
package/__index__.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './src/decorator';
|
|
2
|
+
export * from './src/configuration';
|
|
3
|
+
export * from './src/source/file';
|
|
4
|
+
export * from './src/parser/json';
|
|
5
|
+
export * from './src/parser/types';
|
|
6
|
+
export * from './src/parser/properties';
|
|
7
|
+
export * from './src/source/override';
|
|
8
|
+
export * from './src/source/memory';
|
|
9
|
+
export * from './src/source/types';
|
|
10
|
+
export * from './src/parser/yaml';
|
package/package.json
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@travetto/config",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"description": "Environment-aware config management using yaml files",
|
|
3
|
+
"version": "3.0.0-rc.7",
|
|
4
|
+
"description": "Configuration support",
|
|
6
5
|
"keywords": [
|
|
7
6
|
"yaml",
|
|
8
7
|
"decorators",
|
|
@@ -18,22 +17,21 @@
|
|
|
18
17
|
"name": "Travetto Framework"
|
|
19
18
|
},
|
|
20
19
|
"files": [
|
|
21
|
-
"
|
|
22
|
-
"src"
|
|
23
|
-
"support"
|
|
20
|
+
"__index__.ts",
|
|
21
|
+
"src"
|
|
24
22
|
],
|
|
25
|
-
"main": "
|
|
23
|
+
"main": "__index__.ts",
|
|
26
24
|
"repository": {
|
|
27
25
|
"url": "https://github.com/travetto/travetto.git",
|
|
28
26
|
"directory": "module/config"
|
|
29
27
|
},
|
|
30
28
|
"dependencies": {
|
|
31
|
-
"@travetto/
|
|
32
|
-
"@travetto/
|
|
33
|
-
"@travetto/yaml": "^3.0.0-rc.
|
|
29
|
+
"@travetto/di": "^3.0.0-rc.7",
|
|
30
|
+
"@travetto/schema": "^3.0.0-rc.7",
|
|
31
|
+
"@travetto/yaml": "^3.0.0-rc.5"
|
|
34
32
|
},
|
|
35
|
-
"
|
|
36
|
-
"
|
|
33
|
+
"travetto": {
|
|
34
|
+
"displayName": "Configuration"
|
|
37
35
|
},
|
|
38
36
|
"publishConfig": {
|
|
39
37
|
"access": "public"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { AppError, Class, ClassInstance, GlobalEnv, DataUtil } from '@travetto/base';
|
|
2
|
+
import { DependencyRegistry, Injectable } from '@travetto/di';
|
|
3
|
+
import { RootIndex } from '@travetto/manifest';
|
|
4
|
+
import { BindUtil, SchemaRegistry, SchemaValidator, ValidationResultError } from '@travetto/schema';
|
|
5
|
+
|
|
6
|
+
import { ConfigSourceTarget, ConfigTarget } from './internal/types';
|
|
7
|
+
import { ConfigData } from './parser/types';
|
|
8
|
+
import { ConfigSource, ConfigValue } from './source/types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Manager for application configuration
|
|
12
|
+
*/
|
|
13
|
+
@Injectable()
|
|
14
|
+
export class Configuration {
|
|
15
|
+
|
|
16
|
+
private static getSorted(configs: ConfigValue[], profiles: string[]): ConfigValue[] {
|
|
17
|
+
const order = Object.fromEntries(Object.entries(profiles).map(([k, v]) => [v, +k] as const));
|
|
18
|
+
|
|
19
|
+
return configs.sort((left, right) =>
|
|
20
|
+
(order[left.profile] - order[right.profile]) ||
|
|
21
|
+
left.priority - right.priority ||
|
|
22
|
+
left.source.localeCompare(right.source)
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#storage: Record<string, unknown> = {}; // Lowered, and flattened
|
|
27
|
+
#profiles: string[] = ['application', ...GlobalEnv.profiles, 'override'];
|
|
28
|
+
#sources: string[] = [];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get a sub tree of the config, or everything if namespace is not passed
|
|
32
|
+
* @param ns The namespace of the config to search for, can be dotted for accessing sub namespaces
|
|
33
|
+
*/
|
|
34
|
+
#get(ns?: string): Record<string, unknown> {
|
|
35
|
+
const parts = (ns ? ns.split('.') : []);
|
|
36
|
+
let sub: Record<string, unknown> = this.#storage;
|
|
37
|
+
|
|
38
|
+
while (parts.length && sub) {
|
|
39
|
+
const next = parts.shift()!;
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
41
|
+
sub = sub[next] as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return sub;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Load configurations for active profiles. Load order is defined by:
|
|
49
|
+
* - First in order of profile names (application, ...specified, override)
|
|
50
|
+
* - When dealing with two profiles of the same name, they are then sorted by priority
|
|
51
|
+
* - If of the same priority, then alpha sort on the source
|
|
52
|
+
*/
|
|
53
|
+
async postConstruct(): Promise<void> {
|
|
54
|
+
const providers = await DependencyRegistry.getCandidateTypes(ConfigSourceTarget);
|
|
55
|
+
|
|
56
|
+
const configs = await Promise.all(
|
|
57
|
+
providers.map(async (el) => {
|
|
58
|
+
const inst = await DependencyRegistry.getInstance<ConfigSource>(el.class, el.qualifier);
|
|
59
|
+
return inst.getValues(this.#profiles);
|
|
60
|
+
})
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const sorted = Configuration.getSorted(configs.flat(), this.#profiles);
|
|
64
|
+
|
|
65
|
+
this.#sources = sorted.map(x => `${x.profile}.${x.priority} - ${x.source}`);
|
|
66
|
+
|
|
67
|
+
for (const { config: element } of sorted) {
|
|
68
|
+
DataUtil.deepAssign(this.#storage, BindUtil.expandPaths(element), 'coerce');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Export all active configuration, useful for displaying active state
|
|
74
|
+
* - Will not show fields marked as secret
|
|
75
|
+
*/
|
|
76
|
+
async exportActive(): Promise<{ sources: string[], active: ConfigData }> {
|
|
77
|
+
const configTargets = await DependencyRegistry.getCandidateTypes(ConfigTarget);
|
|
78
|
+
const configs = await Promise.all(
|
|
79
|
+
configTargets
|
|
80
|
+
.filter(el => el.qualifier === DependencyRegistry.get(el.class).qualifier) // Is primary?
|
|
81
|
+
.sort((a, b) => a.class.name.localeCompare(b.class.name))
|
|
82
|
+
.map(async el => {
|
|
83
|
+
const inst = await DependencyRegistry.getInstance<ClassInstance>(el.class, el.qualifier);
|
|
84
|
+
return [el, inst] as const;
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
const out: Record<string, ConfigData> = {};
|
|
88
|
+
for (const [el, inst] of configs) {
|
|
89
|
+
const data = BindUtil.bindSchemaToObject<ConfigData>(
|
|
90
|
+
inst.constructor, {}, inst, { filterField: f => !f.secret, filterValue: v => v !== undefined }
|
|
91
|
+
);
|
|
92
|
+
out[el.class.name] = data;
|
|
93
|
+
}
|
|
94
|
+
return { sources: this.#sources, active: out };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Bind and validate configuration into class instance
|
|
99
|
+
*/
|
|
100
|
+
async bindTo<T>(cls: Class<T>, item: T, namespace: string, validate = true): Promise<T> {
|
|
101
|
+
if (!SchemaRegistry.has(cls)) {
|
|
102
|
+
throw new AppError(`${cls.Ⲑid} is not a valid schema class, config is not supported`);
|
|
103
|
+
}
|
|
104
|
+
const out = BindUtil.bindSchemaToObject(cls, item, this.#get(namespace));
|
|
105
|
+
if (validate) {
|
|
106
|
+
try {
|
|
107
|
+
await SchemaValidator.validate(cls, out);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err instanceof ValidationResultError) {
|
|
110
|
+
err.message = `Failed to construct ${cls.Ⲑid} as validation errors have occurred`;
|
|
111
|
+
const file = RootIndex.getFunctionMetadata(cls)!.source;
|
|
112
|
+
err.payload = { class: cls.Ⲑid, file, ...(err.payload ?? {}) };
|
|
113
|
+
}
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/decorator.ts
CHANGED
|
@@ -1,22 +1,39 @@
|
|
|
1
|
-
import { Class } from '@travetto/base';
|
|
2
|
-
import {
|
|
1
|
+
import { Class, ClassInstance } from '@travetto/base';
|
|
2
|
+
import { DependencyRegistry } from '@travetto/di';
|
|
3
|
+
import { SchemaRegistry } from '@travetto/schema';
|
|
4
|
+
|
|
5
|
+
import { Configuration } from './configuration';
|
|
6
|
+
import { ConfigTarget, ConfigOverrides, CONFIG_OVERRIDES } from './internal/types';
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Indicates that the given class should be populated with the configured fields, on instantiation
|
|
6
|
-
* @augments `@
|
|
7
|
-
* @augments `@
|
|
10
|
+
* @augments `@travetto/schema:Schema`
|
|
11
|
+
* @augments `@travetto/di:Injectable`
|
|
8
12
|
*/
|
|
9
|
-
export function Config(ns: string
|
|
13
|
+
export function Config(ns: string) {
|
|
10
14
|
return <T extends Class>(target: T): T => {
|
|
11
|
-
const og = target.prototype.postConstruct;
|
|
15
|
+
const og: Function = target.prototype.postConstruct;
|
|
16
|
+
// Declare as part of global config
|
|
17
|
+
(DependencyRegistry.getOrCreatePending(target).interfaces ??= []).push(ConfigTarget);
|
|
18
|
+
const env = SchemaRegistry.getOrCreatePendingMetadata<ConfigOverrides>(target, CONFIG_OVERRIDES, { ns, fields: {} });
|
|
19
|
+
env.ns = ns;
|
|
12
20
|
|
|
13
21
|
target.prototype.postConstruct = async function (): Promise<void> {
|
|
14
22
|
// Apply config
|
|
15
|
-
await
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
23
|
+
const cfg = await DependencyRegistry.getInstance(Configuration);
|
|
24
|
+
await cfg.bindTo(target, this, ns);
|
|
25
|
+
await og?.call(this);
|
|
19
26
|
};
|
|
20
27
|
return target;
|
|
21
28
|
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Allows for binding specific fields to environment variables as a top-level override
|
|
33
|
+
*/
|
|
34
|
+
export function EnvVar(name: string) {
|
|
35
|
+
return (inst: ClassInstance, prop: string): void => {
|
|
36
|
+
const env = SchemaRegistry.getOrCreatePendingMetadata<ConfigOverrides>(inst.constructor, CONFIG_OVERRIDES, { ns: '', fields: {} });
|
|
37
|
+
env.fields[prop] = (): string | undefined => process.env[name];
|
|
38
|
+
};
|
|
22
39
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export abstract class ConfigSourceTarget { }
|
|
2
|
+
export abstract class ConfigTarget { }
|
|
3
|
+
export abstract class ConfigParserTarget { }
|
|
4
|
+
|
|
5
|
+
export const CONFIG_OVERRIDES = Symbol.for('@travetto/config:field-override');
|
|
6
|
+
|
|
7
|
+
export type ConfigOverrides = {
|
|
8
|
+
ns: string;
|
|
9
|
+
fields: Record<string, () => (unknown | undefined)>;
|
|
10
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Injectable } from '@travetto/di';
|
|
2
|
+
|
|
3
|
+
import { ConfigData, ConfigParser } from './types';
|
|
4
|
+
|
|
5
|
+
const BACKSLASH = '\\'.charCodeAt(0);
|
|
6
|
+
const EQUALS = '='.charCodeAt(0);
|
|
7
|
+
const COLON = ':'.charCodeAt(0);
|
|
8
|
+
const HASH = '#'.charCodeAt(0);
|
|
9
|
+
const EXCL = '!'.charCodeAt(0);
|
|
10
|
+
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class PropertiesConfigParser implements ConfigParser {
|
|
13
|
+
|
|
14
|
+
static parseLine(text: string): [key: string, value: string] | undefined {
|
|
15
|
+
if (text.charCodeAt(0) === HASH || text.charCodeAt(0) === EXCL) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const key: number[] = [];
|
|
19
|
+
let value: string | undefined;
|
|
20
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
21
|
+
const ch = text.charCodeAt(i);
|
|
22
|
+
if (ch === EQUALS || ch === COLON) { // Break
|
|
23
|
+
value = text.substring(i + 1).trimStart();
|
|
24
|
+
break;
|
|
25
|
+
} else if (ch === BACKSLASH) {
|
|
26
|
+
key.push(text.charCodeAt(i += 1));
|
|
27
|
+
} else {
|
|
28
|
+
key.push(ch);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (value) {
|
|
32
|
+
return [String.fromCharCode(...key).trimEnd(), value];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
ext = ['properties'];
|
|
37
|
+
|
|
38
|
+
parse(text: string): ConfigData {
|
|
39
|
+
const out: ConfigData = {};
|
|
40
|
+
const lines = text.split(/\n/g);
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < lines.length; i++) {
|
|
43
|
+
let line = lines[i];
|
|
44
|
+
while (i < text.length && line.endsWith('\\')) {
|
|
45
|
+
line = `${line.replace(/\\$/, '')}${lines[i += 1].trimStart()}`;
|
|
46
|
+
}
|
|
47
|
+
const entry = PropertiesConfigParser.parseLine(line);
|
|
48
|
+
if (entry) {
|
|
49
|
+
out[entry[0]] = entry[1];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Injectable } from '@travetto/di';
|
|
2
|
+
import { YamlUtil } from '@travetto/yaml';
|
|
3
|
+
|
|
4
|
+
import { ConfigParser } from './types';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class YAMLConfigParser implements ConfigParser {
|
|
8
|
+
ext = ['yaml', 'yml'];
|
|
9
|
+
parse = YamlUtil.parse;
|
|
10
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { FileQueryProvider } from '@travetto/base';
|
|
2
|
+
import { DependencyRegistry, InjectableFactory } from '@travetto/di';
|
|
3
|
+
|
|
4
|
+
import { ConfigParserTarget } from '../internal/types';
|
|
5
|
+
import { ConfigParser } from '../parser/types';
|
|
6
|
+
import { ConfigSource, ConfigValue } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* File-base config source, builds on common file resource provider
|
|
10
|
+
*/
|
|
11
|
+
export class FileConfigSource extends FileQueryProvider implements ConfigSource {
|
|
12
|
+
|
|
13
|
+
@InjectableFactory()
|
|
14
|
+
static getInstance(): ConfigSource {
|
|
15
|
+
return new FileConfigSource();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
depth = 1;
|
|
19
|
+
extMatch: RegExp;
|
|
20
|
+
parsers: Record<string, ConfigParser>;
|
|
21
|
+
priority = 1;
|
|
22
|
+
|
|
23
|
+
constructor(paths: string[] = []) {
|
|
24
|
+
super({ includeCommon: true, paths });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async postConstruct(): Promise<void> {
|
|
28
|
+
const parserClasses = await DependencyRegistry.getCandidateTypes(ConfigParserTarget);
|
|
29
|
+
const parsers = await Promise.all(parserClasses.map(x => DependencyRegistry.getInstance<ConfigParser>(x.class, x.qualifier)));
|
|
30
|
+
|
|
31
|
+
// Register parsers
|
|
32
|
+
this.parsers = {};
|
|
33
|
+
for (const par of parsers) {
|
|
34
|
+
for (const ext of par.ext) {
|
|
35
|
+
this.parsers[ext] = par;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.extMatch = parsers.length ? new RegExp(`[.](${Object.keys(this.parsers).join('|')})`) : /^$/;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async getValues(profiles: string[]): Promise<ConfigValue[]> {
|
|
43
|
+
const out: ConfigValue[] = [];
|
|
44
|
+
|
|
45
|
+
for await (const file of this.query(f => this.extMatch.test(f))) {
|
|
46
|
+
const ext = file.split('.')[1];
|
|
47
|
+
const profile = file.replace(`.${ext}`, '');
|
|
48
|
+
if (!profiles.includes(profile) || !this.parsers[ext]) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const content = await this.read(file);
|
|
52
|
+
out.push({
|
|
53
|
+
profile,
|
|
54
|
+
config: await this.parsers[ext].parse(content),
|
|
55
|
+
source: `file://${file}`,
|
|
56
|
+
priority: this.priority
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ConfigData } from '../parser/types';
|
|
2
|
+
import { ConfigSource, ConfigValue } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Meant to be instantiated and provided as a unique config source
|
|
6
|
+
*/
|
|
7
|
+
export class MemoryConfigSource implements ConfigSource {
|
|
8
|
+
priority = 1;
|
|
9
|
+
data: Record<string, ConfigData>;
|
|
10
|
+
name = 'memory';
|
|
11
|
+
|
|
12
|
+
constructor(data: Record<string, ConfigData>, priority: number = 1) {
|
|
13
|
+
this.data = data;
|
|
14
|
+
this.priority = priority;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
getValues(profiles: string[]): ConfigValue[] {
|
|
18
|
+
const out: ConfigValue[] = [];
|
|
19
|
+
for (const profile of profiles) {
|
|
20
|
+
if (this.data[profile]) {
|
|
21
|
+
out.push({ profile, config: this.data[profile], source: `${this.name}://${profile}`, priority: this.priority });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Injectable } from '@travetto/di';
|
|
2
|
+
import { SchemaRegistry } from '@travetto/schema';
|
|
3
|
+
|
|
4
|
+
import { ConfigOverrides, CONFIG_OVERRIDES } from '../internal/types';
|
|
5
|
+
import { ConfigData } from '../parser/types';
|
|
6
|
+
import { ConfigSource, ConfigValue } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Overridable config source, provides ability to override field level values, currently used by
|
|
10
|
+
* - @EnvVar as a means to allow environment specific overrides
|
|
11
|
+
*/
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class OverrideConfigSource implements ConfigSource {
|
|
14
|
+
priority = 3;
|
|
15
|
+
name = 'override';
|
|
16
|
+
|
|
17
|
+
#build(): ConfigData {
|
|
18
|
+
const out: ConfigData = {};
|
|
19
|
+
for (const cls of SchemaRegistry.getClasses()) {
|
|
20
|
+
const { ns, fields = {} } = SchemaRegistry.getMetadata<ConfigOverrides>(cls, CONFIG_OVERRIDES) ?? {};
|
|
21
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
22
|
+
const val = value();
|
|
23
|
+
if (val !== undefined && val !== '') {
|
|
24
|
+
out[`${ns}.${key}`] = val;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getValues(profiles: string[]): [ConfigValue] {
|
|
32
|
+
return [{
|
|
33
|
+
config: this.#build(),
|
|
34
|
+
profile: 'override',
|
|
35
|
+
source: 'memory://override',
|
|
36
|
+
priority: this.priority
|
|
37
|
+
}];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ConfigData } from '../parser/types';
|
|
2
|
+
|
|
3
|
+
export type ConfigValue = { config: ConfigData, source: string, profile: string, priority: number };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @concrete ../internal/types:ConfigSourceTarget
|
|
7
|
+
*/
|
|
8
|
+
export interface ConfigSource {
|
|
9
|
+
priority: number;
|
|
10
|
+
getValues(profiles: string[]): Promise<ConfigValue[]> | ConfigValue[];
|
|
11
|
+
}
|
package/index.ts
DELETED
package/src/internal/util.ts
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import { Class, ResourceManager, Util } from '@travetto/base';
|
|
2
|
-
import { YamlUtil } from '@travetto/yaml';
|
|
3
|
-
import { BindUtil, SchemaRegistry, ViewConfig } from '@travetto/schema';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Simple Config Utilities
|
|
7
|
-
*/
|
|
8
|
-
export class ConfigUtil {
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Find the key using case insensitive search
|
|
12
|
-
*/
|
|
13
|
-
static #getKeyName(key: string, fields: string[]): string | undefined {
|
|
14
|
-
key = key.trim();
|
|
15
|
-
const match = new RegExp(key, 'i');
|
|
16
|
-
const next = fields.find(x => match.test(x));
|
|
17
|
-
return next;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Takes a env var, and produces a partial object against a schema definition. Does not support arrays, only objects.
|
|
22
|
-
*/
|
|
23
|
-
static #expandEnvEntry(cls: Class, key: string, value: unknown): Record<string, unknown> | undefined {
|
|
24
|
-
const parts = key.split('_');
|
|
25
|
-
|
|
26
|
-
const lastPart = parts.pop()!;
|
|
27
|
-
|
|
28
|
-
if (!lastPart) {
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
let cfg: ViewConfig | undefined = SchemaRegistry.getViewSchema(cls);
|
|
33
|
-
let data: Record<string, unknown> = {};
|
|
34
|
-
const root = data;
|
|
35
|
-
|
|
36
|
-
while (parts.length) {
|
|
37
|
-
let part = parts.shift();
|
|
38
|
-
if (cfg) {
|
|
39
|
-
part = this.#getKeyName(part!, cfg.fields);
|
|
40
|
-
if (!part) {
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
const subType: Class = cfg.schema[part].type;
|
|
44
|
-
if (SchemaRegistry.has(subType)) {
|
|
45
|
-
cfg = SchemaRegistry.getViewSchema(subType);
|
|
46
|
-
} else if (subType === Object) { // wildcard
|
|
47
|
-
cfg = undefined;
|
|
48
|
-
} else {
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
53
|
-
data = ((data[part!] ??= {}) as Record<string, unknown>); // Recurse
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const lastKey = (cfg ? this.#getKeyName(lastPart, cfg.fields) : undefined) ?? (/^[A-Z_0-9]+$/.test(lastPart) ? lastPart.toLowerCase() : lastPart);
|
|
57
|
-
if (typeof value === 'string' && value.includes(',')) {
|
|
58
|
-
value = value.trim().split(/\s*,\s*/);
|
|
59
|
-
}
|
|
60
|
-
data[lastKey] = value;
|
|
61
|
-
|
|
62
|
-
return root;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Bind the environment variables onto an object structure when they match by name.
|
|
67
|
-
* Will split on _ to handle nesting appropriately
|
|
68
|
-
*/
|
|
69
|
-
static getEnvOverlay(cls: Class, ns: string): Record<string, unknown> {
|
|
70
|
-
// Handle process.env on bind as the structure we need may not
|
|
71
|
-
// fully exist until the config has been created
|
|
72
|
-
const nsRe = new RegExp(`^${ns.replace(/[.]/g, '_')}`, 'i'); // Check is case insensitive
|
|
73
|
-
const data: Record<string, unknown> = {};
|
|
74
|
-
for (const [k, v] of Object.entries(process.env)) { // Find all keys that match ns
|
|
75
|
-
if (k.includes('_') && nsRe.test(k)) { // Require at least one level (nothing should be at the top level as all configs are namespaced)
|
|
76
|
-
Util.deepAssign(data, this.#expandEnvEntry(cls, ns ? k.substring(ns.length + 1) : k, v), 'coerce');
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return data;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Parse config file from YAML into JSON
|
|
84
|
-
*/
|
|
85
|
-
static async getConfigFileAsData(file: string, ns: string = ''): Promise<Record<string, unknown>> {
|
|
86
|
-
const data = await ResourceManager.read(file, 'utf8');
|
|
87
|
-
const doc = YamlUtil.parse<Record<string, unknown>>(data);
|
|
88
|
-
return ns ? { [ns]: doc } : doc;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Looks up root object by namespace
|
|
93
|
-
* @param src
|
|
94
|
-
* @param ns
|
|
95
|
-
* @returns
|
|
96
|
-
*/
|
|
97
|
-
static lookupRoot(src: Record<string, unknown>, ns?: string, createIfMissing = false): Record<string, unknown> {
|
|
98
|
-
const parts = (ns ? ns.split('.') : []);
|
|
99
|
-
let sub: Record<string, unknown> = src;
|
|
100
|
-
|
|
101
|
-
while (parts.length && sub) {
|
|
102
|
-
const next = parts.shift()!;
|
|
103
|
-
if (createIfMissing && !sub[next]) {
|
|
104
|
-
sub[next] = {};
|
|
105
|
-
}
|
|
106
|
-
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
107
|
-
sub = sub[next] as Record<string, unknown>;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return sub;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Sanitize payload
|
|
115
|
-
*/
|
|
116
|
-
static sanitizeValuesByKey<T extends Record<string, unknown>>(obj: T, patterns: string[]): T {
|
|
117
|
-
// Support custom redacted keys
|
|
118
|
-
const regex = new RegExp(`(${patterns.filter(x => !!x).join('|')})`, 'i');
|
|
119
|
-
|
|
120
|
-
const full = BindUtil.flattenPaths(obj);
|
|
121
|
-
for (const [k, value] of Object.entries(full)) {
|
|
122
|
-
if (typeof value === 'string' && regex.test(k)) {
|
|
123
|
-
full[k] = '*'.repeat(10);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
127
|
-
return BindUtil.expandPaths(full) as T;
|
|
128
|
-
}
|
|
129
|
-
}
|
package/src/manager.ts
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import * as path from 'path';
|
|
2
|
-
|
|
3
|
-
import { AppError, AppManifest, Class, ResourceManager, Util } from '@travetto/base';
|
|
4
|
-
import { EnvUtil } from '@travetto/boot';
|
|
5
|
-
import { BindUtil, SchemaRegistry, SchemaValidator, ValidationResultError } from '@travetto/schema';
|
|
6
|
-
|
|
7
|
-
import { ConfigUtil } from './internal/util';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Manager for application configuration
|
|
11
|
-
*/
|
|
12
|
-
class $ConfigManager {
|
|
13
|
-
|
|
14
|
-
#initialized?: boolean = false;
|
|
15
|
-
#storage: Record<string, unknown> = {}; // Lowered, and flattened
|
|
16
|
-
#active: Record<string, Record<string, unknown>> = {}; // All active configs
|
|
17
|
-
#redactedKeys = [
|
|
18
|
-
'passphrase.*',
|
|
19
|
-
'password.*',
|
|
20
|
-
'credential.*',
|
|
21
|
-
'.*secret.*',
|
|
22
|
-
'.*key',
|
|
23
|
-
'.*token',
|
|
24
|
-
'pw',
|
|
25
|
-
];
|
|
26
|
-
|
|
27
|
-
protected getStorage(): Record<string, unknown> {
|
|
28
|
-
return this.#storage;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Load all config files
|
|
33
|
-
*/
|
|
34
|
-
async #load(): Promise<void> {
|
|
35
|
-
const profileIndex = Object.fromEntries(Object.entries(AppManifest.env.profiles).map(([k, v]) => [v, +k] as const));
|
|
36
|
-
|
|
37
|
-
const files = (await ResourceManager.findAll(/[.]ya?ml$/))
|
|
38
|
-
.map(file => ({ file, profile: path.basename(file).replace(/[.]ya?ml$/, '') }))
|
|
39
|
-
.filter(({ profile }) => profile in profileIndex)
|
|
40
|
-
.sort((a, b) => profileIndex[a.profile] - profileIndex[b.profile]);
|
|
41
|
-
|
|
42
|
-
if (files.length) {
|
|
43
|
-
console.debug('Found configurations for', { files: files.map(x => x.profile) });
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
for (const f of files) {
|
|
47
|
-
const data = await ConfigUtil.getConfigFileAsData(f.file);
|
|
48
|
-
Util.deepAssign(this.#storage, BindUtil.expandPaths(data), 'coerce');
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Get a sub tree of the config, or everything if namespace is not passed
|
|
54
|
-
* @param ns The namespace of the config to search for, can be dotted for accessing sub namespaces
|
|
55
|
-
*/
|
|
56
|
-
#get(ns?: string): Record<string, unknown> {
|
|
57
|
-
return ConfigUtil.lookupRoot(this.#storage, ns);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Order of specificity (least to most)
|
|
62
|
-
* - Resource application.yml
|
|
63
|
-
* - Resource {profile}.yml
|
|
64
|
-
* - Resource {env}.yml
|
|
65
|
-
* - Environment vars -> Overrides everything (happens at bind time)
|
|
66
|
-
*/
|
|
67
|
-
async init(): Promise<void> {
|
|
68
|
-
if (!this.#initialized) {
|
|
69
|
-
this.#initialized = true;
|
|
70
|
-
await this.#load();
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Output to JSON
|
|
76
|
-
* @param namespace If only a portion of the config should be exported
|
|
77
|
-
* @param secure Determines if secrets should be redacted, defaults to true in prod, false otherwise
|
|
78
|
-
*/
|
|
79
|
-
toJSON(secure: boolean = EnvUtil.isProd()): Record<string, unknown> {
|
|
80
|
-
const copy = JSON.parse(JSON.stringify(this.#active));
|
|
81
|
-
return secure ?
|
|
82
|
-
ConfigUtil.sanitizeValuesByKey(copy, [
|
|
83
|
-
...this.#redactedKeys,
|
|
84
|
-
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
85
|
-
...(this.#get('config')?.redacted ?? []) as string[]
|
|
86
|
-
]) :
|
|
87
|
-
copy;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Bind config to a schema class
|
|
92
|
-
* @param cls
|
|
93
|
-
* @param item
|
|
94
|
-
* @param namespace
|
|
95
|
-
*/
|
|
96
|
-
bindTo<T>(cls: Class<T>, item: T, namespace: string): T {
|
|
97
|
-
if (!SchemaRegistry.has(cls)) {
|
|
98
|
-
throw new AppError(`${cls.ᚕid} is not a valid schema class, config is not supported`);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const cfg = Util.deepAssign({}, this.#get(namespace));
|
|
102
|
-
Util.deepAssign(cfg, ConfigUtil.getEnvOverlay(cls, namespace));
|
|
103
|
-
|
|
104
|
-
return BindUtil.bindSchemaToObject(cls, item, cfg);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async install<T>(cls: Class<T>, item: T, namespace: string, internal?: boolean): Promise<T> {
|
|
108
|
-
const out = await this.bindTo(cls, item, namespace);
|
|
109
|
-
try {
|
|
110
|
-
await SchemaValidator.validate(cls, out);
|
|
111
|
-
} catch (err) {
|
|
112
|
-
if (err instanceof ValidationResultError) {
|
|
113
|
-
err.message = `Failed to construct ${cls.ᚕid} as validation errors have occurred`;
|
|
114
|
-
err.payload = { class: cls.ᚕid, file: cls.ᚕfile, ...(err.payload ?? {}) };
|
|
115
|
-
}
|
|
116
|
-
throw err;
|
|
117
|
-
}
|
|
118
|
-
if (out && !internal) {
|
|
119
|
-
Util.deepAssign(ConfigUtil.lookupRoot(this.#active, namespace, true), out, 'coerce');
|
|
120
|
-
}
|
|
121
|
-
return out;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Reset
|
|
126
|
-
*/
|
|
127
|
-
reset(): void {
|
|
128
|
-
this.#storage = {};
|
|
129
|
-
this.#initialized = false;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export const ConfigManager = new $ConfigManager();
|
package/support/phase.init.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Initializes the config source
|
|
3
|
-
*/
|
|
4
|
-
export const init = {
|
|
5
|
-
key: '@trv:config/init',
|
|
6
|
-
before: ['@trv:registry/init'],
|
|
7
|
-
async action(): Promise<void> {
|
|
8
|
-
const { ConfigManager } = await import('../src/manager');
|
|
9
|
-
await ConfigManager.init();
|
|
10
|
-
}
|
|
11
|
-
};
|