Skip to content

chore:refactor JSON utils, add configurationFromString method for exporting #255

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 10, 2025
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 Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ help: Makefile
testDataDir := test/data/
tempDir := ${testDataDir}temp/
gitDataDir := ${tempDir}sdk-test-data/
branchName := tp/bootstrap-config
branchName := main
githubRepoLink := https://github.com/Eppo-exp/sdk-test-data.git
repoName := sdk-test-data
.PHONY: test-data
Expand Down
8 changes: 4 additions & 4 deletions src/client/eppo-client-with-bandits.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
BanditTestCase,
BANDIT_TEST_DATA_DIR,
readMockConfigurationWireResponse,
SHARED_BOOTSTRAP_BANDIT_FLAGS_FILE,
BANDITS_WIRE_FILE,
} from '../../test/testHelpers';
import ApiEndpoints from '../api-endpoints';
import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger';
Expand All @@ -21,7 +21,7 @@ import {
IConfigurationWire,
IPrecomputedConfiguration,
IObfuscatedPrecomputedConfigurationResponse,
ConfigurationWireV1,
configurationFromString,
} from '../configuration-wire/configuration-wire-types';
import { Evaluator, FlagEvaluation } from '../evaluator';
import {
Expand Down Expand Up @@ -137,8 +137,8 @@ describe('EppoClient Bandits E2E test', () => {
}

describe('bootstrapped client', () => {
const banditFlagsConfig = ConfigurationWireV1.fromString(
readMockConfigurationWireResponse(SHARED_BOOTSTRAP_BANDIT_FLAGS_FILE),
const banditFlagsConfig = configurationFromString(
readMockConfigurationWireResponse(BANDITS_WIRE_FILE),
);

let client: EppoClient;
Expand Down
16 changes: 7 additions & 9 deletions src/client/eppo-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import {
OBFUSCATED_MOCK_UFC_RESPONSE_FILE,
readMockConfigurationWireResponse,
readMockUFCResponse,
SHARED_BOOTSTRAP_FLAGS_FILE,
SHARED_BOOTSTRAP_FLAGS_OBFUSCATED_FILE,
FLAGS_WIRE_FILE,
OBFUSCATED_FLAGS_WIRE_FILE,
SubjectTestCase,
testCasesByFileName,
validateTestAssignments,
Expand All @@ -21,7 +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,
configurationFromString,
IConfigurationWire,
IObfuscatedPrecomputedConfigurationResponse,
ObfuscatedPrecomputedConfigurationResponse,
Expand Down Expand Up @@ -321,8 +321,6 @@ describe('EppoClient E2E test', () => {
});
});

const testCases = testCasesByFileName<IAssignmentTestCase>(ASSIGNMENT_TEST_DATA_DIR);

function testCasesAgainstClient(client: EppoClient, testCase: IAssignmentTestCase) {
const { flag, variationType, defaultValue, subjects } = testCase;

Expand Down Expand Up @@ -358,11 +356,11 @@ describe('EppoClient E2E test', () => {
const testCases = testCasesByFileName<IAssignmentTestCase>(ASSIGNMENT_TEST_DATA_DIR);

describe('boostrapped client', () => {
const bootstrapFlagsConfig = ConfigurationWireV1.fromString(
readMockConfigurationWireResponse(SHARED_BOOTSTRAP_FLAGS_FILE),
const bootstrapFlagsConfig = configurationFromString(
readMockConfigurationWireResponse(FLAGS_WIRE_FILE),
);
const bootstrapFlagsObfuscatedConfig = ConfigurationWireV1.fromString(
readMockConfigurationWireResponse(SHARED_BOOTSTRAP_FLAGS_OBFUSCATED_FILE),
const bootstrapFlagsObfuscatedConfig = configurationFromString(
readMockConfigurationWireResponse(OBFUSCATED_FLAGS_WIRE_FILE),
);

describe('Not obfuscated', () => {
Expand Down
6 changes: 3 additions & 3 deletions src/client/eppo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.stor
import {
ConfigurationWireV1,
IConfigurationWire,
inflateResponse,
IPrecomputedConfiguration,
PrecomputedConfiguration,
} from '../configuration-wire/configuration-wire-types';
import { inflateJsonObject } from '../configuration-wire/json-util';
import {
DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES,
DEFAULT_POLL_CONFIG_REQUEST_RETRIES,
Expand Down Expand Up @@ -348,11 +348,11 @@ export default class EppoClient {
if (!configuration.config) {
throw new Error('Flag configuration not provided');
}
const flagConfigResponse: IUniversalFlagConfigResponse = inflateResponse(
const flagConfigResponse: IUniversalFlagConfigResponse = inflateJsonObject(
configuration.config.response,
);
const banditParamResponse: IBanditParametersResponse | undefined = configuration.bandits
? inflateResponse(configuration.bandits.response)
? inflateJsonObject(configuration.bandits.response)
: undefined;

// We need to run this method sync, but, because the configuration stores potentially have an async write at the end
Expand Down
21 changes: 11 additions & 10 deletions src/configuration-wire/configuration-wire-types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
import { IUniversalFlagConfigResponse, IBanditParametersResponse } from '../http-client';
import { FormatEnum } from '../interfaces';

import { ConfigurationWireV1, deflateResponse, inflateResponse } from './configuration-wire-types';
import { ConfigurationWireV1 } from './configuration-wire-types';
import { deflateJsonObject, inflateJsonObject } from './json-util';

describe('Response String Type Safety', () => {
const mockFlagConfig: IUniversalFlagConfigResponse = readMockUFCResponse(
Expand All @@ -18,22 +19,22 @@ describe('Response String Type Safety', () => {

describe('deflateResponse and inflateResponse', () => {
it('should correctly serialize and deserialize flag config', () => {
const serialized = deflateResponse(mockFlagConfig);
const deserialized = inflateResponse(serialized);
const serialized = deflateJsonObject(mockFlagConfig);
const deserialized = inflateJsonObject(serialized);

expect(deserialized).toEqual(mockFlagConfig);
});

it('should correctly serialize and deserialize bandit config', () => {
const serialized = deflateResponse(mockBanditConfig);
const deserialized = inflateResponse(serialized);
const serialized = deflateJsonObject(mockBanditConfig);
const deserialized = inflateJsonObject(serialized);

expect(deserialized).toEqual(mockBanditConfig);
});

it('should maintain type information through serialization', () => {
const serialized = deflateResponse(mockFlagConfig);
const deserialized = inflateResponse(serialized);
const serialized = deflateJsonObject(mockFlagConfig);
const deserialized = inflateJsonObject(serialized);

// TypeScript compilation check: these should work
expect(deserialized.format).toBe(FormatEnum.SERVER);
Expand All @@ -54,7 +55,7 @@ describe('Response String Type Safety', () => {
if (!wirePacket.config) {
fail('Flag config not present in ConfigurationWire');
}
const deserializedConfig = inflateResponse(wirePacket.config.response);
const deserializedConfig = inflateJsonObject(wirePacket.config.response);
expect(deserializedConfig).toEqual(mockFlagConfig);
});

Expand All @@ -80,8 +81,8 @@ describe('Response String Type Safety', () => {
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);
const deserializedConfig = inflateJsonObject(wirePacket.config.response);
const deserializedBandits = inflateJsonObject(wirePacket.bandits.response);

expect(deserializedConfig).toEqual(mockFlagConfig);
expect(deserializedBandits).toEqual(mockBanditConfig);
Expand Down
39 changes: 19 additions & 20 deletions src/configuration-wire/configuration-wire-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IUniversalFlagConfigResponse, IBanditParametersResponse } from '../http-client';
import { IBanditParametersResponse, IUniversalFlagConfigResponse } from '../http-client';
import {
Environment,
FormatEnum,
Expand All @@ -9,6 +9,20 @@ import {
import { obfuscatePrecomputedBanditMap, obfuscatePrecomputedFlags } from '../obfuscation';
import { ContextAttributes, FlagKey, HashedFlagKey } from '../types';

import { deflateJsonObject, inflateJsonObject, JsonString } from './json-util';

/**
* Builds an `IConfigurationWire` instance from the payload string.
* To generate the payload string, see `ConfigurationWireHelper.fetchBootstrapConfiguration`.
*
* @param payloadString
*/
export function configurationFromString(
payloadString: string | JsonString<IConfigurationWire>,
): IConfigurationWire {
return inflateJsonObject(payloadString as JsonString<IConfigurationWire>);
}

// Base interface for all configuration responses
interface IBasePrecomputedConfigurationResponse {
readonly format: FormatEnum.PRECOMPUTED;
Expand Down Expand Up @@ -169,28 +183,17 @@ export interface IConfigurationWire {
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;
};
type UFCResponseString<T extends UfcResponseType> = JsonString<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 response: UFCResponseString<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;

Expand All @@ -200,10 +203,6 @@ export class ConfigurationWireV1 implements IConfigurationWire {
readonly bandits?: IConfigResponse<IBanditParametersResponse>,
) {}

public static fromString(stringifiedPayload: string): IConfigurationWire {
return JSON.parse(stringifiedPayload) as IConfigurationWire;
}

public static fromResponses(
flagConfig: IUniversalFlagConfigResponse,
banditConfig?: IBanditParametersResponse,
Expand All @@ -213,13 +212,13 @@ export class ConfigurationWireV1 implements IConfigurationWire {
return new ConfigurationWireV1(
undefined,
{
response: deflateResponse(flagConfig),
response: deflateJsonObject(flagConfig),
fetchedAt: new Date().toISOString(),
etag: flagConfigEtag,
},
banditConfig
? {
response: deflateResponse(banditConfig),
response: deflateJsonObject(banditConfig),
fetchedAt: new Date().toISOString(),
etag: banditConfigEtag,
}
Expand Down
12 changes: 12 additions & 0 deletions src/configuration-wire/json-util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type JsonString<T> = string & {
readonly __brand: unique symbol;
readonly __type: T;
};

export function inflateJsonObject<T>(response: JsonString<T>): T {
return JSON.parse(response) as T;
}

export function deflateJsonObject<T>(value: T): JsonString<T> {
return JSON.stringify(value) as JsonString<T>;
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@
ISyncStore,
} from './configuration-store/configuration-store';
import { HybridConfigurationStore } from './configuration-store/hybrid.store';
import { ConfigurationStoreBundle } from './configuration-store/i-configuration-manager';

Check failure on line 35 in src/index.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (18)

Cannot find module './configuration-store/i-configuration-manager' or its corresponding type declarations.

Check failure on line 35 in src/index.ts

View workflow job for this annotation

GitHub Actions / typecheck (18)

Cannot find module './configuration-store/i-configuration-manager' or its corresponding type declarations.

Check failure on line 35 in src/index.ts

View workflow job for this annotation

GitHub Actions / typecheck (20)

Cannot find module './configuration-store/i-configuration-manager' or its corresponding type declarations.

Check failure on line 35 in src/index.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (20)

Cannot find module './configuration-store/i-configuration-manager' or its corresponding type declarations.

Check failure on line 35 in src/index.ts

View workflow job for this annotation

GitHub Actions / typecheck (22)

Cannot find module './configuration-store/i-configuration-manager' or its corresponding type declarations.

Check failure on line 35 in src/index.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (22)

Cannot find module './configuration-store/i-configuration-manager' or its corresponding type declarations.

Check failure on line 35 in src/index.ts

View workflow job for this annotation

GitHub Actions / typecheck (23)

Cannot find module './configuration-store/i-configuration-manager' or its corresponding type declarations.

Check failure on line 35 in src/index.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (23)

Cannot find module './configuration-store/i-configuration-manager' or its corresponding type declarations.
import { MemoryStore, MemoryOnlyConfigurationStore } from './configuration-store/memory.store';
import { ConfigurationWireHelper } from './configuration-wire/configuration-wire-helper';
import {
configurationFromString,
IConfigurationWire,
IObfuscatedPrecomputedConfigurationResponse,
IPrecomputedConfigurationResponse,
Expand Down Expand Up @@ -159,6 +160,7 @@
PrecomputedFlag,
FlagKey,
ConfigurationWireHelper,
configurationFromString,

// Test helpers
decodePrecomputedFlag,
Expand Down
6 changes: 3 additions & 3 deletions test/testHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +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 const FLAGS_WIRE_FILE = 'flags-v1.json';
export const OBFUSCATED_FLAGS_WIRE_FILE = 'flags-v1-obfuscated.json';
export const BANDITS_WIRE_FILE = 'bandit-flags-v1.json';

export interface SubjectTestCase {
subjectKey: string;
Expand Down
Loading