Skip to content

Commit

Permalink
feat: client-side multi-provider implementation (#942)
Browse files Browse the repository at this point in the history
Signed-off-by: Emma Willis  <ehenriks@uwo.ca>
Signed-off-by: Emma Willis <ehenriks@uwo.ca>
  • Loading branch information
emmawillis committed Jun 17, 2024
1 parent f37f9ba commit 06def3e
Show file tree
Hide file tree
Showing 21 changed files with 1,752 additions and 0 deletions.
25 changes: 25 additions & 0 deletions libs/providers/multi-provider-web/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.json"],
"parser": "jsonc-eslint-parser",
"rules": {
"@nx/dependency-checks": "error"
}
}
]
}
139 changes: 139 additions & 0 deletions libs/providers/multi-provider-web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# OpenFeature Multi-Provider

The Multi-Provider allows you to use multiple underlying providers as sources of flag data for the OpenFeature web SDK.
When a flag is being evaluated, the Multi-Provider will consult each underlying provider it is managing in order to determine
the final result. Different evaluation strategies can be defined to control which providers get evaluated and which result is used.

The Multi-Provider is a powerful tool for performing migrations between flag providers, or combining multiple providers into a single
feature flagging interface. For example:
- *Migration*: When migrating between two providers, you can run both in parallel under a unified flagging interface. As flags are added to the
new provider, the Multi-Provider will automatically find and return them, falling back to the old provider if the new provider does not have
- *Multiple Data Sources*: The Multi-Provider allows you to seamlessly combine many sources of flagging data, such as environment variables,
local files, database values and SaaS hosted feature management systems.

## Installation

```
$ npm install @openfeature/multi-provider-web
```

