Skip to content

Commit

Permalink
feat: Identity overrides in local evaluation mode (#143)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
khvn26 authored Apr 19, 2024
1 parent 81d278c commit ad2127c
Show file tree
Hide file tree
Showing 10 changed files with 103 additions and 78 deletions.
4 changes: 0 additions & 4 deletions flagsmith-engine/environments/integrations/models.ts

This file was deleted.

7 changes: 2 additions & 5 deletions flagsmith-engine/environments/models.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FeatureStateModel } from '../features/models';
import { IdentityModel } from '../identities/models';
import { ProjectModel } from '../projects/models';
import { IntegrationModel } from './integrations/models';

export class EnvironmentAPIKeyModel {
id: number;
Expand Down Expand Up @@ -37,10 +37,7 @@ export class EnvironmentModel {
apiKey: string;
project: ProjectModel;
featureStates: FeatureStateModel[] = [];
amplitude_config?: IntegrationModel;
segment_config?: IntegrationModel;
mixpanel_config?: IntegrationModel;
heap_config?: IntegrationModel;
identityOverrides: IdentityModel[] = [];

constructor(id: number, apiKey: string, project: ProjectModel) {
this.id = id;
Expand Down
6 changes: 6 additions & 0 deletions flagsmith-engine/environments/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { buildFeatureStateModel } from '../features/util';
import { buildIdentityModel } from '../identities/util';
import { buildProjectModel } from '../projects/util';
import { EnvironmentAPIKeyModel, EnvironmentModel } from './models';

Expand All @@ -13,6 +14,11 @@ export function buildEnvironmentModel(environmentJSON: any) {
project
);
environmentModel.featureStates = featureStates;
if (!!environmentJSON.identity_overrides) {
environmentModel.identityOverrides = environmentJSON.identity_overrides.map((identityData: any) =>
buildIdentityModel(identityData)
);
}
return environmentModel;
}

Expand Down
27 changes: 14 additions & 13 deletions flagsmith-engine/features/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,26 @@ export function buildFeatureStateModel(featuresStateModelJSON: any): FeatureStat
featuresStateModelJSON.enabled,
featuresStateModelJSON.django_id,
featuresStateModelJSON.feature_state_value,
featuresStateModelJSON.uuid
featuresStateModelJSON.featurestate_uuid
);

featureStateModel.featureSegment = featuresStateModelJSON.feature_segment ?
buildFeatureSegment(featuresStateModelJSON.feature_segment) :
featureStateModel.featureSegment = featuresStateModelJSON.feature_segment ?
buildFeatureSegment(featuresStateModelJSON.feature_segment) :
undefined;

const multivariateFeatureStateValues = featuresStateModelJSON.multivariate_feature_state_values
? featuresStateModelJSON.multivariate_feature_state_values.map((fsv: any) => {
const featureOption = new MultivariateFeatureOptionModel(
fsv.multivariate_feature_option.value,
fsv.multivariate_feature_option.id
);
return new MultivariateFeatureStateValueModel(
featureOption,
fsv.percentage_allocation,
fsv.id
);
})
const featureOption = new MultivariateFeatureOptionModel(
fsv.multivariate_feature_option.value,
fsv.multivariate_feature_option.id
);
return new MultivariateFeatureStateValueModel(
featureOption,
fsv.percentage_allocation,
fsv.id,
fsv.mv_fs_value_uuid
);
})
: [];

featureStateModel.multivariateFeatureStateValues = multivariateFeatureStateValues;
Expand Down
1 change: 0 additions & 1 deletion flagsmith-engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { SegmentModel } from './segments/models';
import { FeatureStateNotFound } from './utils/errors';

