-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Utility class to produce IConfigurationWire
instances
#241
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
Changes from 8 commits
93fcdef
7290e3a
7fc76b8
015c88a
b586bcc
4520fd2
db26eb1
4981b15
110a983
8bb9e72
4701d11
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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', () => { | ||
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(); | ||
}); | ||
}); | ||
}); |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' }, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> { | ||
typotter marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
// 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()); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. minor: given the function is
|
||
} | ||
|
||
const flagsHaveBandits = Object.keys(configResponse.banditReferences ?? {}).length > 0; | ||
if (flagsHaveBandits) { | ||
banditResponse = await this.httpClient.getBanditParameters(); | ||
} | ||
|
||
return ConfigurationWireV1.fromResponses(configResponse, banditResponse); | ||
} | ||
} |
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(); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🙌