Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): add in memory provider #585

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
toddbaert marked this conversation as resolved.
Show resolved Hide resolved

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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
toddbaert marked this conversation as resolved.
Show resolved Hide resolved
*/
import { EvaluationContext, JsonValue } from '@openfeature/shared';

type Variants<T> = Record<string, T>;

/**
* A Feature Flag definition, containing it's specification
*/
export type Flag = {
toddbaert marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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>;
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
1 change: 1 addition & 0 deletions packages/server/src/provider/in-memory-provider/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './in-memory-provider';
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/server/src/provider/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './provider';
export * from './no-op-provider';
export * from './in-memory-provider';
Loading