feat(server): add in memory provider (#585)
## This PR Implements the following features for `inMemoryProvider`: - default value for flags - reason for flag evaluation - Context based evaluation ### Related Issues It's part of #565 --------- Signed-off-by: Luiz Ribeiro <ltrindaderibeiro@gmail.com> Co-authored-by: Todd Baert <todd.baert@dynatrace.com> Co-authored-by: Michael Beemer <beeme1mr@users.noreply.github.com>
This commit is contained in:
parent
2b4dbec62c
commit
5e044efc6d
|
|
@ -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.
|
If a name has no associated provider, the global provider is used.
|
||||||
|
|
||||||
```ts
|
```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
|
// Registering the default provider
|
||||||
OpenFeature.setProvider(NewLocalProvider());
|
OpenFeature.setProvider(InMemoryProvider(myFlags));
|
||||||
// Registering a named provider
|
// Registering a named provider
|
||||||
OpenFeature.setProvider("clientForCache", new NewCachedProvider());
|
OpenFeature.setProvider("otherClient", new InMemoryProvider(someOtherFlags));
|
||||||
|
|
||||||
// A Client backed by default provider
|
// A Client backed by default provider
|
||||||
const clientWithDefault = OpenFeature.getClient();
|
const clientWithDefault = OpenFeature.getClient();
|
||||||
// A Client backed by NewCachedProvider
|
// A Client backed by NewCachedProvider
|
||||||
const clientForCache = OpenFeature.getClient("clientForCache");
|
const clientForCache = OpenFeature.getClient("otherClient");
|
||||||
```
|
```
|
||||||
|
|
||||||
### Eventing
|
### Eventing
|
||||||
|
|
|
||||||
|
|
@ -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<T> = Record<string, T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Feature Flag definition, containing it's specification
|
||||||
|
*/
|
||||||
|
export type Flag = {
|
||||||
|
/**
|
||||||
|
* An object containing all possible flags mappings (variant -> flag value)
|
||||||
|
*/
|
||||||
|
variants: Variants<boolean> | Variants<string> | Variants<number> | Variants<JsonValue>;
|
||||||
|
/**
|
||||||
|
* 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<string, Flag>;
|
||||||
|
|
@ -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<ResolutionDetails<boolean>> {
|
||||||
|
return this.resolveFlagWithReason<boolean>(flagKey, defaultValue, context, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveNumberEvaluation(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: number,
|
||||||
|
context?: EvaluationContext,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<ResolutionDetails<number>> {
|
||||||
|
return this.resolveFlagWithReason<number>(flagKey, defaultValue, context, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveStringEvaluation(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: string,
|
||||||
|
context?: EvaluationContext,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<ResolutionDetails<string>> {
|
||||||
|
return this.resolveFlagWithReason<string>(flagKey, defaultValue, context, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveObjectEvaluation<T extends JsonValue>(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
context?: EvaluationContext,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<ResolutionDetails<T>> {
|
||||||
|
return this.resolveFlagWithReason<T>(flagKey, defaultValue, context, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveFlagWithReason<T extends JsonValue | FlagValueType>(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
ctx?: EvaluationContext,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<ResolutionDetails<T>> {
|
||||||
|
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<T extends JsonValue | FlagValueType>(
|
||||||
|
flagKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
ctx?: EvaluationContext,
|
||||||
|
logger?: Logger,
|
||||||
|
): ResolutionDetails<T> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './in-memory-provider';
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from './provider';
|
export * from './provider';
|
||||||
export * from './no-op-provider';
|
export * from './no-op-provider';
|
||||||
|
export * from './in-memory-provider';
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue