From 21c7f115792e20face33e768f91dd6acc51e4702 Mon Sep 17 00:00:00 2001 From: jarebudev <23311805+jarebudev@users.noreply.github.com> Date: Fri, 8 Nov 2024 22:40:07 +0000 Subject: [PATCH 01/10] initial unleash provider - wip Signed-off-by: jarebudev <23311805+jarebudev@users.noreply.github.com> --- .release-please-manifest.json | 3 +- libs/providers/unleash-web/.eslintrc.json | 25 +++ libs/providers/unleash-web/README.md | 15 ++ libs/providers/unleash-web/babel.config.json | 3 + libs/providers/unleash-web/jest.config.ts | 10 ++ libs/providers/unleash-web/package-lock.json | 104 ++++++++++++ libs/providers/unleash-web/package.json | 17 ++ libs/providers/unleash-web/project.json | 77 +++++++++ libs/providers/unleash-web/src/index.ts | 1 + libs/providers/unleash-web/src/lib/options.ts | 21 +++ .../src/lib/unleash-web-provider.spec.ts | 7 + .../src/lib/unleash-web-provider.ts | 148 ++++++++++++++++++ libs/providers/unleash-web/tsconfig.json | 22 +++ libs/providers/unleash-web/tsconfig.lib.json | 10 ++ libs/providers/unleash-web/tsconfig.spec.json | 9 ++ release-please-config.json | 7 + tsconfig.base.json | 3 +- 17 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 libs/providers/unleash-web/.eslintrc.json create mode 100644 libs/providers/unleash-web/README.md create mode 100644 libs/providers/unleash-web/babel.config.json create mode 100644 libs/providers/unleash-web/jest.config.ts create mode 100644 libs/providers/unleash-web/package-lock.json create mode 100644 libs/providers/unleash-web/package.json create mode 100644 libs/providers/unleash-web/project.json create mode 100644 libs/providers/unleash-web/src/index.ts create mode 100644 libs/providers/unleash-web/src/lib/options.ts create mode 100644 libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts create mode 100644 libs/providers/unleash-web/src/lib/unleash-web-provider.ts create mode 100644 libs/providers/unleash-web/tsconfig.json create mode 100644 libs/providers/unleash-web/tsconfig.lib.json create mode 100644 libs/providers/unleash-web/tsconfig.spec.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1ca017210..7deb1969a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -18,5 +18,6 @@ "libs/providers/multi-provider-web": "0.0.2", "libs/providers/growthbook-client": "0.1.1", "libs/providers/config-cat-web": "0.1.2", - "libs/shared/config-cat-core": "0.1.0" + "libs/shared/config-cat-core": "0.1.0", + "libs/providers/unleash-web": "0.1.0" } diff --git a/libs/providers/unleash-web/.eslintrc.json b/libs/providers/unleash-web/.eslintrc.json new file mode 100644 index 000000000..3230caf3d --- /dev/null +++ b/libs/providers/unleash-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/unleash-web/README.md b/libs/providers/unleash-web/README.md new file mode 100644 index 000000000..6de0c5ad6 --- /dev/null +++ b/libs/providers/unleash-web/README.md @@ -0,0 +1,15 @@ +# unleash-web Provider + +## Installation + +``` +$ npm install @openfeature/unleash-web-provider +``` + +## Building + +Run `nx package providers-unleash-web` to build the library. + +## Running unit tests + +Run `nx test providers-unleash-web` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/providers/unleash-web/babel.config.json b/libs/providers/unleash-web/babel.config.json new file mode 100644 index 000000000..d7bf474d1 --- /dev/null +++ b/libs/providers/unleash-web/babel.config.json @@ -0,0 +1,3 @@ +{ + "presets": [["minify", { "builtIns": false }]] +} diff --git a/libs/providers/unleash-web/jest.config.ts b/libs/providers/unleash-web/jest.config.ts new file mode 100644 index 000000000..a84e57338 --- /dev/null +++ b/libs/providers/unleash-web/jest.config.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +export default { + displayName: 'providers-unleash-web', + preset: '../../../jest.preset.js', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/providers/unleash-web', +}; diff --git a/libs/providers/unleash-web/package-lock.json b/libs/providers/unleash-web/package-lock.json new file mode 100644 index 000000000..27cb14f89 --- /dev/null +++ b/libs/providers/unleash-web/package-lock.json @@ -0,0 +1,104 @@ +{ + "name": "@openfeature/unleash-web-provider", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@openfeature/unleash-web-provider", + "version": "0.0.1", + "dependencies": { + "tslib": "^2.3.0", + "unleash-proxy-client": "^3.6.1" + }, + "peerDependencies": { + "@openfeature/web-sdk": "^1.0.0" + } + }, + "node_modules/@openfeature/core": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.4.0.tgz", + "integrity": "sha512-Cd5eeAouAYaj1RMgVq4gfasoAc4TSkN4fuhloZ3yCQA2t74IdVMAT0iadq1Seqy+G7PZoN2jy706ei9HT55PIg==", + "peer": true + }, + "node_modules/@openfeature/web-sdk": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@openfeature/web-sdk/-/web-sdk-1.2.4.tgz", + "integrity": "sha512-v3RYqMIq+/UXH7eVqfTfp7iWPJ4/Ck5a3RwxAEhypocq5IxUDyEUxXvVU82bkVkbNEKvXYLUWlxT+IuHvh8Eng==", + "peer": true, + "peerDependencies": { + "@openfeature/core": "1.4.0" + } + }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, + "node_modules/unleash-proxy-client": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/unleash-proxy-client/-/unleash-proxy-client-3.6.1.tgz", + "integrity": "sha512-gbvkob/cBewLHMh9aAwWLDLN8D1efJ5FdUMva7wGBVykJMIqyYIlUsJpVNXnpq+feNBn6Qc1D1huXD2bk9bEmA==", + "dependencies": { + "tiny-emitter": "^2.1.0", + "uuid": "^9.0.1" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + } + }, + "dependencies": { + "@openfeature/core": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.4.0.tgz", + "integrity": "sha512-Cd5eeAouAYaj1RMgVq4gfasoAc4TSkN4fuhloZ3yCQA2t74IdVMAT0iadq1Seqy+G7PZoN2jy706ei9HT55PIg==", + "peer": true + }, + "@openfeature/web-sdk": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@openfeature/web-sdk/-/web-sdk-1.2.4.tgz", + "integrity": "sha512-v3RYqMIq+/UXH7eVqfTfp7iWPJ4/Ck5a3RwxAEhypocq5IxUDyEUxXvVU82bkVkbNEKvXYLUWlxT+IuHvh8Eng==", + "peer": true, + "requires": {} + }, + "tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" + }, + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, + "unleash-proxy-client": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/unleash-proxy-client/-/unleash-proxy-client-3.6.1.tgz", + "integrity": "sha512-gbvkob/cBewLHMh9aAwWLDLN8D1efJ5FdUMva7wGBVykJMIqyYIlUsJpVNXnpq+feNBn6Qc1D1huXD2bk9bEmA==", + "requires": { + "tiny-emitter": "^2.1.0", + "uuid": "^9.0.1" + } + }, + "uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" + } + } +} diff --git a/libs/providers/unleash-web/package.json b/libs/providers/unleash-web/package.json new file mode 100644 index 000000000..7df4f8a67 --- /dev/null +++ b/libs/providers/unleash-web/package.json @@ -0,0 +1,17 @@ +{ + "name": "@openfeature/unleash-web-provider", + "version": "0.0.1", + "dependencies": { + "tslib": "^2.3.0", + "unleash-proxy-client": "^3.6.1" + }, + "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.0.0" + } +} diff --git a/libs/providers/unleash-web/project.json b/libs/providers/unleash-web/project.json new file mode 100644 index 000000000..38cf36ea0 --- /dev/null +++ b/libs/providers/unleash-web/project.json @@ -0,0 +1,77 @@ +{ + "name": "providers-unleash-web", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/providers/unleash-web/src", + "projectType": "library", + "targets": { + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "npm run publish-if-not-exists", + "cwd": "dist/libs/providers/unleash-web" + }, + "dependsOn": [ + { + "projects": "self", + "target": "package" + } + ] + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/providers/unleash-web/**/*.ts", "libs/providers/unleash-web/package.json"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/providers/unleash-web/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "package": { + "executor": "@nx/rollup:rollup", + "outputs": ["{options.outputPath}"], + "options": { + "project": "libs/providers/unleash-web/package.json", + "outputPath": "dist/libs/providers/unleash-web", + "entryFile": "libs/providers/unleash-web/src/index.ts", + "tsConfig": "libs/providers/unleash-web/tsconfig.lib.json", + "buildableProjectDepsInPackageJsonType": "dependencies", + "updateBuildableProjectDepsInPackageJson": true, + "compiler": "tsc", + "generateExportsField": true, + "umdName": "unleash-web", + "external": "all", + "format": ["cjs", "esm"], + "assets": [ + { + "glob": "package.json", + "input": "./assets", + "output": "./src/" + }, + { + "glob": "LICENSE", + "input": "./", + "output": "./" + }, + { + "glob": "README.md", + "input": "./libs/providers/unleash-web", + "output": "./" + } + ] + } + } + }, + "tags": [] +} diff --git a/libs/providers/unleash-web/src/index.ts b/libs/providers/unleash-web/src/index.ts new file mode 100644 index 000000000..c322b795b --- /dev/null +++ b/libs/providers/unleash-web/src/index.ts @@ -0,0 +1 @@ +export * from './lib/unleash-web-provider'; diff --git a/libs/providers/unleash-web/src/lib/options.ts b/libs/providers/unleash-web/src/lib/options.ts new file mode 100644 index 000000000..6a9856955 --- /dev/null +++ b/libs/providers/unleash-web/src/lib/options.ts @@ -0,0 +1,21 @@ +export interface UnleashContextOptions { + userId?: string; + sessionId?: string; + remoteAddress?: string; + currentTime?: string; + properties?: { + [key: string]: string; + }; +} + +export interface UnleashOptions { + url: string; + clientKey: string; + appName: string; + context?: UnleashContextOptions; + refreshInterval?: number; + disableRefresh?: boolean; + metricsInterval?: number; + metricsIntervalInitial?: number; +} + diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts new file mode 100644 index 000000000..ce0968aab --- /dev/null +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts @@ -0,0 +1,7 @@ +import { UnleashWebProvider } from './unleash-web-provider'; + +describe('UnleashWebProvider', () => { + it('should be and instance of UnleashWebProvider', () => { + expect(new UnleashWebProvider()).toBeInstanceOf(UnleashWebProvider); + }); +}); diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts new file mode 100644 index 000000000..f02714bb0 --- /dev/null +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts @@ -0,0 +1,148 @@ +import { EvaluationContext, Provider, Logger, JsonValue, FlagNotFoundError, OpenFeatureEventEmitter, ProviderEvents, ResolutionDetails, ProviderFatalError, StandardResolutionReasons } from '@openfeature/web-sdk'; +import { UnleashClient, IConfig, IContext, IMutableContext } from 'unleash-proxy-client'; +import { UnleashOptions, UnleashContextOptions } from './options'; + +export class UnleashWebProvider implements Provider { + metadata = { + name: UnleashWebProvider.name, + }; + + public readonly events = new OpenFeatureEventEmitter(); + + // logger is the OpenFeature logger to use + private _logger?: Logger; + + // options is the Unleash options provided to the provider + private _options?: UnleashOptions; + + // client is the Unleash client reference + private _client?: UnleashClient; + + readonly runsOn = 'client'; + + hooks = []; + + constructor(options: UnleashOptions, logger?: Logger) { + this._options = options; + this._logger = logger; + // TODO map all available options to unleash config - done minimum for now + let config : IConfig = { + url: options.url, + clientKey: options.clientKey, + appName: options.appName, + }; + this._client = new UnleashClient(config); + } + + async initialize(): Promise { + await this.initializeClient(); + this._logger?.info('UnleashWebProvider initialized'); + } + + private async initializeClient() { + try { + this.registerEventListeners(); + this._client?.start(); + return new Promise((resolve) => { + this._client?.on('ready', () => { + this._logger?.info('Unleash ready event received'); + resolve(); + }); + }); + } catch (e) { + throw new ProviderFatalError(getErrorMessage(e)); + } + } + + private registerEventListeners() { + this._client?.on('update', () => { + this._logger?.info('Unleash update event received'); + this.events.emit(ProviderEvents.ConfigurationChanged, { + message: 'Flags changed' + }); + }); + } + + async onContextChange(_oldContext: EvaluationContext, newContext: EvaluationContext): Promise { + this._logger?.info("onContextChange = " + JSON.stringify(newContext)); + let unleashContext = new Map(); + let properties = new Map(); + Object.keys(newContext).forEach((key) => { + this._logger?.info(key + " = " + newContext[key]); + switch(key) { + case "appName": + case "userId": + case "environment": + case "remoteAddress": + case "sessionId": + case "currentTime": + unleashContext.set(key, newContext[key]); + break; + default: + properties.set(key, newContext[key]); + break; + } + }); + unleashContext.set('properties', properties); + await this._client?.updateContext(Object.fromEntries(unleashContext)); + this._logger?.info('Unleash context updated'); + } + + async onClose() { + this._logger?.info('closing Unleash client'); + this._client?.stop(); + } + + resolveBooleanEvaluation(flagKey: string, defaultValue: boolean): ResolutionDetails { + const resp = this._client?.isEnabled(flagKey); + this._logger?.debug("resp = " + resp); + if (typeof resp === 'undefined') { + throw new FlagNotFoundError(); + } + var message = resp ? ' is enabled' : ' is disabled'; + this._logger?.debug(flagKey + message); + return { + value: resp + } + } + + resolveStringEvaluation(flagKey: string, defaultValue: string): ResolutionDetails { + return this.evaluate(flagKey, defaultValue); + } + + resolveNumberEvaluation(flagKey: string, defaultValue: number): ResolutionDetails { + return this.evaluate(flagKey, defaultValue); + } + + resolveObjectEvaluation(flagKey: string, defaultValue: U): ResolutionDetails { + return this.evaluate(flagKey, defaultValue); + } + + private evaluate(flagKey: string, defaultValue: T): ResolutionDetails { + const evaluatedVariant = this._client?.getVariant(flagKey); + let retValue; + let retVariant + this._logger?.debug("evaluatedVariant = " + JSON.stringify(evaluatedVariant)); + if (typeof evaluatedVariant === 'undefined') { + throw new FlagNotFoundError(); + } + if (evaluatedVariant.name === 'disabled') { + retValue = defaultValue as T; + } + else { + retVariant = evaluatedVariant.name; + retValue = evaluatedVariant.payload?.value; + } + return { + variant: retVariant, + value: retValue as T, + }; + } +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} diff --git a/libs/providers/unleash-web/tsconfig.json b/libs/providers/unleash-web/tsconfig.json new file mode 100644 index 000000000..140e5a783 --- /dev/null +++ b/libs/providers/unleash-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/unleash-web/tsconfig.lib.json b/libs/providers/unleash-web/tsconfig.lib.json new file mode 100644 index 000000000..4befa7f09 --- /dev/null +++ b/libs/providers/unleash-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/unleash-web/tsconfig.spec.json b/libs/providers/unleash-web/tsconfig.spec.json new file mode 100644 index 000000000..b2ee74a6b --- /dev/null +++ b/libs/providers/unleash-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"] +} diff --git a/release-please-config.json b/release-please-config.json index df49f0b6e..caf824f69 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -143,6 +143,13 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default" + }, + "libs/providers/unleash-web": { + "release-type": "node", + "prerelease": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default" } }, "changelog-sections": [ diff --git a/tsconfig.base.json b/tsconfig.base.json index 4a9e01f6c..ed4a8079d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -35,7 +35,8 @@ "@openfeature/multi-provider": ["libs/providers/multi-provider/src/index.ts"], "@openfeature/ofrep-core": ["libs/shared/ofrep-core/src/index.ts"], "@openfeature/ofrep-provider": ["libs/providers/ofrep/src/index.ts"], - "@openfeature/ofrep-web-provider": ["libs/providers/ofrep-web/src/index.ts"] + "@openfeature/ofrep-web-provider": ["libs/providers/ofrep-web/src/index.ts"], + "@openfeature/unleash-web-provider": ["libs/providers/unleash-web/src/index.ts"] } }, "exclude": ["node_modules", "tmp"] From ca52bf19a38b0858b124021dc5bafd066f4e3174 Mon Sep 17 00:00:00 2001 From: jarebudev <23311805+jarebudev@users.noreply.github.com> Date: Tue, 26 Nov 2024 22:58:54 +0000 Subject: [PATCH 02/10] added evaluation tests Signed-off-by: jarebudev <23311805+jarebudev@users.noreply.github.com> --- .../unleash-web/src/lib/test-logger.ts | 39 +++++++ .../unleash-web/src/lib/testdata.json | 87 ++++++++++++++ .../src/lib/unleash-web-provider.spec.ts | 108 +++++++++++++++++- .../src/lib/unleash-web-provider.ts | 21 ++-- libs/providers/unleash-web/tsconfig.json | 3 +- 5 files changed, 243 insertions(+), 15 deletions(-) create mode 100644 libs/providers/unleash-web/src/lib/test-logger.ts create mode 100644 libs/providers/unleash-web/src/lib/testdata.json diff --git a/libs/providers/unleash-web/src/lib/test-logger.ts b/libs/providers/unleash-web/src/lib/test-logger.ts new file mode 100644 index 000000000..3005038fb --- /dev/null +++ b/libs/providers/unleash-web/src/lib/test-logger.ts @@ -0,0 +1,39 @@ +/** + * TestLogger is a logger build for testing purposes. + * This is not ready to be production ready, so please avoid using it. + */ +export default class TestLogger { + public inMemoryLogger: Record = { + error: [], + warn: [], + info: [], + debug: [], + }; + + error(...args: unknown[]): void { + this.inMemoryLogger['error'].push(args.join(' ')); + } + + warn(...args: unknown[]): void { + this.inMemoryLogger['warn'].push(args.join(' ')); + } + + info(...args: unknown[]): void { + console.log(args) + this.inMemoryLogger['info'].push(args.join(' ')); + } + + debug(...args: unknown[]): void { + console.log(args) + this.inMemoryLogger['debug'].push(args.join(' ')); + } + + reset() { + this.inMemoryLogger = { + error: [], + warn: [], + info: [], + debug: [], + }; + } +} diff --git a/libs/providers/unleash-web/src/lib/testdata.json b/libs/providers/unleash-web/src/lib/testdata.json new file mode 100644 index 000000000..b7e14d479 --- /dev/null +++ b/libs/providers/unleash-web/src/lib/testdata.json @@ -0,0 +1,87 @@ +{ + "toggles": [ + { + "name": "simpleToggle", + "enabled": true, + "impressionData": true + }, + { + "name": "disabledToggle", + "enabled": false, + "impressionData": true + }, + { + "name": "variantToggleString", + "enabled": true, + "impressionData": true, + "variant": { + "name": "string", + "payload": { + "type": "string", + "value": "some-text" + }, + "enabled": true, + "feature_enabled": true + } + }, + { + "name": "variantToggleJson", + "enabled": true, + "impressionData": true, + "variant": { + "name": "json", + "payload": { + "type": "json", + "value": "{hello: world}" + }, + "enabled": true, + "feature_enabled": true + } + }, + { + "name": "variantToggleCsv", + "enabled": true, + "variant": { + "name": "csv", + "enabled": true, + "payload": { + "type": "csv", + "value": "1,2,3,4" + }, + "feature_enabled": true, + "featureEnabled": true + }, + "impressionData": false + }, + { + "name": "variantToggleInteger", + "enabled": true, + "variant": { + "name": "number", + "enabled": true, + "payload": { + "type": "number", + "value": "3" + }, + "feature_enabled": true, + "featureEnabled": true + }, + "impressionData": false + }, + { + "name": "variantToggleDouble", + "enabled": true, + "variant": { + "name": "number", + "enabled": true, + "payload": { + "type": "number", + "value": "1.2" + }, + "feature_enabled": true, + "featureEnabled": true + }, + "impressionData": false + } + ] +} diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts index ce0968aab..8b0ce0ea0 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts @@ -1,7 +1,111 @@ import { UnleashWebProvider } from './unleash-web-provider'; +import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; + +import testdata from './testdata.json'; +import TestLogger from './test-logger'; describe('UnleashWebProvider', () => { - it('should be and instance of UnleashWebProvider', () => { - expect(new UnleashWebProvider()).toBeInstanceOf(UnleashWebProvider); + const endpoint = 'http://localhost:4242'; + const logger = new TestLogger(); + const valueProperty = 'value'; + + let provider: UnleashWebProvider; + + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockReset(); + }); + + beforeAll(async () => { + enableFetchMocks(); + //fetchMock.mockResponseOnce(JSON.stringify({"toggles":[]})); + fetchMock.mockResponseOnce(JSON.stringify(testdata)); + provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test',}, logger); + await provider.initialize(); + }); + + it('should be an instance of UnleashWebProvider', async () => { + expect(provider).toBeInstanceOf(UnleashWebProvider); + }); + + describe('method resolveBooleanEvaluation', () => { + it('should return false for missing toggle', async () => { + const evaluation = await provider.resolveBooleanEvaluation('nonExistent'); + expect(evaluation).toHaveProperty(valueProperty, false); + }); + + it('should return true if enabled toggle exists', async () => { + const evaluation = await provider.resolveBooleanEvaluation('simpleToggle'); + expect(evaluation).toHaveProperty(valueProperty, true); + }); + + it('should return false if a disabled toggle exists', async () => { + const evaluation = await provider.resolveBooleanEvaluation('disabledToggle'); + expect(evaluation).toHaveProperty(valueProperty, false); + }); + }); + + describe('method resolveStringEvaluation', () => { + it('should return default value for missing value', async () => { + const evaluation = await provider.resolveStringEvaluation('nonExistent', 'defaultValue'); + expect(evaluation).toHaveProperty(valueProperty, 'defaultValue'); + }); + + it('should return right value if variant toggle exists and is enabled', async () => { + const evaluation = await provider.resolveStringEvaluation('variantToggleString', 'variant1'); + expect(evaluation).toHaveProperty(valueProperty, 'some-text'); + }); + + it('should return default value if a toggle is disabled', async () => { + const evaluation = await provider.resolveStringEvaluation('disabledVariant', 'defaultValue'); + expect(evaluation).toHaveProperty(valueProperty, 'defaultValue'); + }); + }); + + describe('method resolveNumberEvaluation', () => { + it('should return default value for missing value', async () => { + const evaluation = await provider.resolveNumberEvaluation('nonExistent', 5); + expect(evaluation).toHaveProperty(valueProperty, 5); + }); + + it('should return integer value if variant toggle exists and is enabled', async () => { + const evaluation = await provider.resolveNumberEvaluation('variantToggleInteger', 0); + expect(evaluation).toHaveProperty(valueProperty, 3); + }); + + it('should return double value if variant toggle exists and is enabled', async () => { + const evaluation = await provider.resolveNumberEvaluation('variantToggleDouble', 0); + expect(evaluation).toHaveProperty(valueProperty, 1.2); + }); + + it('should return default value if a toggle is disabled', async () => { + const evaluation = await provider.resolveNumberEvaluation('disabledVariant', 0); + expect(evaluation).toHaveProperty(valueProperty, 0); + }); + }); + + describe('method resolveObjectEvaluation', () => { + it('should return default value for missing value', async () => { + const defaultValue = '{"notFound" : true}'; + const evaluation = await provider.resolveObjectEvaluation('nonExistent', JSON.parse(defaultValue)); + expect(evaluation).toHaveProperty(valueProperty, JSON.parse(defaultValue)); + }); + + it('should return json value if variant toggle exists and is enabled', async () => { + const expectedVariant = '{hello: world}'; + const evaluation = await provider.resolveObjectEvaluation('variantToggleJson', JSON.parse('{"default": false}')); + expect(evaluation).toHaveProperty(valueProperty, expectedVariant); + }); + + it('should return csv value if variant toggle exists and is enabled', async () => { + const evaluation = await provider.resolveObjectEvaluation('variantToggleCsv', 'a,b,c,d'); + expect(evaluation).toHaveProperty(valueProperty, '1,2,3,4'); + }); + + it('should return default value if a toggle is disabled', async () => { + const defaultValue = '{foo: bar}'; + const evaluation = await provider.resolveObjectEvaluation('disabledVariant', defaultValue); + expect(evaluation).toHaveProperty(valueProperty, defaultValue); + }); }); }); diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts index f02714bb0..eb664c640 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts @@ -20,8 +20,6 @@ export class UnleashWebProvider implements Provider { readonly runsOn = 'client'; - hooks = []; - constructor(options: UnleashOptions, logger?: Logger) { this._options = options; this._logger = logger; @@ -64,7 +62,6 @@ export class UnleashWebProvider implements Provider { } async onContextChange(_oldContext: EvaluationContext, newContext: EvaluationContext): Promise { - this._logger?.info("onContextChange = " + JSON.stringify(newContext)); let unleashContext = new Map(); let properties = new Map(); Object.keys(newContext).forEach((key) => { @@ -93,14 +90,11 @@ export class UnleashWebProvider implements Provider { this._client?.stop(); } - resolveBooleanEvaluation(flagKey: string, defaultValue: boolean): ResolutionDetails { + resolveBooleanEvaluation(flagKey: string): ResolutionDetails { const resp = this._client?.isEnabled(flagKey); - this._logger?.debug("resp = " + resp); if (typeof resp === 'undefined') { throw new FlagNotFoundError(); } - var message = resp ? ' is enabled' : ' is disabled'; - this._logger?.debug(flagKey + message); return { value: resp } @@ -111,7 +105,9 @@ export class UnleashWebProvider implements Provider { } resolveNumberEvaluation(flagKey: string, defaultValue: number): ResolutionDetails { - return this.evaluate(flagKey, defaultValue); + let resolutionDetails = this.evaluate(flagKey, defaultValue); + resolutionDetails.value = Number(resolutionDetails.value); + return resolutionDetails; } resolveObjectEvaluation(flagKey: string, defaultValue: U): ResolutionDetails { @@ -120,22 +116,23 @@ export class UnleashWebProvider implements Provider { private evaluate(flagKey: string, defaultValue: T): ResolutionDetails { const evaluatedVariant = this._client?.getVariant(flagKey); - let retValue; + let value; let retVariant this._logger?.debug("evaluatedVariant = " + JSON.stringify(evaluatedVariant)); if (typeof evaluatedVariant === 'undefined') { throw new FlagNotFoundError(); } + if (evaluatedVariant.name === 'disabled') { - retValue = defaultValue as T; + value = defaultValue as T; } else { retVariant = evaluatedVariant.name; - retValue = evaluatedVariant.payload?.value; + value = evaluatedVariant.payload?.value; } return { variant: retVariant, - value: retValue as T, + value: value as T, }; } } diff --git a/libs/providers/unleash-web/tsconfig.json b/libs/providers/unleash-web/tsconfig.json index 140e5a783..195a2f2e8 100644 --- a/libs/providers/unleash-web/tsconfig.json +++ b/libs/providers/unleash-web/tsconfig.json @@ -7,7 +7,8 @@ "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true }, "files": [], "include": [], From 2678eecd96afff8b669444a50489326ea66df8d3 Mon Sep 17 00:00:00 2001 From: jarebudev <23311805+jarebudev@users.noreply.github.com> Date: Mon, 2 Dec 2024 22:51:19 +0000 Subject: [PATCH 03/10] added event tests Signed-off-by: jarebudev <23311805+jarebudev@users.noreply.github.com> --- .../src/lib/unleash-web-provider.spec.ts | 117 ++++++++++++++++-- .../src/lib/unleash-web-provider.ts | 61 ++++++--- 2 files changed, 153 insertions(+), 25 deletions(-) diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts index 8b0ce0ea0..590d2b545 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts @@ -1,31 +1,128 @@ import { UnleashWebProvider } from './unleash-web-provider'; import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; +import { + EvaluationContext, + OpenFeature, + ProviderEvents, + ProviderStatus, + StandardResolutionReasons, + ErrorCode, + EvaluationDetails, + JsonValue, +} from '@openfeature/web-sdk'; import testdata from './testdata.json'; import TestLogger from './test-logger'; -describe('UnleashWebProvider', () => { - const endpoint = 'http://localhost:4242'; - const logger = new TestLogger(); - const valueProperty = 'value'; +const endpoint = 'http://localhost:4242'; +const logger = new TestLogger(); +const valueProperty = 'value'; +describe('UnleashWebProvider', () => { let provider: UnleashWebProvider; + beforeAll(async () => { + enableFetchMocks(); + }); + + it('should be an instance of UnleashWebProvider', async () => { + fetchMock.mockResponseOnce(JSON.stringify({"toggles":[]})); + provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test',}, logger); + await provider.initialize(); + expect(provider).toBeInstanceOf(UnleashWebProvider); + }); + +}); + +describe('events', () => { + beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockReset(); + fetchMock.resetMocks(); }); beforeAll(async () => { enableFetchMocks(); - //fetchMock.mockResponseOnce(JSON.stringify({"toggles":[]})); - fetchMock.mockResponseOnce(JSON.stringify(testdata)); + }); + + it('should emit ProviderEvents.ConfigurationChanged and ProviderEvents.Ready events when provider is initialized', async () => { + let provider: UnleashWebProvider; + fetchMock.mockResponseOnce(JSON.stringify({"toggles":[]})); provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test',}, logger); + + const configChangeHandler = jest.fn(); + const readyHandler = jest.fn(); + provider.events.addHandler(ProviderEvents.ConfigurationChanged, configChangeHandler); + provider.events.addHandler(ProviderEvents.Ready, readyHandler); await provider.initialize(); + expect(configChangeHandler).toHaveBeenCalledWith({ + message: 'Flags changed', + }); + expect(readyHandler).toHaveBeenCalledWith({ + message: 'Ready', + }); }); - it('should be an instance of UnleashWebProvider', async () => { - expect(provider).toBeInstanceOf(UnleashWebProvider); + it('should emit ProviderEvents.Error event when provider errors on initialization', async () => { + let provider: UnleashWebProvider; + fetchMock.mockResponseOnce('{}', { status: 401 }); + provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test',}, logger); + const handler = jest.fn(); + provider.events.addHandler(ProviderEvents.Error, handler); + await provider.initialize(); + expect(handler).toHaveBeenCalledWith({ + message: 'Error', + }); + }); + + it('should emit ProviderEvents.ConfigurationChanged when the flags change', async () => { + let provider: UnleashWebProvider; + fetchMock.mockResponseOnce(JSON.stringify({"toggles":[]})); + provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test', refreshInterval: 2}, logger); + await provider.initialize(); + await new Promise((resolve) => { + let configChangeHandler = function() { + resolve(); + }; + provider.events.addHandler(ProviderEvents.ConfigurationChanged, configChangeHandler); + fetchMock.mockResponseOnce(JSON.stringify(testdata)); + }); + }); + + it('should emit ProviderEvents.Ready when provider recovers from an error', async () => { + let provider: UnleashWebProvider; + fetchMock.mockResponseOnce(JSON.stringify({"toggles":[]})); + provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test', refreshInterval: 2}, logger); + await provider.initialize(); + await new Promise((resolve) => { + let errorHandler = function() { + resolve(); + }; + provider.events.addHandler(ProviderEvents.Error, errorHandler); + fetchMock.mockResponseOnce('{}', { status: 401 }); + }); + + await new Promise((resolve) => { + let readyHandler = function() { + resolve(); + }; + provider.events.addHandler(ProviderEvents.Ready, readyHandler); + fetchMock.mockResponseOnce(JSON.stringify(testdata)); + }); + }, 10000); +}); + +describe('UnleashWebProvider evaluations', () => { + let provider: UnleashWebProvider; + + beforeEach(() => { + fetchMock.resetMocks(); + }); + + beforeAll(async () => { + enableFetchMocks(); + fetchMock.mockResponseOnce(JSON.stringify(testdata)); + provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test',}, logger); + await provider.initialize(); }); describe('method resolveBooleanEvaluation', () => { diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts index eb664c640..6de5349f2 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts @@ -1,6 +1,25 @@ -import { EvaluationContext, Provider, Logger, JsonValue, FlagNotFoundError, OpenFeatureEventEmitter, ProviderEvents, ResolutionDetails, ProviderFatalError, StandardResolutionReasons } from '@openfeature/web-sdk'; -import { UnleashClient, IConfig, IContext, IMutableContext } from 'unleash-proxy-client'; -import { UnleashOptions, UnleashContextOptions } from './options'; +import { + EvaluationContext, + Provider, + Logger, + JsonValue, + FlagNotFoundError, + OpenFeatureEventEmitter, + ProviderEvents, + ResolutionDetails, + ProviderFatalError, + StandardResolutionReasons +} from '@openfeature/web-sdk'; +import { + UnleashClient, + IConfig, + IContext, + IMutableContext +} from 'unleash-proxy-client'; +import { + UnleashOptions, + UnleashContextOptions +} from './options'; export class UnleashWebProvider implements Provider { metadata = { @@ -28,6 +47,7 @@ export class UnleashWebProvider implements Provider { url: options.url, clientKey: options.clientKey, appName: options.appName, + refreshInterval: options.refreshInterval, }; this._client = new UnleashClient(config); } @@ -40,33 +60,44 @@ export class UnleashWebProvider implements Provider { private async initializeClient() { try { this.registerEventListeners(); - this._client?.start(); - return new Promise((resolve) => { - this._client?.on('ready', () => { - this._logger?.info('Unleash ready event received'); - resolve(); - }); - }); + await this._client?.start(); } catch (e) { throw new ProviderFatalError(getErrorMessage(e)); } } private registerEventListeners() { + this._client?.on('ready', () => { + this._logger?.info('Unleash ready event received'); + this.events.emit(ProviderEvents.Ready, { + message: 'Ready' + }); + }); this._client?.on('update', () => { this._logger?.info('Unleash update event received'); this.events.emit(ProviderEvents.ConfigurationChanged, { message: 'Flags changed' }); }); + this._client?.on('error', () => { + this._logger?.info('Unleash error event received'); + this.events.emit(ProviderEvents.Error, { + message: 'Error' + }); + }); + this._client?.on('recovered', () => { + this._logger?.info('Unleash recovered event received'); + this.events.emit(ProviderEvents.Ready, { + message: 'Recovered' + }); + }); } async onContextChange(_oldContext: EvaluationContext, newContext: EvaluationContext): Promise { let unleashContext = new Map(); let properties = new Map(); Object.keys(newContext).forEach((key) => { - this._logger?.info(key + " = " + newContext[key]); - switch(key) { + switch (key) { case "appName": case "userId": case "environment": @@ -117,7 +148,7 @@ export class UnleashWebProvider implements Provider { private evaluate(flagKey: string, defaultValue: T): ResolutionDetails { const evaluatedVariant = this._client?.getVariant(flagKey); let value; - let retVariant + let variant this._logger?.debug("evaluatedVariant = " + JSON.stringify(evaluatedVariant)); if (typeof evaluatedVariant === 'undefined') { throw new FlagNotFoundError(); @@ -127,11 +158,11 @@ export class UnleashWebProvider implements Provider { value = defaultValue as T; } else { - retVariant = evaluatedVariant.name; + variant = evaluatedVariant.name; value = evaluatedVariant.payload?.value; } return { - variant: retVariant, + variant: variant, value: value as T, }; } From 7faed09a25f8bdf10d06e76e921fbecb911ec271 Mon Sep 17 00:00:00 2001 From: jarebudev <23311805+jarebudev@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:33:30 +0000 Subject: [PATCH 04/10] added documentation, extended rather than duplicate unleash config options Signed-off-by: jarebudev <23311805+jarebudev@users.noreply.github.com> --- libs/providers/unleash-web/README.md | 73 ++++++++++++++++++- libs/providers/unleash-web/src/lib/options.ts | 21 ------ .../src/lib/unleash-web-provider-config.ts | 6 ++ .../src/lib/unleash-web-provider.ts | 21 ++---- 4 files changed, 82 insertions(+), 39 deletions(-) delete mode 100644 libs/providers/unleash-web/src/lib/options.ts create mode 100644 libs/providers/unleash-web/src/lib/unleash-web-provider-config.ts diff --git a/libs/providers/unleash-web/README.md b/libs/providers/unleash-web/README.md index 6de0c5ad6..73d96731e 100644 --- a/libs/providers/unleash-web/README.md +++ b/libs/providers/unleash-web/README.md @@ -1,15 +1,82 @@ # unleash-web Provider +## About this provider + +This provider is a community-developed implementation for Unleash which uses the official [Unleash Proxy Client for the browser Client Side SDK](https://docs.getunleash.io/reference/sdks/javascript-browser). + +This provider uses a **static evaluation context** suitable for client-side implementation. + +Suitable for connecting to an Unleash instance + +* Via the [Unleash front-end API](https://docs.getunleash.io/reference/front-end-api). +* Via [Unleash Edge](https://docs.getunleash.io/reference/unleash-edge). +* Via [Unleash Proxy](https://docs.getunleash.io/reference/unleash-proxy). + +[Gitlab Feature Flags](https://docs.gitlab.com/ee/operations/feature_flags.html) can also be used with this provider - although note that Unleash Edge is not currently supported by Gitlab. + +### Concepts +* Boolean evaluation gets feature enabled status. +* String, Number, and Object evaluation gets feature variant value. +* Object evaluation should be used for JSON/CSV payloads in variants. + ## Installation +```shell +$ npm install @openfeature/unleash-web-provider @openfeature/web-sdk +``` + +## Usage + +To initialize the OpenFeature client with Unleash, you can use the following code snippet: + +```ts +import { UnleashWebProvider } from '@openfeature/unleash-web-provider'; + +const provider = new UnleashWebProvider({ + url: 'http://your.upstream.unleash.instance', + clientKey: 'theclientkey', + appName: 'your app', + }); + +await OpenFeature.setProviderAndWait(provider); ``` -$ npm install @openfeature/unleash-web-provider + +After the provider gets initialized, you can start evaluations of feature flags like so: + +```ts +// Note - this can also be set within the contructor +const evaluationCtx: EvaluationContext = { + usedId: 'theuser', + currentTime: 'time', + sessionId: 'theSessionId', + remoteAddress: 'theRemoteAddress', + environment: 'theEnvironment', + appName: 'theAppName', + aCustomProperty: 'itsValue', + anotherCustomProperty: 'somethingForIt', +}; + +// Set the static context for OpenFeature +await OpenFeature.setContext(evaluationCtx); + +// Get the client +const client = await OpenFeature.getClient(); + +// You can now use the client to evaluate your flags +const details = client.getBooleanValue('my-feature', false); ``` +### Available Options + +Unleash has a variety of configuration options that can be provided to the the `UnleashWebProvider` constructor. + +Please refer to the options described in the official [Unleash Proxy Client for the browser Client Side SDK](https://docs.getunleash.io/reference/sdks/javascript-browser#available-options). + +## Contribute -## Building +### Building Run `nx package providers-unleash-web` to build the library. -## Running unit tests +### Running unit tests Run `nx test providers-unleash-web` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/providers/unleash-web/src/lib/options.ts b/libs/providers/unleash-web/src/lib/options.ts deleted file mode 100644 index 6a9856955..000000000 --- a/libs/providers/unleash-web/src/lib/options.ts +++ /dev/null @@ -1,21 +0,0 @@ -export interface UnleashContextOptions { - userId?: string; - sessionId?: string; - remoteAddress?: string; - currentTime?: string; - properties?: { - [key: string]: string; - }; -} - -export interface UnleashOptions { - url: string; - clientKey: string; - appName: string; - context?: UnleashContextOptions; - refreshInterval?: number; - disableRefresh?: boolean; - metricsInterval?: number; - metricsIntervalInitial?: number; -} - diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider-config.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider-config.ts new file mode 100644 index 000000000..2eceab815 --- /dev/null +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider-config.ts @@ -0,0 +1,6 @@ +import { + IConfig +} from 'unleash-proxy-client'; + +export interface UnleashConfig extends IConfig { +} diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts index 6de5349f2..c769e349c 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts @@ -17,9 +17,8 @@ import { IMutableContext } from 'unleash-proxy-client'; import { - UnleashOptions, - UnleashContextOptions -} from './options'; + UnleashConfig +} from './unleash-web-provider-config'; export class UnleashWebProvider implements Provider { metadata = { @@ -31,24 +30,17 @@ export class UnleashWebProvider implements Provider { // logger is the OpenFeature logger to use private _logger?: Logger; - // options is the Unleash options provided to the provider - private _options?: UnleashOptions; + // config is the Unleash config provided to the provider + private _config?: UnleashConfig; // client is the Unleash client reference private _client?: UnleashClient; readonly runsOn = 'client'; - constructor(options: UnleashOptions, logger?: Logger) { - this._options = options; + constructor(config: UnleashConfig, logger?: Logger) { + this._config = config; this._logger = logger; - // TODO map all available options to unleash config - done minimum for now - let config : IConfig = { - url: options.url, - clientKey: options.clientKey, - appName: options.appName, - refreshInterval: options.refreshInterval, - }; this._client = new UnleashClient(config); } @@ -149,7 +141,6 @@ export class UnleashWebProvider implements Provider { const evaluatedVariant = this._client?.getVariant(flagKey); let value; let variant - this._logger?.debug("evaluatedVariant = " + JSON.stringify(evaluatedVariant)); if (typeof evaluatedVariant === 'undefined') { throw new FlagNotFoundError(); } From 7c8a41b2775457b5b1cad9efa2a208831ae2ed4b Mon Sep 17 00:00:00 2001 From: jarebudev <23311805+jarebudev@users.noreply.github.com> Date: Thu, 5 Dec 2024 23:12:39 +0000 Subject: [PATCH 05/10] linting fixes applied Signed-off-by: jarebudev <23311805+jarebudev@users.noreply.github.com> --- .../unleash-web/src/lib/test-logger.ts | 4 +- .../src/lib/unleash-web-provider-config.ts | 7 +-- .../src/lib/unleash-web-provider.spec.ts | 49 +++++++--------- .../src/lib/unleash-web-provider.ts | 57 ++++++++----------- 4 files changed, 48 insertions(+), 69 deletions(-) diff --git a/libs/providers/unleash-web/src/lib/test-logger.ts b/libs/providers/unleash-web/src/lib/test-logger.ts index 3005038fb..2ae8efd19 100644 --- a/libs/providers/unleash-web/src/lib/test-logger.ts +++ b/libs/providers/unleash-web/src/lib/test-logger.ts @@ -19,12 +19,12 @@ export default class TestLogger { } info(...args: unknown[]): void { - console.log(args) + console.log(args); this.inMemoryLogger['info'].push(args.join(' ')); } debug(...args: unknown[]): void { - console.log(args) + console.log(args); this.inMemoryLogger['debug'].push(args.join(' ')); } diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider-config.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider-config.ts index 2eceab815..d8f466ff5 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider-config.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider-config.ts @@ -1,6 +1,3 @@ -import { - IConfig -} from 'unleash-proxy-client'; +import { IConfig } from 'unleash-proxy-client'; -export interface UnleashConfig extends IConfig { -} +export type UnleashConfig = IConfig; diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts index 590d2b545..f34f2689c 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts @@ -1,15 +1,6 @@ import { UnleashWebProvider } from './unleash-web-provider'; import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; -import { - EvaluationContext, - OpenFeature, - ProviderEvents, - ProviderStatus, - StandardResolutionReasons, - ErrorCode, - EvaluationDetails, - JsonValue, -} from '@openfeature/web-sdk'; +import { ProviderEvents } from '@openfeature/web-sdk'; import testdata from './testdata.json'; import TestLogger from './test-logger'; @@ -26,16 +17,14 @@ describe('UnleashWebProvider', () => { }); it('should be an instance of UnleashWebProvider', async () => { - fetchMock.mockResponseOnce(JSON.stringify({"toggles":[]})); - provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test',}, logger); + fetchMock.mockResponseOnce(JSON.stringify({ toggles: [] })); + provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test' }, logger); await provider.initialize(); expect(provider).toBeInstanceOf(UnleashWebProvider); }); - }); describe('events', () => { - beforeEach(() => { fetchMock.resetMocks(); }); @@ -45,9 +34,8 @@ describe('events', () => { }); it('should emit ProviderEvents.ConfigurationChanged and ProviderEvents.Ready events when provider is initialized', async () => { - let provider: UnleashWebProvider; - fetchMock.mockResponseOnce(JSON.stringify({"toggles":[]})); - provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test',}, logger); + fetchMock.mockResponseOnce(JSON.stringify({ toggles: [] })); + const provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test' }, logger); const configChangeHandler = jest.fn(); const readyHandler = jest.fn(); @@ -63,9 +51,8 @@ describe('events', () => { }); it('should emit ProviderEvents.Error event when provider errors on initialization', async () => { - let provider: UnleashWebProvider; fetchMock.mockResponseOnce('{}', { status: 401 }); - provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test',}, logger); + const provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test' }, logger); const handler = jest.fn(); provider.events.addHandler(ProviderEvents.Error, handler); await provider.initialize(); @@ -75,12 +62,14 @@ describe('events', () => { }); it('should emit ProviderEvents.ConfigurationChanged when the flags change', async () => { - let provider: UnleashWebProvider; - fetchMock.mockResponseOnce(JSON.stringify({"toggles":[]})); - provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test', refreshInterval: 2}, logger); + fetchMock.mockResponseOnce(JSON.stringify({ toggles: [] })); + const provider = new UnleashWebProvider( + { url: endpoint, clientKey: 'clientsecret', appName: 'test', refreshInterval: 2 }, + logger, + ); await provider.initialize(); await new Promise((resolve) => { - let configChangeHandler = function() { + const configChangeHandler = function () { resolve(); }; provider.events.addHandler(ProviderEvents.ConfigurationChanged, configChangeHandler); @@ -89,12 +78,14 @@ describe('events', () => { }); it('should emit ProviderEvents.Ready when provider recovers from an error', async () => { - let provider: UnleashWebProvider; - fetchMock.mockResponseOnce(JSON.stringify({"toggles":[]})); - provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test', refreshInterval: 2}, logger); + fetchMock.mockResponseOnce(JSON.stringify({ toggles: [] })); + const provider = new UnleashWebProvider( + { url: endpoint, clientKey: 'clientsecret', appName: 'test', refreshInterval: 2 }, + logger, + ); await provider.initialize(); await new Promise((resolve) => { - let errorHandler = function() { + const errorHandler = function () { resolve(); }; provider.events.addHandler(ProviderEvents.Error, errorHandler); @@ -102,7 +93,7 @@ describe('events', () => { }); await new Promise((resolve) => { - let readyHandler = function() { + const readyHandler = function () { resolve(); }; provider.events.addHandler(ProviderEvents.Ready, readyHandler); @@ -121,7 +112,7 @@ describe('UnleashWebProvider evaluations', () => { beforeAll(async () => { enableFetchMocks(); fetchMock.mockResponseOnce(JSON.stringify(testdata)); - provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test',}, logger); + provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test' }, logger); await provider.initialize(); }); diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts index c769e349c..d2e92e6bb 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts @@ -8,17 +8,9 @@ import { ProviderEvents, ResolutionDetails, ProviderFatalError, - StandardResolutionReasons } from '@openfeature/web-sdk'; -import { - UnleashClient, - IConfig, - IContext, - IMutableContext -} from 'unleash-proxy-client'; -import { - UnleashConfig -} from './unleash-web-provider-config'; +import { UnleashClient } from 'unleash-proxy-client'; +import { UnleashConfig } from './unleash-web-provider-config'; export class UnleashWebProvider implements Provider { metadata = { @@ -62,45 +54,45 @@ export class UnleashWebProvider implements Provider { this._client?.on('ready', () => { this._logger?.info('Unleash ready event received'); this.events.emit(ProviderEvents.Ready, { - message: 'Ready' + message: 'Ready', }); }); this._client?.on('update', () => { this._logger?.info('Unleash update event received'); this.events.emit(ProviderEvents.ConfigurationChanged, { - message: 'Flags changed' + message: 'Flags changed', }); }); this._client?.on('error', () => { this._logger?.info('Unleash error event received'); this.events.emit(ProviderEvents.Error, { - message: 'Error' + message: 'Error', }); }); this._client?.on('recovered', () => { this._logger?.info('Unleash recovered event received'); this.events.emit(ProviderEvents.Ready, { - message: 'Recovered' + message: 'Recovered', }); }); } async onContextChange(_oldContext: EvaluationContext, newContext: EvaluationContext): Promise { - let unleashContext = new Map(); - let properties = new Map(); + const unleashContext = new Map(); + const properties = new Map(); Object.keys(newContext).forEach((key) => { switch (key) { - case "appName": - case "userId": - case "environment": - case "remoteAddress": - case "sessionId": - case "currentTime": - unleashContext.set(key, newContext[key]); - break; - default: - properties.set(key, newContext[key]); - break; + case 'appName': + case 'userId': + case 'environment': + case 'remoteAddress': + case 'sessionId': + case 'currentTime': + unleashContext.set(key, newContext[key]); + break; + default: + properties.set(key, newContext[key]); + break; } }); unleashContext.set('properties', properties); @@ -119,8 +111,8 @@ export class UnleashWebProvider implements Provider { throw new FlagNotFoundError(); } return { - value: resp - } + value: resp, + }; } resolveStringEvaluation(flagKey: string, defaultValue: string): ResolutionDetails { @@ -128,7 +120,7 @@ export class UnleashWebProvider implements Provider { } resolveNumberEvaluation(flagKey: string, defaultValue: number): ResolutionDetails { - let resolutionDetails = this.evaluate(flagKey, defaultValue); + const resolutionDetails = this.evaluate(flagKey, defaultValue); resolutionDetails.value = Number(resolutionDetails.value); return resolutionDetails; } @@ -140,15 +132,14 @@ export class UnleashWebProvider implements Provider { private evaluate(flagKey: string, defaultValue: T): ResolutionDetails { const evaluatedVariant = this._client?.getVariant(flagKey); let value; - let variant + let variant; if (typeof evaluatedVariant === 'undefined') { throw new FlagNotFoundError(); } if (evaluatedVariant.name === 'disabled') { value = defaultValue as T; - } - else { + } else { variant = evaluatedVariant.name; value = evaluatedVariant.payload?.value; } From 10f70d10e501dc9289bb9faa45fa6ed884441756 Mon Sep 17 00:00:00 2001 From: jarebudev <23311805+jarebudev@users.noreply.github.com> Date: Sun, 8 Dec 2024 22:26:44 +0000 Subject: [PATCH 06/10] added context change tests, tsconfig options Signed-off-by: jarebudev <23311805+jarebudev@users.noreply.github.com> --- .../src/lib/unleash-web-provider.spec.ts | 65 ++++++++++++++++++- .../src/lib/unleash-web-provider.ts | 8 ++- libs/providers/unleash-web/tsconfig.json | 4 +- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts index f34f2689c..2832505d3 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts @@ -1,7 +1,6 @@ import { UnleashWebProvider } from './unleash-web-provider'; import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; -import { ProviderEvents } from '@openfeature/web-sdk'; - +import { OpenFeature, ProviderEvents } from '@openfeature/web-sdk'; import testdata from './testdata.json'; import TestLogger from './test-logger'; @@ -102,6 +101,68 @@ describe('events', () => { }, 10000); }); +describe('onContextChange', () => { + let provider: UnleashWebProvider; + + beforeEach(async () => { + await jest.resetAllMocks(); + provider = new UnleashWebProvider({ url: endpoint, clientKey: 'clientsecret', appName: 'test' }, logger); + jest.spyOn(provider.unleashClient as any, 'fetchToggles').mockImplementation(); + }); + + afterEach(async () => { + await OpenFeature.close(); + }); + + it('sets all unleash context options with no custom properties', async () => { + const unleashClientMock = jest.spyOn(provider.unleashClient as any, 'updateContext'); + await OpenFeature.setProviderAndWait(provider); + await OpenFeature.setContext({ + userId: 'theUserId', + appName: 'anAppName', + remoteAddress: 'the.remoteAddress', + currentTime: '8/12/24 10:10:23', + sessionId: '1234-3245-56567', + environment: 'dev', + }); + expect(unleashClientMock).toHaveBeenCalledWith({ + userId: 'theUserId', + appName: 'anAppName', + remoteAddress: 'the.remoteAddress', + currentTime: '8/12/24 10:10:23', + sessionId: '1234-3245-56567', + environment: 'dev', + }); + }); + + it('sets all unleash context options with some custom properties', async () => { + const unleashClientMock = jest.spyOn(provider.unleashClient as any, 'updateContext'); + await OpenFeature.setProviderAndWait(provider); + await OpenFeature.setContext({ + userId: 'theUserId', + appName: 'anAppName', + remoteAddress: 'the.remoteAddress', + currentTime: '8/12/24 10:10:23', + sessionId: '1234-3245-56567', + environment: 'dev', + foo: 'bar', + hello: 'world', + }); + expect(unleashClientMock).toHaveBeenCalledWith({ + userId: 'theUserId', + appName: 'anAppName', + remoteAddress: 'the.remoteAddress', + currentTime: '8/12/24 10:10:23', + sessionId: '1234-3245-56567', + environment: 'dev', + properties: { + foo: 'bar', + hello: 'world', + }, + }); + }); +}); + describe('UnleashWebProvider evaluations', () => { let provider: UnleashWebProvider; diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts index d2e92e6bb..f6e364898 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts @@ -36,6 +36,10 @@ export class UnleashWebProvider implements Provider { this._client = new UnleashClient(config); } + public get unleashClient() { + return this._client; + } + async initialize(): Promise { await this.initializeClient(); this._logger?.info('UnleashWebProvider initialized'); @@ -95,7 +99,9 @@ export class UnleashWebProvider implements Provider { break; } }); - unleashContext.set('properties', properties); + if (properties.size > 0) { + unleashContext.set('properties', Object.fromEntries(properties)); + } await this._client?.updateContext(Object.fromEntries(unleashContext)); this._logger?.info('Unleash context updated'); } diff --git a/libs/providers/unleash-web/tsconfig.json b/libs/providers/unleash-web/tsconfig.json index 195a2f2e8..1b1308f20 100644 --- a/libs/providers/unleash-web/tsconfig.json +++ b/libs/providers/unleash-web/tsconfig.json @@ -8,7 +8,9 @@ "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, }, "files": [], "include": [], From 2cee1339803e190ce80e291ad530fe9c71fd4318 Mon Sep 17 00:00:00 2001 From: jarebudev <23311805+jarebudev@users.noreply.github.com> Date: Wed, 18 Dec 2024 23:23:36 +0000 Subject: [PATCH 07/10] removed unnecessary cast. Added component owner Signed-off-by: jarebudev <23311805+jarebudev@users.noreply.github.com> --- .github/component_owners.yml | 2 ++ libs/providers/unleash-web/src/lib/unleash-web-provider.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/component_owners.yml b/.github/component_owners.yml index e59dc5a29..4a129f58c 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -30,6 +30,8 @@ components: - markphelps libs/providers/flipt-web: - markphelps + libs/providers/unleash-web: + - jarebudev ignored-authors: - renovate-bot diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts index f6e364898..4d1ded7dd 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts @@ -144,7 +144,7 @@ export class UnleashWebProvider implements Provider { } if (evaluatedVariant.name === 'disabled') { - value = defaultValue as T; + value = defaultValue; } else { variant = evaluatedVariant.name; value = evaluatedVariant.payload?.value; From a95aa2bf027a0b8c8cabdb01102cba69151f946e Mon Sep 17 00:00:00 2001 From: jarebudev <23311805+jarebudev@users.noreply.github.com> Date: Sun, 22 Dec 2024 22:40:36 +0000 Subject: [PATCH 08/10] added checks to ensure that returned variant matches requested flag type and additional tests Signed-off-by: jarebudev <23311805+jarebudev@users.noreply.github.com> --- .../src/lib/unleash-web-provider.spec.ts | 70 +++++++++++-------- .../src/lib/unleash-web-provider.ts | 35 ++++++++-- 2 files changed, 69 insertions(+), 36 deletions(-) diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts index 2832505d3..2eec70edd 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts @@ -1,6 +1,6 @@ import { UnleashWebProvider } from './unleash-web-provider'; import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; -import { OpenFeature, ProviderEvents } from '@openfeature/web-sdk'; +import { OpenFeature, ProviderEvents, TypeMismatchError } from '@openfeature/web-sdk'; import testdata from './testdata.json'; import TestLogger from './test-logger'; @@ -178,83 +178,95 @@ describe('UnleashWebProvider evaluations', () => { }); describe('method resolveBooleanEvaluation', () => { - it('should return false for missing toggle', async () => { - const evaluation = await provider.resolveBooleanEvaluation('nonExistent'); + it('should return false for missing toggle', () => { + const evaluation = provider.resolveBooleanEvaluation('nonExistent'); expect(evaluation).toHaveProperty(valueProperty, false); }); - it('should return true if enabled toggle exists', async () => { - const evaluation = await provider.resolveBooleanEvaluation('simpleToggle'); + it('should return true if enabled toggle exists', () => { + const evaluation = provider.resolveBooleanEvaluation('simpleToggle'); expect(evaluation).toHaveProperty(valueProperty, true); }); - it('should return false if a disabled toggle exists', async () => { - const evaluation = await provider.resolveBooleanEvaluation('disabledToggle'); + it('should return false if a disabled toggle exists', () => { + const evaluation = provider.resolveBooleanEvaluation('disabledToggle'); expect(evaluation).toHaveProperty(valueProperty, false); }); }); describe('method resolveStringEvaluation', () => { - it('should return default value for missing value', async () => { - const evaluation = await provider.resolveStringEvaluation('nonExistent', 'defaultValue'); + it('should return default value for missing value', () => { + const evaluation = provider.resolveStringEvaluation('nonExistent', 'defaultValue'); expect(evaluation).toHaveProperty(valueProperty, 'defaultValue'); }); - it('should return right value if variant toggle exists and is enabled', async () => { - const evaluation = await provider.resolveStringEvaluation('variantToggleString', 'variant1'); + it('should return right value if variant toggle exists and is enabled', () => { + const evaluation = provider.resolveStringEvaluation('variantToggleString', 'variant1'); expect(evaluation).toHaveProperty(valueProperty, 'some-text'); }); - it('should return default value if a toggle is disabled', async () => { - const evaluation = await provider.resolveStringEvaluation('disabledVariant', 'defaultValue'); + it('should return default value if a toggle is disabled', () => { + const evaluation = provider.resolveStringEvaluation('disabledVariant', 'defaultValue'); expect(evaluation).toHaveProperty(valueProperty, 'defaultValue'); }); + + it('should throw TypeMismatchError if requested variant type is not a string', () => { + expect(() => provider.resolveStringEvaluation('variantToggleJson', 'default string')).toThrow(TypeMismatchError); + }); }); describe('method resolveNumberEvaluation', () => { - it('should return default value for missing value', async () => { - const evaluation = await provider.resolveNumberEvaluation('nonExistent', 5); + it('should return default value for missing value', () => { + const evaluation = provider.resolveNumberEvaluation('nonExistent', 5); expect(evaluation).toHaveProperty(valueProperty, 5); }); - it('should return integer value if variant toggle exists and is enabled', async () => { - const evaluation = await provider.resolveNumberEvaluation('variantToggleInteger', 0); + it('should return integer value if variant toggle exists and is enabled', () => { + const evaluation = provider.resolveNumberEvaluation('variantToggleInteger', 0); expect(evaluation).toHaveProperty(valueProperty, 3); }); - it('should return double value if variant toggle exists and is enabled', async () => { - const evaluation = await provider.resolveNumberEvaluation('variantToggleDouble', 0); + it('should return double value if variant toggle exists and is enabled', () => { + const evaluation = provider.resolveNumberEvaluation('variantToggleDouble', 0); expect(evaluation).toHaveProperty(valueProperty, 1.2); }); - it('should return default value if a toggle is disabled', async () => { - const evaluation = await provider.resolveNumberEvaluation('disabledVariant', 0); + it('should return default value if a toggle is disabled', () => { + const evaluation = provider.resolveNumberEvaluation('disabledVariant', 0); expect(evaluation).toHaveProperty(valueProperty, 0); }); + + it('should throw TypeMismatchError if requested variant type is not a number', () => { + expect(() => provider.resolveNumberEvaluation('variantToggleCsv', 0)).toThrow(TypeMismatchError); + }); }); describe('method resolveObjectEvaluation', () => { - it('should return default value for missing value', async () => { + it('should return default value for missing value', () => { const defaultValue = '{"notFound" : true}'; - const evaluation = await provider.resolveObjectEvaluation('nonExistent', JSON.parse(defaultValue)); + const evaluation = provider.resolveObjectEvaluation('nonExistent', JSON.parse(defaultValue)); expect(evaluation).toHaveProperty(valueProperty, JSON.parse(defaultValue)); }); - it('should return json value if variant toggle exists and is enabled', async () => { + it('should return json value if variant toggle exists and is enabled', () => { const expectedVariant = '{hello: world}'; - const evaluation = await provider.resolveObjectEvaluation('variantToggleJson', JSON.parse('{"default": false}')); + const evaluation = provider.resolveObjectEvaluation('variantToggleJson', JSON.parse('{"default": false}')); expect(evaluation).toHaveProperty(valueProperty, expectedVariant); }); - it('should return csv value if variant toggle exists and is enabled', async () => { - const evaluation = await provider.resolveObjectEvaluation('variantToggleCsv', 'a,b,c,d'); + it('should return csv value if variant toggle exists and is enabled', () => { + const evaluation = provider.resolveObjectEvaluation('variantToggleCsv', 'a,b,c,d'); expect(evaluation).toHaveProperty(valueProperty, '1,2,3,4'); }); - it('should return default value if a toggle is disabled', async () => { + it('should return default value if a toggle is disabled', () => { const defaultValue = '{foo: bar}'; - const evaluation = await provider.resolveObjectEvaluation('disabledVariant', defaultValue); + const evaluation = provider.resolveObjectEvaluation('disabledVariant', defaultValue); expect(evaluation).toHaveProperty(valueProperty, defaultValue); }); + + it('should throw TypeMismatchError if requested variant type is not json or csv', () => { + expect(() => provider.resolveObjectEvaluation('variantToggleInteger', 'a,b,c,d')).toThrow(TypeMismatchError); + }); }); }); diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts index 4d1ded7dd..6c7838dc8 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts @@ -8,6 +8,7 @@ import { ProviderEvents, ResolutionDetails, ProviderFatalError, + TypeMismatchError, } from '@openfeature/web-sdk'; import { UnleashClient } from 'unleash-proxy-client'; import { UnleashConfig } from './unleash-web-provider-config'; @@ -122,20 +123,22 @@ export class UnleashWebProvider implements Provider { } resolveStringEvaluation(flagKey: string, defaultValue: string): ResolutionDetails { - return this.evaluate(flagKey, defaultValue); + return this.evaluate(flagKey, defaultValue, 'string'); } resolveNumberEvaluation(flagKey: string, defaultValue: number): ResolutionDetails { - const resolutionDetails = this.evaluate(flagKey, defaultValue); - resolutionDetails.value = Number(resolutionDetails.value); - return resolutionDetails; + return this.evaluate(flagKey, defaultValue, 'number'); } resolveObjectEvaluation(flagKey: string, defaultValue: U): ResolutionDetails { - return this.evaluate(flagKey, defaultValue); + return this.evaluate(flagKey, defaultValue, 'object'); } - private evaluate(flagKey: string, defaultValue: T): ResolutionDetails { + private throwTypeMismatchError(variant: string, variantType: string, flagType: string) { + throw new TypeMismatchError(`Type of requested variant ${variant} is of type ${variantType} but requested flag type of ${flagType}`); + } + + private evaluate(flagKey: string, defaultValue: T, flagType: string): ResolutionDetails { const evaluatedVariant = this._client?.getVariant(flagKey); let value; let variant; @@ -143,11 +146,29 @@ export class UnleashWebProvider implements Provider { throw new FlagNotFoundError(); } - if (evaluatedVariant.name === 'disabled') { + if (evaluatedVariant.name === 'disabled' || typeof evaluatedVariant.payload === 'undefined') { value = defaultValue; } else { variant = evaluatedVariant.name; value = evaluatedVariant.payload?.value; + + const variantType = evaluatedVariant.payload?.type; + + if (flagType === 'string' && flagType !== variantType) { + this.throwTypeMismatchError(variant, variantType, flagType); + } + if (flagType === 'number') { + const numberValue = parseFloat(value); + if (flagType !== variantType || isNaN(numberValue)) { + this.throwTypeMismatchError(variant, variantType, flagType); + } + value = numberValue; + } + if (flagType === 'object') { + if (variantType !== 'json' && variantType !== 'csv') { + this.throwTypeMismatchError(variant, variantType, flagType); + } + } } return { variant: variant, From 1e273960d30f802bc5f113a6afe1266da4daebe6 Mon Sep 17 00:00:00 2001 From: jarebudev <23311805+jarebudev@users.noreply.github.com> Date: Mon, 23 Dec 2024 20:44:31 +0000 Subject: [PATCH 09/10] Applied suggested package.json changes to resolve CI building. Corrected unleash package version mismatches Signed-off-by: jarebudev <23311805+jarebudev@users.noreply.github.com> --- libs/providers/unleash-web/package.json | 10 ++++------ package-lock.json | 18 ++++++++++++++++-- package.json | 3 ++- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/libs/providers/unleash-web/package.json b/libs/providers/unleash-web/package.json index 7df4f8a67..52884cbdf 100644 --- a/libs/providers/unleash-web/package.json +++ b/libs/providers/unleash-web/package.json @@ -1,10 +1,6 @@ { "name": "@openfeature/unleash-web-provider", - "version": "0.0.1", - "dependencies": { - "tslib": "^2.3.0", - "unleash-proxy-client": "^3.6.1" - }, + "version": "0.1.0", "main": "./src/index.js", "typings": "./src/index.d.ts", "scripts": { @@ -12,6 +8,8 @@ "current-version": "echo $npm_package_version" }, "peerDependencies": { - "@openfeature/web-sdk": "^1.0.0" + "@openfeature/web-sdk": "^1.0.0", + "tslib": "^2.3.0", + "unleash-proxy-client": "^3.6.0" } } diff --git a/package-lock.json b/package-lock.json index c3afc1aaf..5a54b113b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,8 @@ "lodash.isequal": "^4.5.0", "lru-cache": "^11.0.0", "object-hash": "^3.0.0", - "tslib": "2.8.0" + "tslib": "2.8.0", + "unleash-proxy-client": "^3.6.0" }, "devDependencies": { "@bufbuild/buf": "^1.34.0", @@ -14371,6 +14372,11 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -14776,6 +14782,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unleash-proxy-client": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/unleash-proxy-client/-/unleash-proxy-client-3.6.1.tgz", + "integrity": "sha512-gbvkob/cBewLHMh9aAwWLDLN8D1efJ5FdUMva7wGBVykJMIqyYIlUsJpVNXnpq+feNBn6Qc1D1huXD2bk9bEmA==", + "dependencies": { + "tiny-emitter": "^2.1.0", + "uuid": "^9.0.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", @@ -14853,7 +14868,6 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index 4443df189..14f19baf7 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "lodash.isequal": "^4.5.0", "lru-cache": "^11.0.0", "object-hash": "^3.0.0", - "tslib": "2.8.0" + "tslib": "2.8.0", + "unleash-proxy-client": "^3.6.0" }, "devDependencies": { "@nx/devkit": "16.9.1", From a0229d9ec5508c06b0bf543738151fdd85c77d9b Mon Sep 17 00:00:00 2001 From: jarebudev <23311805+jarebudev@users.noreply.github.com> Date: Mon, 23 Dec 2024 20:50:32 +0000 Subject: [PATCH 10/10] applied linting fixes Signed-off-by: jarebudev <23311805+jarebudev@users.noreply.github.com> --- .../unleash-web/src/lib/unleash-web-provider.spec.ts | 2 +- libs/providers/unleash-web/src/lib/unleash-web-provider.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts index 2eec70edd..b8a5f6546 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts @@ -235,7 +235,7 @@ describe('UnleashWebProvider evaluations', () => { const evaluation = provider.resolveNumberEvaluation('disabledVariant', 0); expect(evaluation).toHaveProperty(valueProperty, 0); }); - + it('should throw TypeMismatchError if requested variant type is not a number', () => { expect(() => provider.resolveNumberEvaluation('variantToggleCsv', 0)).toThrow(TypeMismatchError); }); diff --git a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts index 6c7838dc8..c9bb8cdd2 100644 --- a/libs/providers/unleash-web/src/lib/unleash-web-provider.ts +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts @@ -135,7 +135,9 @@ export class UnleashWebProvider implements Provider { } private throwTypeMismatchError(variant: string, variantType: string, flagType: string) { - throw new TypeMismatchError(`Type of requested variant ${variant} is of type ${variantType} but requested flag type of ${flagType}`); + throw new TypeMismatchError( + `Type of requested variant ${variant} is of type ${variantType} but requested flag type of ${flagType}`, + ); } private evaluate(flagKey: string, defaultValue: T, flagType: string): ResolutionDetails {