Skip to content

Commit 9e20ea4

Browse files
Merge branch 'sdk-configs-rename-split-to-definition' into configs-sdk-client
AI-Session-Id: e9b3e072-2ec0-428a-b108-9646c6de8629 AI-Tool: claude-code AI-Model: unknown
2 parents 41a2e3e + 513637c commit 9e20ea4

File tree

23 files changed

+569
-293
lines changed

23 files changed

+569
-293
lines changed

src/dtos/types.ts

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -215,51 +215,6 @@ export interface IRBSegment {
215215
} | null
216216
}
217217

218-
// Superset of ISplit (i.e., ISplit extends IConfig)
219-
// - with optional fields related to targeting information and
220-
// - an optional link fields that binds configurations to other entities
221-
export interface IConfig {
222-
name: string,
223-
changeNumber: number,
224-
status?: 'ACTIVE' | 'ARCHIVED',
225-
conditions?: ISplitCondition[] | null,
226-
prerequisites?: null | {
227-
n: string,
228-
ts: string[]
229-
}[]
230-
killed?: boolean,
231-
defaultTreatment: string,
232-
trafficTypeName?: string,
233-
seed?: number,
234-
trafficAllocation?: number,
235-
trafficAllocationSeed?: number
236-
configurations?: {
237-
[treatmentName: string]: string
238-
},
239-
sets?: string[],
240-
impressionsDisabled?: boolean,
241-
// a map of entities (e.g., pipeline, feature-flag, etc) to configuration variants
242-
links?: {
243-
[entityType: string]: {
244-
[entityName: string]: string
245-
}
246-
}
247-
}
248-
249-
/** Interface of the parsed JSON response of `/configs` */
250-
export interface IConfigsResponse {
251-
configs?: {
252-
t: number,
253-
s?: number,
254-
d: IConfig[]
255-
},
256-
rbs?: {
257-
t: number,
258-
s?: number,
259-
d: IRBSegment[]
260-
}
261-
}
262-
263218
// @TODO: rename to IDefinition (Configs and Feature Flags are definitions)
264219
export interface ISplit {
265220
name: string,
@@ -277,7 +232,7 @@ export interface ISplit {
277232
trafficAllocation?: number,
278233
trafficAllocationSeed?: number
279234
configurations?: {
280-
[treatmentName: string]: string
235+
[treatmentName: string]: string | SplitIO.JsonObject
281236
},
282237
sets?: string[],
283238
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;

src/evaluator/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export interface IEvaluation {
2222
treatment?: string,
2323
label: string,
2424
changeNumber?: number,
25-
config?: string | object | null
25+
config?: string | null | SplitIO.JsonObject
2626
}
2727

2828
export type IEvaluationResult = IEvaluation & { treatment: string; impressionsDisabled?: boolean }

0 commit comments

Comments
 (0)