Skip to content

Commit ad2127c

Browse files
authored
feat: Identity overrides in local evaluation mode (#143)
* feat: Identity overrides in local evaluation mode - Support environment-supplied identity overrides - Remove redundant integration config models - Parse feature state and multivariate feature state UUIDs correctly
1 parent 81d278c commit ad2127c

File tree

10 files changed

+103
-78
lines changed

10 files changed

+103
-78
lines changed

flagsmith-engine/environments/integrations/models.ts

Lines changed: 0 additions & 4 deletions
This file was deleted.

flagsmith-engine/environments/models.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { FeatureStateModel } from '../features/models';
2+
import { IdentityModel } from '../identities/models';
23
import { ProjectModel } from '../projects/models';
3-
import { IntegrationModel } from './integrations/models';
44

55
export class EnvironmentAPIKeyModel {
66
id: number;
@@ -37,10 +37,7 @@ export class EnvironmentModel {
3737
apiKey: string;
3838
project: ProjectModel;
3939
featureStates: FeatureStateModel[] = [];
40-
amplitude_config?: IntegrationModel;
41-
segment_config?: IntegrationModel;
42-
mixpanel_config?: IntegrationModel;
43-
heap_config?: IntegrationModel;
40+
identityOverrides: IdentityModel[] = [];
4441

4542
constructor(id: number, apiKey: string, project: ProjectModel) {
4643
this.id = id;

flagsmith-engine/environments/util.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { buildFeatureStateModel } from '../features/util';
2+
import { buildIdentityModel } from '../identities/util';
23
import { buildProjectModel } from '../projects/util';
34
import { EnvironmentAPIKeyModel, EnvironmentModel } from './models';
45

@@ -13,6 +14,11 @@ export function buildEnvironmentModel(environmentJSON: any) {
1314
project
1415
);
1516
environmentModel.featureStates = featureStates;
17+
if (!!environmentJSON.identity_overrides) {
18+
environmentModel.identityOverrides = environmentJSON.identity_overrides.map((identityData: any) =>
19+
buildIdentityModel(identityData)
20+
);
21+
}
1622
return environmentModel;
1723
}
1824

flagsmith-engine/features/util.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,26 @@ export function buildFeatureStateModel(featuresStateModelJSON: any): FeatureStat
1616
featuresStateModelJSON.enabled,
1717
featuresStateModelJSON.django_id,
1818
featuresStateModelJSON.feature_state_value,
19-
featuresStateModelJSON.uuid
19+
featuresStateModelJSON.featurestate_uuid
2020
);
2121

22-
featureStateModel.featureSegment = featuresStateModelJSON.feature_segment ?
23-
buildFeatureSegment(featuresStateModelJSON.feature_segment) :
22+
featureStateModel.featureSegment = featuresStateModelJSON.feature_segment ?
23+
buildFeatureSegment(featuresStateModelJSON.feature_segment) :
2424
undefined;
2525

2626
const multivariateFeatureStateValues = featuresStateModelJSON.multivariate_feature_state_values
2727
? featuresStateModelJSON.multivariate_feature_state_values.map((fsv: any) => {
28-
const featureOption = new MultivariateFeatureOptionModel(
29-
fsv.multivariate_feature_option.value,
30-
fsv.multivariate_feature_option.id
31-
);
32-
return new MultivariateFeatureStateValueModel(
33-
featureOption,
34-
fsv.percentage_allocation,
35-
fsv.id
36-
);
37-
})
28+
const featureOption = new MultivariateFeatureOptionModel(
29+
fsv.multivariate_feature_option.value,
30+
fsv.multivariate_feature_option.id
31+
);
32+
return new MultivariateFeatureStateValueModel(
33+
featureOption,
34+
fsv.percentage_allocation,
35+
fsv.id,
36+
fsv.mv_fs_value_uuid
37+
);
38+
})
3839
: [];
3940

4041
featureStateModel.multivariateFeatureStateValues = multivariateFeatureStateValues;

flagsmith-engine/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { SegmentModel } from './segments/models';
77
import { FeatureStateNotFound } from './utils/errors';
88

99
export { EnvironmentModel } from './environments/models';
10-
export { IntegrationModel } from './environments/integrations/models';
1110
export { FeatureStateModel } from './features/models';
1211
export { IdentityModel } from './identities/models';
1312
export { TraitModel } from './identities/traits/models';

