From f69cdc6ab2758a21b4b06dbdbc37b201d6ce5461 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Mon, 25 Mar 2024 16:59:33 -0700 Subject: [PATCH 01/39] wip --- src/assignment-cache.ts | 7 +- src/assignment-logger.ts | 19 +- src/client/eppo-client.ts | 438 +++++++++++------------------------ src/configuration-store.ts | 1 + src/dto/rule-dto.ts | 1 - src/eppo_value.ts | 75 +++--- src/eval.spec.ts | 458 +++++++++++++++++++++++++++++++++++++ src/eval.ts | 92 ++++++++ src/interfaces.ts | 48 ++++ src/rule_evaluator.spec.ts | 306 ++----------------------- src/rule_evaluator.ts | 17 +- src/shard.ts | 15 -- src/sharders.ts | 32 +++ src/types.ts | 4 + 14 files changed, 840 insertions(+), 673 deletions(-) create mode 100644 src/eval.spec.ts create mode 100644 src/eval.ts create mode 100644 src/interfaces.ts delete mode 100644 src/shard.ts create mode 100644 src/sharders.ts create mode 100644 src/types.ts diff --git a/src/assignment-cache.ts b/src/assignment-cache.ts index f63eb870..aa402e41 100644 --- a/src/assignment-cache.ts +++ b/src/assignment-cache.ts @@ -1,12 +1,13 @@ import { LRUCache } from 'lru-cache'; import { EppoValue } from './eppo_value'; +import { getMD5Hash } from './obfuscation'; export interface AssignmentCacheKey { subjectKey: string; flagKey: string; allocationKey: string; - variationValue: EppoValue; + variationKey: string; } export interface Cacheable { @@ -32,7 +33,7 @@ export abstract class AssignmentCache<T extends Cacheable> { // the subject has been assigned to a different variation // than was previously logged. // in this case we need to log the assignment again. - if (this.cache.get(this.getCacheKey(key)) !== key.variationValue.toHashedString()) { + if (this.cache.get(this.getCacheKey(key)) !== getMD5Hash(key.variationKey)) { return false; } @@ -40,7 +41,7 @@ export abstract class AssignmentCache<T extends Cacheable> { } setLastLoggedAssignment(key: AssignmentCacheKey): void { - this.cache.set(this.getCacheKey(key), key.variationValue.toHashedString()); + this.cache.set(this.getCacheKey(key), getMD5Hash(key.variationKey)); } protected getCacheKey({ subjectKey, flagKey, allocationKey }: AssignmentCacheKey): string { diff --git a/src/assignment-logger.ts b/src/assignment-logger.ts index 6f88d7f2..d1f3a73f 100644 --- a/src/assignment-logger.ts +++ b/src/assignment-logger.ts @@ -13,12 +13,12 @@ export interface IAssignmentEvent { /** * An Eppo allocation key */ - allocation: string; + allocation: string | null; /** * An Eppo experiment key */ - experiment: string; + experiment: string | null; /** * An Eppo feature flag key @@ -28,7 +28,7 @@ export interface IAssignmentEvent { /** * The assigned variation */ - variation: string; + variation: string | null; /** * The entity or user that was assigned to a variation @@ -40,18 +40,13 @@ export interface IAssignmentEvent { */ timestamp: string; - /** - * An Eppo holdout key - */ - holdout: string | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + subjectAttributes: Record<string, any>; /** - * The Eppo holdout variation for the assigned variation + * For additional fields to log */ - holdoutVariation: NullableHoldoutVariationType; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes: Record<string, any>; + [propName: string]: string; } /** diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 99405bc7..f3cb4dd8 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -27,12 +27,12 @@ import { IAllocation } from '../dto/allocation-dto'; import { IExperimentConfiguration } from '../dto/experiment-configuration-dto'; import { IVariation } from '../dto/variation-dto'; import { EppoValue, ValueType } from '../eppo_value'; +import { Evaluator, FlagEvaluation, noneResult } from '../eval'; import ExperimentConfigurationRequestor from '../experiment-configuration-requestor'; import HttpClient from '../http-client'; +import { Flag, VariationType } from '../interfaces'; import { getMD5Hash } from '../obfuscation'; import initPoller, { IPoller } from '../poller'; -import { findMatchingRule } from '../rule_evaluator'; -import { getShard, isShardInRange } from '../shard'; import { validateNotBlank } from '../validation'; /** @@ -40,25 +40,6 @@ import { validateNotBlank } from '../validation'; * @public */ export interface IEppoClient { - /** - * Maps a subject to a variation for a given experiment. - * - * @param subjectKey an identifier of the experiment subject, for example a user ID. - * @param flagKey feature flag identifier - * @param subjectAttributes optional attributes associated with the subject, for example name and email. - * The subject attributes are used for evaluating any targeting rules tied to the experiment. - * @param assignmentHooks optional interface for pre and post assignment hooks - * @returns a variation value if the subject is part of the experiment sample, otherwise null - * @public - */ - getAssignment( - subjectKey: string, - flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record<string, any>, - assignmentHooks?: IAssignmentHooks, - ): string | null; - /** * Maps a subject to a variation for a given experiment. * @@ -74,6 +55,7 @@ export interface IEppoClient { flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes?: Record<string, any>, + defaultValue?: string | null, assignmentHooks?: IAssignmentHooks, ): string | null; @@ -82,6 +64,7 @@ export interface IEppoClient { flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes?: Record<string, any>, + defaultValue?: boolean | null, assignmentHooks?: IAssignmentHooks, ): boolean | null; @@ -90,6 +73,7 @@ export interface IEppoClient { flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes?: Record<string, any>, + defaultValue?: number | null, assignmentHooks?: IAssignmentHooks, ): number | null; @@ -98,6 +82,7 @@ export interface IEppoClient { flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes?: Record<string, any>, + defaultValue?: string | null, assignmentHooks?: IAssignmentHooks, ): string | null; @@ -106,6 +91,7 @@ export interface IEppoClient { flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes?: Record<string, any>, + defaultValue?: object | null, assignmentHooks?: IAssignmentHooks, ): object | null; @@ -147,11 +133,14 @@ export default class EppoClient implements IEppoClient { private configurationStore: IConfigurationStore; private configurationRequestParameters: ExperimentConfigurationRequestParameters | undefined; private requestPoller: IPoller | undefined; + private evaluator: Evaluator; constructor( + evaluator: Evaluator, configurationStore: IConfigurationStore, configurationRequestParameters?: ExperimentConfigurationRequestParameters, ) { + this.evaluator = evaluator; this.configurationStore = configurationStore; this.configurationRequestParameters = configurationRequestParameters; } @@ -216,52 +205,26 @@ export default class EppoClient implements IEppoClient { } } - // @deprecated getAssignment is deprecated in favor of the typed get<Type>Assignment methods - public getAssignment( - subjectKey: string, - flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes: Record<string, any> = {}, - assignmentHooks?: IAssignmentHooks | undefined, - obfuscated = false, - ): string | null { - try { - return ( - this.getAssignmentVariation( - subjectKey, - flagKey, - subjectAttributes, - assignmentHooks, - obfuscated, - ).stringValue ?? null - ); - } catch (error) { - return this.rethrowIfNotGraceful(error); - } - } - public getStringAssignment( subjectKey: string, flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record<string, any> = {}, + defaultValue?: string | null, assignmentHooks?: IAssignmentHooks | undefined, obfuscated = false, ): string | null { - try { - return ( - this.getAssignmentVariation( - subjectKey, - flagKey, - subjectAttributes, - assignmentHooks, - obfuscated, - ValueType.StringType, - ).stringValue ?? null - ); - } catch (error) { - return this.rethrowIfNotGraceful(error); - } + return ( + this.getAssignmentVariation( + subjectKey, + flagKey, + subjectAttributes, + defaultValue ? EppoValue.String(defaultValue) : EppoValue.Null(), + assignmentHooks, + obfuscated, + VariationType.STRING, + ).stringValue ?? null + ); } getBoolAssignment( @@ -269,70 +232,42 @@ export default class EppoClient implements IEppoClient { flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record<string, any> = {}, + defaultValue: boolean | null = null, assignmentHooks?: IAssignmentHooks | undefined, obfuscated = false, ): boolean | null { - try { - return ( - this.getAssignmentVariation( - subjectKey, - flagKey, - subjectAttributes, - assignmentHooks, - obfuscated, - ValueType.BoolType, - ).boolValue ?? null - ); - } catch (error) { - return this.rethrowIfNotGraceful(error); - } + return ( + this.getAssignmentVariation( + subjectKey, + flagKey, + subjectAttributes, + defaultValue ? EppoValue.Bool(defaultValue) : EppoValue.Null(), + assignmentHooks, + obfuscated, + VariationType.BOOLEAN, + ).boolValue ?? null + ); } getNumericAssignment( subjectKey: string, flagKey: string, subjectAttributes?: Record<string, EppoValue>, + defaultValue?: number | null, assignmentHooks?: IAssignmentHooks | undefined, obfuscated = false, ): number | null { - try { - return ( - this.getAssignmentVariation( - subjectKey, - flagKey, - subjectAttributes, - assignmentHooks, - obfuscated, - ValueType.NumericType, - ).numericValue ?? null - ); - } catch (error) { - return this.rethrowIfNotGraceful(error); - } - } - - public getJSONStringAssignment( - subjectKey: string, - flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes: Record<string, any> = {}, - assignmentHooks?: IAssignmentHooks | undefined, - obfuscated = false, - ): string | null { - try { - return ( - this.getAssignmentVariation( - subjectKey, - flagKey, - subjectAttributes, - assignmentHooks, - obfuscated, - ValueType.JSONType, - ).stringValue ?? null - ); - } catch (error) { - return this.rethrowIfNotGraceful(error); - } + return ( + this.getAssignmentVariation( + subjectKey, + flagKey, + subjectAttributes, + defaultValue ? EppoValue.Numeric(defaultValue) : EppoValue.Null(), + assignmentHooks, + obfuscated, + VariationType.FLOAT, + ).numericValue ?? null + ); } public getParsedJSONAssignment( @@ -340,29 +275,27 @@ export default class EppoClient implements IEppoClient { flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record<string, any> = {}, + defaultValue?: object | null, assignmentHooks?: IAssignmentHooks | undefined, obfuscated = false, ): object | null { - try { - return ( - this.getAssignmentVariation( - subjectKey, - flagKey, - subjectAttributes, - assignmentHooks, - obfuscated, - ValueType.JSONType, - ).objectValue ?? null - ); - } catch (error) { - return this.rethrowIfNotGraceful(error); - } + return ( + this.getAssignmentVariation( + subjectKey, + flagKey, + subjectAttributes, + defaultValue ? EppoValue.JSON(defaultValue) : EppoValue.Null(), + assignmentHooks, + obfuscated, + VariationType.JSON, + ).objectValue ?? null + ); } - private rethrowIfNotGraceful(err: Error): null { + private rethrowIfNotGraceful(err: Error, defaultValue?: EppoValue): EppoValue { if (this.isGracefulFailureMode) { console.error(`[Eppo SDK] Error getting assignment: ${err.message}`); - return null; + return defaultValue ?? EppoValue.Null(); } throw err; } @@ -372,147 +305,82 @@ export default class EppoClient implements IEppoClient { flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record<string, any> = {}, + defaultValue: EppoValue, assignmentHooks: IAssignmentHooks | undefined, obfuscated: boolean, - valueType?: ValueType, + expectedVariationType: VariationType, ): EppoValue { - const { allocationKey, assignment, holdoutKey, holdoutVariation } = this.getAssignmentInternal( - subjectKey, - flagKey, - subjectAttributes, - assignmentHooks, - obfuscated, - valueType, - ); - assignmentHooks?.onPostAssignment(flagKey, subjectKey, assignment, allocationKey); - - if (!assignment.isNullType() && allocationKey !== null) - this.logAssignment( - flagKey, - allocationKey, - assignment, + try { + const result = this.getAssignmentDetail( subjectKey, - holdoutKey, - holdoutVariation, + flagKey, subjectAttributes, + expectedVariationType, ); - return assignment; + // TODO: figure out whether to translate VariationType to EppoValueType or not + return EppoValue.generateEppoValue(result.variation?.value, expectedVariationType); + } catch (error) { + return this.rethrowIfNotGraceful(error); + } } - private getAssignmentInternal( + public getAssignmentDetail( subjectKey: string, flagKey: string, - subjectAttributes = {}, - assignmentHooks: IAssignmentHooks | undefined, - obfuscated: boolean, - expectedValueType?: ValueType, - ): { - allocationKey: string | null; - assignment: EppoValue; - holdoutKey: string | null; - holdoutVariation: NullableHoldoutVariationType; - } { + subjectAttributes: Record<string, any> = {}, + expectedVariationType?: VariationType, + obfuscated = false, + ): FlagEvaluation { validateNotBlank(subjectKey, 'Invalid argument: subjectKey cannot be blank'); validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank'); - const nullAssignment = { - allocationKey: null, - assignment: EppoValue.Null(), - holdoutKey: null, - holdoutVariation: null, - }; + const flag: Flag = this.configurationStore.get(flagKey); - const experimentConfig = this.configurationStore.get<IExperimentConfiguration>( - obfuscated ? getMD5Hash(flagKey) : flagKey, - ); - const allowListOverride = this.getSubjectVariationOverride( - subjectKey, - experimentConfig, - expectedValueType, - ); - - if (!allowListOverride.isNullType()) { - if (!allowListOverride.isExpectedType()) { - return nullAssignment; - } - return { ...nullAssignment, assignment: allowListOverride }; + if (flag === null) { + console.warn(`[Eppo SDK] No assigned variation. Flag not found: ${flagKey}`); + // note: this is different from the Python SDK, which returns None instead + return noneResult(flagKey, subjectKey, subjectAttributes); } - // Check for disabled flag. - if (!experimentConfig?.enabled) return nullAssignment; + if (!this.checkTypeMatch(expectedVariationType, flag.variationType)) { + throw new TypeError( + `Variation value does not have the correct type. Found: ${flag.variationType} != ${expectedVariationType}`, + ); + } - // check for overridden assignment via hook - const overriddenAssignment = assignmentHooks?.onPreAssignment(flagKey, subjectKey); - if (overriddenAssignment !== null && overriddenAssignment !== undefined) { - if (!overriddenAssignment.isExpectedType()) return nullAssignment; - return { ...nullAssignment, assignment: overriddenAssignment }; + if (!flag.enabled) { + console.info(`[Eppo SDK] No assigned variation. Flag is disabled: ${flagKey}`); + // note: this is different from the Python SDK, which returns None instead + return noneResult(flagKey, subjectKey, subjectAttributes); } - // Attempt to match a rule from the list. - const matchedRule = findMatchingRule( - subjectAttributes || {}, - experimentConfig.rules, - obfuscated, - ); - if (!matchedRule) return nullAssignment; - - // Check if subject is in allocation sample. - const allocation = experimentConfig.allocations[matchedRule.allocationKey]; - if (!this.isInExperimentSample(subjectKey, flagKey, experimentConfig, allocation)) - return nullAssignment; - - // Compute variation for subject. - const { subjectShards } = experimentConfig; - const { variations, holdouts, statusQuoVariationKey, shippedVariationKey } = allocation; - - let assignedVariation: IVariation | undefined; - let holdoutVariation = null; - - const holdoutShard = getShard(`holdout-${subjectKey}`, subjectShards); - const matchingHoldout = holdouts?.find((holdout) => { - const { statusQuoShardRange, shippedShardRange } = holdout; - if (isShardInRange(holdoutShard, statusQuoShardRange)) { - assignedVariation = variations.find( - (variation) => variation.variationKey === statusQuoVariationKey, - ); - // Only log the holdout variation if this is a rollout allocation - // Only rollout allocations have shippedShardRange specified - if (shippedShardRange) { - holdoutVariation = HoldoutVariationEnum.STATUS_QUO; - } - } else if (shippedShardRange && isShardInRange(holdoutShard, shippedShardRange)) { - assignedVariation = variations.find( - (variation) => variation.variationKey === shippedVariationKey, - ); - holdoutVariation = HoldoutVariationEnum.ALL_SHIPPED; + const result = this.evaluator.evaluateFlag(flag, subjectKey, subjectAttributes, obfuscated); + + try { + if (result && result.doLog) { + // TODO: check assignment cache + this.logAssignment(assignmentEvent); } - return assignedVariation; - }); - const holdoutKey = matchingHoldout?.holdoutKey ?? null; - if (!matchingHoldout) { - const assignmentShard = getShard(`assignment-${subjectKey}-${flagKey}`, subjectShards); - assignedVariation = variations.find((variation) => - isShardInRange(assignmentShard, variation.shardRange), - ); + } catch (error) { + console.error(`[Eppo SDK] Error logging assignment event: ${error}`); } - const internalAssignment: { - allocationKey: string; - assignment: EppoValue; - holdoutKey: string | null; - holdoutVariation: NullableHoldoutVariationType; - } = { - allocationKey: matchedRule.allocationKey, - assignment: EppoValue.generateEppoValue( - expectedValueType, - assignedVariation?.value, - assignedVariation?.typedValue, - ), - holdoutKey, - holdoutVariation: holdoutVariation as NullableHoldoutVariationType, - }; - return internalAssignment.assignment.isExpectedType() ? internalAssignment : nullAssignment; + return result; + } + + private checkTypeMatch(expectedType?: VariationType, actualType?: VariationType): boolean { + return expectedType === undefined || actualType === expectedType; + } + + public get_flag_keys() { + /** + * Returns a list of all flag keys that have been initialized. + * This can be useful to debug the initialization process. + * + * Note that it is generally not a good idea to pre-load all flag configurations. + */ + return this.configurationStore.getKeys(); } public setLogger(logger: IAssignmentLogger) { @@ -555,38 +423,31 @@ export default class EppoClient implements IEppoClient { } } - private logAssignment( - flagKey: string, - allocationKey: string, - variation: EppoValue, - subjectKey: string, - holdout: string | null, - holdoutVariation: NullableHoldoutVariationType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes: Record<string, any> | undefined = {}, - ) { + private logAssignment(result: FlagEvaluation) { + const event: IAssignmentEvent = { + ...(result.extraLogging ?? {}), + allocation: result.allocationKey ?? null, + experiment: result.allocationKey ? `${result.flagKey}-${result.allocationKey}` : null, + featureFlag: result.flagKey, + variation: result.variation?.key ?? null, + subject: result.subjectKey, + timestamp: new Date().toISOString(), + subjectAttributes: result.subjectAttributes, + }; + if ( + result.variation && + result.allocationKey && this.assignmentCache?.hasLoggedAssignment({ - flagKey, - subjectKey, - allocationKey, - variationValue: variation, + flagKey: result.flagKey, + subjectKey: result.subjectKey, + allocationKey: result.allocationKey, + variationKey: result.variation.key, }) ) { return; } - const event: IAssignmentEvent = { - allocation: allocationKey, - experiment: `${flagKey}-${allocationKey}`, - featureFlag: flagKey, - variation: variation.toString(), // return the string representation to the logging callback - timestamp: new Date().toISOString(), - subject: subjectKey, - holdout, - holdoutVariation, - subjectAttributes, - }; // assignment logger may be null while waiting for initialization if (this.assignmentLogger == null) { this.queuedEvents.length < MAX_EVENT_QUEUE_SIZE && this.queuedEvents.push(event); @@ -595,42 +456,13 @@ export default class EppoClient implements IEppoClient { try { this.assignmentLogger.logAssignment(event); this.assignmentCache?.setLastLoggedAssignment({ - flagKey, - subjectKey, - allocationKey, - variationValue: variation, + flagKey: result.flagKey, + subjectKey: result.subjectKey, + allocationKey: result.allocationKey ?? null, + variationKey: result.variation?.key ?? null, }); } catch (error) { console.error(`[Eppo SDK] Error logging assignment event: ${error.message}`); } } - - private getSubjectVariationOverride( - subjectKey: string, - experimentConfig: IExperimentConfiguration, - expectedValueType?: ValueType, - ): EppoValue { - const subjectHash = md5(subjectKey); - const override = experimentConfig?.overrides && experimentConfig.overrides[subjectHash]; - const typedOverride = - experimentConfig?.typedOverrides && experimentConfig.typedOverrides[subjectHash]; - return EppoValue.generateEppoValue(expectedValueType, override, typedOverride); - } - - /** - * This checks whether the subject is included in the experiment sample. - * It is used to determine whether the subject should be assigned to a variant. - * Given a hash function output (bucket), check whether the bucket is between 0 and exposure_percent * total_buckets. - */ - private isInExperimentSample( - subjectKey: string, - flagKey: string, - experimentConfig: IExperimentConfiguration, - allocation: IAllocation, - ): boolean { - const { subjectShards } = experimentConfig; - const { percentExposure } = allocation; - const shard = getShard(`exposure-${subjectKey}-${flagKey}`, subjectShards); - return shard <= percentExposure * subjectShards; - } } diff --git a/src/configuration-store.ts b/src/configuration-store.ts index 90e14804..e2116dc1 100644 --- a/src/configuration-store.ts +++ b/src/configuration-store.ts @@ -1,4 +1,5 @@ export interface IConfigurationStore { get<T>(key: string): T; + getKeys(): string[]; setEntries<T>(entries: Record<string, T>): void; } diff --git a/src/dto/rule-dto.ts b/src/dto/rule-dto.ts index aba56c2e..c72f0cef 100644 --- a/src/dto/rule-dto.ts +++ b/src/dto/rule-dto.ts @@ -23,6 +23,5 @@ export interface Condition { } export interface IRule { - allocationKey: string; conditions: Condition[]; } diff --git a/src/eppo_value.ts b/src/eppo_value.ts index 4f2d42bf..203c2ac3 100644 --- a/src/eppo_value.ts +++ b/src/eppo_value.ts @@ -1,6 +1,6 @@ import { getMD5Hash } from './obfuscation'; -export enum ValueType { +export enum EppoValueType { NullType, BoolType, NumericType, @@ -11,14 +11,14 @@ export enum ValueType { export type IValue = boolean | number | string | undefined; export class EppoValue { - public valueType: ValueType; + public valueType: EppoValueType; public boolValue: boolean | undefined; public numericValue: number | undefined; public stringValue: string | undefined; public objectValue: object | undefined; private constructor( - valueType: ValueType, + valueType: EppoValueType, boolValue: boolean | undefined, numericValue: number | undefined, stringValue: string | undefined, @@ -32,20 +32,19 @@ export class EppoValue { } static generateEppoValue( - expectedValueType?: ValueType, - value?: string, - typedValue?: boolean | number | string | object, + value: boolean | number | string | object | null | undefined, + valueType: EppoValueType, ): EppoValue { - if (value != null && typedValue != null) { - switch (expectedValueType) { - case ValueType.BoolType: - return EppoValue.Bool(typedValue as boolean); - case ValueType.NumericType: - return EppoValue.Numeric(typedValue as number); - case ValueType.StringType: - return EppoValue.String(typedValue as string); - case ValueType.JSONType: - return EppoValue.JSON(value, typedValue as object); + if (value != null && value != undefined) { + switch (valueType) { + case EppoValueType.BoolType: + return EppoValue.Bool(value as boolean); + case EppoValueType.NumericType: + return EppoValue.Numeric(value as number); + case EppoValueType.StringType: + return EppoValue.String(value as string); + case EppoValueType.JSONType: + return EppoValue.JSON(value as object); default: return EppoValue.String(value as string); } @@ -55,15 +54,15 @@ export class EppoValue { toString(): string { switch (this.valueType) { - case ValueType.NullType: + case EppoValueType.NullType: return 'null'; - case ValueType.BoolType: + case EppoValueType.BoolType: return this.boolValue ? 'true' : 'false'; - case ValueType.NumericType: + case EppoValueType.NumericType: return this.numericValue ? this.numericValue.toString() : '0'; - case ValueType.StringType: + case EppoValueType.StringType: return this.stringValue ?? ''; - case ValueType.JSONType: + case EppoValueType.JSONType: try { return JSON.stringify(this.objectValue) ?? ''; } catch { @@ -85,48 +84,40 @@ export class EppoValue { isExpectedType(): boolean { switch (this.valueType) { - case ValueType.BoolType: + case EppoValueType.BoolType: return typeof this.boolValue === 'boolean'; - case ValueType.NumericType: + case EppoValueType.NumericType: return typeof this.numericValue === 'number'; - case ValueType.StringType: + case EppoValueType.StringType: return typeof this.stringValue === 'string'; - case ValueType.JSONType: - try { - return ( - typeof this.objectValue === 'object' && - typeof this.stringValue === 'string' && - JSON.stringify(JSON.parse(this.stringValue)) === JSON.stringify(this.objectValue) - ); - } catch { - return false; - } - case ValueType.NullType: + case EppoValueType.JSONType: + return typeof this.objectValue === 'object'; + case EppoValueType.NullType: return false; } } isNullType(): boolean { - return this.valueType === ValueType.NullType; + return this.valueType === EppoValueType.NullType; } static Bool(value: boolean): EppoValue { - return new EppoValue(ValueType.BoolType, value, undefined, undefined, undefined); + return new EppoValue(EppoValueType.BoolType, value, undefined, undefined, undefined); } static Numeric(value: number): EppoValue { - return new EppoValue(ValueType.NumericType, undefined, value, undefined, undefined); + return new EppoValue(EppoValueType.NumericType, undefined, value, undefined, undefined); } static String(value: string): EppoValue { - return new EppoValue(ValueType.StringType, undefined, undefined, value, undefined); + return new EppoValue(EppoValueType.StringType, undefined, undefined, value, undefined); } - static JSON(value: string, typedValue: object): EppoValue { - return new EppoValue(ValueType.JSONType, undefined, undefined, value, typedValue); + static JSON(value: object): EppoValue { + return new EppoValue(EppoValueType.JSONType, undefined, undefined, undefined, value); } static Null(): EppoValue { - return new EppoValue(ValueType.NullType, undefined, undefined, undefined, undefined); + return new EppoValue(EppoValueType.NullType, undefined, undefined, undefined, undefined); } } diff --git a/src/eval.spec.ts b/src/eval.spec.ts new file mode 100644 index 00000000..b24402d1 --- /dev/null +++ b/src/eval.spec.ts @@ -0,0 +1,458 @@ +import { Evaluator, hashKey, isInShardRange } from './eval'; +import { Flag, Variation, Shard, VariationType } from './interfaces'; +import { MD5Sharder, DeterministicSharder } from './sharders'; + +describe('Evaluator', () => { + const VARIATION_A: Variation = { key: 'a', value: 'A' }; + const VARIATION_B: Variation = { key: 'b', value: 'B' }; + const VARIATION_C: Variation = { key: 'c', value: 'C' }; + + it('should return none result for disabled flag', () => { + const flag: Flag = { + key: 'disabled_flag', + enabled: false, + variationType: VariationType.STRING, + variations: { a: VARIATION_A }, + allocations: [ + { + key: 'default', + rules: [], + splits: [ + { + variationKey: 'a', + shards: [{ salt: 'a', ranges: [{ start: 0, end: 10 }] }], + extraLogging: {}, + }, + ], + doLog: true, + }, + ], + totalShards: 10, + }; + + const evaluator = new Evaluator(new MD5Sharder()); + const result = evaluator.evaluateFlag(flag, 'subject_key', {}, false); + expect(result.flagKey).toEqual('disabled_flag'); + expect(result.allocationKey).toBeNull(); + expect(result.variation).toBeNull(); + expect(result.doLog).toBeFalsy(); + }); + + it('should match shard with full range', () => { + const shard: Shard = { + salt: 'a', + ranges: [{ start: 0, end: 100 }], + }; + + const evaluator = new Evaluator(new MD5Sharder()); + expect(evaluator.matchesShard(shard, 'subject_key', 100)).toBeTruthy(); + }); + + it('should match shard with full range split', () => { + const shard: Shard = { + salt: 'a', + ranges: [ + { start: 0, end: 50 }, + { start: 50, end: 100 }, + ], + }; + + const evaluator = new Evaluator(new MD5Sharder()); + expect(evaluator.matchesShard(shard, 'subject_key', 100)).toBeTruthy(); + + const deterministicEvaluator = new Evaluator(new DeterministicSharder({ subject_key: 50 })); + expect(deterministicEvaluator.matchesShard(shard, 'subject_key', 100)).toBeTruthy(); + }); + + it('should not match shard when out of range', () => { + const shard: Shard = { + salt: 'a', + ranges: [{ start: 0, end: 50 }], + }; + + const evaluator = new Evaluator(new DeterministicSharder({ 'a-subject_key': 99 })); + expect(evaluator.matchesShard(shard, 'subject_key', 100)).toBeFalsy(); + }); + + it('should evaluate empty flag to none result', () => { + const emptyFlag: Flag = { + key: 'empty', + enabled: true, + variationType: VariationType.STRING, + variations: { a: VARIATION_A, b: VARIATION_B }, + allocations: [], + totalShards: 10, + }; + + const evaluator = new Evaluator(new MD5Sharder()); + const result = evaluator.evaluateFlag(emptyFlag, 'subject_key', {}, false); + expect(result.flagKey).toEqual('empty'); + expect(result.allocationKey).toBeNull(); + expect(result.variation).toBeNull(); + expect(result.doLog).toBeFalsy(); + }); + + it('should evaluate simple flag and return control variation', () => { + const flag: Flag = { + key: 'flag-key', + enabled: true, + variationType: VariationType.STRING, + variations: { control: { key: 'control', value: 'control' } }, + allocations: [ + { + key: 'allocation', + rules: [], + splits: [ + { + variationKey: 'control', + shards: [{ salt: 'salt', ranges: [{ start: 0, end: 10000 }] }], + extraLogging: {}, + }, + ], + doLog: true, + }, + ], + totalShards: 10000, + }; + + const evaluator = new Evaluator(new MD5Sharder()); + const result = evaluator.evaluateFlag(flag, 'user-1', {}, false); + expect(result.variation).toEqual({ key: 'control', value: 'control' }); + }); + + it('should evaluate flag and target on id', () => { + const flag: Flag = { + key: 'flag-key', + enabled: true, + variationType: VariationType.STRING, + variations: { control: { key: 'control', value: 'control' } }, + allocations: [ + { + key: 'allocation', + rules: [], + splits: [ + { + variationKey: 'control', + shards: [], + extraLogging: {}, + }, + ], + doLog: true, + }, + ], + totalShards: 10000, + }; + + const evaluator = new Evaluator(new MD5Sharder()); + let result = evaluator.evaluateFlag(flag, 'user-1', {}, false); + expect(result.variation).toEqual({ key: 'control', value: 'control' }); + + result = evaluator.evaluateFlag(flag, 'user-3', {}, false); + expect(result.variation).toBeNull(); + }); + + it('should catch all allocation and return variation A', () => { + const flag: Flag = { + key: 'flag', + enabled: true, + variationType: VariationType.STRING, + variations: { a: VARIATION_A, b: VARIATION_B }, + allocations: [ + { + key: 'default', + rules: [], + splits: [ + { + variationKey: 'a', + shards: [], + extraLogging: {}, + }, + ], + doLog: true, + }, + ], + totalShards: 10, + }; + + const evaluator = new Evaluator(new MD5Sharder()); + const result = evaluator.evaluateFlag(flag, 'subject_key', {}, false); + expect(result.flagKey).toEqual('flag'); + expect(result.allocationKey).toEqual('default'); + expect(result.variation).toEqual(VARIATION_A); + expect(result.doLog).toBeTruthy(); + }); + + it('should match first allocation rule and return variation B', () => { + const flag: Flag = { + key: 'flag', + enabled: true, + variationType: VariationType.STRING, + variations: { a: VARIATION_A, b: VARIATION_B }, + allocations: [ + { + key: 'first', + rules: [], + splits: [ + { + variationKey: 'b', + shards: [], + extraLogging: {}, + }, + ], + doLog: true, + }, + { + key: 'default', + rules: [], + splits: [ + { + variationKey: 'a', + shards: [], + extraLogging: {}, + }, + ], + doLog: true, + }, + ], + totalShards: 10, + }; + + const evaluator = new Evaluator(new MD5Sharder()); + const result = evaluator.evaluateFlag( + flag, + 'subject_key', + { email: 'eppo@example.com' }, + false, + ); + expect(result.flagKey).toEqual('flag'); + expect(result.allocationKey).toEqual('first'); + expect(result.variation).toEqual(VARIATION_B); + }); + + it('should not match first allocation rule and return variation A', () => { + const flag: Flag = { + key: 'flag', + enabled: true, + variationType: VariationType.STRING, + variations: { a: VARIATION_A, b: VARIATION_B }, + allocations: [ + { + key: 'first', + rules: [], + splits: [ + { + variationKey: 'b', + shards: [], + extraLogging: {}, + }, + ], + doLog: true, + }, + { + key: 'default', + rules: [], + splits: [ + { + variationKey: 'a', + shards: [], + extraLogging: {}, + }, + ], + doLog: true, + }, + ], + totalShards: 10, + }; + + const evaluator = new Evaluator(new MD5Sharder()); + const result = evaluator.evaluateFlag(flag, 'subject_key', { email: 'eppo@test.com' }, false); + expect(result.flagKey).toEqual('flag'); + expect(result.allocationKey).toEqual('default'); + expect(result.variation).toEqual(VARIATION_A); + }); + it('should evaluate sharding and return correct variations', () => { + const flag: Flag = { + key: 'flag', + enabled: true, + variationType: VariationType.STRING, + variations: { a: VARIATION_A, b: VARIATION_B, c: VARIATION_C }, + allocations: [ + { + key: 'first', + rules: [], + splits: [ + { + variationKey: 'a', + shards: [ + { salt: 'traffic', ranges: [{ start: 0, end: 5 }] }, + { salt: 'split', ranges: [{ start: 0, end: 3 }] }, + ], + extraLogging: {}, + }, + { + variationKey: 'b', + shards: [ + { salt: 'traffic', ranges: [{ start: 0, end: 5 }] }, + { salt: 'split', ranges: [{ start: 3, end: 6 }] }, + ], + extraLogging: {}, + }, + ], + doLog: true, + }, + { + key: 'default', + rules: [], + splits: [ + { + variationKey: 'c', + shards: [], + extraLogging: {}, + }, + ], + doLog: true, + }, + ], + totalShards: 10, + }; + + const deterministicEvaluator = new Evaluator( + new DeterministicSharder({ + 'traffic-alice': 2, + 'traffic-bob': 3, + 'traffic-charlie': 4, + 'traffic-dave': 7, + 'split-alice': 1, + 'split-bob': 4, + 'split-charlie': 8, + 'split-dave': 1, + }), + ); + + expect(deterministicEvaluator.evaluateFlag(flag, 'alice', {}, false).variation).toEqual( + VARIATION_A, + ); + expect(deterministicEvaluator.evaluateFlag(flag, 'bob', {}, false).variation).toEqual( + VARIATION_B, + ); + expect(deterministicEvaluator.evaluateFlag(flag, 'charlie', {}, false).variation).toEqual( + VARIATION_C, + ); + expect(deterministicEvaluator.evaluateFlag(flag, 'dave', {}, false).variation).toEqual( + VARIATION_C, + ); + }); + + it('should return none result for evaluation prior to allocation', () => { + const now = new Date(); + const flag: Flag = { + key: 'flag', + enabled: true, + variationType: VariationType.STRING, + variations: { a: VARIATION_A }, + allocations: [ + { + key: 'default', + startAt: new Date(now.getFullYear() + 1, 0, 1), + endAt: new Date(now.getFullYear() + 1, 1, 1), + rules: [], + splits: [ + { + variationKey: 'a', + shards: [], + extraLogging: {}, + }, + ], + doLog: true, + }, + ], + totalShards: 10, + }; + + const evaluator = new Evaluator(new MD5Sharder()); + const result = evaluator.evaluateFlag(flag, 'subject_key', {}, false); + expect(result.flagKey).toEqual('flag'); + expect(result.allocationKey).toBeNull(); + expect(result.variation).toBeNull(); + }); + + it('should return correct variation for evaluation during allocation', () => { + const now = new Date(); + const flag: Flag = { + key: 'flag', + enabled: true, + variationType: VariationType.STRING, + variations: { a: VARIATION_A }, + allocations: [ + { + key: 'default', + startAt: new Date(now.getFullYear() - 1, 0, 1), + endAt: new Date(now.getFullYear() + 1, 0, 1), + rules: [], + splits: [ + { + variationKey: 'a', + shards: [], + extraLogging: {}, + }, + ], + doLog: true, + }, + ], + totalShards: 10, + }; + + const evaluator = new Evaluator(new MD5Sharder()); + const result = evaluator.evaluateFlag(flag, 'subject_key', {}, false); + expect(result.flagKey).toEqual('flag'); + expect(result.allocationKey).toEqual('default'); + expect(result.variation).toEqual(VARIATION_A); + }); + + it('should evaluate flag after allocation period', () => { + const now = new Date(); + const flag: Flag = { + key: 'flag', + enabled: true, + variationType: VariationType.STRING, + variations: { a: VARIATION_A }, + allocations: [ + { + key: 'default', + startAt: new Date(now.getFullYear() - 2, 0, 1), + endAt: new Date(now.getFullYear() - 1, 0, 1), + rules: [], + splits: [ + { + variationKey: 'a', + shards: [], + extraLogging: {}, + }, + ], + doLog: true, + }, + ], + totalShards: 10, + }; + + const evaluator = new Evaluator(new MD5Sharder()); + const result = evaluator.evaluateFlag(flag, 'subject_key', {}, false); + expect(result.flagKey).toEqual('flag'); + expect(result.allocationKey).toBeNull(); + expect(result.variation).toBeNull(); + }); + + it('should create a hash key that appends subject to salt', () => { + expect(hashKey('salt', 'subject')).toEqual('salt-subject'); + }); + + it('should correctly determine if a shard is within a range', () => { + expect(isInShardRange(5, { start: 0, end: 10 })).toBeTruthy(); + expect(isInShardRange(10, { start: 0, end: 10 })).toBeFalsy(); + expect(isInShardRange(0, { start: 0, end: 10 })).toBeTruthy(); + expect(isInShardRange(0, { start: 0, end: 0 })).toBeFalsy(); + expect(isInShardRange(0, { start: 0, end: 1 })).toBeTruthy(); + expect(isInShardRange(1, { start: 0, end: 1 })).toBeFalsy(); + expect(isInShardRange(1, { start: 1, end: 1 })).toBeFalsy(); + }); +}); diff --git a/src/eval.ts b/src/eval.ts new file mode 100644 index 00000000..5247736e --- /dev/null +++ b/src/eval.ts @@ -0,0 +1,92 @@ +import { Flag, Shard, Range, Variation, Allocation, Split } from './interfaces'; +import { matchesRule } from './rule_evaluator'; +import { Sharder } from './sharders'; + +export interface FlagEvaluation { + flagKey: string; + subjectKey: string; + subjectAttributes: Record<string, string | number | boolean>; + allocationKey: string | null; + variation: Variation | null; + extraLogging: Record<string, string>; + doLog: boolean; +} + +export class Evaluator { + sharder: Sharder; // Assuming a Sharder type exists, replace 'any' with 'Sharder' when available + + constructor(sharder: Sharder) { + this.sharder = sharder; + } + + evaluateFlag( + flag: Flag, + subjectKey: string, + subjectAttributes: Record<string, string | number | boolean>, + obfuscated: boolean, + ): FlagEvaluation { + if (!flag.enabled) { + return noneResult(flag.key, subjectKey, subjectAttributes); + } + + const now = new Date(); + for (const allocation of flag.allocations) { + if (allocation.startAt && now < allocation.startAt) continue; + if (allocation.endAt && now > allocation.endAt) continue; + + if ( + !allocation.rules || + allocation.rules.some((rule) => + matchesRule(rule, { id: subjectKey, ...subjectAttributes }, obfuscated), + ) + ) { + for (const split of allocation.splits) { + if ( + split.shards.every((shard) => this.matchesShard(shard, subjectKey, flag.totalShards)) + ) { + return { + flagKey: flag.key, + subjectKey, + subjectAttributes, + allocationKey: allocation.key, + variation: flag.variations[split.variationKey], + extraLogging: split.extraLogging, + doLog: allocation.doLog, + }; + } + } + } + } + + return noneResult(flag.key, subjectKey, subjectAttributes); + } + + matchesShard(shard: Shard, subjectKey: string, totalShards: number): boolean { + const h = this.sharder.getShard(hashKey(shard.salt, subjectKey), totalShards); + return shard.ranges.some((range) => isInShardRange(h, range)); + } +} + +export function isInShardRange(shard: number, range: Range): boolean { + return range.start <= shard && shard < range.end; +} + +export function hashKey(salt: string, subjectKey: string): string { + return `${salt}-${subjectKey}`; +} + +export function noneResult( + flagKey: string, + subjectKey: string, + subjectAttributes: Record<string, string | number | boolean>, +): FlagEvaluation { + return { + flagKey, + subjectKey, + subjectAttributes, + allocationKey: null, + variation: null, + extraLogging: {}, + doLog: false, + }; +} diff --git a/src/interfaces.ts b/src/interfaces.ts new file mode 100644 index 00000000..1f11dde4 --- /dev/null +++ b/src/interfaces.ts @@ -0,0 +1,48 @@ +import { Rule } from './rules'; + +export enum VariationType { + STRING = 'string', + INTEGER = 'integer', + FLOAT = 'float', + BOOLEAN = 'boolean', + JSON = 'json', +} + +export interface Variation { + key: string; + value: string | number | boolean; +} + +export interface Range { + start: number; + end: number; +} + +export interface Shard { + salt: string; + ranges: Range[]; +} + +export interface Split { + shards: Shard[]; + variationKey: string; + extraLogging: Record<string, string>; +} + +export interface Allocation { + key: string; + rules: Rule[]; + startAt?: Date; + endAt?: Date; + splits: Split[]; + doLog: boolean; +} + +export interface Flag { + key: string; + enabled: boolean; + variationType: VariationType; + variations: Record<string, Variation>; + allocations: Allocation[]; + totalShards: number; +} diff --git a/src/rule_evaluator.spec.ts b/src/rule_evaluator.spec.ts index d0e2f5f2..a4692936 100644 --- a/src/rule_evaluator.spec.ts +++ b/src/rule_evaluator.spec.ts @@ -1,13 +1,11 @@ import { OperatorType, IRule } from './dto/rule-dto'; -import { findMatchingRule } from './rule_evaluator'; +import { matchesRule } from './rule_evaluator'; -describe('findMatchingRule', () => { +describe('matchesRule', () => { const ruleWithEmptyConditions: IRule = { - allocationKey: 'test', conditions: [], }; const numericRule: IRule = { - allocationKey: 'test', conditions: [ { operator: OperatorType.GTE, @@ -22,7 +20,6 @@ describe('findMatchingRule', () => { ], }; const semverRule: IRule = { - allocationKey: 'test', conditions: [ { operator: OperatorType.GTE, @@ -37,7 +34,6 @@ describe('findMatchingRule', () => { ], }; const ruleWithMatchesCondition: IRule = { - allocationKey: 'test', conditions: [ { operator: OperatorType.MATCHES, @@ -47,294 +43,40 @@ describe('findMatchingRule', () => { ], }; - it('returns null if rules array is empty', () => { - const rules: IRule[] = []; - expect(findMatchingRule({ name: 'my-user' }, rules, false)).toEqual(null); - }); - - it('returns null if attributes do not match any rules', () => { - const rules = [numericRule]; - expect(findMatchingRule({ totalSales: 101 }, rules, false)).toEqual(null); - - // input subject attribute is a string which is not a valid semver nor numeric - // verify that is not parsed to a semver nor a numeric. - expect( - findMatchingRule( - { version: '1.2.03' }, - [ - { - allocationKey: 'test', - conditions: [ - { - operator: OperatorType.GTE, - attribute: 'version', - value: '1.2.0', - }, - ], - }, - ], - false, - ), - ).toEqual(null); - }); - - it('returns the rule if attributes match AND conditions', () => { - const rules = [numericRule]; - expect(findMatchingRule({ totalSales: 100 }, rules, false)).toEqual(numericRule); - }); - - it('returns the rule for semver conditions', () => { - const rules = [semverRule]; - expect(findMatchingRule({ version: '1.1.0' }, rules, false)).toEqual(semverRule); - expect(findMatchingRule({ version: '2.0.0' }, rules, false)).toEqual(semverRule); - expect(findMatchingRule({ version: '2.1.0' }, rules, false)).toBeNull(); - }); - - it('returns the rule for semver prerelease conditions', () => { - /* - https://semver.org/#spec-item-9 - - A pre-release version MAY be denoted by appending a hyphen and a series of dot separated identifiers immediately following the patch version. - Identifiers MUST comprise only ASCII alphanumerics and hyphens [0-9A-Za-z-]. Identifiers MUST NOT be empty. - Numeric identifiers MUST NOT include leading zeroes. Pre-release versions have a lower precedence than the associated normal version. - A pre-release version indicates that the version is unstable and might not satisfy the intended compatibility requirements as denoted by its associated normal version. - Examples: 1.0.0-alpha, 1.0.0-alpha.1, 1.0.0-0.3.7, 1.0.0-x.7.z.92, 1.0.0-x-y-z.--. - - Pre-release versions have a lower precedence than the associated normal version. - For example, 1.0.0-alpha < 1.0.0. - */ - const extendedSemverRule: IRule = { - allocationKey: 'test', - conditions: [ - { - operator: OperatorType.GT, - attribute: 'version', - value: '1.2.3-alpha', - }, - { - operator: OperatorType.LTE, - attribute: 'version', - value: '2.0.0', - }, - ], - }; - const rules = [extendedSemverRule]; - - // is greater than the associated alpha version - expect(findMatchingRule({ version: '1.2.3' }, rules, false)).toEqual(extendedSemverRule); - - // beta is greater than alpha (lexicographically) - expect(findMatchingRule({ version: '1.2.3-beta' }, rules, false)).toEqual(extendedSemverRule); - - // 1.2.4 is greater than 1.2.3 - expect(findMatchingRule({ version: '1.2.4' }, rules, false)).toEqual(extendedSemverRule); - }); - - it('returns the rule for semver build numbers', () => { - /* - https://semver.org/#spec-item-10 - - Build metadata MAY be denoted by appending a plus sign and a series of dot separated identifiers immediately following the patch or pre-release version. - Identifiers MUST comprise only ASCII alphanumerics and hyphens [0-9A-Za-z-]. Identifiers MUST NOT be empty. - Build metadata MUST be ignored when determining version precedence. - Thus two versions that differ only in the build metadata, have the same precedence. - - Examples: 1.0.0-alpha+001, 1.0.0+20130313144700, 1.0.0-beta+exp.sha.5114f85, 1.0.0+21AF26D3----117B344092BD. - */ - - const extendedSemverRule: IRule = { - allocationKey: 'test', - conditions: [ - { - operator: OperatorType.GT, - attribute: 'version', - value: '1.2.3+001', - }, - { - operator: OperatorType.LTE, - attribute: 'version', - value: '2.0.0', - }, - ], - }; - const rules = [extendedSemverRule]; - - // build number is ignored therefore 1.2.3 is not greater than 1.2.3 - expect(findMatchingRule({ version: '1.2.3' }, rules, false)).toEqual(null); - - // build number is ignored therefore 1.2.3 is not greater than 1.2.3 - expect(findMatchingRule({ version: '1.2.3+500' }, rules, false)).toEqual(null); - - // 1.2.4 is greater than 1.2.3 - expect(findMatchingRule({ version: '1.2.4' }, rules, false)).toEqual(extendedSemverRule); - }); - - it('returns the rule for semver mixed prerelease and build numbers', () => { - /* - When a version in Semantic Versioning (SemVer) includes both pre-release identifiers and build metadata, - the version's precedence is determined first by the pre-release version, - with the build metadata being ignored in terms of precedence. - - The format for such a version would look something like this: MAJOR.MINOR.PATCH-prerelease+build - */ - const extendedSemverRule: IRule = { - allocationKey: 'test', - conditions: [ - { - operator: OperatorType.GT, - attribute: 'version', - value: '1.2.3-beta+001', - }, - { - operator: OperatorType.LTE, - attribute: 'version', - value: '2.0.0', - }, - ], - }; - const rules = [extendedSemverRule]; - - // gamma is greater than beta (lexicographically) - expect(findMatchingRule({ version: '1.2.3-gamma' }, rules, false)).toEqual(extendedSemverRule); - - // 1.2.3 is greater than 1.2.3-beta; this is a stable release - expect(findMatchingRule({ version: '1.2.3+500' }, rules, false)).toEqual(extendedSemverRule); - - // 1.2.4 is greater than 1.2.3 - expect(findMatchingRule({ version: '1.2.4' }, rules, false)).toEqual(extendedSemverRule); - }); - - it('returns null if there is no attribute for the condition', () => { - const rules = [numericRule]; - expect(findMatchingRule({ unknown: 'test' }, rules, false)).toEqual(null); - }); - - it('returns the rule if it has no conditions', () => { - const rules = [ruleWithEmptyConditions]; - expect(findMatchingRule({ totalSales: 101 }, rules, false)).toEqual(ruleWithEmptyConditions); - }); + const subjectAttributes = { + totalSales: 50, + version: '1.5.0', + user_id: '12345', + }; - it('allows for a mix of numeric and string values', () => { - const rules = [numericRule, ruleWithMatchesCondition]; - expect(findMatchingRule({ totalSales: 'stringValue' }, rules, false)).toEqual(null); - expect(findMatchingRule({ totalSales: '20' }, rules, false)).toEqual(numericRule); + it('should return true for a rule with empty conditions', () => { + expect(matchesRule(ruleWithEmptyConditions, subjectAttributes, false)).toBe(true); }); - it('handles rule with matches operator', () => { - const rules = [ruleWithMatchesCondition]; - expect(findMatchingRule({ user_id: '14' }, rules, false)).toEqual(ruleWithMatchesCondition); - expect(findMatchingRule({ user_id: 14 }, rules, false)).toEqual(ruleWithMatchesCondition); + it('should return true for a numeric rule that matches the subject attributes', () => { + expect(matchesRule(numericRule, subjectAttributes, false)).toBe(true); }); - it('handles oneOf rule type with boolean', () => { - const oneOfRule: IRule = { - allocationKey: 'test', - conditions: [ - { - operator: OperatorType.ONE_OF, - value: ['true'], - attribute: 'enabled', - }, - ], - }; - const notOneOfRule: IRule = { - allocationKey: 'test', - conditions: [ - { - operator: OperatorType.NOT_ONE_OF, - value: ['true'], - attribute: 'enabled', - }, - ], - }; - expect(findMatchingRule({ enabled: true }, [oneOfRule], false)).toEqual(oneOfRule); - expect(findMatchingRule({ enabled: false }, [oneOfRule], false)).toEqual(null); - expect(findMatchingRule({ enabled: true }, [notOneOfRule], false)).toEqual(null); - expect(findMatchingRule({ enabled: false }, [notOneOfRule], false)).toEqual(notOneOfRule); + it('should return false for a numeric rule that does not match the subject attributes', () => { + const failingAttributes = { totalSales: 101 }; + expect(matchesRule(numericRule, failingAttributes, false)).toBe(false); }); - it('handles oneOf rule type with string', () => { - const oneOfRule: IRule = { - allocationKey: 'test', - conditions: [ - { - operator: OperatorType.ONE_OF, - value: ['user1', 'user2'], - attribute: 'userId', - }, - ], - }; - const notOneOfRule: IRule = { - allocationKey: 'test', - conditions: [ - { - operator: OperatorType.NOT_ONE_OF, - value: ['user14'], - attribute: 'userId', - }, - ], - }; - expect(findMatchingRule({ userId: 'user1' }, [oneOfRule], false)).toEqual(oneOfRule); - expect(findMatchingRule({ userId: 'user2' }, [oneOfRule], false)).toEqual(oneOfRule); - expect(findMatchingRule({ userId: 'user3' }, [oneOfRule], false)).toEqual(null); - expect(findMatchingRule({ userId: 'user14' }, [notOneOfRule], false)).toEqual(null); - expect(findMatchingRule({ userId: 'user15' }, [notOneOfRule], false)).toEqual(notOneOfRule); + it('should return true for a semver rule that matches the subject attributes', () => { + expect(matchesRule(semverRule, subjectAttributes, false)).toBe(true); }); - it('does case insensitive matching with oneOf operator', () => { - const oneOfRule: IRule = { - allocationKey: 'test', - conditions: [ - { - operator: OperatorType.ONE_OF, - value: ['CA', 'US'], - attribute: 'country', - }, - ], - }; - expect(findMatchingRule({ country: 'us' }, [oneOfRule], false)).toEqual(oneOfRule); - expect(findMatchingRule({ country: 'cA' }, [oneOfRule], false)).toEqual(oneOfRule); + it('should return false for a semver rule that does not match the subject attributes', () => { + const failingAttributes = { version: '2.1.0' }; + expect(matchesRule(semverRule, failingAttributes, false)).toBe(false); }); - it('does case insensitive matching with notOneOf operator', () => { - const notOneOf: IRule = { - allocationKey: 'test', - conditions: [ - { - operator: OperatorType.NOT_ONE_OF, - value: ['1.0.BB', '1Ab'], - attribute: 'deviceType', - }, - ], - }; - expect(findMatchingRule({ deviceType: '1ab' }, [notOneOf], false)).toEqual(null); + it('should return true for a rule with matches condition that matches the subject attributes', () => { + expect(matchesRule(ruleWithMatchesCondition, subjectAttributes, false)).toBe(true); }); - it('handles oneOf rule with number', () => { - const oneOfRule: IRule = { - allocationKey: 'test', - conditions: [ - { - operator: OperatorType.ONE_OF, - value: ['1', '2'], - attribute: 'userId', - }, - ], - }; - const notOneOfRule: IRule = { - allocationKey: 'test', - conditions: [ - { - operator: OperatorType.NOT_ONE_OF, - value: ['14'], - attribute: 'userId', - }, - ], - }; - expect(findMatchingRule({ userId: 1 }, [oneOfRule], false)).toEqual(oneOfRule); - expect(findMatchingRule({ userId: '2' }, [oneOfRule], false)).toEqual(oneOfRule); - expect(findMatchingRule({ userId: 3 }, [oneOfRule], false)).toEqual(null); - expect(findMatchingRule({ userId: 14 }, [notOneOfRule], false)).toEqual(null); - expect(findMatchingRule({ userId: '15' }, [notOneOfRule], false)).toEqual(notOneOfRule); + it('should return false for a rule with matches condition that does not match the subject attributes', () => { + const failingAttributes = { user_id: 'abcde' }; + expect(matchesRule(ruleWithMatchesCondition, failingAttributes, false)).toBe(false); }); }); diff --git a/src/rule_evaluator.ts b/src/rule_evaluator.ts index a56b8768..f6418933 100644 --- a/src/rule_evaluator.ts +++ b/src/rule_evaluator.ts @@ -10,22 +10,9 @@ import { import { Condition, OperatorType, IRule, OperatorValueType } from './dto/rule-dto'; import { decodeBase64, getMD5Hash } from './obfuscation'; -export function findMatchingRule( - subjectAttributes: Record<string, any>, - rules: IRule[], - obfuscated: boolean, -): IRule | null { - for (const rule of rules) { - if (matchesRule(subjectAttributes, rule, obfuscated)) { - return rule; - } - } - return null; -} - -function matchesRule( - subjectAttributes: Record<string, any>, +export function matchesRule( rule: IRule, + subjectAttributes: Record<string, any>, obfuscated: boolean, ): boolean { const conditionEvaluations = evaluateRuleConditions( diff --git a/src/shard.ts b/src/shard.ts deleted file mode 100644 index 4ea68fbc..00000000 --- a/src/shard.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as md5 from 'md5'; - -import { IShardRange } from './dto/variation-dto'; - -export function getShard(input: string, subjectShards: number): number { - const hashOutput = md5(input); - // get the first 4 bytes of the md5 hex string and parse it using base 16 - // (8 hex characters represent 4 bytes, e.g. 0xffffffff represents the max 4-byte integer) - const intFromHash = parseInt(hashOutput.slice(0, 8), 16); - return intFromHash % subjectShards; -} - -export function isShardInRange(shard: number, range: IShardRange) { - return shard >= range.start && shard < range.end; -} diff --git a/src/sharders.ts b/src/sharders.ts new file mode 100644 index 00000000..a702ea1d --- /dev/null +++ b/src/sharders.ts @@ -0,0 +1,32 @@ +import { getMD5Hash } from './obfuscation'; + +export abstract class Sharder { + abstract getShard(input: string, totalShards: number): number; +} + +export class MD5Sharder extends Sharder { + getShard(input: string, totalShards: number): number { + const hashOutput = getMD5Hash(input); + // get the first 4 bytes of the md5 hex string and parse it using base 16 + // (8 hex characters represent 4 bytes, e.g. 0xffffffff represents the max 4-byte integer) + const intFromHash = parseInt(hashOutput.slice(0, 8), 16); + return intFromHash % totalShards; + } +} + +export class DeterministicSharder extends Sharder { + /* + Deterministic sharding based on a look-up table + to simplify writing tests + */ + private lookup: Record<string, number>; + + constructor(lookup: Record<string, number>) { + super(); + this.lookup = lookup; + } + + getShard(input: string, totalShards: number): number { + return this.lookup[input] ?? 0; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..db075ec1 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,4 @@ +export type ValueType = string | number | boolean | JSON; +export type AttributeType = string | number | boolean; +export type ConditionValueType = AttributeType | AttributeType[]; +export type SubjectAttributes = { [key: string]: AttributeType }; From 07b9cde524134a4b7e68230e9df75da2b77ed304 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Tue, 26 Mar 2024 17:09:11 -0700 Subject: [PATCH 02/39] making progress --- Makefile | 5 +- src/assignment-logger.ts | 7 +- src/client/eppo-client.spec.ts | 1728 ++++++++------------- src/client/eppo-client.ts | 42 +- src/dto/allocation-dto.ts | 10 - src/dto/experiment-configuration-dto.ts | 12 - src/dto/holdout-dto.ts | 7 - src/dto/variation-dto.ts | 14 - src/eppo_value.spec.ts | 2 +- src/eppo_value.ts | 13 +- src/eval.spec.ts | 39 +- src/eval.ts | 2 +- src/experiment-configuration-requestor.ts | 14 +- src/index.ts | 7 +- src/interfaces.ts | 68 +- src/rule_evaluator.ts | 105 +- test/testHelpers.ts | 34 +- 17 files changed, 842 insertions(+), 1267 deletions(-) delete mode 100644 src/dto/allocation-dto.ts delete mode 100644 src/dto/experiment-configuration-dto.ts delete mode 100644 src/dto/holdout-dto.ts delete mode 100644 src/dto/variation-dto.ts diff --git a/Makefile b/Makefile index 714834bc..e73df080 100644 --- a/Makefile +++ b/Makefile @@ -35,10 +35,7 @@ test-data: rm -rf $(testDataDir) mkdir -p $(tempDir) git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir} - cp ${gitDataDir}rac-experiments-v3.json ${testDataDir} - cp ${gitDataDir}rac-experiments-v3-obfuscated.json ${testDataDir} - cp -r ${gitDataDir}assignment-v2 ${testDataDir} - cp -r ${gitDataDir}assignment-v2-holdouts/. ${testDataDir}assignment-v2 + cp -r ${gitDataDir}ufc ${testDataDir} rm -rf ${tempDir} ## prepare diff --git a/src/assignment-logger.ts b/src/assignment-logger.ts index d1f3a73f..4c2cefcf 100644 --- a/src/assignment-logger.ts +++ b/src/assignment-logger.ts @@ -9,6 +9,7 @@ export type NullableHoldoutVariationType = HoldoutVariationEnum | null; * Holds data about the variation a subject was assigned to. * @public */ + export interface IAssignmentEvent { /** * An Eppo allocation key @@ -42,11 +43,7 @@ export interface IAssignmentEvent { // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record<string, any>; - - /** - * For additional fields to log - */ - [propName: string]: string; + [propName: string]: unknown; } /** diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index de565925..e2e47988 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -1,37 +1,33 @@ /** * @jest-environment jsdom */ -import axios from "axios"; -import * as td from "testdouble"; -import mock, { MockResponse } from "xhr-mock"; +import axios from 'axios'; +import * as td from 'testdouble'; +import mock, { MockResponse } from 'xhr-mock'; import { - IAssignmentTestCase, - MOCK_RAC_RESPONSE_FILE, - OBFUSCATED_MOCK_RAC_RESPONSE_FILE, + MOCK_UFC_RESPONSE_FILE, + OBFUSCATED_MOCK_UFC_RESPONSE_FILE, ValueTestType, readAssignmentTestData, - readMockRacResponse, -} from "../../test/testHelpers"; -import { IAssignmentHooks } from "../assignment-hooks"; -import { IAssignmentLogger } from "../assignment-logger"; -import { IConfigurationStore } from "../configuration-store"; -import { - MAX_EVENT_QUEUE_SIZE, - POLL_INTERVAL_MS, - POLL_JITTER_PCT, -} from "../constants"; -import { OperatorType } from "../dto/rule-dto"; -import { EppoValue } from "../eppo_value"; -import ExperimentConfigurationRequestor from "../experiment-configuration-requestor"; -import HttpClient from "../http-client"; - -import EppoClient, { - ExperimentConfigurationRequestParameters, -} from "./eppo-client"; + readMockUFCResponse, +} from '../../test/testHelpers'; +import { IAssignmentHooks } from '../assignment-hooks'; +import { IAssignmentLogger } from '../assignment-logger'; +import { IConfigurationStore } from '../configuration-store'; +import { MAX_EVENT_QUEUE_SIZE, POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; +import { OperatorType } from '../dto/rule-dto'; +import { EppoValue } from '../eppo_value'; +import { Evaluator } from '../eval'; +import ExperimentConfigurationRequestor from '../experiment-configuration-requestor'; +import HttpClient from '../http-client'; +import { VariationType } from '../interfaces'; +import { MD5Sharder } from '../sharders'; + +import EppoClient, { FlagConfigurationRequestParameters } from './eppo-client'; // eslint-disable-next-line @typescript-eslint/no-var-requires -const packageJson = require("../../package.json"); +const packageJson = require('../../package.json'); class TestConfigurationStore implements IConfigurationStore { private store: Record<string, string> = {}; @@ -46,38 +42,40 @@ class TestConfigurationStore implements IConfigurationStore { this.store[key] = JSON.stringify(val); }); } + + public getKeys(): string[] { + return Object.keys(this.store); + } } export async function init(configurationStore: IConfigurationStore) { const axiosInstance = axios.create({ - baseURL: "http://127.0.0.1:4000", + baseURL: 'http://127.0.0.1:4000', timeout: 1000, }); const httpClient = new HttpClient(axiosInstance, { - apiKey: "dummy", - sdkName: "js-client-sdk-common", + apiKey: 'dummy', + sdkName: 'js-client-sdk-common', sdkVersion: packageJson.version, }); const configurationRequestor = new ExperimentConfigurationRequestor( configurationStore, - httpClient + httpClient, ); await configurationRequestor.fetchAndStoreConfigurations(); } -describe("EppoClient E2E test", () => { - const sessionOverrideSubject = "subject-14"; - const sessionOverrideExperiment = "exp-100"; - +describe('EppoClient E2E test', () => { + // const evaluator = new Evaluator(new MD5Sharder()); const storage = new TestConfigurationStore(); - const globalClient = new EppoClient(storage); + // const globalClient = new EppoClient(evaluator, storage); beforeAll(async () => { mock.setup(); - mock.get(/randomized_assignment\/v3\/config*/, (_req, res) => { - const rac = readMockRacResponse(MOCK_RAC_RESPONSE_FILE); + mock.get(/flag_config\/v1\/config*/, (_req, res) => { + const rac = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE); return res.status(200).body(JSON.stringify(rac)); }); @@ -88,69 +86,60 @@ describe("EppoClient E2E test", () => { mock.teardown(); }); - const flagKey = "mock-experiment"; + const flagKey = 'mock-flag'; + + const variationA = { + key: 'a', + value: 'variation-a', + }; + + const variationB = { + name: 'b', + value: 'variation-b', + }; const mockExperimentConfig = { name: flagKey, enabled: true, - subjectShards: 10000, - overrides: {}, - typedOverrides: {}, - rules: [ + variationType: VariationType.STRING, + variations: { a: variationA, b: variationB }, + allocations: [ { - allocationKey: "allocation1", - conditions: [], - }, - ], - allocations: { - allocation1: { - percentExposure: 1, - statusQuoVariationKey: null, - shippedVariationKey: null, - holdouts: [], - variations: [ - { - name: "control", - value: "control", - typedValue: "control", - shardRange: { - start: 0, - end: 3333, - }, - }, + key: 'allocation-a', + rules: [], + splits: [ { - name: "variant-1", - value: "variant-1", - typedValue: "variant-1", - shardRange: { - start: 3333, - end: 6667, - }, + shards: [ + { salt: 'traffic', ranges: [{ start: 0, end: 10000 }] }, + { salt: 'assignment', ranges: [{ start: 0, end: 5000 }] }, + ], + variationKey: 'a', }, { - name: "variant-2", - value: "variant-2", - typedValue: "variant-2", - shardRange: { - start: 6667, - end: 10000, - }, + shards: [ + { salt: 'traffic', ranges: [{ start: 0, end: 10000 }] }, + { salt: 'assignment', ranges: [{ start: 5000, end: 10000 }] }, + ], + variationKey: 'b', }, ], + doLog: true, }, - }, + ], + totalShards: 10000, }; - describe("error encountered", () => { + describe('error encountered', () => { let client: EppoClient; const mockHooks = td.object<IAssignmentHooks>(); beforeAll(() => { storage.setEntries({ [flagKey]: mockExperimentConfig }); - client = new EppoClient(storage); + const evaluator = new Evaluator(new MD5Sharder()); + client = new EppoClient(evaluator, storage); - td.replace(EppoClient.prototype, "getAssignmentVariation", function () { - throw new Error("So Graceful Error"); + td.replace(EppoClient.prototype, 'getAssignmentVariation', function () { + throw new Error('So Graceful Error'); }); }); @@ -158,107 +147,62 @@ describe("EppoClient E2E test", () => { td.reset(); }); - it("returns null when graceful failure if error encountered", async () => { + it('returns null when graceful failure if error encountered', async () => { client.setIsGracefulFailureMode(true); - expect( - client.getAssignment("subject-identifer", flagKey, {}, mockHooks) - ).toBeNull(); - expect( - client.getBoolAssignment("subject-identifer", flagKey, {}, mockHooks) - ).toBeNull(); - expect( - client.getJSONStringAssignment( - "subject-identifer", - flagKey, - {}, - mockHooks - ) - ).toBeNull(); - expect( - client.getNumericAssignment("subject-identifer", flagKey, {}, mockHooks) - ).toBeNull(); - expect( - client.getParsedJSONAssignment( - "subject-identifer", - flagKey, - {}, - mockHooks - ) - ).toBeNull(); - expect( - client.getStringAssignment("subject-identifer", flagKey, {}, mockHooks) - ).toBeNull(); + expect(client.getBoolAssignment('subject-identifer', flagKey, {})).toBeNull(); + expect(client.getNumericAssignment('subject-identifer', flagKey, {})).toBeNull(); + expect(client.getParsedJSONAssignment('subject-identifer', flagKey, {})).toBeNull(); + expect(client.getStringAssignment('subject-identifer', flagKey, {})).toBeNull(); }); - it("throws error when graceful failure is false", async () => { + it('throws error when graceful failure is false', async () => { client.setIsGracefulFailureMode(false); expect(() => { - client.getAssignment("subject-identifer", flagKey, {}, mockHooks); - }).toThrow(); - - expect(() => { - client.getBoolAssignment("subject-identifer", flagKey, {}, mockHooks); - }).toThrow(); - - expect(() => { - client.getJSONStringAssignment( - "subject-identifer", - flagKey, - {}, - mockHooks - ); + client.getBoolAssignment('subject-identifer', flagKey, {}); }).toThrow(); expect(() => { - client.getParsedJSONAssignment( - "subject-identifer", - flagKey, - {}, - mockHooks - ); + client.getParsedJSONAssignment('subject-identifer', flagKey, {}); }).toThrow(); expect(() => { - client.getNumericAssignment( - "subject-identifer", - flagKey, - {}, - mockHooks - ); + client.getNumericAssignment('subject-identifer', flagKey, {}); }).toThrow(); expect(() => { - client.getStringAssignment("subject-identifer", flagKey, {}, mockHooks); + client.getStringAssignment('subject-identifer', flagKey, {}); }).toThrow(); }); }); - describe("setLogger", () => { + describe('setLogger', () => { beforeAll(() => { storage.setEntries({ [flagKey]: mockExperimentConfig }); }); - it("Invokes logger for queued events", () => { + it('Invokes logger for queued events', () => { const mockLogger = td.object<IAssignmentLogger>(); - const client = new EppoClient(storage); - client.getAssignment("subject-to-be-logged", flagKey); + const evaluator = new Evaluator(new MD5Sharder()); + const client = new EppoClient(evaluator, storage); + client.getStringAssignment('subject-to-be-logged', flagKey); client.setLogger(mockLogger); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); - expect( - td.explain(mockLogger.logAssignment).calls[0].args[0].subject - ).toEqual("subject-to-be-logged"); + expect(td.explain(mockLogger.logAssignment).calls[0].args[0].subject).toEqual( + 'subject-to-be-logged', + ); }); - it("Does not log same queued event twice", () => { + it('Does not log same queued event twice', () => { const mockLogger = td.object<IAssignmentLogger>(); - const client = new EppoClient(storage); + const evaluator = new Evaluator(new MD5Sharder()); + const client = new EppoClient(evaluator, storage); - client.getAssignment("subject-to-be-logged", flagKey); + client.getStringAssignment('subject-to-be-logged', flagKey); client.setLogger(mockLogger); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); @@ -266,179 +210,111 @@ describe("EppoClient E2E test", () => { expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); }); - it("Does not invoke logger for events that exceed queue size", () => { + it('Does not invoke logger for events that exceed queue size', () => { const mockLogger = td.object<IAssignmentLogger>(); - const client = new EppoClient(storage); + const evaluator = new Evaluator(new MD5Sharder()); + const client = new EppoClient(evaluator, storage); + for (let i = 0; i < MAX_EVENT_QUEUE_SIZE + 100; i++) { - client.getAssignment(`subject-to-be-logged-${i}`, flagKey); + client.getStringAssignment(`subject-to-be-logged-${i}`, flagKey); } client.setLogger(mockLogger); - expect(td.explain(mockLogger.logAssignment).callCount).toEqual( - MAX_EVENT_QUEUE_SIZE - ); + expect(td.explain(mockLogger.logAssignment).callCount).toEqual(MAX_EVENT_QUEUE_SIZE); }); }); - describe("getAssignment", () => { - it.each(readAssignmentTestData())( - "test variation assignment splits", - async ({ - experiment, - valueType = ValueTestType.StringType, - subjects, - subjectsWithAttributes, - expectedAssignments, - }: IAssignmentTestCase) => { - `---- Test Case for ${experiment} Experiment ----`; - - const assignments = getAssignmentsWithSubjectAttributes( - subjectsWithAttributes - ? subjectsWithAttributes - : subjects.map((subject) => ({ subjectKey: subject })), - experiment, - valueType - ); - - switch (valueType) { - case ValueTestType.BoolType: { - const boolAssignments = assignments.map( - (a) => a?.boolValue ?? null - ); - expect(boolAssignments).toEqual(expectedAssignments); - break; - } - case ValueTestType.NumericType: { - const numericAssignments = assignments.map( - (a) => a?.numericValue ?? null - ); - expect(numericAssignments).toEqual(expectedAssignments); - break; - } - case ValueTestType.StringType: { - const stringAssignments = assignments.map( - (a) => a?.stringValue ?? null - ); - expect(stringAssignments).toEqual(expectedAssignments); - break; - } - case ValueTestType.JSONType: { - const jsonStringAssignments = assignments.map( - (a) => a?.stringValue ?? null - ); - expect(jsonStringAssignments).toEqual(expectedAssignments); - break; - } - } - } - ); - }); - - it("returns null if getAssignment was called for the subject before any RAC was loaded", () => { - expect( - globalClient.getAssignment( - sessionOverrideSubject, - sessionOverrideExperiment - ) - ).toEqual(null); - }); - - it("returns subject from overrides when enabled is true", () => { - const entry = { - ...mockExperimentConfig, - enabled: false, - overrides: { - "1b50f33aef8f681a13f623963da967ed": "override", - }, - typedOverrides: { - "1b50f33aef8f681a13f623963da967ed": "override", - }, - }; - - storage.setEntries({ [flagKey]: entry }); - - const client = new EppoClient(storage); - const mockLogger = td.object<IAssignmentLogger>(); - client.setLogger(mockLogger); - - const assignment = client.getAssignment("subject-10", flagKey); - expect(assignment).toEqual("override"); - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(0); - }); - - it("returns subject from overrides when enabled is false", () => { - const entry = { - ...mockExperimentConfig, - enabled: false, - overrides: { - "1b50f33aef8f681a13f623963da967ed": "override", - }, - typedOverrides: { - "1b50f33aef8f681a13f623963da967ed": "override", - }, - }; - - storage.setEntries({ [flagKey]: entry }); - - const client = new EppoClient(storage); - const mockLogger = td.object<IAssignmentLogger>(); - client.setLogger(mockLogger); - const assignment = client.getAssignment("subject-10", flagKey); - expect(assignment).toEqual("override"); - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(0); - }); - - it("logs variation assignment and experiment key", () => { + // describe('getStringAssignment', () => { + // it.each(readAssignmentTestData())( + // 'test variation assignment splits', + // async ({ + // experiment, + // valueType = ValueTestType.StringType, + // subjects, + // subjectsWithAttributes, + // expectedAssignments, + // }: IAssignmentTestCase) => { + // `---- Test Case for ${experiment} Experiment ----`; + + // const assignments = getAssignmentsWithSubjectAttributes( + // subjectsWithAttributes + // ? subjectsWithAttributes + // : subjects.map((subject) => ({ subjectKey: subject })), + // experiment, + // valueType, + // ); + + // switch (valueType) { + // case ValueTestType.BoolType: { + // const boolAssignments = assignments.map((a) => a?.boolValue ?? null); + // expect(boolAssignments).toEqual(expectedAssignments); + // break; + // } + // case ValueTestType.NumericType: { + // const numericAssignments = assignments.map((a) => a?.numericValue ?? null); + // expect(numericAssignments).toEqual(expectedAssignments); + // break; + // } + // case ValueTestType.StringType: { + // const stringAssignments = assignments.map((a) => a?.stringValue ?? null); + // expect(stringAssignments).toEqual(expectedAssignments); + // break; + // } + // case ValueTestType.JSONType: { + // const jsonStringAssignments = assignments.map((a) => a?.stringValue ?? null); + // expect(jsonStringAssignments).toEqual(expectedAssignments); + // break; + // } + // } + // }, + // ); + // }); + + // it('returns null if getStringAssignment was called for the subject before any RAC was loaded', () => { + // expect( + // globalClient.getStringAssignment(sessionOverrideSubject, sessionOverrideExperiment), + // ).toEqual(null); + // }); + + it('logs variation assignment and experiment key', () => { const mockLogger = td.object<IAssignmentLogger>(); storage.setEntries({ [flagKey]: mockExperimentConfig }); - const client = new EppoClient(storage); + const evaluator = new Evaluator(new MD5Sharder()); + const client = new EppoClient(evaluator, storage); client.setLogger(mockLogger); const subjectAttributes = { foo: 3 }; - const assignment = client.getAssignment( - "subject-10", - flagKey, - subjectAttributes - ); + const assignment = client.getStringAssignment('subject-10', flagKey, subjectAttributes); - expect(assignment).toEqual("control"); + expect(assignment).toEqual('control'); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); - expect( - td.explain(mockLogger.logAssignment).calls[0].args[0].subject - ).toEqual("subject-10"); - expect( - td.explain(mockLogger.logAssignment).calls[0].args[0].featureFlag - ).toEqual(flagKey); - expect( - td.explain(mockLogger.logAssignment).calls[0].args[0].experiment - ).toEqual(`${flagKey}-${mockExperimentConfig.rules[0].allocationKey}`); - expect( - td.explain(mockLogger.logAssignment).calls[0].args[0].allocation - ).toEqual(`${mockExperimentConfig.rules[0].allocationKey}`); + expect(td.explain(mockLogger.logAssignment).calls[0].args[0].subject).toEqual('subject-10'); + expect(td.explain(mockLogger.logAssignment).calls[0].args[0].featureFlag).toEqual(flagKey); + // expect(td.explain(mockLogger.logAssignment).calls[0].args[0].experiment).toEqual( + // `${flagKey}-${mockExperimentConfig.rules[0].allocationKey}`, + // ); + // expect(td.explain(mockLogger.logAssignment).calls[0].args[0].allocation).toEqual( + // `${mockExperimentConfig.rules[0].allocationKey}`, + // ); }); - it("handles logging exception", () => { + it('handles logging exception', () => { const mockLogger = td.object<IAssignmentLogger>(); - td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow( - new Error("logging error") - ); + td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(new Error('logging error')); storage.setEntries({ [flagKey]: mockExperimentConfig }); - const client = new EppoClient(storage); + const evaluator = new Evaluator(new MD5Sharder()); + const client = new EppoClient(evaluator, storage); client.setLogger(mockLogger); const subjectAttributes = { foo: 3 }; - const assignment = client.getAssignment( - "subject-10", - flagKey, - subjectAttributes - ); + const assignment = client.getStringAssignment('subject-10', flagKey, subjectAttributes); - expect(assignment).toEqual("control"); + expect(assignment).toEqual('control'); }); - describe("assignment logging deduplication", () => { + describe('assignment logging deduplication', () => { let client: EppoClient; let mockLogger: IAssignmentLogger; @@ -446,91 +322,93 @@ describe("EppoClient E2E test", () => { mockLogger = td.object<IAssignmentLogger>(); storage.setEntries({ [flagKey]: mockExperimentConfig }); - client = new EppoClient(storage); + const evaluator = new Evaluator(new MD5Sharder()); + const client = new EppoClient(evaluator, storage); client.setLogger(mockLogger); }); - it("logs duplicate assignments without an assignment cache", () => { + it('logs duplicate assignments without an assignment cache', () => { client.disableAssignmentCache(); - client.getAssignment("subject-10", flagKey); - client.getAssignment("subject-10", flagKey); + client.getStringAssignment('subject-10', flagKey); + client.getStringAssignment('subject-10', flagKey); // call count should be 2 because there is no cache. expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2); }); - it("does not log duplicate assignments", () => { + it('does not log duplicate assignments', () => { client.useNonExpiringInMemoryAssignmentCache(); - client.getAssignment("subject-10", flagKey); - client.getAssignment("subject-10", flagKey); + client.getStringAssignment('subject-10', flagKey); + client.getStringAssignment('subject-10', flagKey); // call count should be 1 because the second call is a cache hit and not logged. expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); }); - it("logs assignment again after the lru cache is full", () => { + it('logs assignment again after the lru cache is full', () => { client.useLRUInMemoryAssignmentCache(2); - client.getAssignment("subject-10", flagKey); // logged - client.getAssignment("subject-10", flagKey); // cached + client.getStringAssignment('subject-10', flagKey); // logged + client.getStringAssignment('subject-10', flagKey); // cached - client.getAssignment("subject-11", flagKey); // logged - client.getAssignment("subject-11", flagKey); // cached + client.getStringAssignment('subject-11', flagKey); // logged + client.getStringAssignment('subject-11', flagKey); // cached - client.getAssignment("subject-12", flagKey); // cache evicted subject-10, logged - client.getAssignment("subject-10", flagKey); // previously evicted, logged - client.getAssignment("subject-12", flagKey); // cached + client.getStringAssignment('subject-12', flagKey); // cache evicted subject-10, logged + client.getStringAssignment('subject-10', flagKey); // previously evicted, logged + client.getStringAssignment('subject-12', flagKey); // cached expect(td.explain(mockLogger.logAssignment).callCount).toEqual(4); }); - it("does not cache assignments if the logger had an exception", () => { + it('does not cache assignments if the logger had an exception', () => { td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow( - new Error("logging error") + new Error('logging error'), ); - const client = new EppoClient(storage); + const evaluator = new Evaluator(new MD5Sharder()); + const client = new EppoClient(evaluator, storage); client.setLogger(mockLogger); - client.getAssignment("subject-10", flagKey); - client.getAssignment("subject-10", flagKey); + client.getStringAssignment('subject-10', flagKey); + client.getStringAssignment('subject-10', flagKey); // call count should be 2 because the first call had an exception // therefore we are not sure the logger was successful and try again. expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2); }); - it("logs for each unique flag", () => { + it('logs for each unique flag', () => { storage.setEntries({ [flagKey]: mockExperimentConfig, - "flag-2": { + 'flag-2': { ...mockExperimentConfig, - name: "flag-2", + name: 'flag-2', }, - "flag-3": { + 'flag-3': { ...mockExperimentConfig, - name: "flag-3", + name: 'flag-3', }, }); client.useNonExpiringInMemoryAssignmentCache(); - client.getAssignment("subject-10", flagKey); - client.getAssignment("subject-10", flagKey); - client.getAssignment("subject-10", "flag-2"); - client.getAssignment("subject-10", "flag-2"); - client.getAssignment("subject-10", "flag-3"); - client.getAssignment("subject-10", "flag-3"); - client.getAssignment("subject-10", flagKey); - client.getAssignment("subject-10", "flag-2"); - client.getAssignment("subject-10", "flag-3"); + client.getStringAssignment('subject-10', flagKey); + client.getStringAssignment('subject-10', flagKey); + client.getStringAssignment('subject-10', 'flag-2'); + client.getStringAssignment('subject-10', 'flag-2'); + client.getStringAssignment('subject-10', 'flag-3'); + client.getStringAssignment('subject-10', 'flag-3'); + client.getStringAssignment('subject-10', flagKey); + client.getStringAssignment('subject-10', 'flag-2'); + client.getStringAssignment('subject-10', 'flag-3'); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(3); }); - it("logs twice for the same flag when rollout increases/flag changes", () => { + it('logs twice for the same flag when rollout increases/flag changes', () => { client.useNonExpiringInMemoryAssignmentCache(); storage.setEntries({ @@ -544,18 +422,18 @@ describe("EppoClient E2E test", () => { holdouts: [], variations: [ { - name: "control", - value: "control", - typedValue: "control", + name: 'control', + value: 'control', + typedValue: 'control', shardRange: { start: 0, end: 10000, }, }, { - name: "treatment", - value: "treatment", - typedValue: "treatment", + name: 'treatment', + value: 'treatment', + typedValue: 'treatment', shardRange: { start: 0, end: 0, @@ -566,7 +444,7 @@ describe("EppoClient E2E test", () => { }, }, }); - client.getAssignment("subject-10", flagKey); + client.getStringAssignment('subject-10', flagKey); storage.setEntries({ [flagKey]: { @@ -579,18 +457,18 @@ describe("EppoClient E2E test", () => { holdouts: [], variations: [ { - name: "control", - value: "control", - typedValue: "control", + name: 'control', + value: 'control', + typedValue: 'control', shardRange: { start: 0, end: 0, }, }, { - name: "treatment", - value: "treatment", - typedValue: "treatment", + name: 'treatment', + value: 'treatment', + typedValue: 'treatment', shardRange: { start: 0, end: 10000, @@ -601,18 +479,18 @@ describe("EppoClient E2E test", () => { }, }, }); - client.getAssignment("subject-10", flagKey); + client.getStringAssignment('subject-10', flagKey); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2); }); - it("logs the same subject/flag/variation after two changes", () => { + it('logs the same subject/flag/variation after two changes', () => { client.useNonExpiringInMemoryAssignmentCache(); // original configuration version storage.setEntries({ [flagKey]: mockExperimentConfig }); - client.getAssignment("subject-10", flagKey); // log this assignment - client.getAssignment("subject-10", flagKey); // cache hit, don't log + client.getStringAssignment('subject-10', flagKey); // log this assignment + client.getStringAssignment('subject-10', flagKey); // cache hit, don't log // change the flag storage.setEntries({ @@ -626,9 +504,9 @@ describe("EppoClient E2E test", () => { holdouts: [], variations: [ { - name: "some-new-treatment", - value: "some-new-treatment", - typedValue: "some-new-treatment", + name: 'some-new-treatment', + value: 'some-new-treatment', + typedValue: 'some-new-treatment', shardRange: { start: 0, end: 10000, @@ -640,29 +518,29 @@ describe("EppoClient E2E test", () => { }, }); - client.getAssignment("subject-10", flagKey); // log this assignment - client.getAssignment("subject-10", flagKey); // cache hit, don't log + client.getStringAssignment('subject-10', flagKey); // log this assignment + client.getStringAssignment('subject-10', flagKey); // cache hit, don't log // change the flag again, back to the original storage.setEntries({ [flagKey]: mockExperimentConfig }); - client.getAssignment("subject-10", flagKey); // important: log this assignment - client.getAssignment("subject-10", flagKey); // cache hit, don't log + client.getStringAssignment('subject-10', flagKey); // important: log this assignment + client.getStringAssignment('subject-10', flagKey); // cache hit, don't log expect(td.explain(mockLogger.logAssignment).callCount).toEqual(3); }); }); - it("only returns variation if subject matches rules", () => { + it('only returns variation if subject matches rules', () => { const entry = { ...mockExperimentConfig, rules: [ { - allocationKey: "allocation1", + allocationKey: 'allocation1', conditions: [ { operator: OperatorType.GT, - attribute: "appVersion", + attribute: 'appVersion', value: 10, }, ], @@ -672,742 +550,422 @@ describe("EppoClient E2E test", () => { storage.setEntries({ [flagKey]: entry }); - const client = new EppoClient(storage); - let assignment = client.getAssignment("subject-10", flagKey, { + const evaluator = new Evaluator(new MD5Sharder()); + const client = new EppoClient(evaluator, storage); + let assignment = client.getStringAssignment('subject-10', flagKey, { appVersion: 9, }); expect(assignment).toBeNull(); - assignment = client.getAssignment("subject-10", flagKey); + assignment = client.getStringAssignment('subject-10', flagKey); expect(assignment).toBeNull(); - assignment = client.getAssignment("subject-10", flagKey, { + assignment = client.getStringAssignment('subject-10', flagKey, { appVersion: 11, }); - expect(assignment).toEqual("control"); - }); - - it("returns control variation and logs holdout key if subject is in holdout in an experiment allocation", () => { - const entry = { - ...mockExperimentConfig, - allocations: { - allocation1: { - percentExposure: 1, - statusQuoVariationKey: "variation-7", - shippedVariationKey: null, - holdouts: [ - { - holdoutKey: "holdout-2", - statusQuoShardRange: { - start: 0, - end: 200, - }, - shippedShardRange: null, // this is an experiment allocation because shippedShardRange is null - }, - { - holdoutKey: "holdout-3", - statusQuoShardRange: { - start: 200, - end: 400, - }, - shippedShardRange: null, - }, - ], - variations: [ - { - name: "control", - value: "control", - typedValue: "control", - shardRange: { - start: 0, - end: 3333, - }, - variationKey: "variation-7", - }, - { - name: "variant-1", - value: "variant-1", - typedValue: "variant-1", - shardRange: { - start: 3333, - end: 6667, - }, - variationKey: "variation-8", - }, - { - name: "variant-2", - value: "variant-2", - typedValue: "variant-2", - shardRange: { - start: 6667, - end: 10000, - }, - variationKey: "variation-9", - }, - ], - }, - }, - }; - - storage.setEntries({ [flagKey]: entry }); - - const mockLogger = td.object<IAssignmentLogger>(); - const client = new EppoClient(storage); - client.setLogger(mockLogger); - td.reset(); - - // subject-79 --> holdout shard is 186 - let assignment = client.getAssignment("subject-79", flagKey); - expect(assignment).toEqual("control"); - // Only log holdout key (not variation) if this is an experiment allocation - expect( - td.explain(mockLogger.logAssignment).calls[0].args[0].holdoutVariation - ).toBeNull(); - expect( - td.explain(mockLogger.logAssignment).calls[0].args[0].holdout - ).toEqual("holdout-2"); - - // subject-8 --> holdout shard is 201 - assignment = client.getAssignment("subject-8", flagKey); - expect(assignment).toEqual("control"); - // Only log holdout key (not variation) if this is an experiment allocation - expect( - td.explain(mockLogger.logAssignment).calls[1].args[0].holdoutVariation - ).toBeNull(); - expect( - td.explain(mockLogger.logAssignment).calls[1].args[0].holdout - ).toEqual("holdout-3"); - - // subject-11 --> holdout shard is 9137 (outside holdout), non-holdout assignment shard is 8414 - assignment = client.getAssignment("subject-11", flagKey); - expect(assignment).toEqual("variant-2"); - expect( - td.explain(mockLogger.logAssignment).calls[2].args[0].holdoutVariation - ).toBeNull(); - expect( - td.explain(mockLogger.logAssignment).calls[2].args[0].holdout - ).toBeNull(); - }); - - it("returns the shipped variation and logs holdout key and variation if subject is in holdout in a rollout allocation", () => { - const entry = { - ...mockExperimentConfig, - allocations: { - allocation1: { - percentExposure: 1, - statusQuoVariationKey: "variation-7", - shippedVariationKey: "variation-8", - holdouts: [ - { - holdoutKey: "holdout-2", - statusQuoShardRange: { - start: 0, - end: 100, - }, - shippedShardRange: { - start: 100, - end: 200, - }, - }, - { - holdoutKey: "holdout-3", - statusQuoShardRange: { - start: 200, - end: 300, - }, - shippedShardRange: { - start: 300, - end: 400, - }, - }, - ], - variations: [ - { - name: "control", - value: "control", - typedValue: "control", - shardRange: { - start: 0, - end: 0, - }, - variationKey: "variation-7", - }, - { - name: "variant-1", - value: "variant-1", - typedValue: "variant-1", - shardRange: { - start: 0, - end: 0, - }, - variationKey: "variation-8", - }, - { - name: "variant-2", - value: "variant-2", - typedValue: "variant-2", - shardRange: { - start: 0, - end: 10000, - }, - variationKey: "variation-9", - }, - ], - }, - }, - }; - - storage.setEntries({ [flagKey]: entry }); - - const mockLogger = td.object<IAssignmentLogger>(); - const client = new EppoClient(storage); - client.setLogger(mockLogger); - td.reset(); - - // subject-227 --> holdout shard is 57 - let assignment = client.getAssignment("subject-227", flagKey); - expect(assignment).toEqual("control"); - // Log both holdout key and variation if this is a rollout allocation - expect( - td.explain(mockLogger.logAssignment).calls[0].args[0].holdoutVariation - ).toEqual("status_quo"); - expect( - td.explain(mockLogger.logAssignment).calls[0].args[0].holdout - ).toEqual("holdout-2"); - - // subject-79 --> holdout shard is 186 - assignment = client.getAssignment("subject-79", flagKey); - expect(assignment).toEqual("variant-1"); - // Log both holdout key and variation if this is a rollout allocation - expect( - td.explain(mockLogger.logAssignment).calls[1].args[0].holdoutVariation - ).toEqual("all_shipped_variants"); - expect( - td.explain(mockLogger.logAssignment).calls[1].args[0].holdout - ).toEqual("holdout-2"); - - // subject-8 --> holdout shard is 201 - assignment = client.getAssignment("subject-8", flagKey); - expect(assignment).toEqual("control"); - // Log both holdout key and variation if this is a rollout allocation - expect( - td.explain(mockLogger.logAssignment).calls[2].args[0].holdoutVariation - ).toEqual("status_quo"); - expect( - td.explain(mockLogger.logAssignment).calls[2].args[0].holdout - ).toEqual("holdout-3"); - - // subject-50 --> holdout shard is 347 - assignment = client.getAssignment("subject-50", flagKey); - expect(assignment).toEqual("variant-1"); - // Log both holdout key and variation if this is a rollout allocation - expect( - td.explain(mockLogger.logAssignment).calls[3].args[0].holdoutVariation - ).toEqual("all_shipped_variants"); - expect( - td.explain(mockLogger.logAssignment).calls[3].args[0].holdout - ).toEqual("holdout-3"); - - // subject-7 --> holdout shard is 9483 (outside holdout), non-holdout assignment shard is 8673 - assignment = client.getAssignment("subject-7", flagKey); - expect(assignment).toEqual("variant-2"); - expect( - td.explain(mockLogger.logAssignment).calls[4].args[0].holdoutVariation - ).toBeNull(); - expect( - td.explain(mockLogger.logAssignment).calls[4].args[0].holdout - ).toBeNull(); - }); - - function getAssignmentsWithSubjectAttributes( - subjectsWithAttributes: { - subjectKey: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record<string, any>; - }[], - experiment: string, - valueTestType: ValueTestType = ValueTestType.StringType, - obfuscated = false - ): (EppoValue | null)[] { - return subjectsWithAttributes.map((subject) => { - switch (valueTestType) { - case ValueTestType.BoolType: { - const ba = globalClient.getBoolAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes, - undefined, - obfuscated - ); - if (ba === null) return null; - return EppoValue.Bool(ba); - } - case ValueTestType.NumericType: { - const na = globalClient.getNumericAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes - ); - if (na === null) return null; - return EppoValue.Numeric(na); - } - case ValueTestType.StringType: { - const sa = globalClient.getStringAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes - ); - if (sa === null) return null; - return EppoValue.String(sa); - } - case ValueTestType.JSONType: { - const sa = globalClient.getJSONStringAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes - ); - const oa = globalClient.getParsedJSONAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes - ); - if (oa == null || sa === null) return null; - return EppoValue.JSON(sa, oa); - } - } - }); - } - - describe("getAssignment with hooks", () => { - let client: EppoClient; - - beforeAll(() => { - storage.setEntries({ [flagKey]: mockExperimentConfig }); - client = new EppoClient(storage); - }); - - describe("onPreAssignment", () => { - it("called with experiment key and subject id", () => { - const mockHooks = td.object<IAssignmentHooks>(); - client.getAssignment("subject-identifer", flagKey, {}, mockHooks); - expect(td.explain(mockHooks.onPreAssignment).callCount).toEqual(1); - expect(td.explain(mockHooks.onPreAssignment).calls[0].args[0]).toEqual( - flagKey - ); - expect(td.explain(mockHooks.onPreAssignment).calls[0].args[1]).toEqual( - "subject-identifer" - ); - }); - - it("overrides returned assignment", async () => { - const mockLogger = td.object<IAssignmentLogger>(); - client.setLogger(mockLogger); - td.reset(); - const variation = await client.getAssignment( - "subject-identifer", - flagKey, - {}, - { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onPreAssignment( - experimentKey: string, - subject: string - ): EppoValue | null { - return EppoValue.String("my-overridden-variation"); - }, - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onPostAssignment( - experimentKey: string, // eslint-disable-line @typescript-eslint/no-unused-vars - subject: string, // eslint-disable-line @typescript-eslint/no-unused-vars - variation: EppoValue | null // eslint-disable-line @typescript-eslint/no-unused-vars - ): void { - // no-op - }, - } - ); - - expect(variation).toEqual("my-overridden-variation"); - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(0); - }); - - it("uses regular assignment logic if onPreAssignment returns null", async () => { - const mockLogger = td.object<IAssignmentLogger>(); - client.setLogger(mockLogger); - td.reset(); - const variation = await client.getAssignment( - "subject-identifer", - flagKey, - {}, - { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onPreAssignment( - experimentKey: string, - subject: string - ): EppoValue | null { - return null; - }, - - onPostAssignment( - experimentKey: string, // eslint-disable-line @typescript-eslint/no-unused-vars - subject: string, // eslint-disable-line @typescript-eslint/no-unused-vars - variation: EppoValue | null // eslint-disable-line @typescript-eslint/no-unused-vars - ): void { - // no-op - }, - } - ); - - expect(variation).not.toBeNull(); - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); - }); - }); - - describe("onPostAssignment", () => { - it("called with assigned variation after assignment", async () => { - const mockHooks = td.object<IAssignmentHooks>(); - const subject = "subject-identifier"; - const variation = client.getAssignment(subject, flagKey, {}, mockHooks); - expect(td.explain(mockHooks.onPostAssignment).callCount).toEqual(1); - expect(td.explain(mockHooks.onPostAssignment).callCount).toEqual(1); - expect(td.explain(mockHooks.onPostAssignment).calls[0].args[0]).toEqual( - flagKey - ); - expect(td.explain(mockHooks.onPostAssignment).calls[0].args[1]).toEqual( - subject - ); - expect(td.explain(mockHooks.onPostAssignment).calls[0].args[2]).toEqual( - EppoValue.String(variation ?? "") - ); - }); - }); - }); -}); - -describe(" EppoClient getAssignment From Obfuscated RAC", () => { - const storage = new TestConfigurationStore(); - const globalClient = new EppoClient(storage); - - beforeAll(async () => { - mock.setup(); - mock.get(/randomized_assignment\/v3\/config*/, (_req, res) => { - const rac = readMockRacResponse(OBFUSCATED_MOCK_RAC_RESPONSE_FILE); - return res.status(200).body(JSON.stringify(rac)); - }); - await init(storage); - }); - - afterAll(() => { - mock.teardown(); - }); - - it.each(readAssignmentTestData())( - "test variation assignment splits", - async ({ - experiment, - valueType = ValueTestType.StringType, - subjects, - subjectsWithAttributes, - expectedAssignments, - }: IAssignmentTestCase) => { - `---- Test Case for ${experiment} Experiment ----`; - - const assignments = getAssignmentsWithSubjectAttributes( - subjectsWithAttributes - ? subjectsWithAttributes - : subjects.map((subject) => ({ subjectKey: subject })), - experiment, - valueType - ); - - switch (valueType) { - case ValueTestType.BoolType: { - const boolAssignments = assignments.map((a) => a?.boolValue ?? null); - expect(boolAssignments).toEqual(expectedAssignments); - break; - } - case ValueTestType.NumericType: { - const numericAssignments = assignments.map( - (a) => a?.numericValue ?? null - ); - expect(numericAssignments).toEqual(expectedAssignments); - break; - } - case ValueTestType.StringType: { - const stringAssignments = assignments.map( - (a) => a?.stringValue ?? null - ); - expect(stringAssignments).toEqual(expectedAssignments); - break; - } - case ValueTestType.JSONType: { - const jsonStringAssignments = assignments.map( - (a) => a?.stringValue ?? null - ); - expect(jsonStringAssignments).toEqual(expectedAssignments); - break; - } - } - } - ); - - function getAssignmentsWithSubjectAttributes( - subjectsWithAttributes: { - subjectKey: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record<string, any>; - }[], - experiment: string, - valueTestType: ValueTestType = ValueTestType.StringType - ): (EppoValue | null)[] { - return subjectsWithAttributes.map((subject) => { - switch (valueTestType) { - case ValueTestType.BoolType: { - const ba = globalClient.getBoolAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes, - undefined, - true - ); - if (ba === null) return null; - return EppoValue.Bool(ba); - } - case ValueTestType.NumericType: { - const na = globalClient.getNumericAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes, - undefined, - true - ); - if (na === null) return null; - return EppoValue.Numeric(na); - } - case ValueTestType.StringType: { - const sa = globalClient.getStringAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes, - undefined, - true - ); - if (sa === null) return null; - return EppoValue.String(sa); - } - case ValueTestType.JSONType: { - const sa = globalClient.getJSONStringAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes, - undefined, - true - ); - const oa = globalClient.getParsedJSONAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes, - undefined, - true - ); - if (oa == null || sa === null) return null; - return EppoValue.JSON(sa, oa); - } - } - }); - } -}); - -describe("Eppo Client constructed with configuration request parameters", () => { - let client: EppoClient; - let storage: IConfigurationStore; - let requestConfiguration: ExperimentConfigurationRequestParameters; - let mockServerResponseFunc: (res: MockResponse) => MockResponse; - - const racBody = JSON.stringify(readMockRacResponse(MOCK_RAC_RESPONSE_FILE)); - const flagKey = "randomization_algo"; - const subjectForGreenVariation = "subject-identiferA"; - - const maxRetryDelay = POLL_INTERVAL_MS * POLL_JITTER_PCT; - - beforeAll(() => { - mock.setup(); - mock.get(/randomized_assignment\/v3\/config*/, (_req, res) => { - return mockServerResponseFunc(res); - }); - }); - - beforeEach(() => { - storage = new TestConfigurationStore(); - requestConfiguration = { - apiKey: "dummy key", - sdkName: "js-client-sdk-common", - sdkVersion: packageJson.version, - }; - mockServerResponseFunc = (res) => res.status(200).body(racBody); - - // We only want to fake setTimeout() and clearTimeout() - jest.useFakeTimers({ - advanceTimers: true, - doNotFake: [ - "Date", - "hrtime", - "nextTick", - "performance", - "queueMicrotask", - "requestAnimationFrame", - "cancelAnimationFrame", - "requestIdleCallback", - "cancelIdleCallback", - "setImmediate", - "clearImmediate", - "setInterval", - "clearInterval", - ], - }); - }); - - afterEach(() => { - jest.clearAllTimers(); - jest.useRealTimers(); - }); - - afterAll(() => { - mock.teardown(); - }); - - it("Fetches initial configuration with parameters in constructor", async () => { - client = new EppoClient(storage, requestConfiguration); - client.setIsGracefulFailureMode(false); - // no configuration loaded - let variation = client.getAssignment(subjectForGreenVariation, flagKey); - expect(variation).toBeNull(); - // have client fetch configurations - await client.fetchFlagConfigurations(); - variation = client.getAssignment(subjectForGreenVariation, flagKey); - expect(variation).toBe("green"); - }); - - it("Fetches initial configuration with parameters provided later", async () => { - client = new EppoClient(storage); - client.setIsGracefulFailureMode(false); - client.setConfigurationRequestParameters(requestConfiguration); - // no configuration loaded - let variation = client.getAssignment(subjectForGreenVariation, flagKey); - expect(variation).toBeNull(); - // have client fetch configurations - await client.fetchFlagConfigurations(); - variation = client.getAssignment(subjectForGreenVariation, flagKey); - expect(variation).toBe("green"); + expect(assignment).toEqual('control'); }); - it.each([ - { pollAfterSuccessfulInitialization: false }, - { pollAfterSuccessfulInitialization: true }, - ])( - "retries initial configuration request with config %p", - async (configModification) => { - let callCount = 0; - mockServerResponseFunc = (res) => { - if (++callCount === 1) { - // Throw an error for the first call - return res.status(500); - } else { - // Return a mock object for subsequent calls - return res.status(200).body(racBody); - } - }; - - const { pollAfterSuccessfulInitialization } = configModification; - requestConfiguration = { - ...requestConfiguration, - pollAfterSuccessfulInitialization, - }; - client = new EppoClient(storage, requestConfiguration); - client.setIsGracefulFailureMode(false); - // no configuration loaded - let variation = client.getAssignment(subjectForGreenVariation, flagKey); - expect(variation).toBeNull(); - - // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes - const fetchPromise = client.fetchFlagConfigurations(); - - // Advance timers mid-init to allow retrying - await jest.advanceTimersByTimeAsync(maxRetryDelay); - - // Await so it can finish its initialization before this test proceeds - await fetchPromise; - - variation = client.getAssignment(subjectForGreenVariation, flagKey); - expect(variation).toBe("green"); - expect(callCount).toBe(2); - - await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS); - // By default, no more polling - expect(callCount).toBe(pollAfterSuccessfulInitialization ? 3 : 2); - } - ); - - it.each([ - { - pollAfterFailedInitialization: false, - throwOnFailedInitialization: false, - }, - { pollAfterFailedInitialization: false, throwOnFailedInitialization: true }, - { pollAfterFailedInitialization: true, throwOnFailedInitialization: false }, - { pollAfterFailedInitialization: true, throwOnFailedInitialization: true }, - ])( - "initial configuration request fails with config %p", - async (configModification) => { - let callCount = 0; - mockServerResponseFunc = (res) => { - if (++callCount === 1) { - // Throw an error for initialization call - return res.status(500); - } else { - // Return a mock object for subsequent calls - return res.status(200).body(racBody); - } - }; - - const { pollAfterFailedInitialization, throwOnFailedInitialization } = - configModification; - - // Note: fake time does not play well with errors bubbled up after setTimeout (event loop, - // timeout queue, message queue stuff) so we don't allow retries when rethrowing. - const numInitialRequestRetries = 0; - - requestConfiguration = { - ...requestConfiguration, - numInitialRequestRetries, - throwOnFailedInitialization, - pollAfterFailedInitialization, - }; - client = new EppoClient(storage, requestConfiguration); - client.setIsGracefulFailureMode(false); - // no configuration loaded - expect( - client.getAssignment(subjectForGreenVariation, flagKey) - ).toBeNull(); - - // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes - if (throwOnFailedInitialization) { - await expect(client.fetchFlagConfigurations()).rejects.toThrow(); - } else { - await expect(client.fetchFlagConfigurations()).resolves.toBeUndefined(); - } - expect(callCount).toBe(1); - // still no configuration loaded - expect( - client.getAssignment(subjectForGreenVariation, flagKey) - ).toBeNull(); - - // Advance timers so a post-init poll can take place - await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 1.5); - - // if pollAfterFailedInitialization = true, we will poll later and get a config, otherwise not - expect(callCount).toBe(pollAfterFailedInitialization ? 2 : 1); - expect(client.getAssignment(subjectForGreenVariation, flagKey)).toBe( - pollAfterFailedInitialization ? "green" : null - ); - } - ); + // describe('getStringAssignment with hooks', () => { + // let client: EppoClient; + + // beforeAll(() => { + // storage.setEntries({ [flagKey]: mockExperimentConfig }); + // client = new EppoClient(storage); + // }); + + // describe('onPreAssignment', () => { + // it('called with experiment key and subject id', () => { + // const mockHooks = td.object<IAssignmentHooks>(); + // client.getStringAssignment('subject-identifer', flagKey, {}, mockHooks); + // expect(td.explain(mockHooks.onPreAssignment).callCount).toEqual(1); + // expect(td.explain(mockHooks.onPreAssignment).calls[0].args[0]).toEqual(flagKey); + // expect(td.explain(mockHooks.onPreAssignment).calls[0].args[1]).toEqual('subject-identifer'); + // }); + + // it('overrides returned assignment', async () => { + // const mockLogger = td.object<IAssignmentLogger>(); + // client.setLogger(mockLogger); + // td.reset(); + // const variation = await client.getStringAssignment( + // 'subject-identifer', + // flagKey, + // {}, + // { + // // eslint-disable-next-line @typescript-eslint/no-unused-vars + // onPreAssignment(experimentKey: string, subject: string): EppoValue | null { + // return EppoValue.String('my-overridden-variation'); + // }, + + // // eslint-disable-next-line @typescript-eslint/no-unused-vars + // onPostAssignment( + // experimentKey: string, // eslint-disable-line @typescript-eslint/no-unused-vars + // subject: string, // eslint-disable-line @typescript-eslint/no-unused-vars + // variation: EppoValue | null, // eslint-disable-line @typescript-eslint/no-unused-vars + // ): void { + // // no-op + // }, + // }, + // ); + + // expect(variation).toEqual('my-overridden-variation'); + // expect(td.explain(mockLogger.logAssignment).callCount).toEqual(0); + // }); + + // it('uses regular assignment logic if onPreAssignment returns null', async () => { + // const mockLogger = td.object<IAssignmentLogger>(); + // client.setLogger(mockLogger); + // td.reset(); + // const variation = await client.getStringAssignment( + // 'subject-identifer', + // flagKey, + // {}, + // { + // // eslint-disable-next-line @typescript-eslint/no-unused-vars + // onPreAssignment(experimentKey: string, subject: string): EppoValue | null { + // return null; + // }, + + // onPostAssignment( + // experimentKey: string, // eslint-disable-line @typescript-eslint/no-unused-vars + // subject: string, // eslint-disable-line @typescript-eslint/no-unused-vars + // variation: EppoValue | null, // eslint-disable-line @typescript-eslint/no-unused-vars + // ): void { + // // no-op + // }, + // }, + // ); + + // expect(variation).not.toBeNull(); + // expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); + // }); + // }); + + // describe('onPostAssignment', () => { + // it('called with assigned variation after assignment', async () => { + // const mockHooks = td.object<IAssignmentHooks>(); + // const subject = 'subject-identifier'; + // const variation = client.getStringAssignment(subject, flagKey, {}, mockHooks); + // expect(td.explain(mockHooks.onPostAssignment).callCount).toEqual(1); + // expect(td.explain(mockHooks.onPostAssignment).callCount).toEqual(1); + // expect(td.explain(mockHooks.onPostAssignment).calls[0].args[0]).toEqual(flagKey); + // expect(td.explain(mockHooks.onPostAssignment).calls[0].args[1]).toEqual(subject); + // expect(td.explain(mockHooks.onPostAssignment).calls[0].args[2]).toEqual( + // EppoValue.String(variation ?? ''), + // ); + // }); + // }); + // }); + // }); + + // describe(' EppoClient getStringAssignment From Obfuscated RAC', () => { + // const storage = new TestConfigurationStore(); + // const evaluator = new Evaluator(new MD5Sharder()); + // const globalClient = new EppoClient(evaluator, storage); + + // beforeAll(async () => { + // mock.setup(); + // mock.get(/randomized_assignment\/v3\/config*/, (_req, res) => { + // const rac = readMockUFCResponse(OBFUSCATED_MOCK_RAC_RESPONSE_FILE); + // return res.status(200).body(JSON.stringify(rac)); + // }); + // await init(storage); + // }); + + // afterAll(() => { + // mock.teardown(); + // }); + + // it.each(readAssignmentTestData())( + // 'test variation assignment splits', + // async ({ + // experiment, + // valueType = ValueTestType.StringType, + // subjects, + // subjectsWithAttributes, + // expectedAssignments, + // }: IAssignmentTestCase) => { + // `---- Test Case for ${experiment} Experiment ----`; + + // const assignments = getAssignmentsWithSubjectAttributes( + // subjectsWithAttributes + // ? subjectsWithAttributes + // : subjects.map((subject) => ({ subjectKey: subject })), + // experiment, + // valueType, + // ); + + // switch (valueType) { + // case ValueTestType.BoolType: { + // const boolAssignments = assignments.map((a) => a?.boolValue ?? null); + // expect(boolAssignments).toEqual(expectedAssignments); + // break; + // } + // case ValueTestType.NumericType: { + // const numericAssignments = assignments.map((a) => a?.numericValue ?? null); + // expect(numericAssignments).toEqual(expectedAssignments); + // break; + // } + // case ValueTestType.StringType: { + // const stringAssignments = assignments.map((a) => a?.stringValue ?? null); + // expect(stringAssignments).toEqual(expectedAssignments); + // break; + // } + // case ValueTestType.JSONType: { + // const jsonStringAssignments = assignments.map((a) => a?.stringValue ?? null); + // expect(jsonStringAssignments).toEqual(expectedAssignments); + // break; + // } + // } + // }, + // ); + + // function getAssignmentsWithSubjectAttributes( + // subjectsWithAttributes: { + // subjectKey: string; + // // eslint-disable-next-line @typescript-eslint/no-explicit-any + // subjectAttributes?: Record<string, any>; + // }[], + // experiment: string, + // valueTestType: ValueTestType = ValueTestType.StringType, + // ): (EppoValue | null)[] { + // return subjectsWithAttributes.map((subject) => { + // switch (valueTestType) { + // case ValueTestType.BoolType: { + // const ba = globalClient.getBoolAssignment( + // subject.subjectKey, + // experiment, + // subject.subjectAttributes, + // undefined, + // true, + // ); + // if (ba === null) return null; + // return EppoValue.Bool(ba); + // } + // case ValueTestType.NumericType: { + // const na = globalClient.getNumericAssignment( + // subject.subjectKey, + // experiment, + // subject.subjectAttributes, + // undefined, + // true, + // ); + // if (na === null) return null; + // return EppoValue.Numeric(na); + // } + // case ValueTestType.StringType: { + // const sa = globalClient.getStringAssignment( + // subject.subjectKey, + // experiment, + // subject.subjectAttributes, + // undefined, + // true, + // ); + // if (sa === null) return null; + // return EppoValue.String(sa); + // } + // case ValueTestType.JSONType: { + // const sa = globalClient.getJSONStringAssignment( + // subject.subjectKey, + // experiment, + // subject.subjectAttributes, + // undefined, + // true, + // ); + // const oa = globalClient.getParsedJSONAssignment( + // subject.subjectKey, + // experiment, + // subject.subjectAttributes, + // undefined, + // true, + // ); + // if (oa == null || sa === null) return null; + // return EppoValue.JSON(sa, oa); + // } + // } + // }); + // } + // }); + + // describe('Eppo Client constructed with configuration request parameters', () => { + // let client: EppoClient; + // let storage: IConfigurationStore; + // let requestConfiguration: ExperimentConfigurationRequestParameters; + // let mockServerResponseFunc: (res: MockResponse) => MockResponse; + + // const racBody = JSON.stringify(readMockRacResponse(MOCK_RAC_RESPONSE_FILE)); + // const flagKey = 'randomization_algo'; + // const subjectForGreenVariation = 'subject-identiferA'; + + // const maxRetryDelay = POLL_INTERVAL_MS * POLL_JITTER_PCT; + + // beforeAll(() => { + // mock.setup(); + // mock.get(/randomized_assignment\/v3\/config*/, (_req, res) => { + // return mockServerResponseFunc(res); + // }); + // }); + + // beforeEach(() => { + // storage = new TestConfigurationStore(); + // requestConfiguration = { + // apiKey: 'dummy key', + // sdkName: 'js-client-sdk-common', + // sdkVersion: packageJson.version, + // }; + // mockServerResponseFunc = (res) => res.status(200).body(racBody); + + // // We only want to fake setTimeout() and clearTimeout() + // jest.useFakeTimers({ + // advanceTimers: true, + // doNotFake: [ + // 'Date', + // 'hrtime', + // 'nextTick', + // 'performance', + // 'queueMicrotask', + // 'requestAnimationFrame', + // 'cancelAnimationFrame', + // 'requestIdleCallback', + // 'cancelIdleCallback', + // 'setImmediate', + // 'clearImmediate', + // 'setInterval', + // 'clearInterval', + // ], + // }); + // }); + + // afterEach(() => { + // jest.clearAllTimers(); + // jest.useRealTimers(); + // }); + + // afterAll(() => { + // mock.teardown(); + // }); + + // it('Fetches initial configuration with parameters in constructor', async () => { + // client = new EppoClient(storage, requestConfiguration); + // client.setIsGracefulFailureMode(false); + // // no configuration loaded + // let variation = client.getStringAssignment(subjectForGreenVariation, flagKey); + // expect(variation).toBeNull(); + // // have client fetch configurations + // await client.fetchFlagConfigurations(); + // variation = client.getStringAssignment(subjectForGreenVariation, flagKey); + // expect(variation).toBe('green'); + // }); + + // it('Fetches initial configuration with parameters provided later', async () => { + // client = new EppoClient(storage); + // client.setIsGracefulFailureMode(false); + // client.setConfigurationRequestParameters(requestConfiguration); + // // no configuration loaded + // let variation = client.getStringAssignment(subjectForGreenVariation, flagKey); + // expect(variation).toBeNull(); + // // have client fetch configurations + // await client.fetchFlagConfigurations(); + // variation = client.getStringAssignment(subjectForGreenVariation, flagKey); + // expect(variation).toBe('green'); + // }); + + // it.each([ + // { pollAfterSuccessfulInitialization: false }, + // { pollAfterSuccessfulInitialization: true }, + // ])('retries initial configuration request with config %p', async (configModification) => { + // let callCount = 0; + // mockServerResponseFunc = (res) => { + // if (++callCount === 1) { + // // Throw an error for the first call + // return res.status(500); + // } else { + // // Return a mock object for subsequent calls + // return res.status(200).body(racBody); + // } + // }; + + // const { pollAfterSuccessfulInitialization } = configModification; + // requestConfiguration = { + // ...requestConfiguration, + // pollAfterSuccessfulInitialization, + // }; + // client = new EppoClient(storage, requestConfiguration); + // client.setIsGracefulFailureMode(false); + // // no configuration loaded + // let variation = client.getStringAssignment(subjectForGreenVariation, flagKey); + // expect(variation).toBeNull(); + + // // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes + // const fetchPromise = client.fetchFlagConfigurations(); + + // // Advance timers mid-init to allow retrying + // await jest.advanceTimersByTimeAsync(maxRetryDelay); + + // // Await so it can finish its initialization before this test proceeds + // await fetchPromise; + + // variation = client.getStringAssignment(subjectForGreenVariation, flagKey); + // expect(variation).toBe('green'); + // expect(callCount).toBe(2); + + // await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + // // By default, no more polling + // expect(callCount).toBe(pollAfterSuccessfulInitialization ? 3 : 2); + // }); + + // it.each([ + // { + // pollAfterFailedInitialization: false, + // throwOnFailedInitialization: false, + // }, + // { pollAfterFailedInitialization: false, throwOnFailedInitialization: true }, + // { pollAfterFailedInitialization: true, throwOnFailedInitialization: false }, + // { pollAfterFailedInitialization: true, throwOnFailedInitialization: true }, + // ])('initial configuration request fails with config %p', async (configModification) => { + // let callCount = 0; + // mockServerResponseFunc = (res) => { + // if (++callCount === 1) { + // // Throw an error for initialization call + // return res.status(500); + // } else { + // // Return a mock object for subsequent calls + // return res.status(200).body(racBody); + // } + // }; + + // const { pollAfterFailedInitialization, throwOnFailedInitialization } = configModification; + + // // Note: fake time does not play well with errors bubbled up after setTimeout (event loop, + // // timeout queue, message queue stuff) so we don't allow retries when rethrowing. + // const numInitialRequestRetries = 0; + + // requestConfiguration = { + // ...requestConfiguration, + // numInitialRequestRetries, + // throwOnFailedInitialization, + // pollAfterFailedInitialization, + // }; + // client = new EppoClient(storage, requestConfiguration); + // client.setIsGracefulFailureMode(false); + // // no configuration loaded + // expect(client.getStringAssignment(subjectForGreenVariation, flagKey)).toBeNull(); + + // // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes + // if (throwOnFailedInitialization) { + // await expect(client.fetchFlagConfigurations()).rejects.toThrow(); + // } else { + // await expect(client.fetchFlagConfigurations()).resolves.toBeUndefined(); + // } + // expect(callCount).toBe(1); + // // still no configuration loaded + // expect(client.getStringAssignment(subjectForGreenVariation, flagKey)).toBeNull(); + + // // Advance timers so a post-init poll can take place + // await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 1.5); + + // // if pollAfterFailedInitialization = true, we will poll later and get a config, otherwise not + // expect(callCount).toBe(pollAfterFailedInitialization ? 2 : 1); + // expect(client.getStringAssignment(subjectForGreenVariation, flagKey)).toBe( + // pollAfterFailedInitialization ? 'green' : null, + // ); + // }); }); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index f3cb4dd8..1589f79c 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -1,5 +1,4 @@ import axios from 'axios'; -import * as md5 from 'md5'; import { AssignmentCache, @@ -8,12 +7,7 @@ import { NonExpiringInMemoryAssignmentCache, } from '../assignment-cache'; import { IAssignmentHooks } from '../assignment-hooks'; -import { - IAssignmentEvent, - IAssignmentLogger, - HoldoutVariationEnum, - NullableHoldoutVariationType, -} from '../assignment-logger'; +import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; import { IConfigurationStore } from '../configuration-store'; import { BASE_URL as DEFAULT_BASE_URL, @@ -23,16 +17,13 @@ import { MAX_EVENT_QUEUE_SIZE, POLL_INTERVAL_MS, } from '../constants'; -import { IAllocation } from '../dto/allocation-dto'; -import { IExperimentConfiguration } from '../dto/experiment-configuration-dto'; -import { IVariation } from '../dto/variation-dto'; -import { EppoValue, ValueType } from '../eppo_value'; +import { EppoValue } from '../eppo_value'; import { Evaluator, FlagEvaluation, noneResult } from '../eval'; import ExperimentConfigurationRequestor from '../experiment-configuration-requestor'; import HttpClient from '../http-client'; import { Flag, VariationType } from '../interfaces'; -import { getMD5Hash } from '../obfuscation'; import initPoller, { IPoller } from '../poller'; +import { AttributeType } from '../types'; import { validateNotBlank } from '../validation'; /** @@ -77,15 +68,6 @@ export interface IEppoClient { assignmentHooks?: IAssignmentHooks, ): number | null; - getJSONStringAssignment( - subjectKey: string, - flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record<string, any>, - defaultValue?: string | null, - assignmentHooks?: IAssignmentHooks, - ): string | null; - getParsedJSONAssignment( subjectKey: string, flagKey: string, @@ -102,7 +84,7 @@ export interface IEppoClient { useCustomAssignmentCache(cache: AssignmentCache<Cacheable>): void; setConfigurationRequestParameters( - configurationRequestParameters: ExperimentConfigurationRequestParameters, + configurationRequestParameters: FlagConfigurationRequestParameters, ): void; fetchFlagConfigurations(): void; @@ -112,7 +94,7 @@ export interface IEppoClient { setIsGracefulFailureMode(gracefulFailureMode: boolean): void; } -export type ExperimentConfigurationRequestParameters = { +export type FlagConfigurationRequestParameters = { apiKey: string; sdkVersion: string; sdkName: string; @@ -131,14 +113,14 @@ export default class EppoClient implements IEppoClient { private isGracefulFailureMode = true; private assignmentCache: AssignmentCache<Cacheable> | undefined; private configurationStore: IConfigurationStore; - private configurationRequestParameters: ExperimentConfigurationRequestParameters | undefined; + private configurationRequestParameters: FlagConfigurationRequestParameters | undefined; private requestPoller: IPoller | undefined; private evaluator: Evaluator; constructor( evaluator: Evaluator, configurationStore: IConfigurationStore, - configurationRequestParameters?: ExperimentConfigurationRequestParameters, + configurationRequestParameters?: FlagConfigurationRequestParameters, ) { this.evaluator = evaluator; this.configurationStore = configurationStore; @@ -146,7 +128,7 @@ export default class EppoClient implements IEppoClient { } public setConfigurationRequestParameters( - configurationRequestParameters: ExperimentConfigurationRequestParameters, + configurationRequestParameters: FlagConfigurationRequestParameters, ) { this.configurationRequestParameters = configurationRequestParameters; } @@ -328,7 +310,7 @@ export default class EppoClient implements IEppoClient { public getAssignmentDetail( subjectKey: string, flagKey: string, - subjectAttributes: Record<string, any> = {}, + subjectAttributes: Record<string, AttributeType> = {}, expectedVariationType?: VariationType, obfuscated = false, ): FlagEvaluation { @@ -360,7 +342,7 @@ export default class EppoClient implements IEppoClient { try { if (result && result.doLog) { // TODO: check assignment cache - this.logAssignment(assignmentEvent); + this.logAssignment(result); } } catch (error) { console.error(`[Eppo SDK] Error logging assignment event: ${error}`); @@ -458,8 +440,8 @@ export default class EppoClient implements IEppoClient { this.assignmentCache?.setLastLoggedAssignment({ flagKey: result.flagKey, subjectKey: result.subjectKey, - allocationKey: result.allocationKey ?? null, - variationKey: result.variation?.key ?? null, + allocationKey: result.allocationKey ?? '__eppo_no_allocation', + variationKey: result.variation?.key ?? '__eppo_no_variation', }); } catch (error) { console.error(`[Eppo SDK] Error logging assignment event: ${error.message}`); diff --git a/src/dto/allocation-dto.ts b/src/dto/allocation-dto.ts deleted file mode 100644 index 76ffb52b..00000000 --- a/src/dto/allocation-dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IHoldout } from './holdout-dto'; -import { IVariation } from './variation-dto'; - -export interface IAllocation { - percentExposure: number; - variations: IVariation[]; - statusQuoVariationKey: string | null; - shippedVariationKey: string | null; - holdouts: IHoldout[]; -} diff --git a/src/dto/experiment-configuration-dto.ts b/src/dto/experiment-configuration-dto.ts deleted file mode 100644 index 5f806b6e..00000000 --- a/src/dto/experiment-configuration-dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IAllocation } from './allocation-dto'; -import { IRule } from './rule-dto'; - -export interface IExperimentConfiguration { - name: string; - enabled: boolean; - subjectShards: number; - overrides: Record<string, string>; - typedOverrides: Record<string, number | boolean | string | object>; - allocations: Record<string, IAllocation>; - rules: IRule[]; -} diff --git a/src/dto/holdout-dto.ts b/src/dto/holdout-dto.ts deleted file mode 100644 index ce0c8210..00000000 --- a/src/dto/holdout-dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IShardRange } from './variation-dto'; - -export interface IHoldout { - statusQuoShardRange: IShardRange; - shippedShardRange: IShardRange | null; - holdoutKey: string; -} diff --git a/src/dto/variation-dto.ts b/src/dto/variation-dto.ts deleted file mode 100644 index 78ee124e..00000000 --- a/src/dto/variation-dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { IValue } from '../eppo_value'; - -export interface IShardRange { - start: number; - end: number; -} - -export interface IVariation { - variationKey: string; - name: string; - value: string; - typedValue: IValue; - shardRange: IShardRange; -} diff --git a/src/eppo_value.spec.ts b/src/eppo_value.spec.ts index 382ea047..22ecdbdb 100644 --- a/src/eppo_value.spec.ts +++ b/src/eppo_value.spec.ts @@ -7,7 +7,7 @@ describe('EppoValue toHashedString function', () => { }); it('is JsonType', () => { - const myInstance = EppoValue.JSON('{"hello":"world"}', { hello: 'world' }); + const myInstance = EppoValue.JSON({ hello: 'world' }); expect(myInstance.toHashedString()).toBe('fbc24bcc7a1794758fc1327fcfebdaf6'); }); }); diff --git a/src/eppo_value.ts b/src/eppo_value.ts index 203c2ac3..a63e852d 100644 --- a/src/eppo_value.ts +++ b/src/eppo_value.ts @@ -1,3 +1,4 @@ +import { VariationType } from './interfaces'; import { getMD5Hash } from './obfuscation'; export enum EppoValueType { @@ -33,17 +34,19 @@ export class EppoValue { static generateEppoValue( value: boolean | number | string | object | null | undefined, - valueType: EppoValueType, + valueType: VariationType, ): EppoValue { if (value != null && value != undefined) { switch (valueType) { - case EppoValueType.BoolType: + case VariationType.BOOLEAN: return EppoValue.Bool(value as boolean); - case EppoValueType.NumericType: + case VariationType.FLOAT: + return EppoValue.Numeric(value as number); + case VariationType.INTEGER: return EppoValue.Numeric(value as number); - case EppoValueType.StringType: + case VariationType.STRING: return EppoValue.String(value as string); - case EppoValueType.JSONType: + case VariationType.JSON: return EppoValue.JSON(value as object); default: return EppoValue.String(value as string); diff --git a/src/eval.spec.ts b/src/eval.spec.ts index b24402d1..bcf091fd 100644 --- a/src/eval.spec.ts +++ b/src/eval.spec.ts @@ -1,5 +1,5 @@ import { Evaluator, hashKey, isInShardRange } from './eval'; -import { Flag, Variation, Shard, VariationType } from './interfaces'; +import { Flag, Variation, Shard, VariationType, OperatorType } from './interfaces'; import { MD5Sharder, DeterministicSharder } from './sharders'; describe('Evaluator', () => { @@ -97,7 +97,7 @@ describe('Evaluator', () => { key: 'flag-key', enabled: true, variationType: VariationType.STRING, - variations: { control: { key: 'control', value: 'control' } }, + variations: { control: { key: 'control', value: 'control-value' } }, allocations: [ { key: 'allocation', @@ -117,10 +117,10 @@ describe('Evaluator', () => { const evaluator = new Evaluator(new MD5Sharder()); const result = evaluator.evaluateFlag(flag, 'user-1', {}, false); - expect(result.variation).toEqual({ key: 'control', value: 'control' }); + expect(result.variation).toEqual({ key: 'control', value: 'control-value' }); }); - it('should evaluate flag and target on id', () => { + it('should evaluate flag based on a targeting condition based on id', () => { const flag: Flag = { key: 'flag-key', enabled: true, @@ -129,7 +129,13 @@ describe('Evaluator', () => { allocations: [ { key: 'allocation', - rules: [], + rules: [ + { + conditions: [ + { operator: OperatorType.ONE_OF, attribute: 'id', value: ['alice', 'bob'] }, + ], + }, + ], splits: [ { variationKey: 'control', @@ -144,10 +150,13 @@ describe('Evaluator', () => { }; const evaluator = new Evaluator(new MD5Sharder()); - let result = evaluator.evaluateFlag(flag, 'user-1', {}, false); + let result = evaluator.evaluateFlag(flag, 'alice', {}, false); expect(result.variation).toEqual({ key: 'control', value: 'control' }); - result = evaluator.evaluateFlag(flag, 'user-3', {}, false); + result = evaluator.evaluateFlag(flag, 'bob', {}, false); + expect(result.variation).toEqual({ key: 'control', value: 'control' }); + + result = evaluator.evaluateFlag(flag, 'charlie', {}, false); expect(result.variation).toBeNull(); }); @@ -191,7 +200,13 @@ describe('Evaluator', () => { allocations: [ { key: 'first', - rules: [], + rules: [ + { + conditions: [ + { operator: OperatorType.MATCHES, attribute: 'email', value: '.*@example.com' }, + ], + }, + ], splits: [ { variationKey: 'b', @@ -238,7 +253,13 @@ describe('Evaluator', () => { allocations: [ { key: 'first', - rules: [], + rules: [ + { + conditions: [ + { operator: OperatorType.MATCHES, attribute: 'email', value: '.*@example.com' }, + ], + }, + ], splits: [ { variationKey: 'b', diff --git a/src/eval.ts b/src/eval.ts index 5247736e..0a2d48fe 100644 --- a/src/eval.ts +++ b/src/eval.ts @@ -35,7 +35,7 @@ export class Evaluator { if (allocation.endAt && now > allocation.endAt) continue; if ( - !allocation.rules || + !allocation.rules.length || allocation.rules.some((rule) => matchesRule(rule, { id: subjectKey, ...subjectAttributes }, obfuscated), ) diff --git a/src/experiment-configuration-requestor.ts b/src/experiment-configuration-requestor.ts index 4a1445f0..4773c14b 100644 --- a/src/experiment-configuration-requestor.ts +++ b/src/experiment-configuration-requestor.ts @@ -1,22 +1,22 @@ import { IConfigurationStore } from './configuration-store'; -import { IExperimentConfiguration } from './dto/experiment-configuration-dto'; import HttpClient from './http-client'; +import { Flag } from './interfaces'; -const RAC_ENDPOINT = '/randomized_assignment/v3/config'; +const UFC_ENDPOINT = '/flag_config/v1/config'; -interface IRandomizedAssignmentConfig { - flags: Record<string, IExperimentConfiguration>; +interface IUniversalFlagConfig { + flags: Record<string, Flag>; } export default class ExperimentConfigurationRequestor { constructor(private configurationStore: IConfigurationStore, private httpClient: HttpClient) {} - async fetchAndStoreConfigurations(): Promise<Record<string, IExperimentConfiguration>> { - const responseData = await this.httpClient.get<IRandomizedAssignmentConfig>(RAC_ENDPOINT); + async fetchAndStoreConfigurations(): Promise<Record<string, Flag>> { + const responseData = await this.httpClient.get<IUniversalFlagConfig>(UFC_ENDPOINT); if (!responseData) { return {}; } - this.configurationStore.setEntries<IExperimentConfiguration>(responseData.flags); + this.configurationStore.setEntries<Flag>(responseData.flags); return responseData.flags; } } diff --git a/src/index.ts b/src/index.ts index a8c67a39..407d795f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,7 @@ import { AssignmentCache } from './assignment-cache'; import { IAssignmentHooks } from './assignment-hooks'; import { IAssignmentLogger, IAssignmentEvent } from './assignment-logger'; -import EppoClient, { - ExperimentConfigurationRequestParameters, - IEppoClient, -} from './client/eppo-client'; +import EppoClient, { FlagConfigurationRequestParameters, IEppoClient } from './client/eppo-client'; import { IConfigurationStore } from './configuration-store'; import * as constants from './constants'; import ExperimentConfigurationRequestor from './experiment-configuration-requestor'; @@ -23,5 +20,5 @@ export { validation, IConfigurationStore, AssignmentCache, - ExperimentConfigurationRequestParameters, + FlagConfigurationRequestParameters as ExperimentConfigurationRequestParameters, }; diff --git a/src/interfaces.ts b/src/interfaces.ts index 1f11dde4..dbdb536a 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,4 @@ -import { Rule } from './rules'; +import { ConditionValueType } from './types'; export enum VariationType { STRING = 'string', @@ -46,3 +46,69 @@ export interface Flag { allocations: Allocation[]; totalShards: number; } + +export enum OperatorType { + MATCHES = 'MATCHES', + NOT_MATCHES = 'NOT_MATCHES', + GTE = 'GTE', + GT = 'GT', + LTE = 'LTE', + LT = 'LT', + ONE_OF = 'ONE_OF', + NOT_ONE_OF = 'NOT_ONE_OF', +} + +export enum OperatorValueType { + PLAIN_STRING = 'PLAIN_STRING', + STRING_ARRAY = 'STRING_ARRAY', + SEM_VER = 'SEM_VER', + NUMERIC = 'NUMERIC', +} + +interface MatchesCondition { + operator: OperatorType.MATCHES; + attribute: string; + value: string; +} + +interface NotMatchesCondition { + operator: OperatorType.NOT_MATCHES; + attribute: string; + value: string; +} + +interface OneOfCondition { + operator: OperatorType.ONE_OF; + attribute: string; + value: string[]; +} + +interface NotOneOfCondition { + operator: OperatorType.NOT_ONE_OF; + attribute: string; + value: string[]; +} + +interface SemVerCondition { + operator: OperatorType.GTE | OperatorType.GT | OperatorType.LTE | OperatorType.LT; + attribute: string; + value: string; +} + +interface NumericCondition { + operator: OperatorType.GTE | OperatorType.GT | OperatorType.LTE | OperatorType.LT; + attribute: string; + value: number; +} + +export type Condition = + | MatchesCondition + | NotMatchesCondition + | OneOfCondition + | NotOneOfCondition + | SemVerCondition + | NumericCondition; + +export interface Rule { + conditions: Condition[]; +} diff --git a/src/rule_evaluator.ts b/src/rule_evaluator.ts index f6418933..40d54fdf 100644 --- a/src/rule_evaluator.ts +++ b/src/rule_evaluator.ts @@ -7,11 +7,12 @@ import { lte as semverLte, } from 'semver'; -import { Condition, OperatorType, IRule, OperatorValueType } from './dto/rule-dto'; +import { Condition, OperatorType, Rule, OperatorValueType } from './interfaces'; import { decodeBase64, getMD5Hash } from './obfuscation'; +import { ConditionValueType } from './types'; export function matchesRule( - rule: IRule, + rule: Rule, subjectAttributes: Record<string, any>, obfuscated: boolean, ): boolean { @@ -28,11 +29,8 @@ function evaluateRuleConditions( conditions: Condition[], obfuscated: boolean, ): boolean[] { - return conditions.map((condition) => - obfuscated - ? evaluateObfuscatedCondition(subjectAttributes, condition) - : evaluateCondition(subjectAttributes, condition), - ); + // TODO: obfuscated version + return conditions.map((condition) => evaluateCondition(subjectAttributes, condition)); } function evaluateCondition(subjectAttributes: Record<string, any>, condition: Condition): boolean { @@ -79,49 +77,50 @@ function evaluateCondition(subjectAttributes: Record<string, any>, condition: Co return false; } -function evaluateObfuscatedCondition( - subjectAttributes: Record<string, any>, - condition: Condition, -): boolean { - const hashedSubjectAttributes: Record<string, any> = Object.entries(subjectAttributes).reduce( - (accum, [key, val]) => ({ [getMD5Hash(key)]: val, ...accum }), - {}, - ); - const value = hashedSubjectAttributes[condition.attribute]; - const conditionValueType = targetingRuleConditionValuesTypesFromValues(value); - - if (value != null) { - switch (condition.operator) { - case getMD5Hash(OperatorType.GTE): - if (conditionValueType === OperatorValueType.SEM_VER) { - return compareSemVer(value, decodeBase64(condition.value), semverGte); - } - return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a >= b); - case getMD5Hash(OperatorType.GT): - if (conditionValueType === OperatorValueType.SEM_VER) { - return compareSemVer(value, decodeBase64(condition.value), semverGt); - } - return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a > b); - case getMD5Hash(OperatorType.LTE): - if (conditionValueType === OperatorValueType.SEM_VER) { - return compareSemVer(value, decodeBase64(condition.value), semverLte); - } - return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a <= b); - case getMD5Hash(OperatorType.LT): - if (conditionValueType === OperatorValueType.SEM_VER) { - return compareSemVer(value, decodeBase64(condition.value), semverLt); - } - return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a < b); - case getMD5Hash(OperatorType.MATCHES): - return new RegExp(decodeBase64(condition.value)).test(value as string); - case getMD5Hash(OperatorType.ONE_OF): - return isOneOf(getMD5Hash(value.toString().toLowerCase()), condition.value); - case getMD5Hash(OperatorType.NOT_ONE_OF): - return isNotOneOf(getMD5Hash(value.toString().toLowerCase()), condition.value); - } - } - return false; -} +// TODO: implement the obfuscated version of this function +// function evaluateObfuscatedCondition( +// subjectAttributes: Record<string, any>, +// condition: Condition, +// ): boolean { +// const hashedSubjectAttributes: Record<string, any> = Object.entries(subjectAttributes).reduce( +// (accum, [key, val]) => ({ [getMD5Hash(key)]: val, ...accum }), +// {}, +// ); +// const value = hashedSubjectAttributes[condition.attribute]; +// const conditionValueType = targetingRuleConditionValuesTypesFromValues(value); + +// if (value != null) { +// switch (condition.operator) { +// case getMD5Hash(OperatorType.GTE): +// if (conditionValueType === OperatorValueType.SEM_VER) { +// return compareSemVer(value, decodeBase64(condition.value), semverGte); +// } +// return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a >= b); +// case getMD5Hash(OperatorType.GT): +// if (conditionValueType === OperatorValueType.SEM_VER) { +// return compareSemVer(value, decodeBase64(condition.value), semverGt); +// } +// return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a > b); +// case getMD5Hash(OperatorType.LTE): +// if (conditionValueType === OperatorValueType.SEM_VER) { +// return compareSemVer(value, decodeBase64(condition.value), semverLte); +// } +// return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a <= b); +// case getMD5Hash(OperatorType.LT): +// if (conditionValueType === OperatorValueType.SEM_VER) { +// return compareSemVer(value, decodeBase64(condition.value), semverLt); +// } +// return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a < b); +// case getMD5Hash(OperatorType.MATCHES): +// return new RegExp(decodeBase64(condition.value)).test(value as string); +// case getMD5Hash(OperatorType.ONE_OF): +// return isOneOf(getMD5Hash(value.toString().toLowerCase()), condition.value); +// case getMD5Hash(OperatorType.NOT_ONE_OF): +// return isNotOneOf(getMD5Hash(value.toString().toLowerCase()), condition.value); +// } +// } +// return false; +// } function isOneOf(attributeValue: string, conditionValue: string[]) { return getMatchingStringValues(attributeValue, conditionValue).length > 0; @@ -155,9 +154,7 @@ function compareSemVer( ); } -function targetingRuleConditionValuesTypesFromValues( - value: number | string | string[], -): OperatorValueType { +function targetingRuleConditionValuesTypesFromValues(value: ConditionValueType): OperatorValueType { // Check if input is a number if (typeof value === 'number') { return OperatorValueType.NUMERIC; @@ -168,7 +165,7 @@ function targetingRuleConditionValuesTypesFromValues( } // Check if input is a string that represents a SemVer - if (validSemver(value)) { + if (typeof value === 'string' && validSemver(value)) { return OperatorValueType.SEM_VER; } diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 75732b23..20724884 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -1,14 +1,12 @@ import * as fs from 'fs'; -import { IExperimentConfiguration } from '../src/dto/experiment-configuration-dto'; -import { IVariation } from '../src/dto/variation-dto'; -import { IValue } from '../src/eppo_value'; +import { Flag, VariationType } from '../src/interfaces'; -export const TEST_DATA_DIR = './test/data/'; -export const ASSIGNMENT_TEST_DATA_DIR = TEST_DATA_DIR + 'assignment-v2/'; -const MOCK_RAC_FILENAME = 'rac-experiments-v3'; -export const MOCK_RAC_RESPONSE_FILE = `${MOCK_RAC_FILENAME}.json`; -export const OBFUSCATED_MOCK_RAC_RESPONSE_FILE = `${MOCK_RAC_FILENAME}-obfuscated.json`; +export const TEST_DATA_DIR = './test/data/ufc/'; +export const ASSIGNMENT_TEST_DATA_DIR = TEST_DATA_DIR + 'tests/'; +const MOCK_UFC_FILENAME = 'flags-v1'; +export const MOCK_UFC_RESPONSE_FILE = `${MOCK_UFC_FILENAME}.json`; +export const OBFUSCATED_MOCK_UFC_RESPONSE_FILE = `${MOCK_UFC_FILENAME}-obfuscated.json`; export enum ValueTestType { BoolType = 'boolean', @@ -17,19 +15,21 @@ export enum ValueTestType { JSONType = 'json', } +interface SubjectTestCase { + subjectKey: string; + subjectAttributes: Record<string, unknown>; + assignment: string | null; +} + export interface IAssignmentTestCase { - experiment: string; - valueType: ValueTestType; + flag: string; + variationType: VariationType; percentExposure: number; - variations: IVariation[]; - subjects: string[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectsWithAttributes: { subjectKey: string; subjectAttributes: Record<string, any> }[]; - expectedAssignments: IValue[]; + subjects: SubjectTestCase[]; } -export function readMockRacResponse(filename: string): { - flags: Record<string, IExperimentConfiguration>; +export function readMockUFCResponse(filename: string): { + flags: Record<string, Flag>; } { return JSON.parse(fs.readFileSync(TEST_DATA_DIR + filename, 'utf-8')); } From fd04b4bede65d015c12ceefc03fc7ae1eca8e458 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Tue, 26 Mar 2024 17:40:56 -0700 Subject: [PATCH 03/39] make more tests pass --- src/client/eppo-client.spec.ts | 69 ++++++++++++++-------------------- src/interfaces.ts | 4 +- 2 files changed, 31 insertions(+), 42 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index e2e47988..cf438a7f 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -21,7 +21,7 @@ import { EppoValue } from '../eppo_value'; import { Evaluator } from '../eval'; import ExperimentConfigurationRequestor from '../experiment-configuration-requestor'; import HttpClient from '../http-client'; -import { VariationType } from '../interfaces'; +import { Flag, VariationType } from '../interfaces'; import { MD5Sharder } from '../sharders'; import EppoClient, { FlagConfigurationRequestParameters } from './eppo-client'; @@ -94,12 +94,12 @@ describe('EppoClient E2E test', () => { }; const variationB = { - name: 'b', + key: 'b', value: 'variation-b', }; - const mockExperimentConfig = { - name: flagKey, + const mockFlag: Flag = { + key: flagKey, enabled: true, variationType: VariationType.STRING, variations: { a: variationA, b: variationB }, @@ -109,19 +109,9 @@ describe('EppoClient E2E test', () => { rules: [], splits: [ { - shards: [ - { salt: 'traffic', ranges: [{ start: 0, end: 10000 }] }, - { salt: 'assignment', ranges: [{ start: 0, end: 5000 }] }, - ], + shards: [], variationKey: 'a', }, - { - shards: [ - { salt: 'traffic', ranges: [{ start: 0, end: 10000 }] }, - { salt: 'assignment', ranges: [{ start: 5000, end: 10000 }] }, - ], - variationKey: 'b', - }, ], doLog: true, }, @@ -134,12 +124,12 @@ describe('EppoClient E2E test', () => { const mockHooks = td.object<IAssignmentHooks>(); beforeAll(() => { - storage.setEntries({ [flagKey]: mockExperimentConfig }); + storage.setEntries({ [flagKey]: mockFlag }); const evaluator = new Evaluator(new MD5Sharder()); client = new EppoClient(evaluator, storage); - td.replace(EppoClient.prototype, 'getAssignmentVariation', function () { - throw new Error('So Graceful Error'); + td.replace(EppoClient.prototype, 'getAssignmentDetail', function () { + throw new Error('Mock test error'); }); }); @@ -179,7 +169,7 @@ describe('EppoClient E2E test', () => { describe('setLogger', () => { beforeAll(() => { - storage.setEntries({ [flagKey]: mockExperimentConfig }); + storage.setEntries({ [flagKey]: mockFlag }); }); it('Invokes logger for queued events', () => { @@ -279,7 +269,7 @@ describe('EppoClient E2E test', () => { it('logs variation assignment and experiment key', () => { const mockLogger = td.object<IAssignmentLogger>(); - storage.setEntries({ [flagKey]: mockExperimentConfig }); + storage.setEntries({ [flagKey]: mockFlag }); const evaluator = new Evaluator(new MD5Sharder()); const client = new EppoClient(evaluator, storage); client.setLogger(mockLogger); @@ -287,23 +277,22 @@ describe('EppoClient E2E test', () => { const subjectAttributes = { foo: 3 }; const assignment = client.getStringAssignment('subject-10', flagKey, subjectAttributes); - expect(assignment).toEqual('control'); + expect(assignment).toEqual(variationA.value); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); - expect(td.explain(mockLogger.logAssignment).calls[0].args[0].subject).toEqual('subject-10'); - expect(td.explain(mockLogger.logAssignment).calls[0].args[0].featureFlag).toEqual(flagKey); - // expect(td.explain(mockLogger.logAssignment).calls[0].args[0].experiment).toEqual( - // `${flagKey}-${mockExperimentConfig.rules[0].allocationKey}`, - // ); - // expect(td.explain(mockLogger.logAssignment).calls[0].args[0].allocation).toEqual( - // `${mockExperimentConfig.rules[0].allocationKey}`, - // ); + + const loggedAssignmentEvent = td.explain(mockLogger.logAssignment).calls[0].args[0]; + console.log(loggedAssignmentEvent); + expect(loggedAssignmentEvent.subject).toEqual('subject-10'); + expect(loggedAssignmentEvent.featureFlag).toEqual(flagKey); + expect(loggedAssignmentEvent.experiment).toEqual(`${flagKey}-${mockFlag.allocations[0].key}`); + expect(loggedAssignmentEvent.allocation).toEqual(mockFlag.allocations[0].key); }); it('handles logging exception', () => { const mockLogger = td.object<IAssignmentLogger>(); td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(new Error('logging error')); - storage.setEntries({ [flagKey]: mockExperimentConfig }); + storage.setEntries({ [flagKey]: mockFlag }); const evaluator = new Evaluator(new MD5Sharder()); const client = new EppoClient(evaluator, storage); client.setLogger(mockLogger); @@ -321,7 +310,7 @@ describe('EppoClient E2E test', () => { beforeEach(() => { mockLogger = td.object<IAssignmentLogger>(); - storage.setEntries({ [flagKey]: mockExperimentConfig }); + storage.setEntries({ [flagKey]: mockFlag }); const evaluator = new Evaluator(new MD5Sharder()); const client = new EppoClient(evaluator, storage); client.setLogger(mockLogger); @@ -382,13 +371,13 @@ describe('EppoClient E2E test', () => { it('logs for each unique flag', () => { storage.setEntries({ - [flagKey]: mockExperimentConfig, + [flagKey]: mockFlag, 'flag-2': { - ...mockExperimentConfig, + ...mockFlag, name: 'flag-2', }, 'flag-3': { - ...mockExperimentConfig, + ...mockFlag, name: 'flag-3', }, }); @@ -413,7 +402,7 @@ describe('EppoClient E2E test', () => { storage.setEntries({ [flagKey]: { - ...mockExperimentConfig, + ...mockFlag, allocations: { allocation1: { percentExposure: 1, @@ -448,7 +437,7 @@ describe('EppoClient E2E test', () => { storage.setEntries({ [flagKey]: { - ...mockExperimentConfig, + ...mockFlag, allocations: { allocation1: { percentExposure: 1, @@ -487,7 +476,7 @@ describe('EppoClient E2E test', () => { client.useNonExpiringInMemoryAssignmentCache(); // original configuration version - storage.setEntries({ [flagKey]: mockExperimentConfig }); + storage.setEntries({ [flagKey]: mockFlag }); client.getStringAssignment('subject-10', flagKey); // log this assignment client.getStringAssignment('subject-10', flagKey); // cache hit, don't log @@ -495,7 +484,7 @@ describe('EppoClient E2E test', () => { // change the flag storage.setEntries({ [flagKey]: { - ...mockExperimentConfig, + ...mockFlag, allocations: { allocation1: { percentExposure: 1, @@ -522,7 +511,7 @@ describe('EppoClient E2E test', () => { client.getStringAssignment('subject-10', flagKey); // cache hit, don't log // change the flag again, back to the original - storage.setEntries({ [flagKey]: mockExperimentConfig }); + storage.setEntries({ [flagKey]: mockFlag }); client.getStringAssignment('subject-10', flagKey); // important: log this assignment client.getStringAssignment('subject-10', flagKey); // cache hit, don't log @@ -533,7 +522,7 @@ describe('EppoClient E2E test', () => { it('only returns variation if subject matches rules', () => { const entry = { - ...mockExperimentConfig, + ...mockFlag, rules: [ { allocationKey: 'allocation1', diff --git a/src/interfaces.ts b/src/interfaces.ts index dbdb536a..fe8257df 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,4 @@ -import { ConditionValueType } from './types'; +import { getMD5Hash } from './obfuscation'; export enum VariationType { STRING = 'string', @@ -26,7 +26,7 @@ export interface Shard { export interface Split { shards: Shard[]; variationKey: string; - extraLogging: Record<string, string>; + extraLogging?: Record<string, string>; } export interface Allocation { From ca7ea07aab96f0996d0e6200598bcd7684432115 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 27 Mar 2024 18:24:17 -0700 Subject: [PATCH 04/39] wip --- src/client/eppo-client.spec.ts | 213 +++++++++++++++++++-------------- src/client/eppo-client.ts | 37 +++++- src/eppo_value.ts | 2 +- src/eval.ts | 2 +- src/interfaces.ts | 2 +- test/testHelpers.ts | 6 +- 6 files changed, 160 insertions(+), 102 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index cf438a7f..decc2402 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -6,8 +6,10 @@ import * as td from 'testdouble'; import mock, { MockResponse } from 'xhr-mock'; import { + IAssignmentTestCase, MOCK_UFC_RESPONSE_FILE, OBFUSCATED_MOCK_UFC_RESPONSE_FILE, + SubjectTestCase, ValueTestType, readAssignmentTestData, readMockUFCResponse, @@ -23,6 +25,7 @@ import ExperimentConfigurationRequestor from '../experiment-configuration-reques import HttpClient from '../http-client'; import { Flag, VariationType } from '../interfaces'; import { MD5Sharder } from '../sharders'; +import { AttributeType, SubjectAttributes } from '../types'; import EppoClient, { FlagConfigurationRequestParameters } from './eppo-client'; @@ -48,6 +51,29 @@ class TestConfigurationStore implements IConfigurationStore { } } +function getTestAssignments( + testCase: IAssignmentTestCase, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assignmentFn: any, +): { subject: SubjectTestCase; assignment: string | boolean | number | null | object }[] { + const assignments: { + subject: SubjectTestCase; + assignment: string | boolean | number | null | object; + }[] = []; + for (const subject of testCase.subjects) { + const assignment = assignmentFn( + subject.subjectKey, + testCase.flag, + subject.subjectAttributes, + null, + undefined, + false, + ); + assignments.push({ subject: subject, assignment: assignment }); + } + return assignments; +} + export async function init(configurationStore: IConfigurationStore) { const axiosInstance = axios.create({ baseURL: 'http://127.0.0.1:4000', @@ -68,9 +94,9 @@ export async function init(configurationStore: IConfigurationStore) { } describe('EppoClient E2E test', () => { - // const evaluator = new Evaluator(new MD5Sharder()); + const evaluator = new Evaluator(new MD5Sharder()); const storage = new TestConfigurationStore(); - // const globalClient = new EppoClient(evaluator, storage); + const globalClient = new EppoClient(evaluator, storage); beforeAll(async () => { mock.setup(); @@ -142,10 +168,21 @@ describe('EppoClient E2E test', () => { expect(client.getBoolAssignment('subject-identifer', flagKey, {})).toBeNull(); expect(client.getNumericAssignment('subject-identifer', flagKey, {})).toBeNull(); - expect(client.getParsedJSONAssignment('subject-identifer', flagKey, {})).toBeNull(); + expect(client.getJSONAssignment('subject-identifer', flagKey, {})).toBeNull(); expect(client.getStringAssignment('subject-identifer', flagKey, {})).toBeNull(); }); + it('returns default value when graceful failure if error encounterd', async () => { + client.setIsGracefulFailureMode(true); + + expect(client.getBoolAssignment('subject-identifer', flagKey, {}, true)).toBe(true); + expect(client.getNumericAssignment('subject-identifer', flagKey, {}, 1)).toBe(1); + expect(client.getJSONAssignment('subject-identifer', flagKey, {}, {})).toEqual({}); + expect(client.getStringAssignment('subject-identifer', flagKey, {}, 'default')).toBe( + 'default', + ); + }); + it('throws error when graceful failure is false', async () => { client.setIsGracefulFailureMode(false); @@ -154,7 +191,7 @@ describe('EppoClient E2E test', () => { }).toThrow(); expect(() => { - client.getParsedJSONAssignment('subject-identifer', flagKey, {}); + client.getJSONAssignment('subject-identifer', flagKey, {}); }).toThrow(); expect(() => { @@ -214,51 +251,66 @@ describe('EppoClient E2E test', () => { }); }); - // describe('getStringAssignment', () => { - // it.each(readAssignmentTestData())( - // 'test variation assignment splits', - // async ({ - // experiment, - // valueType = ValueTestType.StringType, - // subjects, - // subjectsWithAttributes, - // expectedAssignments, - // }: IAssignmentTestCase) => { - // `---- Test Case for ${experiment} Experiment ----`; - - // const assignments = getAssignmentsWithSubjectAttributes( - // subjectsWithAttributes - // ? subjectsWithAttributes - // : subjects.map((subject) => ({ subjectKey: subject })), - // experiment, - // valueType, - // ); - - // switch (valueType) { - // case ValueTestType.BoolType: { - // const boolAssignments = assignments.map((a) => a?.boolValue ?? null); - // expect(boolAssignments).toEqual(expectedAssignments); - // break; - // } - // case ValueTestType.NumericType: { - // const numericAssignments = assignments.map((a) => a?.numericValue ?? null); - // expect(numericAssignments).toEqual(expectedAssignments); - // break; - // } - // case ValueTestType.StringType: { - // const stringAssignments = assignments.map((a) => a?.stringValue ?? null); - // expect(stringAssignments).toEqual(expectedAssignments); - // break; - // } - // case ValueTestType.JSONType: { - // const jsonStringAssignments = assignments.map((a) => a?.stringValue ?? null); - // expect(jsonStringAssignments).toEqual(expectedAssignments); - // break; - // } - // } - // }, - // ); - // }); + describe('UFC General Test Cases', () => { + it.each(readAssignmentTestData())( + 'test variation assignment splits', + async ({ flag, variationType, subjects }: IAssignmentTestCase) => { + `---- Test Case for ${flag} Experiment ----`; + + const evaluator = new Evaluator(new MD5Sharder()); + const client = new EppoClient(evaluator, storage); + + let assignments: { + subject: SubjectTestCase; + assignment: string | boolean | number | null | object; + }[] = []; + switch (variationType) { + case VariationType.BOOLEAN: { + assignments = getTestAssignments( + { flag, variationType, subjects }, + client.getBoolAssignment, + ); + break; + } + case VariationType.NUMERIC: { + assignments = getTestAssignments( + { flag, variationType, subjects }, + client.getNumericAssignment, + ); + break; + } + case VariationType.INTEGER: { + assignments = getTestAssignments( + { flag, variationType, subjects }, + client.getIntegerAssignment, + ); + break; + } + case VariationType.STRING: { + assignments = getTestAssignments( + { flag, variationType, subjects }, + client.getStringAssignment, + ); + break; + } + case VariationType.JSON: { + assignments = getTestAssignments( + { flag, variationType, subjects }, + client.getStringAssignment, + ); + break; + } + default: { + throw new Error(`Unknown variation type: ${variationType}`); + } + } + console.log(assignments); + for (const { subject, assignment } of assignments) { + expect(assignment).toEqual(subject.assignment); + } + }, + ); + }); // it('returns null if getStringAssignment was called for the subject before any RAC was loaded', () => { // expect( @@ -266,6 +318,20 @@ describe('EppoClient E2E test', () => { // ).toEqual(null); // }); + it('returns default value when key does not exist', async () => { + const evaluator = new Evaluator(new MD5Sharder()); + const client = new EppoClient(evaluator, storage); + + const nonExistantFlag = 'non-existent-flag'; + + expect(client.getBoolAssignment('subject-identifer', nonExistantFlag, {}, true)).toBe(true); + expect(client.getNumericAssignment('subject-identifer', nonExistantFlag, {}, 1)).toBe(1); + expect(client.getJSONAssignment('subject-identifer', nonExistantFlag, {}, {})).toEqual({}); + expect(client.getStringAssignment('subject-identifer', nonExistantFlag, {}, 'default')).toBe( + 'default', + ); + }); + it('logs variation assignment and experiment key', () => { const mockLogger = td.object<IAssignmentLogger>(); @@ -281,7 +347,6 @@ describe('EppoClient E2E test', () => { expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); const loggedAssignmentEvent = td.explain(mockLogger.logAssignment).calls[0].args[0]; - console.log(loggedAssignmentEvent); expect(loggedAssignmentEvent.subject).toEqual('subject-10'); expect(loggedAssignmentEvent.featureFlag).toEqual(flagKey); expect(loggedAssignmentEvent.experiment).toEqual(`${flagKey}-${mockFlag.allocations[0].key}`); @@ -300,19 +365,20 @@ describe('EppoClient E2E test', () => { const subjectAttributes = { foo: 3 }; const assignment = client.getStringAssignment('subject-10', flagKey, subjectAttributes); - expect(assignment).toEqual('control'); + expect(assignment).toEqual('variation-a'); }); describe('assignment logging deduplication', () => { let client: EppoClient; + let evaluator: Evaluator; let mockLogger: IAssignmentLogger; beforeEach(() => { mockLogger = td.object<IAssignmentLogger>(); storage.setEntries({ [flagKey]: mockFlag }); - const evaluator = new Evaluator(new MD5Sharder()); - const client = new EppoClient(evaluator, storage); + evaluator = new Evaluator(new MD5Sharder()); + client = new EppoClient(evaluator, storage); client.setLogger(mockLogger); }); @@ -357,8 +423,6 @@ describe('EppoClient E2E test', () => { new Error('logging error'), ); - const evaluator = new Evaluator(new MD5Sharder()); - const client = new EppoClient(evaluator, storage); client.setLogger(mockLogger); client.getStringAssignment('subject-10', flagKey); @@ -374,11 +438,11 @@ describe('EppoClient E2E test', () => { [flagKey]: mockFlag, 'flag-2': { ...mockFlag, - name: 'flag-2', + key: 'flag-2', }, 'flag-3': { ...mockFlag, - name: 'flag-3', + key: 'flag-3', }, }); @@ -398,6 +462,7 @@ describe('EppoClient E2E test', () => { }); it('logs twice for the same flag when rollout increases/flag changes', () => { + // TODO: update test to UFC format client.useNonExpiringInMemoryAssignmentCache(); storage.setEntries({ @@ -473,6 +538,7 @@ describe('EppoClient E2E test', () => { }); it('logs the same subject/flag/variation after two changes', () => { + // TODO: update test to UFC format client.useNonExpiringInMemoryAssignmentCache(); // original configuration version @@ -520,39 +586,6 @@ describe('EppoClient E2E test', () => { }); }); - it('only returns variation if subject matches rules', () => { - const entry = { - ...mockFlag, - rules: [ - { - allocationKey: 'allocation1', - conditions: [ - { - operator: OperatorType.GT, - attribute: 'appVersion', - value: 10, - }, - ], - }, - ], - }; - - storage.setEntries({ [flagKey]: entry }); - - const evaluator = new Evaluator(new MD5Sharder()); - const client = new EppoClient(evaluator, storage); - let assignment = client.getStringAssignment('subject-10', flagKey, { - appVersion: 9, - }); - expect(assignment).toBeNull(); - assignment = client.getStringAssignment('subject-10', flagKey); - expect(assignment).toBeNull(); - assignment = client.getStringAssignment('subject-10', flagKey, { - appVersion: 11, - }); - expect(assignment).toEqual('control'); - }); - // describe('getStringAssignment with hooks', () => { // let client: EppoClient; diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 1589f79c..aa5112ed 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -68,7 +68,7 @@ export interface IEppoClient { assignmentHooks?: IAssignmentHooks, ): number | null; - getParsedJSONAssignment( + getJSONAssignment( subjectKey: string, flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -247,12 +247,33 @@ export default class EppoClient implements IEppoClient { defaultValue ? EppoValue.Numeric(defaultValue) : EppoValue.Null(), assignmentHooks, obfuscated, - VariationType.FLOAT, + VariationType.NUMERIC, ).numericValue ?? null ); } - public getParsedJSONAssignment( + getIntegerAssignment( + subjectKey: string, + flagKey: string, + subjectAttributes?: Record<string, EppoValue>, + defaultValue?: number | null, + assignmentHooks?: IAssignmentHooks | undefined, + obfuscated = false, + ): number | null { + return ( + this.getAssignmentVariation( + subjectKey, + flagKey, + subjectAttributes, + defaultValue ? EppoValue.Numeric(defaultValue) : EppoValue.Null(), + assignmentHooks, + obfuscated, + VariationType.INTEGER, + ).numericValue ?? null + ); + } + + public getJSONAssignment( subjectKey: string, flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -298,12 +319,16 @@ export default class EppoClient implements IEppoClient { flagKey, subjectAttributes, expectedVariationType, + obfuscated, ); - // TODO: figure out whether to translate VariationType to EppoValueType or not - return EppoValue.generateEppoValue(result.variation?.value, expectedVariationType); + if (!result.variation) { + return defaultValue; + } + + return EppoValue.generateEppoValue(result.variation.value, expectedVariationType); } catch (error) { - return this.rethrowIfNotGraceful(error); + return this.rethrowIfNotGraceful(error, defaultValue); } } diff --git a/src/eppo_value.ts b/src/eppo_value.ts index a63e852d..a04af801 100644 --- a/src/eppo_value.ts +++ b/src/eppo_value.ts @@ -40,7 +40,7 @@ export class EppoValue { switch (valueType) { case VariationType.BOOLEAN: return EppoValue.Bool(value as boolean); - case VariationType.FLOAT: + case VariationType.NUMERIC: return EppoValue.Numeric(value as number); case VariationType.INTEGER: return EppoValue.Numeric(value as number); diff --git a/src/eval.ts b/src/eval.ts index 0a2d48fe..34ad548b 100644 --- a/src/eval.ts +++ b/src/eval.ts @@ -50,7 +50,7 @@ export class Evaluator { subjectAttributes, allocationKey: allocation.key, variation: flag.variations[split.variationKey], - extraLogging: split.extraLogging, + extraLogging: split.extraLogging ?? {}, doLog: allocation.doLog, }; } diff --git a/src/interfaces.ts b/src/interfaces.ts index fe8257df..27fae25a 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -3,7 +3,7 @@ import { getMD5Hash } from './obfuscation'; export enum VariationType { STRING = 'string', INTEGER = 'integer', - FLOAT = 'float', + NUMERIC = 'numeric', BOOLEAN = 'boolean', JSON = 'json', } diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 20724884..6d3eebcc 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import { Flag, VariationType } from '../src/interfaces'; +import { AttributeType } from '../src/types'; export const TEST_DATA_DIR = './test/data/ufc/'; export const ASSIGNMENT_TEST_DATA_DIR = TEST_DATA_DIR + 'tests/'; @@ -15,16 +16,15 @@ export enum ValueTestType { JSONType = 'json', } -interface SubjectTestCase { +export interface SubjectTestCase { subjectKey: string; - subjectAttributes: Record<string, unknown>; + subjectAttributes: Record<string, AttributeType>; assignment: string | null; } export interface IAssignmentTestCase { flag: string; variationType: VariationType; - percentExposure: number; subjects: SubjectTestCase[]; } From 2a7b3ec4b6981c4985042031dd891bdbb2b27d86 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 27 Mar 2024 18:36:33 -0700 Subject: [PATCH 05/39] fix more tests --- src/client/eppo-client.spec.ts | 96 +++++++++++----------------------- src/client/eppo-client.ts | 1 + 2 files changed, 31 insertions(+), 66 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index decc2402..5b1b668b 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -461,41 +461,26 @@ describe('EppoClient E2E test', () => { expect(td.explain(mockLogger.logAssignment).callCount).toEqual(3); }); - it('logs twice for the same flag when rollout increases/flag changes', () => { - // TODO: update test to UFC format + it('logs twice for the same flag when allocations change', () => { client.useNonExpiringInMemoryAssignmentCache(); storage.setEntries({ [flagKey]: { ...mockFlag, - allocations: { - allocation1: { - percentExposure: 1, - statusQuoVariationKey: null, - shippedVariationKey: null, - holdouts: [], - variations: [ - { - name: 'control', - value: 'control', - typedValue: 'control', - shardRange: { - start: 0, - end: 10000, - }, - }, + + allocations: [ + { + key: 'allocation-a-2', + rules: [], + splits: [ { - name: 'treatment', - value: 'treatment', - typedValue: 'treatment', - shardRange: { - start: 0, - end: 0, - }, + shards: [], + variationKey: 'a', }, ], + doLog: true, }, - }, + ], }, }); client.getStringAssignment('subject-10', flagKey); @@ -503,34 +488,19 @@ describe('EppoClient E2E test', () => { storage.setEntries({ [flagKey]: { ...mockFlag, - allocations: { - allocation1: { - percentExposure: 1, - statusQuoVariationKey: null, - shippedVariationKey: null, - holdouts: [], - variations: [ + allocations: [ + { + key: 'allocation-a-3', + rules: [], + splits: [ { - name: 'control', - value: 'control', - typedValue: 'control', - shardRange: { - start: 0, - end: 0, - }, - }, - { - name: 'treatment', - value: 'treatment', - typedValue: 'treatment', - shardRange: { - start: 0, - end: 10000, - }, + shards: [], + variationKey: 'a', }, ], + doLog: true, }, - }, + ], }, }); client.getStringAssignment('subject-10', flagKey); @@ -538,7 +508,6 @@ describe('EppoClient E2E test', () => { }); it('logs the same subject/flag/variation after two changes', () => { - // TODO: update test to UFC format client.useNonExpiringInMemoryAssignmentCache(); // original configuration version @@ -551,25 +520,19 @@ describe('EppoClient E2E test', () => { storage.setEntries({ [flagKey]: { ...mockFlag, - allocations: { - allocation1: { - percentExposure: 1, - statusQuoVariationKey: null, - shippedVariationKey: null, - holdouts: [], - variations: [ + allocations: [ + { + key: 'allocation-b', + rules: [], + splits: [ { - name: 'some-new-treatment', - value: 'some-new-treatment', - typedValue: 'some-new-treatment', - shardRange: { - start: 0, - end: 10000, - }, + shards: [], + variationKey: 'b', }, ], + doLog: true, }, - }, + ], }, }); @@ -579,6 +542,7 @@ describe('EppoClient E2E test', () => { // change the flag again, back to the original storage.setEntries({ [flagKey]: mockFlag }); + // Question: Why do we need to log the same assignment again? client.getStringAssignment('subject-10', flagKey); // important: log this assignment client.getStringAssignment('subject-10', flagKey); // cache hit, don't log diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index aa5112ed..f2363339 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -218,6 +218,7 @@ export default class EppoClient implements IEppoClient { assignmentHooks?: IAssignmentHooks | undefined, obfuscated = false, ): boolean | null { + console.log(this.getAssignmentVariation); return ( this.getAssignmentVariation( subjectKey, From d676f4ce3cff708cb4e52983d515c333be32dfc4 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 27 Mar 2024 18:41:07 -0700 Subject: [PATCH 06/39] update event cache test --- src/client/eppo-client.spec.ts | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 5b1b668b..cad2d9af 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -516,18 +516,18 @@ describe('EppoClient E2E test', () => { client.getStringAssignment('subject-10', flagKey); // log this assignment client.getStringAssignment('subject-10', flagKey); // cache hit, don't log - // change the flag + // change the variation storage.setEntries({ [flagKey]: { ...mockFlag, allocations: [ { - key: 'allocation-b', + key: 'allocation-a', // note: same key rules: [], splits: [ { shards: [], - variationKey: 'b', + variationKey: 'b', // but different variation! }, ], doLog: true, @@ -542,11 +542,33 @@ describe('EppoClient E2E test', () => { // change the flag again, back to the original storage.setEntries({ [flagKey]: mockFlag }); - // Question: Why do we need to log the same assignment again? client.getStringAssignment('subject-10', flagKey); // important: log this assignment client.getStringAssignment('subject-10', flagKey); // cache hit, don't log - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(3); + // change the allocation + storage.setEntries({ + [flagKey]: { + ...mockFlag, + allocations: [ + { + key: 'allocation-b', // note: different key + rules: [], + splits: [ + { + shards: [], + variationKey: 'b', // variation has been seen before + }, + ], + doLog: true, + }, + ], + }, + }); + + client.getStringAssignment('subject-10', flagKey); // log this assignment + client.getStringAssignment('subject-10', flagKey); // cache hit, don't log + + expect(td.explain(mockLogger.logAssignment).callCount).toEqual(4); }); }); From 12eb7d1a3b672dc142664eb413867cbffcbfafa8 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 27 Mar 2024 19:00:48 -0700 Subject: [PATCH 07/39] .bind(this) --- src/client/eppo-client.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index cad2d9af..a92e33d7 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -268,35 +268,35 @@ describe('EppoClient E2E test', () => { case VariationType.BOOLEAN: { assignments = getTestAssignments( { flag, variationType, subjects }, - client.getBoolAssignment, + client.getBoolAssignment.bind(client), ); break; } case VariationType.NUMERIC: { assignments = getTestAssignments( { flag, variationType, subjects }, - client.getNumericAssignment, + client.getNumericAssignment.bind(client), ); break; } case VariationType.INTEGER: { assignments = getTestAssignments( { flag, variationType, subjects }, - client.getIntegerAssignment, + client.getIntegerAssignment.bind(client), ); break; } case VariationType.STRING: { assignments = getTestAssignments( { flag, variationType, subjects }, - client.getStringAssignment, + client.getStringAssignment.bind(client), ); break; } case VariationType.JSON: { assignments = getTestAssignments( { flag, variationType, subjects }, - client.getStringAssignment, + client.getStringAssignment.bind(client), ); break; } From e9bb4b3f7a2b1ce866757668e0ac20d6b25c96b4 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 27 Mar 2024 19:07:15 -0700 Subject: [PATCH 08/39] pass all the tests --- src/client/eppo-client.spec.ts | 3 +-- src/eppo_value.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index a92e33d7..0ba6b760 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -296,7 +296,7 @@ describe('EppoClient E2E test', () => { case VariationType.JSON: { assignments = getTestAssignments( { flag, variationType, subjects }, - client.getStringAssignment.bind(client), + client.getJSONAssignment.bind(client), ); break; } @@ -304,7 +304,6 @@ describe('EppoClient E2E test', () => { throw new Error(`Unknown variation type: ${variationType}`); } } - console.log(assignments); for (const { subject, assignment } of assignments) { expect(assignment).toEqual(subject.assignment); } diff --git a/src/eppo_value.ts b/src/eppo_value.ts index a04af801..c8115d2a 100644 --- a/src/eppo_value.ts +++ b/src/eppo_value.ts @@ -116,8 +116,14 @@ export class EppoValue { return new EppoValue(EppoValueType.StringType, undefined, undefined, value, undefined); } - static JSON(value: object): EppoValue { - return new EppoValue(EppoValueType.JSONType, undefined, undefined, undefined, value); + static JSON(value: string | object): EppoValue { + return new EppoValue( + EppoValueType.JSONType, + undefined, + undefined, + undefined, + typeof value === 'string' ? JSON.parse(value) : value, + ); } static Null(): EppoValue { From 684da8ad92ebef095a12fb330b582311e868a029 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 28 Mar 2024 16:34:47 -0700 Subject: [PATCH 09/39] Update script to create obfuscated UFC --- package.json | 2 +- test/writeObfuscatedMockRac.ts | 36 ----------------------- test/writeObfuscatedMockUFC.ts | 52 ++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 37 deletions(-) delete mode 100644 test/writeObfuscatedMockRac.ts create mode 100644 test/writeObfuscatedMockUFC.ts diff --git a/package.json b/package.json index 6d4c906e..20db6832 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "typecheck": "tsc", "test": "yarn test:unit", "test:unit": "NODE_ENV=test jest '.*\\.spec\\.ts'", - "obfuscate-mock-rac": "ts-node test/writeObfuscatedMockRac" + "obfuscate-mock-ufc": "ts-node test/writeObfuscatedMockUFC" }, "jsdelivr": "dist/eppo-sdk.js", "repository": { diff --git a/test/writeObfuscatedMockRac.ts b/test/writeObfuscatedMockRac.ts deleted file mode 100644 index 06f66cda..00000000 --- a/test/writeObfuscatedMockRac.ts +++ /dev/null @@ -1,36 +0,0 @@ -import * as fs from 'fs'; - -import { encodeBase64, getMD5Hash } from '../src/obfuscation'; - -import { - MOCK_RAC_RESPONSE_FILE, - OBFUSCATED_MOCK_RAC_RESPONSE_FILE, - TEST_DATA_DIR, - readMockRacResponse, -} from './testHelpers'; - -export function generateObfuscatedMockRac() { - const rac = readMockRacResponse(MOCK_RAC_RESPONSE_FILE); - const keys = Object.keys(rac.flags); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const flagsCopy: Record<string, any> = {}; - keys.forEach((key) => { - flagsCopy[getMD5Hash(key)] = rac.flags[key]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - flagsCopy[getMD5Hash(key)].rules?.forEach((rule: any) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rule.conditions.forEach((condition: any) => { - condition['value'] = ['ONE_OF', 'NOT_ONE_OF'].includes(condition['operator']) - ? condition['value'].map((value: string) => getMD5Hash(value.toLowerCase())) - : encodeBase64(`${condition['value']}`); - condition['operator'] = getMD5Hash(condition['operator']); - condition['attribute'] = getMD5Hash(condition['attribute']); - }), - ); - }); - return { flags: flagsCopy }; -} - -const obfuscatedRacFilePath = TEST_DATA_DIR + OBFUSCATED_MOCK_RAC_RESPONSE_FILE; -const obfuscatedRac = generateObfuscatedMockRac(); -fs.writeFileSync(obfuscatedRacFilePath, JSON.stringify(obfuscatedRac, null, 2)); diff --git a/test/writeObfuscatedMockUFC.ts b/test/writeObfuscatedMockUFC.ts new file mode 100644 index 00000000..5ffceb3d --- /dev/null +++ b/test/writeObfuscatedMockUFC.ts @@ -0,0 +1,52 @@ +import * as fs from 'fs'; + +import { Flag, Rule } from '../src/interfaces'; +import { encodeBase64, getMD5Hash } from '../src/obfuscation'; + +import { + MOCK_UFC_RESPONSE_FILE, + OBFUSCATED_MOCK_UFC_RESPONSE_FILE, + readMockUFCResponse, + TEST_DATA_DIR, +} from './testHelpers'; + +function obfuscateRule(rule: Rule) { + return { + ...rule, + conditions: rule.conditions.map((condition) => ({ + ...condition, + attribute: getMD5Hash(condition.attribute), + operator: getMD5Hash(condition.operator), + value: + ['ONE_OF', 'NOT_ONE_OF'].includes(condition.operator) && typeof condition.value === 'object' + ? condition.value.map((value) => getMD5Hash(value.toLowerCase())) + : encodeBase64(`${condition.value}`), + })), + }; +} + +function obfuscateFlag(flag: Flag) { + return { + ...flag, + key: getMD5Hash(flag.key), + allocations: flag.allocations.map((allocation) => ({ + ...allocation, + rules: allocation.rules.map(obfuscateRule), + })), + }; +} + +export function generateObfuscatedMockRac() { + const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE); + const keys = Object.keys(ufc.flags); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const flagsCopy: Record<string, any> = {}; + keys.forEach((key) => { + flagsCopy[getMD5Hash(key)] = obfuscateFlag(ufc.flags[key]); + }); + return { flags: flagsCopy }; +} + +const obfuscatedRacFilePath = TEST_DATA_DIR + OBFUSCATED_MOCK_UFC_RESPONSE_FILE; +const obfuscatedRac = generateObfuscatedMockRac(); +fs.writeFileSync(obfuscatedRacFilePath, JSON.stringify(obfuscatedRac, null, 2)); From aa6436634a173fac2a69e1d4452b15286f139437 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 28 Mar 2024 23:23:46 -0700 Subject: [PATCH 10/39] add obfuscation and make all tests pass --- src/client/eppo-client.spec.ts | 691 +++++++----------- src/client/eppo-client.ts | 9 +- src/dto/rule-dto.ts | 27 - src/eval.spec.ts | 61 +- src/eval.ts | 2 +- ...tor.ts => flag-configuration-requestor.ts} | 2 +- src/index.ts | 2 +- src/interfaces.ts | 68 +- src/rule_evaluator.spec.ts | 82 --- src/rule_evaluator.ts | 179 ----- src/rules.spec.ts | 323 ++++++++ src/rules.ts | 300 ++++++++ 12 files changed, 968 insertions(+), 778 deletions(-) delete mode 100644 src/dto/rule-dto.ts rename src/{experiment-configuration-requestor.ts => flag-configuration-requestor.ts} (92%) delete mode 100644 src/rule_evaluator.spec.ts delete mode 100644 src/rule_evaluator.ts create mode 100644 src/rules.spec.ts create mode 100644 src/rules.ts diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 0ba6b760..3d2075a8 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -10,7 +10,6 @@ import { MOCK_UFC_RESPONSE_FILE, OBFUSCATED_MOCK_UFC_RESPONSE_FILE, SubjectTestCase, - ValueTestType, readAssignmentTestData, readMockUFCResponse, } from '../../test/testHelpers'; @@ -18,20 +17,19 @@ import { IAssignmentHooks } from '../assignment-hooks'; import { IAssignmentLogger } from '../assignment-logger'; import { IConfigurationStore } from '../configuration-store'; import { MAX_EVENT_QUEUE_SIZE, POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; -import { OperatorType } from '../dto/rule-dto'; -import { EppoValue } from '../eppo_value'; import { Evaluator } from '../eval'; -import ExperimentConfigurationRequestor from '../experiment-configuration-requestor'; +import FlagConfigurationRequestor from '../flag-configuration-requestor'; import HttpClient from '../http-client'; import { Flag, VariationType } from '../interfaces'; import { MD5Sharder } from '../sharders'; -import { AttributeType, SubjectAttributes } from '../types'; import EppoClient, { FlagConfigurationRequestParameters } from './eppo-client'; // eslint-disable-next-line @typescript-eslint/no-var-requires const packageJson = require('../../package.json'); +const flagEndpoint = /flag_config\/v1\/config*/; + class TestConfigurationStore implements IConfigurationStore { private store: Record<string, string> = {}; @@ -55,6 +53,7 @@ function getTestAssignments( testCase: IAssignmentTestCase, // eslint-disable-next-line @typescript-eslint/no-explicit-any assignmentFn: any, + obfuscated = false, ): { subject: SubjectTestCase; assignment: string | boolean | number | null | object }[] { const assignments: { subject: SubjectTestCase; @@ -67,7 +66,7 @@ function getTestAssignments( subject.subjectAttributes, null, undefined, - false, + obfuscated, ); assignments.push({ subject: subject, assignment: assignment }); } @@ -86,10 +85,7 @@ export async function init(configurationStore: IConfigurationStore) { sdkVersion: packageJson.version, }); - const configurationRequestor = new ExperimentConfigurationRequestor( - configurationStore, - httpClient, - ); + const configurationRequestor = new FlagConfigurationRequestor(configurationStore, httpClient); await configurationRequestor.fetchAndStoreConfigurations(); } @@ -100,9 +96,9 @@ describe('EppoClient E2E test', () => { beforeAll(async () => { mock.setup(); - mock.get(/flag_config\/v1\/config*/, (_req, res) => { - const rac = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE); - return res.status(200).body(JSON.stringify(rac)); + mock.get(flagEndpoint, (_req, res) => { + const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE); + return res.status(200).body(JSON.stringify(ufc)); }); await init(storage); @@ -311,6 +307,89 @@ describe('EppoClient E2E test', () => { ); }); + describe('UFC Obfuscated Test Cases', () => { + const storage = new TestConfigurationStore(); + const evaluator = new Evaluator(new MD5Sharder()); + const globalClient = new EppoClient(evaluator, storage); + + beforeAll(async () => { + mock.setup(); + mock.get(flagEndpoint, (_req, res) => { + const ufc = readMockUFCResponse(OBFUSCATED_MOCK_UFC_RESPONSE_FILE); + console.log(ufc); + return res.status(200).body(JSON.stringify(ufc)); + }); + await init(storage); + }); + + afterAll(() => { + mock.teardown(); + }); + + it.each(readAssignmentTestData())( + 'test variation assignment splits', + async ({ flag, variationType, subjects }: IAssignmentTestCase) => { + `---- Test Case for ${flag} Experiment ----`; + + const evaluator = new Evaluator(new MD5Sharder()); + const client = new EppoClient(evaluator, storage); + + let assignments: { + subject: SubjectTestCase; + assignment: string | boolean | number | null | object; + }[] = []; + switch (variationType) { + case VariationType.BOOLEAN: { + assignments = getTestAssignments( + { flag, variationType, subjects }, + client.getBoolAssignment.bind(client), + true, + ); + break; + } + case VariationType.NUMERIC: { + assignments = getTestAssignments( + { flag, variationType, subjects }, + client.getNumericAssignment.bind(client), + true, + ); + break; + } + case VariationType.INTEGER: { + assignments = getTestAssignments( + { flag, variationType, subjects }, + client.getIntegerAssignment.bind(client), + true, + ); + break; + } + case VariationType.STRING: { + assignments = getTestAssignments( + { flag, variationType, subjects }, + client.getStringAssignment.bind(client), + true, + ); + break; + } + case VariationType.JSON: { + assignments = getTestAssignments( + { flag, variationType, subjects }, + client.getJSONAssignment.bind(client), + true, + ); + break; + } + default: { + throw new Error(`Unknown variation type: ${variationType}`); + } + } + for (const { subject, assignment } of assignments) { + expect(assignment).toEqual(subject.assignment); + } + }, + ); + }); + // it('returns null if getStringAssignment was called for the subject before any RAC was loaded', () => { // expect( // globalClient.getStringAssignment(sessionOverrideSubject, sessionOverrideExperiment), @@ -571,408 +650,190 @@ describe('EppoClient E2E test', () => { }); }); - // describe('getStringAssignment with hooks', () => { - // let client: EppoClient; - - // beforeAll(() => { - // storage.setEntries({ [flagKey]: mockExperimentConfig }); - // client = new EppoClient(storage); - // }); - - // describe('onPreAssignment', () => { - // it('called with experiment key and subject id', () => { - // const mockHooks = td.object<IAssignmentHooks>(); - // client.getStringAssignment('subject-identifer', flagKey, {}, mockHooks); - // expect(td.explain(mockHooks.onPreAssignment).callCount).toEqual(1); - // expect(td.explain(mockHooks.onPreAssignment).calls[0].args[0]).toEqual(flagKey); - // expect(td.explain(mockHooks.onPreAssignment).calls[0].args[1]).toEqual('subject-identifer'); - // }); - - // it('overrides returned assignment', async () => { - // const mockLogger = td.object<IAssignmentLogger>(); - // client.setLogger(mockLogger); - // td.reset(); - // const variation = await client.getStringAssignment( - // 'subject-identifer', - // flagKey, - // {}, - // { - // // eslint-disable-next-line @typescript-eslint/no-unused-vars - // onPreAssignment(experimentKey: string, subject: string): EppoValue | null { - // return EppoValue.String('my-overridden-variation'); - // }, - - // // eslint-disable-next-line @typescript-eslint/no-unused-vars - // onPostAssignment( - // experimentKey: string, // eslint-disable-line @typescript-eslint/no-unused-vars - // subject: string, // eslint-disable-line @typescript-eslint/no-unused-vars - // variation: EppoValue | null, // eslint-disable-line @typescript-eslint/no-unused-vars - // ): void { - // // no-op - // }, - // }, - // ); - - // expect(variation).toEqual('my-overridden-variation'); - // expect(td.explain(mockLogger.logAssignment).callCount).toEqual(0); - // }); - - // it('uses regular assignment logic if onPreAssignment returns null', async () => { - // const mockLogger = td.object<IAssignmentLogger>(); - // client.setLogger(mockLogger); - // td.reset(); - // const variation = await client.getStringAssignment( - // 'subject-identifer', - // flagKey, - // {}, - // { - // // eslint-disable-next-line @typescript-eslint/no-unused-vars - // onPreAssignment(experimentKey: string, subject: string): EppoValue | null { - // return null; - // }, - - // onPostAssignment( - // experimentKey: string, // eslint-disable-line @typescript-eslint/no-unused-vars - // subject: string, // eslint-disable-line @typescript-eslint/no-unused-vars - // variation: EppoValue | null, // eslint-disable-line @typescript-eslint/no-unused-vars - // ): void { - // // no-op - // }, - // }, - // ); - - // expect(variation).not.toBeNull(); - // expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); - // }); - // }); - - // describe('onPostAssignment', () => { - // it('called with assigned variation after assignment', async () => { - // const mockHooks = td.object<IAssignmentHooks>(); - // const subject = 'subject-identifier'; - // const variation = client.getStringAssignment(subject, flagKey, {}, mockHooks); - // expect(td.explain(mockHooks.onPostAssignment).callCount).toEqual(1); - // expect(td.explain(mockHooks.onPostAssignment).callCount).toEqual(1); - // expect(td.explain(mockHooks.onPostAssignment).calls[0].args[0]).toEqual(flagKey); - // expect(td.explain(mockHooks.onPostAssignment).calls[0].args[1]).toEqual(subject); - // expect(td.explain(mockHooks.onPostAssignment).calls[0].args[2]).toEqual( - // EppoValue.String(variation ?? ''), - // ); - // }); - // }); - // }); - // }); + describe('Eppo Client constructed with configuration request parameters', () => { + let client: EppoClient; + let storage: IConfigurationStore; + let requestConfiguration: FlagConfigurationRequestParameters; + let mockServerResponseFunc: (res: MockResponse) => MockResponse; - // describe(' EppoClient getStringAssignment From Obfuscated RAC', () => { - // const storage = new TestConfigurationStore(); - // const evaluator = new Evaluator(new MD5Sharder()); - // const globalClient = new EppoClient(evaluator, storage); - - // beforeAll(async () => { - // mock.setup(); - // mock.get(/randomized_assignment\/v3\/config*/, (_req, res) => { - // const rac = readMockUFCResponse(OBFUSCATED_MOCK_RAC_RESPONSE_FILE); - // return res.status(200).body(JSON.stringify(rac)); - // }); - // await init(storage); - // }); - - // afterAll(() => { - // mock.teardown(); - // }); - - // it.each(readAssignmentTestData())( - // 'test variation assignment splits', - // async ({ - // experiment, - // valueType = ValueTestType.StringType, - // subjects, - // subjectsWithAttributes, - // expectedAssignments, - // }: IAssignmentTestCase) => { - // `---- Test Case for ${experiment} Experiment ----`; - - // const assignments = getAssignmentsWithSubjectAttributes( - // subjectsWithAttributes - // ? subjectsWithAttributes - // : subjects.map((subject) => ({ subjectKey: subject })), - // experiment, - // valueType, - // ); - - // switch (valueType) { - // case ValueTestType.BoolType: { - // const boolAssignments = assignments.map((a) => a?.boolValue ?? null); - // expect(boolAssignments).toEqual(expectedAssignments); - // break; - // } - // case ValueTestType.NumericType: { - // const numericAssignments = assignments.map((a) => a?.numericValue ?? null); - // expect(numericAssignments).toEqual(expectedAssignments); - // break; - // } - // case ValueTestType.StringType: { - // const stringAssignments = assignments.map((a) => a?.stringValue ?? null); - // expect(stringAssignments).toEqual(expectedAssignments); - // break; - // } - // case ValueTestType.JSONType: { - // const jsonStringAssignments = assignments.map((a) => a?.stringValue ?? null); - // expect(jsonStringAssignments).toEqual(expectedAssignments); - // break; - // } - // } - // }, - // ); - - // function getAssignmentsWithSubjectAttributes( - // subjectsWithAttributes: { - // subjectKey: string; - // // eslint-disable-next-line @typescript-eslint/no-explicit-any - // subjectAttributes?: Record<string, any>; - // }[], - // experiment: string, - // valueTestType: ValueTestType = ValueTestType.StringType, - // ): (EppoValue | null)[] { - // return subjectsWithAttributes.map((subject) => { - // switch (valueTestType) { - // case ValueTestType.BoolType: { - // const ba = globalClient.getBoolAssignment( - // subject.subjectKey, - // experiment, - // subject.subjectAttributes, - // undefined, - // true, - // ); - // if (ba === null) return null; - // return EppoValue.Bool(ba); - // } - // case ValueTestType.NumericType: { - // const na = globalClient.getNumericAssignment( - // subject.subjectKey, - // experiment, - // subject.subjectAttributes, - // undefined, - // true, - // ); - // if (na === null) return null; - // return EppoValue.Numeric(na); - // } - // case ValueTestType.StringType: { - // const sa = globalClient.getStringAssignment( - // subject.subjectKey, - // experiment, - // subject.subjectAttributes, - // undefined, - // true, - // ); - // if (sa === null) return null; - // return EppoValue.String(sa); - // } - // case ValueTestType.JSONType: { - // const sa = globalClient.getJSONStringAssignment( - // subject.subjectKey, - // experiment, - // subject.subjectAttributes, - // undefined, - // true, - // ); - // const oa = globalClient.getParsedJSONAssignment( - // subject.subjectKey, - // experiment, - // subject.subjectAttributes, - // undefined, - // true, - // ); - // if (oa == null || sa === null) return null; - // return EppoValue.JSON(sa, oa); - // } - // } - // }); - // } - // }); + const evaluator = new Evaluator(new MD5Sharder()); + const ufcBody = JSON.stringify(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)); + const flagKey = 'numeric_flag'; + const subject = 'alice'; + const pi = 3.1415926; + + const maxRetryDelay = POLL_INTERVAL_MS * POLL_JITTER_PCT; - // describe('Eppo Client constructed with configuration request parameters', () => { - // let client: EppoClient; - // let storage: IConfigurationStore; - // let requestConfiguration: ExperimentConfigurationRequestParameters; - // let mockServerResponseFunc: (res: MockResponse) => MockResponse; - - // const racBody = JSON.stringify(readMockRacResponse(MOCK_RAC_RESPONSE_FILE)); - // const flagKey = 'randomization_algo'; - // const subjectForGreenVariation = 'subject-identiferA'; - - // const maxRetryDelay = POLL_INTERVAL_MS * POLL_JITTER_PCT; - - // beforeAll(() => { - // mock.setup(); - // mock.get(/randomized_assignment\/v3\/config*/, (_req, res) => { - // return mockServerResponseFunc(res); - // }); - // }); - - // beforeEach(() => { - // storage = new TestConfigurationStore(); - // requestConfiguration = { - // apiKey: 'dummy key', - // sdkName: 'js-client-sdk-common', - // sdkVersion: packageJson.version, - // }; - // mockServerResponseFunc = (res) => res.status(200).body(racBody); - - // // We only want to fake setTimeout() and clearTimeout() - // jest.useFakeTimers({ - // advanceTimers: true, - // doNotFake: [ - // 'Date', - // 'hrtime', - // 'nextTick', - // 'performance', - // 'queueMicrotask', - // 'requestAnimationFrame', - // 'cancelAnimationFrame', - // 'requestIdleCallback', - // 'cancelIdleCallback', - // 'setImmediate', - // 'clearImmediate', - // 'setInterval', - // 'clearInterval', - // ], - // }); - // }); - - // afterEach(() => { - // jest.clearAllTimers(); - // jest.useRealTimers(); - // }); - - // afterAll(() => { - // mock.teardown(); - // }); - - // it('Fetches initial configuration with parameters in constructor', async () => { - // client = new EppoClient(storage, requestConfiguration); - // client.setIsGracefulFailureMode(false); - // // no configuration loaded - // let variation = client.getStringAssignment(subjectForGreenVariation, flagKey); - // expect(variation).toBeNull(); - // // have client fetch configurations - // await client.fetchFlagConfigurations(); - // variation = client.getStringAssignment(subjectForGreenVariation, flagKey); - // expect(variation).toBe('green'); - // }); - - // it('Fetches initial configuration with parameters provided later', async () => { - // client = new EppoClient(storage); - // client.setIsGracefulFailureMode(false); - // client.setConfigurationRequestParameters(requestConfiguration); - // // no configuration loaded - // let variation = client.getStringAssignment(subjectForGreenVariation, flagKey); - // expect(variation).toBeNull(); - // // have client fetch configurations - // await client.fetchFlagConfigurations(); - // variation = client.getStringAssignment(subjectForGreenVariation, flagKey); - // expect(variation).toBe('green'); - // }); - - // it.each([ - // { pollAfterSuccessfulInitialization: false }, - // { pollAfterSuccessfulInitialization: true }, - // ])('retries initial configuration request with config %p', async (configModification) => { - // let callCount = 0; - // mockServerResponseFunc = (res) => { - // if (++callCount === 1) { - // // Throw an error for the first call - // return res.status(500); - // } else { - // // Return a mock object for subsequent calls - // return res.status(200).body(racBody); - // } - // }; - - // const { pollAfterSuccessfulInitialization } = configModification; - // requestConfiguration = { - // ...requestConfiguration, - // pollAfterSuccessfulInitialization, - // }; - // client = new EppoClient(storage, requestConfiguration); - // client.setIsGracefulFailureMode(false); - // // no configuration loaded - // let variation = client.getStringAssignment(subjectForGreenVariation, flagKey); - // expect(variation).toBeNull(); - - // // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes - // const fetchPromise = client.fetchFlagConfigurations(); - - // // Advance timers mid-init to allow retrying - // await jest.advanceTimersByTimeAsync(maxRetryDelay); - - // // Await so it can finish its initialization before this test proceeds - // await fetchPromise; - - // variation = client.getStringAssignment(subjectForGreenVariation, flagKey); - // expect(variation).toBe('green'); - // expect(callCount).toBe(2); - - // await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS); - // // By default, no more polling - // expect(callCount).toBe(pollAfterSuccessfulInitialization ? 3 : 2); - // }); - - // it.each([ - // { - // pollAfterFailedInitialization: false, - // throwOnFailedInitialization: false, - // }, - // { pollAfterFailedInitialization: false, throwOnFailedInitialization: true }, - // { pollAfterFailedInitialization: true, throwOnFailedInitialization: false }, - // { pollAfterFailedInitialization: true, throwOnFailedInitialization: true }, - // ])('initial configuration request fails with config %p', async (configModification) => { - // let callCount = 0; - // mockServerResponseFunc = (res) => { - // if (++callCount === 1) { - // // Throw an error for initialization call - // return res.status(500); - // } else { - // // Return a mock object for subsequent calls - // return res.status(200).body(racBody); - // } - // }; - - // const { pollAfterFailedInitialization, throwOnFailedInitialization } = configModification; - - // // Note: fake time does not play well with errors bubbled up after setTimeout (event loop, - // // timeout queue, message queue stuff) so we don't allow retries when rethrowing. - // const numInitialRequestRetries = 0; - - // requestConfiguration = { - // ...requestConfiguration, - // numInitialRequestRetries, - // throwOnFailedInitialization, - // pollAfterFailedInitialization, - // }; - // client = new EppoClient(storage, requestConfiguration); - // client.setIsGracefulFailureMode(false); - // // no configuration loaded - // expect(client.getStringAssignment(subjectForGreenVariation, flagKey)).toBeNull(); - - // // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes - // if (throwOnFailedInitialization) { - // await expect(client.fetchFlagConfigurations()).rejects.toThrow(); - // } else { - // await expect(client.fetchFlagConfigurations()).resolves.toBeUndefined(); - // } - // expect(callCount).toBe(1); - // // still no configuration loaded - // expect(client.getStringAssignment(subjectForGreenVariation, flagKey)).toBeNull(); - - // // Advance timers so a post-init poll can take place - // await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 1.5); - - // // if pollAfterFailedInitialization = true, we will poll later and get a config, otherwise not - // expect(callCount).toBe(pollAfterFailedInitialization ? 2 : 1); - // expect(client.getStringAssignment(subjectForGreenVariation, flagKey)).toBe( - // pollAfterFailedInitialization ? 'green' : null, - // ); - // }); + beforeAll(() => { + mock.setup(); + mock.get(flagEndpoint, (_req, res) => { + return mockServerResponseFunc(res); + }); + }); + + beforeEach(() => { + storage = new TestConfigurationStore(); + requestConfiguration = { + apiKey: 'dummy key', + sdkName: 'js-client-sdk-common', + sdkVersion: packageJson.version, + }; + mockServerResponseFunc = (res) => res.status(200).body(ufcBody); + + // We only want to fake setTimeout() and clearTimeout() + jest.useFakeTimers({ + advanceTimers: true, + doNotFake: [ + 'Date', + 'hrtime', + 'nextTick', + 'performance', + 'queueMicrotask', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'requestIdleCallback', + 'cancelIdleCallback', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + ], + }); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + afterAll(() => { + mock.teardown(); + }); + + it('Fetches initial configuration with parameters in constructor', async () => { + client = new EppoClient(evaluator, storage, requestConfiguration); + client.setIsGracefulFailureMode(false); + // no configuration loaded + let variation = client.getNumericAssignment(subject, flagKey); + expect(variation).toBeNull(); + // have client fetch configurations + await client.fetchFlagConfigurations(); + variation = client.getNumericAssignment(subject, flagKey); + expect(variation).toBe(pi); + }); + + it('Fetches initial configuration with parameters provided later', async () => { + client = new EppoClient(evaluator, storage); + client.setIsGracefulFailureMode(false); + client.setConfigurationRequestParameters(requestConfiguration); + // no configuration loaded + let variation = client.getNumericAssignment(subject, flagKey); + expect(variation).toBeNull(); + // have client fetch configurations + await client.fetchFlagConfigurations(); + variation = client.getNumericAssignment(subject, flagKey); + expect(variation).toBe(pi); + }); + + it.each([ + { pollAfterSuccessfulInitialization: false }, + { pollAfterSuccessfulInitialization: true }, + ])('retries initial configuration request with config %p', async (configModification) => { + let callCount = 0; + mockServerResponseFunc = (res) => { + if (++callCount === 1) { + // Throw an error for the first call + return res.status(500); + } else { + // Return a mock object for subsequent calls + return res.status(200).body(ufcBody); + } + }; + + const { pollAfterSuccessfulInitialization } = configModification; + requestConfiguration = { + ...requestConfiguration, + pollAfterSuccessfulInitialization, + }; + client = new EppoClient(evaluator, storage, requestConfiguration); + client.setIsGracefulFailureMode(false); + // no configuration loaded + let variation = client.getNumericAssignment(subject, flagKey); + expect(variation).toBeNull(); + + // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes + const fetchPromise = client.fetchFlagConfigurations(); + + // Advance timers mid-init to allow retrying + await jest.advanceTimersByTimeAsync(maxRetryDelay); + + // Await so it can finish its initialization before this test proceeds + await fetchPromise; + + variation = client.getNumericAssignment(subject, flagKey); + expect(variation).toBe(pi); + expect(callCount).toBe(2); + + await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + // By default, no more polling + expect(callCount).toBe(pollAfterSuccessfulInitialization ? 3 : 2); + }); + + it.each([ + { + pollAfterFailedInitialization: false, + throwOnFailedInitialization: false, + }, + { pollAfterFailedInitialization: false, throwOnFailedInitialization: true }, + { pollAfterFailedInitialization: true, throwOnFailedInitialization: false }, + { pollAfterFailedInitialization: true, throwOnFailedInitialization: true }, + ])('initial configuration request fails with config %p', async (configModification) => { + let callCount = 0; + mockServerResponseFunc = (res) => { + if (++callCount === 1) { + // Throw an error for initialization call + return res.status(500); + } else { + // Return a mock object for subsequent calls + return res.status(200).body(ufcBody); + } + }; + + const { pollAfterFailedInitialization, throwOnFailedInitialization } = configModification; + + // Note: fake time does not play well with errors bubbled up after setTimeout (event loop, + // timeout queue, message queue stuff) so we don't allow retries when rethrowing. + const numInitialRequestRetries = 0; + + requestConfiguration = { + ...requestConfiguration, + numInitialRequestRetries, + throwOnFailedInitialization, + pollAfterFailedInitialization, + }; + client = new EppoClient(evaluator, storage, requestConfiguration); + client.setIsGracefulFailureMode(false); + // no configuration loaded + expect(client.getNumericAssignment(subject, flagKey)).toBeNull(); + + // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes + if (throwOnFailedInitialization) { + await expect(client.fetchFlagConfigurations()).rejects.toThrow(); + } else { + await expect(client.fetchFlagConfigurations()).resolves.toBeUndefined(); + } + expect(callCount).toBe(1); + // still no configuration loaded + expect(client.getNumericAssignment(subject, flagKey)).toBeNull(); + + // Advance timers so a post-init poll can take place + await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 1.5); + + // if pollAfterFailedInitialization = true, we will poll later and get a config, otherwise not + expect(callCount).toBe(pollAfterFailedInitialization ? 2 : 1); + expect(client.getNumericAssignment(subject, flagKey)).toBe( + pollAfterFailedInitialization ? pi : null, + ); + }); + }); }); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index f2363339..b2e47144 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -19,9 +19,10 @@ import { } from '../constants'; import { EppoValue } from '../eppo_value'; import { Evaluator, FlagEvaluation, noneResult } from '../eval'; -import ExperimentConfigurationRequestor from '../experiment-configuration-requestor'; +import ExperimentConfigurationRequestor from '../flag-configuration-requestor'; import HttpClient from '../http-client'; import { Flag, VariationType } from '../interfaces'; +import { getMD5Hash } from '../obfuscation'; import initPoller, { IPoller } from '../poller'; import { AttributeType } from '../types'; import { validateNotBlank } from '../validation'; @@ -343,7 +344,7 @@ export default class EppoClient implements IEppoClient { validateNotBlank(subjectKey, 'Invalid argument: subjectKey cannot be blank'); validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank'); - const flag: Flag = this.configurationStore.get(flagKey); + const flag: Flag = this.configurationStore.get(obfuscated ? getMD5Hash(flagKey) : flagKey); if (flag === null) { console.warn(`[Eppo SDK] No assigned variation. Flag not found: ${flagKey}`); @@ -364,6 +365,10 @@ export default class EppoClient implements IEppoClient { } const result = this.evaluator.evaluateFlag(flag, subjectKey, subjectAttributes, obfuscated); + if (obfuscated) { + // flag.key is obfuscated, replace with requested flag key + result.flagKey = flagKey; + } try { if (result && result.doLog) { diff --git a/src/dto/rule-dto.ts b/src/dto/rule-dto.ts deleted file mode 100644 index c72f0cef..00000000 --- a/src/dto/rule-dto.ts +++ /dev/null @@ -1,27 +0,0 @@ -export enum OperatorType { - MATCHES = 'MATCHES', - GTE = 'GTE', - GT = 'GT', - LTE = 'LTE', - LT = 'LT', - ONE_OF = 'ONE_OF', - NOT_ONE_OF = 'NOT_ONE_OF', -} - -export enum OperatorValueType { - PLAIN_STRING = 'PLAIN_STRING', - STRING_ARRAY = 'STRING_ARRAY', - SEM_VER = 'SEM_VER', - NUMERIC = 'NUMERIC', -} - -export interface Condition { - operator: OperatorType; - attribute: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any; -} - -export interface IRule { - conditions: Condition[]; -} diff --git a/src/eval.spec.ts b/src/eval.spec.ts index bcf091fd..a212ced3 100644 --- a/src/eval.spec.ts +++ b/src/eval.spec.ts @@ -1,5 +1,7 @@ import { Evaluator, hashKey, isInShardRange } from './eval'; -import { Flag, Variation, Shard, VariationType, OperatorType } from './interfaces'; +import { Flag, Variation, Shard, VariationType } from './interfaces'; +import { encodeBase64, getMD5Hash } from './obfuscation'; +import { ObfuscatedOperatorType, OperatorType } from './rules'; import { MD5Sharder, DeterministicSharder } from './sharders'; describe('Evaluator', () => { @@ -203,7 +205,7 @@ describe('Evaluator', () => { rules: [ { conditions: [ - { operator: OperatorType.MATCHES, attribute: 'email', value: '.*@example.com' }, + { operator: OperatorType.MATCHES, attribute: 'email', value: '.*@example\\.com$' }, ], }, ], @@ -256,7 +258,7 @@ describe('Evaluator', () => { rules: [ { conditions: [ - { operator: OperatorType.MATCHES, attribute: 'email', value: '.*@example.com' }, + { operator: OperatorType.MATCHES, attribute: 'email', value: '.*@example\\.com$' }, ], }, ], @@ -291,6 +293,59 @@ describe('Evaluator', () => { expect(result.allocationKey).toEqual('default'); expect(result.variation).toEqual(VARIATION_A); }); + + it('should not match first allocation rule and return variation A (obfuscated)', () => { + const flag: Flag = { + key: 'obfuscated_flag_key', + enabled: true, + variationType: VariationType.STRING, + variations: { a: VARIATION_A, b: VARIATION_B }, + allocations: [ + { + key: 'first', + rules: [ + { + conditions: [ + { + operator: ObfuscatedOperatorType.MATCHES, + attribute: getMD5Hash('email'), + value: encodeBase64('.*@example\\.com$'), + }, + ], + }, + ], + splits: [ + { + variationKey: 'b', + shards: [], + extraLogging: {}, + }, + ], + doLog: true, + }, + { + key: 'default', + rules: [], + splits: [ + { + variationKey: 'a', + shards: [], + extraLogging: {}, + }, + ], + doLog: true, + }, + ], + totalShards: 10, + }; + + const evaluator = new Evaluator(new MD5Sharder()); + const result = evaluator.evaluateFlag(flag, 'subject_key', { email: 'eppo@test.com' }, false); + expect(result.flagKey).toEqual('obfuscated_flag_key'); + expect(result.allocationKey).toEqual('default'); + expect(result.variation).toEqual(VARIATION_A); + }); + it('should evaluate sharding and return correct variations', () => { const flag: Flag = { key: 'flag', diff --git a/src/eval.ts b/src/eval.ts index 34ad548b..416b6907 100644 --- a/src/eval.ts +++ b/src/eval.ts @@ -1,5 +1,5 @@ import { Flag, Shard, Range, Variation, Allocation, Split } from './interfaces'; -import { matchesRule } from './rule_evaluator'; +import { matchesRule } from './rules'; import { Sharder } from './sharders'; export interface FlagEvaluation { diff --git a/src/experiment-configuration-requestor.ts b/src/flag-configuration-requestor.ts similarity index 92% rename from src/experiment-configuration-requestor.ts rename to src/flag-configuration-requestor.ts index 4773c14b..561cb899 100644 --- a/src/experiment-configuration-requestor.ts +++ b/src/flag-configuration-requestor.ts @@ -8,7 +8,7 @@ interface IUniversalFlagConfig { flags: Record<string, Flag>; } -export default class ExperimentConfigurationRequestor { +export default class FlagConfigurationRequestor { constructor(private configurationStore: IConfigurationStore, private httpClient: HttpClient) {} async fetchAndStoreConfigurations(): Promise<Record<string, Flag>> { diff --git a/src/index.ts b/src/index.ts index 407d795f..f2c21f1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import { IAssignmentLogger, IAssignmentEvent } from './assignment-logger'; import EppoClient, { FlagConfigurationRequestParameters, IEppoClient } from './client/eppo-client'; import { IConfigurationStore } from './configuration-store'; import * as constants from './constants'; -import ExperimentConfigurationRequestor from './experiment-configuration-requestor'; +import ExperimentConfigurationRequestor from './flag-configuration-requestor'; import HttpClient from './http-client'; import * as validation from './validation'; diff --git a/src/interfaces.ts b/src/interfaces.ts index 27fae25a..765adaa8 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,4 @@ -import { getMD5Hash } from './obfuscation'; +import { Rule } from './rules'; export enum VariationType { STRING = 'string', @@ -46,69 +46,3 @@ export interface Flag { allocations: Allocation[]; totalShards: number; } - -export enum OperatorType { - MATCHES = 'MATCHES', - NOT_MATCHES = 'NOT_MATCHES', - GTE = 'GTE', - GT = 'GT', - LTE = 'LTE', - LT = 'LT', - ONE_OF = 'ONE_OF', - NOT_ONE_OF = 'NOT_ONE_OF', -} - -export enum OperatorValueType { - PLAIN_STRING = 'PLAIN_STRING', - STRING_ARRAY = 'STRING_ARRAY', - SEM_VER = 'SEM_VER', - NUMERIC = 'NUMERIC', -} - -interface MatchesCondition { - operator: OperatorType.MATCHES; - attribute: string; - value: string; -} - -interface NotMatchesCondition { - operator: OperatorType.NOT_MATCHES; - attribute: string; - value: string; -} - -interface OneOfCondition { - operator: OperatorType.ONE_OF; - attribute: string; - value: string[]; -} - -interface NotOneOfCondition { - operator: OperatorType.NOT_ONE_OF; - attribute: string; - value: string[]; -} - -interface SemVerCondition { - operator: OperatorType.GTE | OperatorType.GT | OperatorType.LTE | OperatorType.LT; - attribute: string; - value: string; -} - -interface NumericCondition { - operator: OperatorType.GTE | OperatorType.GT | OperatorType.LTE | OperatorType.LT; - attribute: string; - value: number; -} - -export type Condition = - | MatchesCondition - | NotMatchesCondition - | OneOfCondition - | NotOneOfCondition - | SemVerCondition - | NumericCondition; - -export interface Rule { - conditions: Condition[]; -} diff --git a/src/rule_evaluator.spec.ts b/src/rule_evaluator.spec.ts deleted file mode 100644 index a4692936..00000000 --- a/src/rule_evaluator.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { OperatorType, IRule } from './dto/rule-dto'; -import { matchesRule } from './rule_evaluator'; - -describe('matchesRule', () => { - const ruleWithEmptyConditions: IRule = { - conditions: [], - }; - const numericRule: IRule = { - conditions: [ - { - operator: OperatorType.GTE, - attribute: 'totalSales', - value: 10, - }, - { - operator: OperatorType.LTE, - attribute: 'totalSales', - value: 100, - }, - ], - }; - const semverRule: IRule = { - conditions: [ - { - operator: OperatorType.GTE, - attribute: 'version', - value: '1.0.0', - }, - { - operator: OperatorType.LTE, - attribute: 'version', - value: '2.0.0', - }, - ], - }; - const ruleWithMatchesCondition: IRule = { - conditions: [ - { - operator: OperatorType.MATCHES, - attribute: 'user_id', - value: '[0-9]+', - }, - ], - }; - - const subjectAttributes = { - totalSales: 50, - version: '1.5.0', - user_id: '12345', - }; - - it('should return true for a rule with empty conditions', () => { - expect(matchesRule(ruleWithEmptyConditions, subjectAttributes, false)).toBe(true); - }); - - it('should return true for a numeric rule that matches the subject attributes', () => { - expect(matchesRule(numericRule, subjectAttributes, false)).toBe(true); - }); - - it('should return false for a numeric rule that does not match the subject attributes', () => { - const failingAttributes = { totalSales: 101 }; - expect(matchesRule(numericRule, failingAttributes, false)).toBe(false); - }); - - it('should return true for a semver rule that matches the subject attributes', () => { - expect(matchesRule(semverRule, subjectAttributes, false)).toBe(true); - }); - - it('should return false for a semver rule that does not match the subject attributes', () => { - const failingAttributes = { version: '2.1.0' }; - expect(matchesRule(semverRule, failingAttributes, false)).toBe(false); - }); - - it('should return true for a rule with matches condition that matches the subject attributes', () => { - expect(matchesRule(ruleWithMatchesCondition, subjectAttributes, false)).toBe(true); - }); - - it('should return false for a rule with matches condition that does not match the subject attributes', () => { - const failingAttributes = { user_id: 'abcde' }; - expect(matchesRule(ruleWithMatchesCondition, failingAttributes, false)).toBe(false); - }); -}); diff --git a/src/rule_evaluator.ts b/src/rule_evaluator.ts deleted file mode 100644 index 40d54fdf..00000000 --- a/src/rule_evaluator.ts +++ /dev/null @@ -1,179 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - valid as validSemver, - gt as semverGt, - lt as semverLt, - gte as semverGte, - lte as semverLte, -} from 'semver'; - -import { Condition, OperatorType, Rule, OperatorValueType } from './interfaces'; -import { decodeBase64, getMD5Hash } from './obfuscation'; -import { ConditionValueType } from './types'; - -export function matchesRule( - rule: Rule, - subjectAttributes: Record<string, any>, - obfuscated: boolean, -): boolean { - const conditionEvaluations = evaluateRuleConditions( - subjectAttributes, - rule.conditions, - obfuscated, - ); - return !conditionEvaluations.includes(false); -} - -function evaluateRuleConditions( - subjectAttributes: Record<string, any>, - conditions: Condition[], - obfuscated: boolean, -): boolean[] { - // TODO: obfuscated version - return conditions.map((condition) => evaluateCondition(subjectAttributes, condition)); -} - -function evaluateCondition(subjectAttributes: Record<string, any>, condition: Condition): boolean { - const value = subjectAttributes[condition.attribute]; - - const conditionValueType = targetingRuleConditionValuesTypesFromValues(condition.value); - - if (value != null) { - switch (condition.operator) { - case OperatorType.GTE: - if (conditionValueType === OperatorValueType.SEM_VER) { - return compareSemVer(value, condition.value, semverGte); - } - return compareNumber(value, condition.value, (a, b) => a >= b); - case OperatorType.GT: - if (conditionValueType === OperatorValueType.SEM_VER) { - return compareSemVer(value, condition.value, semverGt); - } - return compareNumber(value, condition.value, (a, b) => a > b); - case OperatorType.LTE: - if (conditionValueType === OperatorValueType.SEM_VER) { - return compareSemVer(value, condition.value, semverLte); - } - return compareNumber(value, condition.value, (a, b) => a <= b); - case OperatorType.LT: - if (conditionValueType === OperatorValueType.SEM_VER) { - return compareSemVer(value, condition.value, semverLt); - } - return compareNumber(value, condition.value, (a, b) => a < b); - case OperatorType.MATCHES: - return new RegExp(condition.value as string).test(value as string); - case OperatorType.ONE_OF: - return isOneOf( - value.toString().toLowerCase(), - condition.value.map((value: string) => value.toLowerCase()), - ); - case OperatorType.NOT_ONE_OF: - return isNotOneOf( - value.toString().toLowerCase(), - condition.value.map((value: string) => value.toLowerCase()), - ); - } - } - return false; -} - -// TODO: implement the obfuscated version of this function -// function evaluateObfuscatedCondition( -// subjectAttributes: Record<string, any>, -// condition: Condition, -// ): boolean { -// const hashedSubjectAttributes: Record<string, any> = Object.entries(subjectAttributes).reduce( -// (accum, [key, val]) => ({ [getMD5Hash(key)]: val, ...accum }), -// {}, -// ); -// const value = hashedSubjectAttributes[condition.attribute]; -// const conditionValueType = targetingRuleConditionValuesTypesFromValues(value); - -// if (value != null) { -// switch (condition.operator) { -// case getMD5Hash(OperatorType.GTE): -// if (conditionValueType === OperatorValueType.SEM_VER) { -// return compareSemVer(value, decodeBase64(condition.value), semverGte); -// } -// return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a >= b); -// case getMD5Hash(OperatorType.GT): -// if (conditionValueType === OperatorValueType.SEM_VER) { -// return compareSemVer(value, decodeBase64(condition.value), semverGt); -// } -// return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a > b); -// case getMD5Hash(OperatorType.LTE): -// if (conditionValueType === OperatorValueType.SEM_VER) { -// return compareSemVer(value, decodeBase64(condition.value), semverLte); -// } -// return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a <= b); -// case getMD5Hash(OperatorType.LT): -// if (conditionValueType === OperatorValueType.SEM_VER) { -// return compareSemVer(value, decodeBase64(condition.value), semverLt); -// } -// return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a < b); -// case getMD5Hash(OperatorType.MATCHES): -// return new RegExp(decodeBase64(condition.value)).test(value as string); -// case getMD5Hash(OperatorType.ONE_OF): -// return isOneOf(getMD5Hash(value.toString().toLowerCase()), condition.value); -// case getMD5Hash(OperatorType.NOT_ONE_OF): -// return isNotOneOf(getMD5Hash(value.toString().toLowerCase()), condition.value); -// } -// } -// return false; -// } - -function isOneOf(attributeValue: string, conditionValue: string[]) { - return getMatchingStringValues(attributeValue, conditionValue).length > 0; -} - -function isNotOneOf(attributeValue: string, conditionValue: string[]) { - return getMatchingStringValues(attributeValue, conditionValue).length === 0; -} - -function getMatchingStringValues(attributeValue: string, conditionValues: string[]): string[] { - return conditionValues.filter((value) => value === attributeValue); -} - -function compareNumber( - attributeValue: any, - conditionValue: any, - compareFn: (a: number, b: number) => boolean, -): boolean { - return compareFn(Number(attributeValue), Number(conditionValue)); -} - -function compareSemVer( - attributeValue: any, - conditionValue: any, - compareFn: (a: string, b: string) => boolean, -): boolean { - return ( - !!validSemver(attributeValue) && - !!validSemver(conditionValue) && - compareFn(attributeValue, conditionValue) - ); -} - -function targetingRuleConditionValuesTypesFromValues(value: ConditionValueType): OperatorValueType { - // Check if input is a number - if (typeof value === 'number') { - return OperatorValueType.NUMERIC; - } - - if (Array.isArray(value)) { - return OperatorValueType.STRING_ARRAY; - } - - // Check if input is a string that represents a SemVer - if (typeof value === 'string' && validSemver(value)) { - return OperatorValueType.SEM_VER; - } - - // Check if input is a string that represents a number - if (!isNaN(Number(value))) { - return OperatorValueType.NUMERIC; - } - - // If none of the above, it's a general string - return OperatorValueType.PLAIN_STRING; -} diff --git a/src/rules.spec.ts b/src/rules.spec.ts new file mode 100644 index 00000000..ae38aa92 --- /dev/null +++ b/src/rules.spec.ts @@ -0,0 +1,323 @@ +import { encodeBase64, getMD5Hash } from './obfuscation'; +import { ObfuscatedOperatorType, OperatorType, Rule, matchesRule } from './rules'; + +describe('rules', () => { + describe('Operators', () => { + it('ObfuscatedOperatorTypes should match hashed OperatorTypes', () => { + expect(ObfuscatedOperatorType.GTE).toBe(getMD5Hash(OperatorType.GTE)); + expect(ObfuscatedOperatorType.LTE).toBe(getMD5Hash(OperatorType.LTE)); + expect(ObfuscatedOperatorType.LT).toBe(getMD5Hash(OperatorType.LT)); + expect(ObfuscatedOperatorType.ONE_OF).toBe(getMD5Hash(OperatorType.ONE_OF)); + expect(ObfuscatedOperatorType.NOT_ONE_OF).toBe(getMD5Hash(OperatorType.NOT_ONE_OF)); + expect(ObfuscatedOperatorType.MATCHES).toBe(getMD5Hash(OperatorType.MATCHES)); + expect(ObfuscatedOperatorType.NOT_MATCHES).toBe(getMD5Hash(OperatorType.NOT_MATCHES)); + }); + }); + + describe('matchesRule | standard rules', () => { + const ruleWithEmptyConditions: Rule = { + conditions: [], + }; + const numericRule: Rule = { + conditions: [ + { + operator: OperatorType.GTE, + attribute: 'totalSales', + value: 10, + }, + { + operator: OperatorType.LTE, + attribute: 'totalSales', + value: 100, + }, + ], + }; + const ruleWithOneOfCondition: Rule = { + conditions: [ + { + operator: OperatorType.ONE_OF, + attribute: 'country', + value: ['USA', 'Canada'], + }, + ], + }; + + const ruleWithNotOneOfCondition: Rule = { + conditions: [ + { + operator: OperatorType.NOT_ONE_OF, + attribute: 'country', + value: ['USA', 'Canada'], + }, + ], + }; + + const semverRule: Rule = { + conditions: [ + { + operator: OperatorType.GTE, + attribute: 'version', + value: '1.0.0', + }, + { + operator: OperatorType.LTE, + attribute: 'version', + value: '2.0.0', + }, + ], + }; + const ruleWithMatchesCondition: Rule = { + conditions: [ + { + operator: OperatorType.MATCHES, + attribute: 'user_id', + value: '[0-9]+', + }, + ], + }; + const ruleWithNotMatchesCondition: Rule = { + conditions: [ + { + operator: OperatorType.NOT_MATCHES, + attribute: 'user_id', + value: '[0-9]+', + }, + ], + }; + const subjectAttributes = { + totalSales: 50, + version: '1.5.0', + user_id: '12345', + country: 'USA', + }; + + it('should return true for a rule with empty conditions', () => { + expect(matchesRule(ruleWithEmptyConditions, subjectAttributes, false)).toBe(true); + }); + + it('should return true for a numeric rule that matches the subject attributes', () => { + expect(matchesRule(numericRule, subjectAttributes, false)).toBe(true); + }); + + it('should return false for a numeric rule that does not match the subject attributes', () => { + const failingAttributes = { totalSales: 101 }; + expect(matchesRule(numericRule, failingAttributes, false)).toBe(false); + }); + + it('should return true for a rule with ONE_OF condition that matches the subject attributes', () => { + expect(matchesRule(ruleWithOneOfCondition, { country: 'USA' }, false)).toBe(true); + }); + + it('should return false for a rule with ONE_OF condition that does not match the subject attributes', () => { + expect(matchesRule(ruleWithOneOfCondition, { country: 'UK' }, false)).toBe(false); + }); + + it('should return false for a rule with ONE_OF condition when subject attribute is missing', () => { + expect(matchesRule(ruleWithOneOfCondition, { age: 10 }, false)).toBe(false); + }); + + it('should return false for a rule with NOT_ONE_OF condition that matches the subject attributes', () => { + expect(matchesRule(ruleWithNotOneOfCondition, { country: 'USA' }, false)).toBe(false); + }); + + it('should return true for a rule with NOT_ONE_OF condition that does not match the subject attributes', () => { + expect(matchesRule(ruleWithNotOneOfCondition, { country: 'UK' }, false)).toBe(true); + }); + + it('should return false for a rule with NOT_ONE_OF condition when subject attribute is missing', () => { + expect(matchesRule(ruleWithNotOneOfCondition, { age: 10 }, false)).toBe(false); + }); + ``; + it('should return true for a semver rule that matches the subject attributes', () => { + expect(matchesRule(semverRule, subjectAttributes, false)).toBe(true); + }); + + it('should return false for a semver rule that does not match the subject attributes', () => { + const failingAttributes = { version: '2.1.0' }; + expect(matchesRule(semverRule, failingAttributes, false)).toBe(false); + }); + + it('should return true for a rule with matches condition that matches the subject attributes', () => { + expect(matchesRule(ruleWithMatchesCondition, subjectAttributes, false)).toBe(true); + }); + + it('should return false for a rule with matches condition that does not match the subject attributes', () => { + const failingAttributes = { user_id: 'abcde' }; + expect(matchesRule(ruleWithMatchesCondition, failingAttributes, false)).toBe(false); + }); + + it('should return true for a rule with not_matches condition that matches the subject attributes', () => { + expect(matchesRule(ruleWithNotMatchesCondition, subjectAttributes, false)).toBe(false); + }); + }); + + describe('matchesRule | obfuscated rules', () => { + describe('matchesRule with obfuscated conditions', () => { + const obfuscatedRuleWithOneOfCondition: Rule = { + conditions: [ + { + operator: ObfuscatedOperatorType.ONE_OF, + attribute: getMD5Hash('country'), + value: ['usa', 'canada', 'mexico'].map(getMD5Hash), + }, + ], + }; + + const obfuscatedRuleWithNotOneOfCondition: Rule = { + conditions: [ + { + operator: ObfuscatedOperatorType.NOT_ONE_OF, + attribute: getMD5Hash('country'), + value: ['usa', 'canada', 'mexico'].map(getMD5Hash), + }, + ], + }; + + const obfuscatedRuleWithGTECondition: Rule = { + conditions: [ + { + operator: ObfuscatedOperatorType.GTE, + attribute: getMD5Hash('age'), + value: encodeBase64('18'), + }, + ], + }; + + const obfuscatedRuleWithGTCondition: Rule = { + conditions: [ + { + operator: ObfuscatedOperatorType.GT, + attribute: getMD5Hash('age'), + value: encodeBase64('18'), + }, + ], + }; + const obfuscatedRuleWithLTECondition: Rule = { + conditions: [ + { + operator: ObfuscatedOperatorType.LTE, + attribute: getMD5Hash('age'), + value: encodeBase64('18'), + }, + ], + }; + + const obfuscatedRuleWithLTCondition: Rule = { + conditions: [ + { + operator: ObfuscatedOperatorType.LT, + attribute: getMD5Hash('age'), + value: encodeBase64('18'), + }, + ], + }; + const obfuscatedRuleWithMatchesCondition: Rule = { + conditions: [ + { + operator: ObfuscatedOperatorType.MATCHES, + attribute: getMD5Hash('email'), + value: encodeBase64('.+@example\\.com$'), + }, + ], + }; + + const obfuscatedRuleWithNotMatchesCondition: Rule = { + conditions: [ + { + operator: ObfuscatedOperatorType.NOT_MATCHES, + attribute: getMD5Hash('email'), + value: encodeBase64('.+@example\\.com$'), + }, + ], + }; + + it('should return true for an obfuscated rule with ONE_OF condition that matches the subject attributes', () => { + expect(matchesRule(obfuscatedRuleWithOneOfCondition, { country: 'USA' }, true)).toBe(true); + }); + + it('should return false for an obfuscated rule with ONE_OF condition that does not match the subject attributes', () => { + expect(matchesRule(obfuscatedRuleWithOneOfCondition, { country: 'UK' }, true)).toBe(false); + }); + + it('should return true for an obfuscated rule with NOT_ONE_OF condition that does not match the subject attributes', () => { + expect(matchesRule(obfuscatedRuleWithNotOneOfCondition, { country: 'UK' }, true)).toBe( + true, + ); + }); + + it('should return false for an obfuscated rule with NOT_ONE_OF condition that matches the subject attributes', () => { + expect(matchesRule(obfuscatedRuleWithNotOneOfCondition, { country: 'USA' }, true)).toBe( + false, + ); + }); + + it('should return true for an obfuscated rule with GTE condition that matches the subject attributes', () => { + expect(matchesRule(obfuscatedRuleWithGTECondition, { age: 18 }, true)).toBe(true); + }); + + it('should return false for an obfuscated rule with GTE condition that does not match the subject attributes', () => { + expect(matchesRule(obfuscatedRuleWithGTECondition, { age: 17 }, true)).toBe(false); + }); + + it('should return true for an obfuscated rule with GT condition that matches the subject attributes', () => { + expect(matchesRule(obfuscatedRuleWithGTCondition, { age: 19 }, true)).toBe(true); + }); + + it('should return false for an obfuscated rule with GT condition that does not match the subject attributes', () => { + expect(matchesRule(obfuscatedRuleWithGTCondition, { age: 18 }, true)).toBe(false); + }); + + it('should return true for an obfuscated rule with LTE condition that matches the subject attributes', () => { + expect(matchesRule(obfuscatedRuleWithLTECondition, { age: 18 }, true)).toBe(true); + }); + + it('should return false for an obfuscated rule with LTE condition that does not match the subject attributes', () => { + expect(matchesRule(obfuscatedRuleWithLTECondition, { age: 19 }, true)).toBe(false); + }); + + it('should return true for an obfuscated rule with LT condition that matches the subject attributes', () => { + expect(matchesRule(obfuscatedRuleWithLTCondition, { age: 17 }, true)).toBe(true); + }); + + it('should return false for an obfuscated rule with LT condition that does not match the subject attributes', () => { + expect(matchesRule(obfuscatedRuleWithLTCondition, { age: 18 }, true)).toBe(false); + }); + + it('should return true for an obfuscated rule with MATCHES condition that matches the subject attributes', () => { + expect( + matchesRule(obfuscatedRuleWithMatchesCondition, { email: 'user@example.com' }, true), + ).toBe(true); + }); + + it('should return false for an obfuscated rule with MATCHES condition that does not match the subject attributes', () => { + expect( + matchesRule( + obfuscatedRuleWithMatchesCondition, + { email: 'user@anotherdomain.com' }, + true, + ), + ).toBe(false); + }); + + it('should return true for an obfuscated rule with NOT_MATCHES condition that does not match the subject attributes', () => { + expect( + matchesRule( + obfuscatedRuleWithNotMatchesCondition, + { email: 'user@anotherdomain.com' }, + true, + ), + ).toBe(true); + }); + + it('should return false for an obfuscated rule with NOT_MATCHES condition that matches the subject attributes', () => { + expect( + matchesRule(obfuscatedRuleWithNotMatchesCondition, { email: 'user@example.com' }, true), + ).toBe(false); + }); + + it('should return false for an obfuscated rule with NOT_MATCHES condition when subject attribute is missing', () => { + expect(matchesRule(obfuscatedRuleWithNotMatchesCondition, { age: 30 }, true)).toBe(false); + }); + }); + }); +}); diff --git a/src/rules.ts b/src/rules.ts new file mode 100644 index 00000000..1d52807a --- /dev/null +++ b/src/rules.ts @@ -0,0 +1,300 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + valid as validSemver, + gt as semverGt, + lt as semverLt, + gte as semverGte, + lte as semverLte, +} from 'semver'; + +import { decodeBase64, getMD5Hash } from './obfuscation'; +import { ConditionValueType } from './types'; + +export enum OperatorType { + MATCHES = 'MATCHES', + NOT_MATCHES = 'NOT_MATCHES', + GTE = 'GTE', + GT = 'GT', + LTE = 'LTE', + LT = 'LT', + ONE_OF = 'ONE_OF', + NOT_ONE_OF = 'NOT_ONE_OF', +} + +export enum ObfuscatedOperatorType { + MATCHES = '05015086bdd8402218f6aad6528bef08', + NOT_MATCHES = '8323761667755378c3a78e0a6ed37a78', + GTE = '32d35312e8f24bc1669bd2b45c00d47c', + GT = 'cd6a9bd2a175104eed40f0d33a8b4020', + LTE = 'cc981ecc65ecf63ad1673cbec9c64198', + LT = 'c562607189d77eb9dfb707464c1e7b0b', + ONE_OF = '27457ce369f2a74203396a35ef537c0b', + NOT_ONE_OF = '602f5ee0b6e84fe29f43ab48b9e1addf', +} + +export enum OperatorValueType { + PLAIN_STRING = 'PLAIN_STRING', + STRING_ARRAY = 'STRING_ARRAY', + SEM_VER = 'SEM_VER', + NUMERIC = 'NUMERIC', +} + +type NumericOperator = OperatorType.GTE | OperatorType.GT | OperatorType.LTE | OperatorType.LT; + +type ObfuscatedNumericOperator = + | ObfuscatedOperatorType.GTE + | ObfuscatedOperatorType.GT + | ObfuscatedOperatorType.LTE + | ObfuscatedOperatorType.LT; + +type MatchesCondition = { + operator: OperatorType.MATCHES | ObfuscatedOperatorType.MATCHES; + attribute: string; + value: string; +}; + +type NotMatchesCondition = { + operator: OperatorType.NOT_MATCHES | ObfuscatedOperatorType.NOT_MATCHES; + attribute: string; + value: string; +}; + +type OneOfCondition = { + operator: OperatorType.ONE_OF | ObfuscatedOperatorType.ONE_OF; + attribute: string; + value: string[]; +}; + +type NotOneOfCondition = { + operator: OperatorType.NOT_ONE_OF | ObfuscatedOperatorType.NOT_ONE_OF; + attribute: string; + value: string[]; +}; + +type SemVerCondition = { + operator: NumericOperator; + attribute: string; + value: string; +}; + +type StandardNumericCondition = { + operator: NumericOperator; + attribute: string; + value: number; +}; + +type ObfuscatedNumericCondition = { + operator: ObfuscatedNumericOperator; + attribute: string; + value: string; +}; + +type NumericCondition = StandardNumericCondition | ObfuscatedNumericCondition; + +export type Condition = + | MatchesCondition + | NotMatchesCondition + | OneOfCondition + | NotOneOfCondition + | SemVerCondition + | NumericCondition; + +export interface Rule { + conditions: Condition[]; +} + +export function matchesRule( + rule: Rule, + subjectAttributes: Record<string, any>, + obfuscated: boolean, +): boolean { + const conditionEvaluations = evaluateRuleConditions( + subjectAttributes, + rule.conditions, + obfuscated, + ); + return !conditionEvaluations.includes(false); +} + +function evaluateRuleConditions( + subjectAttributes: Record<string, any>, + conditions: Condition[], + obfuscated: boolean, +): boolean[] { + return conditions.map((condition) => + obfuscated + ? evaluateObfuscatedCondition( + Object.entries(subjectAttributes).reduce( + (accum, [key, val]) => ({ [getMD5Hash(key)]: val, ...accum }), + {}, + ), + condition, + ) + : evaluateCondition(subjectAttributes, condition), + ); +} + +function evaluateCondition(subjectAttributes: Record<string, any>, condition: Condition): boolean { + const value = subjectAttributes[condition.attribute]; + + const conditionValueType = targetingRuleConditionValuesTypesFromValues(condition.value); + + if (value != null) { + switch (condition.operator) { + case OperatorType.GTE: + if (conditionValueType === OperatorValueType.SEM_VER) { + return compareSemVer(value, condition.value, semverGte); + } + return compareNumber(value, condition.value, (a, b) => a >= b); + case OperatorType.GT: + if (conditionValueType === OperatorValueType.SEM_VER) { + return compareSemVer(value, condition.value, semverGt); + } + return compareNumber(value, condition.value, (a, b) => a > b); + case OperatorType.LTE: + if (conditionValueType === OperatorValueType.SEM_VER) { + return compareSemVer(value, condition.value, semverLte); + } + return compareNumber(value, condition.value, (a, b) => a <= b); + case OperatorType.LT: + if (conditionValueType === OperatorValueType.SEM_VER) { + return compareSemVer(value, condition.value, semverLt); + } + return compareNumber(value, condition.value, (a, b) => a < b); + case OperatorType.MATCHES: + return new RegExp(condition.value as string).test(value as string); + case OperatorType.ONE_OF: + return isOneOf( + value.toString().toLowerCase(), + condition.value.map((value: string) => value.toLowerCase()), + ); + case OperatorType.NOT_ONE_OF: + return isNotOneOf( + value.toString().toLowerCase(), + condition.value.map((value: string) => value.toLowerCase()), + ); + } + } + return false; +} + +// TODO: implement the obfuscated version of this function +function evaluateObfuscatedCondition( + hashedSubjectAttributes: Record<string, any>, + condition: Condition, +): boolean { + const value = hashedSubjectAttributes[condition.attribute]; + const conditionValueType = targetingRuleConditionValuesTypesFromValues(value); + + if (value != null) { + switch (condition.operator) { + case ObfuscatedOperatorType.GTE: + if (conditionValueType === OperatorValueType.SEM_VER) { + return compareSemVer(value, decodeBase64(condition.value as string), semverGte); + } + return compareNumber( + value, + Number(decodeBase64(condition.value as string)), + (a, b) => a >= b, + ); + case ObfuscatedOperatorType.GT: + if (conditionValueType === OperatorValueType.SEM_VER) { + return compareSemVer(value, decodeBase64(condition.value as string), semverGt); + } + return compareNumber( + value, + Number(decodeBase64(condition.value as string)), + (a, b) => a > b, + ); + case ObfuscatedOperatorType.LTE: + if (conditionValueType === OperatorValueType.SEM_VER) { + return compareSemVer(value, decodeBase64(condition.value as string), semverLte); + } + return compareNumber( + value, + Number(decodeBase64(condition.value as string)), + (a, b) => a <= b, + ); + case ObfuscatedOperatorType.LT: + if (conditionValueType === OperatorValueType.SEM_VER) { + return compareSemVer(value, decodeBase64(condition.value as string), semverLt); + } + return compareNumber( + value, + Number(decodeBase64(condition.value as string)), + (a, b) => a < b, + ); + case ObfuscatedOperatorType.MATCHES: + return new RegExp(decodeBase64(condition.value as string)).test(value as string); + case ObfuscatedOperatorType.NOT_MATCHES: + return !new RegExp(decodeBase64(condition.value as string)).test(value as string); + case ObfuscatedOperatorType.ONE_OF: + return isOneOf( + getMD5Hash(value.toString().toLowerCase()), + condition.value.map((value: string) => value.toLowerCase()), + ); + case ObfuscatedOperatorType.NOT_ONE_OF: + return isNotOneOf( + getMD5Hash(value.toString().toLowerCase()), + condition.value.map((value: string) => value.toLowerCase()), + ); + } + } + return false; +} + +function isOneOf(attributeValue: string, conditionValue: string[]) { + return getMatchingStringValues(attributeValue, conditionValue).length > 0; +} + +function isNotOneOf(attributeValue: string, conditionValue: string[]) { + return getMatchingStringValues(attributeValue, conditionValue).length === 0; +} + +function getMatchingStringValues(attributeValue: string, conditionValues: string[]): string[] { + return conditionValues.filter((value) => value === attributeValue); +} + +function compareNumber( + attributeValue: any, + conditionValue: any, + compareFn: (a: number, b: number) => boolean, +): boolean { + return compareFn(Number(attributeValue), Number(conditionValue)); +} + +function compareSemVer( + attributeValue: any, + conditionValue: any, + compareFn: (a: string, b: string) => boolean, +): boolean { + return ( + !!validSemver(attributeValue) && + !!validSemver(conditionValue) && + compareFn(attributeValue, conditionValue) + ); +} + +function targetingRuleConditionValuesTypesFromValues(value: ConditionValueType): OperatorValueType { + // Check if input is a number + if (typeof value === 'number') { + return OperatorValueType.NUMERIC; + } + + if (Array.isArray(value)) { + return OperatorValueType.STRING_ARRAY; + } + + // Check if input is a string that represents a SemVer + if (typeof value === 'string' && validSemver(value)) { + return OperatorValueType.SEM_VER; + } + + // Check if input is a string that represents a number + if (!isNaN(Number(value))) { + return OperatorValueType.NUMERIC; + } + + // If none of the above, it's a general string + return OperatorValueType.PLAIN_STRING; +} From 4d6264eda58d2eb94b9fefa8426288edca2a535f Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 28 Mar 2024 23:36:59 -0700 Subject: [PATCH 11/39] cleanup --- src/assignment-cache.ts | 1 - src/client/eppo-client.ts | 2 -- src/eval.ts | 2 +- src/sharders.ts | 3 ++- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/assignment-cache.ts b/src/assignment-cache.ts index aa402e41..4f492caf 100644 --- a/src/assignment-cache.ts +++ b/src/assignment-cache.ts @@ -1,6 +1,5 @@ import { LRUCache } from 'lru-cache'; -import { EppoValue } from './eppo_value'; import { getMD5Hash } from './obfuscation'; export interface AssignmentCacheKey { diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index b2e47144..1df6acfd 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -219,7 +219,6 @@ export default class EppoClient implements IEppoClient { assignmentHooks?: IAssignmentHooks | undefined, obfuscated = false, ): boolean | null { - console.log(this.getAssignmentVariation); return ( this.getAssignmentVariation( subjectKey, @@ -372,7 +371,6 @@ export default class EppoClient implements IEppoClient { try { if (result && result.doLog) { - // TODO: check assignment cache this.logAssignment(result); } } catch (error) { diff --git a/src/eval.ts b/src/eval.ts index 416b6907..8b1f2c25 100644 --- a/src/eval.ts +++ b/src/eval.ts @@ -1,4 +1,4 @@ -import { Flag, Shard, Range, Variation, Allocation, Split } from './interfaces'; +import { Flag, Shard, Range, Variation } from './interfaces'; import { matchesRule } from './rules'; import { Sharder } from './sharders'; diff --git a/src/sharders.ts b/src/sharders.ts index a702ea1d..ff2223fe 100644 --- a/src/sharders.ts +++ b/src/sharders.ts @@ -26,7 +26,8 @@ export class DeterministicSharder extends Sharder { this.lookup = lookup; } - getShard(input: string, totalShards: number): number { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getShard(input: string, _totalShards: number): number { return this.lookup[input] ?? 0; } } From 1e9e10eb4168d8c49a0e3bb26ce61dc9db6f7094 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 3 Apr 2024 10:38:55 -0700 Subject: [PATCH 12/39] Address PR comments --- src/assignment-logger.spec.ts | 18 +++++++++ src/client/eppo-client.spec.ts | 49 +++++++++++++++++------- src/client/eppo-client.ts | 2 +- src/eppo_value.ts | 51 ++++++++----------------- src/{eval.spec.ts => evaluator.spec.ts} | 2 +- src/{eval.ts => evaluator.ts} | 12 +++--- src/index.ts | 6 +-- src/rules.spec.ts | 16 ++++++-- src/rules.ts | 2 +- 9 files changed, 93 insertions(+), 65 deletions(-) create mode 100644 src/assignment-logger.spec.ts rename src/{eval.spec.ts => evaluator.spec.ts} (99%) rename src/{eval.ts => evaluator.ts} (86%) diff --git a/src/assignment-logger.spec.ts b/src/assignment-logger.spec.ts new file mode 100644 index 00000000..88ea82c0 --- /dev/null +++ b/src/assignment-logger.spec.ts @@ -0,0 +1,18 @@ +import { IAssignmentEvent } from './assignment-logger'; + +describe('IAssignmentEvent', () => { + it('should allow adding arbitrary fields', () => { + const event: IAssignmentEvent = { + allocation: 'allocation_123', + experiment: 'experiment_123', + featureFlag: 'feature_flag_123', + variation: 'variation_123', + subject: 'subject_123', + timestamp: new Date().toISOString(), + subjectAttributes: { age: 25, country: 'USA' }, + holdoutKey: 'holdout_key_123', + }; + + expect(event.holdoutKey).toBe('holdout_key_123'); + }); +}); diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 3d2075a8..b54d6d6e 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -17,7 +17,7 @@ import { IAssignmentHooks } from '../assignment-hooks'; import { IAssignmentLogger } from '../assignment-logger'; import { IConfigurationStore } from '../configuration-store'; import { MAX_EVENT_QUEUE_SIZE, POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; -import { Evaluator } from '../eval'; +import { Evaluator } from '../evaluator'; import FlagConfigurationRequestor from '../flag-configuration-requestor'; import HttpClient from '../http-client'; import { Flag, VariationType } from '../interfaces'; @@ -90,7 +90,7 @@ export async function init(configurationStore: IConfigurationStore) { } describe('EppoClient E2E test', () => { - const evaluator = new Evaluator(new MD5Sharder()); + const evaluator = new Evaluator(); const storage = new TestConfigurationStore(); const globalClient = new EppoClient(evaluator, storage); @@ -147,7 +147,7 @@ describe('EppoClient E2E test', () => { beforeAll(() => { storage.setEntries({ [flagKey]: mockFlag }); - const evaluator = new Evaluator(new MD5Sharder()); + const evaluator = new Evaluator(); client = new EppoClient(evaluator, storage); td.replace(EppoClient.prototype, 'getAssignmentDetail', function () { @@ -172,7 +172,9 @@ describe('EppoClient E2E test', () => { client.setIsGracefulFailureMode(true); expect(client.getBoolAssignment('subject-identifer', flagKey, {}, true)).toBe(true); + expect(client.getBoolAssignment('subject-identifer', flagKey, {}, false)).toBe(false); expect(client.getNumericAssignment('subject-identifer', flagKey, {}, 1)).toBe(1); + expect(client.getNumericAssignment('subject-identifer', flagKey, {}, 0)).toBe(0); expect(client.getJSONAssignment('subject-identifer', flagKey, {}, {})).toEqual({}); expect(client.getStringAssignment('subject-identifer', flagKey, {}, 'default')).toBe( 'default', @@ -208,7 +210,7 @@ describe('EppoClient E2E test', () => { it('Invokes logger for queued events', () => { const mockLogger = td.object<IAssignmentLogger>(); - const evaluator = new Evaluator(new MD5Sharder()); + const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); client.getStringAssignment('subject-to-be-logged', flagKey); client.setLogger(mockLogger); @@ -222,7 +224,7 @@ describe('EppoClient E2E test', () => { it('Does not log same queued event twice', () => { const mockLogger = td.object<IAssignmentLogger>(); - const evaluator = new Evaluator(new MD5Sharder()); + const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); client.getStringAssignment('subject-to-be-logged', flagKey); @@ -236,7 +238,7 @@ describe('EppoClient E2E test', () => { it('Does not invoke logger for events that exceed queue size', () => { const mockLogger = td.object<IAssignmentLogger>(); - const evaluator = new Evaluator(new MD5Sharder()); + const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); for (let i = 0; i < MAX_EVENT_QUEUE_SIZE + 100; i++) { @@ -253,13 +255,32 @@ describe('EppoClient E2E test', () => { async ({ flag, variationType, subjects }: IAssignmentTestCase) => { `---- Test Case for ${flag} Experiment ----`; - const evaluator = new Evaluator(new MD5Sharder()); + const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); let assignments: { subject: SubjectTestCase; assignment: string | boolean | number | null | object; }[] = []; + + const typeAssignmentFunctions = { + [VariationType.BOOLEAN]: client.getBoolAssignment.bind(client), + [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), + [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), + [VariationType.STRING]: client.getStringAssignment.bind(client), + [VariationType.JSON]: client.getJSONAssignment.bind(client), + }; + + const assignmentFn = typeAssignmentFunctions[variationType]; + if (!assignmentFn) { + throw new Error(`Unknown variation type: ${variationType}`); + } + + // assignments = getTestAssignments( + // { flag, variationType, subjects }, + // assignmentFn, + // true, + // ); switch (variationType) { case VariationType.BOOLEAN: { assignments = getTestAssignments( @@ -309,7 +330,7 @@ describe('EppoClient E2E test', () => { describe('UFC Obfuscated Test Cases', () => { const storage = new TestConfigurationStore(); - const evaluator = new Evaluator(new MD5Sharder()); + const evaluator = new Evaluator(); const globalClient = new EppoClient(evaluator, storage); beforeAll(async () => { @@ -331,7 +352,7 @@ describe('EppoClient E2E test', () => { async ({ flag, variationType, subjects }: IAssignmentTestCase) => { `---- Test Case for ${flag} Experiment ----`; - const evaluator = new Evaluator(new MD5Sharder()); + const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); let assignments: { @@ -397,7 +418,7 @@ describe('EppoClient E2E test', () => { // }); it('returns default value when key does not exist', async () => { - const evaluator = new Evaluator(new MD5Sharder()); + const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); const nonExistantFlag = 'non-existent-flag'; @@ -414,7 +435,7 @@ describe('EppoClient E2E test', () => { const mockLogger = td.object<IAssignmentLogger>(); storage.setEntries({ [flagKey]: mockFlag }); - const evaluator = new Evaluator(new MD5Sharder()); + const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); client.setLogger(mockLogger); @@ -436,7 +457,7 @@ describe('EppoClient E2E test', () => { td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(new Error('logging error')); storage.setEntries({ [flagKey]: mockFlag }); - const evaluator = new Evaluator(new MD5Sharder()); + const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); client.setLogger(mockLogger); @@ -455,7 +476,7 @@ describe('EppoClient E2E test', () => { mockLogger = td.object<IAssignmentLogger>(); storage.setEntries({ [flagKey]: mockFlag }); - evaluator = new Evaluator(new MD5Sharder()); + evaluator = new Evaluator(); client = new EppoClient(evaluator, storage); client.setLogger(mockLogger); }); @@ -656,7 +677,7 @@ describe('EppoClient E2E test', () => { let requestConfiguration: FlagConfigurationRequestParameters; let mockServerResponseFunc: (res: MockResponse) => MockResponse; - const evaluator = new Evaluator(new MD5Sharder()); + const evaluator = new Evaluator(); const ufcBody = JSON.stringify(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)); const flagKey = 'numeric_flag'; const subject = 'alice'; diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 1df6acfd..bdb2b1a2 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -18,7 +18,7 @@ import { POLL_INTERVAL_MS, } from '../constants'; import { EppoValue } from '../eppo_value'; -import { Evaluator, FlagEvaluation, noneResult } from '../eval'; +import { Evaluator, FlagEvaluation, noneResult } from '../evaluator'; import ExperimentConfigurationRequestor from '../flag-configuration-requestor'; import HttpClient from '../http-client'; import { Flag, VariationType } from '../interfaces'; diff --git a/src/eppo_value.ts b/src/eppo_value.ts index c8115d2a..94699474 100644 --- a/src/eppo_value.ts +++ b/src/eppo_value.ts @@ -36,23 +36,23 @@ export class EppoValue { value: boolean | number | string | object | null | undefined, valueType: VariationType, ): EppoValue { - if (value != null && value != undefined) { - switch (valueType) { - case VariationType.BOOLEAN: - return EppoValue.Bool(value as boolean); - case VariationType.NUMERIC: - return EppoValue.Numeric(value as number); - case VariationType.INTEGER: - return EppoValue.Numeric(value as number); - case VariationType.STRING: - return EppoValue.String(value as string); - case VariationType.JSON: - return EppoValue.JSON(value as object); - default: - return EppoValue.String(value as string); - } + if (value == null) { + return EppoValue.Null(); + } + switch (valueType) { + case VariationType.BOOLEAN: + return EppoValue.Bool(value as boolean); + case VariationType.NUMERIC: + return EppoValue.Numeric(value as number); + case VariationType.INTEGER: + return EppoValue.Numeric(value as number); + case VariationType.STRING: + return EppoValue.String(value as string); + case VariationType.JSON: + return EppoValue.JSON(value as object); + default: + return EppoValue.String(value as string); } - return EppoValue.Null(); } toString(): string { @@ -85,25 +85,6 @@ export class EppoValue { return getMD5Hash(value); } - isExpectedType(): boolean { - switch (this.valueType) { - case EppoValueType.BoolType: - return typeof this.boolValue === 'boolean'; - case EppoValueType.NumericType: - return typeof this.numericValue === 'number'; - case EppoValueType.StringType: - return typeof this.stringValue === 'string'; - case EppoValueType.JSONType: - return typeof this.objectValue === 'object'; - case EppoValueType.NullType: - return false; - } - } - - isNullType(): boolean { - return this.valueType === EppoValueType.NullType; - } - static Bool(value: boolean): EppoValue { return new EppoValue(EppoValueType.BoolType, value, undefined, undefined, undefined); } diff --git a/src/eval.spec.ts b/src/evaluator.spec.ts similarity index 99% rename from src/eval.spec.ts rename to src/evaluator.spec.ts index a212ced3..f7084dee 100644 --- a/src/eval.spec.ts +++ b/src/evaluator.spec.ts @@ -1,4 +1,4 @@ -import { Evaluator, hashKey, isInShardRange } from './eval'; +import { Evaluator, hashKey, isInShardRange } from './evaluator'; import { Flag, Variation, Shard, VariationType } from './interfaces'; import { encodeBase64, getMD5Hash } from './obfuscation'; import { ObfuscatedOperatorType, OperatorType } from './rules'; diff --git a/src/eval.ts b/src/evaluator.ts similarity index 86% rename from src/eval.ts rename to src/evaluator.ts index 8b1f2c25..c952fef8 100644 --- a/src/eval.ts +++ b/src/evaluator.ts @@ -1,6 +1,6 @@ import { Flag, Shard, Range, Variation } from './interfaces'; import { matchesRule } from './rules'; -import { Sharder } from './sharders'; +import { MD5Sharder, Sharder } from './sharders'; export interface FlagEvaluation { flagKey: string; @@ -13,10 +13,10 @@ export interface FlagEvaluation { } export class Evaluator { - sharder: Sharder; // Assuming a Sharder type exists, replace 'any' with 'Sharder' when available + sharder: Sharder; - constructor(sharder: Sharder) { - this.sharder = sharder; + constructor(sharder?: Sharder) { + this.sharder = sharder ?? new MD5Sharder(); } evaluateFlag( @@ -62,8 +62,8 @@ export class Evaluator { } matchesShard(shard: Shard, subjectKey: string, totalShards: number): boolean { - const h = this.sharder.getShard(hashKey(shard.salt, subjectKey), totalShards); - return shard.ranges.some((range) => isInShardRange(h, range)); + const assignedShard = this.sharder.getShard(hashKey(shard.salt, subjectKey), totalShards); + return shard.ranges.some((range) => isInShardRange(assignedShard, range)); } } diff --git a/src/index.ts b/src/index.ts index f2c21f1c..4e6868c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import { IAssignmentLogger, IAssignmentEvent } from './assignment-logger'; import EppoClient, { FlagConfigurationRequestParameters, IEppoClient } from './client/eppo-client'; import { IConfigurationStore } from './configuration-store'; import * as constants from './constants'; -import ExperimentConfigurationRequestor from './flag-configuration-requestor'; +import FlagConfigRequestor from './flag-configuration-requestor'; import HttpClient from './http-client'; import * as validation from './validation'; @@ -15,10 +15,10 @@ export { EppoClient, IEppoClient, constants, - ExperimentConfigurationRequestor, + FlagConfigRequestor, HttpClient, validation, IConfigurationStore, AssignmentCache, - FlagConfigurationRequestParameters as ExperimentConfigurationRequestParameters, + FlagConfigurationRequestParameters, }; diff --git a/src/rules.spec.ts b/src/rules.spec.ts index ae38aa92..2550b610 100644 --- a/src/rules.spec.ts +++ b/src/rules.spec.ts @@ -37,7 +37,7 @@ describe('rules', () => { { operator: OperatorType.ONE_OF, attribute: 'country', - value: ['USA', 'Canada'], + value: ['Canada', 'Mexico', 'USA'], }, ], }; @@ -47,7 +47,7 @@ describe('rules', () => { { operator: OperatorType.NOT_ONE_OF, attribute: 'country', - value: ['USA', 'Canada'], + value: ['Canada', 'Mexico', 'USA'], }, ], }; @@ -71,7 +71,7 @@ describe('rules', () => { { operator: OperatorType.MATCHES, attribute: 'user_id', - value: '[0-9]+', + value: '\\d+', }, ], }; @@ -116,6 +116,10 @@ describe('rules', () => { expect(matchesRule(ruleWithOneOfCondition, { age: 10 }, false)).toBe(false); }); + it('should return false for a rule with ONE_OF condition when subject attribute is null', () => { + expect(matchesRule(ruleWithOneOfCondition, { country: null }, false)).toBe(false); + }); + it('should return false for a rule with NOT_ONE_OF condition that matches the subject attributes', () => { expect(matchesRule(ruleWithNotOneOfCondition, { country: 'USA' }, false)).toBe(false); }); @@ -127,7 +131,11 @@ describe('rules', () => { it('should return false for a rule with NOT_ONE_OF condition when subject attribute is missing', () => { expect(matchesRule(ruleWithNotOneOfCondition, { age: 10 }, false)).toBe(false); }); - ``; + + it('should return false for a rule with NOT_ONE_OF condition when subject attribute is null', () => { + expect(matchesRule(ruleWithNotOneOfCondition, { country: null }, false)).toBe(false); + }); + it('should return true for a semver rule that matches the subject attributes', () => { expect(matchesRule(semverRule, subjectAttributes, false)).toBe(true); }); diff --git a/src/rules.ts b/src/rules.ts index 1d52807a..3e301d83 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -32,7 +32,7 @@ export enum ObfuscatedOperatorType { NOT_ONE_OF = '602f5ee0b6e84fe29f43ab48b9e1addf', } -export enum OperatorValueType { +enum OperatorValueType { PLAIN_STRING = 'PLAIN_STRING', STRING_ARRAY = 'STRING_ARRAY', SEM_VER = 'SEM_VER', From cab5b1f9f0cb135a1da06468ca20d51e70fbddeb Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 3 Apr 2024 13:55:02 -0700 Subject: [PATCH 13/39] Address PR comments --- src/client/eppo-client.spec.ts | 271 +++++++++++++-------------------- src/client/eppo-client.ts | 103 +++++-------- src/eppo_value.ts | 4 +- src/interfaces.ts | 10 +- test/testHelpers.ts | 1 + test/writeObfuscatedMockUFC.ts | 3 +- 6 files changed, 153 insertions(+), 239 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index b54d6d6e..78b34b4e 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -21,7 +21,6 @@ import { Evaluator } from '../evaluator'; import FlagConfigurationRequestor from '../flag-configuration-requestor'; import HttpClient from '../http-client'; import { Flag, VariationType } from '../interfaces'; -import { MD5Sharder } from '../sharders'; import EppoClient, { FlagConfigurationRequestParameters } from './eppo-client'; @@ -63,9 +62,8 @@ function getTestAssignments( const assignment = assignmentFn( subject.subjectKey, testCase.flag, + testCase.defaultValue, subject.subjectAttributes, - null, - undefined, obfuscated, ); assignments.push({ subject: subject, assignment: assignment }); @@ -159,24 +157,18 @@ describe('EppoClient E2E test', () => { td.reset(); }); - it('returns null when graceful failure if error encountered', async () => { - client.setIsGracefulFailureMode(true); - - expect(client.getBoolAssignment('subject-identifer', flagKey, {})).toBeNull(); - expect(client.getNumericAssignment('subject-identifer', flagKey, {})).toBeNull(); - expect(client.getJSONAssignment('subject-identifer', flagKey, {})).toBeNull(); - expect(client.getStringAssignment('subject-identifer', flagKey, {})).toBeNull(); - }); - it('returns default value when graceful failure if error encounterd', async () => { client.setIsGracefulFailureMode(true); - expect(client.getBoolAssignment('subject-identifer', flagKey, {}, true)).toBe(true); - expect(client.getBoolAssignment('subject-identifer', flagKey, {}, false)).toBe(false); - expect(client.getNumericAssignment('subject-identifer', flagKey, {}, 1)).toBe(1); - expect(client.getNumericAssignment('subject-identifer', flagKey, {}, 0)).toBe(0); + expect(client.getBoolAssignment('subject-identifer', flagKey, true, {})).toBe(true); + expect(client.getBoolAssignment('subject-identifer', flagKey, false, {})).toBe(false); + expect(client.getNumericAssignment('subject-identifer', flagKey, 1, {})).toBe(1); + expect(client.getNumericAssignment('subject-identifer', flagKey, 0, {})).toBe(0); expect(client.getJSONAssignment('subject-identifer', flagKey, {}, {})).toEqual({}); - expect(client.getStringAssignment('subject-identifer', flagKey, {}, 'default')).toBe( + expect( + client.getJSONAssignment('subject-identifer', flagKey, { hello: 'world' }, {}), + ).toEqual({ hello: 'world' }); + expect(client.getStringAssignment('subject-identifer', flagKey, 'default', {})).toBe( 'default', ); }); @@ -185,19 +177,19 @@ describe('EppoClient E2E test', () => { client.setIsGracefulFailureMode(false); expect(() => { - client.getBoolAssignment('subject-identifer', flagKey, {}); + client.getBoolAssignment('subject-identifer', flagKey, true, {}); }).toThrow(); expect(() => { - client.getJSONAssignment('subject-identifer', flagKey, {}); + client.getJSONAssignment('subject-identifer', flagKey, {}, {}); }).toThrow(); expect(() => { - client.getNumericAssignment('subject-identifer', flagKey, {}); + client.getNumericAssignment('subject-identifer', flagKey, 1, {}); }).toThrow(); expect(() => { - client.getStringAssignment('subject-identifer', flagKey, {}); + client.getStringAssignment('subject-identifer', flagKey, 'default', {}); }).toThrow(); }); }); @@ -212,7 +204,7 @@ describe('EppoClient E2E test', () => { const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); - client.getStringAssignment('subject-to-be-logged', flagKey); + client.getStringAssignment('subject-to-be-logged', flagKey, 'default-value'); client.setLogger(mockLogger); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); @@ -227,7 +219,7 @@ describe('EppoClient E2E test', () => { const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); - client.getStringAssignment('subject-to-be-logged', flagKey); + client.getStringAssignment('subject-to-be-logged', flagKey, 'default-value'); client.setLogger(mockLogger); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); @@ -242,7 +234,7 @@ describe('EppoClient E2E test', () => { const client = new EppoClient(evaluator, storage); for (let i = 0; i < MAX_EVENT_QUEUE_SIZE + 100; i++) { - client.getStringAssignment(`subject-to-be-logged-${i}`, flagKey); + client.getStringAssignment(`subject-to-be-logged-${i}`, flagKey, 'default-value'); } client.setLogger(mockLogger); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(MAX_EVENT_QUEUE_SIZE); @@ -252,7 +244,7 @@ describe('EppoClient E2E test', () => { describe('UFC General Test Cases', () => { it.each(readAssignmentTestData())( 'test variation assignment splits', - async ({ flag, variationType, subjects }: IAssignmentTestCase) => { + async ({ flag, variationType, defaultValue, subjects }: IAssignmentTestCase) => { `---- Test Case for ${flag} Experiment ----`; const evaluator = new Evaluator(); @@ -276,51 +268,12 @@ describe('EppoClient E2E test', () => { throw new Error(`Unknown variation type: ${variationType}`); } - // assignments = getTestAssignments( - // { flag, variationType, subjects }, - // assignmentFn, - // true, - // ); - switch (variationType) { - case VariationType.BOOLEAN: { - assignments = getTestAssignments( - { flag, variationType, subjects }, - client.getBoolAssignment.bind(client), - ); - break; - } - case VariationType.NUMERIC: { - assignments = getTestAssignments( - { flag, variationType, subjects }, - client.getNumericAssignment.bind(client), - ); - break; - } - case VariationType.INTEGER: { - assignments = getTestAssignments( - { flag, variationType, subjects }, - client.getIntegerAssignment.bind(client), - ); - break; - } - case VariationType.STRING: { - assignments = getTestAssignments( - { flag, variationType, subjects }, - client.getStringAssignment.bind(client), - ); - break; - } - case VariationType.JSON: { - assignments = getTestAssignments( - { flag, variationType, subjects }, - client.getJSONAssignment.bind(client), - ); - break; - } - default: { - throw new Error(`Unknown variation type: ${variationType}`); - } - } + assignments = getTestAssignments( + { flag, variationType, defaultValue, subjects }, + assignmentFn, + false, + ); + for (const { subject, assignment } of assignments) { expect(assignment).toEqual(subject.assignment); } @@ -349,61 +302,31 @@ describe('EppoClient E2E test', () => { it.each(readAssignmentTestData())( 'test variation assignment splits', - async ({ flag, variationType, subjects }: IAssignmentTestCase) => { + async ({ flag, variationType, defaultValue, subjects }: IAssignmentTestCase) => { `---- Test Case for ${flag} Experiment ----`; const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); - let assignments: { - subject: SubjectTestCase; - assignment: string | boolean | number | null | object; - }[] = []; - switch (variationType) { - case VariationType.BOOLEAN: { - assignments = getTestAssignments( - { flag, variationType, subjects }, - client.getBoolAssignment.bind(client), - true, - ); - break; - } - case VariationType.NUMERIC: { - assignments = getTestAssignments( - { flag, variationType, subjects }, - client.getNumericAssignment.bind(client), - true, - ); - break; - } - case VariationType.INTEGER: { - assignments = getTestAssignments( - { flag, variationType, subjects }, - client.getIntegerAssignment.bind(client), - true, - ); - break; - } - case VariationType.STRING: { - assignments = getTestAssignments( - { flag, variationType, subjects }, - client.getStringAssignment.bind(client), - true, - ); - break; - } - case VariationType.JSON: { - assignments = getTestAssignments( - { flag, variationType, subjects }, - client.getJSONAssignment.bind(client), - true, - ); - break; - } - default: { - throw new Error(`Unknown variation type: ${variationType}`); - } + const typeAssignmentFunctions = { + [VariationType.BOOLEAN]: client.getBoolAssignment.bind(client), + [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), + [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), + [VariationType.STRING]: client.getStringAssignment.bind(client), + [VariationType.JSON]: client.getJSONAssignment.bind(client), + }; + + const assignmentFn = typeAssignmentFunctions[variationType]; + if (!assignmentFn) { + throw new Error(`Unknown variation type: ${variationType}`); } + + const assignments = getTestAssignments( + { flag, variationType, defaultValue, subjects }, + assignmentFn, + true, + ); + for (const { subject, assignment } of assignments) { expect(assignment).toEqual(subject.assignment); } @@ -423,10 +346,10 @@ describe('EppoClient E2E test', () => { const nonExistantFlag = 'non-existent-flag'; - expect(client.getBoolAssignment('subject-identifer', nonExistantFlag, {}, true)).toBe(true); - expect(client.getNumericAssignment('subject-identifer', nonExistantFlag, {}, 1)).toBe(1); + expect(client.getBoolAssignment('subject-identifer', nonExistantFlag, true, {})).toBe(true); + expect(client.getNumericAssignment('subject-identifer', nonExistantFlag, 1, {})).toBe(1); expect(client.getJSONAssignment('subject-identifer', nonExistantFlag, {}, {})).toEqual({}); - expect(client.getStringAssignment('subject-identifer', nonExistantFlag, {}, 'default')).toBe( + expect(client.getStringAssignment('subject-identifer', nonExistantFlag, 'default', {})).toBe( 'default', ); }); @@ -440,7 +363,12 @@ describe('EppoClient E2E test', () => { client.setLogger(mockLogger); const subjectAttributes = { foo: 3 }; - const assignment = client.getStringAssignment('subject-10', flagKey, subjectAttributes); + const assignment = client.getStringAssignment( + 'subject-10', + flagKey, + 'default', + subjectAttributes, + ); expect(assignment).toEqual(variationA.value); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); @@ -462,7 +390,12 @@ describe('EppoClient E2E test', () => { client.setLogger(mockLogger); const subjectAttributes = { foo: 3 }; - const assignment = client.getStringAssignment('subject-10', flagKey, subjectAttributes); + const assignment = client.getStringAssignment( + 'subject-10', + flagKey, + 'default', + subjectAttributes, + ); expect(assignment).toEqual('variation-a'); }); @@ -484,8 +417,8 @@ describe('EppoClient E2E test', () => { it('logs duplicate assignments without an assignment cache', () => { client.disableAssignmentCache(); - client.getStringAssignment('subject-10', flagKey); - client.getStringAssignment('subject-10', flagKey); + client.getStringAssignment('subject-10', flagKey, 'default'); + client.getStringAssignment('subject-10', flagKey, 'default'); // call count should be 2 because there is no cache. expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2); @@ -494,8 +427,8 @@ describe('EppoClient E2E test', () => { it('does not log duplicate assignments', () => { client.useNonExpiringInMemoryAssignmentCache(); - client.getStringAssignment('subject-10', flagKey); - client.getStringAssignment('subject-10', flagKey); + client.getStringAssignment('subject-10', flagKey, 'default'); + client.getStringAssignment('subject-10', flagKey, 'default'); // call count should be 1 because the second call is a cache hit and not logged. expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); @@ -504,15 +437,15 @@ describe('EppoClient E2E test', () => { it('logs assignment again after the lru cache is full', () => { client.useLRUInMemoryAssignmentCache(2); - client.getStringAssignment('subject-10', flagKey); // logged - client.getStringAssignment('subject-10', flagKey); // cached + client.getStringAssignment('subject-10', flagKey, 'default'); // logged + client.getStringAssignment('subject-10', flagKey, 'default'); // cached - client.getStringAssignment('subject-11', flagKey); // logged - client.getStringAssignment('subject-11', flagKey); // cached + client.getStringAssignment('subject-11', flagKey, 'default'); // logged + client.getStringAssignment('subject-11', flagKey, 'default'); // cached - client.getStringAssignment('subject-12', flagKey); // cache evicted subject-10, logged - client.getStringAssignment('subject-10', flagKey); // previously evicted, logged - client.getStringAssignment('subject-12', flagKey); // cached + client.getStringAssignment('subject-12', flagKey, 'default'); // cache evicted subject-10, logged + client.getStringAssignment('subject-10', flagKey, 'default'); // previously evicted, logged + client.getStringAssignment('subject-12', flagKey, 'default'); // cached expect(td.explain(mockLogger.logAssignment).callCount).toEqual(4); }); @@ -524,8 +457,8 @@ describe('EppoClient E2E test', () => { client.setLogger(mockLogger); - client.getStringAssignment('subject-10', flagKey); - client.getStringAssignment('subject-10', flagKey); + client.getStringAssignment('subject-10', flagKey, 'default'); + client.getStringAssignment('subject-10', flagKey, 'default'); // call count should be 2 because the first call had an exception // therefore we are not sure the logger was successful and try again. @@ -547,15 +480,15 @@ describe('EppoClient E2E test', () => { client.useNonExpiringInMemoryAssignmentCache(); - client.getStringAssignment('subject-10', flagKey); - client.getStringAssignment('subject-10', flagKey); - client.getStringAssignment('subject-10', 'flag-2'); - client.getStringAssignment('subject-10', 'flag-2'); - client.getStringAssignment('subject-10', 'flag-3'); - client.getStringAssignment('subject-10', 'flag-3'); - client.getStringAssignment('subject-10', flagKey); - client.getStringAssignment('subject-10', 'flag-2'); - client.getStringAssignment('subject-10', 'flag-3'); + client.getStringAssignment('subject-10', flagKey, 'default'); + client.getStringAssignment('subject-10', flagKey, 'default'); + client.getStringAssignment('subject-10', 'flag-2', 'default'); + client.getStringAssignment('subject-10', 'flag-2', 'default'); + client.getStringAssignment('subject-10', 'flag-3', 'default'); + client.getStringAssignment('subject-10', 'flag-3', 'default'); + client.getStringAssignment('subject-10', flagKey, 'default'); + client.getStringAssignment('subject-10', 'flag-2', 'default'); + client.getStringAssignment('subject-10', 'flag-3', 'default'); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(3); }); @@ -582,7 +515,7 @@ describe('EppoClient E2E test', () => { ], }, }); - client.getStringAssignment('subject-10', flagKey); + client.getStringAssignment('subject-10', flagKey, 'default'); storage.setEntries({ [flagKey]: { @@ -602,7 +535,7 @@ describe('EppoClient E2E test', () => { ], }, }); - client.getStringAssignment('subject-10', flagKey); + client.getStringAssignment('subject-10', flagKey, 'default'); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2); }); @@ -612,8 +545,8 @@ describe('EppoClient E2E test', () => { // original configuration version storage.setEntries({ [flagKey]: mockFlag }); - client.getStringAssignment('subject-10', flagKey); // log this assignment - client.getStringAssignment('subject-10', flagKey); // cache hit, don't log + client.getStringAssignment('subject-10', flagKey, 'default'); // log this assignment + client.getStringAssignment('subject-10', flagKey, 'default'); // cache hit, don't log // change the variation storage.setEntries({ @@ -635,14 +568,14 @@ describe('EppoClient E2E test', () => { }, }); - client.getStringAssignment('subject-10', flagKey); // log this assignment - client.getStringAssignment('subject-10', flagKey); // cache hit, don't log + client.getStringAssignment('subject-10', flagKey, 'default'); // log this assignment + client.getStringAssignment('subject-10', flagKey, 'default'); // cache hit, don't log // change the flag again, back to the original storage.setEntries({ [flagKey]: mockFlag }); - client.getStringAssignment('subject-10', flagKey); // important: log this assignment - client.getStringAssignment('subject-10', flagKey); // cache hit, don't log + client.getStringAssignment('subject-10', flagKey, 'default'); // important: log this assignment + client.getStringAssignment('subject-10', flagKey, 'default'); // cache hit, don't log // change the allocation storage.setEntries({ @@ -664,8 +597,8 @@ describe('EppoClient E2E test', () => { }, }); - client.getStringAssignment('subject-10', flagKey); // log this assignment - client.getStringAssignment('subject-10', flagKey); // cache hit, don't log + client.getStringAssignment('subject-10', flagKey, 'default'); // log this assignment + client.getStringAssignment('subject-10', flagKey, 'default'); // cache hit, don't log expect(td.explain(mockLogger.logAssignment).callCount).toEqual(4); }); @@ -735,11 +668,11 @@ describe('EppoClient E2E test', () => { client = new EppoClient(evaluator, storage, requestConfiguration); client.setIsGracefulFailureMode(false); // no configuration loaded - let variation = client.getNumericAssignment(subject, flagKey); - expect(variation).toBeNull(); + let variation = client.getNumericAssignment(subject, flagKey, 0.0); + expect(variation).toBe(0.0); // have client fetch configurations await client.fetchFlagConfigurations(); - variation = client.getNumericAssignment(subject, flagKey); + variation = client.getNumericAssignment(subject, flagKey, 0.0); expect(variation).toBe(pi); }); @@ -748,11 +681,11 @@ describe('EppoClient E2E test', () => { client.setIsGracefulFailureMode(false); client.setConfigurationRequestParameters(requestConfiguration); // no configuration loaded - let variation = client.getNumericAssignment(subject, flagKey); - expect(variation).toBeNull(); + let variation = client.getNumericAssignment(subject, flagKey, 0.0); + expect(variation).toBe(0.0); // have client fetch configurations await client.fetchFlagConfigurations(); - variation = client.getNumericAssignment(subject, flagKey); + variation = client.getNumericAssignment(subject, flagKey, 0.0); expect(variation).toBe(pi); }); @@ -779,8 +712,8 @@ describe('EppoClient E2E test', () => { client = new EppoClient(evaluator, storage, requestConfiguration); client.setIsGracefulFailureMode(false); // no configuration loaded - let variation = client.getNumericAssignment(subject, flagKey); - expect(variation).toBeNull(); + let variation = client.getNumericAssignment(subject, flagKey, 0.0); + expect(variation).toBe(0.0); // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes const fetchPromise = client.fetchFlagConfigurations(); @@ -791,7 +724,7 @@ describe('EppoClient E2E test', () => { // Await so it can finish its initialization before this test proceeds await fetchPromise; - variation = client.getNumericAssignment(subject, flagKey); + variation = client.getNumericAssignment(subject, flagKey, 0.0); expect(variation).toBe(pi); expect(callCount).toBe(2); @@ -835,7 +768,7 @@ describe('EppoClient E2E test', () => { client = new EppoClient(evaluator, storage, requestConfiguration); client.setIsGracefulFailureMode(false); // no configuration loaded - expect(client.getNumericAssignment(subject, flagKey)).toBeNull(); + expect(client.getNumericAssignment(subject, flagKey, 0.0)).toBe(0.0); // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes if (throwOnFailedInitialization) { @@ -845,15 +778,15 @@ describe('EppoClient E2E test', () => { } expect(callCount).toBe(1); // still no configuration loaded - expect(client.getNumericAssignment(subject, flagKey)).toBeNull(); + expect(client.getNumericAssignment(subject, flagKey, 10.0)).toBe(10.0); // Advance timers so a post-init poll can take place await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 1.5); // if pollAfterFailedInitialization = true, we will poll later and get a config, otherwise not expect(callCount).toBe(pollAfterFailedInitialization ? 2 : 1); - expect(client.getNumericAssignment(subject, flagKey)).toBe( - pollAfterFailedInitialization ? pi : null, + expect(client.getNumericAssignment(subject, flagKey, 0.0)).toBe( + pollAfterFailedInitialization ? pi : 0.0, ); }); }); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index bdb2b1a2..dac9f362 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -6,7 +6,6 @@ import { LRUInMemoryAssignmentCache, NonExpiringInMemoryAssignmentCache, } from '../assignment-cache'; -import { IAssignmentHooks } from '../assignment-hooks'; import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; import { IConfigurationStore } from '../configuration-store'; import { @@ -45,38 +44,30 @@ export interface IEppoClient { getStringAssignment( subjectKey: string, flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record<string, any>, - defaultValue?: string | null, - assignmentHooks?: IAssignmentHooks, - ): string | null; + defaultValue: string, + subjectAttributes?: Record<string, AttributeType>, + ): string; getBoolAssignment( subjectKey: string, flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record<string, any>, - defaultValue?: boolean | null, - assignmentHooks?: IAssignmentHooks, - ): boolean | null; + defaultValue: boolean, + subjectAttributes?: Record<string, AttributeType>, + ): boolean; getNumericAssignment( subjectKey: string, flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record<string, any>, - defaultValue?: number | null, - assignmentHooks?: IAssignmentHooks, - ): number | null; + defaultValue: number, + subjectAttributes?: Record<string, AttributeType>, + ): number; getJSONAssignment( subjectKey: string, flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record<string, any>, - defaultValue?: object | null, - assignmentHooks?: IAssignmentHooks, - ): object | null; + defaultValue: object, + subjectAttributes?: Record<string, AttributeType>, + ): object; setLogger(logger: IAssignmentLogger): void; @@ -93,6 +84,8 @@ export interface IEppoClient { stopPolling(): void; setIsGracefulFailureMode(gracefulFailureMode: boolean): void; + + getFlagKeys(): string[]; } export type FlagConfigurationRequestParameters = { @@ -191,108 +184,96 @@ export default class EppoClient implements IEppoClient { public getStringAssignment( subjectKey: string, flagKey: string, + defaultValue: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes: Record<string, any> = {}, - defaultValue?: string | null, - assignmentHooks?: IAssignmentHooks | undefined, + subjectAttributes: Record<string, AttributeType> = {}, obfuscated = false, - ): string | null { + ): string { return ( this.getAssignmentVariation( subjectKey, flagKey, + EppoValue.String(defaultValue), subjectAttributes, - defaultValue ? EppoValue.String(defaultValue) : EppoValue.Null(), - assignmentHooks, obfuscated, VariationType.STRING, - ).stringValue ?? null + ).stringValue ?? defaultValue ); } getBoolAssignment( subjectKey: string, flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes: Record<string, any> = {}, - defaultValue: boolean | null = null, - assignmentHooks?: IAssignmentHooks | undefined, + defaultValue: boolean, + subjectAttributes: Record<string, AttributeType> = {}, obfuscated = false, - ): boolean | null { + ): boolean { return ( this.getAssignmentVariation( subjectKey, flagKey, + EppoValue.Bool(defaultValue), subjectAttributes, - defaultValue ? EppoValue.Bool(defaultValue) : EppoValue.Null(), - assignmentHooks, obfuscated, VariationType.BOOLEAN, - ).boolValue ?? null + ).boolValue ?? defaultValue ); } getNumericAssignment( subjectKey: string, flagKey: string, - subjectAttributes?: Record<string, EppoValue>, - defaultValue?: number | null, - assignmentHooks?: IAssignmentHooks | undefined, + defaultValue: number, + subjectAttributes?: Record<string, AttributeType>, obfuscated = false, - ): number | null { + ): number { return ( this.getAssignmentVariation( subjectKey, flagKey, + EppoValue.Numeric(defaultValue), subjectAttributes, - defaultValue ? EppoValue.Numeric(defaultValue) : EppoValue.Null(), - assignmentHooks, obfuscated, VariationType.NUMERIC, - ).numericValue ?? null + ).numericValue ?? defaultValue ); } getIntegerAssignment( subjectKey: string, flagKey: string, - subjectAttributes?: Record<string, EppoValue>, - defaultValue?: number | null, - assignmentHooks?: IAssignmentHooks | undefined, + defaultValue: number, + subjectAttributes?: Record<string, AttributeType>, obfuscated = false, - ): number | null { + ): number { return ( this.getAssignmentVariation( subjectKey, flagKey, + EppoValue.Numeric(defaultValue), subjectAttributes, - defaultValue ? EppoValue.Numeric(defaultValue) : EppoValue.Null(), - assignmentHooks, obfuscated, VariationType.INTEGER, - ).numericValue ?? null + ).numericValue ?? defaultValue ); } public getJSONAssignment( subjectKey: string, flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes: Record<string, any> = {}, - defaultValue?: object | null, - assignmentHooks?: IAssignmentHooks | undefined, + defaultValue: object, + subjectAttributes: Record<string, AttributeType> = {}, obfuscated = false, - ): object | null { + ): object { return ( this.getAssignmentVariation( subjectKey, flagKey, + EppoValue.JSON(defaultValue), subjectAttributes, - defaultValue ? EppoValue.JSON(defaultValue) : EppoValue.Null(), - assignmentHooks, obfuscated, VariationType.JSON, - ).objectValue ?? null + ).objectValue ?? defaultValue ); } @@ -307,10 +288,8 @@ export default class EppoClient implements IEppoClient { private getAssignmentVariation( subjectKey: string, flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes: Record<string, any> = {}, defaultValue: EppoValue, - assignmentHooks: IAssignmentHooks | undefined, + subjectAttributes: Record<string, AttributeType> = {}, obfuscated: boolean, expectedVariationType: VariationType, ): EppoValue { @@ -384,7 +363,7 @@ export default class EppoClient implements IEppoClient { return expectedType === undefined || actualType === expectedType; } - public get_flag_keys() { + public getFlagKeys() { /** * Returns a list of all flag keys that have been initialized. * This can be useful to debug the initialization process. diff --git a/src/eppo_value.ts b/src/eppo_value.ts index 94699474..0ab9fdae 100644 --- a/src/eppo_value.ts +++ b/src/eppo_value.ts @@ -9,7 +9,7 @@ export enum EppoValueType { JSONType, } -export type IValue = boolean | number | string | undefined; +export type IValue = boolean | number | string; export class EppoValue { public valueType: EppoValueType; @@ -33,7 +33,7 @@ export class EppoValue { } static generateEppoValue( - value: boolean | number | string | object | null | undefined, + value: boolean | number | string | object, valueType: VariationType, ): EppoValue { if (value == null) { diff --git a/src/interfaces.ts b/src/interfaces.ts index 765adaa8..d949bd72 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,11 +1,11 @@ import { Rule } from './rules'; export enum VariationType { - STRING = 'string', - INTEGER = 'integer', - NUMERIC = 'numeric', - BOOLEAN = 'boolean', - JSON = 'json', + STRING = 'STRING', + INTEGER = 'INTEGER', + NUMERIC = 'NUMERIC', + BOOLEAN = 'BOOLEAN', + JSON = 'JSON', } export interface Variation { diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 6d3eebcc..ffa3501d 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -25,6 +25,7 @@ export interface SubjectTestCase { export interface IAssignmentTestCase { flag: string; variationType: VariationType; + defaultValue: string | number | boolean | object; subjects: SubjectTestCase[]; } diff --git a/test/writeObfuscatedMockUFC.ts b/test/writeObfuscatedMockUFC.ts index 5ffceb3d..fc7cd102 100644 --- a/test/writeObfuscatedMockUFC.ts +++ b/test/writeObfuscatedMockUFC.ts @@ -1,7 +1,8 @@ import * as fs from 'fs'; -import { Flag, Rule } from '../src/interfaces'; +import { Flag } from '../src/interfaces'; import { encodeBase64, getMD5Hash } from '../src/obfuscation'; +import { Rule } from '../src/rules'; import { MOCK_UFC_RESPONSE_FILE, From e5d4be1ad12ba1a25a3e55748838d4d86ffd1db8 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 3 Apr 2024 14:23:30 -0700 Subject: [PATCH 14/39] Hardcoded obfuscation tests and other fixes --- src/client/eppo-client.spec.ts | 10 +++--- src/client/eppo-client.ts | 2 +- src/evaluator.spec.ts | 25 +++++---------- src/rules.spec.ts | 57 ++++++++++++++++++++++------------ 4 files changed, 51 insertions(+), 43 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 78b34b4e..6bf03f28 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -344,12 +344,12 @@ describe('EppoClient E2E test', () => { const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); - const nonExistantFlag = 'non-existent-flag'; + const nonExistentFlag = 'non-existent-flag'; - expect(client.getBoolAssignment('subject-identifer', nonExistantFlag, true, {})).toBe(true); - expect(client.getNumericAssignment('subject-identifer', nonExistantFlag, 1, {})).toBe(1); - expect(client.getJSONAssignment('subject-identifer', nonExistantFlag, {}, {})).toEqual({}); - expect(client.getStringAssignment('subject-identifer', nonExistantFlag, 'default', {})).toBe( + expect(client.getBoolAssignment('subject-identifer', nonExistentFlag, true, {})).toBe(true); + expect(client.getNumericAssignment('subject-identifer', nonExistentFlag, 1, {})).toBe(1); + expect(client.getJSONAssignment('subject-identifer', nonExistentFlag, {}, {})).toEqual({}); + expect(client.getStringAssignment('subject-identifer', nonExistentFlag, 'default', {})).toBe( 'default', ); }); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index dac9f362..d209d907 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -349,7 +349,7 @@ export default class EppoClient implements IEppoClient { } try { - if (result && result.doLog) { + if (result?.doLog) { this.logAssignment(result); } } catch (error) { diff --git a/src/evaluator.spec.ts b/src/evaluator.spec.ts index f7084dee..1adf8d9a 100644 --- a/src/evaluator.spec.ts +++ b/src/evaluator.spec.ts @@ -1,14 +1,16 @@ import { Evaluator, hashKey, isInShardRange } from './evaluator'; import { Flag, Variation, Shard, VariationType } from './interfaces'; -import { encodeBase64, getMD5Hash } from './obfuscation'; +import { getMD5Hash } from './obfuscation'; import { ObfuscatedOperatorType, OperatorType } from './rules'; -import { MD5Sharder, DeterministicSharder } from './sharders'; +import { DeterministicSharder } from './sharders'; describe('Evaluator', () => { const VARIATION_A: Variation = { key: 'a', value: 'A' }; const VARIATION_B: Variation = { key: 'b', value: 'B' }; const VARIATION_C: Variation = { key: 'c', value: 'C' }; + const evaluator = new Evaluator(); + it('should return none result for disabled flag', () => { const flag: Flag = { key: 'disabled_flag', @@ -32,7 +34,6 @@ describe('Evaluator', () => { totalShards: 10, }; - const evaluator = new Evaluator(new MD5Sharder()); const result = evaluator.evaluateFlag(flag, 'subject_key', {}, false); expect(result.flagKey).toEqual('disabled_flag'); expect(result.allocationKey).toBeNull(); @@ -46,7 +47,6 @@ describe('Evaluator', () => { ranges: [{ start: 0, end: 100 }], }; - const evaluator = new Evaluator(new MD5Sharder()); expect(evaluator.matchesShard(shard, 'subject_key', 100)).toBeTruthy(); }); @@ -59,7 +59,6 @@ describe('Evaluator', () => { ], }; - const evaluator = new Evaluator(new MD5Sharder()); expect(evaluator.matchesShard(shard, 'subject_key', 100)).toBeTruthy(); const deterministicEvaluator = new Evaluator(new DeterministicSharder({ subject_key: 50 })); @@ -86,7 +85,6 @@ describe('Evaluator', () => { totalShards: 10, }; - const evaluator = new Evaluator(new MD5Sharder()); const result = evaluator.evaluateFlag(emptyFlag, 'subject_key', {}, false); expect(result.flagKey).toEqual('empty'); expect(result.allocationKey).toBeNull(); @@ -117,7 +115,6 @@ describe('Evaluator', () => { totalShards: 10000, }; - const evaluator = new Evaluator(new MD5Sharder()); const result = evaluator.evaluateFlag(flag, 'user-1', {}, false); expect(result.variation).toEqual({ key: 'control', value: 'control-value' }); }); @@ -151,7 +148,6 @@ describe('Evaluator', () => { totalShards: 10000, }; - const evaluator = new Evaluator(new MD5Sharder()); let result = evaluator.evaluateFlag(flag, 'alice', {}, false); expect(result.variation).toEqual({ key: 'control', value: 'control' }); @@ -185,7 +181,6 @@ describe('Evaluator', () => { totalShards: 10, }; - const evaluator = new Evaluator(new MD5Sharder()); const result = evaluator.evaluateFlag(flag, 'subject_key', {}, false); expect(result.flagKey).toEqual('flag'); expect(result.allocationKey).toEqual('default'); @@ -234,7 +229,6 @@ describe('Evaluator', () => { totalShards: 10, }; - const evaluator = new Evaluator(new MD5Sharder()); const result = evaluator.evaluateFlag( flag, 'subject_key', @@ -287,7 +281,6 @@ describe('Evaluator', () => { totalShards: 10, }; - const evaluator = new Evaluator(new MD5Sharder()); const result = evaluator.evaluateFlag(flag, 'subject_key', { email: 'eppo@test.com' }, false); expect(result.flagKey).toEqual('flag'); expect(result.allocationKey).toEqual('default'); @@ -309,7 +302,7 @@ describe('Evaluator', () => { { operator: ObfuscatedOperatorType.MATCHES, attribute: getMD5Hash('email'), - value: encodeBase64('.*@example\\.com$'), + value: 'LipAZXhhbXBsZVxcLmNvbSQ=', //encodeBase64('.*@example\\.com$') }, ], }, @@ -339,7 +332,6 @@ describe('Evaluator', () => { totalShards: 10, }; - const evaluator = new Evaluator(new MD5Sharder()); const result = evaluator.evaluateFlag(flag, 'subject_key', { email: 'eppo@test.com' }, false); expect(result.flagKey).toEqual('obfuscated_flag_key'); expect(result.allocationKey).toEqual('default'); @@ -419,7 +411,7 @@ describe('Evaluator', () => { ); }); - it('should return none result for evaluation prior to allocation', () => { + it('should not match on allocation before startAt has passed', () => { const now = new Date(); const flag: Flag = { key: 'flag', @@ -445,7 +437,6 @@ describe('Evaluator', () => { totalShards: 10, }; - const evaluator = new Evaluator(new MD5Sharder()); const result = evaluator.evaluateFlag(flag, 'subject_key', {}, false); expect(result.flagKey).toEqual('flag'); expect(result.allocationKey).toBeNull(); @@ -478,14 +469,13 @@ describe('Evaluator', () => { totalShards: 10, }; - const evaluator = new Evaluator(new MD5Sharder()); const result = evaluator.evaluateFlag(flag, 'subject_key', {}, false); expect(result.flagKey).toEqual('flag'); expect(result.allocationKey).toEqual('default'); expect(result.variation).toEqual(VARIATION_A); }); - it('should evaluate flag after allocation period', () => { + it('should not match on allocation after endAt has passed', () => { const now = new Date(); const flag: Flag = { key: 'flag', @@ -511,7 +501,6 @@ describe('Evaluator', () => { totalShards: 10, }; - const evaluator = new Evaluator(new MD5Sharder()); const result = evaluator.evaluateFlag(flag, 'subject_key', {}, false); expect(result.flagKey).toEqual('flag'); expect(result.allocationKey).toBeNull(); diff --git a/src/rules.spec.ts b/src/rules.spec.ts index 2550b610..74daa069 100644 --- a/src/rules.spec.ts +++ b/src/rules.spec.ts @@ -57,12 +57,12 @@ describe('rules', () => { { operator: OperatorType.GTE, attribute: 'version', - value: '1.0.0', + value: '1.2.5', }, { operator: OperatorType.LTE, attribute: 'version', - value: '2.0.0', + value: '2.4.2', }, ], }; @@ -141,7 +141,12 @@ describe('rules', () => { }); it('should return false for a semver rule that does not match the subject attributes', () => { - const failingAttributes = { version: '2.1.0' }; + const failingAttributes = { version: '2.6.2' }; + expect(matchesRule(semverRule, failingAttributes, false)).toBe(false); + }); + + it('should return false for a semver rule that does not match the subject attributes', () => { + const failingAttributes = { version: '1.0.6' }; expect(matchesRule(semverRule, failingAttributes, false)).toBe(false); }); @@ -165,8 +170,12 @@ describe('rules', () => { conditions: [ { operator: ObfuscatedOperatorType.ONE_OF, - attribute: getMD5Hash('country'), - value: ['usa', 'canada', 'mexico'].map(getMD5Hash), + attribute: 'e909c2d7067ea37437cf97fe11d91bd0', // getMD5Hash('country') + value: [ + 'ada53304c5b9e4a839615b6e8f908eb6', + 'c2aadac2ca30ca8aadfbe331ae180d28', + '4edfc924721abb774d5447bade86ea5d', + ], // ['usa', 'canada', 'mexico'].map(getMD5Hash) }, ], }; @@ -175,8 +184,12 @@ describe('rules', () => { conditions: [ { operator: ObfuscatedOperatorType.NOT_ONE_OF, - attribute: getMD5Hash('country'), - value: ['usa', 'canada', 'mexico'].map(getMD5Hash), + attribute: 'e909c2d7067ea37437cf97fe11d91bd0', // getMD5Hash('country') + value: [ + 'ada53304c5b9e4a839615b6e8f908eb6', + 'c2aadac2ca30ca8aadfbe331ae180d28', + '4edfc924721abb774d5447bade86ea5d', + ], // ['usa', 'canada', 'mexico'].map(getMD5Hash) }, ], }; @@ -185,8 +198,8 @@ describe('rules', () => { conditions: [ { operator: ObfuscatedOperatorType.GTE, - attribute: getMD5Hash('age'), - value: encodeBase64('18'), + attribute: '7d637d275668ed6d41a9b97e6ad3a556', //getMD5Hash('age') + value: 'MTg=', //encodeBase64('18') }, ], }; @@ -195,8 +208,8 @@ describe('rules', () => { conditions: [ { operator: ObfuscatedOperatorType.GT, - attribute: getMD5Hash('age'), - value: encodeBase64('18'), + attribute: '7d637d275668ed6d41a9b97e6ad3a556', //getMD5Hash('age') + value: 'MTg=', //encodeBase64('18') }, ], }; @@ -204,8 +217,8 @@ describe('rules', () => { conditions: [ { operator: ObfuscatedOperatorType.LTE, - attribute: getMD5Hash('age'), - value: encodeBase64('18'), + attribute: '7d637d275668ed6d41a9b97e6ad3a556', //getMD5Hash('age') + value: 'MTg=', //encodeBase64('18') }, ], }; @@ -214,8 +227,8 @@ describe('rules', () => { conditions: [ { operator: ObfuscatedOperatorType.LT, - attribute: getMD5Hash('age'), - value: encodeBase64('18'), + attribute: '7d637d275668ed6d41a9b97e6ad3a556', //getMD5Hash('age') + value: 'MTg=', //encodeBase64('18') }, ], }; @@ -223,8 +236,8 @@ describe('rules', () => { conditions: [ { operator: ObfuscatedOperatorType.MATCHES, - attribute: getMD5Hash('email'), - value: encodeBase64('.+@example\\.com$'), + attribute: '0c83f57c786a0b4a39efab23731c7ebc', // getMD5Hash('email') + value: 'LitAZXhhbXBsZVwuY29tJA==', //encodeBase64('.+@example\\.com$') }, ], }; @@ -233,8 +246,8 @@ describe('rules', () => { conditions: [ { operator: ObfuscatedOperatorType.NOT_MATCHES, - attribute: getMD5Hash('email'), - value: encodeBase64('.+@example\\.com$'), + attribute: '0c83f57c786a0b4a39efab23731c7ebc', // getMD5Hash('email') + value: 'LitAZXhhbXBsZVwuY29tJA==', //encodeBase64('.+@example\\.com$') }, ], }; @@ -259,6 +272,12 @@ describe('rules', () => { ); }); + it('should return false for an obfuscated rule with NOT_ONE_OF condition when the subject attribute is null', () => { + expect(matchesRule(obfuscatedRuleWithNotOneOfCondition, { country: null }, true)).toBe( + false, + ); + }); + it('should return true for an obfuscated rule with GTE condition that matches the subject attributes', () => { expect(matchesRule(obfuscatedRuleWithGTECondition, { age: 18 }, true)).toBe(true); }); From 7400ed039d231948bf89cd14c180b9cf82d6a62c Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 3 Apr 2024 14:34:49 -0700 Subject: [PATCH 15/39] test for overwriting id for matching rules --- src/evaluator.spec.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/evaluator.spec.ts b/src/evaluator.spec.ts index 1adf8d9a..c6c6eafd 100644 --- a/src/evaluator.spec.ts +++ b/src/evaluator.spec.ts @@ -158,6 +158,39 @@ describe('Evaluator', () => { expect(result.variation).toBeNull(); }); + it('should evaluate flag based on a targeting condition with overwritten id', () => { + const flag: Flag = { + key: 'flag-key', + enabled: true, + variationType: VariationType.STRING, + variations: { control: { key: 'control', value: 'control' } }, + allocations: [ + { + key: 'allocation', + rules: [ + { + conditions: [ + { operator: OperatorType.ONE_OF, attribute: 'id', value: ['alice', 'bob'] }, + ], + }, + ], + splits: [ + { + variationKey: 'control', + shards: [], + extraLogging: {}, + }, + ], + doLog: true, + }, + ], + totalShards: 10000, + }; + + const result = evaluator.evaluateFlag(flag, 'alice', { id: 'charlie' }, false); + expect(result.variation).toBeNull(); + }); + it('should catch all allocation and return variation A', () => { const flag: Flag = { key: 'flag', From 4d9a3a0ef53b35c4bfe38a1450fc24e0a95d769e Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 3 Apr 2024 14:35:14 -0700 Subject: [PATCH 16/39] remove unused import --- src/rules.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rules.spec.ts b/src/rules.spec.ts index 74daa069..491655b9 100644 --- a/src/rules.spec.ts +++ b/src/rules.spec.ts @@ -1,4 +1,4 @@ -import { encodeBase64, getMD5Hash } from './obfuscation'; +import { getMD5Hash } from './obfuscation'; import { ObfuscatedOperatorType, OperatorType, Rule, matchesRule } from './rules'; describe('rules', () => { From 67f7cbcb2bb9f73231b30c816d4d8f1bf0accc39 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 3 Apr 2024 14:36:43 -0700 Subject: [PATCH 17/39] remove unneeded lint comment --- src/client/eppo-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index d209d907..bd4abdb3 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -185,7 +185,6 @@ export default class EppoClient implements IEppoClient { subjectKey: string, flagKey: string, defaultValue: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record<string, AttributeType> = {}, obfuscated = false, ): string { From c6b0960c19d5c087966287cce31ee5c514627ec1 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 3 Apr 2024 14:43:29 -0700 Subject: [PATCH 18/39] Add comment on getAssignmentDetail --- src/client/eppo-client.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index bd4abdb3..1c7c1988 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -311,6 +311,19 @@ export default class EppoClient implements IEppoClient { } } + /** + * [Experimental] Get a detailed return of assignment for a particular subject and flag. + * + * Note: This method is experimental and may change in future versions. + * Please only use for debugging purposes, and not in production. + * + * @param subjectKey The subject key + * @param flagKey The flag key + * @param subjectAttributes The subject attributes + * @param expectedVariationType The expected variation type + * @param obfuscated Whether the flag key is obfuscated + * @returns A detailed return of assignment for a particular subject and flag + */ public getAssignmentDetail( subjectKey: string, flagKey: string, From d71727e0e328b28766985c28e4ec88967adaa1c3 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 3 Apr 2024 17:05:59 -0700 Subject: [PATCH 19/39] addressing PR comments --- src/client/eppo-client.spec.ts | 45 +++++++++++++++------------------- src/client/eppo-client.ts | 7 ++++++ src/rules.spec.ts | 2 +- src/rules.ts | 1 - 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 6bf03f28..50e047e9 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -88,9 +88,7 @@ export async function init(configurationStore: IConfigurationStore) { } describe('EppoClient E2E test', () => { - const evaluator = new Evaluator(); const storage = new TestConfigurationStore(); - const globalClient = new EppoClient(evaluator, storage); beforeAll(async () => { mock.setup(); @@ -141,7 +139,6 @@ describe('EppoClient E2E test', () => { describe('error encountered', () => { let client: EppoClient; - const mockHooks = td.object<IAssignmentHooks>(); beforeAll(() => { storage.setEntries({ [flagKey]: mockFlag }); @@ -157,39 +154,37 @@ describe('EppoClient E2E test', () => { td.reset(); }); - it('returns default value when graceful failure if error encounterd', async () => { + it('returns default value when graceful failure if error encountered', async () => { client.setIsGracefulFailureMode(true); - expect(client.getBoolAssignment('subject-identifer', flagKey, true, {})).toBe(true); - expect(client.getBoolAssignment('subject-identifer', flagKey, false, {})).toBe(false); - expect(client.getNumericAssignment('subject-identifer', flagKey, 1, {})).toBe(1); - expect(client.getNumericAssignment('subject-identifer', flagKey, 0, {})).toBe(0); - expect(client.getJSONAssignment('subject-identifer', flagKey, {}, {})).toEqual({}); - expect( - client.getJSONAssignment('subject-identifer', flagKey, { hello: 'world' }, {}), - ).toEqual({ hello: 'world' }); - expect(client.getStringAssignment('subject-identifer', flagKey, 'default', {})).toBe( - 'default', - ); + expect(client.getBoolAssignment('subject-identifer', flagKey, true)).toBe(true); + expect(client.getBoolAssignment('subject-identifer', flagKey, false)).toBe(false); + expect(client.getNumericAssignment('subject-identifer', flagKey, 1)).toBe(1); + expect(client.getNumericAssignment('subject-identifer', flagKey, 0)).toBe(0); + expect(client.getJSONAssignment('subject-identifer', flagKey, {})).toEqual({}); + expect(client.getJSONAssignment('subject-identifer', flagKey, { hello: 'world' })).toEqual({ + hello: 'world', + }); + expect(client.getStringAssignment('subject-identifer', flagKey, 'default')).toBe('default'); }); it('throws error when graceful failure is false', async () => { client.setIsGracefulFailureMode(false); expect(() => { - client.getBoolAssignment('subject-identifer', flagKey, true, {}); + client.getBoolAssignment('subject-identifer', flagKey, true); }).toThrow(); expect(() => { - client.getJSONAssignment('subject-identifer', flagKey, {}, {}); + client.getJSONAssignment('subject-identifer', flagKey, {}); }).toThrow(); expect(() => { - client.getNumericAssignment('subject-identifer', flagKey, 1, {}); + client.getNumericAssignment('subject-identifer', flagKey, 1); }).toThrow(); expect(() => { - client.getStringAssignment('subject-identifer', flagKey, 'default', {}); + client.getStringAssignment('subject-identifer', flagKey, 'default'); }).toThrow(); }); }); @@ -245,8 +240,6 @@ describe('EppoClient E2E test', () => { it.each(readAssignmentTestData())( 'test variation assignment splits', async ({ flag, variationType, defaultValue, subjects }: IAssignmentTestCase) => { - `---- Test Case for ${flag} Experiment ----`; - const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); @@ -283,8 +276,6 @@ describe('EppoClient E2E test', () => { describe('UFC Obfuscated Test Cases', () => { const storage = new TestConfigurationStore(); - const evaluator = new Evaluator(); - const globalClient = new EppoClient(evaluator, storage); beforeAll(async () => { mock.setup(); @@ -303,8 +294,6 @@ describe('EppoClient E2E test', () => { it.each(readAssignmentTestData())( 'test variation assignment splits', async ({ flag, variationType, defaultValue, subjects }: IAssignmentTestCase) => { - `---- Test Case for ${flag} Experiment ----`; - const evaluator = new Evaluator(); const client = new EppoClient(evaluator, storage); @@ -329,6 +318,12 @@ describe('EppoClient E2E test', () => { for (const { subject, assignment } of assignments) { expect(assignment).toEqual(subject.assignment); + if (assignment !== subject.assignment) { + console.log(assignment, subject.assignment); + throw new Error( + `subject ${subject.subjectKey} was assigned ${assignment} when expected ${subject.assignment} for flag ${flag}`, + ); + } } }, ); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 1c7c1988..92d3db72 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -62,6 +62,13 @@ export interface IEppoClient { subjectAttributes?: Record<string, AttributeType>, ): number; + getIntegerAssignment( + subjectKey: string, + flagKey: string, + defaultValue: number, + subjectAttributes?: Record<string, AttributeType>, + ): number; + getJSONAssignment( subjectKey: string, flagKey: string, diff --git a/src/rules.spec.ts b/src/rules.spec.ts index 491655b9..a2563e99 100644 --- a/src/rules.spec.ts +++ b/src/rules.spec.ts @@ -86,7 +86,7 @@ describe('rules', () => { }; const subjectAttributes = { totalSales: 50, - version: '1.5.0', + version: '1.15.0', user_id: '12345', country: 'USA', }; diff --git a/src/rules.ts b/src/rules.ts index 3e301d83..14077d43 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -178,7 +178,6 @@ function evaluateCondition(subjectAttributes: Record<string, any>, condition: Co return false; } -// TODO: implement the obfuscated version of this function function evaluateObfuscatedCondition( hashedSubjectAttributes: Record<string, any>, condition: Condition, From c650af5f2ffe24410f9cfec6e91cfc57d36201a3 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 4 Apr 2024 10:49:11 -0700 Subject: [PATCH 20/39] update e2e test for more clarity --- src/client/eppo-client.spec.ts | 36 ++++++++++++++++++++++------------ test/testHelpers.ts | 2 +- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 50e047e9..ae8f4911 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -71,6 +71,28 @@ function getTestAssignments( return assignments; } +function validateTestAssignments( + assignments: { + subject: SubjectTestCase; + assignment: string | boolean | number | object | null; + }[], + flag: string, +) { + for (const { subject, assignment } of assignments) { + if (typeof assignment !== 'object') { + // the expect works well for objects, but this comparison does not + if (assignment !== subject.assignment) { + throw new Error( + `subject ${ + subject.subjectKey + } was assigned ${assignment?.toString()} when expected ${subject.assignment?.toString()} for flag ${flag}`, + ); + } + } + expect(subject.assignment).toEqual(assignment); + } +} + export async function init(configurationStore: IConfigurationStore) { const axiosInstance = axios.create({ baseURL: 'http://127.0.0.1:4000', @@ -267,9 +289,7 @@ describe('EppoClient E2E test', () => { false, ); - for (const { subject, assignment } of assignments) { - expect(assignment).toEqual(subject.assignment); - } + validateTestAssignments(assignments, flag); }, ); }); @@ -316,15 +336,7 @@ describe('EppoClient E2E test', () => { true, ); - for (const { subject, assignment } of assignments) { - expect(assignment).toEqual(subject.assignment); - if (assignment !== subject.assignment) { - console.log(assignment, subject.assignment); - throw new Error( - `subject ${subject.subjectKey} was assigned ${assignment} when expected ${subject.assignment} for flag ${flag}`, - ); - } - } + validateTestAssignments(assignments, flag); }, ); }); diff --git a/test/testHelpers.ts b/test/testHelpers.ts index ffa3501d..a0b47388 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -19,7 +19,7 @@ export enum ValueTestType { export interface SubjectTestCase { subjectKey: string; subjectAttributes: Record<string, AttributeType>; - assignment: string | null; + assignment: string | number | boolean | object; } export interface IAssignmentTestCase { From bfd47f62e2fc3e2bebd9193ad01fdee5dd4eaed3 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 4 Apr 2024 12:56:44 -0700 Subject: [PATCH 21/39] move obfuscated to client attribute, valueOf eppoValue --- src/client/eppo-client.spec.ts | 2 +- src/client/eppo-client.ts | 64 ++++++++++++++++++---------------- src/eppo_value.ts | 5 +-- 3 files changed, 35 insertions(+), 36 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index ae8f4911..06f58ec0 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -315,7 +315,7 @@ describe('EppoClient E2E test', () => { 'test variation assignment splits', async ({ flag, variationType, defaultValue, subjects }: IAssignmentTestCase) => { const evaluator = new Evaluator(); - const client = new EppoClient(evaluator, storage); + const client = new EppoClient(evaluator, storage, undefined, true); const typeAssignmentFunctions = { [VariationType.BOOLEAN]: client.getBoolAssignment.bind(client), diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 92d3db72..c9fe4a2b 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -55,14 +55,14 @@ export interface IEppoClient { subjectAttributes?: Record<string, AttributeType>, ): boolean; - getNumericAssignment( + getIntegerAssignment( subjectKey: string, flagKey: string, defaultValue: number, subjectAttributes?: Record<string, AttributeType>, ): number; - getIntegerAssignment( + getNumericAssignment( subjectKey: string, flagKey: string, defaultValue: number, @@ -112,6 +112,7 @@ export default class EppoClient implements IEppoClient { private queuedEvents: IAssignmentEvent[] = []; private assignmentLogger: IAssignmentLogger | undefined; private isGracefulFailureMode = true; + private isObfuscated = false; private assignmentCache: AssignmentCache<Cacheable> | undefined; private configurationStore: IConfigurationStore; private configurationRequestParameters: FlagConfigurationRequestParameters | undefined; @@ -122,10 +123,12 @@ export default class EppoClient implements IEppoClient { evaluator: Evaluator, configurationStore: IConfigurationStore, configurationRequestParameters?: FlagConfigurationRequestParameters, + obfuscated = false, ) { this.evaluator = evaluator; this.configurationStore = configurationStore; this.configurationRequestParameters = configurationRequestParameters; + this.isObfuscated = obfuscated; } public setConfigurationRequestParameters( @@ -193,7 +196,6 @@ export default class EppoClient implements IEppoClient { flagKey: string, defaultValue: string, subjectAttributes: Record<string, AttributeType> = {}, - obfuscated = false, ): string { return ( this.getAssignmentVariation( @@ -201,7 +203,6 @@ export default class EppoClient implements IEppoClient { flagKey, EppoValue.String(defaultValue), subjectAttributes, - obfuscated, VariationType.STRING, ).stringValue ?? defaultValue ); @@ -212,7 +213,6 @@ export default class EppoClient implements IEppoClient { flagKey: string, defaultValue: boolean, subjectAttributes: Record<string, AttributeType> = {}, - obfuscated = false, ): boolean { return ( this.getAssignmentVariation( @@ -220,18 +220,16 @@ export default class EppoClient implements IEppoClient { flagKey, EppoValue.Bool(defaultValue), subjectAttributes, - obfuscated, VariationType.BOOLEAN, ).boolValue ?? defaultValue ); } - getNumericAssignment( + getIntegerAssignment( subjectKey: string, flagKey: string, defaultValue: number, subjectAttributes?: Record<string, AttributeType>, - obfuscated = false, ): number { return ( this.getAssignmentVariation( @@ -239,18 +237,16 @@ export default class EppoClient implements IEppoClient { flagKey, EppoValue.Numeric(defaultValue), subjectAttributes, - obfuscated, - VariationType.NUMERIC, + VariationType.INTEGER, ).numericValue ?? defaultValue ); } - getIntegerAssignment( + getNumericAssignment( subjectKey: string, flagKey: string, defaultValue: number, subjectAttributes?: Record<string, AttributeType>, - obfuscated = false, ): number { return ( this.getAssignmentVariation( @@ -258,8 +254,7 @@ export default class EppoClient implements IEppoClient { flagKey, EppoValue.Numeric(defaultValue), subjectAttributes, - obfuscated, - VariationType.INTEGER, + VariationType.NUMERIC, ).numericValue ?? defaultValue ); } @@ -269,7 +264,6 @@ export default class EppoClient implements IEppoClient { flagKey: string, defaultValue: object, subjectAttributes: Record<string, AttributeType> = {}, - obfuscated = false, ): object { return ( this.getAssignmentVariation( @@ -277,26 +271,16 @@ export default class EppoClient implements IEppoClient { flagKey, EppoValue.JSON(defaultValue), subjectAttributes, - obfuscated, VariationType.JSON, ).objectValue ?? defaultValue ); } - private rethrowIfNotGraceful(err: Error, defaultValue?: EppoValue): EppoValue { - if (this.isGracefulFailureMode) { - console.error(`[Eppo SDK] Error getting assignment: ${err.message}`); - return defaultValue ?? EppoValue.Null(); - } - throw err; - } - private getAssignmentVariation( subjectKey: string, flagKey: string, defaultValue: EppoValue, subjectAttributes: Record<string, AttributeType> = {}, - obfuscated: boolean, expectedVariationType: VariationType, ): EppoValue { try { @@ -305,19 +289,26 @@ export default class EppoClient implements IEppoClient { flagKey, subjectAttributes, expectedVariationType, - obfuscated, ); if (!result.variation) { return defaultValue; } - return EppoValue.generateEppoValue(result.variation.value, expectedVariationType); + return EppoValue.valueOf(result.variation.value, expectedVariationType); } catch (error) { return this.rethrowIfNotGraceful(error, defaultValue); } } + private rethrowIfNotGraceful(err: Error, defaultValue?: EppoValue): EppoValue { + if (this.isGracefulFailureMode) { + console.error(`[Eppo SDK] Error getting assignment: ${err.message}`); + return defaultValue ?? EppoValue.Null(); + } + throw err; + } + /** * [Experimental] Get a detailed return of assignment for a particular subject and flag. * @@ -336,12 +327,11 @@ export default class EppoClient implements IEppoClient { flagKey: string, subjectAttributes: Record<string, AttributeType> = {}, expectedVariationType?: VariationType, - obfuscated = false, ): FlagEvaluation { validateNotBlank(subjectKey, 'Invalid argument: subjectKey cannot be blank'); validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank'); - const flag: Flag = this.configurationStore.get(obfuscated ? getMD5Hash(flagKey) : flagKey); + const flag = this.getFlag(flagKey); if (flag === null) { console.warn(`[Eppo SDK] No assigned variation. Flag not found: ${flagKey}`); @@ -361,8 +351,13 @@ export default class EppoClient implements IEppoClient { return noneResult(flagKey, subjectKey, subjectAttributes); } - const result = this.evaluator.evaluateFlag(flag, subjectKey, subjectAttributes, obfuscated); - if (obfuscated) { + const result = this.evaluator.evaluateFlag( + flag, + subjectKey, + subjectAttributes, + this.isObfuscated, + ); + if (this.isObfuscated) { // flag.key is obfuscated, replace with requested flag key result.flagKey = flagKey; } @@ -378,6 +373,13 @@ export default class EppoClient implements IEppoClient { return result; } + private getFlag(flagKey: string): Flag | null { + const flag: Flag = this.configurationStore.get( + this.isObfuscated ? getMD5Hash(flagKey) : flagKey, + ); + return flag; + } + private checkTypeMatch(expectedType?: VariationType, actualType?: VariationType): boolean { return expectedType === undefined || actualType === expectedType; } diff --git a/src/eppo_value.ts b/src/eppo_value.ts index 0ab9fdae..1beb3544 100644 --- a/src/eppo_value.ts +++ b/src/eppo_value.ts @@ -32,10 +32,7 @@ export class EppoValue { this.objectValue = objectValue; } - static generateEppoValue( - value: boolean | number | string | object, - valueType: VariationType, - ): EppoValue { + static valueOf(value: boolean | number | string | object, valueType: VariationType): EppoValue { if (value == null) { return EppoValue.Null(); } From bfc201ee9c819e811b7163c8ecd73b5edec5e361 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 4 Apr 2024 13:22:37 -0700 Subject: [PATCH 22/39] add version metadata, add isInitialized --- .gitignore | 3 +++ package.json | 1 + src/assignment-logger.ts | 2 ++ src/client/eppo-client.spec.ts | 11 ++++++++--- src/client/eppo-client.ts | 12 ++++++++++++ src/configuration-store.ts | 1 + 6 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index a93ba9cc..542eb287 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ yarn-error.log test/data .vscode/settings.json + +# automatically generated version +src/version.ts \ No newline at end of file diff --git a/package.json b/package.json index 20db6832..b3d2543d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "typecheck": "tsc", "test": "yarn test:unit", "test:unit": "NODE_ENV=test jest '.*\\.spec\\.ts'", + "prebuild": "node -p \"'export const LIB_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts", "obfuscate-mock-ufc": "ts-node test/writeObfuscatedMockUFC" }, "jsdelivr": "dist/eppo-sdk.js", diff --git a/src/assignment-logger.ts b/src/assignment-logger.ts index 4c2cefcf..bda5c8ee 100644 --- a/src/assignment-logger.ts +++ b/src/assignment-logger.ts @@ -44,6 +44,8 @@ export interface IAssignmentEvent { // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record<string, any>; [propName: string]: unknown; + + metaData?: Record<string, unknown>; } /** diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 06f58ec0..6bafdca0 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -13,7 +13,6 @@ import { readAssignmentTestData, readMockUFCResponse, } from '../../test/testHelpers'; -import { IAssignmentHooks } from '../assignment-hooks'; import { IAssignmentLogger } from '../assignment-logger'; import { IConfigurationStore } from '../configuration-store'; import { MAX_EVENT_QUEUE_SIZE, POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; @@ -31,6 +30,7 @@ const flagEndpoint = /flag_config\/v1\/config*/; class TestConfigurationStore implements IConfigurationStore { private store: Record<string, string> = {}; + private _isInitialized = false; public get<T>(key: string): T { const rval = this.store[key]; @@ -41,11 +41,16 @@ class TestConfigurationStore implements IConfigurationStore { Object.entries(entries).forEach(([key, val]) => { this.store[key] = JSON.stringify(val); }); + this._isInitialized = true; } public getKeys(): string[] { return Object.keys(this.store); } + + public isInitialized(): boolean { + return this._isInitialized; + } } function getTestAssignments( @@ -675,8 +680,8 @@ describe('EppoClient E2E test', () => { client = new EppoClient(evaluator, storage, requestConfiguration); client.setIsGracefulFailureMode(false); // no configuration loaded - let variation = client.getNumericAssignment(subject, flagKey, 0.0); - expect(variation).toBe(0.0); + let variation = client.getNumericAssignment(subject, flagKey, 123.4); + expect(variation).toBe(123.4); // have client fetch configurations await client.fetchFlagConfigurations(); variation = client.getNumericAssignment(subject, flagKey, 0.0); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index c9fe4a2b..113f8912 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -25,6 +25,7 @@ import { getMD5Hash } from '../obfuscation'; import initPoller, { IPoller } from '../poller'; import { AttributeType } from '../types'; import { validateNotBlank } from '../validation'; +import { LIB_VERSION } from '../version'; /** * Client for assigning experiment variations. @@ -93,6 +94,8 @@ export interface IEppoClient { setIsGracefulFailureMode(gracefulFailureMode: boolean): void; getFlagKeys(): string[]; + + isInitialized(): boolean; } export type FlagConfigurationRequestParameters = { @@ -394,6 +397,10 @@ export default class EppoClient implements IEppoClient { return this.configurationStore.getKeys(); } + public isInitialized() { + return this.configurationStore.isInitialized(); + } + public setLogger(logger: IAssignmentLogger) { this.assignmentLogger = logger; this.flushQueuedEvents(); // log any events that may have been queued while initializing @@ -444,6 +451,11 @@ export default class EppoClient implements IEppoClient { subject: result.subjectKey, timestamp: new Date().toISOString(), subjectAttributes: result.subjectAttributes, + metaData: { + obfuscated: this.isObfuscated, + sdkLanguage: 'javascript', + sdkLibVersion: LIB_VERSION, + }, }; if ( diff --git a/src/configuration-store.ts b/src/configuration-store.ts index e2116dc1..e901c60c 100644 --- a/src/configuration-store.ts +++ b/src/configuration-store.ts @@ -2,4 +2,5 @@ export interface IConfigurationStore { get<T>(key: string): T; getKeys(): string[]; setEntries<T>(entries: Record<string, T>): void; + isInitialized(): boolean; } From 8e5dbeb53ccb72d299f3ca36cae1d4bbd60f1083 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 4 Apr 2024 13:25:58 -0700 Subject: [PATCH 23/39] Add uninitialized test back in --- src/client/eppo-client.spec.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 6bafdca0..07b4d9d0 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -346,11 +346,13 @@ describe('EppoClient E2E test', () => { ); }); - // it('returns null if getStringAssignment was called for the subject before any RAC was loaded', () => { - // expect( - // globalClient.getStringAssignment(sessionOverrideSubject, sessionOverrideExperiment), - // ).toEqual(null); - // }); + it('returns null if getStringAssignment was called for the subject before any UFC was loaded', () => { + const localClient = new EppoClient(new Evaluator(), new TestConfigurationStore()); + expect(localClient.getStringAssignment('subject-1', flagKey, 'hello world')).toEqual( + 'hello world', + ); + expect(localClient.isInitialized()).toBe(false); + }); it('returns default value when key does not exist', async () => { const evaluator = new Evaluator(); From 7a3f392c0a40c891d5164470ce86a67f14fce726 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 4 Apr 2024 14:49:13 -0700 Subject: [PATCH 24/39] add version update to pre-commit --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index b3d2543d..cd3574dc 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,10 @@ "lint:fix": "eslint --fix '**/*.{ts,tsx}' --cache", "lint:fix-pre-commit": "eslint -c .eslintrc.pre-commit.js --fix '**/*.{ts,tsx}' --no-eslintrc --cache", "prepare": "make prepare", - "pre-commit": "lint-staged && tsc", + "pre-commit": "lint-staged && node -p \"'export const LIB_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts && tsc", "typecheck": "tsc", "test": "yarn test:unit", "test:unit": "NODE_ENV=test jest '.*\\.spec\\.ts'", - "prebuild": "node -p \"'export const LIB_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts", "obfuscate-mock-ufc": "ts-node test/writeObfuscatedMockUFC" }, "jsdelivr": "dist/eppo-sdk.js", From 25d8ebff14c7ce10f5fddbc6cc695f52b789afc4 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 4 Apr 2024 14:50:21 -0700 Subject: [PATCH 25/39] add version in pre-commit --- .gitignore | 3 --- package.json | 2 +- src/version.ts | 1 + 3 files changed, 2 insertions(+), 4 deletions(-) create mode 100644 src/version.ts diff --git a/.gitignore b/.gitignore index 542eb287..a93ba9cc 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,3 @@ yarn-error.log test/data .vscode/settings.json - -# automatically generated version -src/version.ts \ No newline at end of file diff --git a/package.json b/package.json index cd3574dc..11bafa7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk-common", - "version": "2.2.1", + "version": "3.0.0", "description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)", "main": "dist/index.js", "files": [ diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 00000000..25e2b759 --- /dev/null +++ b/src/version.ts @@ -0,0 +1 @@ +export const LIB_VERSION = "2.2.1"; From 834606e75f8e9a06846357732592b5cc3489969c Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 4 Apr 2024 14:50:42 -0700 Subject: [PATCH 26/39] test --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 11bafa7d..75bc0119 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk-common", - "version": "3.0.0", + "version": "2.0.0", "description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)", "main": "dist/index.js", "files": [ From 370d80a17bd7b75067d4f128dd6cf922be8711e9 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 4 Apr 2024 14:51:13 -0700 Subject: [PATCH 27/39] update version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 75bc0119..11bafa7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk-common", - "version": "2.0.0", + "version": "3.0.0", "description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)", "main": "dist/index.js", "files": [ From d058c4622adcab90e48d8df2d17617b374febe24 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 4 Apr 2024 14:53:12 -0700 Subject: [PATCH 28/39] bump version --- src/version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.ts b/src/version.ts index 25e2b759..9a1f5e22 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const LIB_VERSION = "2.2.1"; +export const LIB_VERSION = '3.0.0'; From cf991bce7421e5b6b0949ecaf6a1b73b9fd7aa45 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 4 Apr 2024 14:57:10 -0700 Subject: [PATCH 29/39] dont be fancy --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 94e2a9b7..3dac49d9 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "lint:fix": "eslint --fix '**/*.{ts,tsx}' --cache", "lint:fix-pre-commit": "eslint -c .eslintrc.pre-commit.js --fix '**/*.{ts,tsx}' --no-eslintrc --cache", "prepare": "make prepare", - "pre-commit": "lint-staged && node -p \"'export const LIB_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts && tsc", + "pre-commit": "lint-staged && tsc", "typecheck": "tsc", "test": "yarn test:unit", "test:unit": "NODE_ENV=test jest '.*\\.spec\\.ts'", From 6d063c4ea8d4e07f08574926f91a972e2790ce35 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 10 Apr 2024 13:41:21 -0700 Subject: [PATCH 30/39] remove evaluator as arg to eppo client constructor --- src/client/eppo-client.spec.ts | 42 ++++++++++++---------------------- src/client/eppo-client.ts | 3 +-- 2 files changed, 16 insertions(+), 29 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 07b4d9d0..98b54075 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -169,8 +169,7 @@ describe('EppoClient E2E test', () => { beforeAll(() => { storage.setEntries({ [flagKey]: mockFlag }); - const evaluator = new Evaluator(); - client = new EppoClient(evaluator, storage); + client = new EppoClient(storage); td.replace(EppoClient.prototype, 'getAssignmentDetail', function () { throw new Error('Mock test error'); @@ -224,8 +223,7 @@ describe('EppoClient E2E test', () => { it('Invokes logger for queued events', () => { const mockLogger = td.object<IAssignmentLogger>(); - const evaluator = new Evaluator(); - const client = new EppoClient(evaluator, storage); + const client = new EppoClient(storage); client.getStringAssignment('subject-to-be-logged', flagKey, 'default-value'); client.setLogger(mockLogger); @@ -238,8 +236,7 @@ describe('EppoClient E2E test', () => { it('Does not log same queued event twice', () => { const mockLogger = td.object<IAssignmentLogger>(); - const evaluator = new Evaluator(); - const client = new EppoClient(evaluator, storage); + const client = new EppoClient(storage); client.getStringAssignment('subject-to-be-logged', flagKey, 'default-value'); client.setLogger(mockLogger); @@ -252,8 +249,7 @@ describe('EppoClient E2E test', () => { it('Does not invoke logger for events that exceed queue size', () => { const mockLogger = td.object<IAssignmentLogger>(); - const evaluator = new Evaluator(); - const client = new EppoClient(evaluator, storage); + const client = new EppoClient(storage); for (let i = 0; i < MAX_EVENT_QUEUE_SIZE + 100; i++) { client.getStringAssignment(`subject-to-be-logged-${i}`, flagKey, 'default-value'); @@ -267,8 +263,7 @@ describe('EppoClient E2E test', () => { it.each(readAssignmentTestData())( 'test variation assignment splits', async ({ flag, variationType, defaultValue, subjects }: IAssignmentTestCase) => { - const evaluator = new Evaluator(); - const client = new EppoClient(evaluator, storage); + const client = new EppoClient(storage); let assignments: { subject: SubjectTestCase; @@ -319,8 +314,7 @@ describe('EppoClient E2E test', () => { it.each(readAssignmentTestData())( 'test variation assignment splits', async ({ flag, variationType, defaultValue, subjects }: IAssignmentTestCase) => { - const evaluator = new Evaluator(); - const client = new EppoClient(evaluator, storage, undefined, true); + const client = new EppoClient(storage, undefined, true); const typeAssignmentFunctions = { [VariationType.BOOLEAN]: client.getBoolAssignment.bind(client), @@ -347,7 +341,7 @@ describe('EppoClient E2E test', () => { }); it('returns null if getStringAssignment was called for the subject before any UFC was loaded', () => { - const localClient = new EppoClient(new Evaluator(), new TestConfigurationStore()); + const localClient = new EppoClient(new TestConfigurationStore()); expect(localClient.getStringAssignment('subject-1', flagKey, 'hello world')).toEqual( 'hello world', ); @@ -355,8 +349,7 @@ describe('EppoClient E2E test', () => { }); it('returns default value when key does not exist', async () => { - const evaluator = new Evaluator(); - const client = new EppoClient(evaluator, storage); + const client = new EppoClient(storage); const nonExistentFlag = 'non-existent-flag'; @@ -372,8 +365,7 @@ describe('EppoClient E2E test', () => { const mockLogger = td.object<IAssignmentLogger>(); storage.setEntries({ [flagKey]: mockFlag }); - const evaluator = new Evaluator(); - const client = new EppoClient(evaluator, storage); + const client = new EppoClient(storage); client.setLogger(mockLogger); const subjectAttributes = { foo: 3 }; @@ -399,8 +391,7 @@ describe('EppoClient E2E test', () => { td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(new Error('logging error')); storage.setEntries({ [flagKey]: mockFlag }); - const evaluator = new Evaluator(); - const client = new EppoClient(evaluator, storage); + const client = new EppoClient(storage); client.setLogger(mockLogger); const subjectAttributes = { foo: 3 }; @@ -416,15 +407,13 @@ describe('EppoClient E2E test', () => { describe('assignment logging deduplication', () => { let client: EppoClient; - let evaluator: Evaluator; let mockLogger: IAssignmentLogger; beforeEach(() => { mockLogger = td.object<IAssignmentLogger>(); storage.setEntries({ [flagKey]: mockFlag }); - evaluator = new Evaluator(); - client = new EppoClient(evaluator, storage); + client = new EppoClient(storage); client.setLogger(mockLogger); }); @@ -624,7 +613,6 @@ describe('EppoClient E2E test', () => { let requestConfiguration: FlagConfigurationRequestParameters; let mockServerResponseFunc: (res: MockResponse) => MockResponse; - const evaluator = new Evaluator(); const ufcBody = JSON.stringify(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)); const flagKey = 'numeric_flag'; const subject = 'alice'; @@ -679,7 +667,7 @@ describe('EppoClient E2E test', () => { }); it('Fetches initial configuration with parameters in constructor', async () => { - client = new EppoClient(evaluator, storage, requestConfiguration); + client = new EppoClient(storage, requestConfiguration); client.setIsGracefulFailureMode(false); // no configuration loaded let variation = client.getNumericAssignment(subject, flagKey, 123.4); @@ -691,7 +679,7 @@ describe('EppoClient E2E test', () => { }); it('Fetches initial configuration with parameters provided later', async () => { - client = new EppoClient(evaluator, storage); + client = new EppoClient(storage); client.setIsGracefulFailureMode(false); client.setConfigurationRequestParameters(requestConfiguration); // no configuration loaded @@ -723,7 +711,7 @@ describe('EppoClient E2E test', () => { ...requestConfiguration, pollAfterSuccessfulInitialization, }; - client = new EppoClient(evaluator, storage, requestConfiguration); + client = new EppoClient(storage, requestConfiguration); client.setIsGracefulFailureMode(false); // no configuration loaded let variation = client.getNumericAssignment(subject, flagKey, 0.0); @@ -779,7 +767,7 @@ describe('EppoClient E2E test', () => { throwOnFailedInitialization, pollAfterFailedInitialization, }; - client = new EppoClient(evaluator, storage, requestConfiguration); + client = new EppoClient(storage, requestConfiguration); client.setIsGracefulFailureMode(false); // no configuration loaded expect(client.getNumericAssignment(subject, flagKey, 0.0)).toBe(0.0); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 113f8912..f8f634ad 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -123,12 +123,11 @@ export default class EppoClient implements IEppoClient { private evaluator: Evaluator; constructor( - evaluator: Evaluator, configurationStore: IConfigurationStore, configurationRequestParameters?: FlagConfigurationRequestParameters, obfuscated = false, ) { - this.evaluator = evaluator; + this.evaluator = new Evaluator(); this.configurationStore = configurationStore; this.configurationRequestParameters = configurationRequestParameters; this.isObfuscated = obfuscated; From a5cfe951e06a7707a01b873e8ff425f6841687b6 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky <leoromanovsky@users.noreply.github.com> Date: Fri, 5 Apr 2024 10:43:43 -0700 Subject: [PATCH 31/39] Replace lru-cache package with simple local implementation (FF-1876) (#47) --- package.json | 1 - src/assignment-cache.ts | 7 ++--- src/lru-cache.spec.ts | 64 +++++++++++++++++++++++++++++++++++++++++ src/lru-cache.ts | 60 ++++++++++++++++++++++++++++++++++++++ yarn.lock | 5 ---- 5 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 src/lru-cache.spec.ts create mode 100644 src/lru-cache.ts diff --git a/package.json b/package.json index 3dac49d9..b64f7961 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,6 @@ }, "dependencies": { "axios": "^1.6.0", - "lru-cache": "^10.0.1", "md5": "^2.3.0", "pino": "^8.19.0", "semver": "^7.5.4", diff --git a/src/assignment-cache.ts b/src/assignment-cache.ts index 4f492caf..34f8fe9e 100644 --- a/src/assignment-cache.ts +++ b/src/assignment-cache.ts @@ -1,5 +1,4 @@ -import { LRUCache } from 'lru-cache'; - +import { LRUCache } from './lru-cache'; import { getMD5Hash } from './obfuscation'; export interface AssignmentCacheKey { @@ -69,8 +68,8 @@ export class NonExpiringInMemoryAssignmentCache extends AssignmentCache<Map<stri * multiple users. In this case, the cache size should be set to the maximum number * of users that can be active at the same time. */ -export class LRUInMemoryAssignmentCache extends AssignmentCache<LRUCache<string, string>> { +export class LRUInMemoryAssignmentCache extends AssignmentCache<LRUCache> { constructor(maxSize: number) { - super(new LRUCache<string, string>({ max: maxSize })); + super(new LRUCache(maxSize)); } } diff --git a/src/lru-cache.spec.ts b/src/lru-cache.spec.ts new file mode 100644 index 00000000..66b6b945 --- /dev/null +++ b/src/lru-cache.spec.ts @@ -0,0 +1,64 @@ +import { LRUCache } from './lru-cache'; + +describe('LRUCache', () => { + let cache: LRUCache; + + beforeEach(() => { + cache = new LRUCache(2); + }); + + it('should insert and retrieve a value', () => { + cache.set('a', 'apple'); + expect(cache.get('a')).toBe('apple'); + }); + + it('should return undefined for missing values', () => { + expect(cache.get('missing')).toBeUndefined(); + }); + + it('should overwrite existing values', () => { + cache.set('a', 'apple'); + cache.set('a', 'avocado'); + expect(cache.get('a')).toBe('avocado'); + }); + + it('should evict least recently used item', () => { + cache.set('a', 'apple'); + cache.set('b', 'banana'); + cache.set('c', 'cherry'); + expect(cache.get('a')).toBeUndefined(); + expect(cache.get('b')).toBe('banana'); + expect(cache.get('c')).toBe('cherry'); + }); + + it('should move recently used item to the end of the cache', () => { + cache.set('a', 'apple'); + cache.set('b', 'banana'); + cache.get('a'); // Access 'a' to make it recently used + cache.set('c', 'cherry'); + expect(cache.get('a')).toBe('apple'); + expect(cache.get('b')).toBeUndefined(); + expect(cache.get('c')).toBe('cherry'); + }); + + it('should check if a key exists', () => { + cache.set('a', 'apple'); + expect(cache.has('a')).toBeTruthy(); + expect(cache.has('b')).toBeFalsy(); + }); + + it('should handle the cache capacity of zero', () => { + const zeroCache = new LRUCache(0); + zeroCache.set('a', 'apple'); + expect(zeroCache.get('a')).toBeUndefined(); + }); + + it('should handle the cache capacity of one', () => { + const oneCache = new LRUCache(1); + oneCache.set('a', 'apple'); + expect(oneCache.get('a')).toBe('apple'); + oneCache.set('b', 'banana'); + expect(oneCache.get('a')).toBeUndefined(); + expect(oneCache.get('b')).toBe('banana'); + }); +}); diff --git a/src/lru-cache.ts b/src/lru-cache.ts new file mode 100644 index 00000000..77c56ccb --- /dev/null +++ b/src/lru-cache.ts @@ -0,0 +1,60 @@ +/** + * LRUCache is a cache that stores a maximum number of items. + * + * Items are removed from the cache when the cache is full. + * + * The cache is implemented as a Map, which is a built-in JavaScript object. + * The Map object holds key-value pairs and remembers the order of key-value pairs as they were inserted. + */ +export class LRUCache { + private capacity: number; + private cache: Map<string, string>; + + constructor(capacity: number) { + this.capacity = capacity; + this.cache = new Map<string, string>(); + } + + has(key: string): boolean { + return this.cache.has(key); + } + + get(key: string): string | undefined { + if (!this.cache.has(key)) { + return undefined; + } + + const value = this.cache.get(key); + + if (value !== undefined) { + // the delete and set operations are used together to ensure that the most recently accessed + // or added item is always considered the "newest" in terms of access order. + // This is crucial for maintaining the correct order of elements in the cache, + // which directly impacts which item is considered the least recently used (LRU) and + // thus eligible for eviction when the cache reaches its capacity. + this.cache.delete(key); + this.cache.set(key, value); + } + + return value; + } + + set(key: string, value: string): void { + if (this.capacity === 0) { + return; + } + + if (this.cache.has(key)) { + this.cache.delete(key); + } else if (this.cache.size >= this.capacity) { + // To evict the least recently used (LRU) item, we retrieve the first key in the Map. + // This is possible because the Map object in JavaScript maintains the insertion order of the keys. + // Therefore, the first key represents the oldest entry, which is the least recently used item in our cache. + // We use Map.prototype.keys().next().value to obtain this oldest key and then delete it from the cache. + const oldestKey = this.cache.keys().next().value; + this.cache.delete(oldestKey); + } + + this.cache.set(key, value); + } +} diff --git a/yarn.lock b/yarn.lock index 266c9e47..7778ab6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3116,11 +3116,6 @@ lodash@^4.17.21: resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -lru-cache@^10.0.1: - version "10.0.1" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz" - integrity sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g== - lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz" From 5958d196d1d7d8bbe110f39e07c66577be1ca025 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Thu, 11 Apr 2024 21:16:19 -0700 Subject: [PATCH 32/39] update endpoint to match backend --- src/client/eppo-client.spec.ts | 2 +- src/flag-configuration-requestor.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 98b54075..e71779a4 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -26,7 +26,7 @@ import EppoClient, { FlagConfigurationRequestParameters } from './eppo-client'; // eslint-disable-next-line @typescript-eslint/no-var-requires const packageJson = require('../../package.json'); -const flagEndpoint = /flag_config\/v1\/config*/; +const flagEndpoint = /flag-config\/v1\/config*/; class TestConfigurationStore implements IConfigurationStore { private store: Record<string, string> = {}; diff --git a/src/flag-configuration-requestor.ts b/src/flag-configuration-requestor.ts index 561cb899..6246db39 100644 --- a/src/flag-configuration-requestor.ts +++ b/src/flag-configuration-requestor.ts @@ -2,7 +2,7 @@ import { IConfigurationStore } from './configuration-store'; import HttpClient from './http-client'; import { Flag } from './interfaces'; -const UFC_ENDPOINT = '/flag_config/v1/config'; +const UFC_ENDPOINT = '/flag-config/v1/config'; interface IUniversalFlagConfig { flags: Record<string, Flag>; From ee3d43aa53897491f73c6e9b1a919c043afcbd36 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Mon, 15 Apr 2024 13:19:49 -0700 Subject: [PATCH 33/39] [ufc] add null operator and more fixes (#50) --- src/client/eppo-client.spec.ts | 2 - src/client/eppo-client.ts | 31 ++++++++- src/evaluator.spec.ts | 116 ++++++++++++++++++++++++++++++--- src/evaluator.ts | 26 +++++--- src/interfaces.ts | 6 +- src/rules.spec.ts | 90 +++++++++++++++++++++++++ src/rules.ts | 33 +++++++++- test/writeObfuscatedMockUFC.ts | 21 ++++-- 8 files changed, 292 insertions(+), 33 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index e71779a4..c7f7d6bd 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -16,7 +16,6 @@ import { import { IAssignmentLogger } from '../assignment-logger'; import { IConfigurationStore } from '../configuration-store'; import { MAX_EVENT_QUEUE_SIZE, POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; -import { Evaluator } from '../evaluator'; import FlagConfigurationRequestor from '../flag-configuration-requestor'; import HttpClient from '../http-client'; import { Flag, VariationType } from '../interfaces'; @@ -301,7 +300,6 @@ describe('EppoClient E2E test', () => { mock.setup(); mock.get(flagEndpoint, (_req, res) => { const ufc = readMockUFCResponse(OBFUSCATED_MOCK_UFC_RESPONSE_FILE); - console.log(ufc); return res.status(200).body(JSON.stringify(ufc)); }); await init(storage); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index f8f634ad..394fd757 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -23,7 +23,7 @@ import HttpClient from '../http-client'; import { Flag, VariationType } from '../interfaces'; import { getMD5Hash } from '../obfuscation'; import initPoller, { IPoller } from '../poller'; -import { AttributeType } from '../types'; +import { AttributeType, ValueType } from '../types'; import { validateNotBlank } from '../validation'; import { LIB_VERSION } from '../version'; @@ -364,6 +364,13 @@ export default class EppoClient implements IEppoClient { result.flagKey = flagKey; } + if ( + result?.variation && + !this.checkValueTypeMatch(expectedVariationType, result.variation.value) + ) { + return noneResult(flagKey, subjectKey, subjectAttributes); + } + try { if (result?.doLog) { this.logAssignment(result); @@ -386,6 +393,28 @@ export default class EppoClient implements IEppoClient { return expectedType === undefined || actualType === expectedType; } + private checkValueTypeMatch(expectedType: VariationType | undefined, value: ValueType): boolean { + if (expectedType == undefined) { + return true; + } + + switch (expectedType) { + case VariationType.STRING: + return typeof value === 'string'; + case VariationType.BOOLEAN: + return typeof value === 'boolean'; + case VariationType.INTEGER: + return typeof value === 'number' && Number.isInteger(value); + case VariationType.NUMERIC: + return typeof value === 'number'; + case VariationType.JSON: + // note: converting to object downstream + return typeof value === 'string'; + default: + return false; + } + } + public getFlagKeys() { /** * Returns a list of all flag keys that have been initialized. diff --git a/src/evaluator.spec.ts b/src/evaluator.spec.ts index c6c6eafd..88ab9732 100644 --- a/src/evaluator.spec.ts +++ b/src/evaluator.spec.ts @@ -1,7 +1,7 @@ -import { Evaluator, hashKey, isInShardRange } from './evaluator'; +import { Evaluator, hashKey, isInShardRange, matchesRules } from './evaluator'; import { Flag, Variation, Shard, VariationType } from './interfaces'; import { getMD5Hash } from './obfuscation'; -import { ObfuscatedOperatorType, OperatorType } from './rules'; +import { ObfuscatedOperatorType, OperatorType, Rule } from './rules'; import { DeterministicSharder } from './sharders'; describe('Evaluator', () => { @@ -380,7 +380,6 @@ describe('Evaluator', () => { allocations: [ { key: 'first', - rules: [], splits: [ { variationKey: 'a', @@ -403,7 +402,6 @@ describe('Evaluator', () => { }, { key: 'default', - rules: [], splits: [ { variationKey: 'c', @@ -454,8 +452,8 @@ describe('Evaluator', () => { allocations: [ { key: 'default', - startAt: new Date(now.getFullYear() + 1, 0, 1), - endAt: new Date(now.getFullYear() + 1, 1, 1), + startAt: new Date(now.getFullYear() + 1, 0, 1).toISOString(), + endAt: new Date(now.getFullYear() + 1, 1, 1).toISOString(), rules: [], splits: [ { @@ -486,8 +484,8 @@ describe('Evaluator', () => { allocations: [ { key: 'default', - startAt: new Date(now.getFullYear() - 1, 0, 1), - endAt: new Date(now.getFullYear() + 1, 0, 1), + startAt: new Date(now.getFullYear() - 1, 0, 1).toISOString(), + endAt: new Date(now.getFullYear() + 1, 0, 1).toISOString(), rules: [], splits: [ { @@ -518,8 +516,8 @@ describe('Evaluator', () => { allocations: [ { key: 'default', - startAt: new Date(now.getFullYear() - 2, 0, 1), - endAt: new Date(now.getFullYear() - 1, 0, 1), + startAt: new Date(now.getFullYear() - 2, 0, 1).toISOString(), + endAt: new Date(now.getFullYear() - 1, 0, 1).toISOString(), rules: [], splits: [ { @@ -554,3 +552,101 @@ describe('Evaluator', () => { expect(isInShardRange(1, { start: 1, end: 1 })).toBeFalsy(); }); }); + +describe('matchesRules', () => { + describe('matchesRules function', () => { + it('should return true when there are no rules', () => { + const rules: Rule[] = []; + const subjectAttributes = { id: 'test-subject' }; + const obfuscated = false; + expect(matchesRules(rules, subjectAttributes, obfuscated)).toBeTruthy(); + }); + + it('should return true when a rule matches', () => { + const rules: Rule[] = [ + { + conditions: [ + { + attribute: 'age', + operator: OperatorType.GTE, + value: 18, + }, + ], + }, + ]; + const subjectAttributes = { id: 'test-subject', age: 20 }; + const obfuscated = false; + expect(matchesRules(rules, subjectAttributes, obfuscated)).toBeTruthy(); + }); + + it('should return true when one of two rules matches', () => { + const rules: Rule[] = [ + { + conditions: [ + { + attribute: 'age', + operator: OperatorType.GTE, + value: 18, + }, + ], + }, + { + conditions: [ + { + attribute: 'age', + operator: OperatorType.LTE, + value: 10, + }, + ], + }, + ]; + const subjectAttributes = { id: 'test-subject', age: 10 }; + const obfuscated = false; + expect(matchesRules(rules, subjectAttributes, obfuscated)).toBeTruthy(); + }); + + it('should return true when null or rule is passed', () => { + const rules: Rule[] = [ + { + conditions: [ + { + attribute: 'age', + operator: OperatorType.IS_NULL, + value: true, + }, + ], + }, + { + conditions: [ + { + attribute: 'age', + operator: OperatorType.GTE, + value: 20, + }, + ], + }, + ]; + const obfuscated = false; + expect(matchesRules(rules, { id: 'test-subject', age: 20 }, obfuscated)).toBeTruthy(); + expect(matchesRules(rules, { id: 'test-subject', age: 10 }, obfuscated)).toBeFalsy(); + expect(matchesRules(rules, { id: 'test-subject', country: 'UK' }, obfuscated)).toBeTruthy(); + }); + + it('should return false when no rules match', () => { + const rules: Rule[] = [ + { + conditions: [ + { + attribute: 'age', + operator: OperatorType.GTE, + value: 18, + }, + ], + }, + ]; + const subjectAttributes = { id: 'test-subject', age: 16 }; + const obfuscated = false; + expect(matchesRules(rules, subjectAttributes, obfuscated)).toBeFalsy(); + }); + }); +}); diff --git a/src/evaluator.ts b/src/evaluator.ts index c952fef8..4fe9e818 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -1,11 +1,12 @@ import { Flag, Shard, Range, Variation } from './interfaces'; -import { matchesRule } from './rules'; +import { Rule, matchesRule } from './rules'; import { MD5Sharder, Sharder } from './sharders'; +import { SubjectAttributes } from './types'; export interface FlagEvaluation { flagKey: string; subjectKey: string; - subjectAttributes: Record<string, string | number | boolean>; + subjectAttributes: SubjectAttributes; allocationKey: string | null; variation: Variation | null; extraLogging: Record<string, string>; @@ -22,7 +23,7 @@ export class Evaluator { evaluateFlag( flag: Flag, subjectKey: string, - subjectAttributes: Record<string, string | number | boolean>, + subjectAttributes: SubjectAttributes, obfuscated: boolean, ): FlagEvaluation { if (!flag.enabled) { @@ -31,14 +32,11 @@ export class Evaluator { const now = new Date(); for (const allocation of flag.allocations) { - if (allocation.startAt && now < allocation.startAt) continue; - if (allocation.endAt && now > allocation.endAt) continue; + if (allocation.startAt && now < new Date(allocation.startAt)) continue; + if (allocation.endAt && now > new Date(allocation.endAt)) continue; if ( - !allocation.rules.length || - allocation.rules.some((rule) => - matchesRule(rule, { id: subjectKey, ...subjectAttributes }, obfuscated), - ) + matchesRules(allocation?.rules ?? [], { id: subjectKey, ...subjectAttributes }, obfuscated) ) { for (const split of allocation.splits) { if ( @@ -78,7 +76,7 @@ export function hashKey(salt: string, subjectKey: string): string { export function noneResult( flagKey: string, subjectKey: string, - subjectAttributes: Record<string, string | number | boolean>, + subjectAttributes: SubjectAttributes, ): FlagEvaluation { return { flagKey, @@ -90,3 +88,11 @@ export function noneResult( doLog: false, }; } + +export function matchesRules( + rules: Rule[], + subjectAttributes: SubjectAttributes, + obfuscated: boolean, +): boolean { + return !rules.length || rules.some((rule) => matchesRule(rule, subjectAttributes, obfuscated)); +} diff --git a/src/interfaces.ts b/src/interfaces.ts index d949bd72..e0f69187 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -31,9 +31,9 @@ export interface Split { export interface Allocation { key: string; - rules: Rule[]; - startAt?: Date; - endAt?: Date; + rules?: Rule[]; + startAt?: string; // ISO 8601 + endAt?: string; // ISO 8601 splits: Split[]; doLog: boolean; } diff --git a/src/rules.spec.ts b/src/rules.spec.ts index a2563e99..167e7273 100644 --- a/src/rules.spec.ts +++ b/src/rules.spec.ts @@ -52,6 +52,26 @@ describe('rules', () => { ], }; + const ruleWithNullCondition: Rule = { + conditions: [ + { + operator: OperatorType.IS_NULL, + attribute: 'country', + value: true, + }, + ], + }; + + const ruleWithNotNullCondition: Rule = { + conditions: [ + { + operator: OperatorType.IS_NULL, + attribute: 'country', + value: false, + }, + ], + }; + const semverRule: Rule = { conditions: [ { @@ -136,6 +156,30 @@ describe('rules', () => { expect(matchesRule(ruleWithNotOneOfCondition, { country: null }, false)).toBe(false); }); + it('should return true for a rule with IS_NULL condition when subject attribute is null', () => { + expect(matchesRule(ruleWithNullCondition, { country: null }, false)).toBe(true); + }); + + it('should return false for a rule with IS_NULL condition when subject attribute is not null', () => { + expect(matchesRule(ruleWithNullCondition, { country: 'UK' }, false)).toBe(false); + }); + + it('should return true for a rule with IS_NULL condition when subject attribute is missing', () => { + expect(matchesRule(ruleWithNullCondition, { age: 10 }, false)).toBe(true); + }); + + it('should return false for a rule with NOT IS_NULL condition when subject attribute is null', () => { + expect(matchesRule(ruleWithNotNullCondition, { country: null }, false)).toBe(false); + }); + + it('should return true for a rule with NOT IS_NULL condition when subject attribute is not null', () => { + expect(matchesRule(ruleWithNotNullCondition, { country: 'UK' }, false)).toBe(true); + }); + + it('should return false for a rule with NOT IS_NULL condition when subject attribute is missing', () => { + expect(matchesRule(ruleWithNotNullCondition, { age: 10 }, false)).toBe(false); + }); + it('should return true for a semver rule that matches the subject attributes', () => { expect(matchesRule(semverRule, subjectAttributes, false)).toBe(true); }); @@ -194,6 +238,26 @@ describe('rules', () => { ], }; + const obfuscatedRuleWithNullCondition: Rule = { + conditions: [ + { + operator: ObfuscatedOperatorType.IS_NULL, + attribute: 'e909c2d7067ea37437cf97fe11d91bd0', + value: 'b326b5062b2f0e69046810717534cb09', + }, + ], + }; + + const obfuscatedRuleWithNotNullCondition: Rule = { + conditions: [ + { + operator: ObfuscatedOperatorType.IS_NULL, + attribute: 'e909c2d7067ea37437cf97fe11d91bd0', + value: '68934a3e9455fa72420237eb05902327', + }, + ], + }; + const obfuscatedRuleWithGTECondition: Rule = { conditions: [ { @@ -278,6 +342,32 @@ describe('rules', () => { ); }); + it('should return true for a rule with IS_NULL condition when subject attribute is null', () => { + expect(matchesRule(obfuscatedRuleWithNullCondition, { country: null }, true)).toBe(true); + }); + + it('should return false for a rule with IS_NULL condition when subject attribute is not null', () => { + expect(matchesRule(obfuscatedRuleWithNullCondition, { country: 'UK' }, true)).toBe(false); + }); + + it('should return true for a rule with IS_NULL condition when subject attribute is missing', () => { + expect(matchesRule(obfuscatedRuleWithNullCondition, { age: 10 }, true)).toBe(true); + }); + + it('should return false for a rule with NOT IS_NULL condition when subject attribute is null', () => { + expect(matchesRule(obfuscatedRuleWithNotNullCondition, { country: null }, false)).toBe( + false, + ); + }); + + it('should return true for a rule with NOT IS_NULL condition when subject attribute is not null', () => { + expect(matchesRule(obfuscatedRuleWithNotNullCondition, { country: 'UK' }, true)).toBe(true); + }); + + it('should return false for a rule with NOT IS_NULL condition when subject attribute is missing', () => { + expect(matchesRule(obfuscatedRuleWithNotNullCondition, { age: 10 }, true)).toBe(false); + }); + it('should return true for an obfuscated rule with GTE condition that matches the subject attributes', () => { expect(matchesRule(obfuscatedRuleWithGTECondition, { age: 18 }, true)).toBe(true); }); diff --git a/src/rules.ts b/src/rules.ts index 14077d43..968c4c06 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -19,6 +19,7 @@ export enum OperatorType { LT = 'LT', ONE_OF = 'ONE_OF', NOT_ONE_OF = 'NOT_ONE_OF', + IS_NULL = 'IS_NULL', } export enum ObfuscatedOperatorType { @@ -30,6 +31,7 @@ export enum ObfuscatedOperatorType { LT = 'c562607189d77eb9dfb707464c1e7b0b', ONE_OF = '27457ce369f2a74203396a35ef537c0b', NOT_ONE_OF = '602f5ee0b6e84fe29f43ab48b9e1addf', + IS_NULL = 'dbd9c38e0339e6c34bd48cafc59be388', } enum OperatorValueType { @@ -91,13 +93,28 @@ type ObfuscatedNumericCondition = { type NumericCondition = StandardNumericCondition | ObfuscatedNumericCondition; +type StandardNullCondition = { + operator: OperatorType.IS_NULL; + attribute: string; + value: boolean; +}; + +type ObfuscatedNullCondition = { + operator: ObfuscatedOperatorType.IS_NULL; + attribute: string; + value: string; +}; + +type NullCondition = StandardNullCondition | ObfuscatedNullCondition; + export type Condition = | MatchesCondition | NotMatchesCondition | OneOfCondition | NotOneOfCondition | SemVerCondition - | NumericCondition; + | NumericCondition + | NullCondition; export interface Rule { conditions: Condition[]; @@ -139,6 +156,13 @@ function evaluateCondition(subjectAttributes: Record<string, any>, condition: Co const conditionValueType = targetingRuleConditionValuesTypesFromValues(condition.value); + if (condition.operator === OperatorType.IS_NULL) { + if (condition.value) { + return value === null || value === undefined; + } + return value !== null && value !== undefined; + } + if (value != null) { switch (condition.operator) { case OperatorType.GTE: @@ -185,6 +209,13 @@ function evaluateObfuscatedCondition( const value = hashedSubjectAttributes[condition.attribute]; const conditionValueType = targetingRuleConditionValuesTypesFromValues(value); + if (condition.operator === ObfuscatedOperatorType.IS_NULL) { + if (condition.value === getMD5Hash('true')) { + return value === null || value === undefined; + } + return value !== null && value !== undefined; + } + if (value != null) { switch (condition.operator) { case ObfuscatedOperatorType.GTE: diff --git a/test/writeObfuscatedMockUFC.ts b/test/writeObfuscatedMockUFC.ts index fc7cd102..7845f9d5 100644 --- a/test/writeObfuscatedMockUFC.ts +++ b/test/writeObfuscatedMockUFC.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import { Flag } from '../src/interfaces'; import { encodeBase64, getMD5Hash } from '../src/obfuscation'; -import { Rule } from '../src/rules'; +import { Condition, Rule } from '../src/rules'; import { MOCK_UFC_RESPONSE_FILE, @@ -11,6 +11,18 @@ import { TEST_DATA_DIR, } from './testHelpers'; +function encodeRuleValue(condition: Condition) { + switch (condition.operator) { + case 'ONE_OF': + case 'NOT_ONE_OF': + return condition.value.map((value) => getMD5Hash(value.toLowerCase())); + case 'IS_NULL': + return getMD5Hash(`${condition.value}`); + default: + return encodeBase64(`${condition.value}`); + } +} + function obfuscateRule(rule: Rule) { return { ...rule, @@ -18,10 +30,7 @@ function obfuscateRule(rule: Rule) { ...condition, attribute: getMD5Hash(condition.attribute), operator: getMD5Hash(condition.operator), - value: - ['ONE_OF', 'NOT_ONE_OF'].includes(condition.operator) && typeof condition.value === 'object' - ? condition.value.map((value) => getMD5Hash(value.toLowerCase())) - : encodeBase64(`${condition.value}`), + value: encodeRuleValue(condition), })), }; } @@ -32,7 +41,7 @@ function obfuscateFlag(flag: Flag) { key: getMD5Hash(flag.key), allocations: flag.allocations.map((allocation) => ({ ...allocation, - rules: allocation.rules.map(obfuscateRule), + rules: allocation.rules?.map(obfuscateRule), })), }; } From 9915183c6b3ff86c281bf6accbed904d1b1fef25 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Mon, 15 Apr 2024 13:27:26 -0700 Subject: [PATCH 34/39] export test helpers --- src/client/eppo-client.spec.ts | 48 ++-------------------------------- test/testHelpers.ts | 45 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 46 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index c7f7d6bd..6b78a5e5 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -10,8 +10,10 @@ import { MOCK_UFC_RESPONSE_FILE, OBFUSCATED_MOCK_UFC_RESPONSE_FILE, SubjectTestCase, + getTestAssignments, readAssignmentTestData, readMockUFCResponse, + validateTestAssignments, } from '../../test/testHelpers'; import { IAssignmentLogger } from '../assignment-logger'; import { IConfigurationStore } from '../configuration-store'; @@ -51,52 +53,6 @@ class TestConfigurationStore implements IConfigurationStore { return this._isInitialized; } } - -function getTestAssignments( - testCase: IAssignmentTestCase, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - assignmentFn: any, - obfuscated = false, -): { subject: SubjectTestCase; assignment: string | boolean | number | null | object }[] { - const assignments: { - subject: SubjectTestCase; - assignment: string | boolean | number | null | object; - }[] = []; - for (const subject of testCase.subjects) { - const assignment = assignmentFn( - subject.subjectKey, - testCase.flag, - testCase.defaultValue, - subject.subjectAttributes, - obfuscated, - ); - assignments.push({ subject: subject, assignment: assignment }); - } - return assignments; -} - -function validateTestAssignments( - assignments: { - subject: SubjectTestCase; - assignment: string | boolean | number | object | null; - }[], - flag: string, -) { - for (const { subject, assignment } of assignments) { - if (typeof assignment !== 'object') { - // the expect works well for objects, but this comparison does not - if (assignment !== subject.assignment) { - throw new Error( - `subject ${ - subject.subjectKey - } was assigned ${assignment?.toString()} when expected ${subject.assignment?.toString()} for flag ${flag}`, - ); - } - } - expect(subject.assignment).toEqual(assignment); - } -} - export async function init(configurationStore: IConfigurationStore) { const axiosInstance = axios.create({ baseURL: 'http://127.0.0.1:4000', diff --git a/test/testHelpers.ts b/test/testHelpers.ts index a0b47388..4d9bc120 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -44,3 +44,48 @@ export function readAssignmentTestData(): IAssignmentTestCase[] { }); return testCaseData; } + +export function getTestAssignments( + testCase: IAssignmentTestCase, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assignmentFn: any, + obfuscated = false, +): { subject: SubjectTestCase; assignment: string | boolean | number | null | object }[] { + const assignments: { + subject: SubjectTestCase; + assignment: string | boolean | number | null | object; + }[] = []; + for (const subject of testCase.subjects) { + const assignment = assignmentFn( + subject.subjectKey, + testCase.flag, + testCase.defaultValue, + subject.subjectAttributes, + obfuscated, + ); + assignments.push({ subject: subject, assignment: assignment }); + } + return assignments; +} + +export function validateTestAssignments( + assignments: { + subject: SubjectTestCase; + assignment: string | boolean | number | object | null; + }[], + flag: string, +) { + for (const { subject, assignment } of assignments) { + if (typeof assignment !== 'object') { + // the expect works well for objects, but this comparison does not + if (assignment !== subject.assignment) { + throw new Error( + `subject ${ + subject.subjectKey + } was assigned ${assignment?.toString()} when expected ${subject.assignment?.toString()} for flag ${flag}`, + ); + } + } + expect(subject.assignment).toEqual(assignment); + } +} From fd6e357f8b5410499b72697ca433723982b3e565 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Mon, 15 Apr 2024 15:17:56 -0700 Subject: [PATCH 35/39] export flag and variationtype --- src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/index.ts b/src/index.ts index 4e6868c0..63b9160d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { IConfigurationStore } from './configuration-store'; import * as constants from './constants'; import FlagConfigRequestor from './flag-configuration-requestor'; import HttpClient from './http-client'; +import { Flag, VariationType } from './interfaces'; import * as validation from './validation'; export { @@ -21,4 +22,6 @@ export { IConfigurationStore, AssignmentCache, FlagConfigurationRequestParameters, + Flag, + VariationType, }; From a28e473f551645cf61a507faa6f3faebd83afec1 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Mon, 15 Apr 2024 15:56:27 -0700 Subject: [PATCH 36/39] export attribute type --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index 63b9160d..871db7c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import * as constants from './constants'; import FlagConfigRequestor from './flag-configuration-requestor'; import HttpClient from './http-client'; import { Flag, VariationType } from './interfaces'; +import { AttributeType } from './types'; import * as validation from './validation'; export { @@ -24,4 +25,5 @@ export { FlagConfigurationRequestParameters, Flag, VariationType, + AttributeType, }; From 08e36c832c9ea25b6305f7deb65e8c542a0689d4 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Mon, 15 Apr 2024 15:57:37 -0700 Subject: [PATCH 37/39] export more types --- src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 871db7c1..b311fce5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import * as constants from './constants'; import FlagConfigRequestor from './flag-configuration-requestor'; import HttpClient from './http-client'; import { Flag, VariationType } from './interfaces'; -import { AttributeType } from './types'; +import { AttributeType, SubjectAttributes } from './types'; import * as validation from './validation'; export { @@ -26,4 +26,5 @@ export { Flag, VariationType, AttributeType, + SubjectAttributes, }; From 7735cc6a6f80d31537f3aa4fa9257ed484b66b7c Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Wed, 17 Apr 2024 20:11:38 -0700 Subject: [PATCH 38/39] Update function signatures --- src/client/eppo-client.spec.ts | 132 +++++++++++++++++---------------- src/client/eppo-client.ts | 68 ++++++++--------- test/testHelpers.ts | 4 +- 3 files changed, 104 insertions(+), 100 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 6b78a5e5..bbddfddc 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -138,34 +138,38 @@ describe('EppoClient E2E test', () => { it('returns default value when graceful failure if error encountered', async () => { client.setIsGracefulFailureMode(true); - expect(client.getBoolAssignment('subject-identifer', flagKey, true)).toBe(true); - expect(client.getBoolAssignment('subject-identifer', flagKey, false)).toBe(false); - expect(client.getNumericAssignment('subject-identifer', flagKey, 1)).toBe(1); - expect(client.getNumericAssignment('subject-identifer', flagKey, 0)).toBe(0); - expect(client.getJSONAssignment('subject-identifer', flagKey, {})).toEqual({}); - expect(client.getJSONAssignment('subject-identifer', flagKey, { hello: 'world' })).toEqual({ + expect(client.getBoolAssignment(flagKey, 'subject-identifer', {}, true)).toBe(true); + expect(client.getBoolAssignment(flagKey, 'subject-identifer', {}, false)).toBe(false); + expect(client.getNumericAssignment(flagKey, 'subject-identifer', {}, 1)).toBe(1); + expect(client.getNumericAssignment(flagKey, 'subject-identifer', {}, 0)).toBe(0); + expect(client.getJSONAssignment(flagKey, 'subject-identifer', {}, {})).toEqual({}); + expect( + client.getJSONAssignment(flagKey, 'subject-identifer', {}, { hello: 'world' }), + ).toEqual({ hello: 'world', }); - expect(client.getStringAssignment('subject-identifer', flagKey, 'default')).toBe('default'); + expect(client.getStringAssignment(flagKey, 'subject-identifer', {}, 'default')).toBe( + 'default', + ); }); it('throws error when graceful failure is false', async () => { client.setIsGracefulFailureMode(false); expect(() => { - client.getBoolAssignment('subject-identifer', flagKey, true); + client.getBoolAssignment(flagKey, 'subject-identifer', {}, true); }).toThrow(); expect(() => { - client.getJSONAssignment('subject-identifer', flagKey, {}); + client.getJSONAssignment(flagKey, 'subject-identifer', {}, {}); }).toThrow(); expect(() => { - client.getNumericAssignment('subject-identifer', flagKey, 1); + client.getNumericAssignment(flagKey, 'subject-identifer', {}, 1); }).toThrow(); expect(() => { - client.getStringAssignment('subject-identifer', flagKey, 'default'); + client.getStringAssignment(flagKey, 'subject-identifer', {}, 'default'); }).toThrow(); }); }); @@ -179,7 +183,7 @@ describe('EppoClient E2E test', () => { const mockLogger = td.object<IAssignmentLogger>(); const client = new EppoClient(storage); - client.getStringAssignment('subject-to-be-logged', flagKey, 'default-value'); + client.getStringAssignment(flagKey, 'subject-to-be-logged', {}, 'default-value'); client.setLogger(mockLogger); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); @@ -193,7 +197,7 @@ describe('EppoClient E2E test', () => { const client = new EppoClient(storage); - client.getStringAssignment('subject-to-be-logged', flagKey, 'default-value'); + client.getStringAssignment(flagKey, 'subject-to-be-logged', {}, 'default-value'); client.setLogger(mockLogger); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); @@ -207,7 +211,7 @@ describe('EppoClient E2E test', () => { const client = new EppoClient(storage); for (let i = 0; i < MAX_EVENT_QUEUE_SIZE + 100; i++) { - client.getStringAssignment(`subject-to-be-logged-${i}`, flagKey, 'default-value'); + client.getStringAssignment(flagKey, `subject-to-be-logged-${i}`, {}, 'default-value'); } client.setLogger(mockLogger); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(MAX_EVENT_QUEUE_SIZE); @@ -296,7 +300,7 @@ describe('EppoClient E2E test', () => { it('returns null if getStringAssignment was called for the subject before any UFC was loaded', () => { const localClient = new EppoClient(new TestConfigurationStore()); - expect(localClient.getStringAssignment('subject-1', flagKey, 'hello world')).toEqual( + expect(localClient.getStringAssignment(flagKey, 'subject-1', {}, 'hello world')).toEqual( 'hello world', ); expect(localClient.isInitialized()).toBe(false); @@ -307,10 +311,10 @@ describe('EppoClient E2E test', () => { const nonExistentFlag = 'non-existent-flag'; - expect(client.getBoolAssignment('subject-identifer', nonExistentFlag, true, {})).toBe(true); - expect(client.getNumericAssignment('subject-identifer', nonExistentFlag, 1, {})).toBe(1); - expect(client.getJSONAssignment('subject-identifer', nonExistentFlag, {}, {})).toEqual({}); - expect(client.getStringAssignment('subject-identifer', nonExistentFlag, 'default', {})).toBe( + expect(client.getBoolAssignment(nonExistentFlag, 'subject-identifer', {}, true)).toBe(true); + expect(client.getNumericAssignment(nonExistentFlag, 'subject-identifer', {}, 1)).toBe(1); + expect(client.getJSONAssignment(nonExistentFlag, 'subject-identifer', {}, {})).toEqual({}); + expect(client.getStringAssignment(nonExistentFlag, 'subject-identifer', {}, 'default')).toBe( 'default', ); }); @@ -324,10 +328,10 @@ describe('EppoClient E2E test', () => { const subjectAttributes = { foo: 3 }; const assignment = client.getStringAssignment( - 'subject-10', flagKey, - 'default', + 'subject-10', subjectAttributes, + 'default', ); expect(assignment).toEqual(variationA.value); @@ -350,10 +354,10 @@ describe('EppoClient E2E test', () => { const subjectAttributes = { foo: 3 }; const assignment = client.getStringAssignment( - 'subject-10', flagKey, - 'default', + 'subject-10', subjectAttributes, + 'default', ); expect(assignment).toEqual('variation-a'); @@ -374,8 +378,8 @@ describe('EppoClient E2E test', () => { it('logs duplicate assignments without an assignment cache', () => { client.disableAssignmentCache(); - client.getStringAssignment('subject-10', flagKey, 'default'); - client.getStringAssignment('subject-10', flagKey, 'default'); + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // call count should be 2 because there is no cache. expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2); @@ -384,8 +388,8 @@ describe('EppoClient E2E test', () => { it('does not log duplicate assignments', () => { client.useNonExpiringInMemoryAssignmentCache(); - client.getStringAssignment('subject-10', flagKey, 'default'); - client.getStringAssignment('subject-10', flagKey, 'default'); + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // call count should be 1 because the second call is a cache hit and not logged. expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); @@ -394,15 +398,15 @@ describe('EppoClient E2E test', () => { it('logs assignment again after the lru cache is full', () => { client.useLRUInMemoryAssignmentCache(2); - client.getStringAssignment('subject-10', flagKey, 'default'); // logged - client.getStringAssignment('subject-10', flagKey, 'default'); // cached + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // logged + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cached - client.getStringAssignment('subject-11', flagKey, 'default'); // logged - client.getStringAssignment('subject-11', flagKey, 'default'); // cached + client.getStringAssignment(flagKey, 'subject-11', {}, 'default'); // logged + client.getStringAssignment(flagKey, 'subject-11', {}, 'default'); // cached - client.getStringAssignment('subject-12', flagKey, 'default'); // cache evicted subject-10, logged - client.getStringAssignment('subject-10', flagKey, 'default'); // previously evicted, logged - client.getStringAssignment('subject-12', flagKey, 'default'); // cached + client.getStringAssignment(flagKey, 'subject-12', {}, 'default'); // cache evicted subject-10, logged + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // previously evicted, logged + client.getStringAssignment(flagKey, 'subject-12', {}, 'default'); // cached expect(td.explain(mockLogger.logAssignment).callCount).toEqual(4); }); @@ -414,8 +418,8 @@ describe('EppoClient E2E test', () => { client.setLogger(mockLogger); - client.getStringAssignment('subject-10', flagKey, 'default'); - client.getStringAssignment('subject-10', flagKey, 'default'); + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // call count should be 2 because the first call had an exception // therefore we are not sure the logger was successful and try again. @@ -437,15 +441,15 @@ describe('EppoClient E2E test', () => { client.useNonExpiringInMemoryAssignmentCache(); - client.getStringAssignment('subject-10', flagKey, 'default'); - client.getStringAssignment('subject-10', flagKey, 'default'); - client.getStringAssignment('subject-10', 'flag-2', 'default'); - client.getStringAssignment('subject-10', 'flag-2', 'default'); - client.getStringAssignment('subject-10', 'flag-3', 'default'); - client.getStringAssignment('subject-10', 'flag-3', 'default'); - client.getStringAssignment('subject-10', flagKey, 'default'); - client.getStringAssignment('subject-10', 'flag-2', 'default'); - client.getStringAssignment('subject-10', 'flag-3', 'default'); + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + client.getStringAssignment('flag-2', 'subject-10', {}, 'default'); + client.getStringAssignment('flag-2', 'subject-10', {}, 'default'); + client.getStringAssignment('flag-3', 'subject-10', {}, 'default'); + client.getStringAssignment('flag-3', 'subject-10', {}, 'default'); + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + client.getStringAssignment('flag-2', 'subject-10', {}, 'default'); + client.getStringAssignment('flag-3', 'subject-10', {}, 'default'); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(3); }); @@ -472,7 +476,7 @@ describe('EppoClient E2E test', () => { ], }, }); - client.getStringAssignment('subject-10', flagKey, 'default'); + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); storage.setEntries({ [flagKey]: { @@ -492,7 +496,7 @@ describe('EppoClient E2E test', () => { ], }, }); - client.getStringAssignment('subject-10', flagKey, 'default'); + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2); }); @@ -502,8 +506,8 @@ describe('EppoClient E2E test', () => { // original configuration version storage.setEntries({ [flagKey]: mockFlag }); - client.getStringAssignment('subject-10', flagKey, 'default'); // log this assignment - client.getStringAssignment('subject-10', flagKey, 'default'); // cache hit, don't log + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // log this assignment + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log // change the variation storage.setEntries({ @@ -525,14 +529,14 @@ describe('EppoClient E2E test', () => { }, }); - client.getStringAssignment('subject-10', flagKey, 'default'); // log this assignment - client.getStringAssignment('subject-10', flagKey, 'default'); // cache hit, don't log + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // log this assignment + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log // change the flag again, back to the original storage.setEntries({ [flagKey]: mockFlag }); - client.getStringAssignment('subject-10', flagKey, 'default'); // important: log this assignment - client.getStringAssignment('subject-10', flagKey, 'default'); // cache hit, don't log + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // important: log this assignment + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log // change the allocation storage.setEntries({ @@ -554,8 +558,8 @@ describe('EppoClient E2E test', () => { }, }); - client.getStringAssignment('subject-10', flagKey, 'default'); // log this assignment - client.getStringAssignment('subject-10', flagKey, 'default'); // cache hit, don't log + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // log this assignment + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log expect(td.explain(mockLogger.logAssignment).callCount).toEqual(4); }); @@ -624,11 +628,11 @@ describe('EppoClient E2E test', () => { client = new EppoClient(storage, requestConfiguration); client.setIsGracefulFailureMode(false); // no configuration loaded - let variation = client.getNumericAssignment(subject, flagKey, 123.4); + let variation = client.getNumericAssignment(flagKey, subject, {}, 123.4); expect(variation).toBe(123.4); // have client fetch configurations await client.fetchFlagConfigurations(); - variation = client.getNumericAssignment(subject, flagKey, 0.0); + variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); expect(variation).toBe(pi); }); @@ -637,11 +641,11 @@ describe('EppoClient E2E test', () => { client.setIsGracefulFailureMode(false); client.setConfigurationRequestParameters(requestConfiguration); // no configuration loaded - let variation = client.getNumericAssignment(subject, flagKey, 0.0); + let variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); expect(variation).toBe(0.0); // have client fetch configurations await client.fetchFlagConfigurations(); - variation = client.getNumericAssignment(subject, flagKey, 0.0); + variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); expect(variation).toBe(pi); }); @@ -668,7 +672,7 @@ describe('EppoClient E2E test', () => { client = new EppoClient(storage, requestConfiguration); client.setIsGracefulFailureMode(false); // no configuration loaded - let variation = client.getNumericAssignment(subject, flagKey, 0.0); + let variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); expect(variation).toBe(0.0); // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes @@ -680,7 +684,7 @@ describe('EppoClient E2E test', () => { // Await so it can finish its initialization before this test proceeds await fetchPromise; - variation = client.getNumericAssignment(subject, flagKey, 0.0); + variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); expect(variation).toBe(pi); expect(callCount).toBe(2); @@ -724,7 +728,7 @@ describe('EppoClient E2E test', () => { client = new EppoClient(storage, requestConfiguration); client.setIsGracefulFailureMode(false); // no configuration loaded - expect(client.getNumericAssignment(subject, flagKey, 0.0)).toBe(0.0); + expect(client.getNumericAssignment(flagKey, subject, {}, 0.0)).toBe(0.0); // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes if (throwOnFailedInitialization) { @@ -734,14 +738,14 @@ describe('EppoClient E2E test', () => { } expect(callCount).toBe(1); // still no configuration loaded - expect(client.getNumericAssignment(subject, flagKey, 10.0)).toBe(10.0); + expect(client.getNumericAssignment(flagKey, subject, {}, 10.0)).toBe(10.0); // Advance timers so a post-init poll can take place await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 1.5); // if pollAfterFailedInitialization = true, we will poll later and get a config, otherwise not expect(callCount).toBe(pollAfterFailedInitialization ? 2 : 1); - expect(client.getNumericAssignment(subject, flagKey, 0.0)).toBe( + expect(client.getNumericAssignment(flagKey, subject, {}, 0.0)).toBe( pollAfterFailedInitialization ? pi : 0.0, ); }); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 394fd757..ccc73cb4 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -43,38 +43,38 @@ export interface IEppoClient { * @public */ getStringAssignment( - subjectKey: string, flagKey: string, + subjectKey: string, + subjectAttributes: Record<string, AttributeType>, defaultValue: string, - subjectAttributes?: Record<string, AttributeType>, ): string; getBoolAssignment( - subjectKey: string, flagKey: string, + subjectKey: string, + subjectAttributes: Record<string, AttributeType>, defaultValue: boolean, - subjectAttributes?: Record<string, AttributeType>, ): boolean; getIntegerAssignment( - subjectKey: string, flagKey: string, + subjectKey: string, + subjectAttributes: Record<string, AttributeType>, defaultValue: number, - subjectAttributes?: Record<string, AttributeType>, ): number; getNumericAssignment( - subjectKey: string, flagKey: string, + subjectKey: string, + subjectAttributes: Record<string, AttributeType>, defaultValue: number, - subjectAttributes?: Record<string, AttributeType>, ): number; getJSONAssignment( - subjectKey: string, flagKey: string, + subjectKey: string, + subjectAttributes: Record<string, AttributeType>, defaultValue: object, - subjectAttributes?: Record<string, AttributeType>, ): object; setLogger(logger: IAssignmentLogger): void; @@ -194,101 +194,101 @@ export default class EppoClient implements IEppoClient { } public getStringAssignment( - subjectKey: string, flagKey: string, + subjectKey: string, + subjectAttributes: Record<string, AttributeType>, defaultValue: string, - subjectAttributes: Record<string, AttributeType> = {}, ): string { return ( this.getAssignmentVariation( - subjectKey, flagKey, - EppoValue.String(defaultValue), + subjectKey, subjectAttributes, + EppoValue.String(defaultValue), VariationType.STRING, ).stringValue ?? defaultValue ); } getBoolAssignment( - subjectKey: string, flagKey: string, + subjectKey: string, + subjectAttributes: Record<string, AttributeType>, defaultValue: boolean, - subjectAttributes: Record<string, AttributeType> = {}, ): boolean { return ( this.getAssignmentVariation( - subjectKey, flagKey, - EppoValue.Bool(defaultValue), + subjectKey, subjectAttributes, + EppoValue.Bool(defaultValue), VariationType.BOOLEAN, ).boolValue ?? defaultValue ); } getIntegerAssignment( - subjectKey: string, flagKey: string, + subjectKey: string, + subjectAttributes: Record<string, AttributeType>, defaultValue: number, - subjectAttributes?: Record<string, AttributeType>, ): number { return ( this.getAssignmentVariation( - subjectKey, flagKey, - EppoValue.Numeric(defaultValue), + subjectKey, subjectAttributes, + EppoValue.Numeric(defaultValue), VariationType.INTEGER, ).numericValue ?? defaultValue ); } getNumericAssignment( - subjectKey: string, flagKey: string, + subjectKey: string, + subjectAttributes: Record<string, AttributeType>, defaultValue: number, - subjectAttributes?: Record<string, AttributeType>, ): number { return ( this.getAssignmentVariation( - subjectKey, flagKey, - EppoValue.Numeric(defaultValue), + subjectKey, subjectAttributes, + EppoValue.Numeric(defaultValue), VariationType.NUMERIC, ).numericValue ?? defaultValue ); } public getJSONAssignment( - subjectKey: string, flagKey: string, + subjectKey: string, + subjectAttributes: Record<string, AttributeType>, defaultValue: object, - subjectAttributes: Record<string, AttributeType> = {}, ): object { return ( this.getAssignmentVariation( - subjectKey, flagKey, - EppoValue.JSON(defaultValue), + subjectKey, subjectAttributes, + EppoValue.JSON(defaultValue), VariationType.JSON, ).objectValue ?? defaultValue ); } private getAssignmentVariation( - subjectKey: string, flagKey: string, + subjectKey: string, + subjectAttributes: Record<string, AttributeType>, defaultValue: EppoValue, - subjectAttributes: Record<string, AttributeType> = {}, expectedVariationType: VariationType, ): EppoValue { try { const result = this.getAssignmentDetail( - subjectKey, flagKey, + subjectKey, subjectAttributes, expectedVariationType, ); @@ -325,8 +325,8 @@ export default class EppoClient implements IEppoClient { * @returns A detailed return of assignment for a particular subject and flag */ public getAssignmentDetail( - subjectKey: string, flagKey: string, + subjectKey: string, subjectAttributes: Record<string, AttributeType> = {}, expectedVariationType?: VariationType, ): FlagEvaluation { diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 4d9bc120..d8b8b4cb 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -57,10 +57,10 @@ export function getTestAssignments( }[] = []; for (const subject of testCase.subjects) { const assignment = assignmentFn( - subject.subjectKey, testCase.flag, - testCase.defaultValue, + subject.subjectKey, subject.subjectAttributes, + testCase.defaultValue, obfuscated, ); assignments.push({ subject: subject, assignment: assignment }); From 7b25f18d883c15b6cd5d22aad3a31b0afbe80749 Mon Sep 17 00:00:00 2001 From: Sven Schmit <sven@geteppo.com> Date: Fri, 19 Apr 2024 16:50:27 -0700 Subject: [PATCH 39/39] [UFC] Update obfuscation decoding (#52) --- .gitignore | 2 + src/client/eppo-client.spec.ts | 10 +- src/client/eppo-client.ts | 76 +++++++------- src/decoding.spec.ts | 178 +++++++++++++++++++++++++++++++++ src/decoding.ts | 78 +++++++++++++++ src/interfaces.ts | 34 +++++++ src/rules.spec.ts | 16 +-- src/rules.ts | 20 +--- src/sharders.spec.ts | 37 +++++++ 9 files changed, 391 insertions(+), 60 deletions(-) create mode 100644 src/decoding.spec.ts create mode 100644 src/decoding.ts create mode 100644 src/sharders.spec.ts diff --git a/.gitignore b/.gitignore index a93ba9cc..a7a205fb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ yarn-error.log test/data .vscode/settings.json + +.DS_Store \ No newline at end of file diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index bbddfddc..4178b2c7 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -22,7 +22,7 @@ import FlagConfigurationRequestor from '../flag-configuration-requestor'; import HttpClient from '../http-client'; import { Flag, VariationType } from '../interfaces'; -import EppoClient, { FlagConfigurationRequestParameters } from './eppo-client'; +import EppoClient, { FlagConfigurationRequestParameters, checkTypeMatch } from './eppo-client'; // eslint-disable-next-line @typescript-eslint/no-var-requires const packageJson = require('../../package.json'); @@ -218,11 +218,18 @@ describe('EppoClient E2E test', () => { }); }); + describe('check type match', () => { + it('returns false when types do not match', () => { + expect(checkTypeMatch(VariationType.JSON, VariationType.STRING)).toBe(false); + }); + }); + describe('UFC General Test Cases', () => { it.each(readAssignmentTestData())( 'test variation assignment splits', async ({ flag, variationType, defaultValue, subjects }: IAssignmentTestCase) => { const client = new EppoClient(storage); + client.setIsGracefulFailureMode(false); let assignments: { subject: SubjectTestCase; @@ -273,6 +280,7 @@ describe('EppoClient E2E test', () => { 'test variation assignment splits', async ({ flag, variationType, defaultValue, subjects }: IAssignmentTestCase) => { const client = new EppoClient(storage, undefined, true); + client.setIsGracefulFailureMode(false); const typeAssignmentFunctions = { [VariationType.BOOLEAN]: client.getBoolAssignment.bind(client), diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index ccc73cb4..5b9163ab 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -16,11 +16,12 @@ import { MAX_EVENT_QUEUE_SIZE, POLL_INTERVAL_MS, } from '../constants'; +import { decodeFlag } from '../decoding'; import { EppoValue } from '../eppo_value'; import { Evaluator, FlagEvaluation, noneResult } from '../evaluator'; import ExperimentConfigurationRequestor from '../flag-configuration-requestor'; import HttpClient from '../http-client'; -import { Flag, VariationType } from '../interfaces'; +import { Flag, ObfuscatedFlag, VariationType } from '../interfaces'; import { getMD5Hash } from '../obfuscation'; import initPoller, { IPoller } from '../poller'; import { AttributeType, ValueType } from '../types'; @@ -341,9 +342,9 @@ export default class EppoClient implements IEppoClient { return noneResult(flagKey, subjectKey, subjectAttributes); } - if (!this.checkTypeMatch(expectedVariationType, flag.variationType)) { + if (!checkTypeMatch(expectedVariationType, flag.variationType)) { throw new TypeError( - `Variation value does not have the correct type. Found: ${flag.variationType} != ${expectedVariationType}`, + `Variation value does not have the correct type. Found: ${flag.variationType} != ${expectedVariationType} for flag ${flagKey}`, ); } @@ -364,10 +365,7 @@ export default class EppoClient implements IEppoClient { result.flagKey = flagKey; } - if ( - result?.variation && - !this.checkValueTypeMatch(expectedVariationType, result.variation.value) - ) { + if (result?.variation && !checkValueTypeMatch(expectedVariationType, result.variation.value)) { return noneResult(flagKey, subjectKey, subjectAttributes); } @@ -383,36 +381,15 @@ export default class EppoClient implements IEppoClient { } private getFlag(flagKey: string): Flag | null { - const flag: Flag = this.configurationStore.get( - this.isObfuscated ? getMD5Hash(flagKey) : flagKey, - ); - return flag; + if (this.isObfuscated) { + return this.getObfuscatedFlag(flagKey); + } + return this.configurationStore.get(flagKey); } - private checkTypeMatch(expectedType?: VariationType, actualType?: VariationType): boolean { - return expectedType === undefined || actualType === expectedType; - } - - private checkValueTypeMatch(expectedType: VariationType | undefined, value: ValueType): boolean { - if (expectedType == undefined) { - return true; - } - - switch (expectedType) { - case VariationType.STRING: - return typeof value === 'string'; - case VariationType.BOOLEAN: - return typeof value === 'boolean'; - case VariationType.INTEGER: - return typeof value === 'number' && Number.isInteger(value); - case VariationType.NUMERIC: - return typeof value === 'number'; - case VariationType.JSON: - // note: converting to object downstream - return typeof value === 'string'; - default: - return false; - } + private getObfuscatedFlag(flagKey: string): Flag | null { + const flag: ObfuscatedFlag | null = this.configurationStore.get(getMD5Hash(flagKey)); + return flag ? decodeFlag(flag) : null; } public getFlagKeys() { @@ -517,3 +494,32 @@ export default class EppoClient implements IEppoClient { } } } + +export function checkTypeMatch(expectedType?: VariationType, actualType?: VariationType): boolean { + return expectedType === undefined || actualType === expectedType; +} + +export function checkValueTypeMatch( + expectedType: VariationType | undefined, + value: ValueType, +): boolean { + if (expectedType == undefined) { + return true; + } + + switch (expectedType) { + case VariationType.STRING: + return typeof value === 'string'; + case VariationType.BOOLEAN: + return typeof value === 'boolean'; + case VariationType.INTEGER: + return typeof value === 'number' && Number.isInteger(value); + case VariationType.NUMERIC: + return typeof value === 'number'; + case VariationType.JSON: + // note: converting to object downstream + return typeof value === 'string'; + default: + return false; + } +} diff --git a/src/decoding.spec.ts b/src/decoding.spec.ts new file mode 100644 index 00000000..83bd6661 --- /dev/null +++ b/src/decoding.spec.ts @@ -0,0 +1,178 @@ +import { decodeAllocation, decodeSplit, decodeValue, decodeVariations } from './decoding'; +import { VariationType, ObfuscatedVariation, Variation } from './interfaces'; + +describe('decoding', () => { + describe('decodeVariations', () => { + it('should correctly decode variations', () => { + const encodedVariations: Record<string, ObfuscatedVariation> = { + 'Y29udHJvbA==': { + key: 'Y29udHJvbA==', + value: 'MA==', + }, + dHJlYXRtZW50: { + key: 'dHJlYXRtZW50', + value: 'MQ==', + }, + }; + + const expectedVariations: Record<string, Variation> = { + control: { + key: 'control', + value: 0, + }, + treatment: { + key: 'treatment', + value: 1, + }, + }; + + expect(decodeVariations(encodedVariations, VariationType.INTEGER)).toEqual( + expectedVariations, + ); + }); + }); + + describe('decodeValue', () => { + it('should correctly decode string values', () => { + expect(decodeValue('Y29udHJvbA==', VariationType.STRING)).toEqual('control'); + }); + it('should correctly decode integer values', () => { + expect(decodeValue('NDI=', VariationType.INTEGER)).toEqual(42); + }); + it('should correctly decode numeric values', () => { + expect(decodeValue('My4xNDE1OTI2NTM1OQ==', VariationType.NUMERIC)).toEqual(3.14159265359); + }); + it('should correctly decode "true" boolean values', () => { + expect(decodeValue('dHJ1ZQ==', VariationType.BOOLEAN)).toEqual(true); + }); + it('should correctly decode "false" boolean values', () => { + expect(decodeValue('ZmFsc2U=', VariationType.BOOLEAN)).toEqual(false); + }); + + it('should correctly decode JSON values', () => { + expect( + JSON.parse( + decodeValue( + 'eyJoZWxsbyI6ICJ3b3JsZCIsICJieWUiOiAid29ybGQifQ==', + VariationType.JSON, + ) as string, + ), + ).toEqual({ hello: 'world', bye: 'world' }); + }); + }); + describe('decodeSplit', () => { + it('should correctly decode split without extra logging', () => { + const obfuscatedSplit = { + shards: [ + { + salt: 'c2FsdA==', + ranges: [ + { + start: 0, + end: 100, + }, + ], + }, + ], + variationKey: 'Y29udHJvbA==', + }; + + const expectedSplit = { + shards: [ + { + salt: 'salt', + ranges: [ + { + start: 0, + end: 100, + }, + ], + }, + ], + variationKey: 'control', + }; + + expect(decodeSplit(obfuscatedSplit)).toEqual(expectedSplit); + }); + it('should correctly decode split with extra logging', () => { + const obfuscatedSplit = { + shards: [ + { + salt: 'c2FsdA==', + ranges: [ + { + start: 0, + end: 100, + }, + ], + }, + ], + variationKey: 'Y29udHJvbA==', + extraLogging: { 'aGVsbG8=': 'd29ybGQ=', Ynll: 'd29ybGQ=' }, + }; + + const expectedSplit = { + shards: [ + { + salt: 'salt', + ranges: [ + { + start: 0, + end: 100, + }, + ], + }, + ], + variationKey: 'control', + extraLogging: { + hello: 'world', + bye: 'world', + }, + }; + + expect(decodeSplit(obfuscatedSplit)).toEqual(expectedSplit); + }); + }); + + describe('decodeAllocation', () => { + it('should correctly decode allocation without startAt and endAt', () => { + const obfuscatedAllocation = { + key: 'ZXhwZXJpbWVudA==', + rules: [], + splits: [], // tested in decodeSplit + doLog: true, + }; + + const expectedAllocation = { + key: 'experiment', + rules: [], + splits: [], + doLog: true, + }; + + expect(decodeAllocation(obfuscatedAllocation)).toEqual(expectedAllocation); + }); + + it('should correctly decode allocation with startAt and endAt', () => { + const obfuscatedAllocation = { + key: 'ZXhwZXJpbWVudA==', + startAt: 'MjAyMC0wNC0wMVQxODo1ODo1NS44Mjla', + endAt: 'MjAyNS0wNy0yOVQwOTowMDoxMy4yMDVa', + rules: [], + splits: [], // tested in decodeSplit + doLog: true, + }; + + const expectedAllocation = { + key: 'experiment', + rules: [], + splits: [], + doLog: true, + startAt: '2020-04-01T18:58:55.829Z', + endAt: '2025-07-29T09:00:13.205Z', + }; + + expect(decodeAllocation(obfuscatedAllocation)).toEqual(expectedAllocation); + }); + }); +}); diff --git a/src/decoding.ts b/src/decoding.ts new file mode 100644 index 00000000..52477043 --- /dev/null +++ b/src/decoding.ts @@ -0,0 +1,78 @@ +import { + ObfuscatedFlag, + Flag, + ObfuscatedVariation, + VariationType, + Variation, + ObfuscatedAllocation, + Allocation, + Split, + Shard, + ObfuscatedSplit, +} from './interfaces'; +import { decodeBase64 } from './obfuscation'; + +export function decodeFlag(flag: ObfuscatedFlag): Flag { + return { + ...flag, + variations: decodeVariations(flag.variations, flag.variationType), + allocations: flag.allocations.map(decodeAllocation), + }; +} + +export function decodeVariations( + variations: Record<string, ObfuscatedVariation>, + variationType: VariationType, +): Record<string, Variation> { + return Object.fromEntries( + Object.entries(variations).map(([, variation]) => { + const decodedKey = decodeBase64(variation.key); + return [decodedKey, { key: decodedKey, value: decodeValue(variation.value, variationType) }]; + }), + ); +} + +export function decodeValue(encodedValue: string, type: VariationType): string | number | boolean { + switch (type) { + case VariationType.INTEGER: + case VariationType.NUMERIC: + return Number(decodeBase64(encodedValue)); + case VariationType.BOOLEAN: + return decodeBase64(encodedValue) === 'true'; + default: + return decodeBase64(encodedValue); + } +} + +export function decodeAllocation(allocation: ObfuscatedAllocation): Allocation { + return { + ...allocation, + key: decodeBase64(allocation.key), + splits: allocation.splits.map(decodeSplit), + startAt: allocation.startAt + ? new Date(decodeBase64(allocation.startAt)).toISOString() + : undefined, + endAt: allocation.endAt ? new Date(decodeBase64(allocation.endAt)).toISOString() : undefined, + }; +} + +export function decodeSplit(split: ObfuscatedSplit): Split { + return { + extraLogging: split.extraLogging ? decodeObject(split.extraLogging) : undefined, + variationKey: decodeBase64(split.variationKey), + shards: split.shards.map(decodeShard), + }; +} + +export function decodeShard(shard: Shard): Shard { + return { + ...shard, + salt: decodeBase64(shard.salt), + }; +} + +export function decodeObject(obj: Record<string, string>): Record<string, string> { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [decodeBase64(key), decodeBase64(value)]), + ); +} diff --git a/src/interfaces.ts b/src/interfaces.ts index e0f69187..9f68da4e 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -46,3 +46,37 @@ export interface Flag { allocations: Allocation[]; totalShards: number; } + +export interface ObfuscatedFlag { + key: string; + enabled: boolean; + variationType: VariationType; + variations: Record<string, ObfuscatedVariation>; + allocations: ObfuscatedAllocation[]; + totalShards: number; +} + +export interface ObfuscatedVariation { + key: string; + value: string; +} + +export interface ObfuscatedAllocation { + key: string; + rules?: Rule[]; + startAt?: string; // ISO 8601 + endAt?: string; // ISO 8601 + splits: ObfuscatedSplit[]; + doLog: boolean; +} + +export interface ObfuscatedSplit { + shards: ObfuscatedShard[]; + variationKey: string; + extraLogging?: Record<string, string>; +} + +export interface ObfuscatedShard { + salt: string; + ranges: Range[]; +} diff --git a/src/rules.spec.ts b/src/rules.spec.ts index 167e7273..6c0e8b0b 100644 --- a/src/rules.spec.ts +++ b/src/rules.spec.ts @@ -216,10 +216,10 @@ describe('rules', () => { operator: ObfuscatedOperatorType.ONE_OF, attribute: 'e909c2d7067ea37437cf97fe11d91bd0', // getMD5Hash('country') value: [ - 'ada53304c5b9e4a839615b6e8f908eb6', - 'c2aadac2ca30ca8aadfbe331ae180d28', - '4edfc924721abb774d5447bade86ea5d', - ], // ['usa', 'canada', 'mexico'].map(getMD5Hash) + 'f75d91cdd36b85cc4a8dfeca4f24fa14', + '445d337b5cd5de476f99333df6b0c2a7', + '8dbb07a18d46f63d8b3c8994d5ccc351', + ], // ['USA', 'Canada', 'Mexico'].map(getMD5Hash) }, ], }; @@ -230,10 +230,10 @@ describe('rules', () => { operator: ObfuscatedOperatorType.NOT_ONE_OF, attribute: 'e909c2d7067ea37437cf97fe11d91bd0', // getMD5Hash('country') value: [ - 'ada53304c5b9e4a839615b6e8f908eb6', - 'c2aadac2ca30ca8aadfbe331ae180d28', - '4edfc924721abb774d5447bade86ea5d', - ], // ['usa', 'canada', 'mexico'].map(getMD5Hash) + 'f75d91cdd36b85cc4a8dfeca4f24fa14', + '445d337b5cd5de476f99333df6b0c2a7', + '8dbb07a18d46f63d8b3c8994d5ccc351', + ], // ['USA', 'Canada', 'Mexico'].map(getMD5Hash) }, ], }; diff --git a/src/rules.ts b/src/rules.ts index 968c4c06..2c6ec446 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -188,15 +188,9 @@ function evaluateCondition(subjectAttributes: Record<string, any>, condition: Co case OperatorType.MATCHES: return new RegExp(condition.value as string).test(value as string); case OperatorType.ONE_OF: - return isOneOf( - value.toString().toLowerCase(), - condition.value.map((value: string) => value.toLowerCase()), - ); + return isOneOf(value.toString(), condition.value); case OperatorType.NOT_ONE_OF: - return isNotOneOf( - value.toString().toLowerCase(), - condition.value.map((value: string) => value.toLowerCase()), - ); + return isNotOneOf(value.toString(), condition.value); } } return false; @@ -259,15 +253,9 @@ function evaluateObfuscatedCondition( case ObfuscatedOperatorType.NOT_MATCHES: return !new RegExp(decodeBase64(condition.value as string)).test(value as string); case ObfuscatedOperatorType.ONE_OF: - return isOneOf( - getMD5Hash(value.toString().toLowerCase()), - condition.value.map((value: string) => value.toLowerCase()), - ); + return isOneOf(getMD5Hash(value.toString()), condition.value); case ObfuscatedOperatorType.NOT_ONE_OF: - return isNotOneOf( - getMD5Hash(value.toString().toLowerCase()), - condition.value.map((value: string) => value.toLowerCase()), - ); + return isNotOneOf(getMD5Hash(value.toString()), condition.value); } } return false; diff --git a/src/sharders.spec.ts b/src/sharders.spec.ts new file mode 100644 index 00000000..f3074520 --- /dev/null +++ b/src/sharders.spec.ts @@ -0,0 +1,37 @@ +import { MD5Sharder, DeterministicSharder } from './sharders'; + +describe('Sharders', () => { + describe('MD5Sharder', () => { + it('should correctly calculate shard for given input and total shards', () => { + const sharder = new MD5Sharder(); + const inputs: [string, number][] = [ + ['test-input', 5619], + ['alice', 3170], + ['bob', 7420], + ['charlie', 7497], + ]; + const totalShards = 10000; + inputs.forEach(([input, expectedShard]) => { + expect(sharder.getShard(input, totalShards)).toEqual(expectedShard); + }); + }); + }); + + describe('DeterministicSharder', () => { + it('should return the shard from the lookup table if present', () => { + const lookup = { 'test-input': 5 }; + const sharder = new DeterministicSharder(lookup); + const input = 'test-input'; + const totalShards = 10; // totalShards is ignored in DeterministicSharder + expect(sharder.getShard(input, totalShards)).toEqual(5); + }); + + it('should return 0 if the input is not present in the lookup table', () => { + const lookup = { 'some-other-input': 7 }; + const sharder = new DeterministicSharder(lookup); + const input = 'test-input-not-in-lookup'; + const totalShards = 10; // totalShards is ignored in DeterministicSharder + expect(sharder.getShard(input, totalShards)).toEqual(0); + }); + }); +});