Skip to content

Commit 3e5e390

Browse files
committed
feat: adds $envVar directive
1 parent 6619838 commit 3e5e390

File tree

3 files changed

+302
-12
lines changed

3 files changed

+302
-12
lines changed

app-config-default-extensions/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ module.exports = {
1919
ifDirective,
2020
eqDirective,
2121
envDirective,
22+
envVarDirective,
2223
extendsDirective,
2324
extendsSelfDirective,
2425
overrideDirective,
@@ -37,6 +38,7 @@ module.exports = {
3738
eqDirective(),
3839
v1Compat(),
3940
envDirective(aliases, environmentOverride, environmentSourceNames),
41+
envVarDirective(aliases, environmentOverride, environmentSourceNames),
4042
extendsDirective(),
4143
extendsSelfDirective(),
4244
overrideDirective(),

app-config-extensions/src/index.test.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
ifDirective,
88
eqDirective,
99
envDirective,
10+
envVarDirective,
1011
extendsDirective,
1112
extendsSelfDirective,
1213
overrideDirective,
@@ -898,6 +899,18 @@ describe('$substitute directive', () => {
898899
expect(parsed.toJSON()).toEqual({ foo: 'qa' });
899900
});
900901

902+
it('reads special case name APP_CONFIG_ENV', async () => {
903+
process.env.NODE_ENV = 'qa';
904+
905+
const source = new LiteralSource({
906+
foo: { $subs: { name: 'APP_CONFIG_ENV' } },
907+
});
908+
909+
const parsed = await source.read([substituteDirective()]);
910+
911+
expect(parsed.toJSON()).toEqual({ foo: 'qa' });
912+
});
913+
901914
it('reads object with $name', async () => {
902915
process.env.FOO = 'foo';
903916

@@ -1211,6 +1224,195 @@ describe('$substitute directive', () => {
12111224
});
12121225
});
12131226

1227+
describe('$envVar directive', () => {
1228+
it('fails with non-string values', async () => {
1229+
const source = new LiteralSource({ $envVar: {} });
1230+
await expect(source.read([envVarDirective()])).rejects.toThrow();
1231+
});
1232+
1233+
it('does simple environment variable substitution', async () => {
1234+
process.env.FOO = 'foo';
1235+
process.env.BAR = 'bar';
1236+
1237+
const source = new LiteralSource({
1238+
foo: { $envVar: 'FOO' },
1239+
bar: { $envVar: 'BAR' },
1240+
});
1241+
1242+
const parsed = await source.read([envVarDirective()]);
1243+
1244+
expect(parsed.toJSON()).toEqual({ foo: 'foo', bar: 'bar' });
1245+
});
1246+
1247+
it('reads object with $name', async () => {
1248+
process.env.FOO = 'foo';
1249+
1250+
const source = new LiteralSource({
1251+
foo: { $envVar: { name: 'FOO' } },
1252+
});
1253+
1254+
const parsed = await source.read([envVarDirective()]);
1255+
1256+
expect(parsed.toJSON()).toEqual({ foo: 'foo' });
1257+
});
1258+
1259+
it('fails with $name when not defined', async () => {
1260+
const source = new LiteralSource({
1261+
foo: { $envVar: { name: 'FOO' } },
1262+
});
1263+
1264+
await expect(source.read([envVarDirective()])).rejects.toThrow();
1265+
});
1266+
1267+
it('uses $name when $fallback is defined', async () => {
1268+
process.env.FOO = 'foo';
1269+
1270+
const source = new LiteralSource({
1271+
foo: { $envVar: { name: 'FOO', fallback: 'bar' } },
1272+
});
1273+
1274+
const parsed = await source.read([envVarDirective()]);
1275+
1276+
expect(parsed.toJSON()).toEqual({ foo: 'foo' });
1277+
});
1278+
1279+
it('uses $fallback when $name was not found', async () => {
1280+
const source = new LiteralSource({
1281+
foo: { $envVar: { name: 'FOO', fallback: 'bar' } },
1282+
});
1283+
1284+
const parsed = await source.read([envVarDirective()]);
1285+
1286+
expect(parsed.toJSON()).toEqual({ foo: 'bar' });
1287+
});
1288+
1289+
it('allows null value when $allowNull', async () => {
1290+
const source = new LiteralSource({
1291+
foo: { $envVar: { name: 'FOO', fallback: null, allowNull: true } },
1292+
});
1293+
1294+
const parsed = await source.read([envVarDirective()]);
1295+
1296+
expect(parsed.toJSON()).toEqual({ foo: null });
1297+
});
1298+
1299+
it('does not allow number even when $allowNull', async () => {
1300+
const source = new LiteralSource({
1301+
foo: { $envVar: { name: 'FOO', fallback: 42, allowNull: true } },
1302+
});
1303+
1304+
await expect(source.read([envVarDirective()])).rejects.toThrow();
1305+
});
1306+
1307+
it('parses ints', async () => {
1308+
process.env.FOO = '11';
1309+
1310+
const source = new LiteralSource({
1311+
$envVar: { name: 'FOO', parseInt: true },
1312+
});
1313+
1314+
expect(await source.readToJSON([envVarDirective()])).toEqual(11);
1315+
});
1316+
1317+
it('fails when int is invalid', async () => {
1318+
process.env.FOO = 'not a number';
1319+
1320+
const source = new LiteralSource({
1321+
$envVar: { name: 'FOO', parseInt: true },
1322+
});
1323+
1324+
await expect(source.read([envVarDirective()])).rejects.toThrow();
1325+
});
1326+
1327+
it('parses float', async () => {
1328+
process.env.FOO = '11.2';
1329+
1330+
const source = new LiteralSource({
1331+
$envVar: { name: 'FOO', parseFloat: true },
1332+
});
1333+
1334+
expect(await source.readToJSON([envVarDirective()])).toEqual(11.2);
1335+
});
1336+
1337+
it('fails when float is invalid', async () => {
1338+
process.env.FOO = 'not a number';
1339+
1340+
const source = new LiteralSource({
1341+
$envVar: { name: 'FOO', parseFloat: true },
1342+
});
1343+
1344+
await expect(source.read([envVarDirective()])).rejects.toThrow();
1345+
});
1346+
1347+
it('parses boolean = true', async () => {
1348+
process.env.FOO = 'true';
1349+
1350+
const source = new LiteralSource({
1351+
$envVar: { name: 'FOO', parseBool: true },
1352+
});
1353+
1354+
expect(await source.readToJSON([envVarDirective()])).toEqual(true);
1355+
});
1356+
1357+
it('parses boolean = 1', async () => {
1358+
process.env.FOO = '1';
1359+
1360+
const source = new LiteralSource({
1361+
$envVar: { name: 'FOO', parseBool: true },
1362+
});
1363+
1364+
expect(await source.readToJSON([envVarDirective()])).toEqual(true);
1365+
});
1366+
1367+
it('parses boolean = 0', async () => {
1368+
process.env.FOO = '0';
1369+
1370+
const source = new LiteralSource({
1371+
$envVar: { name: 'FOO', parseBool: true },
1372+
});
1373+
1374+
expect(await source.readToJSON([envVarDirective()])).toEqual(false);
1375+
});
1376+
1377+
it('parses boolean = false', async () => {
1378+
process.env.FOO = 'false';
1379+
1380+
const source = new LiteralSource({
1381+
$envVar: { name: 'FOO', parseBool: true },
1382+
});
1383+
1384+
expect(await source.readToJSON([envVarDirective()])).toEqual(false);
1385+
});
1386+
1387+
it('doesnt visit fallback if name is defined', async () => {
1388+
const failDirective = forKey('$fail', () => () => {
1389+
throw new Error();
1390+
});
1391+
1392+
process.env.FOO = 'foo';
1393+
1394+
const source = new LiteralSource({
1395+
foo: { $envVar: { name: 'FOO', fallback: { fail: true } } },
1396+
});
1397+
1398+
const parsed = await source.read([envVarDirective(), failDirective]);
1399+
1400+
expect(parsed.toJSON()).toEqual({ foo: 'foo' });
1401+
});
1402+
1403+
it('reads special case name APP_CONFIG_ENV', async () => {
1404+
process.env.NODE_ENV = 'qa';
1405+
1406+
const source = new LiteralSource({
1407+
foo: { $envVar: { name: 'APP_CONFIG_ENV' } },
1408+
});
1409+
1410+
const parsed = await source.read([envVarDirective()]);
1411+
1412+
expect(parsed.toJSON()).toEqual({ foo: 'qa' });
1413+
});
1414+
});
1415+
12141416
describe('$timestamp directive', () => {
12151417
it('uses the current date', async () => {
12161418
const now = new Date();

app-config-extensions/src/index.ts

Lines changed: 98 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -236,25 +236,95 @@ export function timestampDirective(dateSource: () => Date = () => new Date()): P
236236
);
237237
}
238238

239-
/** Substitues environment variables found in strings (similar to bash variable substitution) */
240-
export function substituteDirective(
239+
/** Substitues environment variables */
240+
export function envVarDirective(
241241
aliases: EnvironmentAliases = defaultAliases,
242242
environmentOverride?: string,
243243
environmentSourceNames?: string[] | string,
244244
): ParsingExtension {
245245
const envType = environmentOverride ?? currentEnvironment(aliases, environmentSourceNames);
246246

247-
const validateObject: ValidationFunction<
248-
Record<string, any>
249-
> = validationFunction(({ emptySchema }) => emptySchema().addAdditionalProperties());
247+
return forKey('$envVar', (value, key, ctx) => async (parse) => {
248+
let name: string;
249+
let parseInt = false;
250+
let parseFloat = false;
251+
let parseBool = false;
250252

251-
const validateString: ValidationFunction<string> = validationFunction(({ stringSchema }) =>
252-
stringSchema(),
253-
);
253+
if (typeof value === 'string') {
254+
name = value;
255+
} else {
256+
validateObject(value, [...ctx, key]);
257+
if (Array.isArray(value)) throw new AppConfigError('$envVar was given an array');
258+
259+
const resolved = (await parse(value.name)).toJSON();
260+
validateString(resolved, [...ctx, key, [InObject, 'name']]);
261+
262+
parseInt = !!(await parse(value.parseInt)).toJSON();
263+
parseFloat = !!(await parse(value.parseFloat)).toJSON();
264+
parseBool = !!(await parse(value.parseBool)).toJSON();
265+
name = resolved;
266+
}
254267

255-
const validateStringOrNull: ValidationFunction<string> = validationFunction(
256-
({ fromJsonSchema }) => fromJsonSchema({ type: ['null', 'string'] } as const),
257-
);
268+
let resolvedValue = process.env[name];
269+
270+
if (!resolvedValue && name === 'APP_CONFIG_ENV') {
271+
resolvedValue = envType;
272+
}
273+
274+
if (resolvedValue) {
275+
if (parseInt) {
276+
const parsed = Number.parseInt(resolvedValue, 10);
277+
278+
if (Number.isNaN(parsed)) {
279+
throw new AppConfigError(`Failed to parseInt(${resolvedValue})`);
280+
}
281+
282+
return parse(parsed, { shouldFlatten: true });
283+
}
284+
285+
if (parseFloat) {
286+
const parsed = Number.parseFloat(resolvedValue);
287+
288+
if (Number.isNaN(parsed)) {
289+
throw new AppConfigError(`Failed to parseFloat(${resolvedValue})`);
290+
}
291+
292+
return parse(parsed, { shouldFlatten: true });
293+
}
294+
295+
if (parseBool) {
296+
const parsed = resolvedValue.toLowerCase() !== 'false' && resolvedValue !== '0';
297+
298+
return parse(parsed, { shouldFlatten: true });
299+
}
300+
301+
return parse(resolvedValue, { shouldFlatten: true });
302+
}
303+
304+
if (typeof value === 'object' && value.fallback !== undefined) {
305+
const fallback = (await parse(value.fallback)).toJSON();
306+
const allowNull = (await parse(value.allowNull)).toJSON();
307+
308+
if (allowNull) {
309+
validateStringOrNull(fallback, [...ctx, key, [InObject, 'fallback']]);
310+
} else {
311+
validateString(fallback, [...ctx, key, [InObject, 'fallback']]);
312+
}
313+
314+
return parse(fallback, { shouldFlatten: true });
315+
}
316+
317+
throw new AppConfigError(`$envVar could not find ${name} environment variable`);
318+
});
319+
}
320+
321+
/** Substitues environment variables found in strings (similar to bash variable substitution) */
322+
export function substituteDirective(
323+
aliases: EnvironmentAliases = defaultAliases,
324+
environmentOverride?: string,
325+
environmentSourceNames?: string[] | string,
326+
): ParsingExtension {
327+
const envType = environmentOverride ?? currentEnvironment(aliases, environmentSourceNames);
258328

259329
return forKey(['$substitute', '$subs'], (value, key, ctx) => async (parse) => {
260330
if (typeof value === 'string') {
@@ -268,7 +338,11 @@ export function substituteDirective(
268338

269339
validateString(name, [...ctx, key, [InObject, 'name']]);
270340

271-
const resolvedValue = process.env[name];
341+
let resolvedValue = process.env[name];
342+
343+
if (!resolvedValue && name === 'APP_CONFIG_ENV') {
344+
resolvedValue = envType;
345+
}
272346

273347
if (resolvedValue) {
274348
const parseInt = (await parse(selectDefined(value.parseInt, value.$parseInt))).toJSON();
@@ -455,3 +529,15 @@ function selectDefined<T>(...args: (T | null | undefined)[]): T | null {
455529

456530
return undefined as any;
457531
}
532+
533+
const validateObject: ValidationFunction<
534+
Record<string, any>
535+
> = validationFunction(({ emptySchema }) => emptySchema().addAdditionalProperties());
536+
537+
const validateString: ValidationFunction<string> = validationFunction(({ stringSchema }) =>
538+
stringSchema(),
539+
);
540+
541+
const validateStringOrNull: ValidationFunction<string> = validationFunction(({ fromJsonSchema }) =>
542+
fromJsonSchema({ type: ['null', 'string'] } as const),
543+
);

0 commit comments

Comments
 (0)