Skip to content

Commit

Permalink
fix: fixed issue with nested fractional evaluations (#686)
Browse files Browse the repository at this point in the history
Signed-off-by: Michael Beemer <[email protected]>
  • Loading branch information
beeme1mr authored Dec 13, 2023
1 parent f8a0dfc commit e0dbfdb
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 114 deletions.
35 changes: 35 additions & 0 deletions libs/shared/flagd-core/src/lib/flagd-core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,38 @@ describe('flagd-core validations', () => {
expect(() => core.resolveNumberEvaluation('myIntFlag', 100, {}, console)).toThrow(GeneralError);
});
});

describe('flagd-core common flag definitions', () => {
it('should support boolean variant shorthand', () => {
const core = new FlagdCore();
const flagCfg = `{"flags":{"myBoolFlag":{"state":"ENABLED","variants":{"true":true,"false":false},"defaultVariant":"false", "targeting":{"in":["@openfeature.dev",{"var":"email"}]}}}}`;
core.setConfigurations(flagCfg);

const resolved = core.resolveBooleanEvaluation('myBoolFlag', false, { email: '[email protected]' }, console);
expect(resolved.value).toBe(true);
expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH);
expect(resolved.variant).toBe('true');
});

it('should support fractional logic', () => {
const core = new FlagdCore();
const flagCfg = `{"flags":{"headerColor":{"state":"ENABLED","variants":{"red":"red","blue":"blue","grey":"grey"},"defaultVariant":"grey", "targeting":{"fractional":[{"var":"email"},["red",50],["blue",50]]}}}}`;
core.setConfigurations(flagCfg);

const resolved = core.resolveStringEvaluation('headerColor', 'grey', { email: '[email protected]' }, console);
expect(resolved.value).toBe('red');
expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH);
expect(resolved.variant).toBe('red');
});

it('should support nested fractional logic', () => {
const core = new FlagdCore();
const flagCfg = `{"flags":{"headerColor":{"state":"ENABLED","variants":{"red":"red","blue":"blue","grey":"grey"},"defaultVariant":"grey", "targeting":{"if":[true,{"fractional":[{"var":"email"},["red",50],["blue",50]]}]}}}}`;
core.setConfigurations(flagCfg);

const resolved = core.resolveStringEvaluation('headerColor', 'grey', { email: '[email protected]' }, console);
expect(resolved.value).toBe('red');
expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH);
expect(resolved.variant).toBe('red');
});
});
18 changes: 10 additions & 8 deletions libs/shared/flagd-core/src/lib/targeting/fractional.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import { EvaluationContext } from '@openfeature/core';
import { flagdPropertyKey, flagKeyPropertyKey, targetingPropertyKey } from './common';
import MurmurHash3 from 'imurmurhash';
import { flagKeyPropertyKey, flagdPropertyKey, targetingPropertyKey } from './common';

export const fractionalRule = 'fractional';

