Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@eppo/js-client-sdk-common",
"version": "4.13.3",
"version": "4.14.0",
"description": "Common library for Eppo JavaScript SDKs (web, react native, and node)",
"main": "dist/index.js",
"files": [
Expand Down
2 changes: 1 addition & 1 deletion src/client/eppo-client-with-bandits.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/client/eppo-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions src/client/eppo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down 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
2 changes: 1 addition & 1 deletion src/client/eppo-precomputed-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
98 changes: 98 additions & 0 deletions src/configuration-wire/configuration-wire-helper.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

🙌

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.getBootstrapConfigurationFromApi();

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.getBootstrapConfigurationFromApi();

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.getBootstrapConfigurationFromApi();

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();
});
});
});
75 changes: 75 additions & 0 deletions src/configuration-wire/configuration-wire-helper.ts
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This looks extremely similar to configuration fetcher. Is there a significant difference? can this be just a new configuration fetcher?

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' },
Copy link
Collaborator

Choose a reason for hiding this comment

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

is there any reason we're choosing these values for defaults? They seem extremely off from being default

) {
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);
}

/**
* Builds an `IConfigurationWire` object from flag and bandit API responses.
* The IConfigurationWire instance can be used to bootstrap some SDKs.
*/
public async getBootstrapConfigurationFromApi(): Promise<IConfigurationWire> {
// 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());
Copy link
Collaborator

Choose a reason for hiding this comment

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

minor: given the function is async, I'm pretty sure you can just do this here:

return ConfigurationWireV1.empty();

}

const flagsHaveBandits = Object.keys(configResponse.banditReferences ?? {}).length > 0;
if (flagsHaveBandits) {
banditResponse = await this.httpClient.getBanditParameters();
}

return ConfigurationWireV1.fromResponses(configResponse, banditResponse);
}
}
113 changes: 113 additions & 0 deletions src/configuration-wire/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 { IUniversalFlagConfigResponse, IBanditParametersResponse } from '../http-client';
import { FormatEnum } from '../interfaces';

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

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