diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index ec37f01..a2defad 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -1038,7 +1038,7 @@ export default class EppoClient { configDetails.configEnvironment, ); - const configWire: IConfigurationWire = new ConfigurationWireV1(precomputedConfig); + const configWire: IConfigurationWire = ConfigurationWireV1.precomputed(precomputedConfig); return JSON.stringify(configWire); } diff --git a/src/configuration-wire-types.spec.ts b/src/configuration-wire-types.spec.ts new file mode 100644 index 0000000..c7f59ba --- /dev/null +++ b/src/configuration-wire-types.spec.ts @@ -0,0 +1,113 @@ +import { + MOCK_BANDIT_MODELS_RESPONSE_FILE, + MOCK_FLAGS_WITH_BANDITS_RESPONSE_FILE, + readMockUFCResponse, +} from '../test/testHelpers'; + +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( + MOCK_FLAGS_WITH_BANDITS_RESPONSE_FILE, + ) as IUniversalFlagConfigResponse; + const mockBanditConfig: IBanditParametersResponse = readMockUFCResponse( + MOCK_BANDIT_MODELS_RESPONSE_FILE, + ) as IBanditParametersResponse; + + describe('deflateResponse and inflateResponse', () => { + it('should correctly serialize and deserialize flag config', () => { + const serialized = deflateResponse(mockFlagConfig); + const deserialized = inflateResponse(serialized); + + expect(deserialized).toEqual(mockFlagConfig); + }); + + it('should correctly serialize and deserialize bandit config', () => { + const serialized = deflateResponse(mockBanditConfig); + const deserialized = inflateResponse(serialized); + + expect(deserialized).toEqual(mockBanditConfig); + }); + + it('should maintain type information through serialization', () => { + const serialized = deflateResponse(mockFlagConfig); + const deserialized = inflateResponse(serialized); + + // TypeScript compilation check: these should work + expect(deserialized.format).toBe(FormatEnum.SERVER); + expect(deserialized.environment).toStrictEqual({ name: 'Test' }); + }); + }); + + describe('ConfigurationWireV1', () => { + it('should create configuration with flag config', () => { + const wirePacket = ConfigurationWireV1.fromResponses(mockFlagConfig); + + expect(wirePacket.version).toBe(1); + expect(wirePacket.config).toBeDefined(); + expect(wirePacket.bandits).toBeUndefined(); + + // Verify we can deserialize the response + expect(wirePacket.config).toBeTruthy(); + if (!wirePacket.config) { + fail('Flag config not present in ConfigurationWire'); + } + const deserializedConfig = inflateResponse(wirePacket.config.response); + expect(deserializedConfig).toEqual(mockFlagConfig); + }); + + it('should create configuration with both flag and bandit configs', () => { + const wirePacket = ConfigurationWireV1.fromResponses( + mockFlagConfig, + mockBanditConfig, + 'flag-etag', + 'bandit-etag', + ); + + if (!wirePacket.config) { + fail('Flag config not present in ConfigurationWire'); + } + if (!wirePacket.bandits) { + fail('Bandit Model Parameters not present in ConfigurationWire'); + } + + expect(wirePacket.version).toBe(1); + expect(wirePacket.config).toBeDefined(); + expect(wirePacket.bandits).toBeDefined(); + expect(wirePacket.config.etag).toBe('flag-etag'); + expect(wirePacket.bandits.etag).toBe('bandit-etag'); + + // Verify we can deserialize both responses + const deserializedConfig = inflateResponse(wirePacket.config.response); + const deserializedBandits = inflateResponse(wirePacket.bandits.response); + + expect(deserializedConfig).toEqual(mockFlagConfig); + expect(deserializedBandits).toEqual(mockBanditConfig); + }); + + it('should create empty configuration', () => { + const config = ConfigurationWireV1.empty(); + + expect(config.version).toBe(1); + expect(config.config).toBeUndefined(); + expect(config.bandits).toBeUndefined(); + expect(config.precomputed).toBeUndefined(); + }); + + it('should include fetchedAt timestamps', () => { + const wirePacket = ConfigurationWireV1.fromResponses(mockFlagConfig, mockBanditConfig); + + if (!wirePacket.config) { + fail('Flag config not present in ConfigurationWire'); + } + if (!wirePacket.bandits) { + fail('Bandit Model Parameters 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-types.ts b/src/configuration-wire-types.ts index 4f2f30c..0523772 100644 --- a/src/configuration-wire-types.ts +++ b/src/configuration-wire-types.ts @@ -1,3 +1,4 @@ +import { IUniversalFlagConfigResponse, IBanditParametersResponse } from './http-client'; import { Environment, FormatEnum, @@ -151,11 +152,82 @@ export interface IConfigurationWire { */ readonly version: number; - // TODO: Add flags and bandits for offline/non-precomputed initialization + /** + * Wrapper around an IUniversalFlagConfig payload + */ + readonly config?: IConfigResponse; + + /** + * Wrapper around an IBanditParametersResponse payload. + */ + readonly bandits?: IConfigResponse; + readonly precomputed?: IPrecomputedConfiguration; } +// These response types are stringified in the wire format. +type UfcResponseType = IUniversalFlagConfigResponse | IBanditParametersResponse; + +// The UFC responses are JSON-encoded strings so we can treat them as opaque blobs, but we also want to enforce type safety. +type ResponseString = string & { + readonly __brand: unique symbol; + readonly __type: T; +}; + +/** + * A wrapper around a server response that includes the response, etag, and fetchedAt timestamp. + */ +interface IConfigResponse { + readonly response: ResponseString; // JSON-encoded server response + readonly etag?: string; // Entity Tag - denotes a snapshot or version of the config. + readonly fetchedAt?: string; // ISO timestamp for when this config was fetched +} + +export function inflateResponse(response: ResponseString): T { + return JSON.parse(response) as T; +} + +export function deflateResponse(value: T): ResponseString { + return JSON.stringify(value) as ResponseString; +} + export class ConfigurationWireV1 implements IConfigurationWire { public readonly version = 1; - constructor(readonly precomputed?: IPrecomputedConfiguration) {} + + private constructor( + readonly precomputed?: IPrecomputedConfiguration, + readonly config?: IConfigResponse, + readonly bandits?: IConfigResponse, + ) {} + + public static fromResponses( + flagConfig: IUniversalFlagConfigResponse, + banditConfig?: IBanditParametersResponse, + flagConfigEtag?: string, + banditConfigEtag?: string, + ): ConfigurationWireV1 { + return new ConfigurationWireV1( + undefined, + { + response: deflateResponse(flagConfig), + fetchedAt: new Date().toISOString(), + etag: flagConfigEtag, + }, + banditConfig + ? { + response: deflateResponse(banditConfig), + fetchedAt: new Date().toISOString(), + etag: banditConfigEtag, + } + : undefined, + ); + } + + public static precomputed(precomputedConfig: IPrecomputedConfiguration) { + return new ConfigurationWireV1(precomputedConfig); + } + + static empty() { + return new ConfigurationWireV1(); + } }