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/.release-please-manifest.json b/.release-please-manifest.json index 558d42ef5..55aac303b 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.2", "libs/providers/config-cat-web": "0.1.3", - "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..73d96731e --- /dev/null +++ b/libs/providers/unleash-web/README.md @@ -0,0 +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); +``` + +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 + +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..52884cbdf --- /dev/null +++ b/libs/providers/unleash-web/package.json @@ -0,0 +1,15 @@ +{ + "name": "@openfeature/unleash-web-provider", + "version": "0.1.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.0.0", + "tslib": "^2.3.0", + "unleash-proxy-client": "^3.6.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/test-logger.ts b/libs/providers/unleash-web/src/lib/test-logger.ts new file mode 100644 index 000000000..2ae8efd19 --- /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-config.ts b/libs/providers/unleash-web/src/lib/unleash-web-provider-config.ts new file mode 100644 index 000000000..d8f466ff5 --- /dev/null +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider-config.ts @@ -0,0 +1,3 @@ +import { IConfig } from 'unleash-proxy-client'; + +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 new file mode 100644 index 000000000..b8a5f6546 --- /dev/null +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.spec.ts @@ -0,0 +1,272 @@ +import { UnleashWebProvider } from './unleash-web-provider'; +import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; +import { OpenFeature, ProviderEvents, TypeMismatchError } from '@openfeature/web-sdk'; +import testdata from './testdata.json'; +import TestLogger from './test-logger'; + +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.resetMocks(); + }); + + beforeAll(async () => { + enableFetchMocks(); + }); + + it('should emit ProviderEvents.ConfigurationChanged and ProviderEvents.Ready events when provider is initialized', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ toggles: [] })); + const 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 emit ProviderEvents.Error event when provider errors on initialization', async () => { + fetchMock.mockResponseOnce('{}', { status: 401 }); + const 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 () => { + 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) => { + const 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 () => { + 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) => { + const errorHandler = function () { + resolve(); + }; + provider.events.addHandler(ProviderEvents.Error, errorHandler); + fetchMock.mockResponseOnce('{}', { status: 401 }); + }); + + await new Promise((resolve) => { + const readyHandler = function () { + resolve(); + }; + provider.events.addHandler(ProviderEvents.Ready, readyHandler); + fetchMock.mockResponseOnce(JSON.stringify(testdata)); + }); + }, 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; + + 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', () => { + 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', () => { + const evaluation = provider.resolveBooleanEvaluation('simpleToggle'); + expect(evaluation).toHaveProperty(valueProperty, true); + }); + + 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', () => { + const evaluation = provider.resolveStringEvaluation('nonExistent', 'defaultValue'); + expect(evaluation).toHaveProperty(valueProperty, 'defaultValue'); + }); + + 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', () => { + 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', () => { + const evaluation = provider.resolveNumberEvaluation('nonExistent', 5); + expect(evaluation).toHaveProperty(valueProperty, 5); + }); + + 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', () => { + const evaluation = provider.resolveNumberEvaluation('variantToggleDouble', 0); + expect(evaluation).toHaveProperty(valueProperty, 1.2); + }); + + 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', () => { + const defaultValue = '{"notFound" : true}'; + 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', () => { + const expectedVariant = '{hello: world}'; + 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', () => { + 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', () => { + const defaultValue = '{foo: bar}'; + 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 new file mode 100644 index 000000000..c9bb8cdd2 --- /dev/null +++ b/libs/providers/unleash-web/src/lib/unleash-web-provider.ts @@ -0,0 +1,187 @@ +import { + EvaluationContext, + Provider, + Logger, + JsonValue, + FlagNotFoundError, + OpenFeatureEventEmitter, + ProviderEvents, + ResolutionDetails, + ProviderFatalError, + TypeMismatchError, +} from '@openfeature/web-sdk'; +import { UnleashClient } from 'unleash-proxy-client'; +import { UnleashConfig } from './unleash-web-provider-config'; + +export class UnleashWebProvider implements Provider { + metadata = { + name: UnleashWebProvider.name, + }; + + public readonly events = new OpenFeatureEventEmitter(); + + // logger is the OpenFeature logger to use + private _logger?: Logger; + + // 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(config: UnleashConfig, logger?: Logger) { + this._config = config; + this._logger = logger; + this._client = new UnleashClient(config); + } + + public get unleashClient() { + return this._client; + } + + async initialize(): Promise { + await this.initializeClient(); + this._logger?.info('UnleashWebProvider initialized'); + } + + private async initializeClient() { + try { + this.registerEventListeners(); + 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 { + 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; + } + }); + if (properties.size > 0) { + unleashContext.set('properties', Object.fromEntries(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): ResolutionDetails { + const resp = this._client?.isEnabled(flagKey); + if (typeof resp === 'undefined') { + throw new FlagNotFoundError(); + } + return { + value: resp, + }; + } + + resolveStringEvaluation(flagKey: string, defaultValue: string): ResolutionDetails { + return this.evaluate(flagKey, defaultValue, 'string'); + } + + resolveNumberEvaluation(flagKey: string, defaultValue: number): ResolutionDetails { + return this.evaluate(flagKey, defaultValue, 'number'); + } + + resolveObjectEvaluation(flagKey: string, defaultValue: U): ResolutionDetails { + return this.evaluate(flagKey, defaultValue, 'object'); + } + + 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; + if (typeof evaluatedVariant === 'undefined') { + throw new FlagNotFoundError(); + } + + 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, + value: value 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..1b1308f20 --- /dev/null +++ b/libs/providers/unleash-web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "ES6", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": 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/package-lock.json b/package-lock.json index 2f177df51..b8eae6349 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 e8952333e..314af894a 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": { "@bufbuild/buf": "^1.34.0", diff --git a/release-please-config.json b/release-please-config.json index 42b9787c8..2fe8aa122 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -142,6 +142,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"]