Skip to content

Commit 93fcdef

Browse files
committed
feat: IConfiguration definitions for UFC and type safety
1 parent a7d05c7 commit 93fcdef

File tree

4 files changed

+186
-4
lines changed

4 files changed

+186
-4
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk-common",
3-
"version": "4.13.3",
3+
"version": "4.14.0",
44
"description": "Common library for Eppo JavaScript SDKs (web, react native, and node)",
55
"main": "dist/index.js",
66
"files": [

src/client/eppo-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1038,7 +1038,7 @@ export default class EppoClient {
10381038
configDetails.configEnvironment,
10391039
);
10401040

1041-
const configWire: IConfigurationWire = new ConfigurationWireV1(precomputedConfig);
1041+
const configWire: IConfigurationWire = ConfigurationWireV1.precomputed(precomputedConfig);
10421042
return JSON.stringify(configWire);
10431043
}
10441044

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {
2+
MOCK_BANDIT_MODELS_RESPONSE_FILE,
3+
MOCK_FLAGS_WITH_BANDITS_RESPONSE_FILE,
4+
readMockUFCResponse,
5+
} from '../test/testHelpers';
6+
7+
import { ConfigurationWireV1, deflateResponse, inflateResponse } from './configuration-wire-types';
8+
import { IUniversalFlagConfigResponse, IBanditParametersResponse } from './http-client';
9+
import { FormatEnum } from './interfaces';
10+
11+
describe('Response String Type Safety', () => {
12+
const mockFlagConfig: IUniversalFlagConfigResponse = readMockUFCResponse(
13+
MOCK_FLAGS_WITH_BANDITS_RESPONSE_FILE,
14+
) as IUniversalFlagConfigResponse;
15+
const mockBanditConfig: IBanditParametersResponse = readMockUFCResponse(
16+
MOCK_BANDIT_MODELS_RESPONSE_FILE,
17+
) as IBanditParametersResponse;
18+
19+
describe('deflateResponse and inflateResponse', () => {
20+
it('should correctly serialize and deserialize flag config', () => {
21+
const serialized = deflateResponse(mockFlagConfig);
22+
const deserialized = inflateResponse(serialized);
23+
24+
expect(deserialized).toEqual(mockFlagConfig);
25+
});
26+
27+
it('should correctly serialize and deserialize bandit config', () => {
28+
const serialized = deflateResponse(mockBanditConfig);
29+
const deserialized = inflateResponse(serialized);
30+
31+
expect(deserialized).toEqual(mockBanditConfig);
32+
});
33+
34+
it('should maintain type information through serialization', () => {
35+
const serialized = deflateResponse(mockFlagConfig);
36+
const deserialized = inflateResponse(serialized);
37+
38+
// TypeScript compilation check: these should work
39+
expect(deserialized.format).toBe(FormatEnum.SERVER);
40+
expect(deserialized.environment).toStrictEqual({ name: 'Test' });
41+
});
42+
});
43+
44+
describe('ConfigurationWireV1', () => {
45+
it('should create configuration with flag config', () => {
46+
const wirePacket = ConfigurationWireV1.fromResponses(mockFlagConfig);
47+
48+
expect(wirePacket.version).toBe(1);
49+
expect(wirePacket.config).toBeDefined();
50+
expect(wirePacket.bandits).toBeUndefined();
51+
52+
// Verify we can deserialize the response
53+
expect(wirePacket.config).toBeTruthy();
54+
if (!wirePacket.config) {
55+
fail('Flag config not present in ConfigurationWire');
56+
}
57+
const deserializedConfig = inflateResponse(wirePacket.config.response);
58+
expect(deserializedConfig).toEqual(mockFlagConfig);
59+
});
60+
61+
it('should create configuration with both flag and bandit configs', () => {
62+
const wirePacket = ConfigurationWireV1.fromResponses(
63+
mockFlagConfig,
64+
mockBanditConfig,
65+
'flag-etag',
66+
'bandit-etag',
67+
);
68+
69+
if (!wirePacket.config) {
70+
fail('Flag config not present in ConfigurationWire');
71+
}
72+
if (!wirePacket.bandits) {
73+
fail('Bandit Model Parameters not present in ConfigurationWire');
74+
}
75+
76+
expect(wirePacket.version).toBe(1);
77+
expect(wirePacket.config).toBeDefined();
78+
expect(wirePacket.bandits).toBeDefined();
79+
expect(wirePacket.config.etag).toBe('flag-etag');
80+
expect(wirePacket.bandits.etag).toBe('bandit-etag');
81+
82+
// Verify we can deserialize both responses
83+
const deserializedConfig = inflateResponse(wirePacket.config.response);
84+
const deserializedBandits = inflateResponse(wirePacket.bandits.response);
85+
86+
expect(deserializedConfig).toEqual(mockFlagConfig);
87+
expect(deserializedBandits).toEqual(mockBanditConfig);
88+
});
89+
90+
it('should create empty configuration', () => {
91+
const config = ConfigurationWireV1.empty();
92+
93+
expect(config.version).toBe(1);
94+
expect(config.config).toBeUndefined();
95+
expect(config.bandits).toBeUndefined();
96+
expect(config.precomputed).toBeUndefined();
97+
});
98+
99+
it('should include fetchedAt timestamps', () => {
100+
const wirePacket = ConfigurationWireV1.fromResponses(mockFlagConfig, mockBanditConfig);
101+
102+
if (!wirePacket.config) {
103+
fail('Flag config not present in ConfigurationWire');
104+
}
105+
if (!wirePacket.bandits) {
106+
fail('Bandit Model Parameters not present in ConfigurationWire');
107+
}
108+
expect(wirePacket.config.fetchedAt).toBeDefined();
109+
expect(Date.parse(wirePacket.config.fetchedAt ?? '')).not.toBeNaN();
110+
expect(Date.parse(wirePacket.bandits.fetchedAt ?? '')).not.toBeNaN();
111+
});
112+
});
113+
});

