Skip to content

Commit e7d77b3

Browse files
vsamofalNikaple
authored andcommitted
feature: expand values implementation for cosmic file loaders
1 parent 2f514f4 commit e7d77b3

23 files changed

+660
-75
lines changed

.nvmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
v18

lib/interfaces/typed-config-module-options.interface.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { ClassConstructor } from 'class-transformer';
22
import type { ValidatorOptions } from 'class-validator';
33

4-
export type ConfigLoader = () => Record<string, any>;
5-
export type AsyncConfigLoader = () => Promise<Record<string, any>>;
4+
export type ConfigLoader = (...args: any) => Record<string, any>;
5+
export type AsyncConfigLoader = (...args: any) => Promise<Record<string, any>>;
66

77
export interface TypedConfigModuleOptions {
88
/**

lib/loader/file-loader.ts

Lines changed: 152 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -68,44 +68,6 @@ const getSearchOptions = (options: FileLoaderOptions) => {
6868
};
6969
};
7070

71-
/**
72-
* Will fill in some placeholders.
73-
* @param template - Text with placeholders for `data` properties.
74-
* @param data - Data to interpolate into `template`.
75-
*
76-
* @example
77-
```
78-
placeholderResolver('Hello ${name}', {
79-
name: 'John',
80-
});
81-
//=> 'Hello John'
82-
```
83-
*/
84-
const placeholderResolver = (
85-
template: string,
86-
data: Record<string, any>,
87-
disallowUndefinedEnvironmentVariables: boolean,
88-
): string => {
89-
const replace = (placeholder: any, key: string) => {
90-
let value = data;
91-
for (const property of key.split('.')) {
92-
value = value[property];
93-
}
94-
95-
if (!value && disallowUndefinedEnvironmentVariables) {
96-
throw new Error(
97-
`Environment variable is not set for variable name: '${key}'`,
98-
);
99-
}
100-
return String(value);
101-
};
102-
103-
// The regex tries to match either a number inside `${{ }}` or a valid JS identifier or key path.
104-
const braceRegex = /\${(\d+|[a-z$_][\w\-$]*?(?:\.[\w\-$]*?)*?)}/gi;
105-
106-
return template.replace(braceRegex, replace);
107-
};
108-
10971
/**
11072
* File loader loads configuration with `cosmiconfig` from file system.
11173
*
@@ -126,7 +88,7 @@ export const fileLoader = (
12688
cosmiconfig = loadPackage('cosmiconfig', 'fileLoader');
12789

12890
const { cosmiconfigSync } = cosmiconfig;
129-
return (): Record<string, any> => {
91+
return (additionalContext: Record<string, any> = {}): Record<string, any> => {
13092
const { searchPlaces, searchFrom } = getSearchOptions(options);
13193
const loaders = {
13294
'.toml': loadToml,
@@ -136,6 +98,31 @@ export const fileLoader = (
13698
searchPlaces,
13799
...options,
138100
loaders,
101+
transform: (result: Record<string, any>) => {
102+
if (options.ignoreEnvironmentVariableSubstitution ?? true) {
103+
return result;
104+
}
105+
106+
try {
107+
const updatedResult = transformFileLoaderResult(
108+
result.config,
109+
{
110+
// additionalContext must be first so the actual config will override it
111+
...additionalContext,
112+
...result.config,
113+
...process.env,
114+
},
115+
options.disallowUndefinedEnvironmentVariables ?? true,
116+
);
117+
118+
result.config = updatedResult;
119+
return result;
120+
} catch (error: any) {
121+
// enrich error with options information
122+
error.details = options;
123+
throw error;
124+
}
125+
},
139126
});
140127
const result = explorer.search(searchFrom);
141128

@@ -147,17 +134,132 @@ export const fileLoader = (
147134
`File-loader has loaded a configuration file from ${result.filepath}`,
148135
);
149136

150-
let config = result.config;
137+
return result.config;
138+
};
139+
};
140+
141+
function resolveReference(
142+
reference: string,
143+
currentContext: Record<string, any>,
144+
): Record<string, any> | string | number | null {
145+
const parts = reference.split('.');
146+
let value = currentContext;
151147

152-
if (!(options.ignoreEnvironmentVariableSubstitution ?? true)) {
153-
const replacedConfig = placeholderResolver(
154-
JSON.stringify(result.config),
155-
process.env,
156-
options.disallowUndefinedEnvironmentVariables ?? true,
157-
);
158-
config = JSON.parse(replacedConfig);
148+
for (const part of parts) {
149+
value = value[part];
150+
if (value === undefined) {
151+
return null;
159152
}
153+
}
160154

161-
return config;
162-
};
163-
};
155+
return value;
156+
}
157+
158+
function transformFileLoaderResult(
159+
obj: Record<string, any> | string | number,
160+
context: Record<string, any>,
161+
disallowUndefinedEnvironmentVariables: boolean,
162+
visited = new Set<Record<string, any> | string | number>(),
163+
): Record<string, any> | string | number {
164+
if (typeof obj === 'string') {
165+
const match = obj.match(/\$\{(.+?)\}/g);
166+
if (match) {
167+
for (const placeholder of match) {
168+
const variable = placeholder.slice(2, -1);
169+
let resolvedValue = resolveReference(variable, context);
170+
171+
if (obj === resolvedValue) {
172+
throw new Error(
173+
`Circular self reference detected: ${obj} -> ${resolvedValue}`,
174+
);
175+
}
176+
177+
if (resolvedValue !== null) {
178+
if (typeof resolvedValue === 'string') {
179+
// resolve reference first
180+
if (resolvedValue.match(/\$\{(.+?)\}/)) {
181+
try {
182+
resolvedValue = transformFileLoaderResult(
183+
resolvedValue,
184+
context,
185+
disallowUndefinedEnvironmentVariables,
186+
visited,
187+
);
188+
} catch (error) {
189+
if (error instanceof RangeError) {
190+
debug(
191+
`Can not resolve a circular reference in ${obj} -> ${resolvedValue} -> ${error.message}`,
192+
error,
193+
);
194+
}
195+
196+
throw error;
197+
}
198+
}
199+
200+
obj = obj.toString().replace(placeholder, resolvedValue.toString());
201+
} else if (typeof resolvedValue === 'object') {
202+
obj = transformFileLoaderResult(
203+
obj,
204+
resolvedValue,
205+
disallowUndefinedEnvironmentVariables,
206+
visited,
207+
);
208+
} else if (
209+
typeof resolvedValue === 'number' ||
210+
typeof resolvedValue === 'boolean'
211+
) {
212+
// if it's one to one reference, just return it as a number
213+
if (obj === placeholder) {
214+
obj = resolvedValue;
215+
} else {
216+
// this means that we're embedding some number into string
217+
obj = obj
218+
.toString()
219+
.replace(placeholder, resolvedValue.toString());
220+
}
221+
}
222+
} else if (disallowUndefinedEnvironmentVariables) {
223+
throw new Error(
224+
`Environment variable is not set for variable name: '${variable}'`,
225+
);
226+
}
227+
}
228+
}
229+
} else if (typeof obj === 'number' || typeof obj === 'boolean') {
230+
return obj;
231+
} else if (typeof obj === 'object' && obj !== null) {
232+
/**
233+
* it's not really possible to have circular reference in JSON and yaml like this
234+
* but probably one day there will be more complex scenarios for this function
235+
*/
236+
/* istanbul ignore next */
237+
if (visited.has(obj)) {
238+
return obj; // Avoid infinite loops on circular references
239+
}
240+
visited.add(obj);
241+
242+
if (Array.isArray(obj)) {
243+
for (let i = 0; i < obj.length; i++) {
244+
obj[i] = transformFileLoaderResult(
245+
obj[i],
246+
context,
247+
disallowUndefinedEnvironmentVariables,
248+
visited,
249+
);
250+
}
251+
} else {
252+
for (const key in obj) {
253+
obj[key] = transformFileLoaderResult(
254+
obj[key],
255+
context,
256+
disallowUndefinedEnvironmentVariables,
257+
visited,
258+
);
259+
}
260+
}
261+
262+
visited.delete(obj);
263+
}
264+
return obj;
265+
}

