Skip to content

Commit 6c0406c

Browse files
committed
feat: separates "validationFunction" for "lazy" validation of parsing extensions
1 parent d29a09d commit 6c0406c

File tree

3 files changed

+110
-64
lines changed

3 files changed

+110
-64
lines changed

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

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import type { Json } from '@app-config/utils';
12
import type {
3+
ParsedValue,
24
ParsingExtension,
35
ParsingExtensionKey,
46
ParsingExtensionTransform,
@@ -48,33 +50,19 @@ export function validateOptions<T>(
4850
) => ParsingExtensionTransform | false,
4951
{ lazy = false }: { lazy?: boolean } = {},
5052
): ParsingExtension {
51-
const schema = builder(SchemaBuilder);
52-
53-
schema.cacheValidationFunction();
53+
const validate: ValidationFunction<T> = validationFunction(builder);
5454

5555
return (value, ctxKey, ctx) => {
5656
return async (parse, ...args) => {
57-
let valid: T;
57+
let valid: unknown;
5858

5959
if (lazy) {
60-
valid = (value as unknown) as T;
60+
valid = value;
6161
} else {
62-
valid = ((await parse(value)).toJSON() as unknown) as T;
62+
valid = (await parse(value)).toJSON();
6363
}
6464

65-
try {
66-
schema.validate(valid);
67-
} catch (error) {
68-
const message = error instanceof Error ? error.message : 'unknown';
69-
70-
const parents =
71-
[...ctx, ctxKey]
72-
.map(([, k]) => k)
73-
.filter((v) => !!v)
74-
.join('.') || 'root';
75-
76-
throw new ParsingExtensionInvalidOptions(`Validation failed in "${parents}": ${message}`);
77-
}
65+
validate(valid, [...ctx, ctxKey]);
7866

7967
const call = extension(valid, ctxKey, ctx);
8068

@@ -88,3 +76,29 @@ export function validateOptions<T>(
8876
};
8977
};
9078
}
79+
80+
export type ValidationFunction<T> = (value: any, ctx: ParsingExtensionKey[]) => asserts value is T;
81+
82+
export function validationFunction<T>(
83+
builder: (builder: typeof SchemaBuilder) => SchemaBuilder<T>,
84+
): ValidationFunction<T> {
85+
const schema = builder(SchemaBuilder);
86+
87+
schema.cacheValidationFunction();
88+
89+
return (value, ctx): asserts value is T => {
90+
try {
91+
schema.validate(value);
92+
} catch (error) {
93+
const message = error instanceof Error ? error.message : 'unknown';
94+
95+
const parents =
96+
ctx
97+
.map(([, k]) => k)
98+
.filter((v) => !!v)
99+
.join('.') || 'root';
100+
101+
throw new ParsingExtensionInvalidOptions(`Validation failed in "${parents}": ${message}`);
102+
}
103+
};
104+
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,22 @@ describe('$substitute directive', () => {
722722

723723
expect(parsed.toJSON()).toEqual({ foo: 'bar' });
724724
});
725+
726+
it('doesnt visit fallback if name is defined', async () => {
727+
const failDirective = forKey('$fail', () => () => {
728+
throw new Error();
729+
});
730+
731+
process.env.FOO = 'foo';
732+
733+
const source = new LiteralSource({
734+
foo: { $substitute: { $name: 'FOO', $fallback: { $fail: true } } },
735+
});
736+
737+
const parsed = await source.read([environmentVariableSubstitution(), failDirective]);
738+
739+
expect(parsed.toJSON()).toEqual({ foo: 'foo' });
740+
});
725741
});
726742

