Skip to content

Commit 6b4725c

Browse files
committed
feat: extend Configuration/Requestor to support precomputed
1 parent a7da43d commit 6b4725c

9 files changed

+284
-255
lines changed

src/client/eppo-client.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ export type EppoClientParameters = {
118118
};
119119

120120
configuration?: {
121+
/**
122+
* When specified, will run the client in the "precomputed"
123+
* mode. Instead of fetching the full configuration from the
124+
* server, the client will fetch flags and bandits precomputed for
125+
* the specified subject.
126+
*/
127+
precompute?: {
128+
subjectKey: string;
129+
subjectAttributes: Attributes | ContextAttributes;
130+
banditActions?: Record</* flagKey: */ string, BanditActions>;
131+
};
132+
121133
/**
122134
* Strategy for fetching initial configuration.
123135
*
@@ -359,6 +371,24 @@ export default class EppoClient {
359371
this.configurationFeed,
360372
{
361373
wantsBandits: options.bandits?.enable ?? DEFAULT_ENABLE_BANDITS,
374+
precomputed: options.configuration?.precompute
375+
? {
376+
subjectKey: options.configuration.precompute.subjectKey,
377+
subjectAttributes: ensureContextualSubjectAttributes(
378+
options.configuration.precompute.subjectAttributes,
379+
),
380+
banditActions: options.configuration.precompute.banditActions
381+
? Object.fromEntries(
382+
Object.entries(options.configuration.precompute.banditActions).map(
383+
([banditKey, actions]) => [
384+
banditKey,
385+
ensureActionsWithContextualAttributes(actions),
386+
],
387+
),
388+
)
389+
: undefined,
390+
}
391+
: undefined,
362392
},
363393
);
364394

src/client/eppo-precomputed-client.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -362,10 +362,6 @@ export default class EppoPrecomputedClient {
362362
}
363363

364364
private getPrecomputedFlag(flagKey: string): DecodedPrecomputedFlag | null {
365-
return this.getObfuscatedFlag(flagKey);
366-
}
367-
368-
private getObfuscatedFlag(flagKey: string): DecodedPrecomputedFlag | null {
369365
const salt = this.precomputedFlagStore.salt;
370366
const saltedAndHashedFlagKey = getMD5Hash(flagKey, salt);
371367
const precomputedFlag: PrecomputedFlag | null = this.precomputedFlagStore.get(
@@ -376,6 +372,7 @@ export default class EppoPrecomputedClient {
376372

377373
private getPrecomputedBandit(banditKey: string): IPrecomputedBandit | null {
378374
const obfuscatedBandit = this.getObfuscatedPrecomputedBandit(banditKey);
375+
379376
return obfuscatedBandit ? decodePrecomputedBandit(obfuscatedBandit) : null;
380377
}
381378

src/configuration-requestor.spec.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '../test/testHelpers';
77

88
import ApiEndpoints from './api-endpoints';
9+
import { ensureContextualSubjectAttributes } from './attributes';
910
import { BroadcastChannel } from './broadcast';
1011
import { ConfigurationFeed } from './configuration-feed';
1112
import ConfigurationRequestor from './configuration-requestor';
@@ -19,6 +20,32 @@ import FetchHttpClient, {
1920
import { StoreBackedConfiguration } from './i-configuration';
2021
import { BanditParameters, BanditVariation, Flag, VariationType } from './interfaces';
2122

23+
const MOCK_PRECOMPUTED_RESPONSE = {
24+
flags: {
25+
'precomputed-flag-1': {
26+
allocationKey: 'default',
27+
variationKey: 'true-variation',
28+
variationType: 'BOOLEAN',
29+
variationValue: 'true',
30+
extraLogging: {},
31+
doLog: true,
32+
},
33+
'precomputed-flag-2': {
34+
allocationKey: 'test-group',
35+
variationKey: 'variation-a',
36+
variationType: 'STRING',
37+
variationValue: 'variation-a',
38+
extraLogging: {},
39+
doLog: true,
40+
},
41+
},
42+
environment: {
43+
name: 'production',
44+
},
45+
format: 'PRECOMPUTED',
46+
createdAt: '2024-03-20T00:00:00Z',
47+
};
48+
2249
describe('ConfigurationRequestor', () => {
2350
let configurationFeed: ConfigurationFeed;
2451
let configurationStore: ConfigurationStore;
@@ -532,4 +559,66 @@ describe('ConfigurationRequestor', () => {
532559
});
533560
});
534561
});
562+
563+
describe('Precomputed flags', () => {
564+
let fetchSpy: jest.Mock;
565+
beforeEach(() => {
566+
configurationRequestor = new ConfigurationRequestor(httpClient, configurationFeed, {
567+
precomputed: {
568+
subjectKey: 'subject-key',
569+
subjectAttributes: ensureContextualSubjectAttributes({
570+
'attribute-key': 'attribute-value',
571+
}),
572+
},
573+
});
574+
575+
fetchSpy = jest.fn(() => {
576+
return Promise.resolve({
577+
ok: true,
578+
status: 200,
579+
json: () => Promise.resolve(MOCK_PRECOMPUTED_RESPONSE),
580+
});
581+
}) as jest.Mock;
582+
global.fetch = fetchSpy;
583+
});
584+
585+
afterEach(() => {
586+
jest.clearAllMocks();
587+
});
588+
589+
afterAll(() => {
590+
jest.restoreAllMocks();
591+
});
592+
593+
it('Fetches precomputed flag configuration', async () => {
594+
const configuration = await configurationRequestor.fetchConfiguration();
595+
596+
expect(fetchSpy).toHaveBeenCalledTimes(1);
597+
598+
expect(configuration.getFlagKeys().length).toBe(2);
599+
600+
const precomputed = configuration.getPrecomputedConfiguration();
601+
602+
const flag1 = precomputed?.response.flags['precomputed-flag-1'];
603+
expect(flag1?.allocationKey).toBe('default');
604+
expect(flag1?.variationKey).toBe('true-variation');
605+
expect(flag1?.variationType).toBe('BOOLEAN');
606+
expect(flag1?.variationValue).toBe('true');
607+
expect(flag1?.extraLogging).toEqual({});
608+
expect(flag1?.doLog).toBe(true);
609+
610+
const flag2 = precomputed?.response.flags['precomputed-flag-2'];
611+
expect(flag2?.allocationKey).toBe('test-group');
612+
expect(flag2?.variationKey).toBe('variation-a');
613+
expect(flag2?.variationType).toBe('STRING');
614+
expect(flag2?.variationValue).toBe('variation-a');
615+
expect(flag2?.extraLogging).toEqual({});
616+
expect(flag2?.doLog).toBe(true);
617+
618+
expect(precomputed?.response.format).toBe('PRECOMPUTED');
619+
620+
expect(precomputed?.response.environment).toStrictEqual({ name: 'production' });
621+
expect(precomputed?.response.createdAt).toBe('2024-03-20T00:00:00Z');
622+
});
623+
});
535624
});

src/configuration-requestor.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
import { BanditsConfig, Configuration, FlagsConfig } from './configuration';
22
import { ConfigurationFeed, ConfigurationSource } from './configuration-feed';
33
import { IHttpClient } from './http-client';
4+
import { ContextAttributes, FlagKey } from './types';
45

6+
export class ConfigurationError extends Error {
7+
public constructor(message: string) {
8+
super(message);
9+
this.name = 'ConfigurationError';
10+
}
11+
}
12+
13+
/** @internal */
514
export type ConfigurationRequestorOptions = {
615
wantsBandits: boolean;
16+
17+
precomputed?: {
18+
subjectKey: string;
19+
subjectAttributes: ContextAttributes;
20+
banditActions?: Record<FlagKey, Record<string, ContextAttributes>>;
21+
};
722
};
823

924
/**
@@ -35,19 +50,37 @@ export default class ConfigurationRequestor {
3550
});
3651
}
3752

38-
public async fetchConfiguration(): Promise<Configuration | null> {
53+
public async fetchConfiguration(): Promise<Configuration> {
54+
const configuration = this.options.precomputed
55+
? await this.fetchPrecomputedConfiguration(this.options.precomputed)
56+
: await this.fetchRegularConfiguration();
57+
58+
this.latestConfiguration = configuration;
59+
this.configurationFeed.broadcast(configuration, ConfigurationSource.Network);
60+
61+
return configuration;
62+
}
63+
64+
private async fetchRegularConfiguration(): Promise<Configuration> {
3965
const flags = await this.httpClient.getUniversalFlagConfiguration();
4066
if (!flags?.response.flags) {
41-
return null;
67+
throw new ConfigurationError('empty response');
4268
}
4369

4470
const bandits = await this.getBanditsFor(flags);
4571

46-
const configuration = Configuration.fromResponses({ flags, bandits });
47-
this.latestConfiguration = configuration;
48-
this.configurationFeed.broadcast(configuration, ConfigurationSource.Network);
72+
return Configuration.fromResponses({ flags, bandits });
73+
}
4974

50-
return configuration;
75+
private async fetchPrecomputedConfiguration(
76+
precomputed: NonNullable<ConfigurationRequestorOptions['precomputed']>,
77+
): Promise<Configuration> {
78+
const response = await this.httpClient.getPrecomputedFlags(precomputed);
79+
if (!response) {
80+
throw new ConfigurationError('empty response');
81+
}
82+
83+
return Configuration.fromResponses({ precomputed: response });
5184
}
5285

5386
/**

0 commit comments

Comments
 (0)