Skip to content

Commit 6051dfd

Browse files
luizgribeirotoddbaertbeeme1mr
authored
feat: client in memory provider (#617)
## This PR - Adds in memory provider implementation for client (web) sdk - Move files from server sdk to shared ### Related Issues Closes #565 ### Notes It's a first try. Any feedback and enhancement proposals are welcome :) ### How to test Automated/unit testes were implemented, but e2e tests wouldn't hurt. --------- Signed-off-by: Todd Baert <[email protected]> Co-authored-by: Todd Baert <[email protected]> Co-authored-by: Michael Beemer <[email protected]>
1 parent a7d0b95 commit 6051dfd

File tree

6 files changed

+834
-0
lines changed

6 files changed

+834
-0
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Don't export types from this file publicly.
3+
* It might cause confusion since these types are not a part of the general API,
4+
* but just for the in-memory provider.
5+
*/
6+
import { EvaluationContext, JsonValue } from '@openfeature/core';
7+
8+
type Variants<T> = Record<string, T>;
9+
10+
/**
11+
* A Feature Flag definition, containing it's specification
12+
*/
13+
export type Flag = {
14+
/**
15+
* An object containing all possible flags mappings (variant -> flag value)
16+
*/
17+
variants: Variants<boolean> | Variants<string> | Variants<number> | Variants<JsonValue>;
18+
/**
19+
* The variant it will resolve to in STATIC evaluation
20+
*/
21+
defaultVariant: string;
22+
/**
23+
* Determines if flag evaluation is enabled or not for this flag.
24+
* If false, falls back to the default value provided to the client
25+
*/
26+
disabled: boolean;
27+
/**
28+
* Function used in order to evaluate a flag to a specific value given the provided context.
29+
* It should return a variant key.
30+
* If it does not return a valid variant it falls back to the default value provided to the client
31+
* @param EvaluationContext
32+
*/
33+
contextEvaluator?: (ctx: EvaluationContext) => string;
34+
};
35+
36+
export type FlagConfiguration = Record<string, Flag>;
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import {
2+
EvaluationContext,
3+
FlagNotFoundError,
4+
FlagValueType,
5+
GeneralError,
6+
JsonValue,
7+
Logger,
8+
OpenFeatureError,
9+
ProviderEvents,
10+
ResolutionDetails,
11+
StandardResolutionReasons,
12+
TypeMismatchError,
13+
ProviderStatus,
14+
} from '@openfeature/core';
15+
import { Provider } from '../provider';
16+
import { OpenFeatureEventEmitter } from '../../events';
17+
import { FlagConfiguration, Flag } from './flag-configuration';
18+
import { VariantNotFoundError } from './variant-not-found-error';
19+
20+
/**
21+
* A simple OpenFeature provider intended for demos and as a test stub.
22+
*/
23+
export class InMemoryProvider implements Provider {
24+
public readonly events = new OpenFeatureEventEmitter();
25+
public readonly runsOn = 'client';
26+
status: ProviderStatus = ProviderStatus.NOT_READY;
27+
readonly metadata = {
28+
name: 'in-memory',
29+
} as const;
30+
private _flagConfiguration: FlagConfiguration;
31+
private _context: EvaluationContext | undefined;
32+
33+
constructor(flagConfiguration: FlagConfiguration = {}) {
34+
this._flagConfiguration = { ...flagConfiguration };
35+
}
36+
37+
async initialize(context?: EvaluationContext | undefined): Promise<void> {
38+
try {
39+
40+
for (const key in this._flagConfiguration) {
41+
this.resolveFlagWithReason(key, context);
42+
}
43+
44+
this._context = context;
45+
// set the provider's state, but don't emit events manually;
46+
// the SDK does this based on the resolution/rejection of the init promise
47+
this.status = ProviderStatus.READY;
48+
} catch (error) {
49+
this.status = ProviderStatus.ERROR;
50+
throw error;
51+
}
52+
}
53+
54+
/**
55+
* Overwrites the configured flags.
56+
* @param { FlagConfiguration } flagConfiguration new flag configuration
57+
*/
58+
async putConfiguration(flagConfiguration: FlagConfiguration) {
59+
const flagsChanged = Object.entries(flagConfiguration)
60+
.filter(([key, value]) => this._flagConfiguration[key] !== value)
61+
.map(([key]) => key);
62+
63+
this.status = ProviderStatus.STALE;
64+
this.events.emit(ProviderEvents.Stale);
65+
66+
this._flagConfiguration = { ...flagConfiguration };
67+
this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged });
68+
69+
try {
70+
await this.initialize(this._context);
71+
// we need to emit our own events in this case, since it's not part of the init flow.
72+
this.events.emit(ProviderEvents.Ready);
73+
} catch (err) {
74+
this.events.emit(ProviderEvents.Error);
75+
throw err;
76+
}
77+
}
78+
79+
resolveBooleanEvaluation(
80+
flagKey: string,
81+
defaultValue: boolean,
82+
context?: EvaluationContext,
83+
logger?: Logger,
84+
): ResolutionDetails<boolean> {
85+
return this.resolveAndCheckFlag<boolean>(flagKey, defaultValue, context || this._context, logger);
86+
}
87+
88+
resolveNumberEvaluation(
89+
flagKey: string,
90+
defaultValue: number,
91+
context?: EvaluationContext,
92+
logger?: Logger,
93+
): ResolutionDetails<number> {
94+
return this.resolveAndCheckFlag<number>(flagKey, defaultValue, context || this._context, logger);
95+
}
96+
97+
resolveStringEvaluation(
98+
flagKey: string,
99+
defaultValue: string,
100+
context?: EvaluationContext,
101+
logger?: Logger,
102+
): ResolutionDetails<string> {
103+
return this.resolveAndCheckFlag<string>(flagKey, defaultValue, context || this._context, logger);
104+
}
105+
106+
resolveObjectEvaluation<T extends JsonValue>(
107+
flagKey: string,
108+
defaultValue: T,
109+
context?: EvaluationContext,
110+
logger?: Logger,
111+
): ResolutionDetails<T> {
112+
return this.resolveAndCheckFlag<T>(flagKey, defaultValue, context || this._context, logger);
113+
}
114+
115+
private resolveAndCheckFlag<T extends JsonValue | FlagValueType>(flagKey: string,
116+
defaultValue: T, context?: EvaluationContext, logger?: Logger): ResolutionDetails<T> {
117+
if (!(flagKey in this._flagConfiguration)) {
118+
const message = `no flag found with key ${flagKey}`;
119+
logger?.debug(message);
120+
throw new FlagNotFoundError(message);
121+
}
122+
123+
if (this._flagConfiguration[flagKey].disabled) {
124+
return { value: defaultValue, reason: StandardResolutionReasons.DISABLED };
125+
}
126+
127+
const resolvedFlag = this.resolveFlagWithReason(flagKey, context) as ResolutionDetails<T>;
128+
129+
if (resolvedFlag.value === undefined) {
130+
const message = `no value associated with variant provided for ${flagKey} found`;
131+
logger?.error(message);
132+
throw new VariantNotFoundError(message);
133+
}
134+
135+
if (typeof resolvedFlag.value != typeof defaultValue) {
136+
throw new TypeMismatchError();
137+
}
138+
139+
return resolvedFlag;
140+
}
141+
142+
private resolveFlagWithReason<T extends JsonValue | FlagValueType>(
143+
flagKey: string,
144+
ctx?: EvaluationContext,
145+
): ResolutionDetails<T> {
146+
try {
147+
const resolutionResult = this.lookupFlagValue<T>(flagKey, ctx);
148+
149+
return resolutionResult;
150+
} catch (error: unknown) {
151+
if (!(error instanceof OpenFeatureError)) {
152+
throw new GeneralError((error as Error)?.message || 'unknown error');
153+
}
154+
throw error;
155+
}
156+
}
157+
158+
private lookupFlagValue<T extends JsonValue | FlagValueType>(
159+
flagKey: string,
160+
ctx?: EvaluationContext,
161+
): ResolutionDetails<T> {
162+
const flagSpec: Flag = this._flagConfiguration[flagKey];
163+
164+
const isContextEval = ctx && flagSpec?.contextEvaluator;
165+
const variant = isContextEval ? flagSpec.contextEvaluator?.(ctx) : flagSpec.defaultVariant;
166+
167+
const value = variant && flagSpec?.variants[variant];
168+
169+
const evalReason = isContextEval ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.STATIC;
170+
171+
const reason = this.status === ProviderStatus.STALE ? StandardResolutionReasons.CACHED : evalReason;
172+
173+
return {
174+
value: value as T,
175+
...(variant && { variant }),
176+
reason,
177+
};
178+
}
179+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './in-memory-provider';
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ErrorCode, OpenFeatureError } from '@openfeature/core';
2+
3+
/**
4+
* A custom error for the in-memory provider.
5+
* Indicates the resolved or default variant doesn't exist.
6+
*/
7+
export class VariantNotFoundError extends OpenFeatureError {
8+
code: ErrorCode;
9+
constructor(message?: string) {
10+
super(message);
11+
Object.setPrototypeOf(this, VariantNotFoundError.prototype);
12+
this.name = 'VariantNotFoundError';
13+
this.code = ErrorCode.GENERAL;
14+
}
15+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './provider';
22
export * from './no-op-provider';
3+
export * from './in-memory-provider';

0 commit comments

Comments
 (0)