Skip to content

Commit d8dd76f

Browse files
committed
feat(flags): add rules engine boundary
1 parent 4878144 commit d8dd76f

5 files changed

Lines changed: 1021 additions & 3 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://github.com/DataDog).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
import { OperatorType } from '@datadog/flagging-core';
8+
import type { UniversalFlagConfigurationV1 } from '@datadog/flagging-core';
9+
10+
import type {
11+
RulesEngine,
12+
RulesEvaluationDetails,
13+
RulesEvaluationRequest,
14+
RulesValueType
15+
} from '../../rules';
16+
17+
export const buildRulesConfiguration = (): UniversalFlagConfigurationV1 => ({
18+
createdAt: '2026-07-23T12:00:00.000Z',
19+
format: 'SERVER',
20+
environment: { name: 'test' },
21+
flags: {
22+
'dynamic-flag': {
23+
key: 'dynamic-flag',
24+
enabled: true,
25+
variationType: 'BOOLEAN',
26+
variations: {
27+
enabled: { key: 'enabled', value: true },
28+
disabled: { key: 'disabled', value: false }
29+
},
30+
allocations: [
31+
{
32+
key: 'allocation-1',
33+
rules: [
34+
{
35+
conditions: [
36+
{
37+
operator: OperatorType.ONE_OF,
38+
attribute: 'country',
39+
value: ['US']
40+
}
41+
]
42+
}
43+
],
44+
splits: [
45+
{
46+
variationKey: 'enabled',
47+
serialId: 7,
48+
extraLogging: { experiment: 'checkout' },
49+
shards: [
50+
{
51+
salt: 'test-salt',
52+
ranges: [{ start: 0, end: 100 }],
53+
totalShards: 100
54+
}
55+
]
56+
}
57+
],
58+
doLog: false
59+
}
60+
]
61+
}
62+
}
63+
});
64+
65+
type FakeRulesEvaluation = RulesEvaluationDetails<unknown>;
66+
67+
export interface FakeRulesEngine extends RulesEngine {
68+
evaluate: jest.Mock<
69+
FakeRulesEvaluation,
70+
[RulesEvaluationRequest<RulesValueType>]
71+
>;
72+
}
73+
74+
// TODO(FFL-2837): Remove this fake after the upstream rules wire and engine
75+
// contract are published and the state-matrix tests can use canonical vectors.
76+
export const createFakeRulesEngine = (
77+
result: FakeRulesEvaluation
78+
): FakeRulesEngine => {
79+
return {
80+
evaluate: jest.fn(() => result)
81+
} as FakeRulesEngine;
82+
};
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://github.com/DataDog).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
import {
8+
flaggingCoreRulesEngine,
9+
getNoopRulesLogger,
10+
prepareRulesConfiguration,
11+
toRulesEvaluationContext
12+
} from '../rules';
13+
14+
import {
15+
buildRulesConfiguration,
16+
createFakeRulesEngine
17+
} from './__utils__/rulesTestUtils';
18+
19+
describe('rules configuration', () => {
20+
it('converts an SDK context to a flat rules context and reserves identifiers', () => {
21+
expect(
22+
toRulesEvaluationContext({
23+
targetingKey: 'user-1',
24+
attributes: {
25+
country: 'US',
26+
id: 'customer-id',
27+
targetingKey: 'attribute-key',
28+
enabled: true
29+
}
30+
})
31+
).toEqual({
32+
targetingKey: 'user-1',
33+
country: 'US',
34+
enabled: true
35+
});
36+
});
37+
38+
it('clones and freezes a valid rules configuration', () => {
39+
const source = buildRulesConfiguration();
40+
const prepared = prepareRulesConfiguration(source);
41+
42+
expect(prepared.status).toBe('ready');
43+
if (prepared.status !== 'ready') {
44+
throw new Error(prepared.errorMessage);
45+
}
46+
47+
source.flags['dynamic-flag'].enabled = false;
48+
49+
expect(prepared.configuration.flags['dynamic-flag'].enabled).toBe(true);
50+
expect(Object.isFrozen(prepared.configuration)).toBe(true);
51+
expect(
52+
Object.isFrozen(
53+
prepared.configuration.flags['dynamic-flag'].allocations[0]
54+
)
55+
).toBe(true);
56+
});
57+
58+
it('rejects an unsupported operator', () => {
59+
const source = buildRulesConfiguration();
60+
const condition =
61+
source.flags['dynamic-flag'].allocations[0].rules?.[0]
62+
.conditions[0];
63+
64+
if (!condition) {
65+
throw new Error('The fixture has no condition.');
66+
}
67+
(condition as { operator: string }).operator = 'ONE_OF_SHA256';
68+
69+
expect(prepareRulesConfiguration(source)).toEqual({
70+
status: 'error',
71+
errorMessage:
72+
'The rules configuration uses the unsupported operator "ONE_OF_SHA256".'
73+
});
74+
});
75+
76+
it('rejects an invalid regular expression', () => {
77+
const source = buildRulesConfiguration();
78+
const conditions =
79+
source.flags['dynamic-flag'].allocations[0].rules?.[0].conditions;
80+
if (!conditions) {
81+
throw new Error('The fixture has no conditions.');
82+
}
83+
conditions[0] = {
84+
operator: 'MATCHES',
85+
attribute: 'country',
86+
value: '['
87+
} as typeof conditions[number];
88+
89+
expect(prepareRulesConfiguration(source)).toEqual({
90+
status: 'error',
91+
errorMessage: 'A regular expression condition is not valid.'
92+
});
93+
});
94+
95+
it('rejects a split that points to an absent variation', () => {
96+
const source = buildRulesConfiguration();
97+
source.flags['dynamic-flag'].allocations[0].splits[0].variationKey =
98+
'absent';
99+
100+
expect(prepareRulesConfiguration(source)).toEqual({
101+
status: 'error',
102+
errorMessage: 'A split has an invalid variation key.'
103+
});
104+
});
105+
106+
it('normalizes a real flagging-core evaluation', () => {
107+
const configuration = buildRulesConfiguration();
108+
109+
const result = flaggingCoreRulesEngine.evaluate({
110+
configuration,
111+
type: 'boolean',
112+
flagKey: 'dynamic-flag',
113+
defaultValue: false,
114+
context: {
115+
targetingKey: 'user-1',
116+
country: 'US'
117+
},
118+
logger: getNoopRulesLogger()
119+
});
120+
121+
expect(result).toMatchObject({
122+
value: true,
123+
variant: 'enabled',
124+
reason: 'TARGETING_MATCH',
125+
metadata: {
126+
allocationKey: 'allocation-1',
127+
variationType: 'boolean',
128+
doLog: false,
129+
extraLogging: { experiment: 'checkout' },
130+
splitSerialId: 7
131+
}
132+
});
133+
expect(result.metadata.evaluationTimestampMs).toEqual(
134+
expect.any(Number)
135+
);
136+
});
137+
138+
it('checks own properties before it calls flagging-core', () => {
139+
const result = flaggingCoreRulesEngine.evaluate({
140+
configuration: buildRulesConfiguration(),
141+
type: 'boolean',
142+
flagKey: 'toString',
143+
defaultValue: false,
144+
context: { targetingKey: 'user-1' },
145+
logger: getNoopRulesLogger()
146+
});
147+
148+
expect(result).toEqual({
149+
value: false,
150+
reason: 'ERROR',
151+
errorCode: 'FLAG_NOT_FOUND',
152+
metadata: {}
153+
});
154+
});
155+
156+
it('provides a deterministic fake engine for client tests', () => {
157+
const fake = createFakeRulesEngine({
158+
value: true,
159+
variant: 'fake',
160+
reason: 'TARGETING_MATCH',
161+
metadata: {}
162+
});
163+
164+
expect(
165+
fake.evaluate({
166+
configuration: buildRulesConfiguration(),
167+
type: 'boolean',
168+
flagKey: 'dynamic-flag',
169+
defaultValue: false,
170+
context: { targetingKey: 'user-1' },
171+
logger: getNoopRulesLogger()
172+
})
173+
).toMatchObject({ value: true, variant: 'fake' });
174+
});
175+
});

