Skip to content

Commit 9ec6076

Browse files
committed
feat: server overrides
1 parent bb662d6 commit 9ec6076

File tree

3 files changed

+107
-0
lines changed

3 files changed

+107
-0
lines changed

src/client/eppo-client.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from '../configuration';
2323
import ConfigurationRequestor from '../configuration-requestor';
2424
import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store';
25+
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
2526
import {
2627
DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES,
2728
DEFAULT_POLL_CONFIG_REQUEST_RETRIES,
@@ -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

@@ -125,6 +128,7 @@ export default class EppoClient {
125128
private isObfuscated: boolean;
126129
private requestPoller?: IPoller;
127130
private readonly evaluator = new Evaluator();
131+
private readonly overrideValidator = new OverrideValidator();
128132

129133
constructor({
130134
eventDispatcher = new NoOpEventDispatcher(),
@@ -154,6 +158,35 @@ export default class EppoClient {
154158
this.isObfuscated = isObfuscated;
155159
}
156160

161+
/**
162+
* Validates and parses x-eppo-overrides header sent by Eppo's Chrome extension
163+
*/
164+
async parseOverrides(
165+
overridePayload: string | undefined,
166+
): Promise<Record<FlagKey, Variation> | undefined> {
167+
if (!overridePayload) {
168+
return undefined;
169+
}
170+
const payload: OverridePayload = this.overrideValidator.parseOverridePayload(overridePayload);
171+
await this.overrideValidator.validateOverrideApiKey(payload.apiKey);
172+
return payload.overrides;
173+
}
174+
175+
/**
176+
* Creates an EppoClient instance that has the specified overrides applied
177+
* to it without affecting the original EppoClient singleton. Useful for
178+
* applying overrides in a shared Node instance, such as a web server.
179+
*/
180+
withOverrides(overrides: Record<FlagKey, Variation>): EppoClient {
181+
if (overrides && Object.keys(overrides).length) {
182+
const copy = shallowClone(this);
183+
copy.overrideStore = new MemoryOnlyConfigurationStore<Variation>();
184+
copy.overrideStore.setEntries(overrides);
185+
return copy;
186+
}
187+
return this;
188+
}
189+
157190
setConfigurationRequestParameters(
158191
configurationRequestParameters: FlagConfigurationRequestParameters,
159192
) {

src/override-validator.ts

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

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)