diff --git a/package.json b/package.json index 548d57a..1f6f46b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk-common", - "version": "1.4.1", + "version": "1.5.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/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 22f3123..d813994 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -205,6 +205,11 @@ describe('EppoClient E2E test', () => { expect(stringAssignments).toEqual(expectedAssignments); break; } + case ValueTestType.JSONType: { + const jsonStringAssignments = assignments.map((a) => a?.stringValue ?? null); + expect(jsonStringAssignments).toEqual(expectedAssignments); + break; + } } }, ); @@ -217,37 +222,51 @@ describe('EppoClient E2E test', () => { }); it('returns subject from overrides when enabled is true', () => { - window.localStorage.setItem( - flagKey, - JSON.stringify({ - ...mockExperimentConfig, - typedOverrides: { - '1b50f33aef8f681a13f623963da967ed': 'control', - }, - }), - ); + const entry = { + ...mockExperimentConfig, + enabled: false, + overrides: { + '1b50f33aef8f681a13f623963da967ed': 'override', + }, + typedOverrides: { + '1b50f33aef8f681a13f623963da967ed': 'override', + }, + }; + + storage.setEntries({ [flagKey]: entry }); + const client = new EppoClient(storage); + const mockLogger = td.object(); + client.setLogger(mockLogger); + const assignment = client.getAssignment('subject-10', flagKey); - expect(assignment).toEqual('control'); + 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': 'control', + '1b50f33aef8f681a13f623963da967ed': 'override', }, }; storage.setEntries({ [flagKey]: entry }); const client = new EppoClient(storage); + const mockLogger = td.object(); + client.setLogger(mockLogger); const assignment = client.getAssignment('subject-10', flagKey); - expect(assignment).toEqual('control'); + expect(assignment).toEqual('override'); + expect(td.explain(mockLogger.logAssignment).callCount).toEqual(0); }); - it('logs variation assignment', () => { + it('logs variation assignment and experiment key', () => { const mockLogger = td.object(); storage.setEntries({ [flagKey]: mockExperimentConfig }); @@ -260,6 +279,13 @@ describe('EppoClient E2E test', () => { 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}`, + ); }); it('handles logging exception', () => { @@ -326,6 +352,12 @@ describe('EppoClient E2E test', () => { if (sa === null) return null; return EppoValue.String(sa); } + case ValueTestType.JSONType: { + const sa = globalClient.getJSONStringAssignment(subjectKey, experiment); + const oa = globalClient.getParsedJSONAssignment(subjectKey, experiment); + if (oa == null || sa === null) return null; + return EppoValue.JSON(sa, oa); + } } }); } @@ -368,6 +400,20 @@ describe('EppoClient E2E test', () => { 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); + } } }); } @@ -390,6 +436,9 @@ describe('EppoClient E2E test', () => { }); it('overrides returned assignment', async () => { + const mockLogger = td.object(); + client.setLogger(mockLogger); + td.reset(); const variation = await client.getAssignment( 'subject-identifer', flagKey, @@ -412,9 +461,13 @@ describe('EppoClient E2E test', () => { ); 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(); + client.setLogger(mockLogger); + td.reset(); const variation = await client.getAssignment( 'subject-identifer', flagKey, @@ -436,6 +489,7 @@ describe('EppoClient E2E test', () => { ); expect(variation).not.toEqual(null); + expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); }); }); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index d678e11..101c999 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -69,7 +69,7 @@ export interface IEppoClient { assignmentHooks?: IAssignmentHooks, ): number | null; - getJSONAssignment( + getJSONStringAssignment( subjectKey: string, flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -84,6 +84,7 @@ export default class EppoClient implements IEppoClient { constructor(private configurationStore: IConfigurationStore) {} + // @deprecated getAssignment is deprecated in favor of the typed getAssignment methods public getAssignment( subjectKey: string, flagKey: string, @@ -92,13 +93,8 @@ export default class EppoClient implements IEppoClient { assignmentHooks?: IAssignmentHooks | undefined, ): string | null { return ( - this.getAssignmentVariation( - subjectKey, - flagKey, - subjectAttributes, - assignmentHooks, - ValueType.StringType, - )?.stringValue ?? null + this.getAssignmentVariation(subjectKey, flagKey, subjectAttributes, assignmentHooks) + .stringValue ?? null ); } @@ -115,7 +111,7 @@ export default class EppoClient implements IEppoClient { subjectAttributes, assignmentHooks, ValueType.StringType, - )?.stringValue ?? null + ).stringValue ?? null ); } @@ -132,7 +128,7 @@ export default class EppoClient implements IEppoClient { subjectAttributes, assignmentHooks, ValueType.BoolType, - )?.boolValue ?? null + ).boolValue ?? null ); } @@ -149,11 +145,11 @@ export default class EppoClient implements IEppoClient { subjectAttributes, assignmentHooks, ValueType.NumericType, - )?.numericValue ?? null + ).numericValue ?? null ); } - public getJSONAssignment( + public getJSONStringAssignment( subjectKey: string, flagKey: string, subjectAttributes: Record = {}, @@ -165,8 +161,25 @@ export default class EppoClient implements IEppoClient { flagKey, subjectAttributes, assignmentHooks, - ValueType.StringType, - )?.stringValue ?? null + ValueType.JSONType, + ).stringValue ?? null + ); + } + + public getParsedJSONAssignment( + subjectKey: string, + flagKey: string, + subjectAttributes: Record = {}, + assignmentHooks?: IAssignmentHooks | undefined, + ): object | null { + return ( + this.getAssignmentVariation( + subjectKey, + flagKey, + subjectAttributes, + assignmentHooks, + ValueType.JSONType, + ).objectValue ?? null ); } @@ -175,8 +188,8 @@ export default class EppoClient implements IEppoClient { flagKey: string, subjectAttributes: Record = {}, assignmentHooks: IAssignmentHooks | undefined, - valueType: ValueType, - ): EppoValue | null { + valueType?: ValueType, + ): EppoValue { const { allocationKey, assignment } = this.getAssignmentInternal( subjectKey, flagKey, @@ -197,7 +210,7 @@ export default class EppoClient implements IEppoClient { flagKey: string, subjectAttributes = {}, assignmentHooks: IAssignmentHooks | undefined, - valueType: ValueType, + expectedValueType?: ValueType, ): { allocationKey: string | null; assignment: EppoValue } { validateNotBlank(subjectKey, 'Invalid argument: subjectKey cannot be blank'); validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank'); @@ -208,11 +221,13 @@ export default class EppoClient implements IEppoClient { const allowListOverride = this.getSubjectVariationOverride( subjectKey, experimentConfig, - valueType, + expectedValueType, ); - if (allowListOverride) { - if (!allowListOverride.isExpectedType()) return nullAssignment; + if (!allowListOverride.isNullType()) { + if (!allowListOverride.isExpectedType()) { + return nullAssignment; + } return { ...nullAssignment, assignment: allowListOverride }; } @@ -242,27 +257,16 @@ export default class EppoClient implements IEppoClient { const shard = getShard(`assignment-${subjectKey}-${flagKey}`, subjectShards); const assignedVariation = variations.find((variation) => isShardInRange(shard, variation.shardRange), - )?.typedValue; + ); const internalAssignment = { allocationKey: matchedRule.allocationKey, - assignment: EppoValue.Null(), + assignment: EppoValue.generateEppoValue( + expectedValueType, + assignedVariation?.value, + assignedVariation?.typedValue, + ), }; - - switch (valueType) { - case ValueType.BoolType: - internalAssignment['assignment'] = EppoValue.Bool(assignedVariation as boolean); - break; - case ValueType.NumericType: - internalAssignment['assignment'] = EppoValue.Numeric(assignedVariation as number); - break; - case ValueType.StringType: - internalAssignment['assignment'] = EppoValue.String(assignedVariation as string); - break; - default: - return nullAssignment; - } - return internalAssignment.assignment.isExpectedType() ? internalAssignment : nullAssignment; } @@ -314,25 +318,13 @@ export default class EppoClient implements IEppoClient { private getSubjectVariationOverride( subjectKey: string, experimentConfig: IExperimentConfiguration, - valueType: ValueType, - ): EppoValue | null { + expectedValueType?: ValueType, + ): EppoValue { const subjectHash = md5(subjectKey); - const overridden = + const override = experimentConfig?.overrides && experimentConfig.overrides[subjectHash]; + const typedOverride = experimentConfig?.typedOverrides && experimentConfig.typedOverrides[subjectHash]; - if (overridden) { - switch (valueType) { - case ValueType.BoolType: - return EppoValue.Bool(overridden as unknown as boolean); - case ValueType.NumericType: - return EppoValue.Numeric(overridden as unknown as number); - case ValueType.StringType: - return EppoValue.String(overridden as string); - default: - return null; - } - } - - return null; + return EppoValue.generateEppoValue(expectedValueType, override, typedOverride); } /** diff --git a/src/dto/experiment-configuration-dto.ts b/src/dto/experiment-configuration-dto.ts index 61378a6..5f806b6 100644 --- a/src/dto/experiment-configuration-dto.ts +++ b/src/dto/experiment-configuration-dto.ts @@ -5,7 +5,8 @@ export interface IExperimentConfiguration { name: string; enabled: boolean; subjectShards: number; - typedOverrides: Record; + overrides: Record; + typedOverrides: Record; allocations: Record; rules: IRule[]; } diff --git a/src/dto/variation-dto.ts b/src/dto/variation-dto.ts index ef9a37e..63954e0 100644 --- a/src/dto/variation-dto.ts +++ b/src/dto/variation-dto.ts @@ -7,6 +7,7 @@ export interface IShardRange { export interface IVariation { name: string; + value: string; typedValue: IValue; shardRange: IShardRange; } diff --git a/src/eppo_value.ts b/src/eppo_value.ts index 9ad3026..2b2c045 100644 --- a/src/eppo_value.ts +++ b/src/eppo_value.ts @@ -3,6 +3,7 @@ export enum ValueType { BoolType, NumericType, StringType, + JSONType, } export type IValue = boolean | number | string | undefined; @@ -12,17 +13,42 @@ export class EppoValue { public boolValue: boolean | undefined; public numericValue: number | undefined; public stringValue: string | undefined; + public objectValue: object | undefined; private constructor( valueType: ValueType, boolValue: boolean | undefined, numericValue: number | undefined, stringValue: string | undefined, + objectValue: object | undefined, ) { this.valueType = valueType; this.boolValue = boolValue; this.numericValue = numericValue; this.stringValue = stringValue; + this.objectValue = objectValue; + } + + static generateEppoValue( + expectedValueType?: ValueType, + value?: string, + typedValue?: boolean | number | string | object, + ): 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); + default: + return EppoValue.String(value as string); + } + } + return EppoValue.Null(); } toString(): string { @@ -35,6 +61,12 @@ export class EppoValue { return this.numericValue ? this.numericValue.toString() : '0'; case ValueType.StringType: return this.stringValue ?? ''; + case ValueType.JSONType: + try { + return JSON.stringify(this.objectValue) ?? ''; + } catch { + return this.stringValue ?? ''; + } } } @@ -46,8 +78,18 @@ export class EppoValue { return typeof this.numericValue === 'number'; case ValueType.StringType: return typeof this.stringValue === 'string'; - default: - return true; + 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: + return false; } } @@ -56,18 +98,22 @@ export class EppoValue { } static Bool(value: boolean): EppoValue { - return new EppoValue(ValueType.BoolType, value, undefined, undefined); + return new EppoValue(ValueType.BoolType, value, undefined, undefined, undefined); } static Numeric(value: number): EppoValue { - return new EppoValue(ValueType.NumericType, undefined, value, undefined); + return new EppoValue(ValueType.NumericType, undefined, value, undefined, undefined); } static String(value: string): EppoValue { - return new EppoValue(ValueType.StringType, undefined, undefined, value); + return new EppoValue(ValueType.StringType, undefined, undefined, value, undefined); + } + + static JSON(value: string, typedValue: object): EppoValue { + return new EppoValue(ValueType.JSONType, undefined, undefined, value, typedValue); } static Null(): EppoValue { - return new EppoValue(ValueType.NullType, undefined, undefined, undefined); + return new EppoValue(ValueType.NullType, undefined, undefined, undefined, undefined); } } diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 7c687b9..719e17b 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -12,6 +12,7 @@ export enum ValueTestType { BoolType = 'boolean', NumericType = 'numeric', StringType = 'string', + JSONType = 'json', } export interface IAssignmentTestCase {