Skip to content

Commit abfc21a

Browse files
Merge pull request #481 from splitio/refactor-evaluator-to-support-no-target
[SDK Configs] Refactor evaluator to support no target (no key and attributes)
2 parents b992a20 + 6aa1d57 commit abfc21a

25 files changed

Lines changed: 729 additions & 47 deletions

File tree

src/dtos/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ export interface IRBSegment {
215215
} | null
216216
}
217217

218+
// @TODO: rename to IDefinition (Configs and Feature Flags are definitions)
218219
export interface ISplit {
219220
name: string,
220221
changeNumber: number,
@@ -231,7 +232,7 @@ export interface ISplit {
231232
trafficAllocation?: number,
232233
trafficAllocationSeed?: number
233234
configurations?: {
234-
[treatmentName: string]: string
235+
[treatmentName: string]: string | SplitIO.JsonObject
235236
},
236237
sets?: string[],
237238
impressionsDisabled?: boolean
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { FallbackConfigsCalculator } from '../';
2+
import SplitIO from '../../../../types/splitio';
3+
import { CONTROL } from '../../../utils/constants';
4+
5+
describe('FallbackConfigsCalculator', () => {
6+
test('returns specific fallback if config name exists', () => {
7+
const fallbacks: SplitIO.FallbackConfigs = {
8+
byName: {
9+
'configA': { variant: 'VARIANT_A', value: { key: 1 } },
10+
},
11+
};
12+
const calculator = FallbackConfigsCalculator(fallbacks);
13+
const result = calculator('configA', 'label by name');
14+
15+
expect(result).toEqual({
16+
treatment: 'VARIANT_A',
17+
config: { key: 1 },
18+
label: 'fallback - label by name',
19+
});
20+
});
21+
22+
test('returns global fallback if config name is missing and global exists', () => {
23+
const fallbacks: SplitIO.FallbackConfigs = {
24+
byName: {},
25+
global: { variant: 'GLOBAL_VARIANT', value: { global: true } },
26+
};
27+
const calculator = FallbackConfigsCalculator(fallbacks);
28+
const result = calculator('missingConfig', 'label by global');
29+
30+
expect(result).toEqual({
31+
treatment: 'GLOBAL_VARIANT',
32+
config: { global: true },
33+
label: 'fallback - label by global',
34+
});
35+
});
36+
37+
test('returns control fallback if config name and global are missing', () => {
38+
const fallbacks: SplitIO.FallbackConfigs = {
39+
byName: {},
40+
};
41+
const calculator = FallbackConfigsCalculator(fallbacks);
42+
const result = calculator('missingConfig', 'label by noFallback');
43+
44+
expect(result).toEqual({
45+
treatment: CONTROL,
46+
config: null,
47+
label: 'label by noFallback',
48+
});
49+
});
50+
});
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { isValidConfigName, isValidConfig, sanitizeFallbacks } from '../fallbackSanitizer';
2+
import SplitIO from '../../../../types/splitio';
3+
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
4+
5+
describe('FallbackConfigsSanitizer', () => {
6+
const validConfig: SplitIO.Config = { variant: 'on', value: { color: 'blue' } };
7+
const invalidVariantConfig: SplitIO.Config = { variant: ' ', value: { color: 'blue' } };
8+
const invalidValueConfig = { variant: 'on', value: 'not_an_object' } as unknown as SplitIO.Config;
9+
const fallbackMock = {
10+
global: undefined,
11+
byName: {}
12+
};
13+
14+
beforeEach(() => {
15+
loggerMock.mockClear();
16+
});
17+
18+
describe('isValidConfigName', () => {
19+
test('returns true for a valid config name', () => {
20+
expect(isValidConfigName('my_config')).toBe(true);
21+
});
22+
23+
test('returns false for a name longer than 100 chars', () => {
24+
const longName = 'a'.repeat(101);
25+
expect(isValidConfigName(longName)).toBe(false);
26+
});
27+
28+
test('returns false if the name contains spaces', () => {
29+
expect(isValidConfigName('invalid config')).toBe(false);
30+
});
31+
32+
test('returns false if the name is not a string', () => {
33+
// @ts-ignore
34+
expect(isValidConfigName(true)).toBe(false);
35+
});
36+
});
37+
38+
describe('isValidConfig', () => {
39+
test('returns true for a valid config', () => {
40+
expect(isValidConfig(validConfig)).toBe(true);
41+
});
42+
43+
test('returns false for null or undefined', () => {
44+
expect(isValidConfig()).toBe(false);
45+
expect(isValidConfig(undefined)).toBe(false);
46+
});
47+
48+
test('returns false for a variant longer than 100 chars', () => {
49+
const long: SplitIO.Config = { variant: 'a'.repeat(101), value: {} };
50+
expect(isValidConfig(long)).toBe(false);
51+
});
52+
53+
test('returns false if variant does not match regex pattern', () => {
54+
const invalid: SplitIO.Config = { variant: 'invalid variant!', value: {} };
55+
expect(isValidConfig(invalid)).toBe(false);
56+
});
57+
58+
test('returns false if value is not an object', () => {
59+
expect(isValidConfig(invalidValueConfig)).toBe(false);
60+
});
61+
});
62+
63+
describe('sanitizeGlobal', () => {
64+
test('returns the config if valid', () => {
65+
expect(sanitizeFallbacks(loggerMock, { ...fallbackMock, global: validConfig })).toEqual({ ...fallbackMock, global: validConfig });
66+
expect(loggerMock.error).not.toHaveBeenCalled();
67+
});
68+
69+
test('returns undefined and logs error if variant is invalid', () => {
70+
const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidVariantConfig });
71+
expect(result).toEqual(fallbackMock);
72+
expect(loggerMock.error).toHaveBeenCalledWith(
73+
expect.stringContaining('Fallback configs - Discarded fallback')
74+
);
75+
});
76+
77+
test('returns undefined and logs error if value is invalid', () => {
78+
const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidValueConfig });
79+
expect(result).toEqual(fallbackMock);
80+
expect(loggerMock.error).toHaveBeenCalledWith(
81+
expect.stringContaining('Fallback configs - Discarded fallback')
82+
);
83+
});
84+
});
85+
86+
describe('sanitizeByName', () => {
87+
test('returns a sanitized map with valid entries only', () => {
88+
const input = {
89+
valid_config: validConfig,
90+
'invalid config': validConfig,
91+
bad_variant: invalidVariantConfig,
92+
};
93+
94+
const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, byName: input });
95+
96+
expect(result).toEqual({ ...fallbackMock, byName: { valid_config: validConfig } });
97+
expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid config name + bad_variant
98+
});
99+
100+
test('returns empty object if all invalid', () => {
101+
const input = {
102+
'invalid config': invalidVariantConfig,
103+
};
104+
105+
const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, byName: input });
106+
expect(result).toEqual(fallbackMock);
107+
expect(loggerMock.error).toHaveBeenCalled();
108+
});
109+
110+
test('returns same object if all valid', () => {
111+
const input = {
112+
...fallbackMock,
113+
byName: {
114+
config_one: validConfig,
115+
config_two: { variant: 'valid_2', value: { key: 'val' } },
116+
}
117+
};
118+
119+
const result = sanitizeFallbacks(loggerMock, input);
120+
expect(result).toEqual(input);
121+
expect(loggerMock.error).not.toHaveBeenCalled();
122+
});
123+
});
124+
125+
describe('sanitizeFallbacks', () => {
126+
test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error
127+
const result = sanitizeFallbacks(loggerMock, 'invalid_fallbacks');
128+
expect(result).toBeUndefined();
129+
expect(loggerMock.error).toHaveBeenCalledWith(
130+
'Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties'
131+
);
132+
});
133+
134+
test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error
135+
const result = sanitizeFallbacks(loggerMock, true);
136+
expect(result).toBeUndefined();
137+
expect(loggerMock.error).toHaveBeenCalledWith(
138+
'Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties'
139+
);
140+
});
141+
142+
test('sanitizes both global and byName fallbacks for empty object', () => { // @ts-expect-error
143+
const result = sanitizeFallbacks(loggerMock, { global: {} });
144+
expect(result).toEqual({ global: undefined, byName: {} });
145+
});
146+
});
147+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import SplitIO from '../../../../types/splitio';
2+
import { ILogger } from '../../../logger/types';
3+
import { isObject, isString } from '../../../utils/lang';
4+
5+
enum FallbackDiscardReason {
6+
ConfigName = 'Invalid config name (max 100 chars, no spaces)',
7+
Variant = 'Invalid variant (max 100 chars and must match pattern)',
8+
Value = 'Invalid value (must be an object)',
9+
}
10+
11+
const VARIANT_PATTERN = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/;
12+
13+
export function isValidConfigName(name: string): boolean {
14+
return name.length <= 100 && !name.includes(' ');
15+
}
16+
17+
export function isValidConfig(config?: SplitIO.Config): boolean {
18+
if (!isObject(config)) return false;
19+
if (!isString(config!.variant) || config!.variant.length > 100 || !VARIANT_PATTERN.test(config!.variant)) return false;
20+
if (!isObject(config!.value)) return false;
21+
return true;
22+
}
23+
24+
function sanitizeGlobal(logger: ILogger, config?: SplitIO.Config): SplitIO.Config | undefined {
25+
if (config === undefined) return undefined;
26+
if (!isValidConfig(config)) {
27+
if (!isObject(config) || !isString(config!.variant) || config!.variant.length > 100 || !VARIANT_PATTERN.test(config!.variant)) {
28+
logger.error(`Fallback configs - Discarded fallback: ${FallbackDiscardReason.Variant}`);
29+
} else {
30+
logger.error(`Fallback configs - Discarded fallback: ${FallbackDiscardReason.Value}`);
31+
}
32+
return undefined;
33+
}
34+
return config;
35+
}
36+
37+
function sanitizeByName(
38+
logger: ILogger,
39+
byNameFallbacks?: Record<string, SplitIO.Config>
40+
): Record<string, SplitIO.Config> {
41+
const sanitizedByName: Record<string, SplitIO.Config> = {};
42+
43+
if (!isObject(byNameFallbacks)) return sanitizedByName;
44+
45+
Object.keys(byNameFallbacks!).forEach((configName) => {
46+
const config = byNameFallbacks![configName];
47+
48+
if (!isValidConfigName(configName)) {
49+
logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.ConfigName}`);
50+
return;
51+
}
52+
53+
if (!isValidConfig(config)) {
54+
if (!isObject(config) || !isString(config.variant) || config.variant.length > 100 || !VARIANT_PATTERN.test(config.variant)) {
55+
logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.Variant}`);
56+
} else {
57+
logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.Value}`);
58+
}
59+
return;
60+
}
61+
62+
sanitizedByName[configName] = config;
63+
});
64+
65+
return sanitizedByName;
66+
}
67+
68+
export function sanitizeFallbacks(logger: ILogger, fallbacks: SplitIO.FallbackConfigs): SplitIO.FallbackConfigs | undefined {
69+
if (!isObject(fallbacks)) {
70+
logger.error('Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties');
71+
return;
72+
}
73+
74+
return {
75+
global: sanitizeGlobal(logger, fallbacks.global),
76+
byName: sanitizeByName(logger, fallbacks.byName)
77+
};
78+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { IFallbackCalculator } from '../fallbackTreatmentsCalculator/index';
2+
import { CONTROL } from '../../utils/constants';
3+
import SplitIO from '../../../types/splitio';
4+
5+
export const FALLBACK_PREFIX = 'fallback - ';
6+
7+
export function FallbackConfigsCalculator(fallbacks: SplitIO.FallbackConfigs = {}): IFallbackCalculator {
8+
9+
return (configName: string, label = '') => {
10+
const fallback = fallbacks.byName?.[configName] || fallbacks.global;
11+
12+
return fallback ?
13+
{
14+
treatment: fallback.variant,
15+
config: fallback.value,
16+
label: `${FALLBACK_PREFIX}${label}`,
17+
} :
18+
{
19+
treatment: CONTROL,
20+
config: null,
21+
label,
22+
};
23+
};
24+
}

src/evaluator/fallbackTreatmentsCalculator/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import { FallbackTreatmentConfiguration, TreatmentWithConfig } from '../../../types/splitio';
21
import { CONTROL } from '../../utils/constants';
32
import { isString } from '../../utils/lang';
3+
import SplitIO from '../../../types/splitio';
44

5-
export type IFallbackTreatmentsCalculator = (flagName: string, label?: string) => TreatmentWithConfig & { label: string };
5+
export type IFallbackCalculator = (definitionName: string, label?: string) => {
6+
treatment: string;
7+
config: string | null | SplitIO.JsonObject;
8+
label: string
9+
};
610

711
export const FALLBACK_PREFIX = 'fallback - ';
812

9-
export function FallbackTreatmentsCalculator(fallbacks: FallbackTreatmentConfiguration = {}): IFallbackTreatmentsCalculator {
13+
export function FallbackTreatmentsCalculator(fallbacks: SplitIO.FallbackTreatmentConfiguration = {}): IFallbackCalculator {
1014

1115
return (flagName: string, label = '') => {
1216
const fallback = fallbacks.byFlag?.[flagName] || fallbacks.global;

0 commit comments

Comments
 (0)