Skip to content

Commit 34dd31e

Browse files
authored
feat: getExperimentContainer helper function for CMS app integration (#123)
* feat: getExperimentContainer helper function for contentful app integration * FF-3282-contentul-sdk-helper rename key to "treatment" for clarity * CR changes
1 parent 50b41bf commit 34dd31e

File tree

6 files changed

+182
-53
lines changed

6 files changed

+182
-53
lines changed

src/client/eppo-client-assignment-details.spec.ts

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,35 +5,13 @@ import {
55
MOCK_UFC_RESPONSE_FILE,
66
readMockUFCResponse,
77
} from '../../test/testHelpers';
8-
import ApiEndpoints from '../api-endpoints';
9-
import ConfigurationRequestor from '../configuration-requestor';
10-
import { IConfigurationStore } from '../configuration-store/configuration-store';
118
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
129
import { AllocationEvaluationCode } from '../flag-evaluation-details-builder';
13-
import FetchHttpClient from '../http-client';
1410
import { Flag, ObfuscatedFlag, Variation, VariationType } from '../interfaces';
1511
import { OperatorType } from '../rules';
1612

1713
import EppoClient, { IAssignmentDetails } from './eppo-client';
18-
19-
async function init(configurationStore: IConfigurationStore<Flag | ObfuscatedFlag>) {
20-
const apiEndpoints = new ApiEndpoints({
21-
baseUrl: 'http://127.0.0.1:4000',
22-
queryParams: {
23-
apiKey: 'dummy',
24-
sdkName: 'js-client-sdk-common',
25-
sdkVersion: '1.0.0',
26-
},
27-
});
28-
const httpClient = new FetchHttpClient(apiEndpoints, 1000);
29-
const configurationRequestor = new ConfigurationRequestor(
30-
httpClient,
31-
configurationStore,
32-
null,
33-
null,
34-
);
35-
await configurationRequestor.fetchAndStoreConfigurations();
36-
}
14+
import { initConfiguration } from './test-utils';
3715

3816
describe('EppoClient get*AssignmentDetails', () => {
3917
const testStart = Date.now();
@@ -50,7 +28,7 @@ describe('EppoClient get*AssignmentDetails', () => {
5028
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
5129

5230
beforeAll(async () => {
53-
await init(storage);
31+
await initConfiguration(storage);
5432
});
5533

5634
it('should set the details for a matched rule', () => {
@@ -307,7 +285,7 @@ describe('EppoClient get*AssignmentDetails', () => {
307285
});
308286
}) as jest.Mock;
309287

310-
await init(storage);
288+
await initConfiguration(storage);
311289
});
312290

313291
afterAll(() => {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { MOCK_UFC_RESPONSE_FILE, readMockUFCResponse } from '../../test/testHelpers';
2+
import * as applicationLogger from '../application-logger';
3+
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
4+
import { Flag, ObfuscatedFlag } from '../interfaces';
5+
6+
import EppoClient, { IContainerExperiment } from './eppo-client';
7+
import { initConfiguration } from './test-utils';
8+
9+
type Container = { name: string };
10+
11+
describe('getExperimentContainerEntry', () => {
12+
global.fetch = jest.fn(() => {
13+
const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE);
14+
return Promise.resolve({
15+
ok: true,
16+
status: 200,
17+
json: () => Promise.resolve(ufc),
18+
});
19+
}) as jest.Mock;
20+
21+
const controlContainer: Container = { name: 'Control Container' };
22+
const treatment1Container: Container = { name: 'Treatment Variation 1 Container' };
23+
const treatment2Container: Container = { name: 'Treatment Variation 2 Container' };
24+
const treatment3Container: Container = { name: 'Treatment Variation 3 Container' };
25+
26+
let client: EppoClient;
27+
let flagExperiment: IContainerExperiment<Container>;
28+
let getStringAssignmentSpy: jest.SpyInstance;
29+
let loggerWarnSpy: jest.SpyInstance;
30+
31+
beforeEach(async () => {
32+
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
33+
await initConfiguration(storage);
34+
client = new EppoClient(storage);
35+
client.setIsGracefulFailureMode(true);
36+
flagExperiment = {
37+
flagKey: 'my-key',
38+
controlVariationEntry: controlContainer,
39+
treatmentVariationEntries: [treatment1Container, treatment2Container, treatment3Container],
40+
};
41+
getStringAssignmentSpy = jest.spyOn(client, 'getStringAssignment');
42+
loggerWarnSpy = jest.spyOn(applicationLogger.logger, 'warn');
43+
});
44+
45+
afterAll(() => {
46+
getStringAssignmentSpy.mockRestore();
47+
loggerWarnSpy.mockRestore();
48+
});
49+
50+
it('should return the right container when a treatment variation is assigned', async () => {
51+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('treatment-2');
52+
expect(client.getExperimentContainerEntry(flagExperiment, 'subject-key', {})).toEqual(
53+
treatment2Container,
54+
);
55+
56+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('treatment-3');
57+
expect(client.getExperimentContainerEntry(flagExperiment, 'subject-key', {})).toEqual(
58+
treatment3Container,
59+
);
60+
});
61+
62+
it('should return the right container when control is assigned', async () => {
63+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('control');
64+
expect(client.getExperimentContainerEntry(flagExperiment, 'subject-key', {})).toEqual(
65+
controlContainer,
66+
);
67+
expect(loggerWarnSpy).not.toHaveBeenCalled();
68+
});
69+
70+
it('should default to the control container if a treatment number cannot be parsed', async () => {
71+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('treatment-asdf');
72+
expect(client.getExperimentContainerEntry(flagExperiment, 'subject-key', {})).toEqual(
73+
controlContainer,
74+
);
75+
expect(loggerWarnSpy).toHaveBeenCalled();
76+
});
77+
78+
it('should default to the control container if an unknown variation is assigned', async () => {
79+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('adsfsadfsadf');
80+
expect(client.getExperimentContainerEntry(flagExperiment, 'subject-key', {})).toEqual(
81+
controlContainer,
82+
);
83+
expect(loggerWarnSpy).toHaveBeenCalled();
84+
});
85+
86+
it('should default to the control container if an out-of-bounds treatment variation is assigned', async () => {
87+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('treatment-9');
88+
expect(client.getExperimentContainerEntry(flagExperiment, 'subject-key', {})).toEqual(
89+
controlContainer,
90+
);
91+
expect(loggerWarnSpy).toHaveBeenCalled();
92+
});
93+
});

src/client/eppo-client.spec.ts

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,24 @@ import { times } from 'lodash';
22
import * as td from 'testdouble';
33

44
import {
5+
ASSIGNMENT_TEST_DATA_DIR,
56
IAssignmentTestCase,
67
MOCK_UFC_RESPONSE_FILE,
78
OBFUSCATED_MOCK_UFC_RESPONSE_FILE,
89
SubjectTestCase,
910
getTestAssignments,
1011
readMockUFCResponse,
11-
validateTestAssignments,
1212
testCasesByFileName,
13-
ASSIGNMENT_TEST_DATA_DIR,
13+
validateTestAssignments,
1414
} from '../../test/testHelpers';
15-
import ApiEndpoints from '../api-endpoints';
1615
import { IAssignmentLogger } from '../assignment-logger';
17-
import ConfigurationRequestor from '../configuration-requestor';
1816
import { IConfigurationStore } from '../configuration-store/configuration-store';
1917
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
2018
import { MAX_EVENT_QUEUE_SIZE, POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants';
21-
import FetchHttpClient from '../http-client';
2219
import { Flag, ObfuscatedFlag, VariationType } from '../interfaces';
2320

2421
import EppoClient, { FlagConfigurationRequestParameters, checkTypeMatch } from './eppo-client';
25-
26-
export async function init(configurationStore: IConfigurationStore<Flag | ObfuscatedFlag>) {
27-
const apiEndpoints = new ApiEndpoints({
28-
baseUrl: 'http://127.0.0.1:4000',
29-
queryParams: {
30-
apiKey: 'dummy',
31-
sdkName: 'js-client-sdk-common',
32-
sdkVersion: '1.0.0',
33-
},
34-
});
35-
const httpClient = new FetchHttpClient(apiEndpoints, 1000);
36-
const configurationRequestor = new ConfigurationRequestor(
37-
httpClient,
38-
configurationStore,
39-
// Leave bandit stores empty for this test
40-
null,
41-
null,
42-
);
43-
await configurationRequestor.fetchAndStoreConfigurations();
44-
}
22+
import { initConfiguration } from './test-utils';
4523

4624
describe('EppoClient E2E test', () => {
4725
global.fetch = jest.fn(() => {
@@ -56,7 +34,7 @@ describe('EppoClient E2E test', () => {
5634
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
5735

5836
beforeAll(async () => {
59-
await init(storage);
37+
await initConfiguration(storage);
6038
});
6139

6240
const flagKey = 'mock-flag';
@@ -211,7 +189,7 @@ describe('EppoClient E2E test', () => {
211189
});
212190
}) as jest.Mock;
213191

214-
await init(storage);
192+
await initConfiguration(storage);
215193
});
216194

217195
afterAll(() => {
@@ -260,7 +238,7 @@ describe('EppoClient E2E test', () => {
260238
});
261239
}) as jest.Mock;
262240

263-
await init(storage);
241+
await initConfiguration(storage);
264242
});
265243

266244
afterAll(() => {

src/client/eppo-client.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ export type FlagConfigurationRequestParameters = {
6868
skipInitialPoll?: boolean;
6969
};
7070

71+
export interface IContainerExperiment<T> {
72+
flagKey: string;
73+
controlVariationEntry: T;
74+
treatmentVariationEntries: Array<T>;
75+
}
76+
7177
export default class EppoClient {
7278
private readonly queuedAssignmentEvents: IAssignmentEvent[] = [];
7379
private assignmentLogger?: IAssignmentLogger;
@@ -524,6 +530,52 @@ export default class EppoClient {
524530
return { variation, action, evaluationDetails };
525531
}
526532

533+
/**
534+
* For use with 3rd party CMS tooling, such as the Contentful Eppo plugin.
535+
*
536+
* CMS plugins that integrate with Eppo will follow a common format for
537+
* creating a feature flag. The flag created by the CMS plugin will have
538+
* variations with values 'control', 'treatment-1', 'treatment-2', etc.
539+
* This function allows users to easily return the CMS container entry
540+
* for the assigned variation.
541+
*
542+
* @param flagExperiment the flag key, control container entry and treatment container entries.
543+
* @param subjectKey an identifier of the experiment subject, for example a user ID.
544+
* @param subjectAttributes optional attributes associated with the subject, for example name and email.
545+
* @returns The container entry associated with the experiment.
546+
*/
547+
public getExperimentContainerEntry<T>(
548+
flagExperiment: IContainerExperiment<T>,
549+
subjectKey: string,
550+
subjectAttributes: Attributes,
551+
): T {
552+
const { flagKey, controlVariationEntry, treatmentVariationEntries } = flagExperiment;
553+
const assignment = this.getStringAssignment(flagKey, subjectKey, subjectAttributes, 'control');
554+
if (assignment === 'control') {
555+
return controlVariationEntry;
556+
}
557+
if (!assignment.startsWith('treatment-')) {
558+
logger.warn(
559+
`Variation '${assignment}' cannot be mapped to a container. Defaulting to control variation.`,
560+
);
561+
return controlVariationEntry;
562+
}
563+
const treatmentVariationIndex = Number.parseInt(assignment.split('-')[1]) - 1;
564+
if (isNaN(treatmentVariationIndex)) {
565+
logger.warn(
566+
`Variation '${assignment}' cannot be mapped to a container. Defaulting to control variation.`,
567+
);
568+
return controlVariationEntry;
569+
}
570+
if (treatmentVariationIndex >= treatmentVariationEntries.length) {
571+
logger.warn(
572+
`Selected treatment variation (${treatmentVariationIndex}) index is out of bounds. Defaulting to control variation.`,
573+
);
574+
return controlVariationEntry;
575+
}
576+
return treatmentVariationEntries[treatmentVariationIndex];
577+
}
578+
527579
private evaluateBanditAction(
528580
flagKey: string,
529581
subjectKey: string,

src/client/test-utils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import ApiEndpoints from '../api-endpoints';
2+
import ConfigurationRequestor from '../configuration-requestor';
3+
import { IConfigurationStore } from '../configuration-store/configuration-store';
4+
import FetchHttpClient from '../http-client';
5+
import { Flag, ObfuscatedFlag } from '../interfaces';
6+
7+
export async function initConfiguration(
8+
configurationStore: IConfigurationStore<Flag | ObfuscatedFlag>,
9+
) {
10+
const apiEndpoints = new ApiEndpoints({
11+
baseUrl: 'http://127.0.0.1:4000',
12+
queryParams: {
13+
apiKey: 'dummy',
14+
sdkName: 'js-client-sdk-common',
15+
sdkVersion: '3.0.0',
16+
},
17+
});
18+
const httpClient = new FetchHttpClient(apiEndpoints, 1000);
19+
const configurationRequestor = new ConfigurationRequestor(
20+
httpClient,
21+
configurationStore,
22+
null,
23+
null,
24+
);
25+
await configurationRequestor.fetchAndStoreConfigurations();
26+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import EppoClient, {
1919
FlagConfigurationRequestParameters,
2020
IAssignmentDetails,
21+
IContainerExperiment,
2122
} from './client/eppo-client';
2223
import FlagConfigRequestor from './configuration-requestor';
2324
import {
@@ -48,6 +49,7 @@ export {
4849
IAssignmentEvent,
4950
IBanditLogger,
5051
IBanditEvent,
52+
IContainerExperiment,
5153
EppoClient,
5254
constants,
5355
ApiEndpoints,

0 commit comments

Comments
 (0)