lib/typed-config.module.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,17 @@ export class TypedConfigModule {
6161
if (Array.isArray(load)) {
6262
const config = {};
6363
for (const fn of load) {
64+
// we shouldn't silently catch errors here, because app shouldn't start without the proper config
65+
// same way as it doesn't start without the proper database connection
66+
// and the same way as it now fail for the single loader
6467
try {
65-
const conf = fn();
68+
const conf = fn(config);
6669
merge(config, conf);
67-
} catch (err: any) {
68-
debug(`Config load failed: ${err.message}`);
70+
} catch (e: any) {
71+
debug(
72+
`Config load failed: ${e}. Details: ${JSON.stringify(e.details)}`,
73+
);
74+
throw e;
6975
}
7076
}
7177
return config;
@@ -80,10 +86,13 @@ export class TypedConfigModule {
8086
const config = {};
8187
for (const fn of load) {
8288
try {
83-
const conf = await fn();
89+
const conf = await fn(config);
8490
merge(config, conf);
85-
} catch (err: any) {
86-
debug(`Config load failed: ${err.message}`);
91+
} catch (e: any) {
92+
debug(
93+
`Config load failed: ${e}. Details: ${JSON.stringify(e.details)}`,
94+
);
95+
throw e;
8796
}
8897
}
8998
return config;

tests/e2e/multiple-loaders.spec.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,11 @@ describe('Local toml', () => {
3535
expect(databaseConfig.host).toBe('host.part1');
3636
});
3737

38-
it(`should be able load config when some of the loaders fail`, async () => {
39-
await init(['reject', 'part1', 'part2']);
40-
const tableConfig = app.get(TableConfig);
41-
expect(tableConfig.name).toBe('test');
42-
43-
await init(['reject', 'part1', 'part2'], false);
44-
const tableConfig2 = app.get(TableConfig);
45-
expect(tableConfig2.name).toBe('test');
38+
/**
39+
* this is a subject for discussion
40+
* */
41+
it(`should not be able load config when some of the loaders fail`, async () => {
42+
expect(init(['reject', 'part1', 'part2'])).rejects.toThrowError();
4643
});
4744

4845
afterEach(async () => {

0 commit comments

Comments
 (0)