export { EnvironmentModel } from './environments/models';
export { IntegrationModel } from './environments/integrations/models';
export { FeatureStateModel } from './features/models';
export { IdentityModel } from './identities/models';
export { TraitModel } from './identities/traits/models';
Expand Down
1 change: 0 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export {

export {
EnvironmentModel,
IntegrationModel,
FeatureStateModel,
IdentityModel,
TraitModel,
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 22 additions & 10 deletions sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export class Flagsmith {
offlineMode: boolean = false;
offlineHandler?: BaseOfflineHandler = undefined;

identitiesWithOverridesByIdentifier?: Map<string, IdentityModel>;

private cache?: FlagsmithCache;
private onEnvironmentChange?: (error: Error | null, result: EnvironmentModel) => void;
private analyticsProcessor?: AnalyticsProcessor;
Expand Down Expand Up @@ -143,13 +145,13 @@ export class Flagsmith {
if (!this.environmentKey) {
throw new Error('ValueError: environmentKey is required.');
}

const apiUrl = data.apiUrl || DEFAULT_API_URL;
this.apiUrl = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`;
this.environmentFlagsUrl = `${this.apiUrl}flags/`;
this.identitiesUrl = `${this.apiUrl}identities/`;
this.environmentUrl = `${this.apiUrl}environment-document/`;

if (this.enableLocalEvaluation) {
if (!this.environmentKey.startsWith('ser.')) {
console.error(
Expand All @@ -166,11 +168,11 @@ export class Flagsmith {

this.analyticsProcessor = data.enableAnalytics
? new AnalyticsProcessor({
environmentKey: this.environmentKey,
baseApiUrl: this.apiUrl,
requestTimeoutMs: this.requestTimeoutMs,
logger: this.logger
})
environmentKey: this.environmentKey,
baseApiUrl: this.apiUrl,
requestTimeoutMs: this.requestTimeoutMs,
logger: this.logger
})
: undefined;
}
}
Expand Down Expand Up @@ -256,7 +258,7 @@ export class Flagsmith {
if (this.enableLocalEvaluation) {
return new Promise((resolve, reject) => {
return this.environmentPromise!.then(() => {
const identityModel = this.buildIdentityModel(
const identityModel = this.getIdentityModel(
identifier,
Object.keys(traits || {}).map(key => ({
key,
Expand Down Expand Up @@ -289,6 +291,11 @@ export class Flagsmith {
} else {
this.environment = await request;
}
if (this.environment.identityOverrides?.length) {
this.identitiesWithOverridesByIdentifier = new Map<string, IdentityModel>(
this.environment.identityOverrides.map(identity => [identity.identifier, identity]
));
}
if (this.onEnvironmentChange) {
this.onEnvironmentChange(null, this.environment);
}
Expand Down Expand Up @@ -370,7 +377,7 @@ export class Flagsmith {
identifier: string,
traits: { [key: string]: any }
): Promise<Flags> {
const identityModel = this.buildIdentityModel(
const identityModel = this.getIdentityModel(
identifier,
Object.keys(traits).map(key => ({
key,
Expand Down Expand Up @@ -458,8 +465,13 @@ export class Flagsmith {
}
}

private buildIdentityModel(identifier: string, traits: { key: string; value: any }[]) {
private getIdentityModel(identifier: string, traits: { key: string; value: any }[]) {
const traitModels = traits.map(trait => new TraitModel(trait.key, trait.value));
let identityWithOverrides = this.identitiesWithOverridesByIdentifier?.get(identifier);
if (identityWithOverrides) {
identityWithOverrides.updateTraits(traitModels);
return identityWithOverrides;
}
return new IdentityModel('0', traitModels, [], this.environment.apiKey, identifier);
}
}
Expand Down
28 changes: 27 additions & 1 deletion tests/sdk/data/environment.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"feature_states": [
{
"feature_state_value": "segment_override",
"featurestate_uuid": "dd77a1ab-08cf-4743-8a3b-19e730444a14",
"multivariate_feature_state_values": [],
"django_id": 81027,
"feature": {
Expand Down Expand Up @@ -88,5 +89,30 @@
"featurestate_uuid": "96fc3503-09d7-48f1-a83b-2dc903d5c08a",
"enabled": false
}
],
"identity_overrides": [
{
"identifier": "overridden-id",
"identity_uuid": "0f21cde8-63c5-4e50-baca-87897fa6cd01",
"created_date": "2019-08-27T14:53:45.698555Z",
"updated_at": "2023-07-14 16:12:00.000000",
"environment_api_key": "B62qaMZNwfiqT76p38ggrQ",
"identity_features": [
{
"id": 1,
"feature": {
"id": 1,
"name": "some_feature",
"type": "STANDARD"
},
"featurestate_uuid": "1bddb9a5-7e59-42c6-9be9-625fa369749f",
"feature_state_value": "some-overridden-value",
"enabled": false,
"environment": 1,
"identity": null,
"feature_segment": null
}
]
}
]
}
}
73 changes: 31 additions & 42 deletions tests/sdk/flagsmith.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import Flagsmith from '../../sdk';
import { EnvironmentDataPollingManager } from '../../sdk/polling_manager';
import fetch, {RequestInit} from 'node-fetch';
import fetch, { RequestInit } from 'node-fetch';
import { environmentJSON, environmentModel, flagsJSON, flagsmith, identitiesJSON } from './utils';
import { DefaultFlag, Flags } from '../../sdk/models';
import {delay, retryFetch} from '../../sdk/utils';
import * as utils from '../../sdk/utils';
import { delay } from '../../sdk/utils';
import { EnvironmentModel } from '../../flagsmith-engine/environments/models';
import https from 'https'
import { BaseOfflineHandler } from '../../sdk/offline_handlers';
Expand Down Expand Up @@ -48,18 +47,15 @@ test('test_update_environment_sets_environment', async () => {

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

wipeFeatureStateUUIDs(flg.environment)
wipeFeatureStateUUIDs(model)

expect(flg.environment).toStrictEqual(model);
});

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

// @ts-ignore
fetch.mockImplementation((url:string, options:RequestInit)=>{
if(options.agent!==agent) {
fetch.mockImplementation((url: string, options: RequestInit) => {
if (options.agent !== agent) {
throw new Error("Agent has not been set on retry fetch")
}
return Promise.resolve(new Response(environmentJSON()))
Expand Down Expand Up @@ -276,7 +272,7 @@ test('getIdentitySegments throws error if identifier is empty string', () => {
})


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

Expand Down Expand Up @@ -311,19 +307,19 @@ test('test_flagsmith_uses_offline_handler_if_set_and_no_api_response', async ()
const environment: EnvironmentModel = environmentModel(JSON.parse(environmentJSON('offline-environment.json')));
const api_url = 'http://some.flagsmith.com/api/v1/';
const mock_offline_handler = new BaseOfflineHandler() as jest.Mocked<BaseOfflineHandler>;

jest.spyOn(mock_offline_handler, 'getEnvironment').mockReturnValue(environment);

const flagsmith = new Flagsmith({
environmentKey: 'some-key',
apiUrl: api_url,
offlineHandler: mock_offline_handler,
environmentKey: 'some-key',
apiUrl: api_url,
offlineHandler: mock_offline_handler,
});

jest.spyOn(flagsmith, 'getEnvironmentFlags');
jest.spyOn(flagsmith, 'getIdentityFlags');


flagsmith.environmentFlagsUrl = 'http://some.flagsmith.com/api/v1/environment-flags';
flagsmith.identitiesUrl = 'http://some.flagsmith.com/api/v1/identities';

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

// When
const environmentFlags:Flags = await flagsmith.getEnvironmentFlags();
const identityFlags:Flags = await flagsmith.getIdentityFlags('identity', {});
const environmentFlags: Flags = await flagsmith.getEnvironmentFlags();
const identityFlags: Flags = await flagsmith.getIdentityFlags('identity', {});

// Then
expect(mock_offline_handler.getEnvironment).toHaveBeenCalledTimes(1);
expect(flagsmith.getEnvironmentFlags).toHaveBeenCalled();
expect(flagsmith.getIdentityFlags).toHaveBeenCalled();

expect(environmentFlags.isFeatureEnabled('some_feature')).toBe(true);
expect(environmentFlags.getFeatureValue('some_feature')).toBe('offline-value');

expect(identityFlags.isFeatureEnabled('some_feature')).toBe(true);
expect(identityFlags.getFeatureValue('some_feature')).toBe('offline-value');
});

test('cannot use offline mode without offline handler', () => {
// When and Then
expect(() => new Flagsmith({ offlineMode: true, offlineHandler: undefined })).toThrowError(
'ValueError: offlineHandler must be provided to use offline mode.'
'ValueError: offlineHandler must be provided to use offline mode.'
);
});

test('cannot use both default handler and offline handler', () => {
// When and Then
expect(() => new Flagsmith({
offlineHandler: new BaseOfflineHandler(),
defaultFlagHandler: (flagName) => new DefaultFlag('foo', true)
offlineHandler: new BaseOfflineHandler(),
defaultFlagHandler: (flagName) => new DefaultFlag('foo', true)
})).toThrowError('ValueError: Cannot use both defaultFlagHandler and offlineHandler.');
});

test('cannot create Flagsmith client in remote evaluation without API key', () => {
// When and Then
// @ts-ignore
expect(() => new Flagsmith()).toThrowError('ValueError: environmentKey is required.');
});


async function wipeFeatureStateUUIDs (environmentModel: EnvironmentModel) {
// TODO: this has been pulled out of tests above as a helper function.
// I'm not entirely sure why it's necessary, however, we should look to remove.
environmentModel.featureStates.forEach(fs => {
// @ts-ignore
fs.featurestateUUID = undefined;
fs.multivariateFeatureStateValues.forEach(mvfsv => {
// @ts-ignore
mvfsv.mvFsValueUuid = undefined;
})
});
environmentModel.project.segments.forEach(s => {
s.featureStates.forEach(fs => {
// @ts-ignore
fs.featurestateUUID = undefined;
})
})
}

function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
test('test_localEvaluation_true__identity_overrides_evaluated', async () => {
// @ts-ignore
fetch.mockReturnValue(Promise.resolve(new Response(environmentJSON())));

const flg = new Flagsmith({
environmentKey: 'ser.key',
enableLocalEvaluation: true,
});

const flags = await flg.getIdentityFlags("overridden-id");
expect(flags.getFeatureValue("some_feature")).toEqual("some-overridden-value");
});

0 comments on commit ad2127c

Please sign in to comment.