Skip to content

Commit baf33f0

Browse files
refactor: move Multi-Provider shared code to core package (#1324)
## Summary - Consolidates duplicate Multi-Provider code from `server` and `web` packages into `@openfeature/core` (shared package) - Uses generics (`TProviderStatus`, `TProvider`) internally to support both server and web SDKs from a single implementation ## Changes ### Moved to shared package (`@openfeature/core`): - `BaseEvaluationStrategy` and base strategy classes - `StatusTracker` for provider status management - Error utilities (`AggregateError`, `constructAggregateError`) - Common types (`ProviderEntryInput`, `RegisteredProvider`) ### SDK-specific strategy exports: - Each SDK exports pre-configured `FirstMatchStrategy`, `FirstSuccessfulStrategy`, and `ComparisonStrategy` classes - These wrapper classes have `ProviderStatus` pre-bound, maintaining backward compatibility - Users can continue using `new FirstMatchStrategy()` without any changes ### New tests: - Added unit tests for `StatusTracker` (event handling, status priority) - Added unit tests for `constructAggregateError` (including edge cases) ### Documentation: - Updated Multi-Provider READMEs with clearer custom strategy examples - Added `BaseEvaluationStrategy` class structure documentation --------- Signed-off-by: Jonathan Norris <jonathan@taplytics.com> Signed-off-by: Todd Baert <todd.baert@dynatrace.com> Co-authored-by: Todd Baert <todd.baert@dynatrace.com>
1 parent 238bc50 commit baf33f0

35 files changed

+1065
-804
lines changed

packages/server/src/provider/multi-provider/README.md

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,10 @@ client.track('purchase', { targetingKey: 'user123' }, { value: 99.99, currency:
103103
You can customize which providers receive tracking calls by overriding the `shouldTrackWithThisProvider` method in your custom strategy:
104104

105105
```typescript
106-
import { BaseEvaluationStrategy, StrategyPerProviderContext } from '@openfeature/server-sdk';
106+
import { FirstMatchStrategy, StrategyPerProviderContext } from '@openfeature/server-sdk';
107107

108-
class CustomTrackingStrategy extends BaseEvaluationStrategy {
109-
shouldTrackWithThisProvider(
108+
class CustomTrackingStrategy extends FirstMatchStrategy {
109+
override shouldTrackWithThisProvider(
110110
strategyContext: StrategyPerProviderContext,
111111
context: EvaluationContext,
112112
trackingEventName: string,
@@ -129,24 +129,38 @@ class CustomTrackingStrategy extends BaseEvaluationStrategy {
129129

130130
## Custom Strategies
131131

132-
It is also possible to implement your own strategy if the above options do not fit your use case. To do so, create a class which implements the "BaseEvaluationStrategy":
132+
It is also possible to implement your own strategy if the above options do not fit your use case. To do so, create a class which extends one of the built-in strategies or extends `BaseEvaluationStrategy` from `@openfeature/core`:
133133

134134
```typescript
135-
export abstract class BaseEvaluationStrategy {
136-
public runMode: 'parallel' | 'sequential' = 'sequential';
135+
import { FirstMatchStrategy } from '@openfeature/server-sdk';
137136

138-
abstract shouldEvaluateThisProvider(
137+
class MyCustomStrategy extends FirstMatchStrategy {
138+
// Override methods as needed
139+
override shouldEvaluateThisProvider(
139140
strategyContext: StrategyPerProviderContext,
140141
evalContext: EvaluationContext,
141-
): boolean;
142+
): boolean {
143+
// Custom logic here
144+
return super.shouldEvaluateThisProvider(strategyContext, evalContext);
145+
}
146+
}
147+
```
142148

143-
abstract shouldEvaluateNextProvider<T extends FlagValue>(
149+
The `BaseEvaluationStrategy` abstract class has the following structure:
150+
151+
```typescript
152+
export abstract class BaseEvaluationStrategy {
153+
public runMode: 'parallel' | 'sequential' = 'sequential';
154+
155+
shouldEvaluateThisProvider(strategyContext: StrategyPerProviderContext, evalContext: EvaluationContext): boolean;
156+
157+
shouldEvaluateNextProvider<T extends FlagValue>(
144158
strategyContext: StrategyPerProviderContext,
145159
context: EvaluationContext,
146160
result: ProviderResolutionResult<T>,
147161
): boolean;
148162

149-
abstract shouldTrackWithThisProvider(
163+
shouldTrackWithThisProvider(
150164
strategyContext: StrategyPerProviderContext,
151165
context: EvaluationContext,
152166
trackingEventName: string,
@@ -161,16 +175,14 @@ export abstract class BaseEvaluationStrategy {
161175
}
162176
```
163177

164-
The `runMode` property determines whether the list of providers will be evaluated sequentially or in parallel.
178+
The methods serve the following purposes:
179+
180+
- **`runMode`**: Property that determines whether providers are evaluated `'sequential'` (default) or `'parallel'`.
165181

166-
The `shouldEvaluateThisProvider` method is called just before a provider is evaluated by the Multi-Provider. If the function returns `false`, then
167-
the provider will be skipped instead of being evaluated. The function is called with details about the evaluation including the flag key and type.
168-
Check the type definitions for the full list.
182+
- **`shouldEvaluateThisProvider`**: Called before evaluating each provider. Return `false` to skip the provider. By default, skips providers in `NOT_READY` or `FATAL` status.
169183

170-
The `shouldEvaluateNextProvider` function is called after a provider is evaluated. If it returns `true`, the next provider in the sequence will be called,
171-
otherwise no more providers will be evaluated. It is called with the same data as `shouldEvaluateThisProvider` as well as the details about the evaluation result. This function is not called when the `runMode` is `parallel`.
184+
- **`shouldEvaluateNextProvider`**: Called after evaluating a provider (sequential mode only). Return `true` to continue to the next provider, `false` to stop.
172185

173-
The `shouldTrackWithThisProvider` method is called before sending a tracking event to each provider. Return `false` to skip tracking with that provider. By default, it only tracks with providers that are in a ready state (not `NOT_READY` or `FATAL`). Override this method to implement custom tracking logic based on the tracking event name, details, or provider characteristics.
186+
- **`shouldTrackWithThisProvider`**: Called before sending a tracking event to each provider. Return `false` to skip tracking. By default, skips providers in `NOT_READY` or `FATAL` status.
174187

175-
The `determineFinalResult` function is called after all providers have been called, or the `shouldEvaluateNextProvider` function returned false. It is called
176-
with a list of results from all the individual providers' evaluations. It returns the final decision for evaluation result, or throws an error if needed.
188+
- **`determineFinalResult`**: Called after all providers have been evaluated. Takes the list of provider results and returns the final resolution. This is the only abstract method that must be implemented by subclasses.
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
export * from './multi-provider';
2-
export * from './errors';
32
export * from './strategies';

packages/server/src/provider/multi-provider/multi-provider.ts

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,27 @@ import type {
1313
ProviderMetadata,
1414
ResolutionDetails,
1515
TrackingEventDetails,
16+
BaseProviderResolutionResult,
17+
ProviderEntryInput,
18+
RegisteredProvider,
19+
BaseEvaluationStrategy,
1620
} from '@openfeature/core';
17-
import { DefaultLogger, ErrorCode, GeneralError, StandardResolutionReasons } from '@openfeature/core';
21+
import {
22+
DefaultLogger,
23+
ErrorCode,
24+
GeneralError,
25+
StandardResolutionReasons,
26+
constructAggregateError,
27+
throwAggregateErrorFromPromiseResults,
28+
StatusTracker,
29+
} from '@openfeature/core';
30+
import { FirstMatchStrategy } from './strategies';
1831
import type { Provider } from '../provider';
32+
import { ProviderStatus } from '../provider';
1933
import type { Hook } from '../../hooks';
2034
import { OpenFeatureEventEmitter } from '../../events/open-feature-event-emitter';
21-
import { constructAggregateError, throwAggregateErrorFromPromiseResults } from './errors';
35+
import { ProviderEvents } from '../../events';
2236
import { HookExecutor } from './hook-executor';
23-
import { StatusTracker } from './status-tracker';
24-
import type { BaseEvaluationStrategy, ProviderResolutionResult } from './strategies';
25-
import { FirstMatchStrategy } from './strategies';
26-
import type { ProviderEntryInput, RegisteredProvider } from './types';
2737

2838
export class MultiProvider implements Provider {
2939
readonly runsOn = 'server';
@@ -35,15 +45,19 @@ export class MultiProvider implements Provider {
3545

3646
metadata: ProviderMetadata;
3747

38-
providerEntries: RegisteredProvider[] = [];
39-
private providerEntriesByName: Record<string, RegisteredProvider> = {};
48+
providerEntries: RegisteredProvider<Provider>[] = [];
49+
private providerEntriesByName: Record<string, RegisteredProvider<Provider>> = {};
4050

4151
private hookExecutor: HookExecutor;
42-
private statusTracker = new StatusTracker(this.events);
52+
private statusTracker = new StatusTracker<
53+
(typeof ProviderEvents)[keyof typeof ProviderEvents],
54+
ProviderStatus,
55+
Provider
56+
>(this.events, ProviderStatus, ProviderEvents);
4357

4458
constructor(
45-
readonly constructorProviders: ProviderEntryInput[],
46-
private readonly evaluationStrategy: BaseEvaluationStrategy = new FirstMatchStrategy(),
59+
readonly constructorProviders: ProviderEntryInput<Provider>[],
60+
private readonly evaluationStrategy: BaseEvaluationStrategy<ProviderStatus, Provider> = new FirstMatchStrategy(),
4761
private readonly logger: Logger = new DefaultLogger(),
4862
) {
4963
this.hookExecutor = new HookExecutor(this.logger);
@@ -60,7 +74,7 @@ export class MultiProvider implements Provider {
6074
};
6175
}
6276

63-
private registerProviders(constructorProviders: ProviderEntryInput[]) {
77+
private registerProviders(constructorProviders: ProviderEntryInput<Provider>[]) {
6478
const providersByName: Record<string, Provider[]> = {};
6579

6680
for (const constructorProvider of constructorProviders) {
@@ -147,7 +161,7 @@ export class MultiProvider implements Provider {
147161
throw new GeneralError('Hook context not available for evaluation');
148162
}
149163

150-
const tasks: Promise<[boolean, ProviderResolutionResult<T> | null]>[] = [];
164+
const tasks: Promise<[boolean, BaseProviderResolutionResult<T, ProviderStatus, Provider> | null]>[] = [];
151165

152166
for (const providerEntry of this.providerEntries) {
153167
const task = this.evaluateProviderEntry(
@@ -173,7 +187,7 @@ export class MultiProvider implements Provider {
173187
const results = await Promise.all(tasks);
174188
const resolutions = results
175189
.map(([, resolution]) => resolution)
176-
.filter((r): r is ProviderResolutionResult<T> => Boolean(r));
190+
.filter((r): r is BaseProviderResolutionResult<T, ProviderStatus, Provider> => Boolean(r));
177191

178192
const finalResult = this.evaluationStrategy.determineFinalResult({ flagKey, flagType }, context, resolutions);
179193

@@ -192,11 +206,11 @@ export class MultiProvider implements Provider {
192206
flagKey: string,
193207
flagType: FlagValueType,
194208
defaultValue: T,
195-
providerEntry: RegisteredProvider,
209+
providerEntry: RegisteredProvider<Provider>,
196210
hookContext: HookContext,
197211
hookHints: HookHints,
198212
context: EvaluationContext,
199-
): Promise<[boolean, ProviderResolutionResult<T> | null]> {
213+
): Promise<[boolean, BaseProviderResolutionResult<T, ProviderStatus, Provider> | null]> {
200214
let evaluationResult: ResolutionDetails<T> | undefined = undefined;
201215
const provider = providerEntry.provider;
202216
const strategyContext = {
@@ -211,19 +225,19 @@ export class MultiProvider implements Provider {
211225
return [true, null];
212226
}
213227

214-
let resolution: ProviderResolutionResult<T>;
228+
let resolution: BaseProviderResolutionResult<T, ProviderStatus, Provider>;
215229

216230
try {
217231
evaluationResult = await this.evaluateProviderAndHooks(flagKey, defaultValue, provider, hookContext, hookHints);
218232
resolution = {
219233
details: evaluationResult,
220-
provider: provider,
234+
provider,
221235
providerName: providerEntry.name,
222236
};
223237
} catch (error: unknown) {
224238
resolution = {
225239
thrownError: error,
226-
provider: provider,
240+
provider,
227241
providerName: providerEntry.name,
228242
};
229243
}

packages/server/src/provider/multi-provider/status-tracker.ts

Lines changed: 0 additions & 67 deletions
This file was deleted.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Pre-configured strategy exports for the server SDK.
3+
* These classes extend the base strategies from @openfeature/core with the
4+
* server-specific ProviderStatus enum already bound.
5+
*/
6+
import {
7+
BaseFirstMatchStrategy,
8+
BaseFirstSuccessfulStrategy,
9+
BaseComparisonStrategy,
10+
type BaseStrategyProviderContext,
11+
type BaseStrategyPerProviderContext,
12+
type BaseProviderResolutionResult,
13+
type BaseProviderResolutionSuccessResult,
14+
type BaseProviderResolutionErrorResult,
15+
type BaseFinalResult,
16+
type StrategyEvaluationContext,
17+
type FlagValue,
18+
} from '@openfeature/core';
19+
import type { Provider } from '../provider';
20+
import { ProviderStatus } from '../provider';
21+
22+
/**
23+
* Pre-bound type aliases for server SDK.
24+
* These types have the server-specific ProviderStatus and Provider types already applied,
25+
* providing backward compatibility for existing consumers.
26+
*/
27+
export type StrategyProviderContext = BaseStrategyProviderContext<ProviderStatus, Provider>;
28+
export type StrategyPerProviderContext = BaseStrategyPerProviderContext<ProviderStatus, Provider>;
29+
export type ProviderResolutionResult<T extends FlagValue> = BaseProviderResolutionResult<T, ProviderStatus, Provider>;
30+
export type ProviderResolutionSuccessResult<T extends FlagValue> = BaseProviderResolutionSuccessResult<
31+
T,
32+
ProviderStatus,
33+
Provider
34+
>;
35+
export type ProviderResolutionErrorResult = BaseProviderResolutionErrorResult<ProviderStatus, Provider>;
36+
export type FinalResult<T extends FlagValue> = BaseFinalResult<T, ProviderStatus, Provider>;
37+
export type { StrategyEvaluationContext };
38+
39+
/**
40+
* Evaluates providers in order and returns the first successful result.
41+
* Providers that return FLAG_NOT_FOUND are skipped. Any other error stops evaluation.
42+
*/
43+
export class FirstMatchStrategy extends BaseFirstMatchStrategy<ProviderStatus, Provider> {
44+
constructor() {
45+
super(ProviderStatus);
46+
}
47+
}
48+
49+
/**
50+
* Evaluates providers in order and returns the first successful result.
51+
* Any error causes that provider to be skipped.
52+
*/
53+
export class FirstSuccessfulStrategy extends BaseFirstSuccessfulStrategy<ProviderStatus, Provider> {
54+
constructor() {
55+
super(ProviderStatus);
56+
}
57+
}
58+
59+
/**
60+
* Evaluates all providers in parallel and compares results.
61+
* If all providers agree, returns that result. Otherwise, returns the fallback provider's result.
62+
*/
63+
export class ComparisonStrategy extends BaseComparisonStrategy<ProviderStatus, Provider> {
64+
constructor(fallbackProvider: Provider, onMismatch?: (resolutions: ProviderResolutionResult<FlagValue>[]) => void) {
65+
super(ProviderStatus, fallbackProvider, onMismatch);
66+
}
67+
}

0 commit comments

Comments
 (0)