diff --git a/libs/providers/multi-provider-web/.eslintrc.json b/libs/providers/multi-provider-web/.eslintrc.json new file mode 100644 index 000000000..3230caf3d --- /dev/null +++ b/libs/providers/multi-provider-web/.eslintrc.json @@ -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" + } + } + ] +} diff --git a/libs/providers/multi-provider-web/README.md b/libs/providers/multi-provider-web/README.md new file mode 100644 index 000000000..5b0dd4d3b --- /dev/null +++ b/libs/providers/multi-provider-web/README.md @@ -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( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + result: ProviderResolutionResult, + ): boolean; + + abstract determineFinalResult( + strategyContext: StrategyEvaluationContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult; +} +``` +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). diff --git a/libs/providers/multi-provider-web/babel.config.json b/libs/providers/multi-provider-web/babel.config.json new file mode 100644 index 000000000..d7bf474d1 --- /dev/null +++ b/libs/providers/multi-provider-web/babel.config.json @@ -0,0 +1,3 @@ +{ + "presets": [["minify", { "builtIns": false }]] +} diff --git a/libs/providers/multi-provider-web/jest.config.ts b/libs/providers/multi-provider-web/jest.config.ts new file mode 100644 index 000000000..af83bf2ce --- /dev/null +++ b/libs/providers/multi-provider-web/jest.config.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +export default { + displayName: 'providers-multi-provider-web', + preset: '../../../jest.preset.js', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/providers/multi-provider-web', +}; diff --git a/libs/providers/multi-provider-web/package.json b/libs/providers/multi-provider-web/package.json new file mode 100644 index 000000000..430f0c6ff --- /dev/null +++ b/libs/providers/multi-provider-web/package.json @@ -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" + } +} diff --git a/libs/providers/multi-provider-web/project.json b/libs/providers/multi-provider-web/project.json new file mode 100644 index 000000000..e4018680d --- /dev/null +++ b/libs/providers/multi-provider-web/project.json @@ -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": [] +} diff --git a/libs/providers/multi-provider-web/src/index.ts b/libs/providers/multi-provider-web/src/index.ts new file mode 100644 index 000000000..02cc9d8e0 --- /dev/null +++ b/libs/providers/multi-provider-web/src/index.ts @@ -0,0 +1,3 @@ +export * from './lib/multi-provider-web'; +export * from './lib/errors'; +export * from './lib/strategies'; diff --git a/libs/providers/multi-provider-web/src/lib/errors.ts b/libs/providers/multi-provider-web/src/lib/errors.ts new file mode 100644 index 000000000..e29045e44 --- /dev/null +++ b/libs/providers/multi-provider-web/src/lib/errors.ts @@ -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[], + 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); + } +}; diff --git a/libs/providers/multi-provider-web/src/lib/hook-executor.ts b/libs/providers/multi-provider-web/src/lib/hook-executor.ts new file mode 100644 index 000000000..278adf8b3 --- /dev/null +++ b/libs/providers/multi-provider-web/src/lib/hook-executor.ts @@ -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, + 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); + } + } + } +} diff --git a/libs/providers/multi-provider-web/src/lib/multi-provider-web.spec.ts b/libs/providers/multi-provider-web/src/lib/multi-provider-web.spec.ts new file mode 100644 index 000000000..80b558782 --- /dev/null +++ b/libs/providers/multi-provider-web/src/lib/multi-provider-web.spec.ts @@ -0,0 +1,704 @@ +import { WebMultiProvider } from './multi-provider-web'; +import { + DefaultLogger, + ErrorCode, + EvaluationContext, + FlagNotFoundError, + FlagValue, + FlagValueType, + Hook, + InMemoryProvider, + Logger, + OpenFeatureEventEmitter, + Provider, + ProviderEmittableEvents, + ProviderMetadata, + ClientProviderEvents, +} from '@openfeature/web-sdk'; +import { FirstMatchStrategy } from './strategies/FirstMatchStrategy'; +import { FirstSuccessfulStrategy } from './strategies/FirstSuccessfulStrategy'; +import { ComparisonStrategy } from './strategies/ComparisonStrategy'; + +class TestProvider implements Provider { + public metadata: ProviderMetadata = { + name: 'TestProvider', + }; + public events = new OpenFeatureEventEmitter(); + public hooks: Hook[] = []; + constructor( + public resolveBooleanEvaluation = jest.fn().mockReturnValue({ value: false }), + public resolveStringEvaluation = jest.fn().mockReturnValue({ value: 'default' }), + public resolveObjectEvaluation = jest.fn().mockReturnValue({ value: {} }), + public resolveNumberEvaluation = jest.fn().mockReturnValue({ value: 0 }), + public initialize = jest.fn(), + ) {} + + emitEvent(type: ProviderEmittableEvents) { + this.events.emit(type, { providerName: this.metadata.name }); + } +} + +const callEvaluation = (multi: WebMultiProvider, context: EvaluationContext) => { + callBeforeHook(multi, context, 'flag', 'boolean', false); + return multi.resolveBooleanEvaluation('flag', false, context); +}; + +const callBeforeHook = ( + multi: WebMultiProvider, + context: EvaluationContext, + flagKey: string, + flagType: FlagValueType, + defaultValue: FlagValue, + logger: Logger = new DefaultLogger(), +) => { + const hookContext = { + context: context, + flagKey, + flagValueType: flagType, + defaultValue, + clientMetadata: {} as any, + providerMetadata: {} as any, + logger: logger, + }; + multi.hooks[0].before?.(hookContext); +}; + +describe('MultiProvider', () => { + const logger = new DefaultLogger(); + + describe('unique names', () => { + it('uses provider names for unique types', () => { + const multiProvider = new WebMultiProvider([ + { + provider: new InMemoryProvider(), + }, + { + provider: new TestProvider(), + }, + ]); + expect(multiProvider.providerEntries[0].name).toEqual('in-memory'); + expect(multiProvider.providerEntries[1].name).toEqual('TestProvider'); + expect(multiProvider.providerEntries.length).toBe(2); + }); + it('generates unique names for identical provider types', () => { + const multiProvider = new WebMultiProvider([ + { + provider: new TestProvider(), + }, + { + provider: new TestProvider(), + }, + { + provider: new TestProvider(), + }, + { + provider: new InMemoryProvider(), + }, + ]); + expect(multiProvider.providerEntries[0].name).toEqual('TestProvider-1'); + expect(multiProvider.providerEntries[1].name).toEqual('TestProvider-2'); + expect(multiProvider.providerEntries[2].name).toEqual('TestProvider-3'); + expect(multiProvider.providerEntries[3].name).toEqual('in-memory'); + expect(multiProvider.providerEntries.length).toBe(4); + }); + it('uses specified names for identical provider types', () => { + const multiProvider = new WebMultiProvider([ + { + provider: new TestProvider(), + name: 'provider1', + }, + { + provider: new TestProvider(), + name: 'provider2', + }, + ]); + expect(multiProvider.providerEntries[0].name).toEqual('provider1'); + expect(multiProvider.providerEntries[1].name).toEqual('provider2'); + expect(multiProvider.providerEntries.length).toBe(2); + }); + it('throws an error if specified names are not unique', () => { + expect( + () => + new WebMultiProvider([ + { + provider: new TestProvider(), + name: 'provider', + }, + { + provider: new InMemoryProvider(), + name: 'provider', + }, + ]), + ).toThrow(); + }); + }); + + describe('event tracking and statuses', () => { + it('initializes by waiting for all initializations', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + let initializations = 0; + const multiProvider = new WebMultiProvider([ + { + provider: provider1, + }, + { + provider: provider2, + }, + ]); + provider1.initialize.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + initializations++; + }); + provider2.initialize.mockImplementation(() => initializations++); + await multiProvider.initialize(); + expect(initializations).toBe(2); + }); + + it('throws error if a provider errors on initialization', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + let initializations = 0; + const multiProvider = new WebMultiProvider([ + { + provider: provider1, + }, + { + provider: provider2, + }, + ]); + provider1.initialize.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + throw new Error('Failure!'); + }); + provider2.initialize.mockImplementation(async () => initializations++); + await expect(() => multiProvider.initialize()).rejects.toThrow('Failure!'); + }); + + it('emits events when aggregate status changes', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const multiProvider = new WebMultiProvider([ + { + provider: provider1, + }, + { + provider: provider2, + }, + ]); + + let readyEmitted = 0; + let errorEmitted = 0; + let staleEmitted = 0; + multiProvider.events.addHandler(ClientProviderEvents.Ready, () => { + readyEmitted++; + }); + + multiProvider.events.addHandler(ClientProviderEvents.Error, () => { + errorEmitted++; + }); + + multiProvider.events.addHandler(ClientProviderEvents.Stale, () => { + staleEmitted++; + }); + + await multiProvider.initialize(); + + provider1.initialize.mockResolvedValue(true); + provider2.initialize.mockResolvedValue(true); + provider1.emitEvent(ClientProviderEvents.Error); + expect(errorEmitted).toBe(1); + provider2.emitEvent(ClientProviderEvents.Error); + // don't emit error again unless aggregate status is changing + expect(errorEmitted).toBe(1); + provider1.emitEvent(ClientProviderEvents.Error); + // don't emit error again unless aggregate status is changing + expect(errorEmitted).toBe(1); + provider2.emitEvent(ClientProviderEvents.Stale); + provider1.emitEvent(ClientProviderEvents.Ready); + // error status provider is ready now but other provider is stale + expect(readyEmitted).toBe(0); + expect(staleEmitted).toBe(1); + provider2.emitEvent(ClientProviderEvents.Ready); + // now both providers are ready + expect(readyEmitted).toBe(1); + }); + }); + + describe('metadata', () => { + it('contains metadata for all providers', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + + const multiProvider = new WebMultiProvider([ + { + provider: provider1, + }, + { + provider: provider2, + }, + ]); + expect(multiProvider.metadata).toEqual({ + name: 'WebMultiProvider', + 'TestProvider-1': provider1.metadata, + 'TestProvider-2': provider2.metadata, + }); + }); + }); + + describe('evaluation', () => { + describe('hooks', () => { + it("runs all providers' before hooks before evaluation, using same hook context", () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + let hook1Called = false; + let hook2Called = false; + let after1Called = false; + let after2Called = false; + const context = { + test: true, + }; + const hookContext = { + context: context, + flagKey: 'flag', + flagValueType: 'boolean' as any, + defaultValue: false, + clientMetadata: {} as any, + providerMetadata: {} as any, + logger: logger, + }; + + provider1.hooks = [ + { + before: (context) => { + hook1Called = true; + expect(context).toEqual(hookContext); + }, + after: (context) => { + expect(context).toEqual(hookContext); + after1Called = true; + }, + }, + { + before: (context) => { + expect(context).toEqual(hookContext); + hook2Called = true; + }, + }, + ]; + + provider2.hooks = [ + { + after: (context) => { + expect(context).toEqual(hookContext); + after2Called = true; + }, + }, + ]; + + const multiProvider = new WebMultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + ], + new ComparisonStrategy(provider1), + ); + + multiProvider.hooks[0].before?.(hookContext); + multiProvider.resolveBooleanEvaluation('flag', false, context); + expect(hook1Called).toBe(true); + expect(hook2Called).toBe(true); + expect(provider1.resolveBooleanEvaluation).toHaveBeenCalledWith( + 'flag', + false, + { test: true }, + expect.any(Object), + ); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalledWith( + 'flag', + false, + { test: true }, + expect.any(Object), + ); + expect(after1Called).toBe(true); + expect(after2Called).toBe(true); + }); + + it('runs error hook and finally hook', () => { + const provider1 = new TestProvider(); + let error1Called = false; + let finally1Called = false; + + const context = { + test: true, + }; + + const hookContext = { + context: context, + flagKey: 'flag', + flagValueType: 'boolean' as any, + defaultValue: false, + clientMetadata: {} as any, + providerMetadata: {} as any, + logger: logger, + }; + + provider1.hooks = [ + { + error: async (context) => { + expect(context).toEqual(hookContext); + error1Called = true; + }, + finally: async (context) => { + expect(context).toEqual(hookContext); + finally1Called = true; + }, + }, + ]; + + const multiProvider = new WebMultiProvider([ + { + provider: provider1, + }, + ]); + + provider1.resolveBooleanEvaluation.mockImplementation(() => { + throw new Error('test error'); + }); + + multiProvider.hooks[0].before?.(hookContext); + expect(() => multiProvider.resolveBooleanEvaluation('flag', false, context)).toThrow(); + expect(error1Called).toBe(true); + expect(finally1Called).toBe(true); + }); + }); + + describe('resolution logic and strategies', () => { + describe('evaluation data types', () => { + it('evaluates a string variable', () => { + const provider1 = new TestProvider(); + provider1.resolveStringEvaluation.mockReturnValue({ value: 'value' }); + + const multiProvider = new WebMultiProvider([ + { + provider: provider1, + }, + ]); + const context = {}; + callBeforeHook(multiProvider, context, 'flag', 'string', 'default'); + expect(multiProvider.resolveStringEvaluation('flag', 'default', context)).toEqual({ value: 'value' }); + }); + + it('evaluates a number variable', () => { + const provider1 = new TestProvider(); + provider1.resolveNumberEvaluation.mockReturnValue({ value: 1 }); + + const multiProvider = new WebMultiProvider([ + { + provider: provider1, + }, + ]); + const context = {}; + + callBeforeHook(multiProvider, context, 'flag', 'number', 0); + + expect(multiProvider.resolveNumberEvaluation('flag', 0, context)).toEqual({ value: 1 }); + }); + + it('evaluates a boolean variable', () => { + const provider1 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockReturnValue({ value: true }); + + const multiProvider = new WebMultiProvider([ + { + provider: provider1, + }, + ]); + const context = {}; + callBeforeHook(multiProvider, context, 'flag', 'boolean', false); + expect(multiProvider.resolveBooleanEvaluation('flag', false, context)).toEqual({ value: true }); + }); + + it('evaluates an object variable', () => { + const provider1 = new TestProvider(); + provider1.resolveObjectEvaluation.mockReturnValue({ value: { test: true } }); + + const multiProvider = new WebMultiProvider([ + { + provider: provider1, + }, + ]); + const context = {}; + callBeforeHook(multiProvider, context, 'flag', 'object', {}); + expect(multiProvider.resolveObjectEvaluation('flag', {}, context)).toEqual({ value: { test: true } }); + }); + }); + describe('first match strategy', () => { + it('throws an error if any provider throws an error during evaluation', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockImplementation(() => { + throw new Error('test error'); + }); + const multiProvider = new WebMultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + ], + new FirstMatchStrategy(), + ); + + expect(() => callEvaluation(multiProvider, {})).toThrow('test error'); + expect(provider2.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + + it('throws an error if any provider returns an error result during evaluation', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockReturnValue({ + errorCode: 'test-error', + errorMessage: 'test error', + }); + const multiProvider = new WebMultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + ], + new FirstMatchStrategy(), + ); + + expect(() => callEvaluation(multiProvider, {})).toThrow('test error'); + expect(provider2.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + + it('skips providers that return flag not found until it gets a result, skipping any provider after', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockReturnValue({ + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: 'flag not found', + }); + provider2.resolveBooleanEvaluation.mockReturnValue({ + value: true, + }); + const multiProvider = new WebMultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new FirstMatchStrategy(), + ); + const result = callEvaluation(multiProvider, {}); + expect(result).toEqual({ value: true }); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + + it('skips providers that throw flag not found until it gets a result, skipping any provider after', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockImplementation(() => { + throw new FlagNotFoundError('flag not found'); + }); + provider2.resolveBooleanEvaluation.mockReturnValue({ + value: true, + }); + const multiProvider = new WebMultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new FirstMatchStrategy(), + ); + const result = callEvaluation(multiProvider, {}); + expect(result).toEqual({ value: true }); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + }); + + describe('first successful strategy', () => { + it('ignores errors from earlier providers and returns successful result from later provider', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockReturnValue({ + errorCode: 'some error', + errorMessage: 'flag not found', + }); + provider2.resolveBooleanEvaluation.mockReturnValue({ + value: true, + }); + const multiProvider = new WebMultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new FirstSuccessfulStrategy(), + ); + const result = callEvaluation(multiProvider, {}); + expect(result).toEqual({ value: true }); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + }); + + describe('comparison strategy', () => { + it('calls every provider and returns a result if they all agree', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockReturnValue({ value: true }); + + provider2.resolveBooleanEvaluation.mockReturnValue({ value: true }); + provider3.resolveBooleanEvaluation.mockReturnValue({ value: true }); + + const multiProvider = new WebMultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new ComparisonStrategy(provider1), + ); + const result = callEvaluation(multiProvider, {}); + expect(provider1.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).toHaveBeenCalled(); + + expect(result).toEqual({ value: true }); + }); + + it('calls every provider and returns the fallback value if any disagree, and calls onMismatch', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockReturnValue({ + value: true, + }); + provider2.resolveBooleanEvaluation.mockReturnValue({ + value: false, + }); + provider3.resolveBooleanEvaluation.mockReturnValue({ + value: false, + }); + + const onMismatch = jest.fn(); + + const multiProvider = new WebMultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new ComparisonStrategy(provider1, onMismatch), + ); + const result = callEvaluation(multiProvider, {}); + expect(provider1.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(onMismatch).toHaveBeenCalledWith([ + { + provider: provider1, + providerName: 'TestProvider-1', + details: { value: true }, + }, + { + provider: provider2, + providerName: 'TestProvider-2', + details: { value: false }, + }, + { + provider: provider3, + providerName: 'TestProvider-3', + details: { value: false }, + }, + ]); + + expect(result).toEqual({ value: true }); + }); + + it('returns an error if any provider returns an error', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockImplementation(() => { + throw new Error('test error'); + }); + provider2.resolveBooleanEvaluation.mockReturnValue({ + value: false, + }); + provider3.resolveBooleanEvaluation.mockReturnValue({ + value: false, + }); + + const multiProvider = new WebMultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new ComparisonStrategy(provider1), + ); + expect(() => callEvaluation(multiProvider, {})).toThrow('test error'); + expect(provider1.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/libs/providers/multi-provider-web/src/lib/multi-provider-web.ts b/libs/providers/multi-provider-web/src/lib/multi-provider-web.ts new file mode 100644 index 000000000..bcec4ce2d --- /dev/null +++ b/libs/providers/multi-provider-web/src/lib/multi-provider-web.ts @@ -0,0 +1,294 @@ +import { + DefaultLogger, + EvaluationContext, + FlagValueType, + GeneralError, + Hook, + HookContext, + HookHints, + JsonValue, + Logger, + OpenFeatureEventEmitter, + Provider, + ProviderMetadata, + BeforeHookContext, + ResolutionDetails, +} from '@openfeature/web-sdk'; +import { HookExecutor } from './hook-executor'; +import { constructAggregateError, throwAggregateErrorFromPromiseResults } from './errors'; +import { BaseEvaluationStrategy, ProviderResolutionResult, FirstMatchStrategy } from './strategies'; +import { StatusTracker } from './status-tracker'; +import { ProviderEntryInput, RegisteredProvider } from './types'; + +export class WebMultiProvider implements Provider { + readonly runsOn = 'client'; + + public readonly events = new OpenFeatureEventEmitter(); + + private hookContexts: WeakMap = new WeakMap(); + private hookHints: WeakMap = new WeakMap(); + + metadata: ProviderMetadata; + + providerEntries: RegisteredProvider[] = []; + private providerEntriesByName: Record = {}; + + private hookExecutor: HookExecutor; + private statusTracker = new StatusTracker(this.events); + + constructor( + readonly constructorProviders: ProviderEntryInput[], + private readonly evaluationStrategy: BaseEvaluationStrategy = new FirstMatchStrategy(), + private readonly logger: Logger = new DefaultLogger(), + ) { + this.hookExecutor = new HookExecutor(this.logger); + + this.registerProviders(constructorProviders); + + const aggregateMetadata = Object.keys(this.providerEntriesByName).reduce((acc, name) => { + return { ...acc, [name]: this.providerEntriesByName[name].provider.metadata }; + }, {}); + + this.metadata = { + ...aggregateMetadata, + name: WebMultiProvider.name, + }; + } + + private registerProviders(constructorProviders: ProviderEntryInput[]) { + const providersByName: Record = {}; + + for (const constructorProvider of constructorProviders) { + const providerName = constructorProvider.provider.metadata.name; + const candidateName = constructorProvider.name ?? providerName; + + if (constructorProvider.name && providersByName[constructorProvider.name]) { + throw new Error('Provider names must be unique'); + } + + providersByName[candidateName] ??= []; + providersByName[candidateName].push(constructorProvider.provider); + } + + for (const name of Object.keys(providersByName)) { + const useIndexedNames = providersByName[name].length > 1; + for (let i = 0; i < providersByName[name].length; i++) { + const indexedName = useIndexedNames ? `${name}-${i + 1}` : name; + this.providerEntriesByName[indexedName] = { provider: providersByName[name][i], name: indexedName }; + this.providerEntries.push(this.providerEntriesByName[indexedName]); + this.statusTracker.wrapEventHandler(this.providerEntriesByName[indexedName]); + } + } + + // just make sure we don't accidentally modify these later + Object.freeze(this.providerEntries); + Object.freeze(this.providerEntriesByName); + } + + async initialize(context?: EvaluationContext): Promise { + const result = await Promise.allSettled( + this.providerEntries.map((provider) => provider.provider.initialize?.(context)), + ); + throwAggregateErrorFromPromiseResults(result, this.providerEntries); + } + + async onClose() { + const result = await Promise.allSettled(this.providerEntries.map((provider) => provider.provider.onClose?.())); + throwAggregateErrorFromPromiseResults(result, this.providerEntries); + } + + async onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext) { + for (const providerEntry of this.providerEntries) { + await providerEntry.provider.onContextChange?.(oldContext, newContext); + } + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + ): ResolutionDetails { + return this.flagResolutionProxy(flagKey, 'boolean', defaultValue, context); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + ): ResolutionDetails { + return this.flagResolutionProxy(flagKey, 'string', defaultValue, context); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + ): ResolutionDetails { + return this.flagResolutionProxy(flagKey, 'number', defaultValue, context); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + ): ResolutionDetails { + return this.flagResolutionProxy(flagKey, 'object', defaultValue, context); + } + + private flagResolutionProxy( + flagKey: string, + flagType: FlagValueType, + defaultValue: T, + context: EvaluationContext, + ): ResolutionDetails { + const hookContext = this.hookContexts.get(context); + const hookHints = this.hookHints.get(context); + + if (!hookContext || !hookHints) { + throw new GeneralError('Hook context not available for evaluation'); + } + + const results = [] as (ProviderResolutionResult | null)[]; + + for (const providerEntry of this.providerEntries) { + const [shouldEvaluateNext, result] = this.evaluateProviderEntry( + flagKey, + flagType, + defaultValue, + providerEntry, + hookContext, + hookHints, + context, + ); + + results.push(result); + + if (!shouldEvaluateNext) { + break; + } + } + + const resolutions = results.filter((r): r is ProviderResolutionResult => Boolean(r)); + const finalResult = this.evaluationStrategy.determineFinalResult({ flagKey, flagType }, context, resolutions); + + if (finalResult.errors?.length) { + throw constructAggregateError(finalResult.errors); + } + + if (!finalResult.details) { + throw new GeneralError('No result was returned from any provider'); + } + + return finalResult.details; + } + + private evaluateProviderEntry( + flagKey: string, + flagType: FlagValueType, + defaultValue: T, + providerEntry: RegisteredProvider, + hookContext: HookContext, + hookHints: HookHints, + context: EvaluationContext, + ): [shouldEvaluateNext: boolean, ProviderResolutionResult | null] { + let evaluationResult: ResolutionDetails | undefined = undefined; + const provider = providerEntry.provider; + const strategyContext = { + flagKey, + flagType, + provider, + providerName: providerEntry.name, + providerStatus: this.statusTracker.providerStatus(providerEntry.name), + }; + + if (!this.evaluationStrategy.shouldEvaluateThisProvider(strategyContext, context)) { + return [true, null]; + } + + let resolution: ProviderResolutionResult; + + try { + evaluationResult = this.evaluateProviderAndHooks(flagKey, defaultValue, provider, hookContext, hookHints); + resolution = { + details: evaluationResult, + provider: provider, + providerName: providerEntry.name, + }; + } catch (error: unknown) { + resolution = { + thrownError: error, + provider: provider, + providerName: providerEntry.name, + }; + } + + return [this.evaluationStrategy.shouldEvaluateNextProvider(strategyContext, context, resolution), resolution]; + } + + private evaluateProviderAndHooks( + flagKey: string, + defaultValue: T, + provider: Provider, + hookContext: HookContext, + hookHints: HookHints, + ) { + let evaluationResult: ResolutionDetails; + + try { + this.hookExecutor.beforeHooks(provider.hooks, hookContext, hookHints); + + evaluationResult = this.callProviderResolve( + provider, + flagKey, + defaultValue, + hookContext.context, + ) as ResolutionDetails; + + const afterHookEvalDetails = { + ...evaluationResult, + flagMetadata: Object.freeze(evaluationResult.flagMetadata ?? {}), + flagKey, + }; + + this.hookExecutor.afterHooks(provider.hooks, hookContext, afterHookEvalDetails, hookHints); + return evaluationResult; + } catch (error: unknown) { + this.hookExecutor.errorHooks(provider.hooks, hookContext, error, hookHints); + throw error; + } finally { + this.hookExecutor.finallyHooks(provider.hooks, hookContext, hookHints); + } + } + + private callProviderResolve( + provider: Provider, + flagKey: string, + defaultValue: T, + context: EvaluationContext, + ) { + switch (typeof defaultValue) { + case 'string': + return provider.resolveStringEvaluation(flagKey, defaultValue, context, this.logger); + case 'number': + return provider.resolveNumberEvaluation(flagKey, defaultValue, context, this.logger); + case 'object': + return provider.resolveObjectEvaluation(flagKey, defaultValue, context, this.logger); + case 'boolean': + return provider.resolveBooleanEvaluation(flagKey, defaultValue, context, this.logger); + default: + throw new GeneralError('Invalid flag evaluation type'); + } + } + + public get hooks(): Hook[] { + return [ + { + before: (hookContext: BeforeHookContext, hints: HookHints): EvaluationContext => { + this.hookContexts.set(hookContext.context, hookContext); + this.hookHints.set(hookContext.context, hints ?? {}); + return hookContext.context; + }, + }, + ]; + } +} diff --git a/libs/providers/multi-provider-web/src/lib/status-tracker.ts b/libs/providers/multi-provider-web/src/lib/status-tracker.ts new file mode 100644 index 000000000..6059cc260 --- /dev/null +++ b/libs/providers/multi-provider-web/src/lib/status-tracker.ts @@ -0,0 +1,66 @@ +import { EventDetails, OpenFeatureEventEmitter, ProviderEvents, ProviderStatus } from '@openfeature/web-sdk'; +import { RegisteredProvider } from './types'; + +/** + * Tracks each individual provider's status by listening to emitted events + * Maintains an overall "status" for the multi provider which represents the "most critical" status out of all providers + */ +export class StatusTracker { + private readonly providerStatuses: Record = {}; + + constructor(private events: OpenFeatureEventEmitter) {} + + wrapEventHandler(providerEntry: RegisteredProvider) { + const provider = providerEntry.provider; + provider.events?.addHandler(ProviderEvents.Error, (details) => { + this.changeProviderStatus(providerEntry.name, ProviderStatus.ERROR, details); + }); + + provider.events?.addHandler(ProviderEvents.Stale, (details) => { + this.changeProviderStatus(providerEntry.name, ProviderStatus.STALE, details); + }); + + provider.events?.addHandler(ProviderEvents.ConfigurationChanged, (details) => { + this.events.emit(ProviderEvents.ConfigurationChanged, details); + }); + + provider.events?.addHandler(ProviderEvents.Ready, (details) => { + this.changeProviderStatus(providerEntry.name, ProviderStatus.READY, details); + }); + } + + providerStatus(name: string) { + return this.providerStatuses[name]; + } + + private getStatusFromProviderStatuses() { + const statuses = Object.values(this.providerStatuses); + if (statuses.includes(ProviderStatus.FATAL)) { + return ProviderStatus.FATAL; + } else if (statuses.includes(ProviderStatus.NOT_READY)) { + return ProviderStatus.NOT_READY; + } else if (statuses.includes(ProviderStatus.ERROR)) { + return ProviderStatus.ERROR; + } else if (statuses.includes(ProviderStatus.STALE)) { + return ProviderStatus.STALE; + } + return ProviderStatus.READY; + } + + private changeProviderStatus(name: string, status: ProviderStatus, details?: EventDetails) { + const currentStatus = this.getStatusFromProviderStatuses(); + this.providerStatuses[name] = status; + const newStatus = this.getStatusFromProviderStatuses(); + if (currentStatus !== newStatus) { + if (newStatus === ProviderStatus.FATAL) { + this.events.emit(ProviderEvents.Error, details); + } else if (newStatus === ProviderStatus.ERROR) { + this.events.emit(ProviderEvents.Error, details); + } else if (newStatus === ProviderStatus.STALE) { + this.events.emit(ProviderEvents.Stale, details); + } else if (newStatus === ProviderStatus.READY) { + this.events.emit(ProviderEvents.Ready, details); + } + } + } +} diff --git a/libs/providers/multi-provider-web/src/lib/strategies/BaseEvaluationStrategy.ts b/libs/providers/multi-provider-web/src/lib/strategies/BaseEvaluationStrategy.ts new file mode 100644 index 000000000..88cb2b6f4 --- /dev/null +++ b/libs/providers/multi-provider-web/src/lib/strategies/BaseEvaluationStrategy.ts @@ -0,0 +1,111 @@ +import { + ErrorCode, + EvaluationContext, + FlagValue, + FlagValueType, + OpenFeatureError, + Provider, + ProviderStatus, + ResolutionDetails, +} from '@openfeature/web-sdk'; +import { ErrorWithCode } from '../errors'; + +export type StrategyEvaluationContext = { + flagKey: string; + flagType: FlagValueType; +}; +export type StrategyPerProviderContext = StrategyEvaluationContext & { + provider: Provider; + providerName: string; + providerStatus: ProviderStatus; +}; + +type ProviderResolutionResultBase = { + provider: Provider; + providerName: string; +}; + +export type ProviderResolutionSuccessResult = ProviderResolutionResultBase & { + details: ResolutionDetails; +}; + +export type ProviderResolutionErrorResult = ProviderResolutionResultBase & { + thrownError: unknown; +}; + +export type ProviderResolutionResult = + | ProviderResolutionSuccessResult + | ProviderResolutionErrorResult; + +export type FinalResult = { + details?: ResolutionDetails; + provider?: Provider; + providerName?: string; + errors?: { + providerName: string; + error: unknown; + }[]; +}; + +/** + * Base strategy to inherit from. Not directly usable, as strategies must implement the "determineResult" method + * Contains default implementations for `shouldEvaluateThisProvider` and `shouldEvaluateNextProvider` + */ +export abstract class BaseEvaluationStrategy { + shouldEvaluateThisProvider(strategyContext: StrategyPerProviderContext, evalContext: EvaluationContext): boolean { + if ( + strategyContext.providerStatus === ProviderStatus.NOT_READY || + strategyContext.providerStatus === ProviderStatus.FATAL + ) { + return false; + } + return true; + } + + shouldEvaluateNextProvider( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + result: ProviderResolutionResult, + ): boolean { + return true; + } + + abstract determineFinalResult( + strategyContext: StrategyEvaluationContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult; + + protected hasError(resolution: ProviderResolutionResult): resolution is + | ProviderResolutionErrorResult + | (ProviderResolutionSuccessResult & { + details: ResolutionDetails & { errorCode: ErrorCode }; + }) { + return 'thrownError' in resolution || !!resolution.details.errorCode; + } + + protected hasErrorWithCode(resolution: ProviderResolutionResult, code: ErrorCode): boolean { + return 'thrownError' in resolution + ? (resolution.thrownError as OpenFeatureError)?.code === code + : resolution.details.errorCode === code; + } + + protected collectProviderErrors(resolutions: ProviderResolutionResult[]): FinalResult { + const errors: FinalResult['errors'] = []; + for (const resolution of resolutions) { + if ('thrownError' in resolution) { + errors.push({ providerName: resolution.providerName, error: resolution.thrownError }); + } else if (resolution.details.errorCode) { + errors.push({ + providerName: resolution.providerName, + error: new ErrorWithCode(resolution.details.errorCode, resolution.details.errorMessage ?? 'unknown error'), + }); + } + } + return { errors }; + } + + protected resolutionToFinalResult(resolution: ProviderResolutionSuccessResult) { + return { details: resolution.details, provider: resolution.provider, providerName: resolution.providerName }; + } +} diff --git a/libs/providers/multi-provider-web/src/lib/strategies/ComparisonStrategy.ts b/libs/providers/multi-provider-web/src/lib/strategies/ComparisonStrategy.ts new file mode 100644 index 000000000..578776852 --- /dev/null +++ b/libs/providers/multi-provider-web/src/lib/strategies/ComparisonStrategy.ts @@ -0,0 +1,68 @@ +import { + BaseEvaluationStrategy, + FinalResult, + ProviderResolutionResult, + ProviderResolutionSuccessResult, + StrategyPerProviderContext, +} from './BaseEvaluationStrategy'; +import { EvaluationContext, FlagValue, GeneralError, Provider } from '@openfeature/web-sdk'; + +/** + * Evaluate all providers and compare the results. + * If the values agree, return the value + * If the values disagree, return the value from the configured "fallback provider" and execute the "onMismatch" + * callback if defined + */ +export class ComparisonStrategy extends BaseEvaluationStrategy { + constructor( + private fallbackProvider: Provider, + private onMismatch?: (resolutions: ProviderResolutionResult[]) => void, + ) { + super(); + } + + override determineFinalResult( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult { + let value: T | undefined; + let fallbackResolution: ProviderResolutionSuccessResult | undefined; + let finalResolution: ProviderResolutionSuccessResult | undefined; + let mismatch = false; + for (const [i, resolution] of resolutions.entries()) { + if (this.hasError(resolution)) { + return this.collectProviderErrors(resolutions); + } + if (resolution.provider === this.fallbackProvider) { + fallbackResolution = resolution; + } + if (i === 0) { + finalResolution = resolution; + } + if (typeof value !== 'undefined' && value !== resolution.details.value) { + mismatch = true; + } else { + value = resolution.details.value; + } + } + + if (!fallbackResolution) { + throw new GeneralError('Fallback provider not found in resolution results'); + } + + if (!finalResolution) { + throw new GeneralError('Final resolution not found in resolution results'); + } + + if (mismatch) { + this.onMismatch?.(resolutions); + return { + details: fallbackResolution.details, + provider: fallbackResolution.provider, + }; + } + + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/libs/providers/multi-provider-web/src/lib/strategies/FirstMatchStrategy.ts b/libs/providers/multi-provider-web/src/lib/strategies/FirstMatchStrategy.ts new file mode 100644 index 000000000..8c81a0759 --- /dev/null +++ b/libs/providers/multi-provider-web/src/lib/strategies/FirstMatchStrategy.ts @@ -0,0 +1,39 @@ +import { + BaseEvaluationStrategy, + FinalResult, + ProviderResolutionResult, + StrategyPerProviderContext, +} from './BaseEvaluationStrategy'; +import { ErrorCode, EvaluationContext, FlagValue } from '@openfeature/web-sdk'; + +/** + * Return the first result that did not indicate "flag not found". + * If any provider in the course of evaluation returns or throws an error, throw that error + */ +export class FirstMatchStrategy extends BaseEvaluationStrategy { + override shouldEvaluateNextProvider( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + result: ProviderResolutionResult, + ): boolean { + if (this.hasErrorWithCode(result, ErrorCode.FLAG_NOT_FOUND)) { + return true; + } + if (this.hasError(result)) { + return false; + } + return false; + } + + override determineFinalResult( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/libs/providers/multi-provider-web/src/lib/strategies/FirstSuccessfulStrategy.ts b/libs/providers/multi-provider-web/src/lib/strategies/FirstSuccessfulStrategy.ts new file mode 100644 index 000000000..163f3464a --- /dev/null +++ b/libs/providers/multi-provider-web/src/lib/strategies/FirstSuccessfulStrategy.ts @@ -0,0 +1,35 @@ +import { + BaseEvaluationStrategy, + FinalResult, + ProviderResolutionResult, + StrategyPerProviderContext, +} from './BaseEvaluationStrategy'; +import { EvaluationContext, FlagValue } from '@openfeature/web-sdk'; + +/** + * Return the first result that did result in an error + * If any provider in the course of evaluation returns or throws an error, ignore it as long as there is a successful result + * If there is no successful result, throw all errors + */ +export class FirstSuccessfulStrategy extends BaseEvaluationStrategy { + override shouldEvaluateNextProvider( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + result: ProviderResolutionResult, + ): boolean { + // evaluate next only if there was an error + return this.hasError(result); + } + + override determineFinalResult( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/libs/providers/multi-provider-web/src/lib/strategies/index.ts b/libs/providers/multi-provider-web/src/lib/strategies/index.ts new file mode 100644 index 000000000..c11ad275c --- /dev/null +++ b/libs/providers/multi-provider-web/src/lib/strategies/index.ts @@ -0,0 +1,4 @@ +export * from './BaseEvaluationStrategy'; +export * from './FirstMatchStrategy'; +export * from './FirstSuccessfulStrategy'; +export * from './ComparisonStrategy'; diff --git a/libs/providers/multi-provider-web/src/lib/types.ts b/libs/providers/multi-provider-web/src/lib/types.ts new file mode 100644 index 000000000..fbc13bce8 --- /dev/null +++ b/libs/providers/multi-provider-web/src/lib/types.ts @@ -0,0 +1,10 @@ +// Represents an entry in the constructor's provider array which may or may not have a name set +import { Provider } from '@openfeature/web-sdk'; + +export type ProviderEntryInput = { + provider: Provider; + name?: string; +}; + +// Represents a processed and "registered" provider entry where a name has been chosen +export type RegisteredProvider = Required; diff --git a/libs/providers/multi-provider-web/tsconfig.json b/libs/providers/multi-provider-web/tsconfig.json new file mode 100644 index 000000000..140e5a783 --- /dev/null +++ b/libs/providers/multi-provider-web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "ES6", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/providers/multi-provider-web/tsconfig.lib.json b/libs/providers/multi-provider-web/tsconfig.lib.json new file mode 100644 index 000000000..4befa7f09 --- /dev/null +++ b/libs/providers/multi-provider-web/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/providers/multi-provider-web/tsconfig.spec.json b/libs/providers/multi-provider-web/tsconfig.spec.json new file mode 100644 index 000000000..b2ee74a6b --- /dev/null +++ b/libs/providers/multi-provider-web/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +}