Skip to content

Commit e8522e1

Browse files
authored
Merge pull request #151 from launchcodedev/extends-with-env
Supports 'env' option in $extends and $override directives
2 parents f0d9a3f + 155fc8f commit e8522e1

File tree

16 files changed

+409
-108
lines changed

16 files changed

+409
-108
lines changed

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,35 @@ describe('Configuration Loading', () => {
219219
},
220220
);
221221
});
222+
223+
it('extends from another file with different environment', async () => {
224+
await withTempFiles(
225+
{
226+
'.app-config.yml': `
227+
$extends:
228+
path: ./other-file.yml
229+
env: production
230+
bar:
231+
$envVar: APP_CONFIG_ENV
232+
`,
233+
'other-file.yml': `
234+
foo:
235+
$env:
236+
default: default
237+
prod: production
238+
`,
239+
},
240+
async (inDir) => {
241+
expect(
242+
(await loadUnvalidatedConfig({ directory: inDir('.'), environmentOverride: 'test' }))
243+
.fullConfig,
244+
).toEqual({
245+
foo: 'production',
246+
bar: 'test',
247+
});
248+
},
249+
);
250+
});
222251
});
223252

224253
describe('Configuration Loading Options', () => {

app-config-core/src/config-source.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { safeLoad as parseYAML, safeDump as stringifyYAML } from 'js-yaml';
44
import { parse as parseJSON5, stringify as stringifyJSON5 } from 'json5';
55
import { Json, JsonObject } from '@app-config/utils';
66
import { logger } from '@app-config/logging';
7-
import { ParsedValue, ParsingExtension } from './parsed-value';
7+
import { ParsedValue, ParsingContext, ParsingExtension } from './parsed-value';
88
import { AppConfigError, NotFoundError, ParsingError, BadFileType } from './errors';
99

1010
export enum FileType {
@@ -30,15 +30,15 @@ export abstract class ConfigSource {
3030
}
3131

3232
/** Reads the contents of the source into a full ParsedValue (not the raw JSON, like readValue) */
33-
async read(extensions?: ParsingExtension[]): Promise<ParsedValue> {
33+
async read(extensions?: ParsingExtension[], context?: ParsingContext): Promise<ParsedValue> {
3434
const rawValue = await this.readValue();
3535

36-
return ParsedValue.parse(rawValue, this, extensions);
36+
return ParsedValue.parse(rawValue, this, extensions, undefined, context);
3737
}
3838

3939
/** Ergonomic helper for chaining `source.read(extensions).then(v => v.toJSON())` */
40-
async readToJSON(extensions?: ParsingExtension[]): Promise<Json> {
41-
const parsed = await this.read(extensions);
40+
async readToJSON(extensions?: ParsingExtension[], context?: ParsingContext): Promise<Json> {
41+
const parsed = await this.read(extensions, context);
4242

4343
return parsed.toJSON();
4444
}
@@ -82,8 +82,10 @@ export class CombinedSource extends ConfigSource {
8282
}
8383

8484
// override so that ParsedValue is directly from the originating ConfigSource
85-
async read(extensions?: ParsingExtension[]): Promise<ParsedValue> {
86-
const values = await Promise.all(this.sources.map((source) => source.read(extensions)));
85+
async read(extensions?: ParsingExtension[], context?: ParsingContext): Promise<ParsedValue> {
86+
const values = await Promise.all(
87+
this.sources.map((source) => source.read(extensions, { ...context })),
88+
);
8789

8890
const merged = values.reduce<ParsedValue | undefined>((acc, parsed) => {
8991
if (!acc) return parsed;
@@ -137,11 +139,11 @@ export class FallbackSource extends ConfigSource {
137139
}
138140

139141
// override so that ParsedValue is directly from the originating ConfigSource
140-
async read(extensions?: ParsingExtension[]): Promise<ParsedValue> {
142+
async read(extensions?: ParsingExtension[], context?: ParsingContext): Promise<ParsedValue> {
141143
// take the first value that comes back without an error
142144
for (const source of this.sources) {
143145
try {
144-
const value = await source.read(extensions);
146+
const value = await source.read(extensions, context);
145147
logger.verbose(`FallbackSource found successful source`);
146148

147149
return value;

app-config-core/src/parsed-value.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,17 @@ export type ParsingExtensionKey =
1717
| [typeof InArray, number]
1818
| [typeof Root];
1919

20+
export interface ParsingContext {
21+
[k: string]: string | string[] | undefined | ParsingContext;
22+
}
23+
2024
export interface ParsingExtension {
21-
(value: Json, key: ParsingExtensionKey, context: ParsingExtensionKey[]):
22-
| ParsingExtensionTransform
23-
| false;
25+
(
26+
value: Json,
27+
key: ParsingExtensionKey,
28+
parentKeys: ParsingExtensionKey[],
29+
context: ParsingContext,
30+
): ParsingExtensionTransform | false;
2431

2532
/**
2633
* A globally unique string that identifies what parsing extension this is.
@@ -36,6 +43,7 @@ export type ParsingExtensionTransform = (
3643
metadata?: ParsedValueMetadata,
3744
source?: ConfigSource,
3845
extensions?: ParsingExtension[],
46+
context?: ParsingContext,
3947
) => Promise<ParsedValue>,
4048
parent: JsonObject | Json[] | undefined,
4149
source: ConfigSource,
@@ -73,8 +81,9 @@ export class ParsedValue {
7381
source: ConfigSource,
7482
extensions?: ParsingExtension[],
7583
metadata?: ParsedValueMetadata,
84+
context?: ParsingContext,
7685
): Promise<ParsedValue> {
77-
return parseValue(raw, source, extensions, metadata);
86+
return parseValue(raw, source, extensions, metadata, context);
7887
}
7988

8089
/** Parses (with extensions) from a plain JSON object */
@@ -346,22 +355,24 @@ export async function parseValue(
346355
source: ConfigSource,
347356
extensions: ParsingExtension[] = [],
348357
metadata: ParsedValueMetadata = {},
358+
context: ParsingContext = {},
349359
): Promise<ParsedValue> {
350-
return parseValueInner(value, source, extensions, metadata, [[Root]], value);
360+
return parseValueInner(value, source, extensions, metadata, context, [[Root]], value);
351361
}
352362

353363
async function parseValueInner(
354364
value: Json,
355365
source: ConfigSource,
356366
extensions: ParsingExtension[],
357367
metadata: ParsedValueMetadata = {},
358-
context: ParsingExtensionKey[],
368+
context: ParsingContext = {},
369+
parentKeys: ParsingExtensionKey[],
359370
root: Json,
360371
parent?: JsonObject | Json[],
361372
visitedExtensions: Set<ParsingExtension | string> = new Set(),
362373
): Promise<ParsedValue> {
363-
const [currentKey] = context.slice(-1);
364-
const contextualKeys = context.slice(0, context.length - 1);
374+
const [currentKey] = parentKeys.slice(-1);
375+
const parentKeysNext = parentKeys.slice(0, parentKeys.length - 1);
365376

366377
let applicableExtension: ParsingExtensionTransform | undefined;
367378

@@ -377,7 +388,7 @@ async function parseValueInner(
377388
if (visitedExtensions.has(extension)) continue;
378389
if (extension.extensionName && visitedExtensions.has(extension.extensionName)) continue;
379390

380-
const applicable = extension(value, currentKey, contextualKeys);
391+
const applicable = extension(value, currentKey, parentKeysNext, context);
381392

382393
if (applicable) {
383394
applicableExtension = applicable;
@@ -397,13 +408,15 @@ async function parseValueInner(
397408
metadataOverride?: ParsedValueMetadata,
398409
sourceOverride?: ConfigSource,
399410
extensionsOverride?: ParsingExtension[],
411+
contextOverride?: ParsingContext,
400412
) =>
401413
parseValueInner(
402414
inner,
403415
sourceOverride ?? source,
404416
extensionsOverride ?? extensions,
405417
{ ...metadata, ...metadataOverride },
406-
context,
418+
{ ...context, ...contextOverride },
419+
parentKeys,
407420
root,
408421
parent,
409422
visitedExtensions,
@@ -421,7 +434,8 @@ async function parseValueInner(
421434
source,
422435
extensions,
423436
undefined,
424-
context.concat([[InArray, index]]),
437+
context,
438+
parentKeys.concat([[InArray, index]]),
425439
root,
426440
value,
427441
);
@@ -446,7 +460,8 @@ async function parseValueInner(
446460
source,
447461
extensions,
448462
undefined,
449-
context.concat([[InObject, key]]),
463+
context,
464+
parentKeys.concat([[InObject, key]]),
450465
root,
451466
value,
452467
);

app-config-exec/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ function execParsingExtension(): ParsingExtension {
3333
.addBoolean('parseOutput', {}, false)
3434
.addBoolean('trimWhitespace', {}, false),
3535
),
36-
(value) => async (parse, _, context) => {
36+
(value) => async (parse, _, source) => {
3737
let options;
3838

3939
if (typeof value === 'string') {
@@ -50,7 +50,7 @@ function execParsingExtension(): ParsingExtension {
5050
} = options;
5151

5252
try {
53-
const dir = resolveFilepath(context, '.');
53+
const dir = resolveFilepath(source, '.');
5454
const { stdout, stderr } = await execAsync(command, { cwd: dir });
5555

5656
if (failOnStderr && stderr) {

app-config-extension-utils/src/index.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
ParsingContext,
23
ParsingExtension,
34
ParsingExtensionKey,
45
ParsingExtensionTransform,
@@ -7,7 +8,7 @@ import { parseValue, Root, AppConfigError } from '@app-config/core';
78
import { SchemaBuilder } from '@serafin/schema-builder';
89

910
export function composeExtensions(extensions: ParsingExtension[]): ParsingExtension {
10-
const composed: ParsingExtension = (value, [k]) => {
11+
const composed: ParsingExtension = (value, [k], _, context) => {
1112
// only applies to the root - override the parsing extensions
1213
if (k !== Root) return false;
1314

@@ -19,6 +20,7 @@ export function composeExtensions(extensions: ParsingExtension[]): ParsingExtens
1920
// ensures that a recursion doesn't happen
2021
baseExtensions.concat(extensions).filter((v) => v !== composed),
2122
{ shouldFlatten: true },
23+
context,
2224
);
2325
};
2426

@@ -45,9 +47,9 @@ export function forKey(
4547
return key === k;
4648
};
4749

48-
return (value, ctxKey, ctx) => {
49-
if (shouldApply(ctxKey)) {
50-
return parsingExtension(value, ctxKey, ctx);
50+
return (value, currentKey, parentKeys, context) => {
51+
if (shouldApply(currentKey)) {
52+
return parsingExtension(value, currentKey, parentKeys, context);
5153
}
5254

5355
return false;
@@ -72,13 +74,14 @@ export function validateOptions<T>(
7274
extension: (
7375
value: T,
7476
key: ParsingExtensionKey,
75-
context: ParsingExtensionKey[],
77+
parentKeys: ParsingExtensionKey[],
78+
context: ParsingContext,
7679
) => ParsingExtensionTransform | false,
7780
{ lazy = false }: { lazy?: boolean } = {},
7881
): ParsingExtension {
7982
const validate: ValidationFunction<T> = validationFunction(builder);
8083

81-
return (value, ctxKey, ctx) => {
84+
return (value, key, parentKeys, context) => {
8285
return async (parse, ...args) => {
8386
let valid: unknown;
8487

@@ -88,9 +91,9 @@ export function validateOptions<T>(
8891
valid = (await parse(value)).toJSON();
8992
}
9093

91-
validate(valid, [...ctx, ctxKey]);
94+
validate(valid, [...parentKeys, key]);
9295

93-
const call = extension(valid, ctxKey, ctx);
96+
const call = extension(valid, key, parentKeys, context);
9497

9598
if (call) {
9699
return call(parse, ...args);
@@ -103,7 +106,10 @@ export function validateOptions<T>(
103106
};
104107
}
105108

106-
export type ValidationFunction<T> = (value: any, ctx: ParsingExtensionKey[]) => asserts value is T;
109+
export type ValidationFunction<T> = (
110+
value: any,
111+
parentKeys: ParsingExtensionKey[],
112+
) => asserts value is T;
107113

108114
export function validationFunction<T>(
109115
builder: (builder: typeof SchemaBuilder) => SchemaBuilder<T>,
@@ -112,14 +118,14 @@ export function validationFunction<T>(
112118

113119
schema.cacheValidationFunction();
114120

115-
return (value, ctx): asserts value is T => {
121+
return (value, parentKeys): asserts value is T => {
116122
try {
117123
schema.validate(value);
118124
} catch (error) {
119125
const message = error instanceof Error ? error.message : 'unknown';
120126

121127
throw new ParsingExtensionInvalidOptions(
122-
`Validation failed in "${keysToPath(ctx)}": ${message}`,
128+
`Validation failed in "${keysToPath(parentKeys)}": ${message}`,
123129
);
124130
}
125131
};

app-config-extensions/src/env-directive.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ParsingExtension } from '@app-config/core';
22
import { AppConfigError } from '@app-config/core';
33
import { named, forKey, keysToPath, validateOptions } from '@app-config/extension-utils';
44
import {
5-
currentEnvironment,
5+
currentEnvFromContext,
66
defaultAliases,
77
asEnvOptions,
88
EnvironmentAliases,
@@ -15,17 +15,19 @@ export function envDirective(
1515
environmentSourceNames?: string[] | string,
1616
): ParsingExtension {
1717
const metadata = { shouldOverride: true };
18-
const environment = currentEnvironment(
19-
asEnvOptions(environmentOverride, aliases, environmentSourceNames),
20-
);
2118

2219
return named(
2320
'$env',
2421
forKey(
2522
'$env',
2623
validateOptions(
2724
(SchemaBuilder) => SchemaBuilder.emptySchema().addAdditionalProperties(),
28-
(value, _, ctx) => (parse) => {
25+
(value, _, parentKeys, context) => (parse) => {
26+
const environment = currentEnvFromContext(
27+
context,
28+
asEnvOptions(environmentOverride, aliases, environmentSourceNames),
29+
);
30+
2931
if (!environment) {
3032
if ('none' in value) {
3133
return parse(value.none, metadata);
@@ -35,10 +37,10 @@ export function envDirective(
3537
return parse(value.default, metadata);
3638
}
3739

40+
const path = keysToPath(parentKeys);
41+
3842
throw new AppConfigError(
39-
`An $env directive was used (in ${keysToPath(
40-
ctx,
41-
)}), but current environment (eg. NODE_ENV) is undefined`,
43+
`An $env directive was used (in ${path}), but current environment (eg. NODE_ENV) is undefined`,
4244
);
4345
}
4446

@@ -53,11 +55,10 @@ export function envDirective(
5355
}
5456

5557
const found = Object.keys(value).join(', ');
58+
const path = keysToPath(parentKeys);
5659

5760
throw new AppConfigError(
58-
`An $env directive was used (in ${keysToPath(
59-
ctx,
60-
)}), but none matched the current environment (wanted ${environment}, saw [${found}])`,
61+
`An $env directive was used (in ${path}), but none matched the current environment (wanted ${environment}, saw [${found}])`,
6162
);
6263
},
6364
// $env is lazy so that non-applicable envs don't get evaluated

0 commit comments

Comments
 (0)