Skip to content

Commit

Permalink
feat: server overrides
Browse files Browse the repository at this point in the history
  • Loading branch information
greghuels committed Mar 3, 2025
1 parent 4679cfd commit f6b53f7
Show file tree
Hide file tree
Showing 4 changed files with 251 additions and 0 deletions.
141 changes: 141 additions & 0 deletions src/client/eppo-client-with-overrides.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
import { Flag, FormatEnum, ObfuscatedFlag, VariationType } from '../interfaces';
import * as overrideValidatorModule from '../override-validator';

import EppoClient from './eppo-client';

describe('EppoClient', () => {
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();

function setUnobfuscatedFlagEntries(
entries: Record<string, Flag | ObfuscatedFlag>,
): Promise<boolean> {
storage.setFormat(FormatEnum.SERVER);
return storage.setEntries(entries);
}

const flagKey = 'mock-flag';

const variationA = {
key: 'a',
value: 'variation-a',
};

const variationB = {
key: 'b',
value: 'variation-b',
};

const mockFlag: Flag = {
key: flagKey,
enabled: true,
variationType: VariationType.STRING,
variations: { a: variationA, b: variationB },
allocations: [
{
key: 'allocation-a',
rules: [],
splits: [
{
shards: [],
variationKey: 'a',
},
],
doLog: true,
},
],
totalShards: 10000,
};

let client: EppoClient;
let subjectKey: string;

beforeEach(async () => {
await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag });
subjectKey = 'subject-10';
client = new EppoClient({ flagConfigurationStore: storage });
});

describe('parseOverrides', () => {
it('should parse a valid payload', async () => {
jest.spyOn(overrideValidatorModule, 'sendValidationRequest').mockResolvedValue(undefined);
const result = await client.parseOverrides(
JSON.stringify({
browserExtensionKey: 'my-key',
overrides: { [flagKey]: variationB },
}),
);
expect(result).toEqual({ [flagKey]: variationB });
});

it('should throw an error if the key is missing', async () => {
jest.spyOn(overrideValidatorModule, 'sendValidationRequest').mockResolvedValue(undefined);
expect(() =>
client.parseOverrides(
JSON.stringify({
overrides: { [flagKey]: variationB },
}),
),
).rejects.toThrow();
});

it('should throw an error if the key is not a string', async () => {
jest.spyOn(overrideValidatorModule, 'sendValidationRequest').mockResolvedValue(undefined);
expect(() =>
client.parseOverrides(
JSON.stringify({
browserExtensionKey: 123,
overrides: { [flagKey]: variationB },
}),
),
).rejects.toThrow();
});

it('should throw an error if the overrides are not parseable', async () => {
jest.spyOn(overrideValidatorModule, 'sendValidationRequest').mockResolvedValue(undefined);
expect(() =>
client.parseOverrides(`{
browserExtensionKey: 'my-key',
overrides: { [flagKey]: ,
}`),
).rejects.toThrow();
});

it('should throw an error if overrides is not an object', async () => {
jest.spyOn(overrideValidatorModule, 'sendValidationRequest').mockResolvedValue(undefined);
expect(() =>
client.parseOverrides(
JSON.stringify({
browserExtensionKey: 'my-key',
overrides: false,
}),
),
).rejects.toThrow();
});

it('should throw an error if an invalid key is supplied', async () => {
jest.spyOn(overrideValidatorModule, 'sendValidationRequest').mockImplementation(async () => {
throw new Error(`Unable to authorize key`);
});
expect(() =>
client.parseOverrides(
JSON.stringify({
browserExtensionKey: 'my-key',
overrides: { [flagKey]: variationB },
}),
),
).rejects.toThrow();
});
});

