Skip to content

Commit 97c81a5

Browse files
authored
Merge pull request #113 from launchcodedev/validate-parsing-extension-options
Parsing extension utils (validation & forKey)
2 parents 02c1166 + 0188825 commit 97c81a5

File tree

19 files changed

+513
-242
lines changed

19 files changed

+513
-242
lines changed

.github/workflows/publishing.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ jobs:
4646
token: ${{ secrets.NPM_TOKEN }}
4747
access: public
4848
package: ./app-config-encryption/package.json
49+
- uses: JS-DevTools/npm-publish@v1
50+
with:
51+
token: ${{ secrets.NPM_TOKEN }}
52+
access: public
53+
package: ./app-config-extension-utils/package.json
4954
- uses: JS-DevTools/npm-publish@v1
5055
with:
5156
token: ${{ secrets.NPM_TOKEN }}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('@lcdev/eslint-config/cwd')(__dirname);

app-config-extension-utils/README.md

Whitespace-only changes.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"name": "@app-config/extension-utils",
3+
"description": "Utilities for writing @app-config parsing extensions",
4+
"version": "2.1.5",
5+
"license": "MPL-2.0",
6+
"author": {
7+
"name": "Launchcode",
8+
"email": "[email protected]",
9+
"url": "https://lc.dev"
10+
},
11+
"repository": {
12+
"type": "git",
13+
"url": "https://github.com/launchcodedev/app-config.git"
14+
},
15+
"main": "dist/index.js",
16+
"module": "dist/es/index.js",
17+
"types": "dist/index.d.ts",
18+
"files": [
19+
"/dist",
20+
"!**/*.tsbuildinfo",
21+
"!**/*.test.*"
22+
],
23+
"scripts": {
24+
"build": "tsc -b",
25+
"build:es": "tsc -b tsconfig.es.json",
26+
"clean": "rm -rf dist *.tsbuildinfo",
27+
"lint": "eslint src",
28+
"fix": "eslint --fix src",
29+
"test": "jest",
30+
"prepublishOnly": "yarn clean && yarn build && yarn build:es"
31+
},
32+
"dependencies": {
33+
"@app-config/core": "^2.1.5",
34+
"@serafin/schema-builder": "0.14"
35+
},
36+
"devDependencies": {},
37+
"prettier": "@lcdev/prettier",
38+
"jest": {
39+
"preset": "@lcdev/jest"
40+
}
41+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { LiteralSource } from '@app-config/core';
2+
import {
3+
forKey,
4+
composeExtensions,
5+
validateOptions,
6+
ParsingExtensionInvalidOptions,
7+
} from './index';
8+
9+
const foo = forKey('$foo', () => (parse) => parse('foo!'));
10+
const bar = forKey('$bar', () => (parse) => parse('bar!'));
11+
const plusOne = forKey(['+1', '$plusOne'], (value) => (parse) =>
12+
parse((value as number) + 1, { shouldFlatten: true }),
13+
);
14+
15+
describe('forKey', () => {
16+
it('only applies for keys given', async () => {
17+
const source = {
18+
a: {
19+
b: {
20+
$plusOne: 33,
21+
},
22+
c: {
23+
'+1': 1,
24+
},
25+
d: {
26+
$foo: 'bar',
27+
},
28+
},
29+
};
30+
31+
expect(await new LiteralSource(source).readToJSON([foo, bar, plusOne])).toEqual({
32+
a: {
33+
b: 34,
34+
c: 2,
35+
d: {
36+
$foo: 'foo!',
37+
},
38+
},
39+
});
40+
});
41+
});
42+
43+
describe('composeExtensions', () => {
44+
it('combines two extensions', async () => {
45+
const source = {
46+
$foo: 1,
47+
$bar: 2,
48+
};
49+
50+
expect(await new LiteralSource(source).readToJSON([foo, bar])).toEqual({
51+
$foo: 'foo!',
52+
$bar: 'bar!',
53+
});
54+
55+
const combined = composeExtensions([foo, bar]);
56+
57+
expect(await new LiteralSource(source).readToJSON([combined])).toEqual({
58+
$foo: 'foo!',
59+
$bar: 'bar!',
60+
});
61+
});
62+
63+
it('combines two extensions, and applies them in nested properties', async () => {
64+
const source = {
65+
$foo: 1,
66+
a: {
67+
b: {
68+
$bar: 2,
69+
},
70+
},
71+
};
72+
73+
expect(await new LiteralSource(source).readToJSON([foo, bar])).toEqual({
74+
$foo: 'foo!',
75+
a: {
76+
b: {
77+
$bar: 'bar!',
78+
},
79+
},
80+
});
81+
82+
const combined = composeExtensions([foo, bar]);
83+
84+
expect(await new LiteralSource(source).readToJSON([combined])).toEqual({
85+
$foo: 'foo!',
86+
a: {
87+
b: {
88+
$bar: 'bar!',
89+
},
90+
},
91+
});
92+
});
93+
});
94+
95+
describe('validateOptions', () => {
96+
const ext1 = validateOptions(
97+
(SchemaBuilder) => SchemaBuilder.stringSchema(),
98+
(value) => (parse) => parse(`${value}!`, { shouldFlatten: true }),
99+
);
100+
101+
const ext2 = validateOptions(
102+
(SchemaBuilder) => SchemaBuilder.integerSchema(),
103+
(value) => (parse) => parse(value + 42, { shouldFlatten: true }),
104+
);
105+
106+
const ext3 = forKey(
107+
'$ext3',
108+
validateOptions(
109+
(SchemaBuilder) => SchemaBuilder.integerSchema(),
110+
(value) => (parse) => parse(value + 42, { shouldFlatten: true }),
111+
),
112+
);
113+
114+
it('allows valid options', async () => {
115+
expect(await new LiteralSource('start').readToJSON([ext1])).toEqual('start!');
116+
expect(await new LiteralSource(42).readToJSON([ext2])).toEqual(84);
117+
});
118+
119+
it('disallows invalid options', async () => {
120+
await expect(new LiteralSource(42).readToJSON([ext1])).rejects.toThrow(
121+
ParsingExtensionInvalidOptions,
122+
);
123+
await expect(new LiteralSource('start').readToJSON([ext2])).rejects.toThrow(
124+
ParsingExtensionInvalidOptions,
125+
);
126+
});
127+
128+
it('composes forKey and validateOptions', async () => {
129+
expect(await new LiteralSource('start').readToJSON([ext3])).toEqual('start');
130+
expect(await new LiteralSource({ $ext3: 0 }).readToJSON([ext3])).toEqual(42);
131+
await expect(new LiteralSource({ $ext3: 'start' }).readToJSON([ext3])).rejects.toThrow(
132+
'Validation failed in "$ext3": Invalid parameters: data should be integer',
133+
);
134+
await expect(new LiteralSource({ a: { $ext3: 'start' } }).readToJSON([ext3])).rejects.toThrow(
135+
'Validation failed in "a.$ext3": Invalid parameters: data should be integer',
136+
);
137+
});
138+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type {
2+
ParsingExtension,
3+
ParsingExtensionKey,
4+
ParsingExtensionTransform,
5+
} from '@app-config/core';
6+
import { parseValue, Root, AppConfigError } from '@app-config/core';
7+
import { SchemaBuilder } from '@serafin/schema-builder';
8+
9+
export function composeExtensions(extensions: ParsingExtension[]): ParsingExtension {
10+
return (value, [k]) => {
11+
if (k !== Root) return false;
12+
13+
return (_, __, source) => parseValue(value, source, extensions, { shouldFlatten: true });
14+
};
15+
}
16+
17+
export function forKey(
18+
key: string | string[],
19+
parsingExtension: ParsingExtension,
20+
): ParsingExtension {
21+
const shouldApply = ([_, k]: ParsingExtensionKey) => {
22+
if (typeof k !== 'string') return false;
23+
24+
if (Array.isArray(key)) {
25+
return key.includes(k);
26+
}
27+
28+
return key === k;
29+
};
30+
31+
return (value, ctxKey, ctx) => {
32+
if (shouldApply(ctxKey)) {
33+
return parsingExtension(value, ctxKey, ctx);
34+
}
35+
36+
return false;
37+
};
38+
}
39+
40+
export class ParsingExtensionInvalidOptions extends AppConfigError {}
41+
42+
export function validateOptions<T>(
43+
builder: (builder: typeof SchemaBuilder) => SchemaBuilder<T>,
44+
extension: (
45+
value: T,
46+
key: ParsingExtensionKey,
47+
context: ParsingExtensionKey[],
48+
) => ParsingExtensionTransform | false,
49+
): ParsingExtension {
50+
const schema = builder(SchemaBuilder);
51+
52+
schema.cacheValidationFunction();
53+
54+
return (value, ctxKey, ctx) => {
55+
return async (parse, ...args) => {
56+
const valid = ((await parse(value)).toJSON() as unknown) as T;
57+
58+
try {
59+
schema.validate(valid);
60+
} catch (error) {
61+
const message = error instanceof Error ? error.message : 'unknown';
62+
63+
const parents =
64+
[...ctx, ctxKey]
65+
.map(([, k]) => k)
66+
.filter((v) => !!v)
67+
.join('.') || 'root';
68+
69+
throw new ParsingExtensionInvalidOptions(`Validation failed in "${parents}": ${message}`);
70+
}
71+
72+
const call = extension(valid, ctxKey, ctx);
73+
74+
if (call) {
75+
return call(parse, ...args);
76+
}
77+
throw new AppConfigError(
78+
`A parsing extension returned as non-applicable, when using validateOptions. This isn't supported.`,
79+
);
80+
};
81+
};
82+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"target": "es2020",
5+
"module": "es2020",
6+
"outDir": "./dist/es"
7+
}
8+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"extends": "@lcdev/tsconfig",
3+
"compilerOptions": {
4+
"rootDir": "./src",
5+
"outDir": "./dist"
6+
},
7+
"include": ["src"],
8+
"exclude": ["node_modules"],
9+
"references": [
10+
{ "path": "../app-config-core" }
11+
]
12+
}

app-config-extensions/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
},
3232
"dependencies": {
3333
"@app-config/core": "^2.1.5",
34+
"@app-config/extension-utils": "^2.1.5",
3435
"@app-config/logging": "^2.1.5",
3536
"@app-config/node": "^2.1.5",
3637
"@app-config/utils": "^2.1.5"

0 commit comments

Comments
 (0)