diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index 878b909e..36320f56 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -7,17 +7,21 @@ import { testCasesByFileName, BanditTestCase, BANDIT_TEST_DATA_DIR, + readMockConfigurationWireResponse, + SHARED_BOOTSTRAP_BANDIT_FLAGS_FILE, } from '../../test/testHelpers'; import ApiEndpoints from '../api-endpoints'; import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; import { BanditEvaluation, BanditEvaluator } from '../bandit-evaluator'; import { IBanditEvent, IBanditLogger } from '../bandit-logger'; import ConfigurationRequestor from '../configuration-requestor'; +import { ConfigurationManager } from '../configuration-store/configuration-manager'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { IConfigurationWire, IPrecomputedConfiguration, IObfuscatedPrecomputedConfigurationResponse, + ConfigurationWireV1, } from '../configuration-wire/configuration-wire-types'; import { Evaluator, FlagEvaluation } from '../evaluator'; import { @@ -64,12 +68,12 @@ describe('EppoClient Bandits E2E test', () => { }, }); const httpClient = new FetchHttpClient(apiEndpoints, 1000); - const configurationRequestor = new ConfigurationRequestor( - httpClient, + const configManager = new ConfigurationManager( flagStore, banditVariationStore, banditModelStore, ); + const configurationRequestor = new ConfigurationRequestor(httpClient, configManager, true); await configurationRequestor.fetchAndStoreConfigurations(); }); @@ -93,8 +97,8 @@ describe('EppoClient Bandits E2E test', () => { describe('Shared test cases', () => { const testCases = testCasesByFileName(BANDIT_TEST_DATA_DIR); - it.each(Object.keys(testCases))('Shared bandit test case - %s', async (fileName: string) => { - const { flag: flagKey, defaultValue, subjects } = testCases[fileName]; + function testBanditCaseAgainstClient(client: EppoClient, testCase: BanditTestCase) { + const { flag: flagKey, defaultValue, subjects } = testCase; let numAssignmentsChecked = 0; subjects.forEach((subject) => { // test files have actions as an array, so we convert them to a map as expected by the client @@ -130,6 +134,35 @@ describe('EppoClient Bandits E2E test', () => { }); // Ensure that this test case correctly checked some test assignments expect(numAssignmentsChecked).toBeGreaterThan(0); + } + + describe('bootstrapped client', () => { + const banditFlagsConfig = ConfigurationWireV1.fromString( + readMockConfigurationWireResponse(SHARED_BOOTSTRAP_BANDIT_FLAGS_FILE), + ); + + let client: EppoClient; + beforeAll(async () => { + client = new EppoClient({ + flagConfigurationStore: new MemoryOnlyConfigurationStore(), + banditVariationConfigurationStore: new MemoryOnlyConfigurationStore(), + banditModelConfigurationStore: new MemoryOnlyConfigurationStore(), + }); + client.setIsGracefulFailureMode(false); + + // Bootstrap using the bandit flag config. + client.bootstrap(banditFlagsConfig); + }); + + it.each(Object.keys(testCases))('Shared bandit test case - %s', async (fileName: string) => { + testBanditCaseAgainstClient(client, testCases[fileName]); + }); + }); + + describe('traditional client', () => { + it.each(Object.keys(testCases))('Shared bandit test case - %s', async (fileName: string) => { + testBanditCaseAgainstClient(client, testCases[fileName]); + }); }); }); diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 4ef63f18..6fecaade 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -8,7 +8,10 @@ import { IAssignmentTestCase, MOCK_UFC_RESPONSE_FILE, OBFUSCATED_MOCK_UFC_RESPONSE_FILE, + readMockConfigurationWireResponse, readMockUFCResponse, + SHARED_BOOTSTRAP_FLAGS_FILE, + SHARED_BOOTSTRAP_FLAGS_OBFUSCATED_FILE, SubjectTestCase, testCasesByFileName, validateTestAssignments, @@ -18,6 +21,7 @@ import { AssignmentCache } from '../cache/abstract-assignment-cache'; import { IConfigurationStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { + ConfigurationWireV1, IConfigurationWire, IObfuscatedPrecomputedConfigurationResponse, ObfuscatedPrecomputedConfigurationResponse, @@ -317,109 +321,176 @@ describe('EppoClient E2E test', () => { }); }); + function testCasesAgainstClient(client: EppoClient, testCase: IAssignmentTestCase) { + const { flag, variationType, defaultValue, subjects } = testCase; + + let assignments: { + subject: SubjectTestCase; + assignment: string | boolean | number | null | object; + }[] = []; + + const typeAssignmentFunctions = { + [VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), + [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), + [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), + [VariationType.STRING]: client.getStringAssignment.bind(client), + [VariationType.JSON]: client.getJSONAssignment.bind(client), + }; + + const assignmentFn = typeAssignmentFunctions[variationType] as ( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: boolean | string | number | object, + ) => never; + if (!assignmentFn) { + throw new Error(`Unknown variation type: ${variationType}`); + } + + assignments = getTestAssignments({ flag, variationType, defaultValue, subjects }, assignmentFn); + + validateTestAssignments(assignments, flag); + } + describe('UFC Shared Test Cases', () => { const testCases = testCasesByFileName(ASSIGNMENT_TEST_DATA_DIR); - describe('Not obfuscated', () => { - beforeAll(async () => { - global.fetch = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)), + describe('boostrapped client', () => { + const bootstrapFlagsConfig = ConfigurationWireV1.fromString( + readMockConfigurationWireResponse(SHARED_BOOTSTRAP_FLAGS_FILE), + ); + const bootstrapFlagsObfuscatedConfig = ConfigurationWireV1.fromString( + readMockConfigurationWireResponse(SHARED_BOOTSTRAP_FLAGS_OBFUSCATED_FILE), + ); + + describe('Not obfuscated', () => { + let client: EppoClient; + beforeAll(() => { + client = new EppoClient({ + flagConfigurationStore: new MemoryOnlyConfigurationStore(), }); - }) as jest.Mock; + client.setIsGracefulFailureMode(false); - await initConfiguration(storage); - }); + // Bootstrap using the flags config. + client.bootstrap(bootstrapFlagsConfig); + }); + + it('contains some key flags', () => { + const flagKeys = client.getFlagConfigurations(); - afterAll(() => { - jest.restoreAllMocks(); + expect(Object.keys(flagKeys)).toContain('numeric_flag'); + expect(Object.keys(flagKeys)).toContain('kill-switch'); + }); + + it.each(Object.keys(testCases))('test variation assignment splits - %s', (fileName) => { + testCasesAgainstClient(client, testCases[fileName]); + }); }); - it.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => { - const { flag, variationType, defaultValue, subjects } = testCases[fileName]; - const client = new EppoClient({ flagConfigurationStore: storage }); - client.setIsGracefulFailureMode(false); + describe('Obfuscated', () => { + let client: EppoClient; + beforeAll(() => { + client = new EppoClient({ + flagConfigurationStore: new MemoryOnlyConfigurationStore(), + }); + client.setIsGracefulFailureMode(false); - let assignments: { - subject: SubjectTestCase; - assignment: string | boolean | number | null | object; - }[] = []; - - const typeAssignmentFunctions = { - [VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), - [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), - [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), - [VariationType.STRING]: client.getStringAssignment.bind(client), - [VariationType.JSON]: client.getJSONAssignment.bind(client), - }; - - const assignmentFn = typeAssignmentFunctions[variationType] as ( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: boolean | string | number | object, - ) => never; - if (!assignmentFn) { - throw new Error(`Unknown variation type: ${variationType}`); - } + // Bootstrap using the obfuscated flags config. + client.bootstrap(bootstrapFlagsObfuscatedConfig); + }); - assignments = getTestAssignments( - { flag, variationType, defaultValue, subjects }, - assignmentFn, - ); + it('contains some key flags', () => { + const flagKeys = client.getFlagConfigurations(); - validateTestAssignments(assignments, flag); + expect(Object.keys(flagKeys)).toContain('73fcc84c69e49e31fe16a29b2b1f803b'); + expect(Object.keys(flagKeys)).toContain('69d2ea567a75b7b2da9648bf312dc3a5'); + }); + + it.each(Object.keys(testCases))('test variation assignment splits - %s', (fileName) => { + testCasesAgainstClient(client, testCases[fileName]); + }); }); }); - describe('Obfuscated', () => { - beforeAll(async () => { - global.fetch = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(readMockUFCResponse(OBFUSCATED_MOCK_UFC_RESPONSE_FILE)), - }); - }) as jest.Mock; + describe('traditional client', () => { + describe('Not obfuscated', () => { + beforeAll(async () => { + global.fetch = jest.fn(() => { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)), + }); + }) as jest.Mock; + + await initConfiguration(storage); + }); - await initConfiguration(storage); - }); + afterAll(() => { + jest.restoreAllMocks(); + }); - afterAll(() => { - jest.restoreAllMocks(); + it.each(Object.keys(testCases))( + 'test variation assignment splits - %s', + async (fileName) => { + const client = new EppoClient({ flagConfigurationStore: storage }); + client.setIsGracefulFailureMode(false); + + testCasesAgainstClient(client, testCases[fileName]); + }, + ); }); - it.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => { - const { flag, variationType, defaultValue, subjects } = testCases[fileName]; - const client = new EppoClient({ flagConfigurationStore: storage, isObfuscated: true }); - client.setIsGracefulFailureMode(false); + describe('Obfuscated', () => { + beforeAll(async () => { + global.fetch = jest.fn(() => { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(readMockUFCResponse(OBFUSCATED_MOCK_UFC_RESPONSE_FILE)), + }); + }) as jest.Mock; + + await initConfiguration(storage); + }); - const typeAssignmentFunctions = { - [VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), - [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), - [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), - [VariationType.STRING]: client.getStringAssignment.bind(client), - [VariationType.JSON]: client.getJSONAssignment.bind(client), - }; - - const assignmentFn = typeAssignmentFunctions[variationType] as ( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: boolean | string | number | object, - ) => never; - if (!assignmentFn) { - throw new Error(`Unknown variation type: ${variationType}`); - } + afterAll(() => { + jest.restoreAllMocks(); + }); - const assignments = getTestAssignments( - { flag, variationType, defaultValue, subjects }, - assignmentFn, + it.each(Object.keys(testCases))( + 'test variation assignment splits - %s', + async (fileName) => { + const { flag, variationType, defaultValue, subjects } = testCases[fileName]; + const client = new EppoClient({ flagConfigurationStore: storage, isObfuscated: true }); + client.setIsGracefulFailureMode(false); + + const typeAssignmentFunctions = { + [VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), + [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), + [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), + [VariationType.STRING]: client.getStringAssignment.bind(client), + [VariationType.JSON]: client.getJSONAssignment.bind(client), + }; + + const assignmentFn = typeAssignmentFunctions[variationType] as ( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: boolean | string | number | object, + ) => never; + if (!assignmentFn) { + throw new Error(`Unknown variation type: ${variationType}`); + } + + const assignments = getTestAssignments( + { flag, variationType, defaultValue, subjects }, + assignmentFn, + ); + + validateTestAssignments(assignments, flag); + }, ); - - validateTestAssignments(assignments, flag); }); }); }); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 9c641def..a324af8d 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -15,11 +15,13 @@ import { LRUInMemoryAssignmentCache } from '../cache/lru-in-memory-assignment-ca import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-memory-cache-assignment'; import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment-cache'; import ConfigurationRequestor from '../configuration-requestor'; +import { ConfigurationManager } from '../configuration-store/configuration-manager'; import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { ConfigurationWireV1, IConfigurationWire, + inflateResponse, IPrecomputedConfiguration, PrecomputedConfiguration, } from '../configuration-wire/configuration-wire-types'; @@ -40,8 +42,11 @@ import { IFlagEvaluationDetails, } from '../flag-evaluation-details-builder'; import { FlagEvaluationError } from '../flag-evaluation-error'; -import FetchHttpClient from '../http-client'; -import { IConfiguration, StoreBackedConfiguration } from '../i-configuration'; +import FetchHttpClient, { + IBanditParametersResponse, + IUniversalFlagConfigResponse, +} from '../http-client'; +import { IConfiguration } from '../i-configuration'; import { BanditModelData, BanditParameters, @@ -138,6 +143,7 @@ export default class EppoClient { private readonly evaluator = new Evaluator(); private configurationRequestor?: ConfigurationRequestor; private readonly overrideValidator = new OverrideValidator(); + private configurationManager: ConfigurationManager; constructor({ eventDispatcher = new NoOpEventDispatcher(), @@ -165,16 +171,17 @@ export default class EppoClient { this.overrideStore = overrideStore; this.configurationRequestParameters = configurationRequestParameters; this.expectObfuscated = isObfuscated; + + // Initialize the configuration manager + this.configurationManager = new ConfigurationManager( + this.flagConfigurationStore, + this.banditVariationConfigurationStore, + this.banditModelConfigurationStore, + ); } private getConfiguration(): IConfiguration { - return this.configurationRequestor - ? this.configurationRequestor.getConfiguration() - : new StoreBackedConfiguration( - this.flagConfigurationStore, - this.banditVariationConfigurationStore, - this.banditModelConfigurationStore, - ); + return this.configurationManager.getConfiguration(); } private maybeWarnAboutObfuscationMismatch(configObfuscated: boolean) { @@ -242,6 +249,9 @@ export default class EppoClient { setFlagConfigurationStore(flagConfigurationStore: IConfigurationStore) { this.flagConfigurationStore = flagConfigurationStore; this.configObfuscatedCache = undefined; + + // Update the configuration manager + this.innerSetConfigurationStores(); } // noinspection JSUnusedGlobalSymbols @@ -249,10 +259,31 @@ export default class EppoClient { banditVariationConfigurationStore: IConfigurationStore, ) { this.banditVariationConfigurationStore = banditVariationConfigurationStore; + + // Update the configuration manager + this.innerSetConfigurationStores(); } - /** Sets the EventDispatcher instance to use when tracking events with {@link track}. */ // noinspection JSUnusedGlobalSymbols + setBanditModelConfigurationStore( + banditModelConfigurationStore: IConfigurationStore, + ) { + this.banditModelConfigurationStore = banditModelConfigurationStore; + + // Update the configuration manager + this.innerSetConfigurationStores(); + } + + private innerSetConfigurationStores() { + // Set the set of configuration stores to those owned by the `this`. + this.configurationManager.setConfigurationStores({ + flagConfigurationStore: this.flagConfigurationStore, + banditReferenceConfigurationStore: this.banditVariationConfigurationStore, + banditConfigurationStore: this.banditModelConfigurationStore, + }); + } + + /** Sets the EventDispatcher instance to use when tracking events with {@link track}. */ setEventDispatcher(eventDispatcher: EventDispatcher) { this.eventDispatcher = eventDispatcher; } @@ -272,25 +303,6 @@ export default class EppoClient { this.eventDispatcher?.attachContext(key, value); } - // noinspection JSUnusedGlobalSymbols - setBanditModelConfigurationStore( - banditModelConfigurationStore: IConfigurationStore, - ) { - this.banditModelConfigurationStore = banditModelConfigurationStore; - } - - // noinspection JSUnusedGlobalSymbols - /** - * Setting this value will have no side effects other than triggering a warning when the actual - * configuration's obfuscated does not match the value set here. - * - * @deprecated The client determines whether the configuration is obfuscated by inspection - * @param isObfuscated - */ - setIsObfuscated(isObfuscated: boolean) { - this.expectObfuscated = isObfuscated; - } - setOverrideStore(store: ISyncStore): void { this.overrideStore = store; } @@ -309,6 +321,32 @@ export default class EppoClient { ); } + /** + * Initializes the `EppoClient` from the provided configuration. This method is async only to + * accommodate writing to a persistent store. For fastest initialization, (at the cost of persisting configuration), + * use `bootstrap` in conjunction with `MemoryOnlyConfigurationStore` instances which won't do an async write. + */ + bootstrap(configuration: IConfigurationWire): void { + if (!configuration.config) { + throw new Error('Flag configuration not provided'); + } + const flagConfigResponse: IUniversalFlagConfigResponse = inflateResponse( + configuration.config.response, + ); + const banditParamResponse: IBanditParametersResponse | undefined = configuration.bandits + ? inflateResponse(configuration.bandits.response) + : undefined; + + // This method runs async because the configuration stores potentially have an async write at the end of updating + // the configuration. Most instances of offlineInit will use `MemoryOnlyConfigurationStore` instances which actually + // accomplish the config write synchronously. + // `void` keyword here suppresses warnings about leaving the promise hanging. + void this.configurationManager.hydrateConfigurationStoresFromUfc( + flagConfigResponse, + banditParamResponse, + ); + } + async fetchFlagConfigurations() { if (!this.configurationRequestParameters) { throw new Error( @@ -322,7 +360,7 @@ export default class EppoClient { apiKey, sdkName, sdkVersion, - baseUrl, // Default is set in ApiEndpoints constructor if undefined + baseUrl, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, numInitialRequestRetries = DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, numPollRequestRetries = DEFAULT_POLL_CONFIG_REQUEST_RETRIES, @@ -338,22 +376,22 @@ export default class EppoClient { pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS; } - // todo: Inject the chain of dependencies below const apiEndpoints = new ApiEndpoints({ baseUrl, queryParams: { apiKey, sdkName, sdkVersion }, }); const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs); + + // Use the configuration manager when creating the requestor const configurationRequestor = new ConfigurationRequestor( httpClient, - this.flagConfigurationStore, - this.banditVariationConfigurationStore ?? null, - this.banditModelConfigurationStore ?? null, + this.configurationManager, + !!this.banditModelConfigurationStore && !!this.banditVariationConfigurationStore, ); this.configurationRequestor = configurationRequestor; const pollingCallback = async () => { - if (await configurationRequestor.isFlagConfigExpired()) { + if (await this.flagConfigurationStore.isExpired()) { this.configObfuscatedCache = undefined; return configurationRequestor.fetchAndStoreConfigurations(); } diff --git a/src/client/test-utils.ts b/src/client/test-utils.ts index 546ae366..30cb971b 100644 --- a/src/client/test-utils.ts +++ b/src/client/test-utils.ts @@ -1,5 +1,6 @@ import ApiEndpoints from '../api-endpoints'; import ConfigurationRequestor from '../configuration-requestor'; +import { ConfigurationManager } from '../configuration-store/configuration-manager'; import { IConfigurationStore } from '../configuration-store/configuration-store'; import FetchHttpClient from '../http-client'; import { Flag, ObfuscatedFlag } from '../interfaces'; @@ -18,9 +19,8 @@ export async function initConfiguration( const httpClient = new FetchHttpClient(apiEndpoints, 1000); const configurationRequestor = new ConfigurationRequestor( httpClient, - configurationStore, - null, - null, + new ConfigurationManager(configurationStore), + false, ); await configurationRequestor.fetchAndStoreConfigurations(); } diff --git a/src/configuration-requestor.spec.ts b/src/configuration-requestor.spec.ts index 2bfe61d6..82286b14 100644 --- a/src/configuration-requestor.spec.ts +++ b/src/configuration-requestor.spec.ts @@ -7,6 +7,7 @@ import { import ApiEndpoints from './api-endpoints'; import ConfigurationRequestor from './configuration-requestor'; +import { ConfigurationManager } from './configuration-store/configuration-manager'; import { IConfigurationStore } from './configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; import FetchHttpClient, { @@ -22,6 +23,7 @@ describe('ConfigurationRequestor', () => { let banditVariationStore: IConfigurationStore; let banditModelStore: IConfigurationStore; let httpClient: IHttpClient; + let configurationManager: ConfigurationManager; let configurationRequestor: ConfigurationRequestor; beforeEach(async () => { @@ -34,15 +36,21 @@ describe('ConfigurationRequestor', () => { }, }); httpClient = new FetchHttpClient(apiEndpoints, 1000); + + // Create fresh stores for each test flagStore = new MemoryOnlyConfigurationStore(); banditVariationStore = new MemoryOnlyConfigurationStore(); banditModelStore = new MemoryOnlyConfigurationStore(); - configurationRequestor = new ConfigurationRequestor( - httpClient, + + // Create a ConfigurationManager instance + configurationManager = new ConfigurationManager( flagStore, banditVariationStore, banditModelStore, ); + + // Create ConfigurationRequestor with the manager + configurationRequestor = new ConfigurationRequestor(httpClient, configurationManager, true); }); afterEach(() => { @@ -218,7 +226,11 @@ describe('ConfigurationRequestor', () => { }); it('Will not fetch bandit parameters if there is no store', async () => { - configurationRequestor = new ConfigurationRequestor(httpClient, flagStore, null, null); + configurationRequestor = new ConfigurationRequestor( + httpClient, + configurationManager, + false, + ); await configurationRequestor.fetchAndStoreConfigurations(); expect(fetchSpy).toHaveBeenCalledTimes(1); }); @@ -498,29 +510,15 @@ describe('ConfigurationRequestor', () => { describe('getConfiguration', () => { it('should return an empty configuration instance before a config has been loaded', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - - const config = requestor.getConfiguration(); + const config = configurationRequestor.getConfiguration(); expect(config).toBeInstanceOf(StoreBackedConfiguration); expect(config.getFlagKeys()).toEqual([]); }); it('should return a populated configuration instance', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - - await requestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchAndStoreConfigurations(); - const config = requestor.getConfiguration(); + const config = configurationRequestor.getConfiguration(); expect(config).toBeInstanceOf(StoreBackedConfiguration); expect(config.getFlagKeys()).toEqual(['test_flag']); }); @@ -528,15 +526,9 @@ describe('ConfigurationRequestor', () => { describe('fetchAndStoreConfigurations', () => { it('should update configuration with flag data', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - const config = requestor.getConfiguration(); + const config = configurationRequestor.getConfiguration(); - await requestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchAndStoreConfigurations(); expect(config.getFlagKeys()).toEqual(['test_flag']); expect(config.getFlagConfigDetails()).toEqual({ @@ -548,33 +540,14 @@ describe('ConfigurationRequestor', () => { }); it('should update configuration with bandit data when present', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - const config = requestor.getConfiguration(); + const config = configurationRequestor.getConfiguration(); - await requestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchAndStoreConfigurations(); // Verify flag configuration expect(config.getFlagKeys()).toEqual(['test_flag']); // Verify bandit variation configuration - // expect(banditVariationDetails.entries).toEqual({ - // 'test_flag': [ - // { - // flagKey: 'test_flag', - // variationId: 'variation-1', - // // Add other expected properties based on your mock data - // } - // ] - // }); - // expect(banditVariationDetails.environment).toBe('test-env'); - // expect(banditVariationDetails.configFormat).toBe('SERVER'); - - // Verify bandit model configuration const banditVariations = config.getFlagBanditVariations('test_flag'); expect(banditVariations).toEqual([ { @@ -598,47 +571,21 @@ describe('ConfigurationRequestor', () => { modelName: 'falcon', modelVersion: '123', updatedAt: '2023-09-13T04:52:06.462Z', - // Add other expected properties based on your mock data }); }); it('should not fetch bandit parameters if model versions are already loaded', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - - const ufcResponse = { - flags: { test_flag: { key: 'test_flag', value: true } }, - banditReferences: { - bandit: { - modelVersion: 'v1', - flagVariations: [{ flagKey: 'test_flag', variationId: '1' }], - }, - }, - environment: 'test', - createdAt: '2024-01-01', - format: 'SERVER', - }; + // First call to load the initial data + await configurationRequestor.fetchAndStoreConfigurations(); - await requestor.fetchAndStoreConfigurations(); - // const initialFetchCount = fetchSpy.mock.calls.length; + // Reset the mock call count + fetchSpy.mockClear(); // Second call with same model version - // fetchSpy.mockImplementationOnce(() => - // Promise.resolve({ - // ok: true, - // status: 200, - // json: () => Promise.resolve(ufcResponse) - // }) - // ); - - await requestor.fetchAndStoreConfigurations(); - - // Should only have one additional fetch (the UFC) and not the bandit parameters - // expect(fetchSpy.mock.calls.length).toBe(initialFetchCount + 1); + await configurationRequestor.fetchAndStoreConfigurations(); + + // Should only have one fetch (the UFC) and not the bandit parameters + expect(fetchSpy.mock.calls.length).toBe(1); }); }); }); diff --git a/src/configuration-requestor.ts b/src/configuration-requestor.ts index 1f48e09e..7e520534 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -1,38 +1,20 @@ -import { IConfigurationStore } from './configuration-store/configuration-store'; +import { ConfigurationManager } from './configuration-store/configuration-manager'; import { IHttpClient } from './http-client'; -import { - ConfigStoreHydrationPacket, - IConfiguration, - StoreBackedConfiguration, -} from './i-configuration'; -import { BanditVariation, BanditParameters, Flag, BanditReference } from './interfaces'; +import { IConfiguration } from './i-configuration'; +import { BanditReference, BanditParameters } from './interfaces'; -// Requests AND stores flag configurations +// Requests configurations and delegates storage to the ConfigurationManager export default class ConfigurationRequestor { private banditModelVersions: string[] = []; - private readonly configuration: StoreBackedConfiguration; constructor( private readonly httpClient: IHttpClient, - private readonly flagConfigurationStore: IConfigurationStore, - private readonly banditVariationConfigurationStore: IConfigurationStore< - BanditVariation[] - > | null, - private readonly banditModelConfigurationStore: IConfigurationStore | null, - ) { - this.configuration = new StoreBackedConfiguration( - this.flagConfigurationStore, - this.banditVariationConfigurationStore, - this.banditModelConfigurationStore, - ); - } - - public isFlagConfigExpired(): Promise { - return this.flagConfigurationStore.isExpired(); - } + private readonly configurationManager: ConfigurationManager, + private readonly fetchBandits: boolean, + ) {} public getConfiguration(): IConfiguration { - return this.configuration; + return this.configurationManager.getConfiguration(); } async fetchAndStoreConfigurations(): Promise { @@ -41,59 +23,23 @@ export default class ConfigurationRequestor { return; } - const flagResponsePacket: ConfigStoreHydrationPacket = { - entries: configResponse.flags, - environment: configResponse.environment, - createdAt: configResponse.createdAt, - format: configResponse.format, - }; - - let banditVariationPacket: ConfigStoreHydrationPacket | undefined; - let banditModelPacket: ConfigStoreHydrationPacket | undefined; const flagsHaveBandits = Object.keys(configResponse.banditReferences ?? {}).length > 0; - const banditStoresProvided = Boolean( - this.banditVariationConfigurationStore && this.banditModelConfigurationStore, - ); - if (flagsHaveBandits && banditStoresProvided) { - // Map bandit flag associations by flag key for quick lookup (instead of bandit key as provided by the UFC) - const banditVariations = this.indexBanditVariationsByFlagKey(configResponse.banditReferences); - - banditVariationPacket = { - entries: banditVariations, - environment: configResponse.environment, - createdAt: configResponse.createdAt, - format: configResponse.format, - }; + let banditResponse = undefined; - if ( - this.requiresBanditModelConfigurationStoreUpdate( - this.banditModelVersions, - configResponse.banditReferences, - ) - ) { - const banditResponse = await this.httpClient.getBanditParameters(); + if (this.fetchBandits && flagsHaveBandits) { + // Check if we need to fetch bandit parameters + if (this.requiresBanditModelConfigurationStoreUpdate(configResponse.banditReferences)) { + banditResponse = await this.httpClient.getBanditParameters(); if (banditResponse?.bandits) { - banditModelPacket = { - entries: banditResponse.bandits, - environment: configResponse.environment, - createdAt: configResponse.createdAt, - format: configResponse.format, - }; - this.banditModelVersions = this.getLoadedBanditModelVersions(banditResponse.bandits); } } } - if ( - await this.configuration.hydrateConfigurationStores( - flagResponsePacket, - banditVariationPacket, - banditModelPacket, - ) - ) { - // TODO: Notify that config updated. - } + await this.configurationManager.hydrateConfigurationStoresFromUfc( + configResponse, + banditResponse, + ); } private getLoadedBanditModelVersions(entries: Record): string[] { @@ -101,7 +47,6 @@ export default class ConfigurationRequestor { } private requiresBanditModelConfigurationStoreUpdate( - currentBanditModelVersions: string[], banditReferences: Record, ): boolean { const referencedModelVersions = Object.values(banditReferences).map( @@ -109,24 +54,7 @@ export default class ConfigurationRequestor { ); return !referencedModelVersions.every((modelVersion) => - currentBanditModelVersions.includes(modelVersion), + this.banditModelVersions.includes(modelVersion), ); } - - private indexBanditVariationsByFlagKey( - banditVariationsByBanditKey: Record, - ): Record { - const banditVariationsByFlagKey: Record = {}; - Object.values(banditVariationsByBanditKey).forEach((banditReference) => { - banditReference.flagVariations.forEach((banditVariation) => { - let banditVariations = banditVariationsByFlagKey[banditVariation.flagKey]; - if (!banditVariations) { - banditVariations = []; - banditVariationsByFlagKey[banditVariation.flagKey] = banditVariations; - } - banditVariations.push(banditVariation); - }); - }); - return banditVariationsByFlagKey; - } } diff --git a/src/configuration-store/configuration-manager.spec.ts b/src/configuration-store/configuration-manager.spec.ts new file mode 100644 index 00000000..c8e3de07 --- /dev/null +++ b/src/configuration-store/configuration-manager.spec.ts @@ -0,0 +1,530 @@ +import { IBanditParametersResponse, IUniversalFlagConfigResponse } from '../http-client'; +import { ConfigStoreHydrationPacket, StoreBackedConfiguration } from '../i-configuration'; +import { + BanditParameters, + BanditReference, + BanditVariation, + Flag, + FormatEnum, + ObfuscatedFlag, + VariationType, +} from '../interfaces'; + +import { ConfigurationManager } from './configuration-manager'; +import { IConfigurationStore } from './configuration-store'; +import { hydrateConfigurationStore } from './configuration-store-utils'; +import { MemoryOnlyConfigurationStore } from './memory.store'; + +describe('ConfigurationManager', () => { + let flagStore: IConfigurationStore; + let banditVariationStore: IConfigurationStore; + let banditModelStore: IConfigurationStore; + let configManager: ConfigurationManager; + + beforeEach(() => { + // Create fresh stores for each test + flagStore = new MemoryOnlyConfigurationStore(); + banditVariationStore = new MemoryOnlyConfigurationStore(); + banditModelStore = new MemoryOnlyConfigurationStore(); + + // Create a ConfigurationManager instance + configManager = new ConfigurationManager(flagStore, banditVariationStore, banditModelStore); + }); + + describe('constructor', () => { + it('should create a StoreBackedConfiguration with the provided stores', () => { + const config = configManager.getConfiguration(); + expect(config).toBeInstanceOf(StoreBackedConfiguration); + expect(config.getFlagKeys()).toEqual([]); + }); + + it('should handle null bandit stores', () => { + const managerWithNullStores = new ConfigurationManager(flagStore); + const config = managerWithNullStores.getConfiguration(); + expect(config).toBeInstanceOf(StoreBackedConfiguration); + expect(config.getFlagKeys()).toEqual([]); + }); + }); + + describe('getConfiguration', () => { + it('should return the StoreBackedConfiguration instance', () => { + const config = configManager.getConfiguration(); + expect(config).toBeInstanceOf(StoreBackedConfiguration); + }); + }); + + describe('hydrateConfigurationStores', () => { + it('should hydrate flag configuration store', async () => { + const flagPacket: ConfigStoreHydrationPacket = { + entries: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'var-a': { key: 'var-a', value: 'A' }, + 'var-b': { key: 'var-b', value: 'B' }, + }, + allocations: [], + totalShards: 10, + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }; + + await configManager.hydrateConfigurationStores(flagPacket); + + const config = configManager.getConfiguration(); + expect(config.getFlagKeys()).toEqual(['test-flag']); + expect(config.getFlag('test-flag')).toEqual(flagPacket.entries['test-flag']); + }); + + it('should hydrate bandit variation store', async () => { + const flagPacket: ConfigStoreHydrationPacket = { + entries: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'bandit-var': { key: 'bandit-var', value: 'bandit' }, + }, + allocations: [], + totalShards: 10, + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }; + + const banditVariationPacket: ConfigStoreHydrationPacket = { + entries: { + 'test-flag': [ + { + key: 'bandit-1', + flagKey: 'test-flag', + variationKey: 'bandit-var', + variationValue: 'bandit', + }, + ], + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }; + + await configManager.hydrateConfigurationStores(flagPacket, banditVariationPacket); + + const config = configManager.getConfiguration(); + expect(config.getFlagBanditVariations('test-flag')).toEqual( + banditVariationPacket.entries['test-flag'], + ); + }); + + it('should hydrate bandit model store', async () => { + const flagPacket: ConfigStoreHydrationPacket = { + entries: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'bandit-var': { key: 'bandit-var', value: 'bandit' }, + }, + allocations: [], + totalShards: 10, + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }; + + const banditVariationPacket: ConfigStoreHydrationPacket = { + entries: { + 'test-flag': [ + { + key: 'bandit-1', + flagKey: 'test-flag', + variationKey: 'bandit-var', + variationValue: 'bandit', + }, + ], + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }; + + const banditModelPacket: ConfigStoreHydrationPacket = { + entries: { + 'bandit-1': { + banditKey: 'bandit-1', + modelName: 'test-model', + modelVersion: '1.0', + // updatedAt: '2023-01-01', + modelData: { + gamma: 0, + defaultActionScore: 0, + actionProbabilityFloor: 0, + coefficients: {}, + }, + }, + }, + + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }; + + await configManager.hydrateConfigurationStores( + flagPacket, + banditVariationPacket, + banditModelPacket, + ); + + const config = configManager.getConfiguration(); + expect(config.getBandit('bandit-1')).toEqual(banditModelPacket.entries['bandit-1']); + }); + }); + + describe('hydrateConfigurationStoresFromUfc', () => { + it('should return false if no flags in response', async () => { + const result = await configManager.hydrateConfigurationStoresFromUfc( + {} as IUniversalFlagConfigResponse, + ); + expect(result).toBe(false); + }); + + it('should hydrate flag configuration from UFC response', async () => { + const ufcResponse: IUniversalFlagConfigResponse = { + flags: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'var-a': { key: 'var-a', value: 'A' }, + }, + allocations: [], + totalShards: 10, + }, + }, + banditReferences: {}, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: FormatEnum.SERVER, + }; + + await configManager.hydrateConfigurationStoresFromUfc(ufcResponse); + + const config = configManager.getConfiguration(); + expect(config.getFlagKeys()).toEqual(['test-flag']); + expect(config.getFlag('test-flag')).toEqual(ufcResponse.flags['test-flag']); + }); + + it('should hydrate bandit variations from UFC response', async () => { + const ufcResponse: IUniversalFlagConfigResponse = { + flags: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'bandit-var': { key: 'bandit-var', value: 'bandit' }, + }, + allocations: [], + totalShards: 10, + }, + }, + banditReferences: { + 'bandit-1': { + modelVersion: '1.0', + flagVariations: [ + { + key: 'bandit-1', + flagKey: 'test-flag', + variationKey: 'bandit-var', + variationValue: 'bandit', + }, + ], + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: FormatEnum.SERVER, + }; + + await configManager.hydrateConfigurationStoresFromUfc(ufcResponse); + + const config = configManager.getConfiguration(); + expect(config.getFlagBanditVariations('test-flag')).toHaveLength(1); + expect(config.getFlagBanditVariations('test-flag')[0].key).toBe('bandit-1'); + }); + + it('should hydrate bandit models from bandit response', async () => { + const ufcResponse: IUniversalFlagConfigResponse = { + flags: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'bandit-var': { key: 'bandit-var', value: 'bandit' }, + }, + allocations: [], + totalShards: 10, + }, + }, + banditReferences: { + 'bandit-1': { + modelVersion: '1.0', + flagVariations: [ + { + key: 'bandit-1', + flagKey: 'test-flag', + variationKey: 'bandit-var', + variationValue: 'bandit', + }, + ], + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: FormatEnum.SERVER, + }; + + const banditResponse: IBanditParametersResponse = { + bandits: { + 'bandit-1': { + banditKey: 'bandit-1', + modelName: 'test-model', + modelVersion: '1.0', + // updatedAt: '2023-01-01', + modelData: { + coefficients: {}, + gamma: 0, + defaultActionScore: 0, + actionProbabilityFloor: 0, + }, + }, + }, + }; + + await configManager.hydrateConfigurationStoresFromUfc(ufcResponse, banditResponse); + + const config = configManager.getConfiguration(); + expect(config.getBandit('bandit-1')).toEqual(banditResponse.bandits['bandit-1']); + }); + + it('should handle UFC response with no bandit references', async () => { + const ufcResponse: IUniversalFlagConfigResponse = { + flags: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'var-a': { key: 'var-a', value: 'A' }, + }, + allocations: [], + totalShards: 10, + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: FormatEnum.SERVER, + banditReferences: {}, + }; + + await configManager.hydrateConfigurationStoresFromUfc(ufcResponse); + + const config = configManager.getConfiguration(); + expect(config.getFlagKeys()).toEqual(['test-flag']); + expect(config.getFlagBanditVariations('test-flag')).toEqual([]); + }); + }); + + describe('setConfigurationStores', () => { + it('should update the flag configuration store', async () => { + const newFlagStore = new MemoryOnlyConfigurationStore(); + + // Pre-populate the new store + await hydrateConfigurationStore(newFlagStore, { + entries: { + 'new-flag': { + key: 'new-flag', + enabled: true, + variationType: VariationType.BOOLEAN, + variations: { + true: { key: 'true', value: true }, + false: { key: 'false', value: false }, + }, + allocations: [], + totalShards: 10, + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: FormatEnum.SERVER, + }); + + configManager.setConfigurationStores({ + flagConfigurationStore: newFlagStore, + banditReferenceConfigurationStore: banditVariationStore, + banditConfigurationStore: banditModelStore, + }); + + const config = configManager.getConfiguration(); + expect(config.getFlagKeys()).toEqual(['new-flag']); + }); + + it('should update the bandit variation store', async () => { + const newBanditVariationStore = new MemoryOnlyConfigurationStore(); + + // Pre-populate the new store + hydrateConfigurationStore(newBanditVariationStore, { + entries: { + 'test-flag': [ + { + key: 'new-bandit', + flagKey: 'test-flag', + variationKey: 'var-a', + variationValue: 'A', + }, + ], + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }); + + configManager.setConfigurationStores({ + flagConfigurationStore: flagStore, + banditReferenceConfigurationStore: newBanditVariationStore, + banditConfigurationStore: banditModelStore, + }); + + const config = configManager.getConfiguration(); + expect(config.getFlagBanditVariations('test-flag')).toHaveLength(1); + expect(config.getFlagBanditVariations('test-flag')[0].key).toBe('new-bandit'); + }); + + it('should update the bandit model store', async () => { + const newBanditModelStore = new MemoryOnlyConfigurationStore(); + + // Pre-populate the new store + hydrateConfigurationStore(newBanditModelStore, { + entries: { + 'new-bandit': { + banditKey: 'new-bandit', + modelName: 'new-model', + modelVersion: '2.0', + // updatedAt: '2023-02-01', + modelData: { + gamma: 0, + defaultActionScore: 0, + actionProbabilityFloor: 0, + coefficients: {}, + }, + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }); + + configManager.setConfigurationStores({ + flagConfigurationStore: flagStore, + banditReferenceConfigurationStore: banditVariationStore, + banditConfigurationStore: newBanditModelStore, + }); + + const config = configManager.getConfiguration(); + expect(config.getBandit('new-bandit')).toEqual({ + banditKey: 'new-bandit', + modelName: 'new-model', + modelVersion: '2.0', + // updatedAt: '2023-02-01', + modelData: { + gamma: 0, + defaultActionScore: 0, + actionProbabilityFloor: 0, + coefficients: {}, + }, + }); + }); + + it('should handle optional bandit stores', () => { + configManager.setConfigurationStores({ + flagConfigurationStore: flagStore, + }); + + const config = configManager.getConfiguration(); + expect(config).toBeInstanceOf(StoreBackedConfiguration); + expect(config.getFlagKeys()).toEqual([]); + }); + }); + + describe('indexBanditVariationsByFlagKey', () => { + it('should correctly index bandit variations by flag key', async () => { + // We need to test the private method, so we'll use a test-only approach + // by creating a subclass that exposes the private method for testing + class TestableConfigManager extends ConfigurationManager { + public testIndexBanditVariationsByFlagKey(banditRefs: Record) { + return this['indexBanditVariationsByFlagKey'](banditRefs); + } + } + + const testManager = new TestableConfigManager( + flagStore, + banditVariationStore, + banditModelStore, + ); + + const banditReferences: Record = { + 'bandit-1': { + modelVersion: '1.0', + flagVariations: [ + { + key: 'bandit-1-var-1', + flagKey: 'flag-1', + variationKey: 'var-a', + variationValue: 'A', + }, + { + key: 'bandit-1-var-2', + flagKey: 'flag-2', + variationKey: 'var-b', + variationValue: 'B', + }, + ], + }, + 'bandit-2': { + modelVersion: '2.0', + flagVariations: [ + { + key: 'bandit-2-var-1', + flagKey: 'flag-1', + variationKey: 'var-c', + variationValue: 'C', + }, + ], + }, + }; + + const result = testManager.testIndexBanditVariationsByFlagKey(banditReferences); + + expect(Object.keys(result)).toEqual(['flag-1', 'flag-2']); + expect(result['flag-1']).toHaveLength(2); + expect(result['flag-2']).toHaveLength(1); + + // Check that the variations are correctly assigned to their flag keys + expect(result['flag-1'].map((v) => v.key)).toEqual(['bandit-1-var-1', 'bandit-2-var-1']); + expect(result['flag-2'].map((v) => v.key)).toEqual(['bandit-1-var-2']); + }); + }); +}); diff --git a/src/configuration-store/configuration-manager.ts b/src/configuration-store/configuration-manager.ts new file mode 100644 index 00000000..fb1b8ba6 --- /dev/null +++ b/src/configuration-store/configuration-manager.ts @@ -0,0 +1,132 @@ +import { IUniversalFlagConfigResponse, IBanditParametersResponse } from '../http-client'; +import { + IConfiguration, + StoreBackedConfiguration, + ConfigStoreHydrationPacket, +} from '../i-configuration'; +import { + Flag, + ObfuscatedFlag, + BanditReference, + BanditParameters, + BanditVariation, +} from '../interfaces'; + +import { IConfigurationStore } from './configuration-store'; + +export type ConfigurationStoreBundle = { + flagConfigurationStore: IConfigurationStore; + banditReferenceConfigurationStore?: IConfigurationStore; + banditConfigurationStore?: IConfigurationStore; +}; + +export class ConfigurationManager { + private configuration: StoreBackedConfiguration; + + constructor( + private flagConfigurationStore: IConfigurationStore, + private banditReferenceConfigurationStore?: IConfigurationStore, + private banditConfigurationStore?: IConfigurationStore, + ) { + this.configuration = new StoreBackedConfiguration( + this.flagConfigurationStore, + this.banditReferenceConfigurationStore, + this.banditConfigurationStore, + ); + } + + public getConfiguration(): IConfiguration { + return this.configuration; + } + + public async hydrateConfigurationStores( + flagConfigPacket: ConfigStoreHydrationPacket, + banditReferencePacket?: ConfigStoreHydrationPacket, + banditParametersPacket?: ConfigStoreHydrationPacket, + ): Promise { + // Delegate to the configuration to hydrate the stores + return this.configuration.hydrateConfigurationStores( + flagConfigPacket, + banditReferencePacket, + banditParametersPacket, + ); + } + + public async hydrateConfigurationStoresFromUfc( + configResponse: IUniversalFlagConfigResponse, + banditResponse?: IBanditParametersResponse, + ): Promise { + if (!configResponse?.flags) { + return false; + } + + const flagResponsePacket: ConfigStoreHydrationPacket = { + entries: configResponse.flags, + environment: configResponse.environment, + createdAt: configResponse.createdAt, + format: configResponse.format, + }; + + let banditVariationPacket: ConfigStoreHydrationPacket | undefined; + let banditModelPacket: ConfigStoreHydrationPacket | undefined; + const flagsHaveBandits = Object.keys(configResponse.banditReferences ?? {}).length > 0; + + if (flagsHaveBandits) { + // Map bandit flag associations by flag key for quick lookup (instead of bandit key as provided by the UFC) + const banditVariations = this.indexBanditVariationsByFlagKey(configResponse.banditReferences); + + banditVariationPacket = { + entries: banditVariations, + environment: configResponse.environment, + createdAt: configResponse.createdAt, + format: configResponse.format, + }; + + if (banditResponse?.bandits) { + banditModelPacket = { + entries: banditResponse.bandits, + environment: configResponse.environment, + createdAt: configResponse.createdAt, + format: configResponse.format, + }; + } + } + + // Use the hydrateConfigurationStores method to avoid duplication + return this.hydrateConfigurationStores( + flagResponsePacket, + banditVariationPacket, + banditModelPacket, + ); + } + + public setConfigurationStores(configStores: ConfigurationStoreBundle): void { + this.flagConfigurationStore = configStores.flagConfigurationStore; + this.banditReferenceConfigurationStore = configStores.banditReferenceConfigurationStore; + this.banditConfigurationStore = configStores.banditConfigurationStore; + + // Recreate the configuration with the new stores + this.configuration = new StoreBackedConfiguration( + this.flagConfigurationStore, + this.banditReferenceConfigurationStore, + this.banditConfigurationStore, + ); + } + + private indexBanditVariationsByFlagKey( + banditVariationsByBanditKey: Record, + ): Record { + const banditVariationsByFlagKey: Record = {}; + Object.values(banditVariationsByBanditKey).forEach((banditReference) => { + banditReference.flagVariations.forEach((banditVariation) => { + let banditVariations = banditVariationsByFlagKey[banditVariation.flagKey]; + if (!banditVariations) { + banditVariations = []; + banditVariationsByFlagKey[banditVariation.flagKey] = banditVariations; + } + banditVariations.push(banditVariation); + }); + }); + return banditVariationsByFlagKey; + } +} diff --git a/src/configuration-wire/configuration-wire-types.ts b/src/configuration-wire/configuration-wire-types.ts index 9d1a8d2d..d7734d77 100644 --- a/src/configuration-wire/configuration-wire-types.ts +++ b/src/configuration-wire/configuration-wire-types.ts @@ -200,6 +200,10 @@ export class ConfigurationWireV1 implements IConfigurationWire { readonly bandits?: IConfigResponse, ) {} + public static fromString(stringifiedPayload: string): IConfigurationWire { + return JSON.parse(stringifiedPayload) as IConfigurationWire; + } + public static fromResponses( flagConfig: IUniversalFlagConfigResponse, banditConfig?: IBanditParametersResponse, diff --git a/src/i-configuration.ts b/src/i-configuration.ts index f8d3b0df..b9b36cdb 100644 --- a/src/i-configuration.ts +++ b/src/i-configuration.ts @@ -63,6 +63,8 @@ export class StoreBackedConfiguration implements IConfiguration { ); } await Promise.all(promises); + + // TODO: notify of config change if `didUpdateFlags` is true return didUpdateFlags; } diff --git a/src/index.ts b/src/index.ts index a9ebaee9..9a0f65e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ import { ISyncStore, } from './configuration-store/configuration-store'; import { HybridConfigurationStore } from './configuration-store/hybrid.store'; +import { ConfigurationStoreBundle } from './configuration-store/i-configuration-manager'; import { MemoryStore, MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; import { ConfigurationWireHelper } from './configuration-wire/configuration-wire-helper'; import { @@ -110,6 +111,7 @@ export { MemoryStore, HybridConfigurationStore, MemoryOnlyConfigurationStore, + ConfigurationStoreBundle, // Assignment cache AssignmentCacheKey, diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 24b2c49c..8b8be06d 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -19,6 +19,9 @@ const TEST_CONFIGURATION_WIRE_DATA_DIR = './test/data/configuration-wire/'; const MOCK_PRECOMPUTED_FILENAME = 'precomputed-v1'; export const MOCK_PRECOMPUTED_WIRE_FILE = `${MOCK_PRECOMPUTED_FILENAME}.json`; export const MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE = `${MOCK_PRECOMPUTED_FILENAME}-deobfuscated.json`; +export const SHARED_BOOTSTRAP_FLAGS_FILE = 'bootstrap-flags-v1.json'; +export const SHARED_BOOTSTRAP_FLAGS_OBFUSCATED_FILE = 'bootstrap-flags-obfuscated-v1.json'; +export const SHARED_BOOTSTRAP_BANDIT_FLAGS_FILE = 'bootstrap-bandit-flags-v1.json'; export interface SubjectTestCase { subjectKey: string;