diff --git a/packages/server/README.md b/packages/server/README.md index 2d0f8907..d77df13c 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -186,17 +186,28 @@ A name is a logical identifier which can be used to associate clients with a par If a name has no associated provider, the global provider is used. ```ts -import { OpenFeature } from "@openfeature/js-sdk"; +import { OpenFeature, InMemoryProvider } from "@openfeature/js-sdk"; + +const myFlags = { + 'v2_enabled': { + variants: { + on: true, + off: false + }, + disabled: false, + defaultVariant: "on" + } +}; // Registering the default provider -OpenFeature.setProvider(NewLocalProvider()); +OpenFeature.setProvider(InMemoryProvider(myFlags)); // Registering a named provider -OpenFeature.setProvider("clientForCache", new NewCachedProvider()); +OpenFeature.setProvider("otherClient", new InMemoryProvider(someOtherFlags)); // A Client backed by default provider const clientWithDefault = OpenFeature.getClient(); // A Client backed by NewCachedProvider -const clientForCache = OpenFeature.getClient("clientForCache"); +const clientForCache = OpenFeature.getClient("otherClient"); ``` ### Eventing diff --git a/packages/server/src/provider/in-memory-provider/flag-configuration.ts b/packages/server/src/provider/in-memory-provider/flag-configuration.ts new file mode 100644 index 00000000..0ca3e435 --- /dev/null +++ b/packages/server/src/provider/in-memory-provider/flag-configuration.ts @@ -0,0 +1,36 @@ +/** + * Don't export types from this file publicly. + * It might cause confusion since these types are not a part of the general API, + * but just for the in-memory provider. + */ +import { EvaluationContext, JsonValue } from '@openfeature/shared'; + +type Variants = Record; + +/** + * A Feature Flag definition, containing it's specification + */ +export type Flag = { + /** + * An object containing all possible flags mappings (variant -> flag value) + */ + variants: Variants | Variants | Variants | Variants; + /** + * The variant it will resolve to in STATIC evaluation + */ + defaultVariant: string; + /** + * Determines if flag evaluation is enabled or not for this flag. + * If false, falls back to the default value provided to the client + */ + disabled: boolean; + /** + * Function used in order to evaluate a flag to a specific value given the provided context. + * It should return a variant key. + * If it does not return a valid variant it falls back to the default value provided to the client + * @param EvaluationContext + */ + contextEvaluator?: (ctx: EvaluationContext) => string; +}; + +export type FlagConfiguration = Record; diff --git a/packages/server/src/provider/in-memory-provider/in-memory-provider.ts b/packages/server/src/provider/in-memory-provider/in-memory-provider.ts new file mode 100644 index 00000000..223be418 --- /dev/null +++ b/packages/server/src/provider/in-memory-provider/in-memory-provider.ts @@ -0,0 +1,139 @@ +import { + EvaluationContext, + FlagNotFoundError, + FlagValueType, + GeneralError, + JsonValue, + Logger, + OpenFeatureError, + OpenFeatureEventEmitter, + ProviderEvents, + ResolutionDetails, + StandardResolutionReasons, + TypeMismatchError +} from '@openfeature/shared'; +import { Provider } from '../provider'; +import { Flag, FlagConfiguration } from './flag-configuration'; +import { VariantFoundError } from './variant-not-found-error'; + +/** + * A simple OpenFeature provider intended for demos and as a test stub. + */ +export class InMemoryProvider implements Provider { + public readonly events = new OpenFeatureEventEmitter(); + public readonly runsOn = 'server'; + readonly metadata = { + name: 'in-memory', + } as const; + private _flagConfiguration: FlagConfiguration; + + constructor(flagConfiguration: FlagConfiguration = {}) { + this._flagConfiguration = { ...flagConfiguration }; + } + + /** + * Overwrites the configured flags. + * @param { FlagConfiguration } flagConfiguration new flag configuration + */ + putConfiguration(flagConfiguration: FlagConfiguration) { + const flagsChanged = Object.entries(flagConfiguration) + .filter(([key, value]) => this._flagConfiguration[key] !== value) + .map(([key]) => key); + + this._flagConfiguration = { ...flagConfiguration }; + this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged }); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + logger?: Logger, + ): Promise> { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + logger?: Logger, + ): Promise> { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + + async resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + logger?: Logger, + ): Promise> { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + + async resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + logger?: Logger, + ): Promise> { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + + private async resolveFlagWithReason( + flagKey: string, + defaultValue: T, + ctx?: EvaluationContext, + logger?: Logger, + ): Promise> { + try { + const resolutionResult = this.lookupFlagValue(flagKey, defaultValue, ctx, logger); + + if (typeof resolutionResult?.value != typeof defaultValue) { + throw new TypeMismatchError(); + } + + return resolutionResult; + } catch (error: unknown) { + if (!(error instanceof OpenFeatureError)) { + throw new GeneralError((error as Error)?.message || 'unknown error'); + } + throw error; + } + } + + private lookupFlagValue( + flagKey: string, + defaultValue: T, + ctx?: EvaluationContext, + logger?: Logger, + ): ResolutionDetails { + if (!(flagKey in this._flagConfiguration)) { + const message = `no flag found with key ${flagKey}`; + logger?.debug(message); + throw new FlagNotFoundError(message); + } + const flagSpec: Flag = this._flagConfiguration[flagKey]; + + if (flagSpec.disabled) { + return { value: defaultValue, reason: StandardResolutionReasons.DISABLED }; + } + + const isContextEval = ctx && flagSpec?.contextEvaluator; + const variant = isContextEval ? flagSpec.contextEvaluator?.(ctx) : flagSpec.defaultVariant; + + const value = variant && flagSpec?.variants[variant]; + + if (value === undefined) { + const message = `no value associated with variant ${variant}`; + logger?.error(message); + throw new VariantFoundError(message); + } + + return { + value: value as T, + ...(variant && { variant }), + reason: isContextEval ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.STATIC, + }; + } +} diff --git a/packages/server/src/provider/in-memory-provider/index.ts b/packages/server/src/provider/in-memory-provider/index.ts new file mode 100644 index 00000000..1bf0d537 --- /dev/null +++ b/packages/server/src/provider/in-memory-provider/index.ts @@ -0,0 +1 @@ +export * from './in-memory-provider'; diff --git a/packages/server/src/provider/in-memory-provider/variant-not-found-error.ts b/packages/server/src/provider/in-memory-provider/variant-not-found-error.ts new file mode 100644 index 00000000..d7542e84 --- /dev/null +++ b/packages/server/src/provider/in-memory-provider/variant-not-found-error.ts @@ -0,0 +1,15 @@ +import { ErrorCode, OpenFeatureError } from '@openfeature/shared'; + +/** + * A custom error for the in-memory provider. + * Indicates the resolved or default variant doesn't exist. + */ +export class VariantFoundError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, VariantFoundError.prototype); + this.name = 'VariantFoundError'; + this.code = ErrorCode.GENERAL; + } +} diff --git a/packages/server/src/provider/index.ts b/packages/server/src/provider/index.ts index 79eff210..33e12fea 100644 --- a/packages/server/src/provider/index.ts +++ b/packages/server/src/provider/index.ts @@ -1,2 +1,3 @@ export * from './provider'; export * from './no-op-provider'; +export * from './in-memory-provider'; diff --git a/packages/server/test/in-memory-provider.spec.ts b/packages/server/test/in-memory-provider.spec.ts new file mode 100644 index 00000000..28880e21 --- /dev/null +++ b/packages/server/test/in-memory-provider.spec.ts @@ -0,0 +1,581 @@ +import { ErrorCode, FlagNotFoundError, ProviderEvents, StandardResolutionReasons, TypeMismatchError } from '@openfeature/shared'; +import { InMemoryProvider } from '../src'; +import { VariantFoundError } from '../src/provider/in-memory-provider/variant-not-found-error'; + +describe(InMemoryProvider, () => { + describe('boolean flags', () => { + const provider = new InMemoryProvider({}); + it('resolves to default variant with reason static', async () => { + const booleanFlagSpec = { + 'a-boolean-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + provider.putConfiguration(booleanFlagSpec); + + const resolution = await provider.resolveBooleanEvaluation('a-boolean-flag', true); + + expect(resolution).toEqual({ value: true, reason: StandardResolutionReasons.STATIC, variant: 'on' }); + }); + + it('throws FlagNotFound if flag does not exist', async () => { + const booleanFlagSpec = { + 'a-boolean-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + provider.putConfiguration(booleanFlagSpec); + + await expect(provider.resolveBooleanEvaluation('another-boolean-flag', false)).rejects.toThrow(); + }); + + it('resolves to default value with reason disabled if flag is disabled', async () => { + const booleanFlagDisabledSpec = { + 'a-boolean-flag': { + variants: { + on: true, + off: false, + }, + disabled: true, + defaultVariant: 'on', + }, + }; + provider.putConfiguration(booleanFlagDisabledSpec); + + const resolution = await provider.resolveBooleanEvaluation('a-boolean-flag', false); + + expect(resolution).toEqual({ value: false, reason: StandardResolutionReasons.DISABLED }); + }); + + it('throws VariantFoundError if variant does not exist', async () => { + const booleanFlagDisabledSpec = { + 'a-boolean-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'dummy', + }, + }; + provider.putConfiguration(booleanFlagDisabledSpec); + + await expect(provider.resolveBooleanEvaluation('a-boolean-flag', false)).rejects.toThrow(VariantFoundError); + }); + + it('throws TypeMismatchError if variant type does not match with accessors', async () => { + const booleanFlagSpec = { + 'a-boolean-flag': { + variants: { + on: 'yes', + off: 'no', + }, + disabled: false, + defaultVariant: 'on', + }, + }; + provider.putConfiguration(booleanFlagSpec); + + await expect(provider.resolveBooleanEvaluation('a-boolean-flag', false)).rejects.toThrow(TypeMismatchError); + }); + + it('resolves to variant value with reason target match if context is provided and flag spec has context evaluator', async () => { + const booleanFlagCtxSpec = { + 'a-boolean-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + contextEvaluator: () => 'off', + }, + }; + provider.putConfiguration(booleanFlagCtxSpec); + const dummyContext = {}; + + const resolution = await provider.resolveBooleanEvaluation('a-boolean-flag', true, dummyContext); + + expect(resolution).toEqual({ value: false, reason: StandardResolutionReasons.TARGETING_MATCH, variant: 'off' }); + }); + }); + + describe('string flags', () => { + const provider = new InMemoryProvider({}); + const itsDefault = "it's deafault"; + const itsOn = "it's on"; + const itsOff = "it's off"; + it('resolves to default variant with reason static', async () => { + const stringFlagSpec = { + 'a-string-flag': { + variants: { + on: itsOn, + off: itsOff, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + provider.putConfiguration(stringFlagSpec); + + const resolution = await provider.resolveStringEvaluation('a-string-flag', itsDefault); + + expect(resolution).toEqual({ value: itsOn, reason: StandardResolutionReasons.STATIC, variant: 'on' }); + }); + + it('throws FlagNotFound if flag does not exist', async () => { + const StringFlagSpec = { + 'a-string-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + + provider.putConfiguration(StringFlagSpec); + + await expect(provider.resolveStringEvaluation('another-string-flag', itsDefault)).rejects.toThrow(FlagNotFoundError); + }); + + it('resolves to default value with reason disabled if flag is disabled', async () => { + const StringFlagDisabledSpec = { + 'a-string-flag': { + variants: { + on: itsOn, + off: itsOff, + }, + disabled: true, + defaultVariant: 'on', + }, + }; + provider.putConfiguration(StringFlagDisabledSpec); + + const resolution = await provider.resolveStringEvaluation('a-string-flag', itsDefault); + + expect(resolution).toEqual({ value: itsDefault, reason: StandardResolutionReasons.DISABLED }); + }); + + it('throws VariantFoundError if variant does not exist', async () => { + const StringFlagSpec = { + 'a-string-flag': { + variants: { + on: itsOn, + off: itsOff, + }, + disabled: false, + defaultVariant: 'dummy', + }, + }; + provider.putConfiguration(StringFlagSpec); + + await expect(provider.resolveStringEvaluation('a-string-flag', itsDefault)).rejects.toThrow(VariantFoundError); + }); + + it('throws TypeMismatchError if variant does not match with accessor method type', async () => { + const StringFlagSpec = { + 'a-string-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + + provider.putConfiguration(StringFlagSpec); + + await expect(provider.resolveStringEvaluation('a-string-flag', itsDefault)).rejects.toThrow(TypeMismatchError); + }); + + it('resolves to variant value with reason target match if context is provided and flag spec has context evaluator', async () => { + const StringFlagCtxSpec = { + 'a-string-flag': { + variants: { + on: itsOn, + off: itsOff, + }, + disabled: false, + defaultVariant: 'on', + contextEvaluator: () => 'off', + }, + }; + provider.putConfiguration(StringFlagCtxSpec); + const dummyContext = {}; + + const resolution = await provider.resolveStringEvaluation('a-string-flag', itsDefault, dummyContext); + + expect(resolution).toEqual({ value: itsOff, reason: StandardResolutionReasons.TARGETING_MATCH, variant: 'off' }); + }); + }); + + describe('number flags', () => { + const provider = new InMemoryProvider({}); + const defaultNumber = 42; + const onNumber = -528; + const offNumber = 0; + it('resolves to default variant with reason static', async () => { + const numberFlagSpec = { + 'a-number-flag': { + variants: { + on: onNumber, + off: offNumber, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + provider.putConfiguration(numberFlagSpec); + + const resolution = await provider.resolveNumberEvaluation('a-number-flag', defaultNumber); + + expect(resolution).toEqual({ value: onNumber, reason: StandardResolutionReasons.STATIC, variant: 'on' }); + }); + + it('throws FlagNotFound if flag does not exist', async () => { + const numberFlagSpec = { + 'a-number-flag': { + variants: { + on: onNumber, + off: offNumber, + }, + disabled: false, + defaultVariant: 'dummy', + }, + }; + provider.putConfiguration(numberFlagSpec); + + await expect(provider.resolveNumberEvaluation('another-number-flag', defaultNumber)).rejects.toThrow(FlagNotFoundError); + }); + + it('resolves to default value with reason disabled if flag is disabled', async () => { + const numberFlagDisabledSpec = { + 'a-number-flag': { + variants: { + on: onNumber, + off: offNumber, + }, + disabled: true, + defaultVariant: 'on', + }, + }; + provider.putConfiguration(numberFlagDisabledSpec); + + const resolution = await provider.resolveNumberEvaluation('a-number-flag', defaultNumber); + + expect(resolution).toEqual({ value: defaultNumber, reason: StandardResolutionReasons.DISABLED }); + }); + + it('throws VariantNotFoundError if variant does not exist', async () => { + const numberFlagSpec = { + 'a-number-flag': { + variants: { + on: onNumber, + off: offNumber, + }, + disabled: false, + defaultVariant: 'dummy', + }, + }; + provider.putConfiguration(numberFlagSpec); + + await expect(provider.resolveNumberEvaluation('a-number-flag', defaultNumber)).rejects.toThrow(VariantFoundError); + }); + + it('throws TypeMismatchError if variant does not match with accessor method type', async () => { + const numberFlagSpec = { + 'a-number-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + + provider.putConfiguration(numberFlagSpec); + + await expect(provider.resolveNumberEvaluation('a-number-flag', defaultNumber)).rejects.toThrow(TypeMismatchError); + }); + + it('resolves to variant value with reason target match if context is provided and flag spec has context evaluator', async () => { + const numberFlagCtxSpec = { + 'a-number-flag': { + variants: { + on: onNumber, + off: offNumber, + }, + disabled: false, + defaultVariant: 'on', + contextEvaluator: () => 'off', + }, + }; + provider.putConfiguration(numberFlagCtxSpec); + const dummyContext = {}; + + const resolution = await provider.resolveNumberEvaluation('a-number-flag', defaultNumber, dummyContext); + + expect(resolution).toEqual({ + value: offNumber, + reason: StandardResolutionReasons.TARGETING_MATCH, + variant: 'off', + }); + }); + }); + + describe('Object flags', () => { + const provider = new InMemoryProvider({}); + const defaultObject = { someKey: 'default' }; + const onObject = { someKey: 'on' }; + const offObject = { someKey: 'off' }; + it('resolves to default variant with reason static', async () => { + const ObjectFlagSpec = { + 'a-object-flag': { + variants: { + on: onObject, + off: offObject, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + provider.putConfiguration(ObjectFlagSpec); + + const resolution = await provider.resolveObjectEvaluation('a-object-flag', defaultObject); + + expect(resolution).toEqual({ value: onObject, reason: StandardResolutionReasons.STATIC, variant: 'on' }); + }); + + it('throws FlagNotFound if flag does not exist', async () => { + const ObjectFlagSpec = { + 'a-Object-flag': { + variants: { + on: onObject, + off: offObject, + }, + disabled: false, + defaultVariant: 'dummy', + }, + }; + provider.putConfiguration(ObjectFlagSpec); + + await expect(provider.resolveObjectEvaluation('another-number-flag', defaultObject)).rejects.toThrow(FlagNotFoundError); + }); + + it('resolves to default value with reason disabled if flag is disabled', async () => { + const ObjectFlagDisabledSpec = { + 'a-object-flag': { + variants: { + on: onObject, + off: offObject, + }, + disabled: true, + defaultVariant: 'on', + }, + }; + provider.putConfiguration(ObjectFlagDisabledSpec); + + const resolution = await provider.resolveObjectEvaluation('a-object-flag', defaultObject); + + expect(resolution).toEqual({ value: defaultObject, reason: StandardResolutionReasons.DISABLED }); + }); + + it('throws VariantFoundError if variant does not exist', async () => { + const ObjectFlagSpec = { + 'a-Object-flag': { + variants: { + on: onObject, + off: offObject, + }, + disabled: false, + defaultVariant: 'dummy', + }, + }; + provider.putConfiguration(ObjectFlagSpec); + + await expect(provider.resolveObjectEvaluation('a-Object-flag', defaultObject)).rejects.toThrow(VariantFoundError); + }); + + it('throws TypeMismatchError if variant does not match with accessor method type', async () => { + const ObjectFlagSpec = { + 'a-object-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + + provider.putConfiguration(ObjectFlagSpec); + + await expect(provider.resolveObjectEvaluation('a-object-flag', defaultObject)).rejects.toThrow(TypeMismatchError); + }); + + it('resolves to variant value with reason target match if context is provided and flag spec has context evaluator', async () => { + const ObjectFlagCtxSpec = { + 'a-object-flag': { + variants: { + on: onObject, + off: offObject, + }, + disabled: false, + defaultVariant: 'on', + contextEvaluator: () => 'off', + }, + }; + provider.putConfiguration(ObjectFlagCtxSpec); + const dummyContext = {}; + + const resolution = await provider.resolveObjectEvaluation('a-object-flag', defaultObject, dummyContext); + + expect(resolution).toEqual({ + value: offObject, + reason: StandardResolutionReasons.TARGETING_MATCH, + variant: 'off', + }); + }); + }); + + describe('events', () => { + it('emits provider changed event if a new value is added', (done) => { + const flagsSpec = { + 'some-flag': { + variants: { + on: 'initial-value', + }, + defaultVariant: 'on', + disabled: false, + }, + }; + const provider = new InMemoryProvider(flagsSpec); + + provider.events.addHandler(ProviderEvents.ConfigurationChanged, (details) => { + expect(details?.flagsChanged).toEqual(['some-other-flag']); + done(); + }); + + const newFlag = { + 'some-other-flag': { + variants: { + off: 'some-other-value', + }, + defaultVariant: 'off', + disabled: false, + }, + }; + + const newflagsSpec = { ...flagsSpec, ...newFlag }; + provider.putConfiguration(newflagsSpec); + }); + + it('emits provider changed event if an existing value is changed', (done) => { + const flagsSpec = { + 'some-flag': { + variants: { + on: 'initial-value', + }, + defaultVariant: 'on', + disabled: false, + }, + }; + const provider = new InMemoryProvider(flagsSpec); + + provider.events.addHandler(ProviderEvents.ConfigurationChanged, (details) => { + expect(details?.flagsChanged).toEqual(['some-flag']); + done(); + }); + + const newFlagSpec = { + 'some-flag': { + variants: { + off: 'some-other-value', + }, + defaultVariant: 'off', + disabled: false, + }, + }; + provider.putConfiguration(newFlagSpec); + }); + }); + + describe('Flags configuration', () => { + it('reflects changes in flag configuration', async () => { + const provider = new InMemoryProvider({ + 'some-flag': { + variants: { + on: 'initial-value', + }, + defaultVariant: 'on', + disabled: false, + }, + }); + + const firstResolution = await provider.resolveStringEvaluation('some-flag', 'deafaultFirstResolution'); + + expect(firstResolution).toEqual({ + value: 'initial-value', + reason: StandardResolutionReasons.STATIC, + variant: 'on', + }); + + provider.putConfiguration({ + 'some-flag': { + variants: { + on: 'new-value', + }, + defaultVariant: 'on', + disabled: false, + }, + }); + + const secondResolution = await provider.resolveStringEvaluation('some-flag', 'defaultSecondResolution'); + + expect(secondResolution).toEqual({ value: 'new-value', reason: StandardResolutionReasons.STATIC, variant: 'on' }); + }); + + it('does not let you change values with the configuration passed by reference', async () => { + const flagsSpec = { + 'some-flag': { + variants: { + on: 'initial-value', + }, + defaultVariant: 'on', + disabled: false, + }, + }; + + const substituteSpec = { + variants: { + on: 'some-other-value', + }, + defaultVariant: 'on', + disabled: false, + }; + + const provider = new InMemoryProvider(flagsSpec); + + // I passed configuration by reference, so maybe I can mess + // with it behind the providers back! + flagsSpec['some-flag'] = substituteSpec; + + const resolution = await provider.resolveStringEvaluation('some-flag', 'default value'); + expect(resolution).toEqual({ value: 'initial-value', reason: StandardResolutionReasons.STATIC, variant: 'on' }); + }); + }); +});