index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export {
1717

1818
export {
1919
EnvironmentModel,
20-
IntegrationModel,
2120
FeatureStateModel,
2221
IdentityModel,
2322
TraitModel,

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sdk/index.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ export class Flagsmith {
4949
offlineMode: boolean = false;
5050
offlineHandler?: BaseOfflineHandler = undefined;
5151

52+
identitiesWithOverridesByIdentifier?: Map<string, IdentityModel>;
53+
5254
private cache?: FlagsmithCache;
5355
private onEnvironmentChange?: (error: Error | null, result: EnvironmentModel) => void;
5456
private analyticsProcessor?: AnalyticsProcessor;
@@ -143,13 +145,13 @@ export class Flagsmith {
143145
if (!this.environmentKey) {
144146
throw new Error('ValueError: environmentKey is required.');
145147
}
146-
148+
147149
const apiUrl = data.apiUrl || DEFAULT_API_URL;
148150
this.apiUrl = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`;
149151
this.environmentFlagsUrl = `${this.apiUrl}flags/`;
150152
this.identitiesUrl = `${this.apiUrl}identities/`;
151153
this.environmentUrl = `${this.apiUrl}environment-document/`;
152-
154+
153155
if (this.enableLocalEvaluation) {
154156
if (!this.environmentKey.startsWith('ser.')) {
155157
console.error(
@@ -166,11 +168,11 @@ export class Flagsmith {
166168

167169
this.analyticsProcessor = data.enableAnalytics
168170
? new AnalyticsProcessor({
169-
environmentKey: this.environmentKey,
170-
baseApiUrl: this.apiUrl,
171-
requestTimeoutMs: this.requestTimeoutMs,
172-
logger: this.logger
173-
})
171+
environmentKey: this.environmentKey,
172+
baseApiUrl: this.apiUrl,
173+
requestTimeoutMs: this.requestTimeoutMs,
174+
logger: this.logger
175+
})
174176
: undefined;
175177
}
176178
}
@@ -256,7 +258,7 @@ export class Flagsmith {
256258
if (this.enableLocalEvaluation) {
257259
return new Promise((resolve, reject) => {
258260
return this.environmentPromise!.then(() => {
259-
const identityModel = this.buildIdentityModel(
261+
const identityModel = this.getIdentityModel(
260262
identifier,
261263
Object.keys(traits || {}).map(key => ({
262264
key,
@@ -289,6 +291,11 @@ export class Flagsmith {
289291
} else {
290292
this.environment = await request;
291293
}
294+
if (this.environment.identityOverrides?.length) {
295+
this.identitiesWithOverridesByIdentifier = new Map<string, IdentityModel>(
296+
this.environment.identityOverrides.map(identity => [identity.identifier, identity]
297+
));
298+
}
292299
if (this.onEnvironmentChange) {
293300
this.onEnvironmentChange(null, this.environment);
294301
}
@@ -370,7 +377,7 @@ export class Flagsmith {
370377
identifier: string,
371378
traits: { [key: string]: any }
372379
): Promise<Flags> {
373-
const identityModel = this.buildIdentityModel(
380+
const identityModel = this.getIdentityModel(
374381
identifier,
375382
Object.keys(traits).map(key => ({
376383
key,
@@ -458,8 +465,13 @@ export class Flagsmith {
458465
}
459466
}
460467

461-
private buildIdentityModel(identifier: string, traits: { key: string; value: any }[]) {
468+
private getIdentityModel(identifier: string, traits: { key: string; value: any }[]) {
462469
const traitModels = traits.map(trait => new TraitModel(trait.key, trait.value));
470+
let identityWithOverrides = this.identitiesWithOverridesByIdentifier?.get(identifier);
471+
if (identityWithOverrides) {
472+
identityWithOverrides.updateTraits(traitModels);
473+
return identityWithOverrides;
474+
}
463475
return new IdentityModel('0', traitModels, [], this.environment.apiKey, identifier);
464476
}
465477
}

tests/sdk/data/environment.json

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"feature_states": [
1818
{
1919
"feature_state_value": "segment_override",
20+
"featurestate_uuid": "dd77a1ab-08cf-4743-8a3b-19e730444a14",
2021
"multivariate_feature_state_values": [],
2122
"django_id": 81027,
2223
"feature": {
@@ -88,5 +89,30 @@
8889
"featurestate_uuid": "96fc3503-09d7-48f1-a83b-2dc903d5c08a",
8990
"enabled": false
9091
}
92+
],
93+
"identity_overrides": [
94+
{
95+
"identifier": "overridden-id",
96+
"identity_uuid": "0f21cde8-63c5-4e50-baca-87897fa6cd01",
97+
"created_date": "2019-08-27T14:53:45.698555Z",
98+
"updated_at": "2023-07-14 16:12:00.000000",
99+
"environment_api_key": "B62qaMZNwfiqT76p38ggrQ",
100+
"identity_features": [
101+
{
102+
"id": 1,
103+
"feature": {
104+
"id": 1,
105+
"name": "some_feature",
106+
"type": "STANDARD"
107+
},
108+
"featurestate_uuid": "1bddb9a5-7e59-42c6-9be9-625fa369749f",
109+
"feature_state_value": "some-overridden-value",
110+
"enabled": false,
111+
"environment": 1,
112+
"identity": null,
113+
"feature_segment": null
114+
}
115+
]
116+
}
91117
]
92-
}
118+
}

tests/sdk/flagsmith.test.ts

Lines changed: 31 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import Flagsmith from '../../sdk';
22
import { EnvironmentDataPollingManager } from '../../sdk/polling_manager';
3-
import fetch, {RequestInit} from 'node-fetch';
3+
import fetch, { RequestInit } from 'node-fetch';
44
import { environmentJSON, environmentModel, flagsJSON, flagsmith, identitiesJSON } from './utils';
55
import { DefaultFlag, Flags } from '../../sdk/models';
6-
import {delay, retryFetch} from '../../sdk/utils';
7-
import * as utils from '../../sdk/utils';
6+
import { delay } from '../../sdk/utils';
87
import { EnvironmentModel } from '../../flagsmith-engine/environments/models';
98
import https from 'https'
109
import { BaseOfflineHandler } from '../../sdk/offline_handlers';
@@ -48,18 +47,15 @@ test('test_update_environment_sets_environment', async () => {
4847

4948
const model = environmentModel(JSON.parse(environmentJSON()));
5049

51-
wipeFeatureStateUUIDs(flg.environment)
52-
wipeFeatureStateUUIDs(model)
53-
5450
expect(flg.environment).toStrictEqual(model);
5551
});
5652

5753
test('test_set_agent_options', async () => {
5854
const agent = new https.Agent({})
5955

6056
// @ts-ignore
61-
fetch.mockImplementation((url:string, options:RequestInit)=>{
62-
if(options.agent!==agent) {
57+
fetch.mockImplementation((url: string, options: RequestInit) => {
58+
if (options.agent !== agent) {
6359
throw new Error("Agent has not been set on retry fetch")
6460
}
6561
return Promise.resolve(new Response(environmentJSON()))
@@ -276,7 +272,7 @@ test('getIdentitySegments throws error if identifier is empty string', () => {
276272
})
277273

278274

279-
test('offline_mode', async() => {
275+
test('offline_mode', async () => {
280276
// Given
281277
const environment: EnvironmentModel = environmentModel(JSON.parse(environmentJSON('offline-environment.json')));
282278

@@ -311,19 +307,19 @@ test('test_flagsmith_uses_offline_handler_if_set_and_no_api_response', async ()
311307
const environment: EnvironmentModel = environmentModel(JSON.parse(environmentJSON('offline-environment.json')));
312308
const api_url = 'http://some.flagsmith.com/api/v1/';
313309
const mock_offline_handler = new BaseOfflineHandler() as jest.Mocked<BaseOfflineHandler>;
314-
310+
315311
jest.spyOn(mock_offline_handler, 'getEnvironment').mockReturnValue(environment);
316312

317313
const flagsmith = new Flagsmith({
318-
environmentKey: 'some-key',
319-
apiUrl: api_url,
320-
offlineHandler: mock_offline_handler,
314+
environmentKey: 'some-key',
315+
apiUrl: api_url,
316+
offlineHandler: mock_offline_handler,
321317
});
322318

323319
jest.spyOn(flagsmith, 'getEnvironmentFlags');
324320
jest.spyOn(flagsmith, 'getIdentityFlags');
325321

326-
322+
327323
flagsmith.environmentFlagsUrl = 'http://some.flagsmith.com/api/v1/environment-flags';
328324
flagsmith.identitiesUrl = 'http://some.flagsmith.com/api/v1/identities';
329325

@@ -337,64 +333,57 @@ test('test_flagsmith_uses_offline_handler_if_set_and_no_api_response', async ()
337333
fetch.mockReturnValue(Promise.resolve(errorResponse));
338334

339335
// When
340-
const environmentFlags:Flags = await flagsmith.getEnvironmentFlags();
341-
const identityFlags:Flags = await flagsmith.getIdentityFlags('identity', {});
336+
const environmentFlags: Flags = await flagsmith.getEnvironmentFlags();
337+
const identityFlags: Flags = await flagsmith.getIdentityFlags('identity', {});
342338

343339
// Then
344340
expect(mock_offline_handler.getEnvironment).toHaveBeenCalledTimes(1);
345341
expect(flagsmith.getEnvironmentFlags).toHaveBeenCalled();
346342
expect(flagsmith.getIdentityFlags).toHaveBeenCalled();
347-
343+
348344
expect(environmentFlags.isFeatureEnabled('some_feature')).toBe(true);
349345
expect(environmentFlags.getFeatureValue('some_feature')).toBe('offline-value');
350-
346+
351347
expect(identityFlags.isFeatureEnabled('some_feature')).toBe(true);
352348
expect(identityFlags.getFeatureValue('some_feature')).toBe('offline-value');
353349
});
354350

355351
test('cannot use offline mode without offline handler', () => {
356352
// When and Then
357353
expect(() => new Flagsmith({ offlineMode: true, offlineHandler: undefined })).toThrowError(
358-
'ValueError: offlineHandler must be provided to use offline mode.'
354+
'ValueError: offlineHandler must be provided to use offline mode.'
359355
);
360356
});
361-
357+
362358
test('cannot use both default handler and offline handler', () => {
363359
// When and Then
364360
expect(() => new Flagsmith({
365-
offlineHandler: new BaseOfflineHandler(),
366-
defaultFlagHandler: (flagName) => new DefaultFlag('foo', true)
361+
offlineHandler: new BaseOfflineHandler(),
362+
defaultFlagHandler: (flagName) => new DefaultFlag('foo', true)
367363
})).toThrowError('ValueError: Cannot use both defaultFlagHandler and offlineHandler.');
368364
});
369-
365+
370366
test('cannot create Flagsmith client in remote evaluation without API key', () => {
371367
// When and Then
372368
// @ts-ignore
373369
expect(() => new Flagsmith()).toThrowError('ValueError: environmentKey is required.');
374370
});
375371

376372

377-
async function wipeFeatureStateUUIDs (environmentModel: EnvironmentModel) {
378-
// TODO: this has been pulled out of tests above as a helper function.
379-
// I'm not entirely sure why it's necessary, however, we should look to remove.
380-
environmentModel.featureStates.forEach(fs => {
381-
// @ts-ignore
382-
fs.featurestateUUID = undefined;
383-
fs.multivariateFeatureStateValues.forEach(mvfsv => {
384-
// @ts-ignore
385-
mvfsv.mvFsValueUuid = undefined;
386-
})
387-
});
388-
environmentModel.project.segments.forEach(s => {
389-
s.featureStates.forEach(fs => {
390-
// @ts-ignore
391-
fs.featurestateUUID = undefined;
392-
})
393-
})
394-
}
395-
396373
function sleep(ms: number) {
397374
return new Promise((resolve) => {
398375
setTimeout(resolve, ms);
399376
});
400377
}
378+
test('test_localEvaluation_true__identity_overrides_evaluated', async () => {
379+
// @ts-ignore
380+
fetch.mockReturnValue(Promise.resolve(new Response(environmentJSON())));
381+
382+
const flg = new Flagsmith({
383+
environmentKey: 'ser.key',
384+
enableLocalEvaluation: true,
385+
});
386+
387+
const flags = await flg.getIdentityFlags("overridden-id");
388+
expect(flags.getFeatureValue("some_feature")).toEqual("some-overridden-value");
389+
});

0 commit comments

Comments
 (0)