Skip to content

Commit 769e415

Browse files
authored
Fb/code validation through config (#24)
* remember new todo * wip 1 * wip 2 * wip 3 * wip 4 * wip 5 * wip 6 * wip 7 * wip 8 * wip 9 * wip 10 * wip 11 * wip 12 * wip 13 * wip 14 * wip 15 * make scopes a validation too * make scopes a validation too 2 * remaining TODOs * small polish stuff * small polish stuff 2 * small polish stuff 3 * small polish stuff 4 * small polish stuff 5 * small polish stuff 6
1 parent 89a557a commit 769e415

File tree

8 files changed

+356
-94
lines changed

8 files changed

+356
-94
lines changed

example-cap-server/srv/feature/features.yaml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
/check/priority:
44
type: number
55
fallbackValue: 0
6-
validation: '^\d+$'
7-
allowedScopes: [user, tenant]
6+
validations:
7+
- scopes: [user, tenant]
8+
- regex: '^\d+$'
9+
- { module: "$CONFIG_DIR/validators.js", call: validateTenantScope }
810

911
# info: memory statistics logging interval (in milliseconds); 0 means disabled
1012
/memory/logInterval:
1113
type: number
1214
fallbackValue: 0
13-
validation: '^\d+$'
15+
validations:
16+
- regex: '^\d+$'
17+
- { module: "./srv/feature/validators.js", call: validateTenantScope }
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"use strict";
2+
3+
const TENANT_SCOPE_REGEX = /^(people|pets)$/;
4+
5+
const validateTenantScope = (newValue, scopeMap) => {
6+
const tenant = scopeMap?.tenant;
7+
if (tenant && !TENANT_SCOPE_REGEX.test(tenant)) {
8+
return {
9+
errorMessage: 'tenant scope is invalid, only people and pets are allowed: "{0}"',
10+
errorMessageValues: [tenant],
11+
};
12+
}
13+
};
14+
15+
module.exports = {
16+
validateTenantScope,
17+
};

src/featureToggles.js

Lines changed: 95 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"use strict";
1313

1414
// TODO locale for validation messages
15-
// TODO custom validation should be configurable in yaml file
15+
// TODO document all validations scopes, regex, and module and remove allowedScopes
16+
// TODO document clearSubScopes option
1617

1718
const { promisify } = require("util");
1819
const path = require("path");
@@ -24,7 +25,7 @@ const { REDIS_INTEGRATION_MODE } = redis;
2425
const { Logger } = require("./logger");
2526
const { isOnCF, cfEnv } = require("./env");
2627
const { HandlerCollection } = require("./shared/handlerCollection");
27-
const { ENV, isObject } = require("./shared/static");
28+
const { ENV, isObject, tryRequire } = require("./shared/static");
2829
const { promiseAllDone } = require("./shared/promiseAllDone");
2930
const { LimitedLazyCache } = require("./shared/cache");
3031

@@ -42,21 +43,19 @@ const SCOPE_ROOT_KEY = "//";
4243
const CONFIG_KEY = Object.freeze({
4344
TYPE: "TYPE",
4445
ACTIVE: "ACTIVE",
45-
VALIDATION: "VALIDATION",
46-
VALIDATION_REG_EXP: "VALIDATION_REG_EXP",
46+
VALIDATIONS: "VALIDATIONS",
47+
VALIDATIONS_SCOPES_MAP: "VALIDATIONS_SCOPES_MAP",
48+
VALIDATIONS_REG_EXP: "VALIDATIONS_REG_EXP",
4749
APP_URL: "APP_URL",
4850
APP_URL_ACTIVE: "APP_URL_ACTIVE",
49-
ALLOWED_SCOPES: "ALLOWED_SCOPES",
50-
ALLOWED_SCOPES_CHECK_MAP: "ALLOWED_SCOPES_CHECK_MAP",
5151
});
5252

5353
const CONFIG_INFO_KEY = {
5454
[CONFIG_KEY.TYPE]: true,
5555
[CONFIG_KEY.ACTIVE]: true,
56-
[CONFIG_KEY.VALIDATION]: true,
56+
[CONFIG_KEY.VALIDATIONS]: true,
5757
[CONFIG_KEY.APP_URL]: true,
5858
[CONFIG_KEY.APP_URL_ACTIVE]: true,
59-
[CONFIG_KEY.ALLOWED_SCOPES]: true,
6059
};
6160

6261
const COMPONENT_NAME = "/FeatureToggles";
@@ -121,11 +120,11 @@ class FeatureToggles {
121120
/**
122121
* Populate this.__config.
123122
*/
124-
_processConfig(config) {
123+
_processConfig(config, configFilepath) {
125124
const { uris: cfAppUris } = cfEnv.cfApp;
126125

127126
const configEntries = Object.entries(config);
128-
for (const [featureKey, { type, active, appUrl, validation, fallbackValue, allowedScopes }] of configEntries) {
127+
for (const [featureKey, { type, active, appUrl, validations, fallbackValue }] of configEntries) {
129128
this.__featureKeys.push(featureKey);
130129
this.__fallbackValues[featureKey] = fallbackValue;
131130
this.__config[featureKey] = {};
@@ -138,9 +137,76 @@ class FeatureToggles {
138137
this.__config[featureKey][CONFIG_KEY.ACTIVE] = active;
139138
}
140139

141-
if (validation) {
142-
this.__config[featureKey][CONFIG_KEY.VALIDATION] = validation;
143-
this.__config[featureKey][CONFIG_KEY.VALIDATION_REG_EXP] = new RegExp(validation);
140+
if (validations) {
141+
this.__config[featureKey][CONFIG_KEY.VALIDATIONS] = validations;
142+
143+
const workingDir = process.cwd();
144+
const configDir = configFilepath ? path.dirname(configFilepath) : __dirname;
145+
const { validationsScopesMap, validationsRegExp, validationsCode } = validations.reduce(
146+
(acc, validation) => {
147+
if (Array.isArray(validation.scopes)) {
148+
for (const scope of validation.scopes) {
149+
acc.validationsScopesMap[scope] = true;
150+
}
151+
return acc;
152+
}
153+
154+
if (validation.regex) {
155+
acc.validationsRegExp.push(new RegExp(validation.regex));
156+
return acc;
157+
}
158+
159+
if (validation.module) {
160+
let modulePath = validation.module.replace("$CONFIG_DIR", configDir);
161+
if (!path.isAbsolute(modulePath)) {
162+
modulePath = path.join(workingDir, modulePath);
163+
}
164+
let validator = tryRequire(modulePath);
165+
166+
if (validation.call) {
167+
validator = validator?.[validation.call];
168+
}
169+
170+
const validatorType = typeof validator;
171+
if (validatorType === "function") {
172+
acc.validationsCode.push(validator);
173+
} else {
174+
logger.warning(
175+
new VError(
176+
{
177+
name: VERROR_CLUSTER_NAME,
178+
info: {
179+
featureKey,
180+
validation: JSON.stringify(validation),
181+
modulePath,
182+
validatorType,
183+
},
184+
},
185+
"could not load module validation"
186+
)
187+
);
188+
}
189+
return acc;
190+
}
191+
192+
throw new VError(
193+
{
194+
name: VERROR_CLUSTER_NAME,
195+
info: {
196+
featureKey,
197+
validation: JSON.stringify(validation),
198+
},
199+
},
200+
"found invalid validation, only scopes, regex, and module validations are supported"
201+
);
202+
},
203+
{ validationsScopesMap: {}, validationsRegExp: [], validationsCode: [] }
204+
);
205+
this.__config[featureKey][CONFIG_KEY.VALIDATIONS_SCOPES_MAP] = validationsScopesMap;
206+
this.__config[featureKey][CONFIG_KEY.VALIDATIONS_REG_EXP] = validationsRegExp;
207+
for (const validator of validationsCode) {
208+
this.registerFeatureValueValidation(featureKey, validator);
209+
}
144210
}
145211

146212
if (appUrl) {
@@ -151,14 +217,6 @@ class FeatureToggles {
151217
!Array.isArray(cfAppUris) ||
152218
cfAppUris.reduce((accumulator, cfAppUri) => accumulator && appUrlRegex.test(cfAppUri), true);
153219
}
154-
155-
if (Array.isArray(allowedScopes)) {
156-
this.__config[featureKey][CONFIG_KEY.ALLOWED_SCOPES] = allowedScopes;
157-
this.__config[featureKey][CONFIG_KEY.ALLOWED_SCOPES_CHECK_MAP] = allowedScopes.reduce((acc, scope) => {
158-
acc[scope] = true;
159-
return acc;
160-
}, {});
161-
}
162220
}
163221

164222
this.__isConfigProcessed = true;
@@ -287,7 +345,7 @@ class FeatureToggles {
287345
},
288346
];
289347
}
290-
const allowedScopesCheckMap = this.__config[featureKey][CONFIG_KEY.ALLOWED_SCOPES_CHECK_MAP];
348+
const validationsScopesMap = this.__config[featureKey][CONFIG_KEY.VALIDATIONS_SCOPES_MAP];
291349
for (const [scope, value] of Object.entries(scopeMap)) {
292350
if (!FeatureToggles._isValidScopeMapValue(value)) {
293351
return [
@@ -298,7 +356,7 @@ class FeatureToggles {
298356
},
299357
];
300358
}
301-
if (allowedScopesCheckMap && !allowedScopesCheckMap[scope]) {
359+
if (validationsScopesMap && !validationsScopesMap[scope]) {
302360
return [
303361
{
304362
featureKey,
@@ -359,16 +417,19 @@ class FeatureToggles {
359417
];
360418
}
361419

362-
const validationRegExp = this.__config[featureKey][CONFIG_KEY.VALIDATION_REG_EXP];
363-
if (validationRegExp && !validationRegExp.test(value)) {
364-
return [
365-
{
366-
featureKey,
367-
...(scopeKey && { scopeKey }),
368-
errorMessage: 'value "{0}" does not match validation regular expression {1}',
369-
errorMessageValues: [value, this.__config[featureKey][CONFIG_KEY.VALIDATION]],
370-
},
371-
];
420+
const validationsRegExp = this.__config[featureKey][CONFIG_KEY.VALIDATIONS_REG_EXP];
421+
if (Array.isArray(validationsRegExp) && validationsRegExp.length > 0) {
422+
const failingRegExp = validationsRegExp.find((validationRegExp) => !validationRegExp.test(value));
423+
if (failingRegExp) {
424+
return [
425+
{
426+
featureKey,
427+
...(scopeKey && { scopeKey }),
428+
errorMessage: 'value "{0}" does not match validation regular expression {1}',
429+
errorMessageValues: [value, failingRegExp.toString()],
430+
},
431+
];
432+
}
372433
}
373434

374435
const validators = this.__featureValueValidators.getHandlers(featureKey);
@@ -609,7 +670,7 @@ class FeatureToggles {
609670

610671
let toggleCount;
611672
try {
612-
toggleCount = this._processConfig(config);
673+
toggleCount = this._processConfig(config, configFilepath);
613674
} catch (err) {
614675
throw new VError(
615676
{

test/__snapshots__/featureToggles.test.js.snap

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,15 @@ exports[`feature toggles test basic apis getFeaturesInfos 1`] = `
4040
},
4141
"test/feature_aa": {
4242
"config": {
43-
"ALLOWED_SCOPES": [
44-
"tenant",
45-
"user",
46-
],
4743
"TYPE": "boolean",
44+
"VALIDATIONS": [
45+
{
46+
"scopes": [
47+
"tenant",
48+
"user",
49+
],
50+
},
51+
],
4852
},
4953
"fallbackValue": false,
5054
},
@@ -63,21 +67,33 @@ exports[`feature toggles test basic apis getFeaturesInfos 1`] = `
6367
"test/feature_d": {
6468
"config": {
6569
"TYPE": "boolean",
66-
"VALIDATION": "^(?:true)$",
70+
"VALIDATIONS": [
71+
{
72+
"regex": "^(?:true)$",
73+
},
74+
],
6775
},
6876
"fallbackValue": true,
6977
},
7078
"test/feature_e": {
7179
"config": {
7280
"TYPE": "number",
73-
"VALIDATION": "^\\d{1}$",
81+
"VALIDATIONS": [
82+
{
83+
"regex": "^\\d{1}$",
84+
},
85+
],
7486
},
7587
"fallbackValue": 5,
7688
},
7789
"test/feature_f": {
7890
"config": {
7991
"TYPE": "string",
80-
"VALIDATION": "^(?:best|worst)$",
92+
"VALIDATIONS": [
93+
{
94+
"regex": "^(?:best|worst)$",
95+
},
96+
],
8197
},
8298
"fallbackValue": "best",
8399
},
@@ -105,15 +121,20 @@ exports[`feature toggles test basic apis initializeFeatureToggles 1`] = `
105121
"TYPE": "boolean",
106122
},
107123
"test/feature_aa": {
108-
"ALLOWED_SCOPES": [
109-
"tenant",
110-
"user",
124+
"TYPE": "boolean",
125+
"VALIDATIONS": [
126+
{
127+
"scopes": [
128+
"tenant",
129+
"user",
130+
],
131+
},
111132
],
112-
"ALLOWED_SCOPES_CHECK_MAP": {
133+
"VALIDATIONS_REG_EXP": [],
134+
"VALIDATIONS_SCOPES_MAP": {
113135
"tenant": true,
114136
"user": true,
115137
},
116-
"TYPE": "boolean",
117138
},
118139
"test/feature_b": {
119140
"TYPE": "number",
@@ -123,18 +144,39 @@ exports[`feature toggles test basic apis initializeFeatureToggles 1`] = `
123144
},
124145
"test/feature_d": {
125146
"TYPE": "boolean",
126-
"VALIDATION": "^(?:true)$",
127-
"VALIDATION_REG_EXP": /\\^\\(\\?:true\\)\\$/,
147+
"VALIDATIONS": [
148+
{
149+
"regex": "^(?:true)$",
150+
},
151+
],
152+
"VALIDATIONS_REG_EXP": [
153+
/\\^\\(\\?:true\\)\\$/,
154+
],
155+
"VALIDATIONS_SCOPES_MAP": {},
128156
},
129157
"test/feature_e": {
130158
"TYPE": "number",
131-
"VALIDATION": "^\\d{1}$",
132-
"VALIDATION_REG_EXP": /\\^\\\\d\\{1\\}\\$/,
159+
"VALIDATIONS": [
160+
{
161+
"regex": "^\\d{1}$",
162+
},
163+
],
164+
"VALIDATIONS_REG_EXP": [
165+
/\\^\\\\d\\{1\\}\\$/,
166+
],
167+
"VALIDATIONS_SCOPES_MAP": {},
133168
},
134169
"test/feature_f": {
135170
"TYPE": "string",
136-
"VALIDATION": "^(?:best|worst)$",
137-
"VALIDATION_REG_EXP": /\\^\\(\\?:best\\|worst\\)\\$/,
171+
"VALIDATIONS": [
172+
{
173+
"regex": "^(?:best|worst)$",
174+
},
175+
],
176+
"VALIDATIONS_REG_EXP": [
177+
/\\^\\(\\?:best\\|worst\\)\\$/,
178+
],
179+
"VALIDATIONS_SCOPES_MAP": {},
138180
},
139181
"test/feature_g": {
140182
"ACTIVE": false,

0 commit comments

Comments
 (0)