@decohere/config 0.0.1-dev.125 → 0.0.1-dev.5
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/package.json +4 -13
- package/src/configuration-data-source.ts +9 -0
- package/src/configuration.extensions.ts +39 -0
- package/src/configuration.service.ts +73 -0
- package/src/errors.ts +1 -0
- package/src/yaml-data-source.ts +152 -0
- package/tests/confguration.service.spec.ts +101 -0
- package/tests/env-only.spec.ts +30 -0
- package/tests/yaml-config.spec.ts +203 -0
- package/tests/yaml-data-source.pentest.spec.ts +31 -0
- package/LICENSE +0 -15
- package/README.md +0 -40
- package/commonjs/src/configuration-data-source.js +0 -12
- package/commonjs/src/configuration.extensions.js +0 -45
- package/commonjs/src/configuration.service.js +0 -98
- package/commonjs/src/const.js +0 -20
- package/commonjs/src/errors.js +0 -12
- package/commonjs/src/index.js +0 -22
- package/commonjs/src/yaml-data-source.js +0 -185
- package/es6/src/configuration-data-source.js +0 -2
- package/es6/src/configuration.extensions.js +0 -35
- package/es6/src/configuration.service.js +0 -88
- package/es6/src/errors.js +0 -2
- package/es6/src/yaml-data-source.js +0 -172
- package/types/src/configuration-data-source.d.ts +0 -8
- package/types/src/configuration.extensions.d.ts +0 -16
- package/types/src/configuration.service.d.ts +0 -15
- package/types/src/const.d.ts +0 -2
- package/types/src/errors.d.ts +0 -2
- package/types/src/index.d.ts +0 -5
- package/types/src/yaml-data-source.d.ts +0 -24
- /package/{es6/src/const.js → src/const.ts} +0 -0
- /package/{es6/src/index.js → src/index.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decohere/config",
|
|
3
|
-
"version": "0.0.1-dev.
|
|
3
|
+
"version": "0.0.1-dev.5",
|
|
4
4
|
"description": "Database ORM abstractions and repository patterns built on TypeORM",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -10,15 +10,6 @@
|
|
|
10
10
|
"repository",
|
|
11
11
|
"decohere"
|
|
12
12
|
],
|
|
13
|
-
"homepage": "https://github.com/typesmiths/decohere/tree/main/projects/config#readme",
|
|
14
|
-
"bugs": {
|
|
15
|
-
"url": "https://github.com/typesmiths/decohere/issues"
|
|
16
|
-
},
|
|
17
|
-
"repository": {
|
|
18
|
-
"type": "git",
|
|
19
|
-
"url": "git+https://github.com/typesmiths/decohere.git",
|
|
20
|
-
"directory": "projects/config"
|
|
21
|
-
},
|
|
22
13
|
"license": "ISC",
|
|
23
14
|
"author": "Typesmiths",
|
|
24
15
|
"type": "module",
|
|
@@ -34,9 +25,9 @@
|
|
|
34
25
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
35
26
|
},
|
|
36
27
|
"dependencies": {
|
|
37
|
-
"@decohere/
|
|
38
|
-
"@decohere/
|
|
39
|
-
"@decohere/
|
|
28
|
+
"@decohere/http": "0.0.1-dev.5",
|
|
29
|
+
"@decohere/di": "0.0.1-dev.5",
|
|
30
|
+
"@decohere/core": "0.0.1-dev.5",
|
|
40
31
|
"yaml": "^2.9.1"
|
|
41
32
|
},
|
|
42
33
|
"devDependencies": {}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface IConfigurationDataSource {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
getData(): Promise<Record<string, unknown>>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export abstract class ConfigurationDataSource implements IConfigurationDataSource {
|
|
7
|
+
public abstract readonly name: string;
|
|
8
|
+
public abstract getData(): Promise<Record<string, unknown>>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Builder } from "@decohere/http";
|
|
2
|
+
import {
|
|
3
|
+
ConfigurationServiceToken,
|
|
4
|
+
type IConfigurationService,
|
|
5
|
+
} from "@decohere/core/configuration";
|
|
6
|
+
import { extension } from "@decohere/core/extensibility";
|
|
7
|
+
import type { Constructable } from "@decohere/core/reflection";
|
|
8
|
+
import { ConfigurationService } from "./configuration.service.js";
|
|
9
|
+
import type { ConfigurationDataSource } from "./configuration-data-source.js";
|
|
10
|
+
import { ConfigurationDataSourceKey } from "./const.js";
|
|
11
|
+
|
|
12
|
+
export type ConfigurationOptions = {
|
|
13
|
+
configurationService?: Constructable<IConfigurationService>;
|
|
14
|
+
dataSources?: Constructable<ConfigurationDataSource>[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
declare module "@decohere/http" {
|
|
18
|
+
interface Builder {
|
|
19
|
+
withConfiguration(...dataSources: Constructable<ConfigurationDataSource>[]): Builder;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class BuilderExtensions {
|
|
24
|
+
@extension(Builder)
|
|
25
|
+
public static withConfiguration(
|
|
26
|
+
builder: Builder,
|
|
27
|
+
...dataSourceArgs: Constructable<ConfigurationDataSource>[]
|
|
28
|
+
) {
|
|
29
|
+
const dataSources: Constructable<ConfigurationDataSource>[] = dataSourceArgs ?? [];
|
|
30
|
+
builder.withServices((builder) => {
|
|
31
|
+
builder.registerSingleton(ConfigurationServiceToken, ConfigurationService);
|
|
32
|
+
for (const dataSource of dataSources) {
|
|
33
|
+
builder.registerSingleton(ConfigurationDataSourceKey, dataSource);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return builder;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { inject, injectable } from "@decohere/di/decorators";
|
|
2
|
+
import { cache, memoize } from "@decohere/core/caching";
|
|
3
|
+
import type { IConfigurationService } from "@decohere/core/configuration";
|
|
4
|
+
import type { Constructable } from "@decohere/core/reflection";
|
|
5
|
+
import { lowerCamelCase } from "@decohere/core/system";
|
|
6
|
+
import type { ConfigurationDataSource } from "./configuration-data-source.js";
|
|
7
|
+
import { ConfigurationDataSourceKey } from "./const.js";
|
|
8
|
+
import { ConfigurationError } from "./errors.js";
|
|
9
|
+
|
|
10
|
+
@injectable()
|
|
11
|
+
export class ConfigurationService implements IConfigurationService {
|
|
12
|
+
protected data: Record<string, unknown> = {};
|
|
13
|
+
public readonly dataSources: readonly ConfigurationDataSource[] = [];
|
|
14
|
+
protected isInitialized = false;
|
|
15
|
+
|
|
16
|
+
public constructor(
|
|
17
|
+
@inject(ConfigurationDataSourceKey) dataSources: ConfigurationDataSource[] = [],
|
|
18
|
+
) {
|
|
19
|
+
this.dataSources = dataSources;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private async initialize(): Promise<void> {
|
|
23
|
+
if (this.isInitialized) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const records = await Promise.all(this.dataSources.map(async (d) => await d.getData()));
|
|
28
|
+
this.validateDataSources(records);
|
|
29
|
+
const entries = records.flatMap((r) => Object.entries(r));
|
|
30
|
+
this.data = Object.fromEntries(entries);
|
|
31
|
+
this.isInitialized = true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public async get<T extends object>(typeOrString: Constructable<T> | string): Promise<T> {
|
|
35
|
+
await this.ensureInitialized();
|
|
36
|
+
|
|
37
|
+
const obj =
|
|
38
|
+
typeof typeOrString === "string"
|
|
39
|
+
? this.getStringKey<T>(typeOrString)
|
|
40
|
+
: this.getConstructable<T>(typeOrString);
|
|
41
|
+
return obj;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private async ensureInitialized(): Promise<void> {
|
|
45
|
+
if (this.isInitialized) return;
|
|
46
|
+
await this.initialize();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private validateDataSources(records: Record<string, unknown>[]) {
|
|
50
|
+
const keys = records.flatMap((record) => Object.keys(record));
|
|
51
|
+
const uniqueKeys = new Set(keys);
|
|
52
|
+
if (uniqueKeys.size < keys.length) {
|
|
53
|
+
throw new ConfigurationError(
|
|
54
|
+
`Cannot have duplicate data source names. The data sources are: ${keys.join(",")}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@memoize()
|
|
60
|
+
private getConstructable<T extends object>(type: Constructable<T>): T {
|
|
61
|
+
const key = lowerCamelCase(type?.name.replace(/configuration|config/i, ""));
|
|
62
|
+
const data = this.data[key];
|
|
63
|
+
const configObject = new type();
|
|
64
|
+
Object.assign(configObject, data);
|
|
65
|
+
return configObject as T;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@cache()
|
|
69
|
+
private getStringKey<T>(key: string): T {
|
|
70
|
+
const data = this.data[key];
|
|
71
|
+
return data as T;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class ConfigurationError extends Error {}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { io } from "@decohere/core/io";
|
|
2
|
+
import { parse } from "yaml";
|
|
3
|
+
import { ConfigurationDataSource } from "./configuration-data-source.js";
|
|
4
|
+
import { DEFAULT_CONFIG_DIR } from "./const.js";
|
|
5
|
+
import { ConfigurationError } from "./errors.js";
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILE_NAME = "config";
|
|
8
|
+
/** Unset NODE_ENV must not silently load config.dev.yaml (TypeORM synchronize, etc.). */
|
|
9
|
+
const DEFAULT_ENV = "production";
|
|
10
|
+
const FILE_EXT = "yaml";
|
|
11
|
+
|
|
12
|
+
/** Resolve which env-specific config file to merge (e.g. config.dev.yaml). */
|
|
13
|
+
export function resolveConfigEnv(nodeEnv?: string): string {
|
|
14
|
+
const env = nodeEnv ?? process.env.NODE_ENV;
|
|
15
|
+
if (env == null || env.trim() === "") {
|
|
16
|
+
return DEFAULT_ENV;
|
|
17
|
+
}
|
|
18
|
+
return env;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ConfigSource = {
|
|
22
|
+
/** Shown in errors, e.g. the file name. */
|
|
23
|
+
name: string;
|
|
24
|
+
text: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Tree = Record<string, unknown>;
|
|
28
|
+
type MissingVariable = { name: string; path: string };
|
|
29
|
+
|
|
30
|
+
/** `${NAME}` or `${NAME:default}`. The default may be empty, and runs up to the first `}`. */
|
|
31
|
+
const PLACEHOLDER = /\$\{(\w+)(?::([^}]*))?\}/g;
|
|
32
|
+
|
|
33
|
+
function isTree(value: unknown): value is Tree {
|
|
34
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Objects merge key by key; a list or a scalar in the later file replaces the earlier value. */
|
|
38
|
+
function merge(base: Tree, override: Tree): Tree {
|
|
39
|
+
const merged: Tree = { ...base };
|
|
40
|
+
for (const [key, value] of Object.entries(override)) {
|
|
41
|
+
const existing = merged[key];
|
|
42
|
+
merged[key] = isTree(existing) && isTree(value) ? merge(existing, value) : value;
|
|
43
|
+
}
|
|
44
|
+
return merged;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveString(
|
|
48
|
+
value: string,
|
|
49
|
+
path: string,
|
|
50
|
+
missing: MissingVariable[],
|
|
51
|
+
): string | null {
|
|
52
|
+
let hasPlaceholder = false;
|
|
53
|
+
const resolved = value.replace(PLACEHOLDER, (_match, name: string, fallback?: string) => {
|
|
54
|
+
hasPlaceholder = true;
|
|
55
|
+
const fromEnv = process.env[name];
|
|
56
|
+
if (fromEnv !== undefined) {
|
|
57
|
+
return fromEnv;
|
|
58
|
+
}
|
|
59
|
+
if (fallback !== undefined) {
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
missing.push({ name, path });
|
|
63
|
+
return "";
|
|
64
|
+
});
|
|
65
|
+
// A variable set to the text "null" means no value.
|
|
66
|
+
return hasPlaceholder && resolved === "null" ? null : resolved;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveValue(value: unknown, path: string, missing: MissingVariable[]): unknown {
|
|
70
|
+
if (typeof value === "string") {
|
|
71
|
+
return resolveString(value, path, missing);
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
return value.map((item, index) => resolveValue(item, `${path}[${index}]`, missing));
|
|
75
|
+
}
|
|
76
|
+
if (isTree(value)) {
|
|
77
|
+
const resolved: Tree = {};
|
|
78
|
+
for (const [key, item] of Object.entries(value)) {
|
|
79
|
+
resolved[key] = resolveValue(item, path === "" ? key : `${path}.${key}`, missing);
|
|
80
|
+
}
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseSource({ name, text }: ConfigSource): Tree | undefined {
|
|
87
|
+
let parsed: unknown;
|
|
88
|
+
try {
|
|
89
|
+
parsed = parse(text);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
92
|
+
throw new ConfigurationError(`Cannot parse config file ${name}: ${reason}`);
|
|
93
|
+
}
|
|
94
|
+
if (parsed == null) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (!isTree(parsed)) {
|
|
98
|
+
throw new ConfigurationError(
|
|
99
|
+
`Config file ${name} must contain a mapping at the top level`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return parsed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Merge config files in order (later files win), then resolve `${VAR}` placeholders on the
|
|
107
|
+
* merged result.
|
|
108
|
+
*
|
|
109
|
+
* Resolving after the merge means an environment file such as `config.dev.yaml` can replace a
|
|
110
|
+
* production `${VAR}` without that variable being set: a placeholder that was replaced is never
|
|
111
|
+
* looked up. Placeholders still unset after the merge fail together, naming every variable and
|
|
112
|
+
* the config path that used it. `${VAR:default}` keeps a default for optional values.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveYamlConfig(sources: ConfigSource[]): Tree {
|
|
115
|
+
let merged: Tree = {};
|
|
116
|
+
for (const source of sources) {
|
|
117
|
+
const tree = parseSource(source);
|
|
118
|
+
if (tree !== undefined) {
|
|
119
|
+
merged = merge(merged, tree);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const missing: MissingVariable[] = [];
|
|
124
|
+
const resolved = resolveValue(merged, "", missing) as Tree;
|
|
125
|
+
if (missing.length > 0) {
|
|
126
|
+
const lines = missing.map(({ name, path }) => ` - ${name} (used at ${path})`);
|
|
127
|
+
throw new ConfigurationError(
|
|
128
|
+
`Config references ${missing.length} environment variable(s) that are not set:\n${lines.join("\n")}\n` +
|
|
129
|
+
"Set them, give the value a default with ${NAME:default}, or replace it in the environment's config file.",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return resolved;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class YamlDataSource extends ConfigurationDataSource {
|
|
136
|
+
public name: string = YamlDataSource.name;
|
|
137
|
+
public async getData(): Promise<Record<string, unknown>> {
|
|
138
|
+
const configDir = process.env.CONFIG_DIR ?? DEFAULT_CONFIG_DIR;
|
|
139
|
+
const env = resolveConfigEnv();
|
|
140
|
+
const candidates = [
|
|
141
|
+
`${configDir}/${CONFIG_FILE_NAME}.${FILE_EXT}`,
|
|
142
|
+
`${configDir}/${CONFIG_FILE_NAME}.${env}.${FILE_EXT}`,
|
|
143
|
+
];
|
|
144
|
+
const sources: ConfigSource[] = [];
|
|
145
|
+
for (const filePath of candidates) {
|
|
146
|
+
if (await io.fileExists(filePath)) {
|
|
147
|
+
sources.push({ name: filePath, text: await io.readFile(filePath) });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return resolveYamlConfig(sources);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ServiceContainerBuilder } from "@decohere/di";
|
|
2
|
+
import { injectable } from "@decohere/di/decorators";
|
|
3
|
+
import { ConfigurationServiceToken } from "@decohere/core/configuration";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
ConfigurationDataSource,
|
|
7
|
+
ConfigurationDataSourceKey,
|
|
8
|
+
ConfigurationService,
|
|
9
|
+
} from "../src/index.js";
|
|
10
|
+
|
|
11
|
+
describe("ConfigurationService", () => {
|
|
12
|
+
describe("lazy initialization", () => {
|
|
13
|
+
const mockData1 = { field1: "fieldData" };
|
|
14
|
+
const mockData2 = { field2: "fieldData" };
|
|
15
|
+
|
|
16
|
+
class MockDataSource1 extends ConfigurationDataSource {
|
|
17
|
+
public name: string = "";
|
|
18
|
+
public async getData(): Promise<Record<string, unknown>> {
|
|
19
|
+
return mockData1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class MockDataSource2 extends ConfigurationDataSource {
|
|
24
|
+
public name: string = "";
|
|
25
|
+
public async getData(): Promise<Record<string, unknown>> {
|
|
26
|
+
return mockData2;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it("should initialize data sources when get() is called", async () => {
|
|
31
|
+
const dataSource1 = new MockDataSource1();
|
|
32
|
+
const dataSource2 = new MockDataSource2();
|
|
33
|
+
const getData1 = vi.spyOn(dataSource1, "getData");
|
|
34
|
+
const getData2 = vi.spyOn(dataSource2, "getData");
|
|
35
|
+
const configService = new ConfigurationService([dataSource1, dataSource2]);
|
|
36
|
+
await configService.get("field1");
|
|
37
|
+
expect(getData1).toHaveBeenCalled();
|
|
38
|
+
expect(getData2).toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should throw if duplicate named datasources exist when get() is called", async () => {
|
|
42
|
+
const dataSource1 = new MockDataSource1();
|
|
43
|
+
const dataSource2 = new MockDataSource1();
|
|
44
|
+
const configService = new ConfigurationService([dataSource1, dataSource2]);
|
|
45
|
+
const getCall = () => configService.get("field1");
|
|
46
|
+
await expect(getCall).rejects.toThrow(
|
|
47
|
+
"Cannot have duplicate data source names. The data sources are: field1,field1",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("get", () => {
|
|
53
|
+
class CustomObjectConfig {}
|
|
54
|
+
const customObject = { value: "value" };
|
|
55
|
+
class MockDataSource1 extends ConfigurationDataSource {
|
|
56
|
+
public name: string = "";
|
|
57
|
+
public async getData(): Promise<Record<string, unknown>> {
|
|
58
|
+
return {
|
|
59
|
+
stringKey: "value",
|
|
60
|
+
customObject,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
it("should get data from ds with a string key", async () => {
|
|
66
|
+
const configurationService = new ConfigurationService([new MockDataSource1()]);
|
|
67
|
+
|
|
68
|
+
const data = await configurationService.get("stringKey");
|
|
69
|
+
expect(data).toEqual("value");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should get data from type key", async () => {
|
|
73
|
+
const configurationService = new ConfigurationService([new MockDataSource1()]);
|
|
74
|
+
|
|
75
|
+
const data = await configurationService.get(CustomObjectConfig);
|
|
76
|
+
expect(data).toEqual(customObject);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("injectable", () => {
|
|
81
|
+
const mockData = { field1: "fieldData" };
|
|
82
|
+
|
|
83
|
+
@injectable()
|
|
84
|
+
class MockDataSource extends ConfigurationDataSource {
|
|
85
|
+
public name: string = "";
|
|
86
|
+
public async getData(): Promise<Record<string, unknown>> {
|
|
87
|
+
return mockData;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
it("should support injecting data source", async () => {
|
|
91
|
+
const containerBuiler = new ServiceContainerBuilder();
|
|
92
|
+
containerBuiler.registerSingleton(ConfigurationServiceToken, ConfigurationService);
|
|
93
|
+
containerBuiler.registerSingleton(ConfigurationDataSourceKey, MockDataSource);
|
|
94
|
+
const container = containerBuiler.build();
|
|
95
|
+
const configService = (await container.resolve(
|
|
96
|
+
ConfigurationServiceToken,
|
|
97
|
+
)) as ConfigurationService;
|
|
98
|
+
expect(configService.dataSources.at(0)).toBeInstanceOf(MockDataSource);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// dotenv reads the real disk (it is not routed through the memfs mock), so use a real temp dir.
|
|
5
|
+
const realFs = await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
6
|
+
const { tmpdir } = await vi.importActual<typeof import("node:os")>("node:os");
|
|
7
|
+
|
|
8
|
+
describe("config reads configuration from the environment only (#576)", () => {
|
|
9
|
+
const sentinel = "DECOHERE_ENV_ONLY_SENTINEL";
|
|
10
|
+
const originalCwd = process.cwd();
|
|
11
|
+
let dir: string | undefined;
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
process.chdir(originalCwd);
|
|
15
|
+
delete process.env[sentinel];
|
|
16
|
+
if (dir) realFs.rmSync(dir, { recursive: true, force: true });
|
|
17
|
+
dir = undefined;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("does not load a .env file from the working directory on import", async () => {
|
|
21
|
+
dir = realFs.mkdtempSync(join(tmpdir(), "decohere-env-only-"));
|
|
22
|
+
realFs.writeFileSync(join(dir, ".env"), `${sentinel}=from-dotenv\n`);
|
|
23
|
+
process.chdir(dir);
|
|
24
|
+
|
|
25
|
+
vi.resetModules();
|
|
26
|
+
await import("../src/yaml-data-source.js");
|
|
27
|
+
|
|
28
|
+
expect(process.env[sentinel]).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { vol } from "memfs";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { ConfigurationError } from "../src/errors.js";
|
|
4
|
+
import { resolveYamlConfig, YamlDataSource } from "../src/yaml-data-source.js";
|
|
5
|
+
|
|
6
|
+
const file = (name: string, text: string) => ({ name, text });
|
|
7
|
+
|
|
8
|
+
describe("YAML config: merge the files first, then resolve ${VAR} (#634)", () => {
|
|
9
|
+
const touched: string[] = [];
|
|
10
|
+
|
|
11
|
+
function setEnv(name: string, value: string | undefined) {
|
|
12
|
+
touched.push(name);
|
|
13
|
+
if (value === undefined) delete process.env[name];
|
|
14
|
+
else process.env[name] = value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const name of touched.splice(0)) delete process.env[name];
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("lets a later file replace a ${VAR} without that variable being set", () => {
|
|
22
|
+
setEnv("DB_HOST_634", undefined);
|
|
23
|
+
|
|
24
|
+
const config = resolveYamlConfig([
|
|
25
|
+
file("config.yaml", "database:\n host: ${DB_HOST_634}\n port: 5432\n"),
|
|
26
|
+
file("config.dev.yaml", "database:\n host: postgres\n"),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
expect(config).toEqual({ database: { host: "postgres", port: 5432 } });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("resolves a ${VAR} that no later file replaces", () => {
|
|
33
|
+
setEnv("DB_HOST_634", "db.internal");
|
|
34
|
+
|
|
35
|
+
const config = resolveYamlConfig([
|
|
36
|
+
file("config.yaml", "database:\n host: ${DB_HOST_634}\n"),
|
|
37
|
+
file("config.dev.yaml", "database:\n port: 5433\n"),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
expect(config).toEqual({ database: { host: "db.internal", port: 5433 } });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("fails listing every missing variable and where it was used", () => {
|
|
44
|
+
setEnv("MISSING_A_634", undefined);
|
|
45
|
+
setEnv("MISSING_B_634", undefined);
|
|
46
|
+
setEnv("MISSING_C_634", undefined);
|
|
47
|
+
const base = [
|
|
48
|
+
"database:",
|
|
49
|
+
" host: ${MISSING_A_634}",
|
|
50
|
+
" password: ${MISSING_B_634}",
|
|
51
|
+
"auth:",
|
|
52
|
+
" domain: ${MISSING_C_634}",
|
|
53
|
+
"",
|
|
54
|
+
].join("\n");
|
|
55
|
+
|
|
56
|
+
let failure: unknown;
|
|
57
|
+
try {
|
|
58
|
+
resolveYamlConfig([
|
|
59
|
+
file("config.yaml", base),
|
|
60
|
+
file("config.dev.yaml", "auth:\n domain: dev.example.com\n"),
|
|
61
|
+
]);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
failure = error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
expect(failure).toBeInstanceOf(ConfigurationError);
|
|
67
|
+
const message = (failure as Error).message;
|
|
68
|
+
expect(message).toContain("MISSING_A_634 (used at database.host)");
|
|
69
|
+
expect(message).toContain("MISSING_B_634 (used at database.password)");
|
|
70
|
+
// The dev file replaces auth.domain, so that one is not missing.
|
|
71
|
+
expect(message).not.toContain("MISSING_C_634");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("names the path inside lists", () => {
|
|
75
|
+
setEnv("ORIGIN_634", undefined);
|
|
76
|
+
|
|
77
|
+
expect(() =>
|
|
78
|
+
resolveYamlConfig([file("config.yaml", "cors:\n origins:\n - ${ORIGIN_634}\n")]),
|
|
79
|
+
).toThrow(/ORIGIN_634 \(used at cors\.origins\[0\]\)/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("reports only the variable that is missing inside a longer value", () => {
|
|
83
|
+
setEnv("HOST_634", "db");
|
|
84
|
+
setEnv("PORT_634", undefined);
|
|
85
|
+
|
|
86
|
+
expect(() =>
|
|
87
|
+
resolveYamlConfig([
|
|
88
|
+
file("config.yaml", "url: postgres://${HOST_634}:${PORT_634}/app\n"),
|
|
89
|
+
]),
|
|
90
|
+
).toThrow(/PORT_634 \(used at url\)/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("substitutes into a longer value", () => {
|
|
94
|
+
setEnv("HOST_634", "db");
|
|
95
|
+
setEnv("PORT_634", "5432");
|
|
96
|
+
|
|
97
|
+
const config = resolveYamlConfig([
|
|
98
|
+
file("config.yaml", "url: postgres://${HOST_634}:${PORT_634}/app\n"),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
expect(config).toEqual({ url: "postgres://db:5432/app" });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("keeps ${VAR:default} for optional values", () => {
|
|
105
|
+
setEnv("OPTIONAL_634", undefined);
|
|
106
|
+
setEnv("OVERRIDDEN_634", "from-env");
|
|
107
|
+
|
|
108
|
+
const config = resolveYamlConfig([
|
|
109
|
+
file(
|
|
110
|
+
"config.yaml",
|
|
111
|
+
"a: ${OPTIONAL_634:fallback}\nb: ${OVERRIDDEN_634:fallback}\nc: ${OPTIONAL_634:}\n",
|
|
112
|
+
),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
expect(config).toEqual({ a: "fallback", b: "from-env", c: "" });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("treats a variable set to an empty string as set", () => {
|
|
119
|
+
setEnv("EMPTY_634", "");
|
|
120
|
+
|
|
121
|
+
expect(resolveYamlConfig([file("config.yaml", "token: ${EMPTY_634}\n")])).toEqual({
|
|
122
|
+
token: "",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("turns a placeholder that resolves to the text null into null", () => {
|
|
127
|
+
setEnv("NOTHING_634", "null");
|
|
128
|
+
|
|
129
|
+
expect(resolveYamlConfig([file("config.yaml", "value: ${NOTHING_634}\n")])).toEqual({
|
|
130
|
+
value: null,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("merges objects deeply and lets a later file replace lists and scalars", () => {
|
|
135
|
+
const config = resolveYamlConfig([
|
|
136
|
+
file("config.yaml", "a:\n x: 1\n y: 2\nlist: [1, 2, 3]\nkeep: base\n"),
|
|
137
|
+
file("config.dev.yaml", "a:\n y: 20\nlist: [9]\n"),
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
expect(config).toEqual({ a: { x: 1, y: 20 }, list: [9], keep: "base" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("skips an empty file and rejects one that is not a mapping", () => {
|
|
144
|
+
expect(
|
|
145
|
+
resolveYamlConfig([file("config.yaml", ""), file("config.dev.yaml", "a: 1\n")]),
|
|
146
|
+
).toEqual({
|
|
147
|
+
a: 1,
|
|
148
|
+
});
|
|
149
|
+
expect(() => resolveYamlConfig([file("config.yaml", "- just\n- a list\n")])).toThrow(
|
|
150
|
+
/config\.yaml.*mapping/,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("says which file could not be parsed", () => {
|
|
155
|
+
expect(() => resolveYamlConfig([file("config.dev.yaml", "a: [unclosed\n")])).toThrow(
|
|
156
|
+
/config\.dev\.yaml/,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("YamlDataSource reads config.yaml then config.<env>.yaml (#634)", () => {
|
|
162
|
+
const saved = { CONFIG_DIR: process.env.CONFIG_DIR, NODE_ENV: process.env.NODE_ENV };
|
|
163
|
+
|
|
164
|
+
beforeEach(() => {
|
|
165
|
+
process.env.CONFIG_DIR = "/app/config";
|
|
166
|
+
delete process.env.CRYPTO_DB_HOST;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
vol.reset();
|
|
171
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
172
|
+
if (value === undefined) delete process.env[key];
|
|
173
|
+
else process.env[key] = value;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("boots dev when config.dev.yaml replaces the production placeholder", async () => {
|
|
178
|
+
process.env.NODE_ENV = "dev";
|
|
179
|
+
vol.fromJSON({
|
|
180
|
+
"/app/config/config.yaml": "database:\n host: ${CRYPTO_DB_HOST}\n",
|
|
181
|
+
"/app/config/config.dev.yaml": "database:\n host: postgres\n",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(await new YamlDataSource().getData()).toEqual({ database: { host: "postgres" } });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("still requires the variable in production, where nothing replaces it", async () => {
|
|
188
|
+
process.env.NODE_ENV = "production";
|
|
189
|
+
vol.fromJSON({
|
|
190
|
+
"/app/config/config.yaml": "database:\n host: ${CRYPTO_DB_HOST}\n",
|
|
191
|
+
"/app/config/config.dev.yaml": "database:\n host: postgres\n",
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await expect(new YamlDataSource().getData()).rejects.toThrow(/CRYPTO_DB_HOST/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("reads only the base file when there is no file for the environment", async () => {
|
|
198
|
+
process.env.NODE_ENV = "staging";
|
|
199
|
+
vol.fromJSON({ "/app/config/config.yaml": "name: api\n" });
|
|
200
|
+
|
|
201
|
+
expect(await new YamlDataSource().getData()).toEqual({ name: "api" });
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
const { resolveConfigEnv } = await import("../src/yaml-data-source.js");
|
|
4
|
+
|
|
5
|
+
describe("resolveConfigEnv CU-869eh25fu", () => {
|
|
6
|
+
it("defaults empty NODE_ENV to production (not dev)", () => {
|
|
7
|
+
expect(resolveConfigEnv("")).toBe("production");
|
|
8
|
+
expect(resolveConfigEnv(" ")).toBe("production");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("defaults unset process.env.NODE_ENV to production", () => {
|
|
12
|
+
const prev = process.env.NODE_ENV;
|
|
13
|
+
delete process.env.NODE_ENV;
|
|
14
|
+
try {
|
|
15
|
+
expect(resolveConfigEnv()).toBe("production");
|
|
16
|
+
} finally {
|
|
17
|
+
if (prev === undefined) delete process.env.NODE_ENV;
|
|
18
|
+
else process.env.NODE_ENV = prev;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("preserves explicit NODE_ENV values", () => {
|
|
23
|
+
expect(resolveConfigEnv("dev")).toBe("dev");
|
|
24
|
+
expect(resolveConfigEnv("production")).toBe("production");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("would have loaded config.dev.yaml under the old default (dev)", () => {
|
|
28
|
+
// Documents the regression: `?? "dev"` merged synchronize:true from config.dev.yaml.
|
|
29
|
+
expect(resolveConfigEnv()).not.toBe("dev");
|
|
30
|
+
});
|
|
31
|
+
});
|