Skip to content

Commit 114151d

Browse files
committed
feat: server overrides
1 parent 4679cfd commit 114151d

File tree

4 files changed

+237
-0
lines changed

4 files changed

+237
-0
lines changed
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
2+
import { Flag, FormatEnum, ObfuscatedFlag, VariationType } from '../interfaces';
3+
import * as overrideValidatorModule from '../override-validator';
4+
5+
import EppoClient from './eppo-client';
6+
7+
describe('EppoClient', () => {
8+
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
9+
10+
function setUnobfuscatedFlagEntries(
11+
entries: Record<string, Flag | ObfuscatedFlag>,
12+
): Promise<boolean> {
13+
storage.setFormat(FormatEnum.SERVER);
14+
return storage.setEntries(entries);
15+
}
16+
17+
const flagKey = 'mock-flag';
18+
19+
const variationA = {
20+
key: 'a',
21+
value: 'variation-a',
22+
};
23+
24+
const variationB = {
25+
key: 'b',
26+
value: 'variation-b',
27+
};
28+
29+
const mockFlag: Flag = {
30+
key: flagKey,
31+
enabled: true,
32+
variationType: VariationType.STRING,
33+
variations: { a: variationA, b: variationB },
34+
allocations: [
35+
{
36+
key: 'allocation-a',
37+
rules: [],
38+
splits: [
39+
{
40+
shards: [],
41+
variationKey: 'a',
42+
},
43+
],
44+
doLog: true,
45+
},
46+
],
47+
totalShards: 10000,
48+
};
49+
50+
let client: EppoClient;
51+
let subjectKey: string;
52+
53+
beforeEach(async () => {
54+
await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag });
55+
subjectKey = 'subject-10';
56+
client = new EppoClient({ flagConfigurationStore: storage });
57+
});
58+
59+
describe('parseOverrides', () => {
60+
it('should parse a valid payload', async () => {
61+
jest.spyOn(overrideValidatorModule, 'sendValidationRequest').mockResolvedValue(undefined);
62+
const result = await client.parseOverrides(
63+
JSON.stringify({
64+
browserExtensionKey: 'my-key',
65+
overrides: { [flagKey]: variationB },
66+
}),
67+
);
68+
expect(result).toEqual({ [flagKey]: variationB });
69+
});
70+
71+
it('should throw an error if the key is missing', async () => {
72+
jest.spyOn(overrideValidatorModule, 'sendValidationRequest').mockResolvedValue(undefined);
73+
expect(() =>
74+
client.parseOverrides(
75+
JSON.stringify({
76+
overrides: { [flagKey]: variationB },
77+
}),
78+
),
79+
).rejects.toThrow();
80+
});
81+
82+
it('should throw an error if the key is not a string', async () => {
83+
jest.spyOn(overrideValidatorModule, 'sendValidationRequest').mockResolvedValue(undefined);
84+
expect(() =>
85+
client.parseOverrides(
86+
JSON.stringify({
87+
browserExtensionKey: 123,
88+
overrides: { [flagKey]: variationB },
89+
}),
90+
),
91+
).rejects.toThrow();
92+
});
93+
94+
it('should throw an error if the overrides are not parseable', async () => {
95+
jest.spyOn(overrideValidatorModule, 'sendValidationRequest').mockResolvedValue(undefined);
96+
expect(() =>
97+
client.parseOverrides(`{
98+
browserExtensionKey: 'my-key',
99+
overrides: { [flagKey]: ,
100+
}`),
101+
).rejects.toThrow();
102+
});
103+
104+
it('should throw an error if overrides is not an object', async () => {
105+
jest.spyOn(overrideValidatorModule, 'sendValidationRequest').mockResolvedValue(undefined);
106+
expect(() =>
107+
client.parseOverrides(
108+
JSON.stringify({
109+
browserExtensionKey: 'my-key',
110+
overrides: false,
111+
}),
112+
),
113+
).rejects.toThrow();
114+
});
115+
});
116+
117+
describe('withOverrides', () => {
118+
it('should create a new instance of EppoClient with specified overrides without affecting the original instance', () => {
119+
const clientWithOverrides = client.withOverrides({ [flagKey]: variationB });
120+
121+
expect(client.getStringAssignment(flagKey, subjectKey, {}, 'default')).toBe('variation-a');
122+
expect(clientWithOverrides.getStringAssignment(flagKey, subjectKey, {}, 'default')).toBe(
123+
'variation-b',
124+
);
125+
});
126+
});
127+
});