// we put the context at the first index of the arg array in this rule
export function fractional(context: EvaluationContext, ...args: unknown[]): string | null {
if (typeof context !== 'object') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function fractional(data: unknown, context: Record<any, any>): string | null {
if (!Array.isArray(data)) {
return null;
}

const args = Array.from(data);
if (args.length < 2) {
console.error('Invalid targeting rule. Require at least two buckets.');
return null;
}
const flagdProperties = context[flagdPropertyKey] as { [flagKeyPropertyKey]: string };
if (!flagdProperties || !flagdProperties[flagKeyPropertyKey]) {

const flagdProperties = context[flagdPropertyKey];
if (!flagdProperties) {
return null;
}

let bucketBy: string | undefined;
let bucketBy: string;
let buckets: unknown[];

if (typeof args[0] == 'string') {
Expand Down
15 changes: 9 additions & 6 deletions libs/shared/flagd-core/src/lib/targeting/sem-ver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ import { compare, parse } from 'semver';

export const semVerRule = 'sem_ver';

export function semVer(...args: unknown[]): boolean {
if (args.length != 3) {
export function semVer(data: unknown): boolean {
if (!Array.isArray(data)) {
return false;
}

const semVertString1 = typeof args[0] === 'string' ? args[0] : undefined;
const semVertString2 = typeof args[2] === 'string' ? args[2] : undefined;
const args = Array.from(data);

if (args.length != 3) {
return false;
}

const semVer1 = parse(semVertString1);
const semVer2 = parse(semVertString2);
const semVer1 = parse(args[0]);
const semVer2 = parse(args[2]);

if (!semVer1 || !semVer2) {
return false;
Expand Down
20 changes: 10 additions & 10 deletions libs/shared/flagd-core/src/lib/targeting/string-comp.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
export const startsWithRule = 'starts_with';
export const endsWithRule = 'ends_with';

export function startsWithHandler(...args: unknown[]) {
return compare(startsWithRule, args);
export function startsWithHandler(data: unknown) {
return compare(startsWithRule, data);
}

export function endsWithHandler(...args: unknown[]) {
return compare(endsWithRule, args);
export function endsWithHandler(data: unknown) {
return compare(endsWithRule, data);
}

function compare(method: string, args: unknown[]): boolean {
if (!Array.isArray(args)) {
function compare(method: string, data: unknown): boolean {
if (!Array.isArray(data)) {
return false;
}

if (args.length != 2) {
if (data.length != 2) {
return false;
}

if (typeof args[0] !== 'string' || typeof args[1] !== 'string') {
if (typeof data[0] !== 'string' || typeof data[1] !== 'string') {
return false;
}

switch (method) {
case startsWithRule:
return args[0].startsWith(args[1]);
return data[0].startsWith(data[1]);
case endsWithRule:
return args[0].endsWith(args[1]);
return data[0].endsWith(data[1]);
default:
return false;
}
Expand Down
29 changes: 15 additions & 14 deletions libs/shared/flagd-core/src/lib/targeting/targeting.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { LogicEngine } from 'json-logic-engine';
import { endsWithHandler, endsWithRule, startsWithHandler, startsWithRule } from './string-comp';
import { semVer, semVerRule } from './sem-ver';
import { fractional, fractionalRule } from './fractional';
import { flagdPropertyKey, flagKeyPropertyKey, timestampPropertyKey } from './common';
import * as jsonLogic from 'json-logic-js';

jsonLogic.add_operation(startsWithRule, startsWithHandler);
jsonLogic.add_operation(endsWithRule, endsWithHandler);
jsonLogic.add_operation(semVerRule, semVer);
jsonLogic.add_operation(fractionalRule, fractional);

export class Targeting {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
applyTargeting(flagKey: string, logic: { [key: string]: any }, data: object): unknown {
private readonly _logicEngine: LogicEngine;

constructor() {
const engine = new LogicEngine();
engine.addMethod(startsWithRule, startsWithHandler);
engine.addMethod(endsWithRule, endsWithHandler);
engine.addMethod(semVerRule, semVer);
engine.addMethod(fractionalRule, fractional);

this._logicEngine = engine;
}

applyTargeting(flagKey: string, logic: unknown, data: object): unknown {
if (Object.hasOwn(data, flagdPropertyKey)) {
console.warn(`overwriting ${flagdPropertyKey} property in the context`);
}
Expand All @@ -24,11 +30,6 @@ export class Targeting {
},
};

// we need access to the context/$flagd object in the "fractional" rule, so set it at arg zero if it's not there
if (logic[fractionalRule] && !logic[fractionalRule]?.[0]?.[flagdPropertyKey]) {
logic[fractionalRule] = [ctxData, ...logic[fractionalRule]];
}

return jsonLogic.apply(logic, ctxData);
return this._logicEngine.run(logic, ctxData);
}
}
Loading

0 comments on commit e0dbfdb

Please sign in to comment.