diff --git a/package.json b/package.json index 7834f1d..a140c9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk-common", - "version": "4.13.5", + "version": "4.14.0", "description": "Common library for Eppo JavaScript SDKs (web, react native, and node)", "main": "dist/index.js", "files": [ diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index 18a1b40..878b909 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -18,7 +18,7 @@ import { IConfigurationWire, IPrecomputedConfiguration, IObfuscatedPrecomputedConfigurationResponse, -} from '../configuration-wire-types'; +} from '../configuration-wire/configuration-wire-types'; import { Evaluator, FlagEvaluation } from '../evaluator'; import { AllocationEvaluationCode, diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index a761d8d..4ef63f1 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -21,7 +21,7 @@ import { IConfigurationWire, IObfuscatedPrecomputedConfigurationResponse, ObfuscatedPrecomputedConfigurationResponse, -} from '../configuration-wire-types'; +} from '../configuration-wire/configuration-wire-types'; import { MAX_EVENT_QUEUE_SIZE, DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; import { decodePrecomputedFlag } from '../decoding'; import { Flag, ObfuscatedFlag, VariationType, FormatEnum, Variation } from '../interfaces'; diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index a2defad..a8f3a5d 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -22,7 +22,7 @@ import { IConfigurationWire, IPrecomputedConfiguration, PrecomputedConfiguration, -} from '../configuration-wire-types'; +} from '../configuration-wire/configuration-wire-types'; import { DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, DEFAULT_POLL_CONFIG_REQUEST_RETRIES, diff --git a/src/client/eppo-precomputed-client.spec.ts b/src/client/eppo-precomputed-client.spec.ts index d2b14ac..c512f0c 100644 --- a/src/client/eppo-precomputed-client.spec.ts +++ b/src/client/eppo-precomputed-client.spec.ts @@ -13,7 +13,7 @@ import { } from '../attributes'; import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; -import { IPrecomputedConfigurationResponse } from '../configuration-wire-types'; +import { IPrecomputedConfigurationResponse } from '../configuration-wire/configuration-wire-types'; import { DEFAULT_POLL_INTERVAL_MS, MAX_EVENT_QUEUE_SIZE, POLL_JITTER_PCT } from '../constants'; import FetchHttpClient from '../http-client'; import { diff --git a/src/configuration-wire/configuration-wire-helper.spec.ts b/src/configuration-wire/configuration-wire-helper.spec.ts new file mode 100644 index 0000000..c3cc17e --- /dev/null +++ b/src/configuration-wire/configuration-wire-helper.spec.ts @@ -0,0 +1,98 @@ +import { IBanditParametersResponse, IUniversalFlagConfigResponse } from '../http-client'; +import { FormatEnum } from '../interfaces'; +import { getMD5Hash } from '../obfuscation'; + +import { ConfigurationWireHelper } from './configuration-wire-helper'; + +const TEST_BASE_URL = 'https://us-central1-eppo-qa.cloudfunctions.net/serveGitHubRacTestFile'; +const DUMMY_SDK_KEY = 'dummy-sdk-key'; + +// This SDK causes the cloud endpoint below to serve the UFC test file with bandit flags. +const BANDIT_SDK_KEY = 'this-key-serves-bandits'; + +describe('ConfigurationWireHelper', () => { + describe('getBootstrapConfigurationFromApi', () => { + it('should fetch obfuscated flags with android SDK', async () => { + const helper = ConfigurationWireHelper.build(DUMMY_SDK_KEY, { + sdkName: 'android', + sdkVersion: '4.0.0', + baseUrl: TEST_BASE_URL, + }); + + const wirePacket = await helper.fetchBootstrapConfiguration(); + + expect(wirePacket.version).toBe(1); + expect(wirePacket.config).toBeDefined(); + + if (!wirePacket.config) { + throw new Error('Flag config not present in ConfigurationWire'); + } + + const configResponse = JSON.parse(wirePacket.config.response) as IUniversalFlagConfigResponse; + expect(configResponse.format).toBe(FormatEnum.CLIENT); + expect(configResponse.flags).toBeDefined(); + expect(Object.keys(configResponse.flags).length).toBeGreaterThan(1); + expect(Object.keys(configResponse.flags)).toHaveLength(19); + + const testFlagKey = getMD5Hash('numeric_flag'); + expect(Object.keys(configResponse.flags)).toContain(testFlagKey); + + // No bandits. + expect(configResponse.banditReferences).toBeUndefined(); + expect(wirePacket.bandits).toBeUndefined(); + }); + + it('should fetch flags and bandits for node-server SDK', async () => { + const helper = ConfigurationWireHelper.build(BANDIT_SDK_KEY, { + sdkName: 'node-server', + sdkVersion: '4.0.0', + baseUrl: TEST_BASE_URL, + }); + + const wirePacket = await helper.fetchBootstrapConfiguration(); + + expect(wirePacket.version).toBe(1); + expect(wirePacket.config).toBeDefined(); + + if (!wirePacket.config) { + throw new Error('Flag config not present in ConfigurationWire'); + } + + const configResponse = JSON.parse(wirePacket.config.response) as IUniversalFlagConfigResponse; + expect(configResponse.format).toBe(FormatEnum.SERVER); + expect(configResponse.flags).toBeDefined(); + expect(configResponse.banditReferences).toBeDefined(); + expect(Object.keys(configResponse.flags)).toContain('banner_bandit_flag'); + expect(Object.keys(configResponse.flags)).toContain('car_bandit_flag'); + + expect(wirePacket.bandits).toBeDefined(); + const banditResponse = JSON.parse( + wirePacket.bandits?.response ?? '', + ) as IBanditParametersResponse; + expect(Object.keys(banditResponse.bandits).length).toBeGreaterThan(1); + expect(Object.keys(banditResponse.bandits)).toContain('banner_bandit'); + expect(Object.keys(banditResponse.bandits)).toContain('car_bandit'); + }); + + it('should include fetchedAt timestamps', async () => { + const helper = ConfigurationWireHelper.build(BANDIT_SDK_KEY, { + sdkName: 'android', + sdkVersion: '4.0.0', + baseUrl: TEST_BASE_URL, + }); + + const wirePacket = await helper.fetchBootstrapConfiguration(); + + if (!wirePacket.config) { + throw new Error('Flag config not present in ConfigurationWire'); + } + if (!wirePacket.bandits) { + throw new Error('Bandit config not present in ConfigurationWire'); + } + + expect(wirePacket.config.fetchedAt).toBeDefined(); + expect(Date.parse(wirePacket.config.fetchedAt ?? '')).not.toBeNaN(); + expect(Date.parse(wirePacket.bandits.fetchedAt ?? '')).not.toBeNaN(); + }); + }); +}); diff --git a/src/configuration-wire/configuration-wire-helper.ts b/src/configuration-wire/configuration-wire-helper.ts new file mode 100644 index 0000000..0a06511 --- /dev/null +++ b/src/configuration-wire/configuration-wire-helper.ts @@ -0,0 +1,75 @@ +import ApiEndpoints from '../api-endpoints'; +import FetchHttpClient, { + IBanditParametersResponse, + IHttpClient, + IUniversalFlagConfigResponse, +} from '../http-client'; + +import { ConfigurationWireV1, IConfigurationWire } from './configuration-wire-types'; + +export type SdkOptions = { + sdkName: string; + sdkVersion: string; + baseUrl?: string; +}; + +/** + * Helper class for fetching and converting configuration from the Eppo API(s). + */ +export class ConfigurationWireHelper { + private httpClient: IHttpClient; + + /** + * Build a new ConfigurationHelper for the target SDK Key. + * @param sdkKey + */ + public static build( + sdkKey: string, + opts: SdkOptions = { sdkName: 'android', sdkVersion: '4.0.0' }, + ) { + const { sdkName, sdkVersion, baseUrl } = opts; + return new ConfigurationWireHelper(sdkKey, sdkName, sdkVersion, baseUrl); + } + + private constructor( + sdkKey: string, + targetSdkName = 'android', + targetSdkVersion = '4.0.0', + baseUrl?: string, + ) { + const queryParams = { + sdkName: targetSdkName, + sdkVersion: targetSdkVersion, + apiKey: sdkKey, + }; + const apiEndpoints = new ApiEndpoints({ + baseUrl, + queryParams, + }); + + this.httpClient = new FetchHttpClient(apiEndpoints, 5000); + } + + /** + * Fetches configuration data from the API and build a Bootstrap Configuration (aka an `IConfigurationWire` object). + * The IConfigurationWire instance can be used to bootstrap some SDKs. + */ + public async fetchBootstrapConfiguration(): Promise { + // Get the configs + let banditResponse: IBanditParametersResponse | undefined; + const configResponse: IUniversalFlagConfigResponse | undefined = + await this.httpClient.getUniversalFlagConfiguration(); + + if (!configResponse?.flags) { + console.warn('Unable to fetch configuration, returning empty configuration'); + return Promise.resolve(ConfigurationWireV1.empty()); + } + + const flagsHaveBandits = Object.keys(configResponse.banditReferences ?? {}).length > 0; + if (flagsHaveBandits) { + banditResponse = await this.httpClient.getBanditParameters(); + } + + return ConfigurationWireV1.fromResponses(configResponse, banditResponse); + } +} diff --git a/src/configuration-wire-types.spec.ts b/src/configuration-wire/configuration-wire-types.spec.ts similarity index 97% rename from src/configuration-wire-types.spec.ts rename to src/configuration-wire/configuration-wire-types.spec.ts index c7f59ba..c90e8ac 100644 --- a/src/configuration-wire-types.spec.ts +++ b/src/configuration-wire/configuration-wire-types.spec.ts @@ -2,11 +2,11 @@ import { MOCK_BANDIT_MODELS_RESPONSE_FILE, MOCK_FLAGS_WITH_BANDITS_RESPONSE_FILE, readMockUFCResponse, -} from '../test/testHelpers'; +} from '../../test/testHelpers'; +import { IUniversalFlagConfigResponse, IBanditParametersResponse } from '../http-client'; +import { FormatEnum } from '../interfaces'; import { ConfigurationWireV1, deflateResponse, inflateResponse } from './configuration-wire-types'; -import { IUniversalFlagConfigResponse, IBanditParametersResponse } from './http-client'; -import { FormatEnum } from './interfaces'; describe('Response String Type Safety', () => { const mockFlagConfig: IUniversalFlagConfigResponse = readMockUFCResponse( diff --git a/src/configuration-wire-types.ts b/src/configuration-wire/configuration-wire-types.ts similarity index 98% rename from src/configuration-wire-types.ts rename to src/configuration-wire/configuration-wire-types.ts index 0523772..9d1a8d2 100644 --- a/src/configuration-wire-types.ts +++ b/src/configuration-wire/configuration-wire-types.ts @@ -1,13 +1,13 @@ -import { IUniversalFlagConfigResponse, IBanditParametersResponse } from './http-client'; +import { IUniversalFlagConfigResponse, IBanditParametersResponse } from '../http-client'; import { Environment, FormatEnum, IObfuscatedPrecomputedBandit, IPrecomputedBandit, PrecomputedFlag, -} from './interfaces'; -import { obfuscatePrecomputedBanditMap, obfuscatePrecomputedFlags } from './obfuscation'; -import { ContextAttributes, FlagKey, HashedFlagKey } from './types'; +} from '../interfaces'; +import { obfuscatePrecomputedBanditMap, obfuscatePrecomputedFlags } from '../obfuscation'; +import { ContextAttributes, FlagKey, HashedFlagKey } from '../types'; // Base interface for all configuration responses interface IBasePrecomputedConfigurationResponse { diff --git a/src/http-client.ts b/src/http-client.ts index 23f2867..789b55a 100644 --- a/src/http-client.ts +++ b/src/http-client.ts @@ -1,5 +1,5 @@ import ApiEndpoints from './api-endpoints'; -import { IObfuscatedPrecomputedConfigurationResponse } from './configuration-wire-types'; +import { IObfuscatedPrecomputedConfigurationResponse } from './configuration-wire/configuration-wire-types'; import { BanditParameters, BanditReference, diff --git a/src/index.ts b/src/index.ts index 6b15bc9..a9ebaee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,11 +33,12 @@ import { } from './configuration-store/configuration-store'; import { HybridConfigurationStore } from './configuration-store/hybrid.store'; import { MemoryStore, MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; +import { ConfigurationWireHelper } from './configuration-wire/configuration-wire-helper'; import { IConfigurationWire, IObfuscatedPrecomputedConfigurationResponse, IPrecomputedConfigurationResponse, -} from './configuration-wire-types'; +} from './configuration-wire/configuration-wire-types'; import * as constants from './constants'; import { decodePrecomputedFlag } from './decoding'; import { EppoAssignmentLogger } from './eppo-assignment-logger'; @@ -155,6 +156,7 @@ export { IPrecomputedConfigurationResponse, PrecomputedFlag, FlagKey, + ConfigurationWireHelper, // Test helpers decodePrecomputedFlag,