From c6f6837b26c4f030f1a7c27ab7e658a3fa4047c6 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Wed, 5 Feb 2025 06:18:06 -0600 Subject: [PATCH] feat: server overrides --- src/client/eppo-client-with-overrides.spec.ts | 141 ++++++++++++++++++ src/client/eppo-client.ts | 33 ++++ src/override-validator.ts | 73 +++++++++ src/util.ts | 4 + 4 files changed, 251 insertions(+) create mode 100644 src/client/eppo-client-with-overrides.spec.ts create mode 100644 src/override-validator.ts diff --git a/src/client/eppo-client-with-overrides.spec.ts b/src/client/eppo-client-with-overrides.spec.ts new file mode 100644 index 0000000..4e05cf5 --- /dev/null +++ b/src/client/eppo-client-with-overrides.spec.ts @@ -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(); + + function setUnobfuscatedFlagEntries( + entries: Record, + ): Promise { + 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', + ); + }); + }); +}); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 4541d6a..e54143b 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -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, @@ -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, @@ -63,6 +65,7 @@ import { FlagKey, ValueType, } from '../types'; +import { shallowClone } from '../util'; import { validateNotBlank } from '../validation'; import { LIB_VERSION } from '../version'; @@ -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(), @@ -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 | undefined> { + if (!overridePayload) { + return undefined; + } + const payload: OverridePayload = this.overrideValidator.parseOverridePayload(overridePayload); + await this.overrideValidator.validateKey(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): EppoClient { + if (overrides && Object.keys(overrides).length) { + const copy = shallowClone(this); + copy.overrideStore = new MemoryOnlyConfigurationStore(); + copy.overrideStore.setEntries(overrides); + return copy; + } + return this; + } + setConfigurationRequestParameters( configurationRequestParameters: FlagConfigurationRequestParameters, ) { diff --git a/src/override-validator.ts b/src/override-validator.ts new file mode 100644 index 0000000..c2a5986 --- /dev/null +++ b/src/override-validator.ts @@ -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; +} + +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 validKeyCache = 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 validateKey(browserExtensionKey: string) { + if (this.validKeyCache.get(browserExtensionKey) === 'true') { + return true; + } + await sendValidationRequest(browserExtensionKey); + this.validKeyCache.set(browserExtensionKey, 'true'); + } +} diff --git a/src/util.ts b/src/util.ts index e63e50c..d66961f 100644 --- a/src/util.ts +++ b/src/util.ts @@ -2,3 +2,7 @@ export async function waitForMs(ms: number) { await new Promise((resolve) => setTimeout(resolve, ms)); } + +export function shallowClone(original: T): T { + return Object.assign(Object.create(Object.getPrototypeOf(original)), original); +}