Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/client/eppo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
113 changes: 113 additions & 0 deletions src/configuration-wire-types.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
76 changes: 74 additions & 2 deletions src/configuration-wire-types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { IUniversalFlagConfigResponse, IBanditParametersResponse } from './http-client';
import {
Environment,
FormatEnum,
Expand Down Expand Up @@ -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<IUniversalFlagConfigResponse>;

/**
* Wrapper around an IBanditParametersResponse payload.
*/
readonly bandits?: IConfigResponse<IBanditParametersResponse>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥


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<T extends UfcResponseType> = string & {
readonly __brand: unique symbol;
readonly __type: T;
};

/**
* A wrapper around a server response that includes the response, etag, and fetchedAt timestamp.
*/
interface IConfigResponse<T extends UfcResponseType> {
readonly response: ResponseString<T>; // 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<T extends UfcResponseType>(response: ResponseString<T>): T {
return JSON.parse(response) as T;
}

export function deflateResponse<T extends UfcResponseType>(value: T): ResponseString<T> {
return JSON.stringify(value) as ResponseString<T>;
}

export class ConfigurationWireV1 implements IConfigurationWire {
public readonly version = 1;
constructor(readonly precomputed?: IPrecomputedConfiguration) {}

private constructor(
readonly precomputed?: IPrecomputedConfiguration,
readonly config?: IConfigResponse<IUniversalFlagConfigResponse>,
readonly bandits?: IConfigResponse<IBanditParametersResponse>,
) {}

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();
}
}
Loading