Skip to content

Commit 1808f28

Browse files
committed
feat(#129): adds parsing context in parsing extensions
1 parent fae1292 commit 1808f28

File tree

11 files changed

+174
-79
lines changed

11 files changed

+174
-79
lines changed

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: 21 additions & 6 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, parentKeys: 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,15 +355,17 @@ 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 = {},
368+
context: ParsingContext = {},
358369
parentKeys: ParsingExtensionKey[],
359370
root: Json,
360371
parent?: JsonObject | Json[],
@@ -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, parentKeysNext);
391+
const applicable = extension(value, currentKey, parentKeysNext, context);
381392

382393
if (applicable) {
383394
applicableExtension = applicable;
@@ -397,12 +408,14 @@ 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 },
418+
{ ...context, ...contextOverride },
406419
parentKeys,
407420
root,
408421
parent,
@@ -421,6 +434,7 @@ async function parseValueInner(
421434
source,
422435
extensions,
423436
undefined,
437+
context,
424438
parentKeys.concat([[InArray, index]]),
425439
root,
426440
value,
@@ -446,6 +460,7 @@ async function parseValueInner(
446460
source,
447461
extensions,
448462
undefined,
463+
context,
449464
parentKeys.concat([[InObject, key]]),
450465
root,
451466
value,

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

Lines changed: 16 additions & 10 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;
@@ -73,12 +75,13 @@ export function validateOptions<T>(
7375
value: T,
7476
key: ParsingExtensionKey,
7577
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

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

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { named, forKey, validationFunction, ValidationFunction } from '@app-conf
33
import { AppConfigError, InObject } from '@app-config/core';
44
import {
55
asEnvOptions,
6-
currentEnvironment,
6+
currentEnvFromContext,
77
defaultAliases,
88
EnvironmentAliases,
99
} from '@app-config/node';
@@ -15,13 +15,14 @@ export function envVarDirective(
1515
environmentOverride?: string,
1616
environmentSourceNames?: string[] | string,
1717
): ParsingExtension {
18-
const environment = currentEnvironment(
19-
asEnvOptions(environmentOverride, aliases, environmentSourceNames),
20-
);
21-
2218
return named(
2319
'$envVar',
24-
forKey('$envVar', (value, key, ctx) => async (parse) => {
20+
forKey('$envVar', (value, key, parentKeys, context) => async (parse) => {
21+
const environment = currentEnvFromContext(
22+
context,
23+
asEnvOptions(environmentOverride, aliases, environmentSourceNames),
24+
);
25+
2526
let name: string;
2627
let parseInt = false;
2728
let parseFloat = false;
@@ -30,11 +31,11 @@ export function envVarDirective(
3031
if (typeof value === 'string') {
3132
name = value;
3233
} else {
33-
validateObject(value, [...ctx, key]);
34+
validateObject(value, [...parentKeys, key]);
3435
if (Array.isArray(value)) throw new AppConfigError('$envVar was given an array');
3536

3637
const resolved = (await parse(value.name)).toJSON();
37-
validateString(resolved, [...ctx, key, [InObject, 'name']]);
38+
validateString(resolved, [...parentKeys, key, [InObject, 'name']]);
3839

3940
parseInt = !!(await parse(value.parseInt)).toJSON();
4041
parseFloat = !!(await parse(value.parseFloat)).toJSON();
@@ -111,9 +112,9 @@ export function envVarDirective(
111112
const allowNull = (await parse(value.allowNull)).toJSON();
112113

113114
if (allowNull) {
114-
validateStringOrNull(fallback, [...ctx, key, [InObject, 'fallback']]);
115+
validateStringOrNull(fallback, [...parentKeys, key, [InObject, 'fallback']]);
115116
} else {
116-
validateString(fallback, [...ctx, key, [InObject, 'fallback']]);
117+
validateString(fallback, [...parentKeys, key, [InObject, 'fallback']]);
117118
}
118119

119120
return parseValue(fallback);

0 commit comments

Comments
 (0)