describe('withOverrides', () => {
it('should create a new instance of EppoClient with specified overrides without affecting the original instance', () => {
const clientWithOverrides = client.withOverrides({ [flagKey]: variationB });

expect(client.getStringAssignment(flagKey, subjectKey, {}, 'default')).toBe('variation-a');
expect(clientWithOverrides.getStringAssignment(flagKey, subjectKey, {}, 'default')).toBe(
'variation-b',
);
});
});
});
33 changes: 33 additions & 0 deletions src/client/eppo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-mem
import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment-cache';
import ConfigurationRequestor from '../configuration-requestor';
import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store';
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
import {
ConfigurationWireV1,
IConfigurationWire,
Expand Down Expand Up @@ -53,6 +54,7 @@ import {
VariationType,
} from '../interfaces';
import { getMD5Hash } from '../obfuscation';
import { OverridePayload, OverrideValidator } from '../override-validator';
import initPoller, { IPoller } from '../poller';
import {
Attributes,
Expand All @@ -63,6 +65,7 @@ import {
FlagKey,
ValueType,
} from '../types';
import { shallowClone } from '../util';
import { validateNotBlank } from '../validation';
import { LIB_VERSION } from '../version';

Expand Down Expand Up @@ -134,6 +137,7 @@ export default class EppoClient {
private requestPoller?: IPoller;
private readonly evaluator = new Evaluator();
private configurationRequestor?: ConfigurationRequestor;
private readonly overrideValidator = new OverrideValidator();

constructor({
eventDispatcher = new NoOpEventDispatcher(),
Expand Down Expand Up @@ -199,6 +203,35 @@ export default class EppoClient {
return this.configObfuscatedCache;
}

/**
* Validates and parses x-eppo-overrides header sent by Eppo's Chrome extension
*/
async parseOverrides(
overridePayload: string | undefined,
): Promise<Record<FlagKey, Variation> | undefined> {
if (!overridePayload) {
return undefined;
}
const payload: OverridePayload = this.overrideValidator.parseOverridePayload(overridePayload);
await this.overrideValidator.validateOverrideApiKey(payload.browserExtensionKey);
return payload.overrides;
}

/**
* Creates an EppoClient instance that has the specified overrides applied
* to it without affecting the original EppoClient singleton. Useful for
* applying overrides in a shared Node instance, such as a web server.
*/
withOverrides(overrides: Record<FlagKey, Variation>): EppoClient {
if (overrides && Object.keys(overrides).length) {
const copy = shallowClone(this);
copy.overrideStore = new MemoryOnlyConfigurationStore<Variation>();
copy.overrideStore.setEntries(overrides);
return copy;
}
return this;
}

setConfigurationRequestParameters(
configurationRequestParameters: FlagConfigurationRequestParameters,
) {
Expand Down
73 changes: 73 additions & 0 deletions src/override-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { TLRUCache } from './cache/tlru-cache';
import { Variation } from './interfaces';
import { FlagKey } from './types';

const FIVE_MINUTES_IN_MS = 5 * 3600 * 1000;
const KEY_VALIDATION_URL = 'https://eppo.cloud/api/flag-overrides/v1/validate-key';

export interface OverridePayload {
browserExtensionKey: string;
overrides: Record<FlagKey, Variation>;
}

export const sendValidationRequest = async (browserExtensionKey: string) => {
const response = await fetch(KEY_VALIDATION_URL, {
method: 'POST',
body: JSON.stringify({
key: browserExtensionKey,
}),
headers: {
'Content-Type': 'application/json',
},
});
if (response.status !== 200) {
throw new Error(`Unable to authorize key: ${response.statusText}`);
}
};

export class OverrideValidator {
private validApiKeyCache = new TLRUCache(100, FIVE_MINUTES_IN_MS);

parseOverridePayload(overridePayload: string): OverridePayload {
const errorMsg = (msg: string) => `Unable to parse overridePayload: ${msg}`;
try {
const parsed = JSON.parse(overridePayload);
this.validateParsedOverridePayload(parsed);
return parsed as OverridePayload;
} catch (err: unknown) {
const message: string = (err as any)?.message ?? 'unknown error';
throw new Error(errorMsg(message));
}
}

private validateParsedOverridePayload(parsed: any) {
if (typeof parsed !== 'object') {
throw new Error(`Expected object, but received ${typeof parsed}`);
}
const keys = Object.keys(parsed);
if (!keys.includes('browserExtensionKey')) {
throw new Error(`Missing required field: 'browserExtensionKey'`);
}
if (!keys.includes('overrides')) {
throw new Error(`Missing required field: 'overrides'`);
}
if (typeof parsed['browserExtensionKey'] !== 'string') {
throw new Error(
`Invalid type for 'browserExtensionKey'. Expected string, but received ${typeof parsed['browserExtensionKey']}`,
);
}
if (typeof parsed['overrides'] !== 'object') {
throw new Error(
`Invalid type for 'overrides'. Expected object, but received ${typeof parsed['overrides']}.`,
);
}
}

async validateOverrideApiKey(overrideApiKey: string) {
if (this.validApiKeyCache.get(overrideApiKey) === 'true') {
return true;
}
await sendValidationRequest(overrideApiKey);
this.validApiKeyCache.set(overrideApiKey, 'true');
}
}
4 changes: 4 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@
export async function waitForMs(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}

export function shallowClone<T>(original: T): T {
return Object.assign(Object.create(Object.getPrototypeOf(original)), original);
}

0 comments on commit f6b53f7

Please sign in to comment.