src/client/eppo-client.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-mem
1616
import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment-cache';
1717
import ConfigurationRequestor from '../configuration-requestor';
1818
import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store';
19+
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
1920
import {
2021
ConfigurationWireV1,
2122
IConfigurationWire,
@@ -53,6 +54,7 @@ import {
5354
VariationType,
5455
} from '../interfaces';
5556
import { getMD5Hash } from '../obfuscation';
57+
import { OverridePayload, OverrideValidator } from '../override-validator';
5658
import initPoller, { IPoller } from '../poller';
5759
import {
5860
Attributes,
@@ -63,6 +65,7 @@ import {
6365
FlagKey,
6466
ValueType,
6567
} from '../types';
68+
import { shallowClone } from '../util';
6669
import { validateNotBlank } from '../validation';
6770
import { LIB_VERSION } from '../version';
6871

@@ -134,6 +137,7 @@ export default class EppoClient {
134137
private requestPoller?: IPoller;
135138
private readonly evaluator = new Evaluator();
136139
private configurationRequestor?: ConfigurationRequestor;
140+
private readonly overrideValidator = new OverrideValidator();
137141

138142
constructor({
139143
eventDispatcher = new NoOpEventDispatcher(),
@@ -199,6 +203,35 @@ export default class EppoClient {
199203
return this.configObfuscatedCache;
200204
}
201205

206+
/**
207+
* Validates and parses x-eppo-overrides header sent by Eppo's Chrome extension
208+
*/
209+
async parseOverrides(
210+
overridePayload: string | undefined,
211+
): Promise<Record<FlagKey, Variation> | undefined> {
212+
if (!overridePayload) {
213+
return undefined;
214+
}
215+
const payload: OverridePayload = this.overrideValidator.parseOverridePayload(overridePayload);
216+
await this.overrideValidator.validateOverrideApiKey(payload.browserExtensionKey);
217+
return payload.overrides;
218+
}
219+
220+
/**
221+
* Creates an EppoClient instance that has the specified overrides applied
222+
* to it without affecting the original EppoClient singleton. Useful for
223+
* applying overrides in a shared Node instance, such as a web server.
224+
*/
225+
withOverrides(overrides: Record<FlagKey, Variation>): EppoClient {
226+
if (overrides && Object.keys(overrides).length) {
227+
const copy = shallowClone(this);
228+
copy.overrideStore = new MemoryOnlyConfigurationStore<Variation>();
229+
copy.overrideStore.setEntries(overrides);
230+
return copy;
231+
}
232+
return this;
233+
}
234+
202235
setConfigurationRequestParameters(
203236
configurationRequestParameters: FlagConfigurationRequestParameters,
204237
) {

src/override-validator.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { TLRUCache } from './cache/tlru-cache';
2+
import { Variation } from './interfaces';
3+
import { FlagKey } from './types';
4+
5+
const FIVE_MINUTES_IN_MS = 5 * 3600 * 1000;
6+
const KEY_VALIDATION_URL = 'https://eppo.cloud/api/flag-overrides/v1/validate-key';
7+
8+
export interface OverridePayload {
9+
browserExtensionKey: string;
10+
overrides: Record<FlagKey, Variation>;
11+
}
12+
13+
export const sendValidationRequest = async (browserExtensionKey: string) => {
14+
const response = await fetch(KEY_VALIDATION_URL, {
15+
method: 'POST',
16+
body: JSON.stringify({
17+
key: browserExtensionKey,
18+
}),
19+
headers: {
20+
'Content-Type': 'application/json',
21+
},
22+
});
23+
if (response.status !== 200) {
24+
throw new Error(`Unable to authorize API token: ${response.statusText}`);
25+
}
26+
};
27+
28+
export class OverrideValidator {
29+
private validApiKeyCache = new TLRUCache(100, FIVE_MINUTES_IN_MS);
30+
31+
parseOverridePayload(overridePayload: string): OverridePayload {
32+
const errorMsg = (msg: string) => `Unable to parse overridePayload: ${msg}`;
33+
try {
34+
const parsed = JSON.parse(overridePayload);
35+
this.validateParsedOverridePayload(parsed);
36+
return parsed as OverridePayload;
37+
} catch (err: unknown) {
38+
const message: string = (err as any)?.message ?? 'unknown error';
39+
throw new Error(errorMsg(message));
40+
}
41+
}
42+
43+
private validateParsedOverridePayload(parsed: any) {
44+
if (typeof parsed !== 'object') {
45+
throw new Error(`Expected object, but received ${typeof parsed}`);
46+
}
47+
const keys = Object.keys(parsed);
48+
if (!keys.includes('browserExtensionKey')) {
49+
throw new Error(`Missing required field: 'browserExtensionKey'`);
50+
}
51+
if (!keys.includes('overrides')) {
52+
throw new Error(`Missing required field: 'overrides'`);
53+
}
54+
if (typeof parsed['browserExtensionKey'] !== 'string') {
55+
throw new Error(
56+
`Invalid type for 'browserExtensionKey'. Expected string, but received ${typeof parsed['browserExtensionKey']}`,
57+
);
58+
}
59+
if (typeof parsed['overrides'] !== 'object') {
60+
throw new Error(
61+
`Invalid type for 'overrides'. Expected object, but received ${typeof parsed['overrides']}.`,
62+
);
63+
}
64+
}
65+
66+
async validateOverrideApiKey(overrideApiKey: string) {
67+
if (this.validApiKeyCache.get(overrideApiKey) === 'true') {
68+
return true;
69+
}
70+
await sendValidationRequest(overrideApiKey);
71+
this.validApiKeyCache.set(overrideApiKey, 'true');
72+
}
73+
}

src/util.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,7 @@
22
export async function waitForMs(ms: number) {
33
await new Promise((resolve) => setTimeout(resolve, ms));
44
}
5+
6+
export function shallowClone<T>(original: T): T {
7+
return Object.assign(Object.create(Object.getPrototypeOf(original)), original);
8+
}

0 commit comments

Comments
 (0)