727743
describe('$timestamp directive', () => {

app-config-extensions/src/index.ts

Lines changed: 61 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { join, dirname, resolve, isAbsolute } from 'path';
2-
import { forKey, validateOptions } from '@app-config/extension-utils';
2+
import {
3+
forKey,
4+
validateOptions,
5+
validationFunction,
6+
ValidationFunction,
7+
} from '@app-config/extension-utils';
38
import {
49
ParsedValue,
510
ParsedValueMetadata,
611
ParsingExtension,
712
AppConfigError,
813
NotFoundError,
914
FailedToSelectSubObject,
15+
InObject,
1016
} from '@app-config/core';
1117
import {
1218
currentEnvironment,
@@ -46,26 +52,27 @@ export function extendsDirective(): ParsingExtension {
4652

4753
/** Lookup a property in the same file, and "copy" it */
4854
export function extendsSelfDirective(): ParsingExtension {
49-
return forKey(
50-
'$extendsSelf',
51-
validateOptions(
52-
(SchemaBuilder) => SchemaBuilder.stringSchema(),
53-
(value) => async (parse, _, __, ___, root) => {
54-
// we temporarily use a ParsedValue literal so that we get the same property lookup semantics
55-
const selected = ParsedValue.literal(root).property(value.split('.'));
55+
const validate: ValidationFunction<string> = validationFunction(({ stringSchema }) =>
56+
stringSchema(),
57+
);
5658

57-
if (selected === undefined) {
58-
throw new AppConfigError(`$extendsSelf selector was not found (${value})`);
59-
}
59+
return forKey('$extendsSelf', (input, key, ctx) => async (parse, _, __, ___, root) => {
60+
const value = (await parse(input)).toJSON();
61+
validate(value, [...ctx, key]);
6062

61-
if (selected.asObject() !== undefined) {
62-
return parse(selected.toJSON(), { shouldMerge: true });
63-
}
63+
// we temporarily use a ParsedValue literal so that we get the same property lookup semantics
64+
const selected = ParsedValue.literal(root).property(value.split('.'));
6465

65-
return parse(selected.toJSON(), { shouldFlatten: true });
66-
},
67-
),
68-
);
66+
if (selected === undefined) {
67+
throw new AppConfigError(`$extendsSelf selector was not found (${value})`);
68+
}
69+
70+
if (selected.asObject() !== undefined) {
71+
return parse(selected.toJSON(), { shouldMerge: true });
72+
}
73+
74+
return parse(selected.toJSON(), { shouldFlatten: true });
75+
});
6976
}
7077

7178
/** Looks up an environment-specific value ($env) */
@@ -108,6 +115,7 @@ export function envDirective(
108115
`An $env directive was used, but none matched the current environment (wanted ${environment}, saw [${found}])`,
109116
);
110117
},
118+
// $env is lazy so that non-applicable envs don't get evaluated
111119
{ lazy: true },
112120
),
113121
);
@@ -158,36 +166,44 @@ export function environmentVariableSubstitution(
158166
): ParsingExtension {
159167
const envType = environmentOverride ?? currentEnvironment(aliases, environmentSourceNames);
160168

161-
return forKey(
162-
['$substitute', '$subs'],
163-
validateOptions(
164-
(SchemaBuilder) =>
165-
SchemaBuilder.oneOf(
166-
SchemaBuilder.stringSchema(),
167-
SchemaBuilder.emptySchema().addString('$name').addString('$fallback', {}, false),
168-
),
169-
(value) => (parse) => {
170-
if (typeof value === 'object') {
171-
const { $name: variableName, $fallback: fallback } = value;
172-
const resolvedValue = process.env[variableName];
169+
const validateObject: ValidationFunction<
170+
Record<string, any>
171+
> = validationFunction(({ emptySchema }) => emptySchema().addAdditionalProperties());
173172

174-
if (fallback !== undefined) {
175-
return parse(resolvedValue || fallback, { shouldFlatten: true });
176-
}
173+
const validateString: ValidationFunction<string> = validationFunction(({ stringSchema }) =>
174+
stringSchema(),
175+
);
177176

178-
if (!resolvedValue) {
179-
throw new AppConfigError(
180-
`$substitute could not find ${variableName} environment variable`,
181-
);
182-
}
177+
return forKey(['$substitute', '$subs'], (value, key, ctx) => async (parse) => {
178+
if (typeof value === 'string') {
179+
return parse(performAllSubstitutions(value, envType), { shouldFlatten: true });
180+
}
183181

184-
return parse(resolvedValue, { shouldFlatten: true });
185-
}
182+
validateObject(value, [...ctx, key]);
183+
if (Array.isArray(value)) throw new AppConfigError('$substitute was given an array');
186184

187-
return parse(performAllSubstitutions(value, envType), { shouldFlatten: true });
188-
},
189-
),
190-
);
185+
const { $name: variableName, $fallback: fallback } = value;
186+
validateString(variableName, [...ctx, key, [InObject, '$name']]);
187+
188+
const resolvedValue = process.env[variableName];
189+
190+
if (resolvedValue) {
191+
return parse(resolvedValue, { shouldFlatten: true });
192+
}
193+
194+
if (fallback !== undefined) {
195+
const fallbackValue = (await parse(fallback)).toJSON();
196+
validateString(fallbackValue, [...ctx, key, [InObject, '$fallback']]);
197+
198+
return parse(fallbackValue, { shouldFlatten: true });
199+
}
200+
201+
if (!resolvedValue) {
202+
throw new AppConfigError(`$substitute could not find ${variableName} environment variable`);
203+
}
204+
205+
return parse(resolvedValue, { shouldFlatten: true });
206+
});
191207
}
192208

193209
// common logic for $extends and $override
@@ -206,7 +222,7 @@ function fileReferenceDirective(keyName: string, meta: ParsedValueMetadata): Par
206222

207223
return SchemaBuilder.oneOf(reference, SchemaBuilder.arraySchema(reference));
208224
},
209-
(value) => async (parse, _, context, extensions) => {
225+
(value) => async (_, __, context, extensions) => {
210226
const retrieveFile = async (filepath: string, subselector?: string, isOptional = false) => {
211227
let resolvedPath = filepath;
212228

0 commit comments

Comments
 (0)