src/configuration-wire-types.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { IUniversalFlagConfigResponse, IBanditParametersResponse } from './http-client';
12
import {
23
Environment,
34
FormatEnum,
@@ -151,11 +152,79 @@ export interface IConfigurationWire {
151152
*/
152153
readonly version: number;
153154

154-
// TODO: Add flags and bandits for offline/non-precomputed initialization
155+
/**
156+
* Wrapper around an IUniversalFlagConfig payload
157+
*/
158+
readonly config?: IConfigResponse<IUniversalFlagConfigResponse>;
159+
160+
/**
161+
* Wrapper around an IBanditParametersResponse payload.
162+
*/
163+
readonly bandits?: IConfigResponse<IBanditParametersResponse>;
164+
155165
readonly precomputed?: IPrecomputedConfiguration;
156166
}
157167

168+
// We treat these two responses as a "whole" configuration - that is, the set of data required to compute flags and bandits.
169+
type UfcResponseType = IUniversalFlagConfigResponse | IBanditParametersResponse;
170+
171+
// The UFC responses are JSON-encoded strings so we can treat them as opaque blobs, but we also want to enforce type safety.
172+
type ResponseString<T extends UfcResponseType> = string & {
173+
readonly __brand: unique symbol;
174+
readonly __type: T;
175+
};
176+
177+
export function inflateResponse<T extends UfcResponseType>(response: ResponseString<T>): T {
178+
return JSON.parse(response) as T;
179+
}
180+
181+
export function deflateResponse<T extends UfcResponseType>(value: T): ResponseString<T> {
182+
return JSON.stringify(value) as ResponseString<T>;
183+
}
184+
185+
interface IConfigResponse<T extends UfcResponseType> {
186+
readonly response: ResponseString<T>; // JSON-encoded server response
187+
readonly etag?: string; // Entity Tag - denotes a snapshot or version of the config.
188+
readonly fetchedAt?: string; // ISO timestamp for when this config was fetched
189+
}
190+
158191
export class ConfigurationWireV1 implements IConfigurationWire {
159192
public readonly version = 1;
160-
constructor(readonly precomputed?: IPrecomputedConfiguration) {}
193+
194+
private constructor(
195+
readonly precomputed?: IPrecomputedConfiguration,
196+
readonly config?: IConfigResponse<IUniversalFlagConfigResponse>,
197+
readonly bandits?: IConfigResponse<IBanditParametersResponse>,
198+
) {}
199+
200+
public static fromResponses(
201+
flagConfig: IUniversalFlagConfigResponse,
202+
banditConfig?: IBanditParametersResponse,
203+
flagConfigEtag?: string,
204+
banditConfigEtag?: string,
205+
): ConfigurationWireV1 {
206+
return new ConfigurationWireV1(
207+
undefined,
208+
{
209+
response: deflateResponse(flagConfig),
210+
fetchedAt: new Date().toISOString(),
211+
etag: flagConfigEtag,
212+
},
213+
banditConfig
214+
? {
215+
response: deflateResponse(banditConfig),
216+
fetchedAt: new Date().toISOString(),
217+
etag: banditConfigEtag,
218+
}
219+
: undefined,
220+
);
221+
}
222+
223+
public static precomputed(precomputedConfig: IPrecomputedConfiguration) {
224+
return new ConfigurationWireV1(precomputedConfig);
225+
}
226+
227+
static empty() {
228+
return new ConfigurationWireV1();
229+
}
161230
}

0 commit comments

Comments
 (0)