> [!TIP]
> This provider is designed to be used with the [Web SDK](https://openfeature.dev/docs/reference/technologies/client/web/).
## Usage
The Multi-Provider is initialized with an array of providers it should evaluate:

```typescript
import { WebMultiProvider } from '@openfeature/multi-provider-web'
import { OpenFeature } from '@openfeature/web-sdk'

const multiProvider = new WebMultiProvider([
{
provider: new ProviderA()
},
{
provider: new ProviderB()
}
])

await OpenFeature.setProviderAndWait(multiProvider)

const client = OpenFeature.getClient()

console.log("Evaluating flag")
console.log(client.getBooleanDetails("my-flag", false));
```

By default, the Multi-Provider will evaluate all underlying providers in order and return the first successful result. If a provider indicates
it does not have a flag (FLAG_NOT_FOUND error code), then it will be skipped and the next provider will be evaluated. If any provider throws
or returns an error result, the operation will fail and the error will be thrown. If no provider returns a successful result, the operation
will fail with a FLAG_NOT_FOUND error code.

To change this behaviour, a different "strategy" can be provided:

```typescript
import { WebMultiProvider, FirstSuccessfulStrategy } from '@openfeature/multi-provider-web'

const multiProvider = new WebMultiProvider(
[
{
provider: new ProviderA()
},
{
provider: new ProviderB()
}
],
new FirstSuccessfulStrategy()
)
```
The Multi-Provider comes with three strategies out of the box:
`FirstMatchStrategy` (default): Evaluates all providers in order and returns the first successful result. Providers that indicate FLAG_NOT_FOUND error will be skipped and the next provider will be evaluated. Any other error will cause the operation to fail and the set of errors to be thrown.
- `FirstSuccessfulStrategy`: Evaluates all providers in order and returns the first successful result. Any error will cause that provider to be skipped.
If no successful result is returned, the set of errors will be thrown.
- `ComparisonStrategy`: Evaluates all providers in parallel. If every provider returns a successful result with the same value, then that result is returned.
Otherwise, the result returned by the configured "fallback provider" will be used. When values do not agree, an optional callback will be executed to notify
you of the mismatch. This can be useful when migrating between providers that are expected to contain identical configuration. You can easily spot mismatches
in configuration without affecting flag behaviour.

This strategy accepts several arguments during initialization:

```typescript
import { WebMultiProvider, ComparisonStrategy } from '@openfeature/multi-provider-web'

const providerA = new ProviderA()
const multiProvider = new WebMultiProvider(
[
{
provider: providerA
},
{
provider: new ProviderB()
}
],
new ComparisonStrategy(providerA, (details) => {
console.log("Mismatch detected", details)
})
)
```
The first argument is the "fallback provider" whose value to use in the event that providers do not agree. It should be the same object reference as one of the providers in the list. The second argument is a callback function that will be executed when a mismatch is detected. The callback will be passed an object containing the details of each provider's resolution, including the flag key, the value returned, and any errors that were thrown.

## Custom Strategies
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":
```typescript
export abstract class BaseEvaluationStrategy {
public runMode: 'parallel' | 'sequential' = 'sequential';

abstract shouldEvaluateThisProvider(strategyContext: StrategyPerProviderContext, evalContext: EvaluationContext): boolean;

abstract shouldEvaluateNextProvider<T extends FlagValue>(
strategyContext: StrategyPerProviderContext,
context: EvaluationContext,
result: ProviderResolutionResult<T>,
): boolean;

abstract determineFinalResult<T extends FlagValue>(
strategyContext: StrategyEvaluationContext,
context: EvaluationContext,
resolutions: ProviderResolutionResult<T>[],
): FinalResult<T>;
}
```
The `runMode` property determines whether the list of providers will be evaluated sequentially or in parallel.

The `shouldEvaluateThisProvider` method is called just before a provider is evaluated by the Multi-Provider. If the function returns `false`, then
the provider will be skipped instead of being evaluated. The function is called with details about the evaluation including the flag key and type.
Check the type definitions for the full list.

The `shouldEvaluateNextProvider` function is called after a provider is evaluated. If it returns `true`, the next provider in the sequence will be called,
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`.

The `determineFinalResult` function is called after all providers have been called, or the `shouldEvaluateNextProvider` function returned false. It is called
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.

## Building

Run `nx package providers-multi-provider` to build the library.

## Running unit tests

Run `nx test providers-multi-provider` to execute the unit tests via [Jest](https://jestjs.io).
3 changes: 3 additions & 0 deletions libs/providers/multi-provider-web/babel.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"presets": [["minify", { "builtIns": false }]]
}
10 changes: 10 additions & 0 deletions libs/providers/multi-provider-web/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* eslint-disable */
export default {
displayName: 'providers-multi-provider-web',
preset: '../../../jest.preset.js',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/providers/multi-provider-web',
};
16 changes: 16 additions & 0 deletions libs/providers/multi-provider-web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@openfeature/multi-provider-web",
"version": "0.0.1",
"dependencies": {
"tslib": "^2.3.0"
},
"main": "./src/index.js",
"typings": "./src/index.d.ts",
"scripts": {
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
"current-version": "echo $npm_package_version"
},
"peerDependencies": {
"@openfeature/web-sdk": "^1.6.0"
}
}
76 changes: 76 additions & 0 deletions libs/providers/multi-provider-web/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"name": "providers-multi-provider-web",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/providers/multi-provider-web/src",
"projectType": "library",
"targets": {
"publish": {
"executor": "nx:run-commands",
"options": {
"command": "npm run publish-if-not-exists",
"cwd": "dist/libs/providers/multi-provider-web"
},
"dependsOn": [
{
"projects": "self",
"target": "package"
}
]
},
"lint": {
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/providers/multi-provider-web/**/*.ts", "libs/providers/multi-provider-web/package.json"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/providers/multi-provider-web/jest.config.ts",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
},
"package": {
"executor": "@nx/rollup:rollup",
"outputs": ["{options.outputPath}"],
"options": {
"project": "libs/providers/multi-provider-web/package.json",
"outputPath": "dist/libs/providers/multi-provider-web",
"entryFile": "libs/providers/multi-provider-web/src/index.ts",
"tsConfig": "libs/providers/multi-provider-web/tsconfig.lib.json",
"buildableProjectDepsInPackageJsonType": "dependencies",
"compiler": "tsc",
"generateExportsField": true,
"umdName": "multi-provider-web",
"external": "all",
"format": ["cjs", "esm"],
"assets": [
{
"glob": "package.json",
"input": "./assets",
"output": "./src/"
},
{
"glob": "LICENSE",
"input": "./",
"output": "./"
},
{
"glob": "README.md",
"input": "./libs/providers/multi-provider-web",
"output": "./"
}
]
}
}
},
"tags": []
}
3 changes: 3 additions & 0 deletions libs/providers/multi-provider-web/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './lib/multi-provider-web';
export * from './lib/errors';
export * from './lib/strategies';
52 changes: 52 additions & 0 deletions libs/providers/multi-provider-web/src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ErrorCode, GeneralError, OpenFeatureError } from '@openfeature/web-sdk';
import { RegisteredProvider } from './types';

export class ErrorWithCode extends OpenFeatureError {
constructor(
public code: ErrorCode,
message: string,
) {
super(message);
}
}

export class AggregateError extends GeneralError {
constructor(
message: string,
public originalErrors: { source: string; error: unknown }[],
) {
super(message);
}
}

export const constructAggregateError = (providerErrors: { error: unknown; providerName: string }[]) => {
const errorsWithSource = providerErrors
.map(({ providerName, error }) => {
return { source: providerName, error };
})
.flat();

// log first error in the message for convenience, but include all errors in the error object for completeness
return new AggregateError(
`Provider errors occurred: ${errorsWithSource[0].source}: ${errorsWithSource[0].error}`,
errorsWithSource,
);
};

export const throwAggregateErrorFromPromiseResults = (
result: PromiseSettledResult<unknown>[],
providerEntries: RegisteredProvider[],
) => {
const errors = result
.map((r, i) => {
if (r.status === 'rejected') {
return { error: r.reason, providerName: providerEntries[i].name };
}
return null;
})
.filter((val): val is { error: unknown; providerName: string } => Boolean(val));

if (errors.length) {
throw constructAggregateError(errors);
}
};
56 changes: 56 additions & 0 deletions libs/providers/multi-provider-web/src/lib/hook-executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { EvaluationDetails, FlagValue, Hook, HookContext, HookHints, Logger } from '@openfeature/web-sdk';

/**
* Utility for executing a set of hooks of each type. Implementation is largely copied from the main OpenFeature SDK.
*/
export class HookExecutor {
constructor(private logger: Logger) {}

beforeHooks(hooks: Hook[] | undefined, hookContext: HookContext, hints: HookHints) {
for (const hook of hooks ?? []) {
hook?.before?.(hookContext, Object.freeze(hints));
}
}

afterHooks(
hooks: Hook[] | undefined,
hookContext: HookContext,
evaluationDetails: EvaluationDetails<FlagValue>,
hints: HookHints,
) {
// run "after" hooks sequentially
for (const hook of hooks ?? []) {
hook?.after?.(hookContext, evaluationDetails, hints);
}
}

errorHooks(hooks: Hook[] | undefined, hookContext: HookContext, err: unknown, hints: HookHints) {
// run "error" hooks sequentially
for (const hook of hooks ?? []) {
try {
hook?.error?.(hookContext, err, hints);
} catch (err) {
this.logger.error(`Unhandled error during 'error' hook: ${err}`);
if (err instanceof Error) {
this.logger.error(err.stack);
}
this.logger.error((err as Error)?.stack);
}
}
}

finallyHooks(hooks: Hook[] | undefined, hookContext: HookContext, hints: HookHints) {
// run "finally" hooks sequentially
for (const hook of hooks ?? []) {
try {
hook?.finally?.(hookContext, hints);
} catch (err) {
this.logger.error(`Unhandled error during 'finally' hook: ${err}`);
if (err instanceof Error) {
this.logger.error(err.stack);
}
this.logger.error((err as Error)?.stack);
}
}
}
}
Loading

0 comments on commit 06def3e

Please sign in to comment.