diff --git a/.gitmodules b/.gitmodules index e480e55ef..5eea3f02d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,9 @@ [submodule "libs/providers/flagd/schemas"] path = libs/providers/flagd/schemas - url = https://github.com/open-feature/schemas.git + url = https://github.com/open-feature/flagd-schemas.git [submodule "libs/providers/flagd-web/schemas"] path = libs/providers/flagd-web/schemas - url = https://github.com/open-feature/schemas + url = https://github.com/open-feature/flagd-schemas.git [submodule "libs/providers/flagd/spec"] path = libs/providers/flagd/spec url = https://github.com/open-feature/spec.git diff --git a/libs/shared/flagd-core/flagd-schemas b/libs/shared/flagd-core/flagd-schemas index b81a56eea..37baa2cde 160000 --- a/libs/shared/flagd-core/flagd-schemas +++ b/libs/shared/flagd-core/flagd-schemas @@ -1 +1 @@ -Subproject commit b81a56eea3b2c4c543a50d4f7f79a8f32592a0af +Subproject commit 37baa2cdea48a5ac614ba3e718b7d02ad4120611 diff --git a/libs/shared/flagd-core/package.json b/libs/shared/flagd-core/package.json index 4a35f6a03..7dc9bdfd5 100644 --- a/libs/shared/flagd-core/package.json +++ b/libs/shared/flagd-core/package.json @@ -1,15 +1,16 @@ { "name": "@openfeature/flagd-core", "version": "0.2.5", + "license": "Apache-2.0", "scripts": { "publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi", "current-version": "echo $npm_package_version" }, "peerDependencies": { - "@openfeature/core": ">=0.0.16" + "@openfeature/core": ">=1.6.0" }, "dependencies": { "ajv": "^8.12.0", "tslib": "^2.3.0" } -} +} \ No newline at end of file diff --git a/libs/shared/flagd-core/src/lib/feature-flag.spec.ts b/libs/shared/flagd-core/src/lib/feature-flag.spec.ts index db3d60955..fb185e24e 100644 --- a/libs/shared/flagd-core/src/lib/feature-flag.spec.ts +++ b/libs/shared/flagd-core/src/lib/feature-flag.spec.ts @@ -1,5 +1,13 @@ +import type { Logger } from '@openfeature/core'; import { FeatureFlag, Flag } from './feature-flag'; +const logger: Logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +}; + describe('Flagd flag structure', () => { it('should be constructed with valid input - boolean', () => { const input: Flag = { @@ -12,16 +20,35 @@ describe('Flagd flag structure', () => { targeting: '', }; - const ff = new FeatureFlag(input); + const ff = new FeatureFlag('test', input, logger); expect(ff).toBeTruthy(); expect(ff.state).toBe('ENABLED'); expect(ff.defaultVariant).toBe('off'); - expect(ff.targeting).toBe(''); expect(ff.variants.get('on')).toBeTruthy(); expect(ff.variants.get('off')).toBeFalsy(); }); + it('should be constructed with valid input - string', () => { + const input: Flag = { + state: 'ENABLED', + defaultVariant: 'off', + variants: { + on: 'on', + off: 'off', + }, + targeting: '', + }; + + const ff = new FeatureFlag('test', input, logger); + + expect(ff).toBeTruthy(); + expect(ff.state).toBe('ENABLED'); + expect(ff.defaultVariant).toBe('off'); + expect(ff.variants.get('on')).toBe('on'); + expect(ff.variants.get('off')).toBe('off'); + }); + it('should be constructed with valid input - number', () => { const input: Flag = { state: 'ENABLED', @@ -33,12 +60,11 @@ describe('Flagd flag structure', () => { targeting: '', }; - const ff = new FeatureFlag(input); + const ff = new FeatureFlag('test', input, logger); expect(ff).toBeTruthy(); expect(ff.state).toBe('ENABLED'); expect(ff.defaultVariant).toBe('one'); - expect(ff.targeting).toBe(''); expect(ff.variants.get('one')).toBe(1.0); expect(ff.variants.get('two')).toBe(2.0); }); @@ -60,12 +86,11 @@ describe('Flagd flag structure', () => { targeting: '', }; - const ff = new FeatureFlag(input); + const ff = new FeatureFlag('test', input, logger); expect(ff).toBeTruthy(); expect(ff.state).toBe('ENABLED'); expect(ff.defaultVariant).toBe('pi2'); - expect(ff.targeting).toBe(''); expect(ff.variants.get('pi2')).toStrictEqual({ value: 3.14, accuracy: 2 }); expect(ff.variants.get('pi5')).toStrictEqual({ value: 3.14159, accuracy: 5 }); }); diff --git a/libs/shared/flagd-core/src/lib/feature-flag.ts b/libs/shared/flagd-core/src/lib/feature-flag.ts index a8f93e865..b5c724c54 100644 --- a/libs/shared/flagd-core/src/lib/feature-flag.ts +++ b/libs/shared/flagd-core/src/lib/feature-flag.ts @@ -1,5 +1,15 @@ -import { FlagValue, ParseError } from '@openfeature/core'; +import type { + FlagValue, + FlagMetadata, + ResolutionDetails, + JsonValue, + Logger, + EvaluationContext, + ResolutionReason, +} from '@openfeature/core'; +import { ParseError, StandardResolutionReasons, ErrorCode } from '@openfeature/core'; import { sha1 } from 'object-hash'; +import { Targeting } from './targeting/targeting'; /** * Flagd flag configuration structure mapping to schema definition. @@ -9,27 +19,68 @@ export interface Flag { defaultVariant: string; variants: { [key: string]: FlagValue }; targeting?: string; + metadata?: FlagMetadata; } +type RequiredResolutionDetails = Omit, 'value'> & { + flagMetadata: FlagMetadata; +} & ( + | { + reason: 'ERROR'; + errorCode: ErrorCode; + errorMessage: string; + value?: never; + } + | { + value: T; + variant: string; + errorCode?: never; + errorMessage?: never; + } + ); + /** * Flagd flag configuration structure for internal reference. */ export class FeatureFlag { + private readonly _key: string; private readonly _state: 'ENABLED' | 'DISABLED'; private readonly _defaultVariant: string; private readonly _variants: Map; - private readonly _targeting: unknown; private readonly _hash: string; + private readonly _metadata: FlagMetadata; + private readonly _targeting?: Targeting; + private readonly _targetingParseErrorMessage?: string; - constructor(flag: Flag) { + constructor( + key: string, + flag: Flag, + private readonly logger: Logger, + ) { + this._key = key; this._state = flag['state']; this._defaultVariant = flag['defaultVariant']; this._variants = new Map(Object.entries(flag['variants'])); - this._targeting = flag['targeting']; + this._metadata = flag['metadata'] ?? {}; + + if (flag.targeting && Object.keys(flag.targeting).length > 0) { + try { + this._targeting = new Targeting(flag.targeting, logger); + } catch (err) { + const message = `Invalid targeting configuration for flag '${key}'`; + this.logger.warn(message); + this._targetingParseErrorMessage = message; + } + } this._hash = sha1(flag); + this.validateStructure(); } + get key(): string { + return this._key; + } + get hash(): string { return this._hash; } @@ -42,14 +93,73 @@ export class FeatureFlag { return this._defaultVariant; } - get targeting(): unknown { - return this._targeting; - } - get variants(): Map { return this._variants; } + get metadata(): FlagMetadata { + return this._metadata; + } + + evaluate(evalCtx: EvaluationContext, logger: Logger = this.logger): RequiredResolutionDetails { + let variant: string; + let reason: ResolutionReason; + + if (this._targetingParseErrorMessage) { + return { + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.PARSE_ERROR, + errorMessage: this._targetingParseErrorMessage, + flagMetadata: this.metadata, + }; + } + + if (!this._targeting) { + variant = this._defaultVariant; + reason = StandardResolutionReasons.STATIC; + } else { + let targetingResolution: JsonValue; + try { + targetingResolution = this._targeting.evaluate(this._key, evalCtx, logger); + } catch (e) { + logger.debug(`Error evaluating targeting rule for flag '${this._key}': ${(e as Error).message}`); + return { + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.GENERAL, + errorMessage: `Error evaluating targeting rule for flag '${this._key}'`, + flagMetadata: this.metadata, + }; + } + + // Return default variant if targeting resolution is null or undefined + if (targetingResolution === null || targetingResolution === undefined) { + variant = this._defaultVariant; + reason = StandardResolutionReasons.DEFAULT; + } else { + // Obtain resolution in string. This is useful for short-circuiting json logic + variant = targetingResolution.toString(); + reason = StandardResolutionReasons.TARGETING_MATCH; + } + } + + const resolvedValue = this._variants.get(variant); + if (resolvedValue === undefined) { + return { + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.GENERAL, + errorMessage: `Variant '${variant}' not found in flag with key '${this._key}'`, + flagMetadata: this.metadata, + }; + } + + return { + value: resolvedValue, + reason, + variant, + flagMetadata: this.metadata, + }; + } + validateStructure() { // basic validation, ideally this sort of thing is caught by IDEs and other schema validation before we get here // consistent with Java/Go and other implementations, we only warn for schema validation, but we fail for this sort of basic structural errors diff --git a/libs/shared/flagd-core/src/lib/flagd-core.spec.ts b/libs/shared/flagd-core/src/lib/flagd-core.spec.ts index ea80e8362..5c4851ec3 100644 --- a/libs/shared/flagd-core/src/lib/flagd-core.spec.ts +++ b/libs/shared/flagd-core/src/lib/flagd-core.spec.ts @@ -1,5 +1,14 @@ -import { FlagNotFoundError, ParseError, StandardResolutionReasons, TypeMismatchError } from '@openfeature/core'; +import { ErrorCode, ParseError, StandardResolutionReasons } from '@openfeature/core'; +import type { Logger } from '@openfeature/core'; import { FlagdCore } from './flagd-core'; +import { FeatureFlag } from './feature-flag'; + +const logger: Logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +}; describe('flagd-core resolving', () => { describe('truthy variant values', () => { @@ -12,35 +21,35 @@ describe('flagd-core resolving', () => { }); it('should resolve boolean flag', () => { - const resolved = core.resolveBooleanEvaluation('myBoolFlag', false, {}, console); + const resolved = core.resolveBooleanEvaluation('myBoolFlag', false, {}); expect(resolved.value).toBe(true); expect(resolved.reason).toBe(StandardResolutionReasons.STATIC); expect(resolved.variant).toBe('on'); }); it('should resolve string flag', () => { - const resolved = core.resolveStringEvaluation('myStringFlag', 'key2', {}, console); + const resolved = core.resolveStringEvaluation('myStringFlag', 'key2', {}); expect(resolved.value).toBe('val1'); expect(resolved.reason).toBe(StandardResolutionReasons.STATIC); expect(resolved.variant).toBe('key1'); }); it('should resolve number flag', () => { - const resolved = core.resolveNumberEvaluation('myFloatFlag', 2.34, {}, console); + const resolved = core.resolveNumberEvaluation('myFloatFlag', 2.34, {}); expect(resolved.value).toBe(1.23); expect(resolved.reason).toBe(StandardResolutionReasons.STATIC); expect(resolved.variant).toBe('one'); }); it('should resolve object flag', () => { - const resolved = core.resolveObjectEvaluation('myObjectFlag', { key: true }, {}, console); + const resolved = core.resolveObjectEvaluation('myObjectFlag', { key: true }, {}); expect(resolved.value).toStrictEqual({ key: 'val' }); expect(resolved.reason).toBe(StandardResolutionReasons.STATIC); expect(resolved.variant).toBe('object1'); }); it('should resolve all flags', () => { - const resolved = core.resolveAll({}, console); + const resolved = core.resolveAll({}); expect(resolved).toMatchSnapshot(); }); }); @@ -55,35 +64,35 @@ describe('flagd-core resolving', () => { }); it('should resolve boolean flag', () => { - const resolved = core.resolveBooleanEvaluation('myBoolFlag', false, {}, console); + const resolved = core.resolveBooleanEvaluation('myBoolFlag', false, {}); expect(resolved.value).toBe(false); expect(resolved.reason).toBe(StandardResolutionReasons.STATIC); expect(resolved.variant).toBe('off'); }); it('should resolve string flag', () => { - const resolved = core.resolveStringEvaluation('myStringFlag', 'key2', {}, console); + const resolved = core.resolveStringEvaluation('myStringFlag', 'key2', {}); expect(resolved.value).toBe(''); expect(resolved.reason).toBe(StandardResolutionReasons.STATIC); expect(resolved.variant).toBe('key1'); }); it('should resolve number flag', () => { - const resolved = core.resolveNumberEvaluation('myFloatFlag', 2.34, {}, console); + const resolved = core.resolveNumberEvaluation('myFloatFlag', 2.34, {}); expect(resolved.value).toBe(0); expect(resolved.reason).toBe(StandardResolutionReasons.STATIC); expect(resolved.variant).toBe('zero'); }); it('should resolve object flag', () => { - const resolved = core.resolveObjectEvaluation('myObjectFlag', { key: true }, {}, console); + const resolved = core.resolveObjectEvaluation('myObjectFlag', { key: true }, {}); expect(resolved.value).toStrictEqual({}); expect(resolved.reason).toBe(StandardResolutionReasons.STATIC); expect(resolved.variant).toBe('object1'); }); it('should resolve all flags', () => { - const resolved = core.resolveAll({}, console); + const resolved = core.resolveAll({}); expect(resolved).toMatchSnapshot(); }); }); @@ -100,21 +109,21 @@ describe('flagd-core targeting evaluations', () => { }); it('should resolve for correct inputs', () => { - const resolved = core.resolveStringEvaluation('targetedFlag', 'none', { email: 'admin@openfeature.dev' }, console); + const resolved = core.resolveStringEvaluation('targetedFlag', 'none', { email: 'admin@openfeature.dev' }); expect(resolved.value).toBe('BBB'); expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH); expect(resolved.variant).toBe('second'); }); it('should fallback to default - missing targeting context data', () => { - const resolved = core.resolveStringEvaluation('targetedFlag', 'none', {}, console); + const resolved = core.resolveStringEvaluation('targetedFlag', 'none', {}); expect(resolved.value).toBe('AAA'); expect(resolved.reason).toBe(StandardResolutionReasons.DEFAULT); expect(resolved.variant).toBe('first'); }); it('should handle short circuit fallbacks', () => { - const resolved = core.resolveBooleanEvaluation('shortCircuit', false, { favoriteNumber: 1 }, console); + const resolved = core.resolveBooleanEvaluation('shortCircuit', false, { favoriteNumber: 1 }); expect(resolved.value).toBe(true); expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH); expect(resolved.variant).toBe('true'); @@ -127,12 +136,7 @@ describe('flagd-core targeting evaluations', () => { const core = new FlagdCore(); core.setConfigurations(caseVariantValueFlag); - const evaluation = core.resolveBooleanEvaluation( - 'new-welcome-banner', - false, - { email: 'test@example.com' }, - console, - ); + const evaluation = core.resolveBooleanEvaluation('new-welcome-banner', false, { email: 'test@example.com' }); expect(evaluation.value).toBe(true); expect(evaluation.variant).toBe('true'); expect(evaluation.reason).toBe(StandardResolutionReasons.TARGETING_MATCH); @@ -142,7 +146,7 @@ describe('flagd-core targeting evaluations', () => { describe('flagd-core validations', () => { // flags of disabled, invalid variants and missing variant const mixFlags = - '{"flags":{"myBoolFlag":{"state":"DISABLED","variants":{"on":true,"off":false},"defaultVariant":"on"},"myStringFlag":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"}}}'; + '{"flags":{"disabledFlag":{"state":"DISABLED","variants":{"on":true,"off":false},"defaultVariant":"on"},"myStringFlag":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"}}}'; let core: FlagdCore; beforeAll(() => { @@ -150,26 +154,35 @@ describe('flagd-core validations', () => { core.setConfigurations(mixFlags); }); - it('should throw because the flag does not exist', () => { + it('should return reason "error" because the flag does not exist', () => { const flagKey = 'nonexistentFlagKey'; - expect(() => core.resolveBooleanEvaluation(flagKey, false, {}, console)).toThrow( - new FlagNotFoundError(`flag: '${flagKey}' not found`), - ); + const evaluation = core.resolveBooleanEvaluation(flagKey, false, {}); + expect(evaluation.reason).toBe(StandardResolutionReasons.ERROR); + expect(evaluation.errorCode).toBe(ErrorCode.FLAG_NOT_FOUND); + expect(evaluation.errorMessage).toBe(`flag '${flagKey}' not found`); + expect(evaluation.value).toBe(false); + expect(evaluation.variant).toBeUndefined(); }); - it('should throw because the flag is disabled and should behave like it does not exist', () => { - const flagKey = 'myBoolFlag'; - expect(() => core.resolveBooleanEvaluation(flagKey, false, {}, console)).toThrow( - new FlagNotFoundError(`flag: '${flagKey}' is disabled`), - ); + it('should return reason "error" because the flag is disabled', () => { + const flagKey = 'disabledFlag'; + const evaluation = core.resolveBooleanEvaluation(flagKey, false, {}); + expect(evaluation.reason).toBe(StandardResolutionReasons.ERROR); + expect(evaluation.errorCode).toBe(ErrorCode.FLAG_NOT_FOUND); + expect(evaluation.errorMessage).toBe(`flag '${flagKey}' is disabled`); + expect(evaluation.value).toBe(false); + expect(evaluation.variant).toBeUndefined(); }); it('should validate variant', () => { - expect(() => core.resolveStringEvaluation('myStringFlag', 'hello', {}, console)).toThrow(TypeMismatchError); + const evaluation = core.resolveStringEvaluation('myStringFlag', 'hello', {}); + expect(evaluation.value).toBe('hello'); + expect(evaluation.reason).toBe(StandardResolutionReasons.ERROR); + expect(evaluation.errorCode).toBe(ErrorCode.TYPE_MISMATCH); }); it('should only resolve enabled flags', () => { - const resolved = core.resolveAll({}, console); + const resolved = core.resolveAll({}); expect(resolved).toHaveLength(1); expect(resolved[0]).toHaveProperty('flagKey', 'myStringFlag'); }); @@ -182,19 +195,14 @@ describe('flagd-core common flag definitions', () => { core.setConfigurations(flagCfg); it('should support truthy values', () => { - const resolvedTruthy = core.resolveBooleanEvaluation( - 'myBoolFlag', - false, - { email: 'user@openfeature.dev' }, - console, - ); + const resolvedTruthy = core.resolveBooleanEvaluation('myBoolFlag', false, { email: 'user@openfeature.dev' }); expect(resolvedTruthy.value).toBe(true); expect(resolvedTruthy.reason).toBe(StandardResolutionReasons.TARGETING_MATCH); expect(resolvedTruthy.variant).toBe('true'); }); it('should support falsy values', () => { - const resolvedFalsy = core.resolveBooleanEvaluation('myBoolFlag', false, { email: 'user@flagd.dev' }, console); + const resolvedFalsy = core.resolveBooleanEvaluation('myBoolFlag', false, { email: 'user@flagd.dev' }); expect(resolvedFalsy.value).toBe(false); expect(resolvedFalsy.reason).toBe(StandardResolutionReasons.TARGETING_MATCH); expect(resolvedFalsy.variant).toBe('false'); @@ -206,7 +214,7 @@ describe('flagd-core common flag definitions', () => { const flagCfg = `{"flags":{"headerColor":{"state":"ENABLED","variants":{"red":"red","blue":"blue","grey":"grey"},"defaultVariant":"grey", "targeting":{"fractional":[{"cat":[{"var":"$flagd.flagKey"},{"var":"email"}]},["red",50],["blue",50]]}}}}`; core.setConfigurations(flagCfg); - const resolved = core.resolveStringEvaluation('headerColor', 'grey', { email: 'user@openfeature.dev' }, console); + const resolved = core.resolveStringEvaluation('headerColor', 'grey', { email: 'user@openfeature.dev' }); expect(resolved.value).toBe('red'); expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH); expect(resolved.variant).toBe('red'); @@ -217,7 +225,7 @@ describe('flagd-core common flag definitions', () => { const flagCfg = `{"flags":{"headerColor":{"state":"ENABLED","variants":{"red":"red","blue":"blue","grey":"grey"},"defaultVariant":"grey", "targeting":{"if":[true,{"fractional":[{"cat":[{"var":"$flagd.flagKey"},{"var":"email"}]},["red",50],["blue",50]]}]}}}}`; core.setConfigurations(flagCfg); - const resolved = core.resolveStringEvaluation('headerColor', 'grey', { email: 'user@openfeature.dev' }, console); + const resolved = core.resolveStringEvaluation('headerColor', 'grey', { email: 'user@openfeature.dev' }); expect(resolved.value).toBe('red'); expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH); expect(resolved.variant).toBe('red'); @@ -228,7 +236,7 @@ describe('flagd-core common flag definitions', () => { const flagCfg = `{"flags":{"isEnabled":{"state":"ENABLED","variants":{"true":true,"false":false},"defaultVariant":"false","targeting":{}}}}`; core.setConfigurations(flagCfg); - const resolved = core.resolveBooleanEvaluation('isEnabled', false, {}, console); + const resolved = core.resolveBooleanEvaluation('isEnabled', false, {}); expect(resolved.value).toBe(false); expect(resolved.reason).toBe(StandardResolutionReasons.STATIC); expect(resolved.variant).toBe('false'); @@ -252,3 +260,131 @@ describe('flagd-core common flag definitions', () => { expect(() => core.setConfigurations(flagCfg)).toThrow(ParseError); }); }); + +describe('flagd-core flag metadata', () => { + const targetingFlag = + '{"flags":{"targetedFlag":{"variants":{"first":"AAA","second":"BBB","third":"CCC"},"defaultVariant":"first","state":"ENABLED","targeting":{"if":[{"in":["@openfeature.dev",{"var":"email"}]},"second",null]},"metadata":{"owner": "mike"}},"shortCircuit":{"variants":{"true":true,"false":false},"defaultVariant":"false","state":"ENABLED","targeting":{"==":[{"var":"favoriteNumber"},1]}}},"metadata":{"flagSetId":"dev","version":"1"}}'; + let core: FlagdCore; + + beforeAll(() => { + core = new FlagdCore(); + core.setConfigurations(targetingFlag); + }); + + it('should return "targetedFlag" flag metadata', () => { + const resolved = core.resolveStringEvaluation('targetedFlag', 'none', { email: 'admin@openfeature.dev' }); + expect(resolved.flagMetadata).toEqual({ version: '1', flagSetId: 'dev', owner: 'mike' }); + }); + + it('should return "shortCircuit" flag metadata', () => { + const resolved = core.resolveBooleanEvaluation('shortCircuit', false, { favoriteNumber: 1 }); + expect(resolved.flagMetadata).toEqual({ version: '1', flagSetId: 'dev' }); + }); +}); + +describe('flagd-core error conditions', () => { + const errorFlags = { + flags: { + basic: { + variants: { on: true, off: false }, + defaultVariant: 'on', + state: 'ENABLED', + }, + disabledFlag: { + variants: { on: true, off: false }, + defaultVariant: 'on', + state: 'DISABLED', + }, + invalidTargetingRule: { + variants: { on: true, off: false }, + defaultVariant: 'on', + state: 'ENABLED', + targeting: { invalid: true }, + }, + invalidVariantName: { + variants: { true: true, false: false }, + defaultVariant: 'false', + state: 'ENABLED', + targeting: { if: [true, 'invalid'] }, + }, + }, + metadata: { flagSetId: 'dev', version: '1' }, + }; + let core: FlagdCore; + + beforeAll(() => { + core = new FlagdCore(); + core.setConfigurations(JSON.stringify(errorFlags)); + }); + + it('should not find the flag', () => { + const resolved = core.resolveBooleanEvaluation('invalid', false, {}); + expect(resolved.reason).toBe(StandardResolutionReasons.ERROR); + expect(resolved.errorCode).toBe(ErrorCode.FLAG_NOT_FOUND); + expect(resolved.errorMessage).toBeTruthy(); + expect(resolved.flagMetadata).toEqual({ version: '1', flagSetId: 'dev' }); + }); + + it('should treat disabled flags as not found', () => { + const resolved = core.resolveBooleanEvaluation('disabledFlag', false, {}); + expect(resolved.reason).toBe(StandardResolutionReasons.ERROR); + expect(resolved.errorCode).toBe(ErrorCode.FLAG_NOT_FOUND); + expect(resolved.errorMessage).toBeTruthy(); + expect(resolved.flagMetadata).toEqual({ version: '1', flagSetId: 'dev' }); + }); + + it('should return a parse error code', () => { + const resolved = core.resolveBooleanEvaluation('invalidTargetingRule', false, {}); + expect(resolved.reason).toBe(StandardResolutionReasons.ERROR); + expect(resolved.errorCode).toBe(ErrorCode.PARSE_ERROR); + expect(resolved.errorMessage).toBeTruthy(); + expect(resolved.flagMetadata).toEqual({ version: '1', flagSetId: 'dev' }); + }); + + it('should return a general error if targeting evaluate fails', () => { + const evaluationErrorCore = new FlagdCore({ + setConfigurations: jest.fn(), + getFlag: () => { + const featureFlag = new FeatureFlag( + 'basic', + { + defaultVariant: 'off', + state: 'ENABLED', + variants: { on: true, off: false }, + metadata: { version: '1', flagSetId: 'dev' }, + }, + logger, + ); + /* eslint-disable @typescript-eslint/no-explicit-any */ + (featureFlag as any)['_targeting'] = () => { + throw new Error('something broke'); + }; + return featureFlag; + }, + getFlags: jest.fn(), + getFlagSetMetadata: jest.fn(), + }); + + const resolved = evaluationErrorCore.resolveBooleanEvaluation('basic', false, {}); + expect(resolved.reason).toBe(StandardResolutionReasons.ERROR); + expect(resolved.errorCode).toBe(ErrorCode.GENERAL); + expect(resolved.errorMessage).toBeTruthy(); + expect(resolved.flagMetadata).toEqual({ version: '1', flagSetId: 'dev' }); + }); + + it('should return a general error if the variant is not a string', () => { + const resolved = core.resolveBooleanEvaluation('invalidVariantName', false, {}); + expect(resolved.reason).toBe(StandardResolutionReasons.ERROR); + expect(resolved.errorCode).toBe(ErrorCode.GENERAL); + expect(resolved.errorMessage).toBeTruthy(); + expect(resolved.flagMetadata).toEqual({ version: '1', flagSetId: 'dev' }); + }); + + it('should return a type mismatch error', () => { + const resolved = core.resolveStringEvaluation('basic', 'false', {}); + expect(resolved.reason).toBe(StandardResolutionReasons.ERROR); + expect(resolved.errorCode).toBe(ErrorCode.TYPE_MISMATCH); + expect(resolved.errorMessage).toBeTruthy(); + expect(resolved.flagMetadata).toEqual({ version: '1', flagSetId: 'dev' }); + }); +}); diff --git a/libs/shared/flagd-core/src/lib/flagd-core.ts b/libs/shared/flagd-core/src/lib/flagd-core.ts index 7136f2514..6f65fe203 100644 --- a/libs/shared/flagd-core/src/lib/flagd-core.ts +++ b/libs/shared/flagd-core/src/lib/flagd-core.ts @@ -1,21 +1,18 @@ -import { MemoryStorage, Storage } from './storage'; -import { - EvaluationContext, - FlagNotFoundError, - FlagValue, - GeneralError, - JsonValue, - FlagValueType, +import { DefaultLogger, ErrorCode, SafeLogger, StandardResolutionReasons } from '@openfeature/core'; +import type { ResolutionDetails, - StandardResolutionReasons, - TypeMismatchError, Logger, - SafeLogger, - DefaultLogger, + EvaluationContext, EvaluationDetails, + FlagValue, + FlagValueType, + JsonValue, + FlagMetadata, } from '@openfeature/core'; -import { Targeting } from './targeting/targeting'; import { FeatureFlag } from './feature-flag'; +import { MemoryStorage, Storage } from './storage'; + +type ResolutionDetailsWithFlagMetadata = Required, 'flagMetadata'>> & ResolutionDetails; /** * Expose flag configuration setter and flag resolving methods. @@ -23,26 +20,14 @@ import { FeatureFlag } from './feature-flag'; export class FlagdCore implements Storage { private _logger: Logger; private _storage: Storage; - private _targeting: Targeting; constructor(storage?: Storage, logger?: Logger) { - this._storage = storage ? storage : new MemoryStorage(logger); this._logger = logger ? new SafeLogger(logger) : new DefaultLogger(); - this._targeting = new Targeting(this._logger); + this._storage = storage ? storage : new MemoryStorage(this._logger); } - /** - * Sets the logger for the FlagdCore instance. - * @param logger - The logger to be set. - * @returns - The FlagdCore instance with the logger set. - */ - setLogger(logger: Logger) { - this._logger = new SafeLogger(logger); - return this; - } - - setConfigurations(cfg: string): string[] { - return this._storage.setConfigurations(cfg); + setConfigurations(flagConfig: string): string[] { + return this._storage.setConfigurations(flagConfig); } getFlag(key: string): FeatureFlag | undefined { @@ -53,6 +38,10 @@ export class FlagdCore implements Storage { return this._storage.getFlags(); } + getFlagSetMetadata(): FlagMetadata { + return this._storage.getFlagSetMetadata(); + } + /** * Resolve the flag evaluation to a boolean value. * @param flagKey - The key of the flag to be evaluated. @@ -65,8 +54,8 @@ export class FlagdCore implements Storage { flagKey: string, defaultValue: boolean, evalCtx?: EvaluationContext, - logger?: Logger, - ): ResolutionDetails { + logger: Logger = this._logger, + ): ResolutionDetailsWithFlagMetadata { return this.resolve('boolean', flagKey, defaultValue, evalCtx, logger); } @@ -82,8 +71,8 @@ export class FlagdCore implements Storage { flagKey: string, defaultValue: string, evalCtx?: EvaluationContext, - logger?: Logger, - ): ResolutionDetails { + logger: Logger = this._logger, + ): ResolutionDetailsWithFlagMetadata { return this.resolve('string', flagKey, defaultValue, evalCtx, logger); } @@ -99,8 +88,8 @@ export class FlagdCore implements Storage { flagKey: string, defaultValue: number, evalCtx?: EvaluationContext, - logger?: Logger, - ): ResolutionDetails { + logger: Logger = this._logger, + ): ResolutionDetailsWithFlagMetadata { return this.resolve('number', flagKey, defaultValue, evalCtx, logger); } @@ -117,8 +106,8 @@ export class FlagdCore implements Storage { flagKey: string, defaultValue: T, evalCtx?: EvaluationContext, - logger?: Logger, - ): ResolutionDetails { + logger: Logger = this._logger, + ): ResolutionDetailsWithFlagMetadata { return this.resolve('object', flagKey, defaultValue, evalCtx, logger); } @@ -128,117 +117,95 @@ export class FlagdCore implements Storage { * @param logger - The logger to be used to troubleshoot targeting errors. Overrides the default logger. * @returns - The list of evaluation details for all enabled flags. */ - resolveAll(evalCtx?: EvaluationContext, logger?: Logger): EvaluationDetails[] { - logger ??= this._logger; + resolveAll(evalCtx: EvaluationContext = {}, logger: Logger = this._logger): EvaluationDetails[] { const values: EvaluationDetails[] = []; for (const [key, flag] of this.getFlags()) { try { if (flag.state === 'DISABLED') { continue; } - const result = this.evaluate(key, evalCtx, logger); - values.push({ - ...result, - flagKey: key, - flagMetadata: Object.freeze(result.flagMetadata ?? {}), - }); + const result = flag.evaluate(evalCtx, logger); + + if (result.value !== undefined) { + values.push({ + ...result, + flagKey: key, + }); + } else { + logger.debug(`Flag ${key} omitted because ${result.errorCode}: ${result.errorMessage}`); + } } catch (e) { - logger.error(`Error resolving flag ${key}: ${(e as Error).message}`); + logger.debug(`Error resolving flag ${key}: ${(e as Error).message}`); } } return values; } /** - * Resolves the value of a flag based on the specified type type. + * Resolves the value of a flag based on the specified type. * @template T - The type of the flag value. * @param {FlagValueType} type - The type of the flag value. * @param {string} flagKey - The key of the flag. * @param {T} defaultValue - The default value of the flag. * @param {EvaluationContext} evalCtx - The evaluation context for targeting rules. - * @param {Logger} [logger] - The optional logger for logging errors. - * @returns {ResolutionDetails} - The resolved value and the reason for the resolution. - * @throws {FlagNotFoundError} - If the flag with the given key is not found. - * @throws {TypeMismatchError} - If the evaluated type of the flag does not match the expected type. - * @throws {GeneralError} - If the variant specified in the flag is not found. + * @param {Logger} logger - The logger to be used to troubleshoot targeting errors. Overrides the default logger. + * @returns {ResolutionDetailsWithFlagMetadata} - The resolved value and the reason for the resolution. */ resolve( type: FlagValueType, flagKey: string, - _: T, + defaultValue: T, evalCtx: EvaluationContext = {}, - logger?: Logger, - ): ResolutionDetails { - const { value, reason, variant } = this.evaluate(flagKey, evalCtx, logger); - - if (typeof value !== type) { - throw new TypeMismatchError( - `Evaluated type of the flag ${flagKey} does not match. Expected ${type}, got ${typeof value}`, - ); - } - - return { - value: value as T, - reason, - variant, - }; - } - - /** - * Evaluates the flag and returns the resolved value regardless of the type. - */ - private evaluate(flagKey: string, evalCtx: EvaluationContext = {}, logger?: Logger): ResolutionDetails { - logger ??= this._logger; + logger: Logger = this._logger, + ): ResolutionDetailsWithFlagMetadata { const flag = this._storage.getFlag(flagKey); - // flag exist check + if (!flag) { - throw new FlagNotFoundError(`flag: '${flagKey}' not found`); + return { + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: `flag '${flagKey}' not found`, + flagMetadata: this._storage.getFlagSetMetadata(), + }; } - // flag status check if (flag.state === 'DISABLED') { - throw new FlagNotFoundError(`flag: '${flagKey}' is disabled`); - } - - let variant; - let reason; - - if (!flag.targeting || Object.keys(flag.targeting).length === 0) { - logger.debug(`Flag ${flagKey} has no targeting rules`); - variant = flag.defaultVariant; - reason = StandardResolutionReasons.STATIC; - } else { - let targetingResolution; - try { - targetingResolution = this._targeting.applyTargeting(flagKey, flag.targeting, evalCtx); - } catch (e) { - throw new GeneralError(`Error evaluating targeting rule for flag ${flagKey}: ${(e as Error)?.message}`); - } - - // Return default variant if targeting resolution is null or undefined - if (targetingResolution == null) { - variant = flag.defaultVariant; - reason = StandardResolutionReasons.DEFAULT; - } else { - // Obtain resolution in string. This is useful for short-circuiting json logic - variant = targetingResolution.toString(); - reason = StandardResolutionReasons.TARGETING_MATCH; - } + return { + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: `flag '${flagKey}' is disabled`, + flagMetadata: flag.metadata, + }; } - if (typeof variant !== 'string') { - throw new TypeMismatchError('Variant must be a string, but found ' + typeof variant); + const resolution = flag.evaluate(evalCtx, logger); + + /** + * A resolution without a value represents an error condition. It contains + * information about the error but requires the default value set. + */ + if (resolution.value === undefined) { + return { + ...resolution, + value: defaultValue, + }; } - const resolvedVariant = flag.variants.get(variant); - if (resolvedVariant === undefined) { - throw new GeneralError(`Variant ${variant} not found in flag with key ${flagKey}`); + if (typeof resolution.value !== type) { + return { + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: `Evaluated type of the flag ${flagKey} does not match. Expected ${type}, got ${typeof resolution.value}`, + flagMetadata: flag.metadata, + }; } return { - value: resolvedVariant, - reason, - variant, + ...resolution, + value: resolution.value as T, }; } } diff --git a/libs/shared/flagd-core/src/lib/parser.spec.ts b/libs/shared/flagd-core/src/lib/parser.spec.ts index b1123caa1..8b45fef5f 100644 --- a/libs/shared/flagd-core/src/lib/parser.spec.ts +++ b/libs/shared/flagd-core/src/lib/parser.spec.ts @@ -1,5 +1,11 @@ -import { ParseError } from '@openfeature/core'; +import { type Logger, ParseError } from '@openfeature/core'; import { parse } from './parser'; +const logger: Logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +}; describe('Flag configurations', () => { describe('throwIfSchemaInvalid=false', () => { @@ -18,7 +24,7 @@ describe('Flag configurations', () => { ' }\n' + '}'; - const flags = parse(simpleFlag, false); + const { flags } = parse(simpleFlag, false, logger); expect(flags).toBeTruthy(); expect(flags.get('myBoolFlag')).toBeTruthy(); }); @@ -27,7 +33,7 @@ describe('Flag configurations', () => { const longFlag = '{"flags":{"myBoolFlag":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"},"myStringFlag":{"state":"ENABLED","variants":{"key1":"val1","key2":"val2"},"defaultVariant":"key1"},"myFloatFlag":{"state":"ENABLED","variants":{"one":1.23,"two":2.34},"defaultVariant":"one"},"myIntFlag":{"state":"ENABLED","variants":{"one":1,"two":2},"defaultVariant":"one"},"myObjectFlag":{"state":"ENABLED","variants":{"object1":{"key":"val"},"object2":{"key":true}},"defaultVariant":"object1"},"fibAlgo":{"variants":{"recursive":"recursive","memo":"memo","loop":"loop","binet":"binet"},"defaultVariant":"recursive","state":"ENABLED","targeting":{"if":[{"$ref":"emailWithFaas"},"binet",null]}},"targetedFlag":{"variants":{"first":"AAA","second":"BBB","third":"CCC"},"defaultVariant":"first","state":"ENABLED","targeting":{"if":[{"in":["@openfeature.dev",{"var":"email"}]},"second",{"in":["Chrome",{"var":"userAgent"}]},"third",null]}}},"$evaluators":{"emailWithFaas":{"in":["@faas.com",{"var":["email"]}]}}}'; - const flags = parse(longFlag, false); + const { flags } = parse(longFlag, false, logger); expect(flags).toBeTruthy(); expect(flags.get('myBoolFlag')).toBeTruthy(); expect(flags.get('myStringFlag')).toBeTruthy(); @@ -47,31 +53,32 @@ describe('Flag configurations', () => { ' }\n' + '}'; - expect(() => parse(invalidFlag, false)).toThrowError(); + expect(() => parse(invalidFlag, false, logger)).toThrow(); }); it('should parse flag configurations with references', () => { const flagWithRef = '{"flags":{"fibAlgo":{"variants":{"recursive":"recursive","binet":"binet"},"defaultVariant":"recursive","state":"ENABLED","targeting":{"if":[{"$ref":"emailSuffixFaas"},"binet",null]}}},"$evaluators":{"emailSuffixFaas":{"in":["@faas.com",{"var":["email"]}]}}}'; - const flags = parse(flagWithRef, false); + const { flags } = parse(flagWithRef, false, logger); expect(flags).toBeTruthy(); const fibAlgo = flags.get('fibAlgo'); expect(fibAlgo).toBeTruthy(); - expect(fibAlgo?.targeting).toStrictEqual({ if: [{ in: ['@faas.com', { var: ['email'] }] }, 'binet', null] }); + expect(fibAlgo?.evaluate({ email: 'test@test.com' })).toHaveProperty('value', 'recursive'); + expect(fibAlgo?.evaluate({ email: 'test@faas.com' })).toHaveProperty('value', 'binet'); }); it('should throw a parsing error due to invalid JSON', () => { const invalidJson = '{'; - expect(() => parse(invalidJson, false)).toThrow(ParseError); + expect(() => parse(invalidJson, false, logger)).toThrow(ParseError); }); it('should throw a parsing error due to invalid flagd configuration', () => { const invalidFlagdConfig = '{"flags":{"fibAlgo":{}}}'; - expect(() => parse(invalidFlagdConfig, false)).toThrow(ParseError); + expect(() => parse(invalidFlagdConfig, false, logger)).toThrow(ParseError); }); it('should not throw if targeting invalid', () => { @@ -90,7 +97,7 @@ describe('Flag configurations', () => { ' }\n' + '}'; - expect(() => parse(invalidFlag, false)).not.toThrow(ParseError); + expect(() => parse(invalidFlag, false, logger)).not.toThrow(ParseError); }); }); @@ -111,7 +118,26 @@ describe('Flag configurations', () => { ' }\n' + '}'; - expect(() => parse(invalidFlag, true)).toThrow(ParseError); + expect(() => parse(invalidFlag, true, logger)).toThrow(ParseError); + }); + + it('should not throw if targeting is valid', () => { + const invalidFlag = + '{\n' + + ' "flags": {\n' + + ' "myBoolFlag": {\n' + + ' "state": "ENABLED",\n' + + ' "variants": {\n' + + ' "on": true,\n' + + ' "off": false\n' + + ' },\n' + + ' "defaultVariant": "off",\n' + + ' "targeting": {"if":[{"in":[{"var":"locale"},["us","ca"]]}, "true"]}' + + ' }\n' + + ' }\n' + + '}'; + + expect(() => parse(invalidFlag, true, logger)).not.toThrow(); }); }); }); diff --git a/libs/shared/flagd-core/src/lib/parser.ts b/libs/shared/flagd-core/src/lib/parser.ts index 506fe6081..70e977140 100644 --- a/libs/shared/flagd-core/src/lib/parser.ts +++ b/libs/shared/flagd-core/src/lib/parser.ts @@ -1,9 +1,20 @@ -import { Logger, ParseError } from '@openfeature/core'; +import type { Logger, FlagMetadata } from '@openfeature/core'; +import { ParseError } from '@openfeature/core'; import Ajv from 'ajv'; import flagsSchema from '../../flagd-schemas/json/flags.json'; import targetingSchema from '../../flagd-schemas/json/targeting.json'; import { FeatureFlag, Flag } from './feature-flag'; +type FlagConfig = { + flags: { [key: string]: Flag }; + metadata?: FlagMetadata; +}; + +type FlagSet = { + flags: Map; + metadata: FlagMetadata; +}; + const ajv = new Ajv({ strict: false }); const validate = ajv.addSchema(targetingSchema).compile(flagsSchema); @@ -14,31 +25,57 @@ const errorMessages = 'invalid flagd flag configuration'; /** * Validate and parse flag configurations. + * @param flagConfig The flag configuration string. + * @param strictValidation Validates against the flag and targeting schemas. + * @param logger The logger to be used for troubleshooting. + * @returns The parsed flag configurations. */ -export function parse(flagCfg: string, throwIfSchemaInvalid: boolean, logger?: Logger): Map { +export function parse(flagConfig: string, strictValidation: boolean, logger: Logger): FlagSet { try { - const transformed = transform(flagCfg); - const flags: { flags: { [key: string]: Flag } } = JSON.parse(transformed); - const isValid = validate(flags); + const transformed = transform(flagConfig); + const parsedFlagConfig: FlagConfig = JSON.parse(transformed); + + const isValid = validate(parsedFlagConfig); if (!isValid) { const message = `${errorMessages}: ${JSON.stringify(validate.errors, undefined, 2)}`; - logger?.warn(message); - if (throwIfSchemaInvalid) { + if (strictValidation) { throw new ParseError(message); + } else { + logger.debug(message); } } const flagMap = new Map(); - for (const flagsKey in flags.flags) { - flagMap.set(flagsKey, new FeatureFlag(flags.flags[flagsKey])); + const flagSetMetadata = parsedFlagConfig.metadata ?? {}; + + for (const flagsKey in parsedFlagConfig.flags) { + const flag = parsedFlagConfig.flags[flagsKey]; + flagMap.set( + flagsKey, + new FeatureFlag( + flagsKey, + { + ...flag, + // Flag metadata has higher precedence than flag set metadata + metadata: { + ...parsedFlagConfig.metadata, + ...flag.metadata, + }, + }, + logger, + ), + ); } - return flagMap; + return { + flags: flagMap, + metadata: flagSetMetadata, + }; } catch (err) { if (err instanceof ParseError) { throw err; } - throw new ParseError(errorMessages); + throw new ParseError(errorMessages, { cause: err }); } } diff --git a/libs/shared/flagd-core/src/lib/storage.spec.ts b/libs/shared/flagd-core/src/lib/storage.spec.ts index 70be887bc..f68c1e9ef 100644 --- a/libs/shared/flagd-core/src/lib/storage.spec.ts +++ b/libs/shared/flagd-core/src/lib/storage.spec.ts @@ -1,11 +1,19 @@ +import type { Logger } from '@openfeature/core'; import { FeatureFlag } from './feature-flag'; import { MemoryStorage } from './storage'; +const logger: Logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +}; + describe('MemoryStorage', () => { let storage: MemoryStorage; beforeEach(() => { - storage = new MemoryStorage(); + storage = new MemoryStorage(logger); }); it('should set configurations correctly', () => { @@ -18,11 +26,16 @@ describe('MemoryStorage', () => { new Map([ [ 'flag1', - new FeatureFlag({ - state: 'ENABLED', - defaultVariant: 'variant1', - variants: { variant1: true, variant2: false }, - }), + new FeatureFlag( + 'flag1', + { + state: 'ENABLED', + defaultVariant: 'variant1', + variants: { variant1: true, variant2: false }, + metadata: {}, + }, + logger, + ), ], ]), ); @@ -47,13 +60,48 @@ describe('MemoryStorage', () => { // Assert that the correct flag is returned expect(storage.getFlag('flag1')).toEqual( - new FeatureFlag({ state: 'ENABLED', defaultVariant: 'variant1', variants: { variant1: true, variant2: false } }), + new FeatureFlag( + 'flag1', + { state: 'ENABLED', defaultVariant: 'variant1', variants: { variant1: true, variant2: false }, metadata: {} }, + logger, + ), ); expect(storage.getFlag('flag2')).toEqual( - new FeatureFlag({ state: 'ENABLED', defaultVariant: 'variant1', variants: { variant1: true, variant2: false } }), + new FeatureFlag( + 'flag2', + { state: 'ENABLED', defaultVariant: 'variant1', variants: { variant1: true, variant2: false }, metadata: {} }, + logger, + ), ); // Assert that undefined is returned for non-existing flag expect(storage.getFlag('flag3')).toBeUndefined(); }); + + describe('metadata', () => { + it('should return flag set version and id, owner, and drop "random"', () => { + const cfg = + '{"flags":{"flag1":{"state":"ENABLED","defaultVariant":"variant1","variants":{"variant1":true,"variant2":false},"metadata":{"owner":"mike"}}}, "metadata":{"version":"1", "flagSetId": "test", "additionalProp": "name"}}'; + storage.setConfigurations(cfg); + const flag1 = storage.getFlag('flag1'); + + expect(flag1?.metadata).toEqual({ version: '1', flagSetId: 'test', additionalProp: 'name', owner: 'mike' }); + }); + + it('should merge metadata with flag metadata overriding matching flag set metadata', () => { + const cfg = + '{"flags":{"flag1":{"state":"ENABLED","defaultVariant":"variant1","variants":{"variant1":true,"variant2":false},"metadata":{"owner":"mike", "flagSetId": "prod" }}}, "metadata":{"version":"1", "flagSetId": "dev"}}'; + storage.setConfigurations(cfg); + const flag1 = storage.getFlag('flag1'); + + expect(flag1?.metadata).toEqual({ version: '1', flagSetId: 'prod', owner: 'mike' }); + }); + + it('should set flag set metadata correctly', () => { + const cfg = + '{"flags":{"flag1":{"state":"ENABLED","defaultVariant":"variant1","variants":{"variant1":true,"variant2":false}}}, "metadata":{"version":"1", "flagSetId": "dev"}}'; + storage.setConfigurations(cfg); + expect(storage.getFlagSetMetadata()).toEqual({ version: '1', flagSetId: 'dev' }); + }); + }); }); diff --git a/libs/shared/flagd-core/src/lib/storage.ts b/libs/shared/flagd-core/src/lib/storage.ts index 1c498c154..9a0bc8da7 100644 --- a/libs/shared/flagd-core/src/lib/storage.ts +++ b/libs/shared/flagd-core/src/lib/storage.ts @@ -1,4 +1,4 @@ -import { Logger } from '@openfeature/core'; +import type { FlagMetadata, Logger } from '@openfeature/core'; import { FeatureFlag } from './feature-flag'; import { parse } from './parser'; @@ -8,11 +8,12 @@ import { parse } from './parser'; export interface Storage { /** * Sets the configurations and returns the list of flags that have changed. - * @param cfg The configuration string to be parsed and stored. + * @param flagConfig The configuration string to be parsed and stored. + * @param strictValidation Validates against the flag and targeting schemas. * @returns The list of flags that have changed. * @throws {Error} If the configuration string is invalid. */ - setConfigurations(cfg: string): string[]; + setConfigurations(flagConfig: string, strictValidation?: boolean): string[]; /** * Gets the feature flag configuration with the given key. @@ -26,6 +27,12 @@ export interface Storage { * @returns The map of all the flags. */ getFlags(): Map; + + /** + * Gets metadata related to the flag set. + * @returns {FlagMetadata} The flag set metadata. + */ + getFlagSetMetadata(): FlagMetadata; } /** @@ -33,8 +40,9 @@ export interface Storage { */ export class MemoryStorage implements Storage { private _flags: Map; + private _flagSetMetadata: FlagMetadata = {}; - constructor(private logger?: Logger) { + constructor(private logger: Logger) { this._flags = new Map(); } @@ -46,8 +54,12 @@ export class MemoryStorage implements Storage { return this._flags; } - setConfigurations(cfg: string): string[] { - const newFlags = parse(cfg, false, this.logger); + getFlagSetMetadata(): FlagMetadata { + return this._flagSetMetadata; + } + + setConfigurations(flagConfig: string, strictValidation = false): string[] { + const { flags: newFlags, metadata } = parse(flagConfig, strictValidation, this.logger); const oldFlags = this._flags; const added: string[] = []; const removed: string[] = []; @@ -68,6 +80,7 @@ export class MemoryStorage implements Storage { }); this._flags = newFlags; + this._flagSetMetadata = metadata; return [...added, ...removed, ...changed]; } } diff --git a/libs/shared/flagd-core/src/lib/targeting/common.ts b/libs/shared/flagd-core/src/lib/targeting/common.ts index 93191cfb9..6b2546422 100644 --- a/libs/shared/flagd-core/src/lib/targeting/common.ts +++ b/libs/shared/flagd-core/src/lib/targeting/common.ts @@ -1,4 +1,17 @@ +import type { EvaluationContext, Logger } from '@openfeature/core'; + export const flagdPropertyKey = '$flagd'; export const flagKeyPropertyKey = 'flagKey'; export const timestampPropertyKey = 'timestamp'; export const targetingPropertyKey = 'targetingKey'; +export const loggerSymbol = Symbol.for('flagd.logger'); + +export type EvaluationContextWithLogger = EvaluationContext & { [loggerSymbol]: Logger }; + +export function getLoggerFromContext(context: EvaluationContextWithLogger): Logger { + const logger = context[loggerSymbol]; + if (!logger) { + throw new Error('Logger not found in context'); + } + return logger; +} diff --git a/libs/shared/flagd-core/src/lib/targeting/fractional.ts b/libs/shared/flagd-core/src/lib/targeting/fractional.ts index 03f4067f6..0de62d133 100644 --- a/libs/shared/flagd-core/src/lib/targeting/fractional.ts +++ b/libs/shared/flagd-core/src/lib/targeting/fractional.ts @@ -1,69 +1,69 @@ -import { flagKeyPropertyKey, flagdPropertyKey, targetingPropertyKey } from './common'; import MurmurHash3 from 'imurmurhash'; -import type { EvaluationContext, EvaluationContextValue, Logger } from '@openfeature/core'; +import type { EvaluationContextValue } from '@openfeature/core'; +import { flagKeyPropertyKey, flagdPropertyKey, targetingPropertyKey, getLoggerFromContext } from './common'; +import type { EvaluationContextWithLogger } from './common'; export const fractionalRule = 'fractional'; -export function fractionalFactory(logger: Logger) { - return function fractional(data: unknown, context: EvaluationContext): string | null { - if (!Array.isArray(data)) { - return null; - } - - const args = Array.from(data); - if (args.length < 2) { - logger.debug(`Invalid ${fractionalRule} configuration: Expected at least 2 buckets, got ${args.length}`); - return null; - } +export function fractional(data: unknown, context: EvaluationContextWithLogger): string | null { + const logger = getLoggerFromContext(context); + if (!Array.isArray(data)) { + return null; + } - const flagdProperties = context[flagdPropertyKey] as { [key: string]: EvaluationContextValue }; - if (!flagdProperties) { - logger.debug('Missing flagd properties, cannot perform fractional targeting'); - return null; - } + const args = Array.from(data); + if (args.length < 2) { + logger.debug(`Invalid ${fractionalRule} configuration: Expected at least 2 buckets, got ${args.length}`); + return null; + } - let bucketBy: string | undefined; - let buckets: unknown[]; - - if (typeof args[0] == 'string') { - bucketBy = args[0]; - buckets = args.slice(1, args.length); - } else { - const targetingKey = context[targetingPropertyKey]; - if (!targetingKey) { - logger.debug('Missing targetingKey property, cannot perform fractional targeting'); - return null; - } - bucketBy = `${flagdProperties[flagKeyPropertyKey]}${targetingKey}`; - buckets = args; - } + const flagdProperties = context[flagdPropertyKey] as { [key: string]: EvaluationContextValue } | undefined; + if (!flagdProperties) { + logger.debug('Missing flagd properties, cannot perform fractional targeting'); + return null; + } - let bucketingList; + let bucketBy: string | undefined; + let buckets: unknown[]; - try { - bucketingList = toBucketingList(buckets); - } catch (err) { - logger.debug(`Invalid ${fractionalRule} configuration: `, (err as Error).message); + if (typeof args[0] == 'string') { + bucketBy = args[0]; + buckets = args.slice(1, args.length); + } else { + const targetingKey = context[targetingPropertyKey]; + if (!targetingKey) { + logger.debug('Missing targetingKey property, cannot perform fractional targeting'); return null; } + bucketBy = `${flagdProperties[flagKeyPropertyKey]}${targetingKey}`; + buckets = args; + } + + let bucketingList; + + try { + bucketingList = toBucketingList(buckets); + } catch (err) { + logger.debug(`Invalid ${fractionalRule} configuration: `, (err as Error).message); + return null; + } - // hash in signed 32 format. Bitwise operation here works in signed 32 hence the conversion - const hash = new MurmurHash3(bucketBy).result() | 0; - const bucket = (Math.abs(hash) / 2147483648) * 100; + // hash in signed 32 format. Bitwise operation here works in signed 32 hence the conversion + const hash = new MurmurHash3(bucketBy).result() | 0; + const bucket = (Math.abs(hash) / 2147483648) * 100; - let sum = 0; - for (let i = 0; i < bucketingList.fractions.length; i++) { - const bucketEntry = bucketingList.fractions[i]; + let sum = 0; + for (let i = 0; i < bucketingList.fractions.length; i++) { + const bucketEntry = bucketingList.fractions[i]; - sum += relativeWeight(bucketingList.totalWeight, bucketEntry.fraction); + sum += relativeWeight(bucketingList.totalWeight, bucketEntry.fraction); - if (sum >= bucket) { - return bucketEntry.variant; - } + if (sum >= bucket) { + return bucketEntry.variant; } + } - return null; - }; + return null; } function relativeWeight(totalWeight: number, weight: number): number { @@ -72,6 +72,7 @@ function relativeWeight(totalWeight: number, weight: number): number { } return (weight * 100) / totalWeight; } + function toBucketingList(from: unknown[]): { fractions: { variant: string; fraction: number }[]; totalWeight: number; diff --git a/libs/shared/flagd-core/src/lib/targeting/sem-ver.ts b/libs/shared/flagd-core/src/lib/targeting/sem-ver.ts index 8b30feb43..c57ea7087 100644 --- a/libs/shared/flagd-core/src/lib/targeting/sem-ver.ts +++ b/libs/shared/flagd-core/src/lib/targeting/sem-ver.ts @@ -1,52 +1,52 @@ -import type { Logger } from '@openfeature/core'; import { compare, parse } from 'semver'; +import { getLoggerFromContext } from './common'; +import type { EvaluationContextWithLogger } from './common'; export const semVerRule = 'sem_ver'; -export function semVerFactory(logger: Logger) { - return function semVer(data: unknown): boolean { - if (!Array.isArray(data)) { - logger.debug(`Invalid ${semVerRule} configuration: Expected an array`); - return false; - } - - const args = Array.from(data); - - if (args.length != 3) { - logger.debug(`Invalid ${semVerRule} configuration: Expected 3 arguments, got ${args.length}`); - return false; - } - - const semVer1 = parse(args[0]); - const semVer2 = parse(args[2]); - - if (!semVer1 || !semVer2) { - logger.debug(`Invalid ${semVerRule} configuration: Unable to parse semver`); - return false; - } - - const operator = String(args[1]); - const result = compare(semVer1, semVer2); - - switch (operator) { - case '=': - return result == 0; - case '!=': - return result != 0; - case '<': - return result < 0; - case '<=': - return result <= 0; - case '>=': - return result >= 0; - case '>': - return result > 0; - case '^': - return semVer1.major == semVer2.major; - case '~': - return semVer1.major == semVer2.major && semVer1.minor == semVer2.minor; - } +export function semVer(data: unknown, context: EvaluationContextWithLogger): boolean { + const logger = getLoggerFromContext(context); + if (!Array.isArray(data)) { + logger.debug(`Invalid ${semVerRule} configuration: Expected an array`); + return false; + } + + const args = Array.from(data); + + if (args.length != 3) { + logger.debug(`Invalid ${semVerRule} configuration: Expected 3 arguments, got ${args.length}`); + return false; + } + + const semVer1 = parse(args[0]); + const semVer2 = parse(args[2]); + if (!semVer1 || !semVer2) { + logger.debug(`Invalid ${semVerRule} configuration: Unable to parse semver`); return false; - }; + } + + const operator = String(args[1]); + const result = compare(semVer1, semVer2); + + switch (operator) { + case '=': + return result == 0; + case '!=': + return result != 0; + case '<': + return result < 0; + case '<=': + return result <= 0; + case '>=': + return result >= 0; + case '>': + return result > 0; + case '^': + return semVer1.major == semVer2.major; + case '~': + return semVer1.major == semVer2.major && semVer1.minor == semVer2.minor; + } + + return false; } diff --git a/libs/shared/flagd-core/src/lib/targeting/string-comp.ts b/libs/shared/flagd-core/src/lib/targeting/string-comp.ts index 3303ae52a..b4a9246b4 100644 --- a/libs/shared/flagd-core/src/lib/targeting/string-comp.ts +++ b/libs/shared/flagd-core/src/lib/targeting/string-comp.ts @@ -1,46 +1,41 @@ -import { type Logger } from '@openfeature/core'; +import { getLoggerFromContext } from './common'; +import type { EvaluationContextWithLogger } from './common'; export const startsWithRule = 'starts_with'; export const endsWithRule = 'ends_with'; -export function stringCompareFactory(logger: Logger) { - function startsWithHandler(data: unknown) { - return compare(startsWithRule, data); - } +export function startsWith(data: unknown, context: EvaluationContextWithLogger) { + return compare(startsWithRule, data, context); +} - function endsWithHandler(data: unknown) { - return compare(endsWithRule, data); +export function endsWith(data: unknown, context: EvaluationContextWithLogger) { + return compare(endsWithRule, data, context); +} + +function compare(method: string, data: unknown, context: EvaluationContextWithLogger): boolean { + const logger = getLoggerFromContext(context); + if (!Array.isArray(data)) { + logger.debug('Invalid comparison configuration: input is not an array'); + return false; } - function compare(method: string, data: unknown): boolean { - if (!Array.isArray(data)) { - logger.debug('Invalid comparison configuration: input is not an array'); - return false; - } + if (data.length != 2) { + logger.debug(`Invalid comparison configuration: invalid array length ${data.length}`); + return false; + } - if (data.length != 2) { - logger.debug(`Invalid comparison configuration: invalid array length ${data.length}`); - return false; - } + if (typeof data[0] !== 'string' || typeof data[1] !== 'string') { + logger.debug('Invalid comparison configuration: array values are not strings'); + return false; + } - if (typeof data[0] !== 'string' || typeof data[1] !== 'string') { - logger.debug('Invalid comparison configuration: array values are not strings'); + switch (method) { + case startsWithRule: + return data[0].startsWith(data[1]); + case endsWithRule: + return data[0].endsWith(data[1]); + default: + logger.debug(`Invalid comparison configuration: Invalid method '${method}'`); return false; - } - - switch (method) { - case startsWithRule: - return data[0].startsWith(data[1]); - case endsWithRule: - return data[0].endsWith(data[1]); - default: - logger.debug(`Invalid comparison configuration: Invalid method '${method}'`); - return false; - } } - - return { - startsWithHandler, - endsWithHandler, - }; } diff --git a/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts b/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts index c014f257e..235432e8f 100644 --- a/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts +++ b/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts @@ -1,253 +1,273 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ +import type { Logger } from '@openfeature/core'; import { Targeting } from './targeting'; -const logger = { - debug: () => {}, - error: () => {}, - info: () => {}, - warn: () => {}, +const logger: Logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), }; -describe('Targeting rule evaluator', () => { - let targeting: Targeting; - - beforeAll(() => { - targeting = new Targeting(logger); - }); - - it('should inject flag key as a property', () => { - const flagKey = 'flagA'; - const input = { '===': [{ var: '$flagd.flagKey' }, flagKey] }; - - expect(targeting.applyTargeting(flagKey, input, {})).toBeTruthy(); - }); - - it('should inject current timestamp as a property', () => { - const ts = Math.floor(Date.now() / 1000); - const input = { '>=': [{ var: '$flagd.timestamp' }, ts] }; - - expect(targeting.applyTargeting('flag', input, {})).toBeTruthy(); - }); - - it('should override injected properties if already present in context', () => { - const flagKey = 'flagA'; - const input = { '===': [{ var: '$flagd.flagKey' }, flagKey] }; - const ctx = { - $flagd: { - flagKey: 'someOtherFlag', - }, - }; - - expect(targeting.applyTargeting(flagKey, input, ctx)).toBeTruthy(); - }); -}); - -describe('String comparison operator', () => { - let targeting: Targeting; - - beforeAll(() => { - targeting = new Targeting(logger); - }); - - it('should evaluate starts with calls', () => { - const input = { starts_with: [{ var: 'email' }, 'admin'] }; - expect(targeting.applyTargeting('flag', input, { email: 'admin@abc.com' })).toBeTruthy(); - }); - - it('should evaluate ends with calls', () => { - const input = { ends_with: [{ var: 'email' }, 'abc.com'] }; - expect(targeting.applyTargeting('flag', input, { email: 'admin@abc.com' })).toBeTruthy(); - }); -}); - -describe('String comparison operator should validate', () => { - let targeting: Targeting; - - beforeAll(() => { - targeting = new Targeting(logger); - }); - - it('missing input', () => { - const input = { starts_with: [{ var: 'email' }] }; - expect(targeting.applyTargeting('flag', input, { email: 'admin@abc.com' })).toBeFalsy(); - }); - - it('non string variable', () => { - const input = { starts_with: [{ var: 'someNumber' }, 'abc.com'] }; - expect(targeting.applyTargeting('flag', input, { someNumber: 123456 })).toBeFalsy(); - }); - - it('non string comparator', () => { - const input = { starts_with: [{ var: 'email' }, 123456] }; - expect(targeting.applyTargeting('flag', input, { email: 'admin@abc.com' })).toBeFalsy(); - }); -}); - -describe('Sem ver operator', () => { - let targeting: Targeting; - - beforeAll(() => { - targeting = new Targeting(logger); - }); - - it('should support equal operator', () => { - const input = { sem_ver: ['v1.2.3', '=', '1.2.3'] }; - expect(targeting.applyTargeting('flag', input, {})).toBeTruthy(); - }); - - it('should support neq operator', () => { - const input = { sem_ver: ['v1.2.3', '!=', '1.2.4'] }; - expect(targeting.applyTargeting('flag', input, {})).toBeTruthy(); - }); - - it('should support lt operator', () => { - const input = { sem_ver: ['v1.2.3', '<', '1.2.4'] }; - expect(targeting.applyTargeting('flag', input, {})).toBeTruthy(); - }); - - it('should support lte operator', () => { - const input = { sem_ver: ['v1.2.3', '<=', '1.2.3'] }; - expect(targeting.applyTargeting('flag', input, {})).toBeTruthy(); - }); - - it('should support gte operator', () => { - const input = { sem_ver: ['v1.2.3', '>=', '1.2.3'] }; - expect(targeting.applyTargeting('flag', input, {})).toBeTruthy(); - }); - - it('should support gt operator', () => { - const input = { sem_ver: ['v1.2.4', '>', '1.2.3'] }; - expect(targeting.applyTargeting('flag', input, {})).toBeTruthy(); - }); - - it('should support major comparison operator', () => { - const input = { sem_ver: ['v1.2.3', '^', 'v1.0.0'] }; - expect(targeting.applyTargeting('flag', input, {})).toBeTruthy(); - }); - - it('should support minor comparison operator', () => { - const input = { sem_ver: ['v5.0.3', '~', 'v5.0.8'] }; - expect(targeting.applyTargeting('flag', input, {})).toBeTruthy(); - }); - - it('should handle unknown operator', () => { - const input = { sem_ver: ['v1.0.0', '-', 'v1.0.0'] }; - expect(targeting.applyTargeting('flag', input, {})).toBeFalsy(); - }); - - it('should handle invalid inputs', () => { - const input = { sem_ver: ['myVersion_1', '=', 'myVersion_1'] }; - expect(targeting.applyTargeting('flag', input, {})).toBeFalsy(); - }); - - it('should validate inputs', () => { - const input = { sem_ver: ['myVersion_2', '+', 'myVersion_1', 'myVersion_1'] }; - expect(targeting.applyTargeting('flag', input, {})).toBeFalsy(); - }); -}); - -describe('fractional operator', () => { - let targeting: Targeting; - - beforeAll(() => { - targeting = new Targeting(logger); - }); - - it('should evaluate valid rule', () => { - const input = { - fractional: [{ cat: [{ var: '$flagd.flagKey' }, { var: 'key' }] }, ['red', 50], ['blue', 50]], - }; - - expect(targeting.applyTargeting('flagA', input, { key: 'bucketKeyA' })).toBe('red'); - }); - - it('should evaluate valid rule', () => { - const input = { - fractional: [{ cat: [{ var: '$flagd.flagKey' }, { var: 'key' }] }, ['red', 50], ['blue', 50]], - }; - - expect(targeting.applyTargeting('flagA', input, { key: 'bucketKeyB' })).toBe('blue'); - }); - - it('should evaluate valid rule with targeting key', () => { - const input = { - fractional: [ - ['red', 50], - ['blue', 50], - ], - }; - - expect(targeting.applyTargeting('flagA', input, { targetingKey: 'bucketKeyB' })).toBe('blue'); - }); - - it('should evaluate valid rule with targeting key although one does not have a fraction', () => { - const input = { - fractional: [['red', 1], ['blue']], - }; - - expect(targeting.applyTargeting('flagA', input, { targetingKey: 'bucketKeyB' })).toBe('blue'); - }); - - it('should return null if targeting key is missing', () => { - const input = { - fractional: [ - ['red', 1], - ['blue', 1], - ], - }; - - expect(targeting.applyTargeting('flagA', input, {})).toBe(null); - }); -}); - -describe('fractional operator should validate', () => { - let targeting: Targeting; - - beforeAll(() => { - targeting = new Targeting(logger); - }); - - it('bucket sum with sum bigger than 100', () => { - const input = { - fractional: [ - ['red', 55], - ['blue', 55], - ], - }; - - expect(targeting.applyTargeting('flagA', input, { targetingKey: 'key' })).toBe('blue'); - }); - - it('bucket sum with sum lower than 100', () => { - const input = { - fractional: [ - ['red', 45], - ['blue', 45], - ], - }; - - expect(targeting.applyTargeting('flagA', input, { targetingKey: 'key' })).toBe('blue'); - }); - - it('buckets properties to have variant and fraction', () => { - const input = { - fractional: [ - ['red', 50], - [100, 50], - ], - }; - - expect(targeting.applyTargeting('flagA', input, { targetingKey: 'key' })).toBe(null); - }); - - it('buckets properties to have variant and fraction', () => { - const input = { - fractional: [ - ['red', 45, 1256], - ['blue', 4, 455], - ], - }; +const requestLogger: Logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +}; - expect(targeting.applyTargeting('flagA', input, { targetingKey: 'key' })).toBe(null); +describe('targeting', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('Context injection', () => { + it('should inject flag key as a property', () => { + const flagKey = 'flagA'; + const logic = { '===': [{ var: '$flagd.flagKey' }, flagKey] }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate(flagKey, {})).toBeTruthy(); + }); + + it('should inject current timestamp as a property', () => { + const ts = Math.floor(Date.now() / 1000); + const logic = { '>=': [{ var: '$flagd.timestamp' }, ts] }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flag', {})).toBeTruthy(); + }); + + it('should override injected properties if already present in context', () => { + const flagKey = 'flagA'; + const logic = { '===': [{ var: '$flagd.flagKey' }, flagKey] }; + const ctx = { + $flagd: { + flagKey: 'someOtherFlag', + }, + }; + + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate(flagKey, ctx)).toBeTruthy(); + }); + }); + + describe('String comparison operator', () => { + it('should evaluate starts with calls', () => { + const logic = { starts_with: [{ var: 'email' }, 'admin'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', { email: 'admin@abc.com' })).toBeTruthy(); + }); + + it('should evaluate ends with calls', () => { + const logic = { ends_with: [{ var: 'email' }, 'abc.com'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', { email: 'admin@abc.com' })).toBeTruthy(); + }); + + it('should be falsy if the input is not an array', () => { + const logic = { starts_with: 'invalid' }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', { email: 'admin@abc.com' })).toBeFalsy(); + expect(logger.debug).toHaveBeenCalled(); + }); + + it('should be falsy if the input array is too large', () => { + const logic = { starts_with: [{ var: 'email' }, 'abc.com', 'invalid'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', { email: 'admin@abc.com' })).toBeFalsy(); + expect(logger.debug).toHaveBeenCalled(); + }); + + it('should be falsy if the input array contains a non-string', () => { + const logic = { starts_with: [{ var: 'email' }, 2] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', { email: 'admin@abc.com' })).toBeFalsy(); + expect(logger.debug).toHaveBeenCalled(); + }); + }); + + describe('Sem ver operator', () => { + it('should support equal operator', () => { + const logic = { sem_ver: ['v1.2.3', '=', '1.2.3'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', {})).toBeTruthy(); + }); + + it('should support neq operator', () => { + const logic = { sem_ver: ['v1.2.3', '!=', '1.2.4'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', {})).toBeTruthy(); + }); + + it('should support lt operator', () => { + const logic = { sem_ver: ['v1.2.3', '<', '1.2.4'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', {})).toBeTruthy(); + }); + + it('should support lte operator', () => { + const logic = { sem_ver: ['v1.2.3', '<=', '1.2.3'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', {})).toBeTruthy(); + }); + + it('should support gte operator', () => { + const logic = { sem_ver: ['v1.2.3', '>=', '1.2.3'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', {})).toBeTruthy(); + }); + + it('should support gt operator', () => { + const logic = { sem_ver: ['v1.2.4', '>', '1.2.3'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', {})).toBeTruthy(); + }); + + it('should support major comparison operator', () => { + const logic = { sem_ver: ['v1.2.3', '^', 'v1.0.0'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', {})).toBeTruthy(); + }); + + it('should support minor comparison operator', () => { + const logic = { sem_ver: ['v5.0.3', '~', 'v5.0.8'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', {})).toBeTruthy(); + }); + + it('should handle unknown operator', () => { + const logic = { sem_ver: ['v1.0.0', '-', 'v1.0.0'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', {})).toBeFalsy(); + }); + + it('should handle invalid inputs', () => { + const logic = { sem_ver: ['myVersion_1', '=', 'myVersion_1'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', {})).toBeFalsy(); + }); + + it('should validate inputs', () => { + const logic = { sem_ver: ['myVersion_2', '+', 'myVersion_1', 'myVersion_1'] }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flag', {})).toBeFalsy(); + }); + }); + + describe('Fractional operator', () => { + it('should evaluate to red with key "bucketKeyA"', () => { + const logic = { + fractional: [{ cat: [{ var: '$flagd.flagKey' }, { var: 'key' }] }, ['red', 50], ['blue', 50]], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { key: 'bucketKeyA' })).toBe('red'); + }); + + it('should evaluate to blue with key "bucketKeyB"', () => { + const logic = { + fractional: [{ cat: [{ var: '$flagd.flagKey' }, { var: 'key' }] }, ['red', 50], ['blue', 50]], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { key: 'bucketKeyB' })).toBe('blue'); + }); + + it('should evaluate valid rule with targeting key', () => { + const logic = { + fractional: [ + ['red', 50], + ['blue', 50], + ], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { targetingKey: 'bucketKeyB' })).toBe('blue'); + }); + + it('should evaluate valid rule with targeting key although one does not have a fraction', () => { + const logic = { + fractional: [['red', 1], ['blue']], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { targetingKey: 'bucketKeyB' })).toBe('blue'); + }); + + it('should return null if targeting key is missing', () => { + const logic = { + fractional: [ + ['red', 1], + ['blue', 1], + ], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', {})).toBe(null); + }); + + it('should support bucket sum with sum bigger than 100', () => { + const logic = { + fractional: [ + ['red', 55], + ['blue', 55], + ], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { targetingKey: 'key' })).toBe('blue'); + }); + + it('should support bucket sum with sum lower than 100', () => { + const logic = { + fractional: [ + ['red', 45], + ['blue', 45], + ], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { targetingKey: 'key' })).toBe('blue'); + }); + + it('should not support non-string variant names', () => { + const logic = { + fractional: [ + ['red', 50], + [100, 50], + ], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { targetingKey: 'key' })).toBe(null); + }); + + it('should not support invalid bucket configurations', () => { + const logic = { + fractional: [ + ['red', 45, 1256], + ['blue', 4, 455], + ], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { targetingKey: 'key' })).toBe(null); + expect(logger.debug).toHaveBeenCalled(); + }); + + it('should log using a custom logger', () => { + const logic = { + fractional: [ + ['red', 45, 1256], + ['blue', 4, 455], + ], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { targetingKey: 'key' }, requestLogger)).toBe(null); + expect(logger.debug).not.toHaveBeenCalled(); + expect(requestLogger.debug).toHaveBeenCalled(); + }); }); }); diff --git a/libs/shared/flagd-core/src/lib/targeting/targeting.ts b/libs/shared/flagd-core/src/lib/targeting/targeting.ts index bdd9e4350..ca14a35a5 100644 --- a/libs/shared/flagd-core/src/lib/targeting/targeting.ts +++ b/libs/shared/flagd-core/src/lib/targeting/targeting.ts @@ -1,36 +1,46 @@ import { LogicEngine } from 'json-logic-engine'; -import { stringCompareFactory, endsWithRule, startsWithRule } from './string-comp'; -import { semVerFactory, semVerRule } from './sem-ver'; -import { fractionalFactory, fractionalRule } from './fractional'; -import { flagdPropertyKey, flagKeyPropertyKey, timestampPropertyKey } from './common'; -import { type Logger } from '@openfeature/core'; +import { endsWith, startsWith, endsWithRule, startsWithRule } from './string-comp'; +import { semVer, semVerRule } from './sem-ver'; +import { fractional, fractionalRule } from './fractional'; +import { flagdPropertyKey, flagKeyPropertyKey, loggerSymbol, timestampPropertyKey } from './common'; +import type { EvaluationContextWithLogger } from './common'; +import type { EvaluationContext, Logger, JsonValue } from '@openfeature/core'; + export class Targeting { - private readonly _logicEngine: LogicEngine; + private readonly _logicEngine: { (ctx: EvaluationContextWithLogger): T }; - constructor(private logger: Logger) { + constructor( + logic: unknown, + private logger: Logger, + ) { const engine = new LogicEngine(); - const { endsWithHandler, startsWithHandler } = stringCompareFactory(logger); - engine.addMethod(startsWithRule, startsWithHandler); - engine.addMethod(endsWithRule, endsWithHandler); - engine.addMethod(semVerRule, semVerFactory(logger)); - engine.addMethod(fractionalRule, fractionalFactory(logger)); + engine.addMethod(startsWithRule, startsWith); + engine.addMethod(endsWithRule, endsWith); + engine.addMethod(semVerRule, semVer); + engine.addMethod(fractionalRule, fractional); - this._logicEngine = engine; + // JSON logic engine returns a generic Function interface, so we cast it to any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this._logicEngine = engine.build(logic) as any; } - applyTargeting(flagKey: string, logic: unknown, data: object): unknown { - if (Object.hasOwn(data, flagdPropertyKey)) { - this.logger.warn(`overwriting ${flagdPropertyKey} property in the context`); + evaluate(flagKey: string, ctx: EvaluationContext, logger: Logger = this.logger): T { + if (Object.hasOwn(ctx, flagdPropertyKey)) { + this.logger.debug(`overwriting ${flagdPropertyKey} property in the context`); } - const ctxData = { - ...data, + return this._logicEngine({ + ...ctx, [flagdPropertyKey]: { [flagKeyPropertyKey]: flagKey, [timestampPropertyKey]: Math.floor(Date.now() / 1000), }, - }; - - return this._logicEngine.run(logic, ctxData); + /** + * Inject the current logger into the context. This is used in custom methods. + * The symbol is used to prevent collisions with other properties and is omitted + * when context is serialized. + */ + [loggerSymbol]: logger, + }); } } diff --git a/package-lock.json b/package-lock.json index 89da26f3e..af7df70ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "copy-anything": "^3.0.5", "flagsmith": "^4.0.0", "imurmurhash": "^0.1.4", - "json-logic-engine": "1.3.9", + "json-logic-engine": "4.0.2", "launchdarkly-js-client-sdk": "^3.1.3", "lodash.isempty": "^4.4.0", "lodash.isequal": "^4.5.0", @@ -10874,10 +10874,9 @@ "dev": true }, "node_modules/json-logic-engine": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/json-logic-engine/-/json-logic-engine-1.3.9.tgz", - "integrity": "sha512-cS/OgwggY1tLjEh52BBP8Fiw8yFi2xzi4H91nezhZyigLSKiUeOIr9BB/5T7fW70Mds1R4778CXGFfrpl86kug==", - "license": "MIT", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/json-logic-engine/-/json-logic-engine-4.0.2.tgz", + "integrity": "sha512-LvKZcgQ1c2fZ0/wl+mjnerllVWdKSR2y24AQjy0bnVgOg3ZqQBTbCeMmmn518F+GhdAc1VOXHbyOAf7rQy6qRA==", "engines": { "node": ">=12.22.7" } diff --git a/package.json b/package.json index fb1f2650f..642df2692 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "copy-anything": "^3.0.5", "flagsmith": "^4.0.0", "imurmurhash": "^0.1.4", - "json-logic-engine": "1.3.9", + "json-logic-engine": "4.0.2", "launchdarkly-js-client-sdk": "^3.1.3", "lodash.isempty": "^4.4.0", "lodash.isequal": "^4.5.0", diff --git a/release-please-config.json b/release-please-config.json index 42b9787c8..f0b241c20 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -75,10 +75,10 @@ }, "libs/shared/flagd-core": { "release-type": "node", - "prerelease": true, "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, - "versioning": "default" + "versioning": "default", + "release-as": "1.0.0" }, "libs/shared/ofrep-core": { "release-type": "node",