packages/core/src/flags/configuration/__tests__/wire.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import type { ParsedFlagsConfiguration } from '../types';
88
import { configurationFromString, configurationToString } from '../wire';
99

10+
import { buildRulesConfiguration } from './__utils__/rulesTestUtils';
11+
1012
const buildResponse = () => ({
1113
data: {
1214
id: '2',
@@ -125,3 +127,60 @@ describe('configurationToString round-trip', () => {
125127
);
126128
});
127129
});
130+
131+
describe('rules configuration wire compatibility', () => {
132+
it('parses and serializes a rules configuration', () => {
133+
const rulesBased = {
134+
response: buildRulesConfiguration(),
135+
fetchedAt: 123,
136+
etag: 'rules-etag'
137+
};
138+
const wire = JSON.stringify({
139+
version: 1,
140+
rulesBased: {
141+
...rulesBased,
142+
response: JSON.stringify(rulesBased.response)
143+
}
144+
});
145+
146+
const parsed = configurationFromString(wire) as {
147+
rulesBased?: typeof rulesBased;
148+
};
149+
150+
expect(parsed.rulesBased).toEqual(rulesBased);
151+
expect(
152+
configurationFromString(
153+
configurationToString(
154+
(parsed as unknown) as ParsedFlagsConfiguration
155+
)
156+
)
157+
).toEqual(parsed);
158+
});
159+
160+
it('keeps both branches in a mixed configuration', () => {
161+
const mixedWire = buildWire({
162+
rulesBased: {
163+
response: JSON.stringify(buildRulesConfiguration())
164+
}
165+
});
166+
167+
const parsed = configurationFromString(mixedWire) as {
168+
precomputed?: unknown;
169+
rulesBased?: unknown;
170+
};
171+
172+
expect(parsed.precomputed).toBeDefined();
173+
expect(parsed.rulesBased).toBeDefined();
174+
});
175+
176+
it('returns an empty configuration for malformed rules JSON', () => {
177+
expect(
178+
configurationFromString(
179+
JSON.stringify({
180+
version: 1,
181+
rulesBased: { response: '{' }
182+
})
183+
)
184+
).toEqual({});
185+
});
186+
});

0 commit comments

Comments
 (0)