diff --git a/.gitmodules b/.gitmodules index 43e091d9f..120a15eb6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -17,3 +17,6 @@ [submodule "libs/shared/flagd-core/spec"] path = libs/shared/flagd-core/spec url = https://github.com/open-feature/spec +[submodule "libs/providers/go-feature-flag/wasm-releases"] + path = libs/providers/go-feature-flag/wasm-releases + url = https://github.com/go-feature-flag/wasm-releases.git diff --git a/libs/providers/go-feature-flag/.eslintrc.json b/libs/providers/go-feature-flag/.eslintrc.json index 1cb92f884..74ed2c520 100644 --- a/libs/providers/go-feature-flag/.eslintrc.json +++ b/libs/providers/go-feature-flag/.eslintrc.json @@ -1,6 +1,6 @@ { "extends": ["../../../.eslintrc.json"], - "ignorePatterns": ["!**/*"], + "ignorePatterns": ["!**/*", "src/lib/wasm/wasm_exec.js"], "rules": { "@typescript-eslint/no-explicit-any": "off", "quotes": ["error", "single"] diff --git a/libs/providers/go-feature-flag/README.md b/libs/providers/go-feature-flag/README.md index fa7f528ed..8442e0e9f 100644 --- a/libs/providers/go-feature-flag/README.md +++ b/libs/providers/go-feature-flag/README.md @@ -1,18 +1,215 @@ -# Server-side Go Feature Flag Provider +# Server-side GO Feature Flag Provider -This provider is an implementation for [`go-feature-flag`](https://github.com/thomaspoignant/go-feature-flag) a simple and complete -feature flag solution, without any complex backend system to install, all you need is a file as your backend. +A feature flag provider for [OpenFeature](https://openfeature.dev/) that integrates with [go-feature-flag](https://github.com/thomaspoignant/go-feature-flag), a simple and complete feature flag solution. -It uses [`go-feature-flag-relay-proxy`](https://github.com/thomaspoignant/go-feature-flag-relay-proxy) which expose the capabilities of the SDK through an API layer. +This provider supports both **in-process** and **remote** evaluation modes, offering flexibility for different deployment scenarios. -## Installation +## Features 🚀 +- **Dual Evaluation Modes**: In-process evaluation for performance and remote evaluation for centralized control +- **Real-time Configuration Updates**: Automatic polling for flag configuration changes +- **Comprehensive Data Collection**: Built-in event tracking and analytics +- **Flexible Context Support**: Rich evaluation context with targeting rules +- **Caching**: Intelligent caching with automatic cache invalidation +- **Error Handling**: Robust error handling with fallback mechanisms +- **TypeScript Support**: Full TypeScript support with type safety +- **OpenFeature Compliance**: Full compliance with OpenFeature specification + +## Installation 📦 + +```bash +npm install @openfeature/go-feature-flag-provider +``` + +### Peer Dependencies + +```bash +npm install @openfeature/server-sdk +``` + +## Quick Start 🏃‍♂️ + +### Basic Setup + +```typescript +import { OpenFeature } from '@openfeature/server-sdk'; +import { GoFeatureFlagProvider, EvaluationType } from '@openfeature/go-feature-flag-provider'; + +// Initialize the provider +const provider = new GoFeatureFlagProvider({ + endpoint: 'https://your-relay-proxy.com', + evaluationType: EvaluationType.Remote, +}); + +// Register the provider +OpenFeature.setProvider(provider); + +// Get a client +const client = OpenFeature.getClient(); + +// Evaluate a flag +const flagValue = await client.getBooleanValue('my-feature-flag', false, { + targetingKey: 'user-123', + email: 'user@example.com', +}); +``` + +### In-Process Evaluation + +For high-performance scenarios where you want to evaluate flags locally: + +```typescript +import { GoFeatureFlagProvider, EvaluationType } from '@openfeature/go-feature-flag-provider'; + +const provider = new GoFeatureFlagProvider({ + endpoint: 'https://your-relay-proxy.com', + evaluationType: EvaluationType.InProcess, + flagChangePollingIntervalMs: 30000, // Poll every 30 seconds +}); +``` + +## Configuration Options ⚙️ + +### Provider Options + +| Option | Type | Default | Description | +| ----------------------------- | ------------------ | ------------ | ----------------------------------------------- | +| `endpoint` | `string` | **Required** | The endpoint of the GO Feature Flag relay-proxy | +| `evaluationType` | `EvaluationType` | `InProcess` | Evaluation mode: `InProcess` or `Remote` | +| `timeout` | `number` | `10000` | HTTP request timeout in milliseconds | +| `flagChangePollingIntervalMs` | `number` | `120000` | Polling interval for configuration changes | +| `dataFlushInterval` | `number` | `120000` | Data collection flush interval | +| `maxPendingEvents` | `number` | `10000` | Maximum pending events before flushing | +| `disableDataCollection` | `boolean` | `false` | Disable data collection entirely | +| `apiKey` | `string` | `undefined` | API key for authentication | +| `exporterMetadata` | `ExporterMetadata` | `undefined` | Custom metadata for events | +| `fetchImplementation` | `FetchAPI` | `undefined` | Custom fetch implementation | + +### Evaluation Types + +#### InProcess Evaluation + +- **Performance**: Fastest evaluation with local caching +- **Network**: Minimal network calls, only for configuration updates and tracking +- **Use Case**: High-performance applications, real-time evaluation + +#### Remote Evaluation + +- **Performance**: Network-dependent evaluation +- **Network**: Each evaluation requires a network call, works well with side-cars or in the edge +- **Use Case**: Centralized control + +## Advanced Usage 🔧 + +### Custom Context and Targeting + +```typescript +const context = { + targetingKey: 'user-123', + email: 'john.doe@example.com', + firstname: 'John', + lastname: 'Doe', + anonymous: false, + professional: true, + rate: 3.14, + age: 30, + company_info: { + name: 'my_company', + size: 120, + }, + labels: ['pro', 'beta'], +}; + +const flagValue = await client.getBooleanValue('my-feature-flag', false, context); +``` + +### Data Collection and Analytics + +The provider automatically collects evaluation data. You can customize this behavior: + +```typescript +const provider = new GoFeatureFlagProvider({ + endpoint: 'https://your-relay-proxy.com', + evaluationType: EvaluationType.Remote, + disableDataCollection: false, // Enable data collection + dataFlushInterval: 20000, // Flush every 20 seconds + maxPendingEvents: 5000, // Max 5000 pending events +}); +``` + +### Custom Exporter Metadata + +Add custom metadata to your evaluation events: + +```typescript +import { ExporterMetadata } from '@openfeature/go-feature-flag-provider'; + +const metadata = new ExporterMetadata() + .add('environment', 'production') + .add('version', '1.0.0') + .add('region', 'us-east-1'); + +const provider = new GoFeatureFlagProvider({ + endpoint: 'https://your-relay-proxy.com', + evaluationType: EvaluationType.Remote, + exporterMetadata: metadata, +}); +``` + +## Flag Types Supported 🎯 + +The provider supports all OpenFeature flag types: + +### Boolean Flags + +```typescript +const isEnabled = await client.getBooleanValue('feature-flag', false, context); +const details = await client.getBooleanDetails('feature-flag', false, context); ``` -$ npm install @openfeature/go-feature-flag-provider + +### String Flags + +```typescript +const message = await client.getStringValue('welcome-message', 'Hello!', context); +const details = await client.getStringDetails('welcome-message', 'Hello!', context); ``` -Required peer dependencies +### Number Flags +```typescript +const percentage = await client.getNumberValue('discount-percentage', 0, context); +const details = await client.getNumberDetails('discount-percentage', 0, context); ``` -$ npm install @openfeature/server-sdk + +### Object Flags + +```typescript +const config = await client.getObjectValue('user-config', {}, context); +const details = await client.getObjectDetails('user-config', {}, context); ``` + +## Tracking Events 📊 + +The provider supports custom event tracking: + +```typescript +// Track a custom event +client.track('user_action', context, { + action: 'button_click', + page: 'homepage', + timestamp: Date.now(), +}); +``` + +## Contributing 🤝 + +We welcome contributions! Please see our [contributing guidelines](CONTRIBUTING.md) for details. + +## License 📄 + +This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. + +## Support 💬 + +- **Documentation**: [GO Feature Flag Documentation](https://gofeatureflag.org/), [OpenFeature Documentation](https://openfeature.dev/) +- **Issues**: [GitHub Issues](https://github.com/thomaspoignant/go-feature-flag/issues) diff --git a/libs/providers/go-feature-flag/jest.config.ts b/libs/providers/go-feature-flag/jest.config.ts index c70991442..0f8f725d2 100644 --- a/libs/providers/go-feature-flag/jest.config.ts +++ b/libs/providers/go-feature-flag/jest.config.ts @@ -2,13 +2,13 @@ export default { displayName: 'provider-go-feature-flag', preset: '../../../jest.preset.js', - globals: { - 'ts-jest': { - tsconfig: '/tsconfig.spec.json', - }, - }, transform: { - '^.+\\.[tj]s$': 'ts-jest', + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + }, + ], }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../../coverage/libs/providers/go-feature-flag', diff --git a/libs/providers/go-feature-flag/package-lock.json b/libs/providers/go-feature-flag/package-lock.json new file mode 100644 index 000000000..bc5d1d82e --- /dev/null +++ b/libs/providers/go-feature-flag/package-lock.json @@ -0,0 +1,1430 @@ +{ + "name": "@openfeature/go-feature-flag-provider", + "version": "0.7.8", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@openfeature/go-feature-flag-provider", + "version": "0.7.8", + "license": "Apache-2.0", + "dependencies": { + "axios": "1.10.0", + "copy-anything": "^3.0.5", + "lru-cache": "^11.0.0", + "object-hash": "^3.0.0" + }, + "devDependencies": { + "@ljharb/eslint-config": "^21.2.0", + "eslint-plugin-tree-shaking": "^1.12.2" + }, + "peerDependencies": { + "@openfeature/server-sdk": "^1.18.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz", + "integrity": "sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.4.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", + "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@ljharb/eslint-config": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@ljharb/eslint-config/-/eslint-config-21.2.0.tgz", + "integrity": "sha512-rQEEKJAsqHZrzi/NsnnJu77oKJn7prFxifc42Dtsx/5nQT894bRZsXsJOFIDB1tjIOXIoXexYwuwZ8v4ezohOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "=7.1.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + }, + "peerDependencies": { + "eslint": "=8.8.0" + } + }, + "node_modules/@openfeature/core": { + "version": "1.8.1", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@openfeature/server-sdk": { + "version": "1.18.0", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@openfeature/core": "^1.7.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0", + "peer": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.10.0", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/copy-anything": { + "version": "3.0.5", + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.8.0.tgz", + "integrity": "sha512-H3KXAzQGBH1plhYS3okDix2ZthuYJlQQEGE5k0IKuEqUSiyu4AmxxlJ2MtTYeJ3xB4jDhcYCwGOg2TXYdnDXlQ==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint/eslintrc": "^1.0.5", + "@humanwhocodes/config-array": "^0.9.2", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.1.0", + "eslint-utils": "^3.0.0", + "eslint-visitor-keys": "^3.2.0", + "espree": "^9.3.0", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^6.0.1", + "globals": "^13.6.0", + "ignore": "^5.2.0", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.0.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "regexpp": "^3.2.0", + "strip-ansi": "^6.0.1", + "strip-json-comments": "^3.1.0", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-tree-shaking": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-tree-shaking/-/eslint-plugin-tree-shaking-1.12.2.tgz", + "integrity": "sha512-D3MBKjH9EaGZg1gxqezGmjCeWzHs/O0jRFajpuXLYKeOCh3ycgoXeZ1ceKaD2QnK+v18ly+XjNDUCgss4vKbWg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.0.tgz", + "integrity": "sha512-aWwkhnS0qAXqNOgKOK0dJ2nvzEbhEvpy8OlJ9kZ0FeZnA6zpjv1/Vei+puGFFX7zkPCkHHXb7IDX3A+7yPrRWg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-what": { + "version": "4.1.16", + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lru-cache": { + "version": "11.1.0", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/object-hash": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-compile-cache": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz", + "integrity": "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "peer": true + } + } +} diff --git a/libs/providers/go-feature-flag/package.json b/libs/providers/go-feature-flag/package.json index 966ae3793..c1707edc5 100644 --- a/libs/providers/go-feature-flag/package.json +++ b/libs/providers/go-feature-flag/package.json @@ -7,12 +7,14 @@ "current-version": "echo $npm_package_version" }, "peerDependencies": { - "@openfeature/server-sdk": "^1.15.0" + "@openfeature/server-sdk": "^1.17.1", + "@openfeature/core": "^1.6.0", + "@openfeature/ofrep-provider": "0.2.1", + "@jest/globals": "29.7.0" }, - "dependencies": { - "object-hash": "^3.0.0", - "lru-cache": "^11.0.0", - "axios": "1.11.0", - "copy-anything": "^3.0.5" + "dependencies": {}, + "devDependencies": { + "@ljharb/eslint-config": "^21.2.0", + "eslint-plugin-tree-shaking": "^1.12.2" } } diff --git a/libs/providers/go-feature-flag/project.json b/libs/providers/go-feature-flag/project.json index 4d7597dc0..fc9e01ff6 100644 --- a/libs/providers/go-feature-flag/project.json +++ b/libs/providers/go-feature-flag/project.json @@ -16,6 +16,14 @@ } ] }, + "copy-wasm": { + "executor": "nx:run-commands", + "options": { + "commands": ["node scripts/copy-latest-wasm.js"], + "cwd": "libs/providers/go-feature-flag", + "parallel": false + } + }, "lint": { "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"] @@ -25,11 +33,21 @@ "outputs": ["{workspaceRoot}/coverage/libs/providers/go-feature-flag"], "options": { "jestConfig": "libs/providers/go-feature-flag/jest.config.ts" - } + }, + "dependsOn": [ + { + "target": "copy-wasm" + } + ] }, "package": { "executor": "@nx/rollup:rollup", "outputs": ["{options.outputPath}"], + "dependsOn": [ + { + "target": "copy-wasm" + } + ], "options": { "project": "libs/providers/go-feature-flag/package.json", "outputPath": "dist/libs/providers/go-feature-flag", diff --git a/libs/providers/go-feature-flag/scripts/README.md b/libs/providers/go-feature-flag/scripts/README.md new file mode 100644 index 000000000..cc21e8538 --- /dev/null +++ b/libs/providers/go-feature-flag/scripts/README.md @@ -0,0 +1,41 @@ +# Scripts + +This directory contains utility scripts for the go-feature-flag provider. + +## copy-latest-wasm.js + +This script copies the go-feature-flag WASM evaluation module from the `wasm-releases` submodule. + +### Purpose + +Previously, the WASM filename was hardcoded with a specific version (e.g., `gofeatureflag-evaluation_v1.45.6.wasm`), which made updates cumbersome and error-prone. This script replaces that approach with a configurable solution that: + +1. Uses an explicit version constant (`TARGET_WASM_VERSION`) for controlled updates +2. Updates the git submodule to get the latest WASM releases +3. Validates that the requested version exists before copying +4. Copies the WASM file to the expected location + +### Configuration + +The script has one configuration constant at the top: + +- `TARGET_WASM_VERSION`: The explicit version to use (e.g., `'v1.45.6'`) + +### Usage + +The script is automatically executed by the `copy-wasm` target in `project.json` and is used as a dependency for the `test` and `package` targets. + +You can also run it manually: + +```bash +node scripts/copy-latest-wasm.js +``` + +### Benefits + +- **Explicit control**: You can specify exactly which version to use +- **Easy updates**: Simply change the `TARGET_WASM_VERSION` constant when you want to upgrade +- **Error prevention**: Validates that the requested version exists before copying +- **Maintainability**: Reduces manual maintenance overhead while providing control +- **Consistency**: Ensures reproducible builds with known versions +- **Simplicity**: Clear and straightforward approach without complex logic diff --git a/libs/providers/go-feature-flag/scripts/copy-latest-wasm.js b/libs/providers/go-feature-flag/scripts/copy-latest-wasm.js new file mode 100755 index 000000000..547a5f1f9 --- /dev/null +++ b/libs/providers/go-feature-flag/scripts/copy-latest-wasm.js @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +/** + * Script to copy the go-feature-flag WASM evaluation module + * This replaces the hardcoded version approach with a configurable one + */ +const TARGET_WASM_VERSION = 'v1.45.6'; + +function copyWasmFile() { + try { + // Update git submodule first + console.log('Updating git submodule...'); + execSync('git submodule update --init wasm-releases', { + cwd: path.join(__dirname, '..'), + stdio: 'inherit', + }); + + const wasmFileName = `gofeatureflag-evaluation_${TARGET_WASM_VERSION}.wasm`; + console.log(`Using explicit WASM version: ${TARGET_WASM_VERSION}`); + + const sourcePath = path.join(__dirname, '../wasm-releases/evaluation', wasmFileName); + const targetPath = path.join(__dirname, '../src/lib/wasm/wasm-module/gofeatureflag-evaluation.wasm'); + + // Check if the source file exists + if (!fs.existsSync(sourcePath)) { + console.error(`Error: WASM file not found: ${sourcePath}`); + console.error('Available files in wasm-releases/evaluation:'); + const evaluationDir = path.join(__dirname, '../wasm-releases/evaluation'); + if (fs.existsSync(evaluationDir)) { + const files = fs.readdirSync(evaluationDir).filter((file) => file.endsWith('.wasm')); + files.forEach((file) => console.error(` - ${file}`)); + } + process.exit(1); + } + + // Ensure target directory exists + const targetDir = path.dirname(targetPath); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + // Copy the file + console.log(`Copying ${wasmFileName} to ${targetPath}...`); + fs.copyFileSync(sourcePath, targetPath); + + console.log('✅ Successfully copied WASM file'); + } catch (error) { + console.error('Error copying WASM file:', error.message); + process.exit(1); + } +} + +// Run the script +copyWasmFile(); diff --git a/libs/providers/go-feature-flag/src/lib/context-transfomer.spec.ts b/libs/providers/go-feature-flag/src/lib/context-transfomer.spec.ts deleted file mode 100644 index 54cb16123..000000000 --- a/libs/providers/go-feature-flag/src/lib/context-transfomer.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { EvaluationContext } from '@openfeature/server-sdk'; -import type { GOFFEvaluationContext } from './model'; -import { transformContext } from './context-transformer'; - -describe('contextTransformer', () => { - it('should use the targetingKey as user key', () => { - const got = transformContext({ - targetingKey: 'user-key', - } as EvaluationContext); - const want: GOFFEvaluationContext = { - key: 'user-key', - custom: {}, - }; - expect(got).toEqual(want); - }); - - it('should specify the anonymous field base on attributes', () => { - const got = transformContext({ - targetingKey: 'user-key', - anonymous: true, - } as EvaluationContext); - const want: GOFFEvaluationContext = { - key: 'user-key', - custom: { anonymous: true }, - }; - expect(got).toEqual(want); - }); - - it('should hash the context as key if no targetingKey provided', () => { - const got = transformContext({ - anonymous: true, - firstname: 'John', - lastname: 'Doe', - email: 'john.doe@gofeatureflag.org', - } as EvaluationContext); - - const want: GOFFEvaluationContext = { - key: 'dd3027562879ff6857cc6b8b88ced570546d7c0c', - custom: { - anonymous: true, - firstname: 'John', - lastname: 'Doe', - email: 'john.doe@gofeatureflag.org', - }, - }; - expect(got).toEqual(want); - }); - it('should fill custom fields if extra field are present', () => { - const got = transformContext({ - targetingKey: 'user-key', - anonymous: true, - firstname: 'John', - lastname: 'Doe', - email: 'john.doe@gofeatureflag.org', - } as EvaluationContext); - - const want: GOFFEvaluationContext = { - key: 'user-key', - custom: { - firstname: 'John', - lastname: 'Doe', - email: 'john.doe@gofeatureflag.org', - anonymous: true, - }, - }; - expect(got).toEqual(want); - }); -}); diff --git a/libs/providers/go-feature-flag/src/lib/context-transformer.ts b/libs/providers/go-feature-flag/src/lib/context-transformer.ts deleted file mode 100644 index dcb9a1f99..000000000 --- a/libs/providers/go-feature-flag/src/lib/context-transformer.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { EvaluationContext } from '@openfeature/server-sdk'; -import { sha1 } from 'object-hash'; -import type { GOFFEvaluationContext } from './model'; - -/** - * transformContext takes the raw OpenFeature context returns a GoFeatureFlagUser. - * @param context - the context used for flag evaluation. - * @returns {GOFFEvaluationContext} the evaluation context against which we will evaluate the flag. - */ -export function transformContext(context: EvaluationContext): GOFFEvaluationContext { - const { targetingKey, ...attributes } = context; - const key = targetingKey || sha1(context) || 'anonymous'; - return { - key: key, - custom: attributes, - }; -} diff --git a/libs/providers/go-feature-flag/src/lib/controller/cache.ts b/libs/providers/go-feature-flag/src/lib/controller/cache.ts deleted file mode 100644 index bcef37f4f..000000000 --- a/libs/providers/go-feature-flag/src/lib/controller/cache.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { GoFeatureFlagProviderOptions, Cache } from '../model'; -import type { EvaluationContext, Logger, ResolutionDetails } from '@openfeature/server-sdk'; -import { LRUCache } from 'lru-cache'; -import hash from 'object-hash'; - -export class CacheController { - // cacheTTL is the time we keep the evaluation in the cache before we consider it as obsolete. - // If you want to keep the value forever, you can set the FlagCacheTTL field to -1 - private readonly cacheTTL?: number; - // logger is the Open Feature logger to use - private logger?: Logger; - // cache contains the local cache used in the provider to avoid calling the relay-proxy for every evaluation - private readonly cache?: Cache; - // options for this provider - private readonly options: GoFeatureFlagProviderOptions; - - constructor(options: GoFeatureFlagProviderOptions, logger?: Logger) { - this.options = options; - this.cacheTTL = options.flagCacheTTL !== undefined && options.flagCacheTTL !== 0 ? options.flagCacheTTL : 1000 * 60; - this.logger = logger; - const cacheSize = - options.flagCacheSize !== undefined && options.flagCacheSize !== 0 ? options.flagCacheSize : 10000; - this.cache = options.cache || new LRUCache({ maxSize: cacheSize, sizeCalculation: () => 1 }); - } - - get(flagKey: string, evaluationContext: EvaluationContext): ResolutionDetails | undefined { - if (this.options.disableCache) { - return undefined; - } - const cacheKey = this.buildCacheKey(flagKey, evaluationContext); - return this.cache?.get(cacheKey); - } - - set( - flagKey: string, - evaluationContext: EvaluationContext, - evaluationResponse: { resolutionDetails: ResolutionDetails; isCacheable: boolean }, - ) { - if (this.options.disableCache) { - return; - } - - const cacheKey = this.buildCacheKey(flagKey, evaluationContext); - if (this.cache !== undefined && evaluationResponse.isCacheable) { - if (this.cacheTTL === -1) { - this.cache.set(cacheKey, evaluationResponse.resolutionDetails); - } else { - this.cache.set(cacheKey, evaluationResponse.resolutionDetails, { ttl: this.cacheTTL }); - } - } - } - - clear(): void { - return this.cache?.clear(); - } - - private buildCacheKey(flagKey: string, evaluationContext: EvaluationContext): string { - return `${flagKey}-${hash(evaluationContext)}`; - } -} diff --git a/libs/providers/go-feature-flag/src/lib/controller/goff-api.ts b/libs/providers/go-feature-flag/src/lib/controller/goff-api.ts deleted file mode 100644 index 1272a8d85..000000000 --- a/libs/providers/go-feature-flag/src/lib/controller/goff-api.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { - DataCollectorRequest, - DataCollectorResponse, - ExporterMetadataValue, - FeatureEvent, - GoFeatureFlagProviderOptions, - GoFeatureFlagProxyRequest, - GoFeatureFlagProxyResponse, -} from '../model'; -import { ConfigurationChange } from '../model'; -import type { EvaluationContext, Logger, ResolutionDetails } from '@openfeature/server-sdk'; -import { ErrorCode, FlagNotFoundError, StandardResolutionReasons, TypeMismatchError } from '@openfeature/server-sdk'; -import { transformContext } from '../context-transformer'; -import axios, { isAxiosError } from 'axios'; -import { Unauthorized } from '../errors/unauthorized'; -import { ProxyNotReady } from '../errors/proxyNotReady'; -import { ProxyTimeout } from '../errors/proxyTimeout'; -import { UnknownError } from '../errors/unknownError'; -import { CollectorError } from '../errors/collector-error'; -import { ConfigurationChangeEndpointNotFound } from '../errors/configuration-change-endpoint-not-found'; -import { ConfigurationChangeEndpointUnknownErr } from '../errors/configuration-change-endpoint-unknown-err'; -import { GoFeatureFlagError } from '../errors/goff-error'; - -export class GoffApiController { - // endpoint of your go-feature-flag relay proxy instance - private readonly endpoint: string; - - // timeout in millisecond before we consider the request as a failure - private readonly timeout: number; - // logger is the Open Feature logger to use - private logger?: Logger; - - // etag is the etag of the last configuration change - private etag: string | null = null; - - constructor(options: GoFeatureFlagProviderOptions, logger?: Logger) { - this.endpoint = options.endpoint; - this.timeout = options.timeout ?? 0; - this.logger = logger; - // Add API key to the headers - if (options.apiKey) { - axios.defaults.headers.common['Authorization'] = `Bearer ${options.apiKey}`; - } - } - - /** - * Call the GO Feature Flag API to evaluate a flag - * @param flagKey - * @param defaultValue - * @param evaluationContext - * @param expectedType - * @param exporterMetadata - */ - async evaluate( - flagKey: string, - defaultValue: T, - evaluationContext: EvaluationContext, - expectedType: string, - exporterMetadata: Record = {}, - ): Promise<{ resolutionDetails: ResolutionDetails; isCacheable: boolean }> { - const goffEvaluationContext = transformContext(evaluationContext); - - // build URL to access to the endpoint - const endpointURL = new URL(this.endpoint); - endpointURL.pathname = `v1/feature/${flagKey}/eval`; - - if (goffEvaluationContext.custom === undefined) { - goffEvaluationContext.custom = {}; - } - goffEvaluationContext.custom['gofeatureflag'] = { - exporterMetadata: { - openfeature: true, - provider: 'js', - ...exporterMetadata, - }, - }; - - const request: GoFeatureFlagProxyRequest = { - evaluationContext: goffEvaluationContext, - defaultValue, - }; - let apiResponseData: GoFeatureFlagProxyResponse; - - try { - const response = await axios.post>(endpointURL.toString(), request, { - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - timeout: this.timeout, - }); - apiResponseData = response.data; - } catch (error) { - if (axios.isAxiosError(error) && error.response?.status == 401) { - throw new Unauthorized('invalid token used to contact GO Feature Flag relay proxy instance'); - } - // Impossible to contact the relay-proxy - if (axios.isAxiosError(error) && (error.code === 'ECONNREFUSED' || error.response?.status === 404)) { - throw new ProxyNotReady(`impossible to call go-feature-flag relay proxy on ${endpointURL}`, error); - } - - // Timeout when calling the API - if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') { - throw new ProxyTimeout(`impossible to retrieve the ${flagKey} on time`, error); - } - - throw new UnknownError( - `unknown error while retrieving flag ${flagKey} for evaluation context ${evaluationContext.targetingKey}`, - error, - ); - } - // Check that we received the expectedType - if (typeof apiResponseData.value !== expectedType) { - throw new TypeMismatchError( - `Flag value ${flagKey} had unexpected type ${typeof apiResponseData.value}, expected ${expectedType}.`, - ); - } - // Case of the flag is not found - if (apiResponseData.errorCode === ErrorCode.FLAG_NOT_FOUND) { - throw new FlagNotFoundError(`Flag ${flagKey} was not found in your configuration`); - } - - // Case of the flag is disabled - if (apiResponseData.reason === StandardResolutionReasons.DISABLED) { - // we don't set a variant since we are using the default value, and we are not able to know - // which variant it is. - return { - resolutionDetails: { value: defaultValue, reason: apiResponseData.reason }, - isCacheable: true, - }; - } - - if (apiResponseData.reason === StandardResolutionReasons.ERROR) { - return { - resolutionDetails: { - value: defaultValue, - reason: apiResponseData.reason, - errorCode: this.convertErrorCode(apiResponseData.errorCode), - }, - isCacheable: true, - }; - } - - return { - resolutionDetails: { - value: apiResponseData.value, - variant: apiResponseData.variationType, - reason: apiResponseData.reason?.toString() || 'UNKNOWN', - flagMetadata: apiResponseData.metadata || undefined, - errorCode: this.convertErrorCode(apiResponseData.errorCode), - }, - isCacheable: apiResponseData.cacheable, - }; - } - - async collectData(events: FeatureEvent[], dataCollectorMetadata: Record) { - if (events?.length === 0) { - return; - } - - const request: DataCollectorRequest = { events: events, meta: dataCollectorMetadata }; - const endpointURL = new URL(this.endpoint); - endpointURL.pathname = 'v1/data/collector'; - - try { - await axios.post(endpointURL.toString(), request, { - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - timeout: this.timeout, - }); - } catch (e) { - throw new CollectorError(`impossible to send the data to the collector: ${e}`); - } - } - - public async configurationHasChanged(): Promise { - const endpointURL = new URL(this.endpoint); - endpointURL.pathname = 'v1/flag/change'; - - const headers: any = { - 'Content-Type': 'application/json', - }; - - if (this.etag) { - headers['If-None-Match'] = this.etag; - } - try { - const response = await axios.get(endpointURL.toString(), { headers }); - if (response.status === 304) { - return ConfigurationChange.FLAG_CONFIGURATION_NOT_CHANGED; - } - - const isInitialConfiguration = this.etag === null; - this.etag = response.headers['etag']; - return isInitialConfiguration - ? ConfigurationChange.FLAG_CONFIGURATION_INITIALIZED - : ConfigurationChange.FLAG_CONFIGURATION_UPDATED; - } catch (e) { - if (isAxiosError(e) && e.response?.status === 304) { - return ConfigurationChange.FLAG_CONFIGURATION_NOT_CHANGED; - } - if (isAxiosError(e) && e.response?.status === 404) { - throw new ConfigurationChangeEndpointNotFound('impossible to find the configuration change endpoint'); - } - if (e instanceof GoFeatureFlagError) { - throw e; - } - throw new ConfigurationChangeEndpointUnknownErr( - 'unknown error while retrieving the configuration change endpoint', - e as Error, - ); - } - } - - private convertErrorCode(errorCode: ErrorCode | undefined): ErrorCode | undefined { - if ((errorCode as string) === '') { - return undefined; - } - if (errorCode === undefined) { - return undefined; - } - if (Object.values(ErrorCode).includes(errorCode as ErrorCode)) { - return ErrorCode[errorCode as ErrorCode]; - } - return ErrorCode.GENERAL; - } -} diff --git a/libs/providers/go-feature-flag/src/lib/data-collector-hook.ts b/libs/providers/go-feature-flag/src/lib/data-collector-hook.ts deleted file mode 100644 index f7c48e4e1..000000000 --- a/libs/providers/go-feature-flag/src/lib/data-collector-hook.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { EvaluationDetails, FlagValue, Hook, HookContext, Logger } from '@openfeature/server-sdk'; -import { StandardResolutionReasons } from '@openfeature/server-sdk'; -import type { DataCollectorHookOptions, ExporterMetadataValue, FeatureEvent } from './model'; -import { copy } from 'copy-anything'; -import { CollectorError } from './errors/collector-error'; -import type { GoffApiController } from './controller/goff-api'; - -const defaultTargetingKey = 'undefined-targetingKey'; -type Timer = ReturnType; - -export class GoFeatureFlagDataCollectorHook implements Hook { - // collectUnCachedEvent (optional) set to true if you want to send all events not only the cached evaluations. - collectUnCachedEvaluation?: boolean; - // bgSchedulerId contains the id of the setInterval that is running. - private bgScheduler?: Timer; - // dataCollectorBuffer contains all the FeatureEvents that we need to send to the relay-proxy for data collection. - private dataCollectorBuffer?: FeatureEvent[]; - // dataFlushInterval interval time (in millisecond) we use to call the relay proxy to collect data. - private readonly dataFlushInterval: number; - // dataCollectorMetadata are the metadata used when calling the data collector endpoint - private readonly dataCollectorMetadata: Record; - private readonly goffApiController: GoffApiController; - // logger is the Open Feature logger to use - private logger?: Logger; - - constructor(options: DataCollectorHookOptions, goffApiController: GoffApiController, logger?: Logger) { - this.dataFlushInterval = options.dataFlushInterval || 1000 * 60; - this.logger = logger; - this.goffApiController = goffApiController; - this.collectUnCachedEvaluation = options.collectUnCachedEvaluation; - this.dataCollectorMetadata = { - provider: 'js', - openfeature: true, - ...options.exporterMetadata, - }; - } - - init() { - this.bgScheduler = setInterval(async () => await this.callGoffDataCollection(), this.dataFlushInterval); - this.dataCollectorBuffer = []; - } - - async close() { - clearInterval(this.bgScheduler); - // We call the data collector with what is still in the buffer. - await this.callGoffDataCollection(); - } - - /** - * callGoffDataCollection is a function called periodically to send the usage of the flag to the - * central service in charge of collecting the data. - */ - async callGoffDataCollection() { - const dataToSend = copy(this.dataCollectorBuffer) || []; - this.dataCollectorBuffer = []; - try { - await this.goffApiController.collectData(dataToSend, this.dataCollectorMetadata); - } catch (e) { - if (!(e instanceof CollectorError)) { - throw e; - } - this.logger?.error(e); - // if we have an issue calling the collector, we put the data back in the buffer - this.dataCollectorBuffer = [...this.dataCollectorBuffer, ...dataToSend]; - return; - } - } - - after(hookContext: HookContext, evaluationDetails: EvaluationDetails) { - if (!this.collectUnCachedEvaluation && evaluationDetails.reason !== StandardResolutionReasons.CACHED) { - return; - } - - const event = { - contextKind: hookContext.context['anonymous'] ? 'anonymousUser' : 'user', - kind: 'feature', - creationDate: Math.round(Date.now() / 1000), - default: false, - key: hookContext.flagKey, - value: evaluationDetails.value, - variation: evaluationDetails.variant || 'SdkDefault', - userKey: hookContext.context.targetingKey || defaultTargetingKey, - }; - this.dataCollectorBuffer?.push(event); - } - - error(hookContext: HookContext) { - const event = { - contextKind: hookContext.context['anonymous'] ? 'anonymousUser' : 'user', - kind: 'feature', - creationDate: Math.round(Date.now() / 1000), - default: true, - key: hookContext.flagKey, - value: hookContext.defaultValue, - variation: 'SdkDefault', - userKey: hookContext.context.targetingKey || defaultTargetingKey, - }; - this.dataCollectorBuffer?.push(event); - } -} diff --git a/libs/providers/go-feature-flag/src/lib/errors/collector-error.ts b/libs/providers/go-feature-flag/src/lib/errors/collector-error.ts deleted file mode 100644 index d0ac36929..000000000 --- a/libs/providers/go-feature-flag/src/lib/errors/collector-error.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { GoFeatureFlagError } from './goff-error'; - -/** - * An error occurred while calling the GOFF event collector. - */ -export class CollectorError extends GoFeatureFlagError { - constructor(message?: string, originalError?: Error) { - super(message, originalError); - } -} diff --git a/libs/providers/go-feature-flag/src/lib/errors/configuration-change-endpoint-not-found.ts b/libs/providers/go-feature-flag/src/lib/errors/configuration-change-endpoint-not-found.ts deleted file mode 100644 index 83e5f6ccb..000000000 --- a/libs/providers/go-feature-flag/src/lib/errors/configuration-change-endpoint-not-found.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { GoFeatureFlagError } from './goff-error'; - -export class ConfigurationChangeEndpointNotFound extends GoFeatureFlagError { - constructor(message?: string, originalError?: Error) { - super(message, originalError); - } -} diff --git a/libs/providers/go-feature-flag/src/lib/errors/configuration-change-endpoint-unknown-err.ts b/libs/providers/go-feature-flag/src/lib/errors/configuration-change-endpoint-unknown-err.ts deleted file mode 100644 index eff74cd00..000000000 --- a/libs/providers/go-feature-flag/src/lib/errors/configuration-change-endpoint-unknown-err.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { GoFeatureFlagError } from './goff-error'; - -export class ConfigurationChangeEndpointUnknownErr extends GoFeatureFlagError { - constructor(message?: string, originalError?: Error) { - super(message, originalError); - } -} diff --git a/libs/providers/go-feature-flag/src/lib/errors/goff-error.ts b/libs/providers/go-feature-flag/src/lib/errors/goff-error.ts deleted file mode 100644 index 31ef02f23..000000000 --- a/libs/providers/go-feature-flag/src/lib/errors/goff-error.ts +++ /dev/null @@ -1 +0,0 @@ -export class GoFeatureFlagError extends Error {} diff --git a/libs/providers/go-feature-flag/src/lib/errors/proxyNotReady.ts b/libs/providers/go-feature-flag/src/lib/errors/proxyNotReady.ts deleted file mode 100644 index ef8146975..000000000 --- a/libs/providers/go-feature-flag/src/lib/errors/proxyNotReady.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ErrorCode, OpenFeatureError } from '@openfeature/server-sdk'; - -// ProxyNotReady is an error send when we try to call the relay proxy and he is not ready -// to return a valid response. -export class ProxyNotReady extends OpenFeatureError { - code: ErrorCode; - - constructor(message: string, originalError: Error) { - super(`${message}: ${originalError}`); - Object.setPrototypeOf(this, ProxyNotReady.prototype); - this.code = ErrorCode.PROVIDER_NOT_READY; - } -} diff --git a/libs/providers/go-feature-flag/src/lib/errors/proxyTimeout.ts b/libs/providers/go-feature-flag/src/lib/errors/proxyTimeout.ts deleted file mode 100644 index cebf1f276..000000000 --- a/libs/providers/go-feature-flag/src/lib/errors/proxyTimeout.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ErrorCode, OpenFeatureError } from '@openfeature/server-sdk'; - -// ProxyTimeout is an error send when we try to call the relay proxy and he his not responding -// in the appropriate time. -export class ProxyTimeout extends OpenFeatureError { - code: ErrorCode; - - constructor(message: string, originalError: Error) { - super(`${message}: ${originalError}`); - Object.setPrototypeOf(this, ProxyTimeout.prototype); - this.code = ErrorCode.GENERAL; - } -} diff --git a/libs/providers/go-feature-flag/src/lib/errors/unauthorized.ts b/libs/providers/go-feature-flag/src/lib/errors/unauthorized.ts deleted file mode 100644 index 41af5d18f..000000000 --- a/libs/providers/go-feature-flag/src/lib/errors/unauthorized.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ErrorCode, OpenFeatureError } from '@openfeature/server-sdk'; - -// Unauthorized is an error sent when the provider makes an unauthorized call to the relay proxy. -export class Unauthorized extends OpenFeatureError { - code: ErrorCode; - - constructor(message: string) { - super(message); - Object.setPrototypeOf(this, Unauthorized.prototype); - this.code = ErrorCode.GENERAL; - } -} diff --git a/libs/providers/go-feature-flag/src/lib/errors/unknownError.ts b/libs/providers/go-feature-flag/src/lib/errors/unknownError.ts deleted file mode 100644 index 1fcb5c293..000000000 --- a/libs/providers/go-feature-flag/src/lib/errors/unknownError.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ErrorCode, OpenFeatureError } from '@openfeature/server-sdk'; - -// UnknownError is an error send when something unexpected happened. -export class UnknownError extends OpenFeatureError { - code: ErrorCode; - - constructor(message: string, originalError: Error | unknown) { - super(`${message}: ${originalError}`); - Object.setPrototypeOf(this, UnknownError.prototype); - this.code = ErrorCode.GENERAL; - } -} diff --git a/libs/providers/go-feature-flag/src/lib/evaluator/evaluator.ts b/libs/providers/go-feature-flag/src/lib/evaluator/evaluator.ts new file mode 100644 index 000000000..e211343d8 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/evaluator/evaluator.ts @@ -0,0 +1,72 @@ +import type { EvaluationContext, JsonValue, ResolutionDetails } from '@openfeature/server-sdk'; + +/** + * IEvaluator is an interface that represents the evaluation of a feature flag. + * It can have multiple implementations: Remote or InProcess. + */ +export interface IEvaluator { + /** + * Initialize the evaluator. + */ + initialize(): Promise; + + /** + * Evaluates a boolean flag. + * @param flagKey - The key of the flag to evaluate. + * @param defaultValue - The default value to return if the flag is not found. + * @param evaluationContext - The context in which to evaluate the flag. + * @returns The resolution details of the flag evaluation. + */ + evaluateBoolean( + flagKey: string, + defaultValue: boolean, + evaluationContext?: EvaluationContext, + ): Promise>; + /** + * Evaluates a string flag. + * @param flagKey - The key of the flag to evaluate. + * @param defaultValue - The default value to return if the flag is not found. + * @param evaluationContext - The context in which to evaluate the flag. + * @returns The resolution details of the flag evaluation. + */ + evaluateString( + flagKey: string, + defaultValue: string, + evaluationContext?: EvaluationContext, + ): Promise>; + /** + * Evaluates a number flag. + * @param flagKey - The key of the flag to evaluate. + * @param defaultValue - The default value to return if the flag is not found. + * @param evaluationContext - The context in which to evaluate the flag. + * @returns The resolution details of the flag evaluation. + */ + evaluateNumber( + flagKey: string, + defaultValue: number, + evaluationContext?: EvaluationContext, + ): Promise>; + + /** + * Evaluates an object flag. + * @param flagKey - The key of the flag to evaluate. + * @param defaultValue - The default value to return if the flag is not found. + * @param evaluationContext - The context in which to evaluate the flag. + * @returns The resolution details of the flag evaluation. + */ + evaluateObject( + flagKey: string, + defaultValue: T, + evaluationContext?: EvaluationContext, + ): Promise>; + + /** + * Check if the flag is trackable. + */ + isFlagTrackable(flagKey: string): boolean; + + /** + * Dispose the evaluator. + */ + dispose(): Promise; +} diff --git a/libs/providers/go-feature-flag/src/lib/evaluator/inprocess-evaluator.test.ts b/libs/providers/go-feature-flag/src/lib/evaluator/inprocess-evaluator.test.ts new file mode 100644 index 000000000..5b94d9bc7 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/evaluator/inprocess-evaluator.test.ts @@ -0,0 +1,115 @@ +import { InProcessEvaluator } from './inprocess-evaluator'; +import type { GoFeatureFlagApi } from '../service/api'; +import type { GoFeatureFlagProviderOptions } from '../go-feature-flag-provider-options'; +import { FlagNotFoundError, type Logger, OpenFeatureEventEmitter } from '@openfeature/server-sdk'; +import { EvaluationType } from '../model'; + +// Mock the EvaluateWasm class +jest.mock('../wasm/evaluate-wasm', () => ({ + EvaluateWasm: jest.fn().mockImplementation(() => ({ + initialize: jest.fn().mockResolvedValue(undefined), + evaluate: jest.fn().mockResolvedValue({ + value: true, + reason: 'TARGETING_MATCH', + trackEvents: true, + }), + dispose: jest.fn().mockResolvedValue(undefined), + })), +})); + +describe('InProcessEvaluator', () => { + let evaluator: InProcessEvaluator; + let mockApi: jest.Mocked; + let mockOptions: GoFeatureFlagProviderOptions; + let mockLogger: jest.Mocked; + + beforeEach(() => { + mockApi = { + retrieveFlagConfiguration: jest.fn().mockResolvedValue({ + flags: { + 'test-flag': { + key: 'test-flag', + trackEvents: true, + variations: {}, + rules: [], + defaultSdkValue: true, + }, + }, + evaluationContextEnrichment: {}, + etag: 'test-etag', + lastUpdated: new Date(), + }), + } as any; + + mockOptions = { + endpoint: 'http://localhost:1031', + evaluationType: EvaluationType.InProcess, + timeout: 10000, + flagChangePollingIntervalMs: 120000, + }; + + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as any; + + evaluator = new InProcessEvaluator(mockOptions, mockApi, new OpenFeatureEventEmitter(), mockLogger); + }); + + describe('initialize', () => { + it('should initialize successfully', async () => { + await expect(evaluator.initialize()).resolves.not.toThrow(); + expect(mockApi.retrieveFlagConfiguration).toHaveBeenCalledWith(undefined, undefined); + }); + }); + + describe('evaluateBoolean', () => { + it('should evaluate boolean flag successfully', async () => { + await evaluator.initialize(); + + const result = await evaluator.evaluateBoolean('test-flag', false, { user: 'test' }); + + const want = { + value: true, + reason: 'TARGETING_MATCH', + }; + expect(result).toEqual(want); + }); + + it('should throw error when flag not found', async () => { + await evaluator.initialize(); + + await expect(evaluator.evaluateBoolean('non-existent-flag', false, { user: 'test' })).rejects.toThrow( + FlagNotFoundError, + ); + }); + }); + + describe('isFlagTrackable', () => { + it('should return true for existing flag', async () => { + await evaluator.initialize(); + + const result = evaluator.isFlagTrackable('test-flag'); + + expect(result).toBe(true); + }); + + it('should return true for non-existent flag', async () => { + await evaluator.initialize(); + + const result = evaluator.isFlagTrackable('non-existent-flag'); + + expect(result).toBe(true); + }); + }); + + describe('dispose', () => { + it('should dispose successfully', async () => { + await evaluator.initialize(); + + await expect(evaluator.dispose()).resolves.not.toThrow(); + }); + }); +}); diff --git a/libs/providers/go-feature-flag/src/lib/evaluator/inprocess-evaluator.ts b/libs/providers/go-feature-flag/src/lib/evaluator/inprocess-evaluator.ts new file mode 100644 index 000000000..8a9954897 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/evaluator/inprocess-evaluator.ts @@ -0,0 +1,336 @@ +import type { Logger } from '@openfeature/server-sdk'; +import { + ErrorCode, + type EvaluationContext, + FlagNotFoundError, + GeneralError, + InvalidContextError, + type JsonValue, + type OpenFeatureEventEmitter, + ParseError, + ProviderFatalError, + ProviderNotReadyError, + type ResolutionDetails, + ServerProviderEvents, + TargetingKeyMissingError, + TypeMismatchError, +} from '@openfeature/server-sdk'; +import type { IEvaluator } from './evaluator'; +import type { GoFeatureFlagApi } from '../service/api'; +import type { GoFeatureFlagProviderOptions } from '../go-feature-flag-provider-options'; +import type { EvaluationResponse, Flag, WasmInput } from '../model'; +import { EvaluateWasm } from '../wasm/evaluate-wasm'; +import { ImpossibleToRetrieveConfigurationException } from '../exception'; + +/** + * InProcessEvaluator is an implementation of the IEvaluator interface that evaluates feature flags in-process. + * It uses the WASM evaluation engine to perform flag evaluations locally. + */ +export class InProcessEvaluator implements IEvaluator { + private readonly api: GoFeatureFlagApi; + private readonly options: GoFeatureFlagProviderOptions; + private readonly evaluationEngine: EvaluateWasm; + private readonly logger?: Logger; + private readonly eventChannel?: OpenFeatureEventEmitter; // Event channel for notifications + + // Configuration state + private etag?: string; + private lastUpdate: Date = new Date(0); + private flags: Record = {}; + private evaluationContextEnrichment: Record = {}; + private periodicRunner?: NodeJS.Timeout; + + /** + * Constructor of the InProcessEvaluator. + * @param api - API to contact GO Feature Flag + * @param options - Options to configure the provider + * @param eventChannel - Event channel to send events to the event bus or event handler + * @param logger - Logger instance + */ + constructor( + options: GoFeatureFlagProviderOptions, + api: GoFeatureFlagApi, + eventChannel: OpenFeatureEventEmitter, + logger?: Logger, + ) { + this.api = api; + this.options = options; + this.eventChannel = eventChannel; + this.logger = logger; + this.evaluationEngine = new EvaluateWasm(logger); + } + + /** + * Initialize the evaluator. + */ + async initialize(): Promise { + await this.evaluationEngine.initialize(); + await this.loadConfiguration(true); + // Start periodic configuration polling + if (this.options.flagChangePollingIntervalMs && this.options.flagChangePollingIntervalMs > 0) { + this.periodicRunner = setTimeout(() => this.poll(), this.options.flagChangePollingIntervalMs); + } + } + + /** + * Poll the configuration from the API. + */ + private poll(): void { + this.loadConfiguration(false) + .catch((error) => this.logger?.error('Failed to load configuration:', error)) + .finally(() => { + if (this.periodicRunner) { + // check if polling is still active + this.periodicRunner = setTimeout(() => this.poll(), this.options.flagChangePollingIntervalMs); + } + }); + } + + /** + * Evaluates a boolean flag. + * @param flagKey - The key of the flag to evaluate. + * @param defaultValue - The default value to return if the flag is not found. + * @param evaluationContext - The context in which to evaluate the flag. + * @returns The resolution details of the flag evaluation. + */ + async evaluateBoolean( + flagKey: string, + defaultValue: boolean, + evaluationContext?: EvaluationContext, + ): Promise> { + const response = await this.genericEvaluate(flagKey, defaultValue, evaluationContext); + this.handleError(response, flagKey); + + if (typeof response.value === 'boolean') { + return this.prepareResponse(response, flagKey, response.value); + } + + throw new TypeMismatchError(`Flag ${flagKey} had unexpected type, expected boolean.`); + } + + /** + * Evaluates a string flag. + * @param flagKey - The key of the flag to evaluate. + * @param defaultValue - The default value to return if the flag is not found. + * @param evaluationContext - The context in which to evaluate the flag. + * @returns The resolution details of the flag evaluation. + */ + async evaluateString( + flagKey: string, + defaultValue: string, + evaluationContext?: EvaluationContext, + ): Promise> { + const response = await this.genericEvaluate(flagKey, defaultValue, evaluationContext); + this.handleError(response, flagKey); + + if (typeof response.value === 'string') { + return this.prepareResponse(response, flagKey, response.value); + } + + throw new TypeMismatchError(`Flag ${flagKey} had unexpected type, expected string.`); + } + + /** + * Evaluates a number flag. + * @param flagKey - The key of the flag to evaluate. + * @param defaultValue - The default value to return if the flag is not found. + * @param evaluationContext - The context in which to evaluate the flag. + * @returns The resolution details of the flag evaluation. + */ + async evaluateNumber( + flagKey: string, + defaultValue: number, + evaluationContext?: EvaluationContext, + ): Promise> { + const response = await this.genericEvaluate(flagKey, defaultValue, evaluationContext); + this.handleError(response, flagKey); + + if (typeof response.value === 'number') { + return this.prepareResponse(response, flagKey, response.value); + } + + throw new TypeMismatchError(`Flag ${flagKey} had unexpected type, expected number.`); + } + + /** + * Evaluates an object flag. + * @param flagKey - The key of the flag to evaluate. + * @param defaultValue - The default value to return if the flag is not found. + * @param evaluationContext - The context in which to evaluate the flag. + * @returns The resolution details of the flag evaluation. + */ + async evaluateObject( + flagKey: string, + defaultValue: T, + evaluationContext?: EvaluationContext, + ): Promise> { + const response = await this.genericEvaluate(flagKey, defaultValue, evaluationContext); + this.handleError(response, flagKey); + + if (response.value !== null && response.value !== undefined) { + return this.prepareResponse(response, flagKey, response.value as T); + } + + throw new TypeMismatchError(`Flag ${flagKey} had unexpected type, expected object.`); + } + + /** + * Check if the flag is trackable. + * @param flagKey - The key of the flag to check. + * @returns True if the flag is trackable. + */ + isFlagTrackable(flagKey: string): boolean { + const flag = this.flags[flagKey]; + if (!flag) { + this.logger?.warn(`Flag with key ${flagKey} not found`); + // If the flag is not found, this is most likely a configuration change, so we track it by default. + return true; + } + + return flag.trackEvents ?? true; + } + + /** + * Dispose the evaluator. + */ + async dispose(): Promise { + if (this.periodicRunner) { + clearInterval(this.periodicRunner); + this.periodicRunner = undefined; + } + return this.evaluationEngine.dispose(); + } + + /** + * Evaluates a flag with the given key and default value in the context of the provided evaluation context. + * @param flagKey - Name of the feature flag + * @param defaultValue - Default value in case of error + * @param evaluationContext - Context of the evaluation + * @returns An EvaluationResponse containing the output of the evaluation. + */ + private async genericEvaluate( + flagKey: string, + defaultValue: unknown, + evaluationContext?: EvaluationContext, + ): Promise { + const flag = this.flags[flagKey]; + if (!flag) { + return { + value: defaultValue as JsonValue, + errorCode: 'FLAG_NOT_FOUND', + errorDetails: `Flag with key '${flagKey}' not found`, + reason: 'ERROR', + trackEvents: true, + }; + } + + const input: WasmInput = { + flagKey, + evalContext: evaluationContext ? (evaluationContext as Record) : {}, + flagContext: { + defaultSdkValue: defaultValue, + evaluationContextEnrichment: this.evaluationContextEnrichment, + }, + flag, + }; + + return await this.evaluationEngine.evaluate(input); + } + + /** + * LoadConfiguration is responsible for loading the configuration of the flags from the API. + * @throws ImpossibleToRetrieveConfigurationException - In case we are not able to call the relay proxy and to get the flag values. + */ + private async loadConfiguration(firstLoad = false): Promise { + try { + // Call the API to retrieve the flags' configuration and store it in the local copy + const flagConfigResponse = await this.api.retrieveFlagConfiguration(this.etag, undefined); + + if (!flagConfigResponse) { + throw new ImpossibleToRetrieveConfigurationException('Flag configuration response is null'); + } + + if (this.etag && this.etag === flagConfigResponse.etag) { + this.logger?.debug('Flag configuration has not changed'); + return; + } + + const respLastUpdated = flagConfigResponse.lastUpdated || new Date(0); + if ( + this.lastUpdate.getTime() !== new Date(0).getTime() && + respLastUpdated.getTime() !== new Date(0).getTime() && + respLastUpdated < this.lastUpdate + ) { + this.logger?.warn('Configuration received is older than the current one'); + return; + } + + this.logger?.debug('Flag configuration has changed'); + this.etag = flagConfigResponse.etag; + this.lastUpdate = flagConfigResponse.lastUpdated || new Date(0); + this.flags = flagConfigResponse.flags || {}; + this.evaluationContextEnrichment = flagConfigResponse.evaluationContextEnrichment || {}; + + // Send an event to the event channel to notify about the configuration change + if (this.eventChannel && !firstLoad) { + this.logger?.debug('Emitting configuration changed event'); + this.eventChannel.emit(ServerProviderEvents.ConfigurationChanged, {}); + } + } catch (error) { + this.logger?.error('Failed to load configuration:', error); + throw error; + } + } + + /** + * HandleError is handling the error response from the evaluation API. + * @param response - Response of the evaluation. + * @param flagKey - Name of the feature flag. + * @throws Error - When the evaluation is on error. + */ + private handleError(response: EvaluationResponse, flagKey: string): void { + switch (response.errorCode) { + case '': + case null: + case undefined: + // if we no error code it means that the evaluation is successful + return; + case ErrorCode.FLAG_NOT_FOUND: + throw new FlagNotFoundError(response.errorDetails || `Flag ${flagKey} was not found in your configuration`); + case ErrorCode.PARSE_ERROR: + throw new ParseError(response.errorDetails || `Parse error for flag ${flagKey}`); + case ErrorCode.TYPE_MISMATCH: + throw new TypeMismatchError(response.errorDetails || `Type mismatch for flag ${flagKey}`); + case ErrorCode.TARGETING_KEY_MISSING: + throw new TargetingKeyMissingError(response.errorDetails || `Targeting key missing for flag ${flagKey}`); + case ErrorCode.INVALID_CONTEXT: + throw new InvalidContextError(response.errorDetails || `Invalid context for flag ${flagKey}`); + case ErrorCode.PROVIDER_NOT_READY: + throw new ProviderNotReadyError(response.errorDetails || `Provider not ready for flag ${flagKey}`); + case ErrorCode.PROVIDER_FATAL: + throw new ProviderFatalError(response.errorDetails || `Provider fatal error for flag ${flagKey}`); + default: + throw new GeneralError(response.errorDetails || `Evaluation error: ${response.errorCode}`); + } + } + + /** + * PrepareResponse is preparing the response to be returned to the caller. + * @param response - Response of the evaluation. + * @param flagKey - Name of the feature flag. + * @param value - Value of the feature flag. + * @returns ResolutionDetails with the flag value and metadata. + */ + private prepareResponse(response: EvaluationResponse, flagKey: string, value: T): ResolutionDetails { + try { + return { + value, + reason: response.reason, + flagMetadata: response.metadata as Record, + variant: response.variationType, + }; + } catch (error) { + throw new TypeMismatchError(`Flag value ${flagKey} had unexpected type ${typeof response.value}.`); + } + } +} diff --git a/libs/providers/go-feature-flag/src/lib/evaluator/remote-evaluator.ts b/libs/providers/go-feature-flag/src/lib/evaluator/remote-evaluator.ts new file mode 100644 index 000000000..974c903be --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/evaluator/remote-evaluator.ts @@ -0,0 +1,118 @@ +import type { EvaluationContext, JsonValue, Logger, ResolutionDetails } from '@openfeature/core'; +import type { IEvaluator } from './evaluator'; +import { OFREPProvider, type OFREPProviderOptions } from '@openfeature/ofrep-provider'; +import type { GoFeatureFlagProviderOptions } from '../go-feature-flag-provider-options'; +import { isomorphicFetch } from '../helper/fetch-api'; + +export class RemoteEvaluator implements IEvaluator { + /** + * The OFREP provider + */ + private readonly ofrepProvider: OFREPProvider; + /** + * The logger to use. + */ + private readonly logger?: Logger; + + constructor(options: GoFeatureFlagProviderOptions, logger?: Logger) { + this.logger = logger; + const ofrepOptions: OFREPProviderOptions = { + baseUrl: options.endpoint, + timeoutMs: options.timeout, + fetchImplementation: options.fetchImplementation ?? isomorphicFetch(), + }; + ofrepOptions.headers = [['Content-Type', 'application/json']]; + if (options.apiKey) { + ofrepOptions.headers.push(['Authorization', `Bearer ${options.apiKey}`]); + } + this.ofrepProvider = new OFREPProvider(ofrepOptions); + } + + /** + * Evaluates a boolean flag. + * @param flagKey - The key of the flag to evaluate. + * @param defaultValue - The default value to return if the flag is not found. + * @param evaluationContext - The context in which to evaluate the flag. + * @returns The resolution details of the flag evaluation. + */ + async evaluateBoolean( + flagKey: string, + defaultValue: boolean, + evaluationContext?: EvaluationContext, + ): Promise> { + return this.ofrepProvider.resolveBooleanEvaluation(flagKey, defaultValue, evaluationContext ?? {}); + } + + /** + * Evaluates a string flag. + * @param flagKey - The key of the flag to evaluate. + * @param defaultValue - The default value to return if the flag is not found. + * @param evaluationContext - The context in which to evaluate the flag. + * @returns The resolution details of the flag evaluation. + */ + async evaluateString( + flagKey: string, + defaultValue: string, + evaluationContext?: EvaluationContext, + ): Promise> { + return this.ofrepProvider.resolveStringEvaluation(flagKey, defaultValue, evaluationContext ?? {}); + } + + /** + * Evaluates a number flag. + * @param flagKey - The key of the flag to evaluate. + * @param defaultValue - The default value to return if the flag is not found. + * @param evaluationContext - The context in which to evaluate the flag. + * @returns The resolution details of the flag evaluation. + */ + async evaluateNumber( + flagKey: string, + defaultValue: number, + evaluationContext?: EvaluationContext, + ): Promise> { + return this.ofrepProvider.resolveNumberEvaluation(flagKey, defaultValue, evaluationContext ?? {}); + } + + /** + * Evaluates an object flag. + * @param flagKey - The key of the flag to evaluate. + * @param defaultValue - The default value to return if the flag is not found. + * @param evaluationContext - The context in which to evaluate the flag. + * @returns The resolution details of the flag evaluation. + */ + evaluateObject( + flagKey: string, + defaultValue: T, + evaluationContext?: EvaluationContext, + ): Promise> { + return this.ofrepProvider.resolveObjectEvaluation(flagKey, defaultValue, evaluationContext ?? {}); + } + + /** + * Checks if the flag is trackable. + * @param _flagKey - The key of the flag to check. + * @returns True if the flag is trackable, false otherwise. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isFlagTrackable(_flagKey: string): boolean { + return false; + } + + /** + * Disposes the evaluator. + * @returns A promise that resolves when the evaluator is disposed. + */ + dispose(): Promise { + this.logger?.info('Disposing Remote evaluator'); + return Promise.resolve(); + } + + /** + * Initializes the evaluator. + * @returns A promise that resolves when the evaluator is initialized. + */ + async initialize(): Promise { + this.logger?.info('Initializing Remote evaluator'); + return Promise.resolve(); + } +} diff --git a/libs/providers/go-feature-flag/src/lib/exception/evaluator-not-found-exception.ts b/libs/providers/go-feature-flag/src/lib/exception/evaluator-not-found-exception.ts new file mode 100644 index 000000000..cbaf95baa --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/exception/evaluator-not-found-exception.ts @@ -0,0 +1,7 @@ +import { GoFeatureFlagException } from './go-feature-flag-exception'; + +export class EvaluatorNotFoundException extends GoFeatureFlagException { + constructor(message: string) { + super(message); + } +} diff --git a/libs/providers/go-feature-flag/src/lib/exception/event-publisher-not-found-exception.ts b/libs/providers/go-feature-flag/src/lib/exception/event-publisher-not-found-exception.ts new file mode 100644 index 000000000..d2d03093f --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/exception/event-publisher-not-found-exception.ts @@ -0,0 +1,7 @@ +import { GoFeatureFlagException } from './go-feature-flag-exception'; + +export class EventPublisherNotFoundException extends GoFeatureFlagException { + constructor(message: string) { + super(message); + } +} diff --git a/libs/providers/go-feature-flag/src/lib/exception/flag-configuration-endpoint-not-found-exception.ts b/libs/providers/go-feature-flag/src/lib/exception/flag-configuration-endpoint-not-found-exception.ts new file mode 100644 index 000000000..3fb3132ab --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/exception/flag-configuration-endpoint-not-found-exception.ts @@ -0,0 +1,7 @@ +import { GoFeatureFlagException } from './go-feature-flag-exception'; + +export class FlagConfigurationEndpointNotFoundException extends GoFeatureFlagException { + constructor() { + super('Flag configuration endpoint not found'); + } +} diff --git a/libs/providers/go-feature-flag/src/lib/exception/go-feature-flag-exception.ts b/libs/providers/go-feature-flag/src/lib/exception/go-feature-flag-exception.ts new file mode 100644 index 000000000..379a6f7a5 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/exception/go-feature-flag-exception.ts @@ -0,0 +1,6 @@ +export abstract class GoFeatureFlagException extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} diff --git a/libs/providers/go-feature-flag/src/lib/exception/impossible-to-retrieve-configuration-exception.ts b/libs/providers/go-feature-flag/src/lib/exception/impossible-to-retrieve-configuration-exception.ts new file mode 100644 index 000000000..56ad2cc8a --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/exception/impossible-to-retrieve-configuration-exception.ts @@ -0,0 +1,7 @@ +import { GoFeatureFlagException } from './go-feature-flag-exception'; + +export class ImpossibleToRetrieveConfigurationException extends GoFeatureFlagException { + constructor(message: string) { + super(message); + } +} diff --git a/libs/providers/go-feature-flag/src/lib/exception/impossible-to-send-data-to-the-collector-exception.ts b/libs/providers/go-feature-flag/src/lib/exception/impossible-to-send-data-to-the-collector-exception.ts new file mode 100644 index 000000000..f74e6add3 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/exception/impossible-to-send-data-to-the-collector-exception.ts @@ -0,0 +1,7 @@ +import { GoFeatureFlagException } from './go-feature-flag-exception'; + +export class ImpossibleToSendDataToTheCollectorException extends GoFeatureFlagException { + constructor(message: string) { + super(message); + } +} diff --git a/libs/providers/go-feature-flag/src/lib/exception/index.ts b/libs/providers/go-feature-flag/src/lib/exception/index.ts new file mode 100644 index 000000000..314454e3a --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/exception/index.ts @@ -0,0 +1,11 @@ +export * from './go-feature-flag-exception'; +export * from './evaluator-not-found-exception'; +export * from './event-publisher-not-found-exception'; +export * from './flag-configuration-endpoint-not-found-exception'; +export * from './impossible-to-retrieve-configuration-exception'; +export * from './impossible-to-send-data-to-the-collector-exception'; +export * from './invalid-options-exception'; +export * from './unauthorized-exception'; +export * from './wasm-function-not-found-exception'; +export * from './wasm-invalid-result-exception'; +export * from './wasm-not-loaded-exception'; diff --git a/libs/providers/go-feature-flag/src/lib/exception/invalid-options-exception.ts b/libs/providers/go-feature-flag/src/lib/exception/invalid-options-exception.ts new file mode 100644 index 000000000..d13180d65 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/exception/invalid-options-exception.ts @@ -0,0 +1,7 @@ +import { GoFeatureFlagException } from './go-feature-flag-exception'; + +export class InvalidOptionsException extends GoFeatureFlagException { + constructor(message: string) { + super(message); + } +} diff --git a/libs/providers/go-feature-flag/src/lib/exception/unauthorized-exception.ts b/libs/providers/go-feature-flag/src/lib/exception/unauthorized-exception.ts new file mode 100644 index 000000000..ee7f7ce8c --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/exception/unauthorized-exception.ts @@ -0,0 +1,7 @@ +import { GoFeatureFlagException } from './go-feature-flag-exception'; + +export class UnauthorizedException extends GoFeatureFlagException { + constructor(message: string) { + super(message); + } +} diff --git a/libs/providers/go-feature-flag/src/lib/exception/wasm-function-not-found-exception.ts b/libs/providers/go-feature-flag/src/lib/exception/wasm-function-not-found-exception.ts new file mode 100644 index 000000000..5184629e5 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/exception/wasm-function-not-found-exception.ts @@ -0,0 +1,11 @@ +import { GoFeatureFlagException } from './go-feature-flag-exception'; + +/** + * Exception thrown when a required WASM function is not found. + */ +export class WasmFunctionNotFoundException extends GoFeatureFlagException { + constructor(functionName: string) { + super(`WASM function '${functionName}' not found`); + this.name = 'WasmFunctionNotFoundException'; + } +} diff --git a/libs/providers/go-feature-flag/src/lib/exception/wasm-invalid-result-exception.ts b/libs/providers/go-feature-flag/src/lib/exception/wasm-invalid-result-exception.ts new file mode 100644 index 000000000..24abae115 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/exception/wasm-invalid-result-exception.ts @@ -0,0 +1,11 @@ +import { GoFeatureFlagException } from './go-feature-flag-exception'; + +/** + * Exception thrown when the WASM module returns an invalid result. + */ +export class WasmInvalidResultException extends GoFeatureFlagException { + constructor(message: string) { + super(message); + this.name = 'WasmInvalidResultException'; + } +} diff --git a/libs/providers/go-feature-flag/src/lib/exception/wasm-not-loaded-exception.ts b/libs/providers/go-feature-flag/src/lib/exception/wasm-not-loaded-exception.ts new file mode 100644 index 000000000..f26ba0066 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/exception/wasm-not-loaded-exception.ts @@ -0,0 +1,11 @@ +import { GoFeatureFlagException } from './go-feature-flag-exception'; + +/** + * Exception thrown when the WASM module cannot be loaded. + */ +export class WasmNotLoadedException extends GoFeatureFlagException { + constructor(message: string) { + super(message); + this.name = 'WasmNotLoadedException'; + } +} diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider-options.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider-options.ts new file mode 100644 index 000000000..2b4bd22ea --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider-options.ts @@ -0,0 +1,61 @@ +import type { EvaluationType, ExporterMetadata } from './model'; +import type { FetchAPI } from './helper/fetch-api'; + +export interface GoFeatureFlagProviderOptions { + /** + * The endpoint of the GO Feature Flag relay-proxy. + */ + endpoint: string; + + /** + * The type of evaluation to use. + * @default EvaluationType.InProcess + */ + evaluationType?: EvaluationType; + + /** + * The timeout for HTTP requests in milliseconds. + * @default 10000 + */ + timeout?: number; + + /** + * The interval for polling flag configuration changes in milliseconds. + * @default 120000 + */ + flagChangePollingIntervalMs?: number; + + /** + * The interval for flushing data collection events in milliseconds. + * @default 120000 + */ + dataFlushInterval?: number; + + /** + * The maximum number of pending events before flushing. + * @default 10000 + */ + maxPendingEvents?: number; + + /** + * Whether to disable data collection. + * @default false + */ + disableDataCollection?: boolean; + + /** + * ‼️Important: If you are using a GO Feature Flag relay proxy before version v1.41.0, the information + * of this field will not be added to your feature events. + */ + exporterMetadata?: ExporterMetadata; + + /** + * API key for authentication with the relay-proxy. + */ + apiKey?: string; + + /** + * Fetch implementation for HTTP requests. + */ + fetchImplementation?: FetchAPI; +} diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts deleted file mode 100644 index 68022fe92..000000000 --- a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.spec.ts +++ /dev/null @@ -1,1111 +0,0 @@ -/** - * @jest-environment node - */ -import type { Client } from '@openfeature/server-sdk'; -import { ErrorCode, OpenFeature, ServerProviderStatus, StandardResolutionReasons } from '@openfeature/server-sdk'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import { GoFeatureFlagProvider } from './go-feature-flag-provider'; -import type { GoFeatureFlagProxyResponse } from './model'; -import TestLogger from './test-logger'; - -describe('GoFeatureFlagProvider', () => { - const endpoint = 'http://go-feature-flag-relay-proxy.local:1031/'; - const dataCollectorEndpoint = `${endpoint}v1/data/collector`; - const axiosMock = new MockAdapter(axios); - const validBoolResponse: GoFeatureFlagProxyResponse = { - value: true, - variationType: 'trueVariation', - reason: StandardResolutionReasons.TARGETING_MATCH, - failed: false, - trackEvents: true, - version: '1.0.0', - metadata: { - description: 'a description of the flag', - issue_number: 1, - }, - cacheable: true, - }; - - let goff: GoFeatureFlagProvider; - let cli: Client; - const testLogger = new TestLogger(); - - afterEach(async () => { - await OpenFeature.close(); - axiosMock.reset(); - axiosMock.resetHistory(); - testLogger.reset(); - await OpenFeature.close(); - }); - - beforeEach(async () => { - await OpenFeature.close(); - axiosMock.reset(); - axiosMock.resetHistory(); - goff = new GoFeatureFlagProvider({ endpoint }); - await OpenFeature.setProviderAndWait('test-provider', goff); - cli = OpenFeature.getClient('test-provider'); - }); - - describe('common usecases and errors', () => { - it('should be an instance of GoFeatureFlagProvider', () => { - const goff = new GoFeatureFlagProvider({ endpoint }); - expect(goff).toBeInstanceOf(GoFeatureFlagProvider); - }); - it('should throw an error if proxy not ready', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - axiosMock.onPost(dns).reply(404); - const res = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const want = { - errorCode: ErrorCode.PROVIDER_NOT_READY, - errorMessage: - 'impossible to call go-feature-flag relay proxy on http://go-feature-flag-relay-proxy.local:1031/v1/feature/random-flag/eval: Error: Request failed with status code 404', - flagKey: flagName, - reason: StandardResolutionReasons.ERROR, - value: false, - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('should throw an error if the call timeout', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - axiosMock.onPost(dns).timeout(); - const res = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const want = { - errorCode: ErrorCode.GENERAL, - errorMessage: 'impossible to retrieve the random-flag on time: Error: timeout of 0ms exceeded', - flagKey: flagName, - reason: StandardResolutionReasons.ERROR, - value: false, - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - describe('error codes in HTTP response', () => { - it('SDK error codes should return correct code', async () => { - const flagName = 'random-other-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - axiosMock.onPost(dns).reply(200, { - value: true, - variationType: 'trueVariation', - errorCode: ErrorCode.PROVIDER_NOT_READY, - reason: StandardResolutionReasons.ERROR, - } as GoFeatureFlagProxyResponse); - const res = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const want = { - errorCode: ErrorCode.PROVIDER_NOT_READY, - flagKey: flagName, - reason: StandardResolutionReasons.ERROR, - value: false, - flagMetadata: {}, - }; - expect(res).toEqual(expect.objectContaining(want)); - }); - it('unknown error codes should return GENERAL code', async () => { - const flagName = 'random-other-other-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - axiosMock.onPost(dns).reply(200, { - value: true, - variationType: 'trueVariation', - errorCode: 'NOT-AN-SDK-ERROR', - reason: StandardResolutionReasons.ERROR, - } as unknown as GoFeatureFlagProxyResponse); - const res = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const want = { - errorCode: ErrorCode.GENERAL, - flagKey: flagName, - reason: StandardResolutionReasons.ERROR, - value: false, - flagMetadata: {}, - }; - expect(res).toEqual(expect.objectContaining(want)); - }); - }); - it('should throw an error if we fail in other network errors case', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - axiosMock.onPost(dns).networkError(); - const res = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const want = { - errorCode: ErrorCode.GENERAL, - errorMessage: `unknown error while retrieving flag ${flagName} for evaluation context ${targetingKey}: Error: Network Error`, - flagKey: flagName, - reason: StandardResolutionReasons.ERROR, - value: false, - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('should throw an error if the flag does not exists', async () => { - const flagName = 'unknown-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: 'sdk-default', - variationType: 'trueVariation', - errorCode: ErrorCode.FLAG_NOT_FOUND, - } as GoFeatureFlagProxyResponse); - const res = await cli.getStringDetails(flagName, 'sdk-default', { targetingKey }); - const want = { - errorCode: ErrorCode.FLAG_NOT_FOUND, - errorMessage: `Flag ${flagName} was not found in your configuration`, - flagKey: flagName, - reason: StandardResolutionReasons.ERROR, - value: 'sdk-default', - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('should throw an error if invalid api key is provided', async () => { - const flagName = 'unauthorized'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - axiosMock.onPost(dns).reply(401, {} as GoFeatureFlagProxyResponse); - const res = await cli.getStringDetails(flagName, 'sdk-default', { targetingKey }); - const want = { - errorCode: ErrorCode.GENERAL, - errorMessage: 'invalid token used to contact GO Feature Flag relay proxy instance', - flagKey: flagName, - reason: StandardResolutionReasons.ERROR, - value: 'sdk-default', - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('should be valid with an API key provided', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: true, - variationType: 'trueVariation', - reason: StandardResolutionReasons.TARGETING_MATCH, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - const res = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.TARGETING_MATCH, - value: true, - variant: 'trueVariation', - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('provider should be ready after after setting the provider to Open Feature', async () => { - expect(cli.providerStatus).toEqual(ServerProviderStatus.READY); - }); - - it('should send exporter metadata to the evaluation API', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - axiosMock.onPost(dns).reply(200, { - value: true, - variationType: 'trueVariation', - reason: StandardResolutionReasons.TARGETING_MATCH, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const provider = new GoFeatureFlagProvider({ - endpoint, - exporterMetadata: { key1: 'value', key2: 123, key3: 123.45 }, - }); - await OpenFeature.setProviderAndWait('test-exporter-metadata', provider); - const cli = OpenFeature.getClient('test-exporter-metadata'); - - await cli.getBooleanDetails(flagName, false, { targetingKey }); - const request = axiosMock.history.post[0]; - const want = { - openfeature: true, - provider: 'js', - key1: 'value', - key2: 123, - key3: 123.45, - }; - const got = JSON.parse(request.data).evaluationContext.custom.gofeatureflag.exporterMetadata; - expect(got).toEqual(want); - }); - }); - describe('resolveBooleanEvaluation', () => { - it('should throw an error if we expect a boolean and got another type', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: 'true', - variationType: 'trueVariation', - } as GoFeatureFlagProxyResponse); - const res = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const want = { - errorCode: ErrorCode.TYPE_MISMATCH, - errorMessage: 'Flag value random-flag had unexpected type string, expected boolean.', - flagKey: flagName, - reason: StandardResolutionReasons.ERROR, - value: false, - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - - it('should resolve a valid boolean flag with TARGETING_MATCH reason', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: true, - variationType: 'trueVariation', - reason: StandardResolutionReasons.TARGETING_MATCH, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.TARGETING_MATCH, - value: true, - flagMetadata: {}, - variant: 'trueVariation', - }; - expect(res).toEqual(want); - }); - - it('fix version 0.7.2', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - trackEvents: true, - variationType: 'True', - failed: false, - version: '', - reason: 'TARGETING_MATCH', - errorCode: '', - value: true, - cacheable: true, - metadata: { - description: 'this is a test', - pr_link: 'https://github.com/thomaspoignant/go-feature-flag/pull/916', - }, - }); - - const res = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.TARGETING_MATCH, - value: true, - flagMetadata: { - description: 'this is a test', - pr_link: 'https://github.com/thomaspoignant/go-feature-flag/pull/916', - }, - variant: 'True', - }; - expect(res).toEqual(want); - }); - - it('should resolve a valid boolean flag with SPLIT reason', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: true, - variationType: 'trueVariation', - reason: StandardResolutionReasons.SPLIT, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.SPLIT, - value: true, - flagMetadata: {}, - variant: 'trueVariation', - }; - expect(res).toEqual(want); - }); - it('should use boolean default value if the flag is disabled', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: true, - variationType: 'defaultSdk', - reason: StandardResolutionReasons.DISABLED, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - const res = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.DISABLED, - value: false, - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - }); - describe('resolveStringEvaluation', () => { - it('should throw an error if we expect a string and got another type', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: true, - variationType: 'trueVariation', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getStringDetails(flagName, 'false', { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.ERROR, - errorMessage: `Flag value ${flagName} had unexpected type boolean, expected string.`, - errorCode: ErrorCode.TYPE_MISMATCH, - value: 'false', - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('should resolve a valid string flag with TARGETING_MATCH reason', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: 'true value', - variationType: 'trueVariation', - reason: StandardResolutionReasons.TARGETING_MATCH, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getStringDetails(flagName, 'default', { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.TARGETING_MATCH, - value: 'true value', - variant: 'trueVariation', - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('should resolve a valid string flag with SPLIT reason', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: 'true value', - variationType: 'trueVariation', - reason: StandardResolutionReasons.SPLIT, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getStringDetails(flagName, 'default', { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.SPLIT, - value: 'true value', - variant: 'trueVariation', - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('should use string default value if the flag is disabled', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: 'defaultSdk', - variationType: 'defaultSdk', - reason: StandardResolutionReasons.DISABLED, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getStringDetails(flagName, 'randomDefaultValue', { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.DISABLED, - value: 'randomDefaultValue', - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - }); - describe('resolveNumberEvaluation', () => { - it('should throw an error if we expect a number and got another type', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: true, - variationType: 'trueVariation', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getNumberDetails(flagName, 14, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.ERROR, - errorCode: ErrorCode.TYPE_MISMATCH, - errorMessage: `Flag value ${flagName} had unexpected type boolean, expected number.`, - value: 14, - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('should resolve a valid number flag with TARGETING_MATCH reason', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: 14, - variationType: 'trueVariation', - reason: StandardResolutionReasons.TARGETING_MATCH, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getNumberDetails(flagName, 14, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.TARGETING_MATCH, - value: 14, - variant: 'trueVariation', - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('should resolve a valid number flag with SPLIT reason', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: 14, - variationType: 'trueVariation', - reason: StandardResolutionReasons.SPLIT, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getNumberDetails(flagName, 14, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.SPLIT, - value: 14, - variant: 'trueVariation', - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('should use number default value if the flag is disabled', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: 123, - variationType: 'defaultSdk', - reason: StandardResolutionReasons.DISABLED, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getNumberDetails(flagName, 14, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.DISABLED, - value: 14, - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - }); - describe('resolveObjectEvaluation', () => { - it('should throw an error if we expect a json array and got another type', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: true, - variationType: 'trueVariation', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getObjectDetails(flagName, {}, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.ERROR, - errorCode: ErrorCode.TYPE_MISMATCH, - errorMessage: `Flag value ${flagName} had unexpected type boolean, expected object.`, - value: {}, - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('should resolve a valid object flag with TARGETING_MATCH reason', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: { key: true }, - variationType: 'trueVariation', - reason: StandardResolutionReasons.TARGETING_MATCH, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.TARGETING_MATCH, - value: { key: true }, - flagMetadata: {}, - variant: 'trueVariation', - }; - expect(res).toEqual(want); - }); - it('should resolve a valid object flag with SPLIT reason', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: { key: true }, - variationType: 'trueVariation', - reason: StandardResolutionReasons.SPLIT, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.SPLIT, - value: { key: true }, - flagMetadata: {}, - variant: 'trueVariation', - }; - expect(res).toEqual(want); - }); - it('should use object default value if the flag is disabled', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: { key: 123 }, - variationType: 'defaultSdk', - reason: StandardResolutionReasons.DISABLED, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.DISABLED, - value: { key: 'default' }, - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('should resolve a valid json array flag with TARGETING_MATCH reason', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: ['1', '2'], - variationType: 'trueVariation', - reason: StandardResolutionReasons.TARGETING_MATCH, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.TARGETING_MATCH, - value: ['1', '2'], - flagMetadata: {}, - variant: 'trueVariation', - }; - expect(res).toEqual(want); - }); - it('should resolve a valid json array flag with SPLIT reason', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: ['1', '2'], - variationType: 'trueVariation', - reason: StandardResolutionReasons.SPLIT, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.SPLIT, - value: ['1', '2'], - flagMetadata: {}, - variant: 'trueVariation', - }; - expect(res).toEqual(want); - }); - it('should use json array default value if the flag is disabled', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: ['key', '123'], - variationType: 'defaultSdk', - reason: StandardResolutionReasons.DISABLED, - failed: false, - trackEvents: true, - version: '1.0.0', - } as GoFeatureFlagProxyResponse); - - const res = await cli.getObjectDetails(flagName, ['key', '124'], { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.DISABLED, - value: ['key', '124'], - flagMetadata: {}, - }; - expect(res).toEqual(want); - }); - it('should return metadata associated to the flag', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, { - value: true, - variationType: 'trueVariation', - reason: StandardResolutionReasons.TARGETING_MATCH, - failed: false, - trackEvents: true, - version: '1.0.0', - metadata: { - description: 'a description of the flag', - issue_number: 1, - }, - cacheable: true, - } as GoFeatureFlagProxyResponse); - - const res = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const want = { - flagKey: flagName, - reason: StandardResolutionReasons.TARGETING_MATCH, - value: true, - variant: 'trueVariation', - flagMetadata: { - description: 'a description of the flag', - issue_number: 1, - }, - }; - expect(res).toEqual(want); - }); - }); - describe('cache testing', () => { - it('should use the cache if we evaluate 2 times the same flag', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, validBoolResponse); - const goff = new GoFeatureFlagProvider({ - endpoint, - flagCacheTTL: 3000, - flagCacheSize: 1, - disableDataCollection: true, - }); - await OpenFeature.setProviderAndWait('test-provider-cache', goff); - const cli = OpenFeature.getClient('test-provider-cache'); - const got1 = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const got2 = await cli.getBooleanDetails(flagName, false, { targetingKey }); - expect(got1.reason).toEqual(StandardResolutionReasons.TARGETING_MATCH); - expect(got2.reason).toEqual(StandardResolutionReasons.CACHED); - expect(axiosMock.history['post'].length).toBe(1); - }); - - it('should use not use the cache if we evaluate 2 times the same flag if cache is disabled', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, validBoolResponse); - const goff = new GoFeatureFlagProvider({ - endpoint, - disableCache: true, - disableDataCollection: true, - }); - await OpenFeature.setProviderAndWait('test-provider-cache', goff); - const cli = OpenFeature.getClient('test-provider-cache'); - const got1 = await cli.getBooleanDetails(flagName, false, { targetingKey }); - const got2 = await cli.getBooleanDetails(flagName, false, { targetingKey }); - expect(got1).toEqual(got2); - expect(axiosMock.history['post'].length).toBe(2); - }); - - it('should not retrieve from the cache if max size cache is reached', async () => { - const flagName1 = 'random-flag'; - const flagName2 = 'random-flag-1'; - const targetingKey = 'user-key'; - const dns1 = `${endpoint}v1/feature/${flagName1}/eval`; - const dns2 = `${endpoint}v1/feature/${flagName2}/eval`; - axiosMock.onPost(dns1).reply(200, validBoolResponse); - axiosMock.onPost(dns2).reply(200, validBoolResponse); - const goff = new GoFeatureFlagProvider({ - endpoint, - flagCacheSize: 1, - disableDataCollection: true, - }); - await OpenFeature.setProviderAndWait('test-provider-cache', goff); - const cli = OpenFeature.getClient('test-provider-cache'); - await cli.getBooleanDetails(flagName1, false, { targetingKey }); - await cli.getBooleanDetails(flagName2, false, { targetingKey }); - await cli.getBooleanDetails(flagName1, false, { targetingKey }); - expect(axiosMock.history['post'].length).toBe(3); - }); - - it('should not store in the cache if cacheable is false', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns1 = `${endpoint}v1/feature/${flagName}/eval`; - axiosMock.onPost(dns1).reply(200, { ...validBoolResponse, cacheable: false }); - const goff = new GoFeatureFlagProvider({ - endpoint, - flagCacheSize: 1, - disableDataCollection: true, - }); - await OpenFeature.setProviderAndWait('test-provider-cache', goff); - const cli = OpenFeature.getClient('test-provider-cache'); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - expect(axiosMock.history['post'].length).toBe(2); - }); - - it('should not retrieve from the cache it the TTL is reached', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns1 = `${endpoint}v1/feature/${flagName}/eval`; - axiosMock.onPost(dns1).reply(200, { ...validBoolResponse }); - const goff = new GoFeatureFlagProvider({ - endpoint, - flagCacheSize: 1, - disableDataCollection: true, - flagCacheTTL: 200, - }); - await OpenFeature.setProviderAndWait('test-provider-cache', goff); - const cli = OpenFeature.getClient('test-provider-cache'); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await new Promise((r) => setTimeout(r, 300)); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - expect(axiosMock.history['post'].length).toBe(2); - }); - - it('should not retrieve from the cache if we have 2 different flag', async () => { - const flagName1 = 'random-flag'; - const flagName2 = 'random-flag-1'; - const targetingKey = 'user-key'; - const dns1 = `${endpoint}v1/feature/${flagName1}/eval`; - const dns2 = `${endpoint}v1/feature/${flagName2}/eval`; - axiosMock.onPost(dns1).reply(200, validBoolResponse); - axiosMock.onPost(dns2).reply(200, validBoolResponse); - const goff = new GoFeatureFlagProvider({ - endpoint, - flagCacheSize: 1, - disableDataCollection: true, - }); - await OpenFeature.setProviderAndWait('test-provider-cache', goff); - const cli = OpenFeature.getClient('test-provider-cache'); - await cli.getBooleanDetails(flagName1, false, { targetingKey }); - await cli.getBooleanDetails(flagName2, false, { targetingKey }); - expect(axiosMock.history['post'].length).toBe(2); - }); - it('should not retrieve from the cache if context properties are different but same targeting key', async () => { - const flagName1 = 'random-flag'; - const targetingKey = 'user-key'; - const dns1 = `${endpoint}v1/feature/${flagName1}/eval`; - axiosMock.onPost(dns1).reply(200, validBoolResponse); - const goff = new GoFeatureFlagProvider({ - endpoint, - flagCacheSize: 1, - disableDataCollection: true, - }); - await OpenFeature.setProviderAndWait('test-provider-cache', goff); - const cli = OpenFeature.getClient('test-provider-cache'); - await cli.getBooleanDetails(flagName1, false, { targetingKey, email: 'foo.bar@gofeatureflag.org' }); - await cli.getBooleanDetails(flagName1, false, { targetingKey, email: 'bar.foo@gofeatureflag.org' }); - expect(axiosMock.history['post'].length).toBe(2); - }); - }); - describe('data collector testing', () => { - it('should call the data collector when closing Open Feature', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, validBoolResponse); - const goff = new GoFeatureFlagProvider({ - endpoint, - flagCacheTTL: 3000, - flagCacheSize: 100, - dataFlushInterval: 1000, // in milliseconds - exporterMetadata: { - nodeJSVersion: '14.17.0', - appVersion: '1.0.0', - identifier: 123, - }, - }); - const providerName = expect.getState().currentTestName || 'test'; - await OpenFeature.setProviderAndWait(providerName, goff); - const cli = OpenFeature.getClient(providerName); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await OpenFeature.close(); - const collectorCalls = axiosMock.history['post'].filter((i) => i.url === dataCollectorEndpoint); - expect(collectorCalls.length).toBe(1); - const got = JSON.parse(collectorCalls[0].data); - expect(isNaN(got.events[0].creationDate)).toBe(false); - const want = { - events: [ - { - contextKind: 'user', - kind: 'feature', - creationDate: got.events[0].creationDate, - default: false, - key: 'random-flag', - value: true, - variation: 'trueVariation', - userKey: 'user-key', - }, - ], - meta: { provider: 'js', openfeature: true, nodeJSVersion: '14.17.0', appVersion: '1.0.0', identifier: 123 }, - }; - expect(got).toEqual(want); - }); - - it('should call the data collector when waiting more than the dataFlushInterval', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, validBoolResponse); - const goff = new GoFeatureFlagProvider({ - endpoint, - flagCacheTTL: 3000, - flagCacheSize: 100, - dataFlushInterval: 100, // in milliseconds - exporterMetadata: { - nodeJSVersion: '14.17.0', - appVersion: '1.0.0', - identifier: 123, - }, - }); - const providerName = expect.getState().currentTestName || 'test'; - await OpenFeature.setProviderAndWait(providerName, goff); - const cli = OpenFeature.getClient(providerName); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await new Promise((r) => setTimeout(r, 130)); - const collectorCalls = axiosMock.history['post'].filter((i) => i.url === dataCollectorEndpoint); - expect(collectorCalls.length).toBe(1); - }); - - it('should call the data collector multiple time while waiting dataFlushInterval time', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, validBoolResponse); - const goff = new GoFeatureFlagProvider({ - endpoint, - flagCacheTTL: 3000, - flagCacheSize: 100, - dataFlushInterval: 100, // in milliseconds - exporterMetadata: { - nodeJSVersion: '14.17.0', - appVersion: '1.0.0', - identifier: 123, - }, - }); - const providerName = expect.getState().currentTestName || 'test'; - await OpenFeature.setProviderAndWait(providerName, goff); - const cli = OpenFeature.getClient(providerName); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await new Promise((r) => setTimeout(r, 130)); - const collectorCalls = axiosMock.history['post'].filter((i) => i.url === dataCollectorEndpoint); - expect(collectorCalls.length).toBe(1); - axiosMock.resetHistory(); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await new Promise((r) => setTimeout(r, 130)); - const collectorCalls2 = axiosMock.history['post'].filter((i) => i.url === dataCollectorEndpoint); - expect(collectorCalls2.length).toBe(1); - }); - - it('should not call the data collector before the dataFlushInterval', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, validBoolResponse); - const goff = new GoFeatureFlagProvider({ - endpoint, - flagCacheTTL: 3000, - flagCacheSize: 100, - dataFlushInterval: 200, // in milliseconds - exporterMetadata: { - nodeJSVersion: '14.17.0', - appVersion: '1.0.0', - identifier: 123, - }, - }); - const providerName = expect.getState().currentTestName || 'test'; - await OpenFeature.setProviderAndWait(providerName, goff); - const cli = OpenFeature.getClient(providerName); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await new Promise((r) => setTimeout(r, 130)); - const collectorCalls = axiosMock.history['post'].filter((i) => i.url === dataCollectorEndpoint); - - expect(collectorCalls.length).toBe(0); - }); - - it('should have a log when data collector is not available', async () => { - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, validBoolResponse); - axiosMock.onPost(dataCollectorEndpoint).reply(500, {}); - - const goff = new GoFeatureFlagProvider( - { - endpoint, - flagCacheTTL: 3000, - flagCacheSize: 100, - dataFlushInterval: 2000, // in milliseconds - exporterMetadata: { - nodeJSVersion: '14.17.0', - appVersion: '1.0.0', - identifier: 123, - }, - }, - testLogger, - ); - const providerName = expect.getState().currentTestName || 'test'; - await OpenFeature.setProviderAndWait(providerName, goff); - const cli = OpenFeature.getClient(providerName); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await cli.getBooleanDetails(flagName, false, { targetingKey }); - await OpenFeature.close(); - - expect(testLogger.inMemoryLogger['error'].length).toBe(1); - expect(testLogger.inMemoryLogger['error']).toContain( - 'Error: impossible to send the data to the collector: Error: Request failed with status code 500', - ); - }); - }); - describe('polling', () => { - it('should_stop_calling_flag_change_if_receive_404', async () => { - const providerName = expect.getState().currentTestName || 'test'; - const goff = new GoFeatureFlagProvider({ - endpoint, - disableDataCollection: false, - pollInterval: 100, - flagCacheTTL: 3000, - flagCacheSize: 100, - }); - await OpenFeature.setProviderAndWait(providerName, goff); - const flagName = 'random-flag'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, validBoolResponse); - axiosMock.onGet(`${endpoint}v1/flag/change`).reply(404); - await new Promise((r) => setTimeout(r, 1000)); - - const nbCall = axiosMock.history['get'].filter((i) => i.url === `${endpoint}v1/flag/change`).length; - expect(nbCall).toBe(1); - }); - it('should not get cached value if flag configuration changed', async () => { - const providerName = expect.getState().currentTestName || 'test'; - const goff = new GoFeatureFlagProvider({ - endpoint, - disableDataCollection: false, - pollInterval: 100, - flagCacheTTL: 3000, - flagCacheSize: 100, - }); - await OpenFeature.setProviderAndWait(providerName, goff); - const cli = OpenFeature.getClient(providerName); - - const flagName = 'random-flag'; - const targetingKey = 'user-key'; - const dns = `${endpoint}v1/feature/${flagName}/eval`; - - axiosMock.onPost(dns).reply(200, validBoolResponse); - axiosMock.onGet(`${endpoint}v1/flag/change`).replyOnce(200, {}, { etag: '123' }); - axiosMock.onGet(`${endpoint}v1/flag/change`).replyOnce(200, {}, { etag: '456' }); - axiosMock.onGet(`${endpoint}v1/flag/change`).reply(304); - - const res1 = await cli.getBooleanDetails(flagName, false, { targetingKey }); - expect(res1.reason).toEqual(StandardResolutionReasons.TARGETING_MATCH); - const res2 = await cli.getBooleanDetails(flagName, false, { targetingKey }); - expect(res2.reason).toEqual(StandardResolutionReasons.CACHED); - await new Promise((r) => setTimeout(r, 1000)); - const res3 = await cli.getBooleanDetails(flagName, false, { targetingKey }); - expect(res3.reason).toEqual(StandardResolutionReasons.TARGETING_MATCH); - }); - }); -}); diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.test.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.test.ts new file mode 100644 index 000000000..d06300586 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.test.ts @@ -0,0 +1,1466 @@ +import { ErrorCode, OpenFeature, ServerProviderEvents } from '@openfeature/server-sdk'; +import { GoFeatureFlagProvider } from './go-feature-flag-provider'; +import fetchMock from 'jest-fetch-mock'; +import * as fs from 'fs'; +import * as path from 'path'; +import { HTTP_HEADER_LAST_MODIFIED } from './helper/constants'; +import { EvaluationType } from './model'; +import { + FlagConfigurationEndpointNotFoundException, + InvalidOptionsException, + UnauthorizedException, +} from './exception'; + +const DefaultEvaluationContext = { + targetingKey: 'd45e303a-38c2-11ed-a261-0242ac120002', + email: 'john.doe@gofeatureflag.org', + firstname: 'john', + lastname: 'doe', + anonymous: false, + professional: true, + rate: 3.14, + age: 30, + company_info: { + name: 'my_company', + size: 120, + }, + labels: ['pro', 'beta'], +}; + +describe('GoFeatureFlagProvider', () => { + let testClientName = ''; + beforeEach(async () => { + testClientName = expect.getState().currentTestName ?? 'my-test'; + await OpenFeature.close(); + jest.useFakeTimers(); + fetchMock.enableMocks(); + }); + + afterEach(async () => { + testClientName = ''; + jest.clearAllMocks(); + jest.useRealTimers(); + fetchMock.resetMocks(); + + // Clean up OpenFeature + await OpenFeature.close(); + }); + + describe('Constructor', () => { + it('should validate metadata name', () => { + const provider = new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + evaluationType: EvaluationType.Remote, + }); + expect(provider.metadata.name).toBe('GoFeatureFlagProvider'); + }); + + it('should throw InvalidOptionsException when options is null', () => { + expect(() => new GoFeatureFlagProvider(null as any)).toThrow(InvalidOptionsException); + expect(() => new GoFeatureFlagProvider(null as any)).toThrow('No options provided'); + }); + + it('should throw InvalidOptionsException when options is undefined', () => { + expect(() => new GoFeatureFlagProvider(undefined as any)).toThrow(InvalidOptionsException); + expect(() => new GoFeatureFlagProvider(undefined as any)).toThrow('No options provided'); + }); + + it('should throw InvalidOptionsException when endpoint is null', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: null as any, + }), + ).toThrow(InvalidOptionsException); + expect( + () => + new GoFeatureFlagProvider({ + endpoint: null as any, + }), + ).toThrow('endpoint is a mandatory field when initializing the provider'); + }); + + it('should throw InvalidOptionsException when endpoint is undefined', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: undefined as any, + }), + ).toThrow(InvalidOptionsException); + expect( + () => + new GoFeatureFlagProvider({ + endpoint: undefined as any, + }), + ).toThrow('endpoint is a mandatory field when initializing the provider'); + }); + + it('should throw InvalidOptionsException when endpoint is empty string', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: '', + }), + ).toThrow(InvalidOptionsException); + expect( + () => + new GoFeatureFlagProvider({ + endpoint: '', + }), + ).toThrow('endpoint is a mandatory field when initializing the provider'); + }); + + it('should throw InvalidOptionsException when endpoint is whitespace only', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: ' ', + }), + ).toThrow(InvalidOptionsException); + expect( + () => + new GoFeatureFlagProvider({ + endpoint: ' ', + }), + ).toThrow('endpoint is a mandatory field when initializing the provider'); + }); + + it('should throw InvalidOptionsException when endpoint is not a valid URL', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'not-a-url', + }), + ).toThrow(InvalidOptionsException); + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'not-a-url', + }), + ).toThrow('endpoint must be a valid URL (http or https)'); + }); + + it('should throw InvalidOptionsException when endpoint is missing protocol', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'gofeatureflag.org', + }), + ).toThrow(InvalidOptionsException); + }); + + it('should throw InvalidOptionsException when endpoint has invalid protocol', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'ftp://gofeatureflag.org', + }), + ).toThrow(InvalidOptionsException); + }); + + it('should accept valid HTTP endpoint', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'http://gofeatureflag.org', + }), + ).not.toThrow(); + }); + + it('should accept valid HTTPS endpoint', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + }), + ).not.toThrow(); + }); + + it('should accept valid endpoint with path', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org/api/v1', + }), + ).not.toThrow(); + }); + + it('should accept valid endpoint with port', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org:8080', + }), + ).not.toThrow(); + }); + + it('should throw InvalidOptionsException when flagChangePollingIntervalMs is zero', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + flagChangePollingIntervalMs: 0, + }), + ).toThrow(InvalidOptionsException); + }); + + it('should throw InvalidOptionsException when flagChangePollingIntervalMs is negative', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + flagChangePollingIntervalMs: -1000, + }), + ).toThrow(InvalidOptionsException); + }); + + it('should accept valid flagChangePollingIntervalMs', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + flagChangePollingIntervalMs: 30000, + }), + ).not.toThrow(); + }); + + it('should throw InvalidOptionsException when timeout is zero', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + timeout: 0, + }), + ).toThrow(InvalidOptionsException); + }); + + it('should throw InvalidOptionsException when timeout is negative', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + timeout: -5000, + }), + ).toThrow(InvalidOptionsException); + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + timeout: -5000, + }), + ).toThrow('timeout must be greater than zero'); + }); + + it('should throw InvalidOptionsException when dataFlushInterval is zero', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + dataFlushInterval: 0, + }), + ).toThrow(InvalidOptionsException); + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + dataFlushInterval: 0, + }), + ).toThrow('dataFlushInterval must be greater than zero'); + }); + + it('should throw InvalidOptionsException when dataFlushInterval is negative', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + dataFlushInterval: -1000, + }), + ).toThrow(InvalidOptionsException); + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + dataFlushInterval: -1000, + }), + ).toThrow('dataFlushInterval must be greater than zero'); + }); + + it('should accept valid dataFlushInterval', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + dataFlushInterval: 1000, + }), + ).not.toThrow(); + }); + + it('should throw InvalidOptionsException when maxPendingEvents is zero', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + maxPendingEvents: 0, + }), + ).toThrow(InvalidOptionsException); + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + maxPendingEvents: 0, + }), + ).toThrow('maxPendingEvents must be greater than zero'); + }); + + it('should throw InvalidOptionsException when maxPendingEvents is negative', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + maxPendingEvents: -100, + }), + ).toThrow(InvalidOptionsException); + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + maxPendingEvents: -100, + }), + ).toThrow('maxPendingEvents must be greater than zero'); + }); + + it('should accept valid maxPendingEvents', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + maxPendingEvents: 10000, + }), + ).not.toThrow(); + }); + + it('should accept provider with all optional fields set to valid values', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + evaluationType: EvaluationType.InProcess, + timeout: 15000, + flagChangePollingIntervalMs: 60000, + dataFlushInterval: 2000, + maxPendingEvents: 5000, + disableDataCollection: true, + apiKey: 'test-api-key', + }), + ).not.toThrow(); + }); + + it('should accept provider with minimal required options', () => { + expect( + () => + new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + }), + ).not.toThrow(); + }); + }); + + describe('Basic Provider Functionality', () => { + it('should handle track method calls', () => { + const provider = new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + evaluationType: EvaluationType.Remote, + }); + + expect(() => { + provider.track('test-event', { targetingKey: 'test-user' }); + }).not.toThrow(); + }); + + it('should handle evaluation context with various data types', () => { + const provider = new GoFeatureFlagProvider({ + endpoint: 'https://gofeatureflag.org', + evaluationType: EvaluationType.Remote, + }); + + const complexContext = { + targetingKey: 'user123', + stringValue: 'test', + numberValue: 42, + booleanValue: true, + objectValue: { key: 'value' }, + arrayValue: [1, 2, 3], + }; + + expect(() => { + provider.track('test-event', complexContext); + }).not.toThrow(); + }); + }); + + describe('Remote Evaluation', () => { + it('should evaluate a string flag with remote evaluation', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + value: 'CC0000', + key: 'string_key', + reason: 'TARGETING_MATCH', + variant: 'color1', + metadata: { + team: 'ecommerce', + businessPurpose: 'experiment', + }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + evaluationType: EvaluationType.Remote, + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getStringDetails('string_key', 'default', DefaultEvaluationContext); + + const want = { + value: 'CC0000', + reason: 'TARGETING_MATCH', + flagKey: 'string_key', + variant: 'color1', + flagMetadata: { + team: 'ecommerce', + businessPurpose: 'experiment', + }, + }; + expect(result).toEqual(want); + }); + + it('should evaluate a boolean flag with remote evaluation', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + value: true, + key: 'bool_key', + reason: 'STATIC', + variant: 'enabled', + metadata: { + team: 'ecommerce', + businessPurpose: 'experiment', + }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + evaluationType: EvaluationType.Remote, + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getBooleanDetails('bool_key', false, DefaultEvaluationContext); + + const want = { + value: true, + reason: 'STATIC', + flagKey: 'bool_key', + variant: 'enabled', + flagMetadata: { + team: 'ecommerce', + businessPurpose: 'experiment', + }, + }; + expect(result).toEqual(want); + }); + + it('should evaluate a double flag with remote evaluation', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + value: 1.4, + key: 'double_key', + reason: 'STATIC', + variant: 'value1', + metadata: { + team: 'ecommerce', + businessPurpose: 'experiment', + }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + evaluationType: EvaluationType.Remote, + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getNumberDetails('double_key', 1.11, DefaultEvaluationContext); + + const want = { + value: 1.4, + reason: 'STATIC', + flagKey: 'double_key', + variant: 'value1', + flagMetadata: { + team: 'ecommerce', + businessPurpose: 'experiment', + }, + }; + expect(result).toEqual(want); + }); + + it('should evaluate an int flag with remote evaluation', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + value: 1, + key: 'int_key', + reason: 'STATIC', + variant: 'value2', + metadata: { + team: 'ecommerce', + businessPurpose: 'experiment', + }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + evaluationType: EvaluationType.Remote, + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getNumberDetails('int_key', 1, DefaultEvaluationContext); + + const want = { + value: 1, + reason: 'STATIC', + flagKey: 'int_key', + variant: 'value2', + flagMetadata: { + team: 'ecommerce', + businessPurpose: 'experiment', + }, + }; + expect(result).toEqual(want); + }); + + it('should evaluate an object flag with remote evaluation', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + value: { + name: 'gofeatureflag', + size: 150, + }, + key: 'object_key', + reason: 'STATIC', + variant: 'value3', + metadata: { + team: 'ecommerce', + businessPurpose: 'experiment', + }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + evaluationType: EvaluationType.Remote, + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getObjectDetails( + 'object_key', + { + name: 'my_company', + size: 120, + }, + DefaultEvaluationContext, + ); + + const want = { + value: { + name: 'gofeatureflag', + size: 150, + }, + reason: 'STATIC', + flagKey: 'object_key', + variant: 'value3', + flagMetadata: { + team: 'ecommerce', + businessPurpose: 'experiment', + }, + }; + expect(result).toEqual(want); + }); + + it('should error if flag is not found', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + key: 'not_found_key', + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: 'flag not found', + flagMetadata: { + team: 'ecommerce', + businessPurpose: 'experiment', + }, + }), + { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }, + ); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + evaluationType: EvaluationType.Remote, + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getNumberDetails('not_found_key', 1, DefaultEvaluationContext); + + const want = { + value: 1, + reason: 'ERROR', + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: ErrorCode.FLAG_NOT_FOUND, + flagKey: 'not_found_key', + flagMetadata: {}, + }; + expect(result).toEqual(want); + }); + + it('should error if flag type mismatch', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + key: 'type_mismatch_key', + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: 'type mismatch', + flagMetadata: { + team: 'ecommerce', + businessPurpose: 'experiment', + }, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + evaluationType: EvaluationType.Remote, + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getNumberDetails('type_mismatch_key', 1, DefaultEvaluationContext); + + const want = { + value: 1, + reason: 'ERROR', + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: ErrorCode.TYPE_MISMATCH, + flagKey: 'type_mismatch_key', + flagMetadata: {}, + }; + expect(result).toEqual(want); + }); + }); + + describe('InProcess Evaluation', () => { + it('should evaluate a string flag with inprocess evaluation', async () => { + fetchMock.mockResponseOnce(getConfigurationEndpointResult(), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getStringDetails('string_key', 'default', DefaultEvaluationContext); + const want = { + reason: 'TARGETING_MATCH', + flagKey: 'string_key', + value: 'CC0000', + flagMetadata: { + description: 'this is a test', + pr_link: 'https://github.com/thomaspoignant/go-feature-flag/pull/916', + }, + variant: 'True', + }; + expect(result).toEqual(want); + }); + + it('should evaluate a boolean flag with inprocess evaluation', async () => { + fetchMock.mockResponseOnce(getConfigurationEndpointResult(), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getBooleanDetails('bool_targeting_match', false, DefaultEvaluationContext); + const want = { + reason: 'TARGETING_MATCH', + flagKey: 'bool_targeting_match', + value: true, + flagMetadata: { + description: 'this is a test', + pr_link: 'https://github.com/thomaspoignant/go-feature-flag/pull/916', + }, + variant: 'True', + }; + expect(result).toEqual(want); + }); + + it('should evaluate an int flag with inprocess evaluation', async () => { + fetchMock.mockResponseOnce(getConfigurationEndpointResult(), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getNumberDetails('integer_key', 1, DefaultEvaluationContext); + const want = { + reason: 'TARGETING_MATCH', + flagKey: 'integer_key', + value: 100, + flagMetadata: { + description: 'this is a test', + pr_link: 'https://github.com/thomaspoignant/go-feature-flag/pull/916', + }, + variant: 'True', + }; + expect(result).toEqual(want); + }); + + it('should evaluate a double flag with inprocess evaluation', async () => { + fetchMock.mockResponseOnce(getConfigurationEndpointResult(), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getNumberDetails('double_key', 1, DefaultEvaluationContext); + const want = { + reason: 'TARGETING_MATCH', + flagKey: 'double_key', + value: 100.25, + flagMetadata: { + description: 'this is a test', + pr_link: 'https://github.com/thomaspoignant/go-feature-flag/pull/916', + }, + variant: 'True', + }; + expect(result).toEqual(want); + }); + + it('should evaluate an object flag with inprocess evaluation', async () => { + fetchMock.mockResponseOnce(getConfigurationEndpointResult(), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getObjectDetails( + 'object_key', + { + default: true, + }, + DefaultEvaluationContext, + ); + const want = { + reason: 'TARGETING_MATCH', + flagKey: 'object_key', + value: { + test: 'test1', + test2: false, + test3: 123.3, + test4: 1, + }, + flagMetadata: { + description: 'this is a test', + pr_link: 'https://github.com/thomaspoignant/go-feature-flag/pull/916', + }, + variant: 'True', + }; + expect(result).toEqual(want); + }); + + it('should evaluate an array flag with inprocess evaluation', async () => { + fetchMock.mockResponseOnce(getConfigurationEndpointResult(), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getObjectDetails('list_key', ['default', 'true'], DefaultEvaluationContext); + const want = { + reason: 'TARGETING_MATCH', + flagKey: 'list_key', + value: ['true'], + flagMetadata: { + description: 'this is a test', + pr_link: 'https://github.com/thomaspoignant/go-feature-flag/pull/916', + }, + variant: 'True', + }; + expect(result).toEqual(want); + }); + + it('should error FLAG_NOT_FOUND when flag is not found', async () => { + fetchMock.mockResponseOnce(getConfigurationEndpointResult(), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getObjectDetails('flag_not_found', ['default', 'true'], DefaultEvaluationContext); + const want = { + reason: 'ERROR', + flagKey: 'flag_not_found', + value: ['default', 'true'], + errorCode: ErrorCode.FLAG_NOT_FOUND, + // eslint-disable-next-line quotes + errorMessage: "Flag with key 'flag_not_found' not found", + flagMetadata: {}, + }; + expect(result).toEqual(want); + }); + + it('Should error if we expect a boolean and got another type', async () => { + fetchMock.mockResponseOnce(getConfigurationEndpointResult(), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getStringDetails('double_key', 'default', DefaultEvaluationContext); + const want = { + reason: 'ERROR', + flagKey: 'double_key', + value: 'default', + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: 'Flag double_key had unexpected type, expected string.', + flagMetadata: {}, + }; + expect(result).toEqual(want); + }); + + it('Should use boolean default value if the flag is disabled', async () => { + fetchMock.mockResponseOnce(getConfigurationEndpointResult(), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const result = await client.getBooleanDetails('disabled_bool', false, DefaultEvaluationContext); + const want = { + reason: 'DISABLED', + flagKey: 'disabled_bool', + value: false, + flagMetadata: { + description: 'this is a test', + pr_link: 'https://github.com/thomaspoignant/go-feature-flag/pull/916', + }, + variant: 'SdkDefault', + }; + expect(result).toEqual(want); + }); + + it('Should emit configuration change event, if config has changed', async () => { + jest.useRealTimers(); + fetchMock.mockResponses( + [ + getConfigurationEndpointResult(), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ], + [ + getConfigurationEndpointResult('change-config-before'), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ], + ); + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + flagChangePollingIntervalMs: 100, + }); + + let configChangedCount = 0; + provider.events.addHandler(ServerProviderEvents.ConfigurationChanged, () => { + configChangedCount++; + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + await new Promise((resolve) => setTimeout(resolve, 180)); + + expect(configChangedCount).toBeGreaterThan(0); + }); + + it('Should change evaluation details if config has changed', async () => { + jest.useRealTimers(); + let callCount = 0; + fetchMock.mockIf(/^http:\/\/localhost:1031\/v1\/flag\/configuration/, async (request) => { + callCount++; + if (callCount <= 1) { + return { + body: getConfigurationEndpointResult('change-config-before'), + status: 200, + headers: { + 'Content-Type': 'application/json', + ETag: '"1234567890"', + [HTTP_HEADER_LAST_MODIFIED]: '2021-01-01T00:00:00Z', + }, + }; + } else { + return { + body: getConfigurationEndpointResult('change-config-after'), + status: 200, + headers: { + 'Content-Type': 'application/json', + ETag: '"2345678910"', + [HTTP_HEADER_LAST_MODIFIED]: '2021-01-02T00:00:00Z', + }, + }; + } + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + flagChangePollingIntervalMs: 150, + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + const res1 = await client.getBooleanDetails('TEST', false, DefaultEvaluationContext); + expect(res1.value).toBe(false); + await new Promise((resolve) => setTimeout(resolve, 250)); + const res2 = await client.getBooleanDetails('TEST', false, DefaultEvaluationContext); + expect(res2.value).toBe(true); + expect(res1).not.toEqual(res2); + }); + + it('Should error if flag configuration endpoint return a 404', async () => { + fetchMock.mockIf(/^http:\/\/localhost:1031\/v1\/flag\/configuration/, async () => { + return { + body: '{}', + status: 404, + headers: { + 'Content-Type': 'application/json', + }, + }; + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + flagChangePollingIntervalMs: 100, + }); + try { + await OpenFeature.setProviderAndWait(testClientName, provider); + expect(true).toBe(false); // if we reach this line, the test should fail + } catch (error) { + expect(error).toBeInstanceOf(FlagConfigurationEndpointNotFoundException); + } + }); + + it('Should error if flag configuration endpoint return a 403', async () => { + fetchMock.mockIf(/^http:\/\/localhost:1031\/v1\/flag\/configuration/, async () => { + return { + body: '{}', + status: 403, + headers: { + 'Content-Type': 'application/json', + }, + }; + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + flagChangePollingIntervalMs: 100, + }); + try { + await OpenFeature.setProviderAndWait(testClientName, provider); + expect(true).toBe(false); // if we reach this line, the test should fail + } catch (error) { + expect(error).toBeInstanceOf(UnauthorizedException); + } + }); + + it('Should error if flag configuration endpoint return a 401', async () => { + fetchMock.mockIf(/^http:\/\/localhost:1031\/v1\/flag\/configuration/, async () => { + return { + body: '{}', + status: 401, + headers: { + 'Content-Type': 'application/json', + }, + }; + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + flagChangePollingIntervalMs: 100, + }); + try { + await OpenFeature.setProviderAndWait(testClientName, provider); + expect(true).toBe(false); // if we reach this line, the test should fail + } catch (error) { + expect(error).toBeInstanceOf(UnauthorizedException); + } + }); + + it('Should apply a scheduled rollout step', async () => { + jest.useRealTimers(); + fetchMock.mockIf(/^http:\/\/localhost:1031\/v1\/flag\/configuration/, async () => { + const res = getConfigurationEndpointResult('scheduled-rollout'); + return { + body: res, + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }; + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + flagChangePollingIntervalMs: 150, + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + + const got = await client.getBooleanDetails('my-flag', false, DefaultEvaluationContext); + const want = { + reason: 'TARGETING_MATCH', + flagKey: 'my-flag', + flagMetadata: { + defaultValue: false, + description: 'this is a test flag', + }, + value: true, + variant: 'enabled', + }; + expect(got).toEqual(want); + }); + + it('Should not apply a scheduled rollout in the future', async () => { + jest.setSystemTime(new Date('2021-01-01T01:00:00Z')); + fetchMock.mockIf(/^http:\/\/localhost:1031\/v1\/flag\/configuration/, async () => { + return { + body: getConfigurationEndpointResult('scheduled-rollout'), + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }; + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + flagChangePollingIntervalMs: 100, + }); + + await OpenFeature.setProviderAndWait(testClientName, provider); + const client = OpenFeature.getClient(testClientName); + + const got = await client.getBooleanDetails('my-flag-scheduled-in-future', false, DefaultEvaluationContext); + const want = { + reason: 'STATIC', + flagKey: 'my-flag-scheduled-in-future', + flagMetadata: { + defaultValue: false, + description: 'this is a test flag', + }, + value: false, + variant: 'disabled', + }; + expect(got).toEqual(want); + }); + }); + + describe('Track method', () => { + it('should track events with context and details', async () => { + jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); + fetchMock.mockIf(/^http:\/\/localhost:1031\/v1\/data\/collector/, async () => { + return { + body: JSON.stringify({}), + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }; + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + evaluationType: EvaluationType.Remote, + dataFlushInterval: 100, + maxPendingEvents: 1, + }); + + await OpenFeature.setProviderAndWait('track-events-with-context-and-details', provider); + await provider.initialize(); + const client = OpenFeature.getClient('track-events-with-context-and-details'); + + client.track( + 'testEvent', + { + targetingKey: 'testTargetingKey', + email: 'test@example.com', + }, + { + test: 'testValue', + metric: 42, + }, + ); + + jest.advanceTimersByTime(100); + + const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; + expect(lastCall).toBeDefined(); + expect(lastCall[0]).toBe('http://localhost:1031/v1/data/collector'); + + const want = { + meta: {}, + events: [ + { + kind: 'tracking', + userKey: 'testTargetingKey', + contextKind: 'user', + key: 'testEvent', + trackingEventDetails: { + test: 'testValue', + metric: 42, + }, + creationDate: 1609459200, + evaluationContext: { + targetingKey: 'testTargetingKey', + email: 'test@example.com', + }, + }, + ], + }; + expect(lastCall[1]?.body).toBeDefined(); + expect(JSON.parse(lastCall[1]?.body as string)).toEqual(want); + }); + + it('should track events without context', async () => { + jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); + fetchMock.mockIf(/^http:\/\/localhost:1031\/v1\/data\/collector/, async () => { + return { + body: getConfigurationEndpointResult(), + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }; + }); + + fetchMock.mockIf(/^http:\/\/localhost:1031\/v1\/flag\/configuration/, async () => { + return { + body: JSON.stringify({}), + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }; + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + dataFlushInterval: 100, + maxPendingEvents: 1, + }); + + await OpenFeature.setProviderAndWait('track-events-without-context', provider); + const client = OpenFeature.getClient('track-events-without-context'); + + client.track('testEventWithoutContext'); + + jest.advanceTimersByTime(110); + + const want = { + meta: {}, + events: [ + { + kind: 'tracking', + userKey: 'undefined-targetingKey', + contextKind: 'user', + key: 'testEventWithoutContext', + trackingEventDetails: {}, + creationDate: 1609459200, + evaluationContext: {}, + }, + ], + }; + + // Find the last call to /v1/data/collector + const dataCollectorCalls = fetchMock.mock.calls.filter( + (call) => call[0] === 'http://localhost:1031/v1/data/collector', + ); + const lastDataCollectorCall = dataCollectorCalls[dataCollectorCalls.length - 1]; + expect(lastDataCollectorCall).toBeDefined(); + expect(lastDataCollectorCall[0]).toBe('http://localhost:1031/v1/data/collector'); + expect(lastDataCollectorCall[1]?.body).toBeDefined(); + expect(JSON.parse(lastDataCollectorCall[1]?.body as string)).toEqual(want); + }); + + it('should track events without tracking details', async () => { + jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); + fetchMock.mockIf(/^http:\/\/localhost:1031\/v1\/data\/collector/, async () => { + return { + body: JSON.stringify({}), + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }; + }); + + fetchMock.mockIf(/^http:\/\/localhost:1031\/v1\/flag\/configuration/, async () => { + return { + body: JSON.stringify({}), + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }; + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + dataFlushInterval: 100, + maxPendingEvents: 1, + }); + + await OpenFeature.setProviderAndWait('track-events-without-tracking-details', provider); + const client = OpenFeature.getClient('track-events-without-tracking-details'); + + client.track('testEventNoDetails', { + targetingKey: 'testTargetingKey', + userId: '123', + }); + + jest.advanceTimersByTime(150); + + const want = { + meta: {}, + events: [ + { + kind: 'tracking', + userKey: 'testTargetingKey', + key: 'testEventNoDetails', + trackingEventDetails: {}, + evaluationContext: { + targetingKey: 'testTargetingKey', + userId: '123', + }, + creationDate: 1609459200, + contextKind: 'user', + }, + ], + }; + + // Find the last call to /v1/data/collector + const dataCollectorCalls = fetchMock.mock.calls.filter( + (call) => call[0] === 'http://localhost:1031/v1/data/collector', + ); + const lastDataCollectorCall = dataCollectorCalls[dataCollectorCalls.length - 1]; + expect(lastDataCollectorCall).toBeDefined(); + expect(lastDataCollectorCall[0]).toBe('http://localhost:1031/v1/data/collector'); + expect(lastDataCollectorCall[1]?.body).toBeDefined(); + expect(JSON.parse(lastDataCollectorCall[1]?.body as string)).toEqual(want); + }); + + it('should track multiple events', async () => { + jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); + fetchMock.mockIf(/^http:\/\/localhost:1031\/v1\/data\/collector/, async () => { + return { + body: JSON.stringify({}), + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }; + }); + + const provider = new GoFeatureFlagProvider({ + endpoint: 'http://localhost:1031', + evaluationType: EvaluationType.Remote, + dataFlushInterval: 100, + maxPendingEvents: 3, + }); + + await OpenFeature.setProviderAndWait('track-events-with-context-and-details', provider); + await provider.initialize(); + const client = OpenFeature.getClient('track-events-with-context-and-details'); + + client.track( + 'testEvent', + { + targetingKey: 'testTargetingKey', + email: 'test@example.com', + }, + { + test: 'testValue', + metric: 42, + }, + ); + client.track( + 'testEvent', + { + targetingKey: 'testTargetingKey', + email: 'test2@example.com', + }, + { + test: 'testValue', + metric: 43, + }, + ); + client.track( + 'testEvent', + { + targetingKey: 'testTargetingKey3', + email: 'test3@example.com', + }, + { + test: 'testValue', + metric: 44, + }, + ); + + jest.advanceTimersByTime(100); + + const want = { + meta: {}, + events: [ + { + kind: 'tracking', + userKey: 'testTargetingKey', + contextKind: 'user', + key: 'testEvent', + trackingEventDetails: { + test: 'testValue', + metric: 42, + }, + creationDate: 1609459200, + evaluationContext: { + targetingKey: 'testTargetingKey', + email: 'test@example.com', + }, + }, + { + kind: 'tracking', + userKey: 'testTargetingKey', + contextKind: 'user', + key: 'testEvent', + trackingEventDetails: { + test: 'testValue', + metric: 43, + }, + creationDate: 1609459200, + evaluationContext: { + targetingKey: 'testTargetingKey', + email: 'test2@example.com', + }, + }, + { + kind: 'tracking', + userKey: 'testTargetingKey3', + contextKind: 'user', + key: 'testEvent', + trackingEventDetails: { + test: 'testValue', + metric: 44, + }, + creationDate: 1609459200, + evaluationContext: { + targetingKey: 'testTargetingKey3', + email: 'test3@example.com', + }, + }, + ], + }; + + const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; + expect(lastCall).toBeDefined(); + expect(lastCall[0]).toBe('http://localhost:1031/v1/data/collector'); + expect(lastCall[1]?.body).toBeDefined(); + expect(JSON.parse(lastCall[1]?.body as string)).toEqual(want); + }); + }); +}); + +function getConfigurationEndpointResult(mode = 'default'): string { + const filePath = path.resolve(__dirname, 'testdata', 'flag-configuration', mode + '.json'); + const fileContent = fs.readFileSync(filePath, 'utf-8'); + return JSON.stringify(JSON.parse(fileContent)); +} diff --git a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts index e423171f6..06350d077 100644 --- a/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts +++ b/libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts @@ -1,217 +1,188 @@ -import type { EvaluationContext, Hook, JsonValue, Logger, Provider, ResolutionDetails } from '@openfeature/server-sdk'; -import { OpenFeatureEventEmitter, ServerProviderEvents, StandardResolutionReasons } from '@openfeature/server-sdk'; -import type { ExporterMetadataValue, GoFeatureFlagProviderOptions } from './model'; -import { ConfigurationChange } from './model'; -import { GoFeatureFlagDataCollectorHook } from './data-collector-hook'; -import { GoffApiController } from './controller/goff-api'; -import { CacheController } from './controller/cache'; -import { ConfigurationChangeEndpointNotFound } from './errors/configuration-change-endpoint-not-found'; - -// GoFeatureFlagProvider is the official Open-feature provider for GO Feature Flag. -export class GoFeatureFlagProvider implements Provider { +import type { + EvaluationContext, + Hook, + JsonValue, + Logger, + Provider, + ResolutionDetails, + Tracking, + TrackingEventDetails, +} from '@openfeature/server-sdk'; +import { OpenFeatureEventEmitter } from '@openfeature/server-sdk'; +import type { GoFeatureFlagProviderOptions } from './go-feature-flag-provider-options'; +import type { IEvaluator } from './evaluator/evaluator'; +import { InProcessEvaluator } from './evaluator/inprocess-evaluator'; +import { GoFeatureFlagApi } from './service/api'; +import { DataCollectorHook, EnrichEvaluationContextHook } from './hook'; +import { EventPublisher } from './service/event-publisher'; +import { getContextKind } from './helper/event-util'; +import { DEFAULT_TARGETING_KEY } from './helper/constants'; +import { EvaluationType, type TrackingEvent } from './model'; +import { InvalidOptionsException } from './exception'; +import { RemoteEvaluator } from './evaluator/remote-evaluator'; + +export class GoFeatureFlagProvider implements Provider, Tracking { metadata = { name: GoFeatureFlagProvider.name, }; + readonly runsOn = 'server'; events = new OpenFeatureEventEmitter(); - hooks?: Hook[]; - private DEFAULT_POLL_INTERVAL = 30000; - // disableDataCollection set to true if you don't want to collect the usage of flags retrieved in the cache. - private readonly _disableDataCollection: boolean; - // dataCollectorHook is the hook used to send the data to the GO Feature Flag data collector API. - private readonly _dataCollectorHook?: GoFeatureFlagDataCollectorHook; - // goffApiController is the controller used to communicate with the GO Feature Flag relay-proxy API. - private readonly _goffApiController: GoffApiController; - // cacheController is the controller used to cache the evaluation of the flags. - private readonly _cacheController?: CacheController; - private _pollingIntervalId?: number; - private _pollingInterval: number; - private _exporterMetadata?: Record; + hooks: Hook[] = []; - constructor(options: GoFeatureFlagProviderOptions, logger?: Logger) { - this._goffApiController = new GoffApiController(options); - this._dataCollectorHook = new GoFeatureFlagDataCollectorHook( - { - dataFlushInterval: options.dataFlushInterval, - collectUnCachedEvaluation: false, - exporterMetadata: options.exporterMetadata, - }, - this._goffApiController, - logger, - ); - this._exporterMetadata = options.exporterMetadata; - this._disableDataCollection = options.disableDataCollection || false; - this._cacheController = new CacheController(options, logger); - this._pollingInterval = options.pollInterval ?? this.DEFAULT_POLL_INTERVAL; - } + /** The options for the provider. */ + private readonly options: GoFeatureFlagProviderOptions; + /** The logger for the provider. */ + private readonly logger?: Logger; + /** The evaluation service for the provider. */ + private readonly evaluator: IEvaluator; + /** The event publisher for the provider. */ + private readonly eventPublisher: EventPublisher; - /** - * initialize is called everytime the provider is instanced inside GO Feature Flag. - * It will start the background process for data collection to be able to run every X ms. - */ - async initialize() { - if (!this._disableDataCollection && this._dataCollectorHook) { - this.hooks = [this._dataCollectorHook]; - this._dataCollectorHook.init(); - } + constructor(options: GoFeatureFlagProviderOptions, logger?: Logger) { + this.validateInputOptions(options); + this.options = options; + this.logger = logger; + const api = new GoFeatureFlagApi(options); + this.evaluator = this.getEvaluator(options, api, logger); + this.eventPublisher = new EventPublisher(api, options, logger); - if (this._pollingInterval > 0) { - this.startPolling(); - } + // Initialize hooks + this.initializeHooks(); } - /** - * onClose is called everytime OpenFeature.Close() function is called. - * It will gracefully terminate the provider and ensure that all the data are sent to the relay-proxy. - */ - async onClose() { - this.stopPolling(); - this._cacheController?.clear(); - await this._dataCollectorHook?.close(); + /** @inheritdoc */ + track(trackingEventName: string, context?: EvaluationContext, trackingEventDetails?: TrackingEventDetails): void { + // Create a tracking event object + const event: TrackingEvent = { + kind: 'tracking', + userKey: context?.targetingKey ?? DEFAULT_TARGETING_KEY, + contextKind: getContextKind(context), + key: trackingEventName, + trackingEventDetails: trackingEventDetails ?? {}, + creationDate: Math.floor(Date.now() / 1000), + evaluationContext: context ?? {}, + }; + this.eventPublisher.addEvent(event); } - /** - * resolveBooleanEvaluation is calling the GO Feature Flag relay-proxy API and return a boolean value. - * @param flagKey - name of your feature flag key. - * @param defaultValue - default value is used if we are not able to evaluate the flag for this user. - * @param context - the context used for flag evaluation. - * @return {Promise>} An object containing the result of the flag evaluation by GO Feature Flag. - * @throws {ProxyNotReady} When we are not able to communicate with the relay-proxy - * @throws {ProxyTimeout} When the HTTP call is timing out - * @throws {UnknownError} When an unknown error occurs - * @throws {TypeMismatchError} When the type of the variation is not the one expected - * @throws {FlagNotFoundError} When the flag does not exist - */ + /** @inheritdoc */ async resolveBooleanEvaluation( flagKey: string, defaultValue: boolean, context: EvaluationContext, ): Promise> { - return this.resolveEvaluationGoFeatureFlagProxy(flagKey, defaultValue, context, 'boolean'); + return this.evaluator.evaluateBoolean(flagKey, defaultValue, context); } - /** - * resolveStringEvaluation is calling the GO Feature Flag relay-proxy API and return a string value. - * @param flagKey - name of your feature flag key. - * @param defaultValue - default value is used if we are not able to evaluate the flag for this user. - * @param context - the context used for flag evaluation. - * @return {Promise>} An object containing the result of the flag evaluation by GO Feature Flag. - * @throws {ProxyNotReady} When we are not able to communicate with the relay-proxy - * @throws {ProxyTimeout} When the HTTP call is timing out - * @throws {UnknownError} When an unknown error occurs - * @throws {TypeMismatchError} When the type of the variation is not the one expected - * @throws {FlagNotFoundError} When the flag does not exist - */ + /** @inheritdoc */ async resolveStringEvaluation( flagKey: string, defaultValue: string, context: EvaluationContext, ): Promise> { - return this.resolveEvaluationGoFeatureFlagProxy(flagKey, defaultValue, context, 'string'); + return this.evaluator.evaluateString(flagKey, defaultValue, context); } - /** - * resolveNumberEvaluation is calling the GO Feature Flag relay-proxy API and return a number value. - * @param flagKey - name of your feature flag key. - * @param defaultValue - default value is used if we are not able to evaluate the flag for this user. - * @param context - the context used for flag evaluation. - * @return {Promise>} An object containing the result of the flag evaluation by GO Feature Flag. - * @throws {ProxyNotReady} When we are not able to communicate with the relay-proxy - * @throws {ProxyTimeout} When the HTTP call is timing out - * @throws {UnknownError} When an unknown error occurs - * @throws {TypeMismatchError} When the type of the variation is not the one expected - * @throws {FlagNotFoundError} When the flag does not exist - */ + /** @inheritdoc */ async resolveNumberEvaluation( flagKey: string, defaultValue: number, context: EvaluationContext, ): Promise> { - return this.resolveEvaluationGoFeatureFlagProxy(flagKey, defaultValue, context, 'number'); + return this.evaluator.evaluateNumber(flagKey, defaultValue, context); } - /** - * resolveObjectEvaluation is calling the GO Feature Flag relay-proxy API and return an object. - * @param flagKey - name of your feature flag key. - * @param defaultValue - default value is used if we are not able to evaluate the flag for this user. - * @param context - the context used for flag evaluation. - * @return {Promise>} An object containing the result of the flag evaluation by GO Feature Flag. - * @throws {ProxyNotReady} When we are not able to communicate with the relay-proxy - * @throws {ProxyTimeout} When the HTTP call is timing out - * @throws {UnknownError} When an unknown error occurs - * @throws {TypeMismatchError} When the type of the variation is not the one expected - * @throws {FlagNotFoundError} When the flag does not exist - */ - async resolveObjectEvaluation( + async resolveObjectEvaluation( flagKey: string, - defaultValue: U, + defaultValue: T, context: EvaluationContext, - ): Promise> { - return this.resolveEvaluationGoFeatureFlagProxy(flagKey, defaultValue, context, 'object'); + ): Promise> { + return this.evaluator.evaluateObject(flagKey, defaultValue, context); } /** - * resolveEvaluationGoFeatureFlagProxy is a generic function the call the GO Feature Flag relay-proxy API - * to evaluate the flag. - * This is the same call for all types of flags so this function also checks if the return call is the one expected. - * @param flagKey - name of your feature flag key. - * @param defaultValue - default value is used if we are not able to evaluate the flag for this user. - * @param evaluationContext - the evaluationContext against who we will evaluate the flag. - * @param expectedType - the type we expect the result to be - * @return {Promise>} An object containing the result of the flag evaluation by GO Feature Flag. - * @throws {ProxyNotReady} When we are not able to communicate with the relay-proxy - * @throws {ProxyTimeout} When the HTTP call is timing out - * @throws {UnknownError} When an unknown error occurs - * @throws {TypeMismatchError} When the type of the variation is not the one expected - * @throws {FlagNotFoundError} When the flag does not exist + * Start the provider and initialize the event publisher. */ - async resolveEvaluationGoFeatureFlagProxy( - flagKey: string, - defaultValue: T, - evaluationContext: EvaluationContext, - expectedType: string, - ): Promise> { - const cacheValue = this._cacheController?.get(flagKey, evaluationContext); - if (cacheValue) { - cacheValue.reason = StandardResolutionReasons.CACHED; - return cacheValue; + async initialize(): Promise { + try { + this.evaluator && (await this.evaluator.initialize()); + this.eventPublisher && (await this.eventPublisher.start()); + } catch (error) { + this.logger?.error('Failed to initialize the provider', error); + throw error; } + } - const evaluationResponse = await this._goffApiController.evaluate( - flagKey, - defaultValue, - evaluationContext, - expectedType, - this._exporterMetadata ?? {}, - ); + /** + * Dispose the provider and stop the event publisher. + */ + async dispose(): Promise { + this.evaluator && (await this.evaluator.dispose()); + this.eventPublisher && (await this.eventPublisher.stop()); + } - this._cacheController?.set(flagKey, evaluationContext, evaluationResponse); - return evaluationResponse.resolutionDetails; + /** + * Get the evaluator based on the evaluation type specified in the options. + */ + private getEvaluator(options: GoFeatureFlagProviderOptions, api: GoFeatureFlagApi, logger?: Logger): IEvaluator { + switch (options.evaluationType) { + case EvaluationType.Remote: + return new RemoteEvaluator(options, logger); + default: + return new InProcessEvaluator(options, api, this.events, logger); + } } - private startPolling() { - this._pollingIntervalId = setInterval(async () => { - try { - const res = await this._goffApiController.configurationHasChanged(); - if (res === ConfigurationChange.FLAG_CONFIGURATION_UPDATED) { - this.events?.emit(ServerProviderEvents.ConfigurationChanged, { message: 'Flags updated' }); - this._cacheController?.clear(); - } - } catch (error) { - if (error instanceof ConfigurationChangeEndpointNotFound && this._pollingIntervalId) { - this.stopPolling(); - } - } - }, this._pollingInterval) as unknown as number; + /** + * Initialize the hooks for the provider. + */ + private initializeHooks(): void { + this.hooks.push(new DataCollectorHook(this.evaluator, this.eventPublisher)); + this.logger?.debug('Data collector hook initialized'); + if (this.options.exporterMetadata) { + this.hooks.push(new EnrichEvaluationContextHook(this.options.exporterMetadata)); + this.logger?.debug('Enrich evaluation context hook initialized'); + } } /** - * Stop polling for flag updates - * @private + * Validates the input options provided when creating the provider. + * @param options Options used while creating the provider + * @throws {InvalidOptionsException} if no options are provided, or we have a wrong configuration. */ - private stopPolling() { - if (this._pollingIntervalId) { - clearInterval(this._pollingIntervalId); + private validateInputOptions(options: GoFeatureFlagProviderOptions): void { + if (!options) { + throw new InvalidOptionsException('No options provided'); + } + + if (!options.endpoint || options.endpoint.trim() === '') { + throw new InvalidOptionsException('endpoint is a mandatory field when initializing the provider'); + } + + try { + const url = new URL(options.endpoint); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new InvalidOptionsException('endpoint must be a valid URL (http or https)'); + } + } catch { + throw new InvalidOptionsException('endpoint must be a valid URL (http or https)'); + } + + if (options.flagChangePollingIntervalMs !== undefined && options.flagChangePollingIntervalMs <= 0) { + throw new InvalidOptionsException('flagChangePollingIntervalMs must be greater than zero'); + } + + if (options.timeout !== undefined && options.timeout <= 0) { + throw new InvalidOptionsException('timeout must be greater than zero'); + } + + if (options.dataFlushInterval !== undefined && options.dataFlushInterval <= 0) { + throw new InvalidOptionsException('dataFlushInterval must be greater than zero'); + } + + if (options.maxPendingEvents !== undefined && options.maxPendingEvents <= 0) { + throw new InvalidOptionsException('maxPendingEvents must be greater than zero'); } } } diff --git a/libs/providers/go-feature-flag/src/lib/helper/constants.ts b/libs/providers/go-feature-flag/src/lib/helper/constants.ts new file mode 100644 index 000000000..c0e9ecac3 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/helper/constants.ts @@ -0,0 +1,25 @@ +/** + * Constants used throughout the GO Feature Flag API. + */ +export const HTTP_HEADER_CONTENT_TYPE = 'Content-Type'; +export const HTTP_HEADER_AUTHORIZATION = 'Authorization'; +export const HTTP_HEADER_IF_NONE_MATCH = 'If-None-Match'; +export const HTTP_HEADER_ETAG = 'etag'; +export const HTTP_HEADER_LAST_MODIFIED = 'last-modified'; +export const APPLICATION_JSON = 'application/json'; +export const BEARER_TOKEN = 'Bearer '; + +export const HTTP_STATUS = { + OK: 200, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + UNAVAILABLE: 503, + NOT_MODIFIED: 304, +} as const; + +export const DEFAULT_FLUSH_INTERVAL_MS = 120000; +export const DEFAULT_MAX_PENDING_EVENTS = 10000; + +export const DEFAULT_TARGETING_KEY = 'undefined-targetingKey'; diff --git a/libs/providers/go-feature-flag/src/lib/helper/event-util.ts b/libs/providers/go-feature-flag/src/lib/helper/event-util.ts new file mode 100644 index 000000000..205c8a2b0 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/helper/event-util.ts @@ -0,0 +1,10 @@ +import type { EvaluationContext } from '@openfeature/core'; + +/** + * Get the context kind based on the evaluation context. + * @param context - The evaluation context to check + * @returns 'anonymous' if the context is anonymous, 'user' otherwise + */ +export const getContextKind = (context?: EvaluationContext): string => { + return !context || context['anonymous'] === true ? 'anonymousUser' : 'user'; +}; diff --git a/libs/providers/go-feature-flag/src/lib/helper/fetch-api.ts b/libs/providers/go-feature-flag/src/lib/helper/fetch-api.ts new file mode 100644 index 000000000..3bd8e83e2 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/helper/fetch-api.ts @@ -0,0 +1,17 @@ +/** + * FetchAPI is the type of the fetch function. + */ +export type FetchAPI = WindowOrWorkerGlobalScope['fetch']; + +export const isomorphicFetch = (): FetchAPI => { + // We need to do this, as fetch needs the window as scope in the browser: https://fetch.spec.whatwg.org/#concept-request-window + // Without this any request will fail in the browser https://stackoverflow.com/questions/69876859/why-does-bind-fix-failed-to-execute-fetch-on-window-illegal-invocation-err + if (globalThis) { + return globalThis.fetch.bind(globalThis); + } else if (window) { + return window.fetch.bind(window); + } else if (self) { + return self.fetch.bind(self); + } + return fetch; +}; diff --git a/libs/providers/go-feature-flag/src/lib/hook/data-collector-hook.test.ts b/libs/providers/go-feature-flag/src/lib/hook/data-collector-hook.test.ts new file mode 100644 index 000000000..b777cb544 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/hook/data-collector-hook.test.ts @@ -0,0 +1,195 @@ +import { DataCollectorHook } from './data-collector-hook'; +import type { IEvaluator } from '../evaluator/evaluator'; +import type { EventPublisher } from '../service/event-publisher'; +import type { HookContext, EvaluationDetails } from '@openfeature/server-sdk'; +import { EvaluatorNotFoundException, EventPublisherNotFoundException } from '../exception'; +import { mockLogger } from '../testutil/mock-logger'; + +describe('DataCollectorHook', () => { + let mockEvaluator: jest.Mocked; + let mockEventPublisher: jest.Mocked; + let hook: DataCollectorHook; + + beforeEach(() => { + mockEvaluator = { + isFlagTrackable: jest.fn(), + initialize: jest.fn(), + dispose: jest.fn(), + evaluateBoolean: jest.fn(), + evaluateString: jest.fn(), + evaluateNumber: jest.fn(), + evaluateObject: jest.fn(), + } as jest.Mocked; + + mockEventPublisher = { + addEvent: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + } as unknown as jest.Mocked; + + hook = new DataCollectorHook(mockEvaluator, mockEventPublisher); + }); + + describe('constructor', () => { + it('should throw error if evaluator is null', () => { + expect(() => new DataCollectorHook(null as any, mockEventPublisher)).toThrow(EvaluatorNotFoundException); + }); + + it('should throw error if eventPublisher is null', () => { + expect(() => new DataCollectorHook(mockEvaluator, null as any)).toThrow(EventPublisherNotFoundException); + }); + }); + + describe('after', () => { + it('should not collect data if flag is not trackable', async () => { + mockEvaluator.isFlagTrackable.mockReturnValue(false); + + const context: HookContext = { + flagKey: 'test-flag', + defaultValue: false, + context: { targetingKey: 'user-1' }, + flagValueType: 'boolean', + clientMetadata: { providerMetadata: { name: 'test' } }, + providerMetadata: { name: 'test' }, + logger: mockLogger, + }; + + const details: EvaluationDetails = { + flagKey: 'test-flag', + value: true, + variant: 'on', + reason: 'TARGETING_MATCH', + flagMetadata: {}, + }; + + await hook.after(context, details); + + expect(mockEvaluator.isFlagTrackable).toHaveBeenCalledWith('test-flag'); + expect(mockEventPublisher.addEvent).not.toHaveBeenCalled(); + }); + + it('should collect data if flag is trackable', async () => { + mockEvaluator.isFlagTrackable.mockReturnValue(true); + + const context: HookContext = { + flagKey: 'test-flag', + defaultValue: false, + context: { targetingKey: 'user-1' }, + flagValueType: 'boolean', + clientMetadata: { providerMetadata: { name: 'test' } }, + providerMetadata: { name: 'test' }, + logger: mockLogger, + }; + + const details: EvaluationDetails = { + flagKey: 'test-flag', + value: true, + variant: 'on', + reason: 'TARGETING_MATCH', + flagMetadata: {}, + }; + + await hook.after(context, details); + + expect(mockEvaluator.isFlagTrackable).toHaveBeenCalledWith('test-flag'); + expect(mockEventPublisher.addEvent).toHaveBeenCalledWith({ + kind: 'feature', + key: 'test-flag', + contextKind: 'user', + default: false, + variation: 'on', + value: true, + userKey: 'user-1', + creationDate: expect.any(Number), + }); + }); + + it('should handle anonymous user correctly', async () => { + mockEvaluator.isFlagTrackable.mockReturnValue(true); + + const context: HookContext = { + flagKey: 'test-flag', + defaultValue: false, + context: { targetingKey: '1234', anonymous: true }, + flagValueType: 'boolean', + clientMetadata: { providerMetadata: { name: 'test' } }, + providerMetadata: { name: 'test' }, + logger: mockLogger, + }; + + const details: EvaluationDetails = { + flagKey: 'test-flag', + value: true, + variant: 'on', + reason: 'TARGETING_MATCH', + flagMetadata: {}, + }; + + await hook.after(context, details); + + expect(mockEventPublisher.addEvent).toHaveBeenCalledWith({ + kind: 'feature', + key: 'test-flag', + contextKind: 'anonymousUser', + default: false, + variation: 'on', + value: true, + userKey: '1234', + creationDate: expect.any(Number), + }); + }); + }); + + describe('error', () => { + it('should not collect data if flag is not trackable', async () => { + mockEvaluator.isFlagTrackable.mockReturnValue(false); + + const context: HookContext = { + flagKey: 'test-flag', + defaultValue: false, + context: { targetingKey: 'user-1' }, + flagValueType: 'boolean', + clientMetadata: { providerMetadata: { name: 'test' } }, + providerMetadata: { name: 'test' }, + logger: mockLogger, + }; + + const error = new Error('Test error'); + + await hook.error(context, error); + + expect(mockEvaluator.isFlagTrackable).toHaveBeenCalledWith('test-flag'); + expect(mockEventPublisher.addEvent).not.toHaveBeenCalled(); + }); + + it('should collect error data if flag is trackable', async () => { + mockEvaluator.isFlagTrackable.mockReturnValue(true); + + const context: HookContext = { + flagKey: 'test-flag', + defaultValue: false, + context: { targetingKey: 'user-1' }, + flagValueType: 'boolean', + clientMetadata: { providerMetadata: { name: 'test' } }, + providerMetadata: { name: 'test' }, + logger: mockLogger, + }; + + const error = new Error('Test error'); + + await hook.error(context, error); + + expect(mockEvaluator.isFlagTrackable).toHaveBeenCalledWith('test-flag'); + expect(mockEventPublisher.addEvent).toHaveBeenCalledWith({ + kind: 'feature', + key: 'test-flag', + contextKind: 'user', + default: true, + variation: 'SdkDefault', + value: false, + userKey: 'user-1', + creationDate: expect.any(Number), + }); + }); + }); +}); diff --git a/libs/providers/go-feature-flag/src/lib/hook/data-collector-hook.ts b/libs/providers/go-feature-flag/src/lib/hook/data-collector-hook.ts new file mode 100644 index 000000000..01807126f --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/hook/data-collector-hook.ts @@ -0,0 +1,96 @@ +import type { Hook, HookContext, EvaluationDetails, JsonValue } from '@openfeature/server-sdk'; +import type { IEvaluator } from '../evaluator/evaluator'; +import type { EventPublisher } from '../service/event-publisher'; +import type { FeatureEvent } from '../model'; +import { EvaluatorNotFoundException, EventPublisherNotFoundException } from '../exception'; +import { getContextKind } from '../helper/event-util'; +import { DEFAULT_TARGETING_KEY } from '../helper/constants'; + +/** + * DataCollectorHook is a hook that collects data during the evaluation of feature flags. + */ +export class DataCollectorHook implements Hook { + private readonly evaluator: IEvaluator; + private readonly eventPublisher: EventPublisher; + + /** + * DataCollectorHook is a hook that collects data during the evaluation of feature flags. + * @param evaluator - service to evaluate the flag + * @param eventPublisher - service to publish events + * @throws Error if evaluator or eventPublisher is null + */ + constructor(evaluator: IEvaluator, eventPublisher: EventPublisher) { + if (!evaluator) { + throw new EvaluatorNotFoundException('Evaluator cannot be null'); + } + if (!eventPublisher) { + throw new EventPublisherNotFoundException('EventPublisher cannot be null'); + } + this.evaluator = evaluator; + this.eventPublisher = eventPublisher; + } + + /** + + * Called immediately after successful flag evaluation. + * @param context - Provides context of innovation + * @param details - Flag evaluation information + * @param _hints - Caller provided data + */ + async after( + context: HookContext, + details: EvaluationDetails, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _hints?: Record, + ): Promise { + if (!this.evaluator.isFlagTrackable(context.flagKey)) { + // If the flag is not trackable, we do not need to collect data. + return; + } + + const eventToPublish: FeatureEvent = { + contextKind: getContextKind(context.context), + kind: 'feature', + creationDate: Math.floor(Date.now() / 1000), + default: false, + key: context.flagKey, + value: details.value, + variation: details.variant ?? 'SdkDefault', + userKey: context.context?.targetingKey ?? DEFAULT_TARGETING_KEY, + }; + + this.eventPublisher.addEvent(eventToPublish); + } + + /** + * Called immediately after an unsuccessful flag evaluation. + * @param context - Provides context of innovation + * @param error - Exception representing what went wrong + * @param hints - Caller provided data + */ + async error( + context: HookContext, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _error: Error, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _hints?: Record, + ): Promise { + if (!this.evaluator.isFlagTrackable(context.flagKey)) { + // If the flag is not trackable, we do not need to collect data. + return; + } + + const eventToPublish: FeatureEvent = { + contextKind: getContextKind(context.context), + kind: 'feature', + key: context.flagKey, + default: true, + variation: 'SdkDefault', + value: context.defaultValue, + userKey: context.context?.targetingKey ?? DEFAULT_TARGETING_KEY, + creationDate: Math.floor(Date.now() / 1000), + }; + + this.eventPublisher.addEvent(eventToPublish); + } +} diff --git a/libs/providers/go-feature-flag/src/lib/hook/enrich-evaluation-context-hook.test.ts b/libs/providers/go-feature-flag/src/lib/hook/enrich-evaluation-context-hook.test.ts new file mode 100644 index 000000000..9fc3d77b0 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/hook/enrich-evaluation-context-hook.test.ts @@ -0,0 +1,122 @@ +import { ExporterMetadata } from '../model'; +import { mockLogger } from '../testutil/mock-logger'; +import { EnrichEvaluationContextHook } from './enrich-evaluation-context-hook'; +import type { HookContext, EvaluationContext } from '@openfeature/server-sdk'; + +describe('EnrichEvaluationContextHook', () => { + let hook: EnrichEvaluationContextHook; + + describe('constructor', () => { + it('should handle null metadata', () => { + hook = new EnrichEvaluationContextHook(undefined); + expect(hook).toBeDefined(); + }); + + it('should handle empty metadata', () => { + hook = new EnrichEvaluationContextHook(new ExporterMetadata()); + expect(hook).toBeDefined(); + }); + + it('should handle metadata with values', () => { + const metadata = new ExporterMetadata().add('version', '1.0.0').add('environment', 'test'); + hook = new EnrichEvaluationContextHook(metadata); + expect(hook).toBeDefined(); + }); + }); + + describe('before', () => { + it('should return original context when no metadata is provided', async () => { + hook = new EnrichEvaluationContextHook(undefined); + + const originalContext: EvaluationContext = { user: 'test-user' }; + + const context: HookContext = { + flagKey: 'test-flag', + defaultValue: false, + context: originalContext, + flagValueType: 'boolean', + clientMetadata: { providerMetadata: { name: 'test' } }, + providerMetadata: { name: 'test' }, + logger: mockLogger, + }; + + const result = await hook.before(context); + + expect(result).toEqual(originalContext); + }); + + it('should enrich context with metadata when provided', async () => { + const metadata = new ExporterMetadata().add('version', '1.0.0').add('environment', 'test'); + hook = new EnrichEvaluationContextHook(metadata); + + const originalContext: EvaluationContext = { user: 'test-user' }; + + const context: HookContext = { + flagKey: 'test-flag', + defaultValue: false, + context: originalContext, + flagValueType: 'boolean', + clientMetadata: { providerMetadata: { name: 'test' } }, + providerMetadata: { name: 'test' }, + logger: mockLogger, + }; + + const result = await hook.before(context); + + // Check that the original context is preserved + expect(result['user']).toBe('test-user'); + + // Check that the metadata is added + expect(result['gofeatureflag']).toEqual(metadata.asObject()); + }); + + it('should merge metadata with existing context', async () => { + const metadata = new ExporterMetadata().add('version', '1.0.0').add('environment', 'test'); + hook = new EnrichEvaluationContextHook(metadata); + + const originalContext: EvaluationContext = { + user: 'test-user', + gofeatureflag: { existing: 'value' }, + }; + + const context: HookContext = { + flagKey: 'test-flag', + defaultValue: false, + context: originalContext, + flagValueType: 'boolean', + clientMetadata: { providerMetadata: { name: 'test' } }, + providerMetadata: { name: 'test' }, + logger: mockLogger, + }; + + const result = await hook.before(context); + + // Check that the original context is preserved + expect(result['user']).toBe('test-user'); + + // Check that the metadata is added (should override existing gofeatureflag) + expect(result['gofeatureflag']).toEqual(metadata.asObject()); + }); + + it('should handle empty metadata object', async () => { + hook = new EnrichEvaluationContextHook(new ExporterMetadata()); + + const originalContext: EvaluationContext = { user: 'test-user' }; + + const context: HookContext = { + flagKey: 'test-flag', + defaultValue: false, + context: originalContext, + flagValueType: 'boolean', + clientMetadata: { providerMetadata: { name: 'test' } }, + providerMetadata: { name: 'test' }, + logger: mockLogger, + }; + + const result = await hook.before(context); + + // Should return original context unchanged + expect(result).toEqual(originalContext); + }); + }); +}); diff --git a/libs/providers/go-feature-flag/src/lib/hook/enrich-evaluation-context-hook.ts b/libs/providers/go-feature-flag/src/lib/hook/enrich-evaluation-context-hook.ts new file mode 100644 index 000000000..ce8256afe --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/hook/enrich-evaluation-context-hook.ts @@ -0,0 +1,44 @@ +import type { EvaluationContext, Hook, HookContext, JsonValue } from '@openfeature/server-sdk'; +import { ExporterMetadata } from '../model'; + +/** + * Enrich the evaluation context with additional information + */ +export class EnrichEvaluationContextHook implements Hook { + private readonly metadata: ExporterMetadata; + + /** + * Constructor of the Hook + * @param metadata - metadata to use in order to enrich the evaluation context + */ + constructor(metadata?: ExporterMetadata) { + if (!metadata) { + this.metadata = new ExporterMetadata(); + return; + } + + this.metadata = metadata; + } + + /** + * Enrich the evaluation context with additional information before the evaluation of the flag + * @param context - The hook context + * @param hints - Caller provided data + * @returns The enriched evaluation context + */ + async before( + context: HookContext, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _hints?: Record, + ): Promise { + const enrichedContext = { ...context.context }; + + if (this.metadata) { + const metadataAsObject = this.metadata?.asObject() ?? {}; + if (Object.keys(metadataAsObject).length > 0) { + enrichedContext['gofeatureflag'] = metadataAsObject; + } + } + return enrichedContext; + } +} diff --git a/libs/providers/go-feature-flag/src/lib/hook/index.ts b/libs/providers/go-feature-flag/src/lib/hook/index.ts new file mode 100644 index 000000000..759b85fe3 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/hook/index.ts @@ -0,0 +1,2 @@ +export { DataCollectorHook } from './data-collector-hook'; +export { EnrichEvaluationContextHook } from './enrich-evaluation-context-hook'; diff --git a/libs/providers/go-feature-flag/src/lib/model.ts b/libs/providers/go-feature-flag/src/lib/model.ts deleted file mode 100644 index 314cb7644..000000000 --- a/libs/providers/go-feature-flag/src/lib/model.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { ErrorCode, EvaluationContextValue, ResolutionDetails } from '@openfeature/server-sdk'; - -export interface GOFFEvaluationContext { - key: string; - custom?: { - [key: string]: EvaluationContextValue; - }; -} - -/** - * GoFeatureFlagProxyRequest is the request format used to call the GO Feature Flag - * API in the relay-proxy. - * The default value is used if something is failing. - */ -export interface GoFeatureFlagProxyRequest { - evaluationContext: GOFFEvaluationContext; - defaultValue: T; -} - -/** - * GoFeatureFlagProxyResponse is the response from the API. - * It contains the information about the flag you are evaluating. - */ -export interface GoFeatureFlagProxyResponse { - failed: boolean; - trackEvents: boolean; - value: T; - variationType: string; - version?: string; - reason: string | GOFeatureFlagResolutionReasons; - metadata: Record; - errorCode?: ErrorCode; - cacheable: boolean; -} - -/** - * Cache is the interface used to implement an alternative cache for the provider. - */ -export interface Cache { - get: (key: string) => ResolutionDetails | undefined; - set: (key: string, value: ResolutionDetails, options?: Record) => void; - clear: () => void; -} - -/** - * GoFeatureFlagProviderOptions is the object containing all the provider options - * when initializing the open-feature provider. - */ -export interface GoFeatureFlagProviderOptions { - endpoint: string; - timeout?: number; // in millisecond - - // apiKey (optional) If the relay proxy is configured to authenticate the requests, you should provide - // an API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. - // (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above) - // Default: null - apiKey?: string; - - // cache (optional) set an alternative cache library. - cache?: Cache; - - // disableCache (optional) set to true if you would like that every flag evaluation goes to the GO Feature Flag directly. - disableCache?: boolean; - - // flagCacheSize (optional) is the maximum number of flag events we keep in memory to cache your flags. - // default: 10000 - flagCacheSize?: number; - - // flagCacheTTL (optional) is the time we keep the evaluation in the cache before we consider it as obsolete. - // If you want to keep the value forever you can set the FlagCacheTTL field to -1 - // default: 1 minute - flagCacheTTL?: number; - - // dataFlushInterval (optional) interval time (in millisecond) we use to call the relay proxy to collect data. - // The parameter is used only if the cache is enabled, otherwise the collection of the data is done directly - // when calling the evaluation API. - // default: 1 minute - dataFlushInterval?: number; - - // disableDataCollection set to true if you don't want to collect the usage of flags retrieved in the cache. - disableDataCollection?: boolean; - - // pollInterval is the time in milliseconds to wait between we call the endpoint to detect configuration changes API - // If a negative number is provided, the provider will not poll. - // Default: 30000 - pollInterval?: number; // in milliseconds - - // exporterMetadata (optional) exporter metadata is a set of key-value that will be added to the metadata when calling the - // exporter API. All those information will be added to the event produce by the exporter. - // - // ‼️Important: If you are using a GO Feature Flag relay proxy before version v1.41.0, the information - // of this field will not be added to your feature events. - exporterMetadata?: Record; -} - -// ExporterMetadataValue is the type of the value that can be used in the exporterMetadata -export type ExporterMetadataValue = string | number | boolean; - -// GOFeatureFlagResolutionReasons allows to extends resolution reasons -export declare enum GOFeatureFlagResolutionReasons {} - -export interface DataCollectorRequest { - events: FeatureEvent[]; - meta: Record; -} - -export interface FeatureEvent { - contextKind: string; - creationDate: number; - default: boolean; - key: string; - kind: string; - userKey: string; - value: T; - variation: string; - version?: string; -} - -export interface DataCollectorResponse { - ingestedContentCount: number; -} - -export interface DataCollectorHookOptions { - // dataFlushInterval (optional) interval time (in millisecond) we use to call the relay proxy to collect data. - // The parameter is used only if the cache is enabled, otherwise the collection of the data is done directly - // when calling the evaluation API. - // default: 1 minute - dataFlushInterval?: number; - - // collectUnCachedEvent (optional) set to true if you want to send all events not only the cached evaluations. - collectUnCachedEvaluation?: boolean; - - // exporterMetadata (optional) exporter metadata is a set of key-value that will be added to the metadata when calling the - // exporter API. All those information will be added to the event produce by the exporter. - // - // ‼️Important: If you are using a GO Feature Flag relay proxy before version v1.41.0, the information - // of this field will not be added to your feature events. - exporterMetadata?: Record; -} - -export enum ConfigurationChange { - FLAG_CONFIGURATION_INITIALIZED, - FLAG_CONFIGURATION_UPDATED, - FLAG_CONFIGURATION_NOT_CHANGED, -} diff --git a/libs/providers/go-feature-flag/src/lib/model/evaluation-response.ts b/libs/providers/go-feature-flag/src/lib/model/evaluation-response.ts new file mode 100644 index 000000000..01bf5617a --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/evaluation-response.ts @@ -0,0 +1,41 @@ +import type { JsonValue } from '@openfeature/server-sdk'; + +/** + * EvaluationResponse represents the response from the GO Feature Flag evaluation. + */ +export interface EvaluationResponse { + /** + * Variation is the variation of the flag that was returned by the evaluation. + */ + variationType?: string; + + /** + * trackEvents indicates whether events should be tracked for this evaluation. + */ + trackEvents: boolean; + + /** + * reason is the reason for the evaluation result. + */ + reason?: string; + + /** + * errorCode is the error code for the evaluation result, if any. + */ + errorCode?: string; + + /** + * errorDetails provides additional details about the error, if any. + */ + errorDetails?: string; + + /** + * value is the evaluated value of the flag. + */ + value?: JsonValue; + + /** + * metadata is a dictionary containing additional metadata about the evaluation. + */ + metadata?: Record; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/evaluation-type.ts b/libs/providers/go-feature-flag/src/lib/model/evaluation-type.ts new file mode 100644 index 000000000..d5d226108 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/evaluation-type.ts @@ -0,0 +1,14 @@ +/** + * This enum represents the type of evaluation that can be performed. + */ +export enum EvaluationType { + /** + * InProcess: The evaluation is done in the process of the provider. + */ + InProcess = 'InProcess', + + /** + * Remote: The evaluation is done on the edge (e.g., CDN or API). + */ + Remote = 'Remote', +} diff --git a/libs/providers/go-feature-flag/src/lib/model/event.ts b/libs/providers/go-feature-flag/src/lib/model/event.ts new file mode 100644 index 000000000..c2dce1229 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/event.ts @@ -0,0 +1,4 @@ +import type { FeatureEvent } from './feature-event'; +import type { TrackingEvent } from './tracking-event'; + +export type ExportEvent = FeatureEvent | TrackingEvent; diff --git a/libs/providers/go-feature-flag/src/lib/model/experimentation-rollout.ts b/libs/providers/go-feature-flag/src/lib/model/experimentation-rollout.ts new file mode 100644 index 000000000..5c80ad677 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/experimentation-rollout.ts @@ -0,0 +1,14 @@ +/** + * Represents the rollout period of an experimentation. + */ +export interface ExperimentationRollout { + /** + * The start date of the experimentation rollout. + */ + start: Date; + + /** + * The end date of the experimentation rollout. + */ + end: Date; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/exporter-metadata.test.ts b/libs/providers/go-feature-flag/src/lib/model/exporter-metadata.test.ts new file mode 100644 index 000000000..50e4bc83d --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/exporter-metadata.test.ts @@ -0,0 +1,212 @@ +import { ExporterMetadata } from './exporter-metadata'; + +describe('ExporterMetadata', () => { + let exporterMetadata: ExporterMetadata; + + beforeEach(() => { + exporterMetadata = new ExporterMetadata(); + }); + + describe('add method', () => { + it('should add string metadata', () => { + exporterMetadata.add('testKey', 'testValue'); + const result = exporterMetadata.asObject(); + + expect(result).toEqual({ + testKey: 'testValue', + }); + }); + + it('should add boolean metadata', () => { + exporterMetadata.add('enabled', true); + exporterMetadata.add('disabled', false); + const result = exporterMetadata.asObject(); + + expect(result).toEqual({ + enabled: true, + disabled: false, + }); + }); + + it('should add number metadata', () => { + exporterMetadata.add('count', 42); + exporterMetadata.add('version', 1.5); + const result = exporterMetadata.asObject(); + + expect(result).toEqual({ + count: 42, + version: 1.5, + }); + }); + + it('should overwrite existing metadata with the same key', () => { + exporterMetadata.add('key', 'initialValue'); + exporterMetadata.add('key', 'updatedValue'); + const result = exporterMetadata.asObject(); + + expect(result).toEqual({ + key: 'updatedValue', + }); + }); + + it('should handle multiple metadata entries', () => { + exporterMetadata.add('stringKey', 'stringValue'); + exporterMetadata.add('booleanKey', true); + exporterMetadata.add('numberKey', 123); + const result = exporterMetadata.asObject(); + + expect(result).toEqual({ + stringKey: 'stringValue', + booleanKey: true, + numberKey: 123, + }); + }); + + it('should handle empty string values', () => { + exporterMetadata.add('emptyKey', ''); + const result = exporterMetadata.asObject(); + + expect(result).toEqual({ + emptyKey: '', + }); + }); + + it('should handle zero number values', () => { + exporterMetadata.add('zeroKey', 0); + const result = exporterMetadata.asObject(); + + expect(result).toEqual({ + zeroKey: 0, + }); + }); + }); + + describe('asObject method', () => { + it('should return empty object when no metadata is added', () => { + const result = exporterMetadata.asObject(); + + expect(result).toEqual({}); + }); + + it('should return immutable object', () => { + exporterMetadata.add('testKey', 'testValue'); + const result = exporterMetadata.asObject(); + + // Verify the object is frozen (immutable) + expect(Object.isFrozen(result)).toBe(true); + }); + + it('should return a new object instance each time', () => { + exporterMetadata.add('testKey', 'testValue'); + const result1 = exporterMetadata.asObject(); + const result2 = exporterMetadata.asObject(); + + expect(result1).toEqual(result2); + expect(result1).not.toBe(result2); // Different object instances + }); + + it('should not be affected by subsequent add operations', () => { + exporterMetadata.add('initialKey', 'initialValue'); + const result1 = exporterMetadata.asObject(); + + exporterMetadata.add('newKey', 'newValue'); + const result2 = exporterMetadata.asObject(); + + expect(result1).toEqual({ + initialKey: 'initialValue', + }); + expect(result2).toEqual({ + initialKey: 'initialValue', + newKey: 'newValue', + }); + }); + + it('should handle special characters in keys', () => { + exporterMetadata.add('key-with-dashes', 'value1'); + exporterMetadata.add('key_with_underscores', 'value2'); + exporterMetadata.add('keyWithCamelCase', 'value3'); + exporterMetadata.add('key with spaces', 'value4'); + const result = exporterMetadata.asObject(); + + expect(result).toEqual({ + 'key-with-dashes': 'value1', + key_with_underscores: 'value2', + keyWithCamelCase: 'value3', + 'key with spaces': 'value4', + }); + }); + + it('should handle special characters in values', () => { + exporterMetadata.add('key1', 'value with spaces'); + exporterMetadata.add('key2', 'value-with-dashes'); + exporterMetadata.add('key3', 'value_with_underscores'); + exporterMetadata.add('key4', 'valueWithCamelCase'); + const result = exporterMetadata.asObject(); + + expect(result).toEqual({ + key1: 'value with spaces', + key2: 'value-with-dashes', + key3: 'value_with_underscores', + key4: 'valueWithCamelCase', + }); + }); + }); + + describe('integration tests', () => { + it('should maintain state across multiple operations', () => { + // Add initial metadata + exporterMetadata.add('app', 'my-app'); + exporterMetadata.add('version', '1.0.0'); + + let result = exporterMetadata.asObject(); + expect(result).toEqual({ + app: 'my-app', + version: '1.0.0', + }); + + // Add more metadata + exporterMetadata.add('environment', 'production'); + exporterMetadata.add('debug', false); + + result = exporterMetadata.asObject(); + expect(result).toEqual({ + app: 'my-app', + version: '1.0.0', + environment: 'production', + debug: false, + }); + + // Update existing metadata + exporterMetadata.add('version', '2.0.0'); + + result = exporterMetadata.asObject(); + expect(result).toEqual({ + app: 'my-app', + version: '2.0.0', + environment: 'production', + debug: false, + }); + }); + + it('should handle complex metadata scenarios', () => { + // Simulate a real-world scenario + exporterMetadata.add('sdk', 'go-feature-flag'); + exporterMetadata.add('sdkVersion', '1.0.0'); + exporterMetadata.add('endpoint', 'http://localhost:1031'); + exporterMetadata.add('timeout', 5000); + exporterMetadata.add('retryEnabled', true); + exporterMetadata.add('maxRetries', 3); + + const result = exporterMetadata.asObject(); + + expect(result).toEqual({ + sdk: 'go-feature-flag', + sdkVersion: '1.0.0', + endpoint: 'http://localhost:1031', + timeout: 5000, + retryEnabled: true, + maxRetries: 3, + }); + }); + }); +}); diff --git a/libs/providers/go-feature-flag/src/lib/model/exporter-metadata.ts b/libs/providers/go-feature-flag/src/lib/model/exporter-metadata.ts new file mode 100644 index 000000000..01e0fdc53 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/exporter-metadata.ts @@ -0,0 +1,24 @@ +/** + * This class represents the exporter metadata that will be sent in your evaluation data collector + */ +export class ExporterMetadata { + private metadata: Record = {}; + + /** + * Add a metadata to the exporter + * @param key - the key of the metadata + * @param value - the value of the metadata + */ + add(key: string, value: string | boolean | number): ExporterMetadata { + this.metadata[key] = value; + return this; + } + + /** + * Return the metadata as an immutable object + * @returns the metadata as an immutable object + */ + asObject(): Record { + return Object.freeze({ ...this.metadata }); + } +} diff --git a/libs/providers/go-feature-flag/src/lib/model/exporter-request.ts b/libs/providers/go-feature-flag/src/lib/model/exporter-request.ts new file mode 100644 index 000000000..e54f921c3 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/exporter-request.ts @@ -0,0 +1,16 @@ +import type { ExportEvent } from './event'; + +/** + * ExporterRequest is an interface that represents the request to the GO Feature Flag data collector API. + */ +export interface ExporterRequest { + /** + * metadata is the metadata that will be sent in your evaluation data collector. + */ + meta: Record; + + /** + * events is the list of events that will be sent in your evaluation data collector. + */ + events: ExportEvent[]; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/feature-event.ts b/libs/providers/go-feature-flag/src/lib/model/feature-event.ts new file mode 100644 index 000000000..2262a9816 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/feature-event.ts @@ -0,0 +1,52 @@ +import type { JsonValue } from '@openfeature/server-sdk'; + +/** + * This interface represents a feature event, used to send evaluation events to the GO Feature Flag server. + */ +export interface FeatureEvent { + /** + * Kind is the kind of event. + */ + kind: 'feature'; + + /** + * Creation date of the event in seconds since epoch. + */ + creationDate: number; + + /** + * ContextKind is the kind of context that generated an event. + */ + contextKind: string; + + /** + * Feature flag name or key. + */ + key: string; + + /** + * User key is the unique identifier for the user or context (the targetingKey). + */ + userKey: string; + + /** + * Default is true if the feature is using the default value. + */ + default: boolean; + + /** + * Value of the feature flag evaluation result. + */ + value?: JsonValue; + + /** + * Variation is the variation of the feature flag that was returned by the evaluation. + */ + variation: string; + + /** + * Version is the version of the feature flag that was evaluated. + * If the feature flag is not versioned, this will be null or empty. + */ + version?: string; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/flag-base.ts b/libs/providers/go-feature-flag/src/lib/model/flag-base.ts new file mode 100644 index 000000000..d22445814 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/flag-base.ts @@ -0,0 +1,53 @@ +import type { JsonValue } from '@openfeature/server-sdk'; +import type { ExperimentationRollout } from './experimentation-rollout'; +import type { Rule } from './rule'; + +/** + * Represents the base structure of a feature flag for GO Feature Flag. + */ +export interface FlagBase { + /** + * The variations available for this flag. + */ + variations?: Record; + + /** + * The list of targeting rules for this flag. + */ + targeting?: Rule[]; + + /** + * The key used for bucketing users. + */ + bucketingKey?: string; + + /** + * The default rule to apply if no targeting rule matches. + */ + defaultRule: Rule; + + /** + * The experimentation rollout configuration. + */ + experimentation?: ExperimentationRollout; + + /** + * Indicates if events should be tracked for this flag. + */ + trackEvents?: boolean; + + /** + * Indicates if the flag is disabled. + */ + disable?: boolean; + + /** + * The version of the flag. + */ + version?: string; + + /** + * Additional metadata for the flag. + */ + metadata?: Record; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/flag-config-request.ts b/libs/providers/go-feature-flag/src/lib/model/flag-config-request.ts new file mode 100644 index 000000000..3a8b7973c --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/flag-config-request.ts @@ -0,0 +1,9 @@ +/** + * FlagConfigRequest represents the request payload for flag configuration. + */ +export interface FlagConfigRequest { + /** + * List of flags to retrieve, if not set or empty, we will retrieve all available flags. + */ + flags?: string[]; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/flag-config-response.ts b/libs/providers/go-feature-flag/src/lib/model/flag-config-response.ts new file mode 100644 index 000000000..816c0eac8 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/flag-config-response.ts @@ -0,0 +1,27 @@ +import type { JsonValue } from '@openfeature/server-sdk'; +import type { Flag } from './flag'; + +/** + * FlagConfigResponse is a class that represents the response of the flag configuration. + */ +export interface FlagConfigResponse { + /** + * Flags is a dictionary that contains the flag key and its corresponding Flag object. + */ + flags: Record; + + /** + * EvaluationContextEnrichment is a dictionary that contains additional context for the evaluation of flags. + */ + evaluationContextEnrichment: Record; + + /** + * Etag is a string that represents the entity tag of the flag configuration response. + */ + etag?: string; + + /** + * LastUpdated is a nullable DateTime that represents the last time the flag configuration was updated. + */ + lastUpdated?: Date; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/flag-context.ts b/libs/providers/go-feature-flag/src/lib/model/flag-context.ts new file mode 100644 index 000000000..ade7bc229 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/flag-context.ts @@ -0,0 +1,17 @@ +import type { JsonValue } from '@openfeature/server-sdk'; + +/** + * Represents the context of a flag in the GO Feature Flag system. + * Contains the default SDK value and evaluation context enrichment. + */ +export interface FlagContext { + /** + * The default value to return from the SDK if no rule matches. + */ + defaultSdkValue?: unknown; + + /** + * Additional context values to enrich the evaluation context. + */ + evaluationContextEnrichment: Record; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/flag.ts b/libs/providers/go-feature-flag/src/lib/model/flag.ts new file mode 100644 index 000000000..d1907a7f4 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/flag.ts @@ -0,0 +1,12 @@ +import type { FlagBase } from './flag-base'; +import type { ScheduledStep } from './scheduled-step'; + +/** + * Represents a feature flag for GO Feature Flag. + */ +export interface Flag extends FlagBase { + /** + * The list of scheduled rollout steps for this flag. + */ + scheduledRollout?: ScheduledStep[]; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/index.ts b/libs/providers/go-feature-flag/src/lib/model/index.ts new file mode 100644 index 000000000..d7533bc4e --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/index.ts @@ -0,0 +1,33 @@ +// Export all types and classes from the model directory + +// Event-related exports +export * from './event'; +export * from './feature-event'; +export * from './tracking-event'; + +// Exporter-related exports +export * from './exporter-request'; +export * from './exporter-metadata'; + +// Flag configuration exports +export * from './flag-config-request'; +export * from './flag-config-response'; + +// Flag-related exports +export * from './flag'; +export * from './flag-base'; +export * from './flag-context'; + +// Rule and rollout exports +export * from './rule'; +export * from './progressive-rollout'; +export * from './progressive-rollout-step'; +export * from './experimentation-rollout'; +export * from './scheduled-step'; + +// Evaluation exports +export * from './evaluation-type'; +export * from './evaluation-response'; + +// WASM-related exports +export * from './wasm-input'; diff --git a/libs/providers/go-feature-flag/src/lib/model/progressive-rollout-step.ts b/libs/providers/go-feature-flag/src/lib/model/progressive-rollout-step.ts new file mode 100644 index 000000000..e045dd5f8 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/progressive-rollout-step.ts @@ -0,0 +1,19 @@ +/** + * Represents a step in the progressive rollout of a feature flag. + */ +export interface ProgressiveRolloutStep { + /** + * The variation to be served at this rollout step. + */ + variation?: string; + + /** + * The percentage of users to receive this variation at this step. + */ + percentage?: number; + + /** + * The date when this rollout step becomes active. + */ + date: Date; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/progressive-rollout.ts b/libs/providers/go-feature-flag/src/lib/model/progressive-rollout.ts new file mode 100644 index 000000000..98b840805 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/progressive-rollout.ts @@ -0,0 +1,16 @@ +import type { ProgressiveRolloutStep } from './progressive-rollout-step'; + +/** + * Represents the progressive rollout of a feature flag. + */ +export interface ProgressiveRollout { + /** + * The initial step of the progressive rollout. + */ + initial: ProgressiveRolloutStep; + + /** + * The end step of the progressive rollout. + */ + end: ProgressiveRolloutStep; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/rule.ts b/libs/providers/go-feature-flag/src/lib/model/rule.ts new file mode 100644 index 000000000..88f481730 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/rule.ts @@ -0,0 +1,36 @@ +import type { ProgressiveRollout } from './progressive-rollout'; + +/** + * Represents a rule in the GO Feature Flag system. + */ +export interface Rule { + /** + * The name of the rule. + */ + name?: string; + + /** + * The query associated with the rule. + */ + query?: string; + + /** + * The variation to serve if the rule matches. + */ + variation?: string; + + /** + * The percentage mapping for variations. + */ + percentage?: Record; + + /** + * Indicates if the rule is disabled. + */ + disable?: boolean; + + /** + * The progressive rollout configuration for this rule. + */ + progressiveRollout?: ProgressiveRollout; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/scheduled-step.ts b/libs/providers/go-feature-flag/src/lib/model/scheduled-step.ts new file mode 100644 index 000000000..fbe7b7caf --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/scheduled-step.ts @@ -0,0 +1,11 @@ +import type { FlagBase } from './flag-base'; + +/** + * Represents a scheduled step in the rollout of a feature flag. + */ +export interface ScheduledStep extends FlagBase { + /** + * The date of the scheduled step. + */ + date?: Date; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/tracking-event.ts b/libs/providers/go-feature-flag/src/lib/model/tracking-event.ts new file mode 100644 index 000000000..31cf8edce --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/tracking-event.ts @@ -0,0 +1,42 @@ +import type { EvaluationContext, TrackingEventDetails } from '@openfeature/server-sdk'; + +/** + * TrackingEvent is an interface that represents a tracking event for a feature flag. + * A tracking event is generated when we call the track method on the client. + */ +export interface TrackingEvent { + /** + * Kind is the kind of event. + */ + kind: 'tracking'; + + /** + * Creation date of the event in seconds since epoch. + */ + creationDate: number; + + /** + * ContextKind is the kind of context that generated an event. + */ + contextKind: string; + + /** + * Feature flag name or key. + */ + key: string; + + /** + * User key is the unique identifier for the user or context (the targetingKey). + */ + userKey: string; + + /** + * EvaluationContext contains the evaluation context used for the tracking. + */ + evaluationContext?: EvaluationContext; + + /** + * TrackingDetails contains the details of the tracking event. + */ + trackingEventDetails?: TrackingEventDetails; +} diff --git a/libs/providers/go-feature-flag/src/lib/model/wasm-input.ts b/libs/providers/go-feature-flag/src/lib/model/wasm-input.ts new file mode 100644 index 000000000..2c0dfe218 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/model/wasm-input.ts @@ -0,0 +1,28 @@ +import type { JsonValue } from '@openfeature/server-sdk'; +import type { Flag } from './flag'; +import type { FlagContext } from './flag-context'; + +/** + * Represents the input to the WASM module, containing the flag key, flag, evaluation context, and flag context. + */ +export interface WasmInput { + /** + * Flag key to be evaluated. + */ + flagKey: string; + + /** + * Flag to be evaluated. + */ + flag: Flag; + + /** + * Evaluation context for a flag evaluation. + */ + evalContext: Record; + + /** + * Flag context containing default SDK value and evaluation context enrichment. + */ + flagContext: FlagContext; +} diff --git a/libs/providers/go-feature-flag/src/lib/service/api.test.ts b/libs/providers/go-feature-flag/src/lib/service/api.test.ts new file mode 100644 index 000000000..f6c928643 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/service/api.test.ts @@ -0,0 +1,470 @@ +import { GoFeatureFlagApi } from './api'; +import type { GoFeatureFlagProviderOptions } from '../go-feature-flag-provider-options'; +import type { FetchAPI } from '../helper/fetch-api'; +import { ExporterMetadata, type FeatureEvent, type TrackingEvent } from '../model'; +import { + FlagConfigurationEndpointNotFoundException, + ImpossibleToRetrieveConfigurationException, + UnauthorizedException, + ImpossibleToSendDataToTheCollectorException, + InvalidOptionsException, +} from '../exception'; +import { MockFetch, MockResponse } from '../testutil/mock-fetch'; + +describe('GoFeatureFlagApi', () => { + let mockFetch: MockFetch; + let fetchImplementation: FetchAPI; + + beforeEach(() => { + mockFetch = new MockFetch(); + fetchImplementation = mockFetch.fetch.bind(mockFetch) as FetchAPI; + + // Mock global fetch for tests that don't provide fetchImplementation + (global as any).fetch = fetchImplementation; + }); + + afterEach(() => { + mockFetch.reset(); + // Clean up global fetch mock + delete (global as any).fetch; + }); + + describe('Constructor', () => { + it('should throw if options are missing', () => { + expect(() => new GoFeatureFlagApi(null as any)).toThrow(InvalidOptionsException); + }); + }); + + describe('RetrieveFlagConfiguration', () => { + const baseOptions: GoFeatureFlagProviderOptions = { + endpoint: 'http://localhost:8080', + fetchImplementation, + }; + + it('should call the configuration endpoint', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponse('http://localhost:8080/v1/flag/configuration', new MockResponse(200, '{}')); + + await api.retrieveFlagConfiguration(); + + const request = mockFetch.getLastRequest(); + expect(request?.url).toBe('http://localhost:8080/v1/flag/configuration'); + expect(request?.options.method).toBe('POST'); + expect(request?.options.body).toEqual(JSON.stringify({ flags: [] })); + }); + + it('should include API key in authorization header when provided', async () => { + const options: GoFeatureFlagProviderOptions = { + ...baseOptions, + apiKey: 'my-api-key', + }; + const api = new GoFeatureFlagApi(options); + mockFetch.setResponse('http://localhost:8080/v1/flag/configuration', new MockResponse(200, '{}')); + + await api.retrieveFlagConfiguration(); + + const request = mockFetch.getLastRequest(); + expect(request?.options.headers).toHaveProperty('Authorization', 'Bearer my-api-key'); + }); + + it('should not include authorization header when API key is not provided', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponse('http://localhost:8080/v1/flag/configuration', new MockResponse(200, '{}')); + + await api.retrieveFlagConfiguration(); + + const request = mockFetch.getLastRequest(); + expect(request?.options.headers).not.toHaveProperty('Authorization'); + }); + + it('should include content-type header', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponse('http://localhost:8080/v1/flag/configuration', new MockResponse(200, '{}')); + + await api.retrieveFlagConfiguration(); + + const request = mockFetch.getLastRequest(); + expect(request?.options.headers).toHaveProperty('Content-Type', 'application/json'); + }); + + it('should include If-None-Match header when etag is provided', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponse('http://localhost:8080/v1/flag/configuration', new MockResponse(200, '{}')); + + await api.retrieveFlagConfiguration('12345'); + + const request = mockFetch.getLastRequest(); + expect(request?.options.headers).toHaveProperty('If-None-Match', '12345'); + }); + + it('should include flags in request body when provided', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponse('http://localhost:8080/v1/flag/configuration', new MockResponse(200, '{}')); + + await api.retrieveFlagConfiguration(undefined, ['flag1', 'flag2']); + + const request = mockFetch.getLastRequest(); + expect(request?.options.body).toBe(JSON.stringify({ flags: ['flag1', 'flag2'] })); + }); + + it('should throw UnauthorizedException on 401 response', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponseByStatus('401', new MockResponse(401, 'Unauthorized')); + + await expect(api.retrieveFlagConfiguration()).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException on 403 response', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponseByStatus('403', new MockResponse(403, 'Forbidden')); + + await expect(api.retrieveFlagConfiguration()).rejects.toThrow(UnauthorizedException); + }); + + it('should throw ImpossibleToRetrieveConfigurationException on 400 response', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponseByStatus('400', new MockResponse(400, 'Bad Request')); + + await expect(api.retrieveFlagConfiguration()).rejects.toThrow(ImpossibleToRetrieveConfigurationException); + }); + + it('should throw ImpossibleToRetrieveConfigurationException on 500 response', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponseByStatus('500', new MockResponse(500, 'Internal Server Error')); + + await expect(api.retrieveFlagConfiguration()).rejects.toThrow(ImpossibleToRetrieveConfigurationException); + }); + + it('should throw FlagConfigurationEndpointNotFoundException on 404 response', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponseByStatus('404', new MockResponse(404, 'Not Found')); + + await expect(api.retrieveFlagConfiguration()).rejects.toThrow(FlagConfigurationEndpointNotFoundException); + }); + + it('should return valid FlagConfigResponse on 200 response', async () => { + const api = new GoFeatureFlagApi(baseOptions); + const responseBody = JSON.stringify({ + flags: { + TEST: { + variations: { + on: true, + off: false, + }, + defaultRule: { variation: 'off' }, + }, + TEST2: { + variations: { + on: true, + off: false, + }, + defaultRule: { variation: 'on' }, + }, + }, + evaluationContextEnrichment: { + env: 'production', + }, + }); + + mockFetch.setResponse( + 'http://localhost:8080/v1/flag/configuration', + new MockResponse(200, responseBody, { + etag: '"123456789"', + 'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT', + }), + ); + + const result = await api.retrieveFlagConfiguration(); + + expect(result.etag).toBe('"123456789"'); + expect(result.lastUpdated).toEqual(new Date('Wed, 21 Oct 2015 07:28:00 GMT')); + expect(result.flags).toHaveProperty('TEST'); + expect(result.flags).toHaveProperty('TEST2'); + expect(result.evaluationContextEnrichment).toHaveProperty('env', 'production'); + }); + + it('should handle 304 response without flags and context', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponseByStatus( + '304', + new MockResponse(304, '', { + etag: '"123456789"', + 'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT', + }), + ); + + const result = await api.retrieveFlagConfiguration(); + + expect(result.etag).toBe('"123456789"'); + expect(result.lastUpdated).toEqual(new Date('Wed, 21 Oct 2015 07:28:00 GMT')); + expect(result.flags).toEqual({}); + expect(result.evaluationContextEnrichment).toEqual({}); + }); + + it('should handle invalid last-modified header', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponse( + 'http://localhost:8080/v1/flag/configuration', + new MockResponse(200, '{}', { + etag: '"123456789"', + 'last-modified': 'invalid-date', + }), + ); + + const result = await api.retrieveFlagConfiguration(); + + expect(result.lastUpdated?.getTime()).toBeNaN(); + }); + + it('should handle network errors', async () => { + const mockFetchWithError = async () => { + throw new Error('Network error'); + }; + + const optionsWithErrorFetch: GoFeatureFlagProviderOptions = { + ...baseOptions, + fetchImplementation: mockFetchWithError, + }; + const apiWithError = new GoFeatureFlagApi(optionsWithErrorFetch); + + await expect(apiWithError.retrieveFlagConfiguration()).rejects.toThrow( + ImpossibleToRetrieveConfigurationException, + ); + }); + + it('should handle timeout', async () => { + const mockFetchWithDelay = async (url: string, options: RequestInit = {}) => { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (options.signal && (options.signal as AbortSignal).aborted) { + throw new Error('Request aborted'); + } + return new MockResponse(200, '{}'); + }; + + const optionsWithDelayFetch: GoFeatureFlagProviderOptions = { + ...baseOptions, + fetchImplementation: mockFetchWithDelay as unknown as FetchAPI, + timeout: 1, + }; + const apiWithDelay = new GoFeatureFlagApi(optionsWithDelayFetch); + + await expect(apiWithDelay.retrieveFlagConfiguration()).rejects.toThrow( + ImpossibleToRetrieveConfigurationException, + ); + }); + }); + + describe('SendEventToDataCollector', () => { + const baseOptions: GoFeatureFlagProviderOptions = { + endpoint: 'http://localhost:8080', + fetchImplementation, + }; + + it('should call the data collector endpoint', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponse('http://localhost:8080/v1/data/collector', new MockResponse(200, 'Success')); + + const events: FeatureEvent[] | TrackingEvent[] = []; + const metadata: ExporterMetadata = new ExporterMetadata(); + + await api.sendEventToDataCollector(events, metadata); + + const request = mockFetch.getLastRequest(); + expect(request?.url).toBe('http://localhost:8080/v1/data/collector'); + expect(request?.options.method).toBe('POST'); + }); + + it('should include API key in authorization header when provided', async () => { + const options: GoFeatureFlagProviderOptions = { + ...baseOptions, + apiKey: 'my-api-key', + }; + const api = new GoFeatureFlagApi(options); + mockFetch.setResponse('http://localhost:8080/v1/data/collector', new MockResponse(200, 'Success')); + + const events: FeatureEvent[] | TrackingEvent[] = []; + const metadata: ExporterMetadata = new ExporterMetadata(); + + await api.sendEventToDataCollector(events, metadata); + + const request = mockFetch.getLastRequest(); + expect(request?.options.headers).toHaveProperty('Authorization', 'Bearer my-api-key'); + }); + + it('should not include authorization header when API key is not provided', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponse('http://localhost:8080/v1/data/collector', new MockResponse(200, 'Success')); + + const events: FeatureEvent[] | TrackingEvent[] = []; + const metadata: ExporterMetadata = new ExporterMetadata(); + + await api.sendEventToDataCollector(events, metadata); + + const request = mockFetch.getLastRequest(); + expect(request?.options.headers).not.toHaveProperty('Authorization'); + }); + + it('should include content-type header', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponse('http://localhost:8080/v1/data/collector', new MockResponse(200, 'Success')); + + const events: FeatureEvent[] | TrackingEvent[] = []; + const metadata: ExporterMetadata = new ExporterMetadata(); + + await api.sendEventToDataCollector(events, metadata); + + const request = mockFetch.getLastRequest(); + expect(request?.options.headers).toHaveProperty('Content-Type', 'application/json'); + }); + + it('should include events and metadata in request body', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponse('http://localhost:8080/v1/data/collector', new MockResponse(200, 'Success')); + + const events: FeatureEvent[] | TrackingEvent[] = [ + { + kind: 'feature', + creationDate: 1750406145, + contextKind: 'user', + key: 'TEST', + userKey: '642e135a-1df9-4419-a3d3-3c42e0e67509', + default: false, + value: 'toto', + variation: 'on', + version: '1.0.0', + }, + ]; + + const metadata: ExporterMetadata = new ExporterMetadata().add('env', 'production'); + + await api.sendEventToDataCollector(events, metadata); + + const request = mockFetch.getLastRequest(); + const body = JSON.parse(request?.options.body as string); + expect(body.meta).toEqual({ env: 'production' }); + expect(body.events).toHaveLength(1); + expect(JSON.stringify(body.events)).toBe(JSON.stringify(events)); + }); + + it('should handle tracking events', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponse('http://localhost:8080/v1/data/collector', new MockResponse(200, 'Success')); + + const events: FeatureEvent[] | TrackingEvent[] = [ + { + kind: 'tracking', + creationDate: 1750406145, + contextKind: 'user', + key: 'TEST2', + userKey: '642e135a-1df9-4419-a3d3-3c42e0e67509', + trackingEventDetails: { + action: 'click', + label: 'button1', + value: 1, + }, + }, + ]; + + const metadata: ExporterMetadata = new ExporterMetadata().add('env', 'production'); + + await api.sendEventToDataCollector(events, metadata); + + const request = mockFetch.getLastRequest(); + const body = JSON.parse(request?.options.body as string); + expect(body.events).toHaveLength(1); + expect(body.events[0].kind).toBe('tracking'); + expect(body.events[0].trackingEventDetails).toEqual({ + action: 'click', + label: 'button1', + value: 1, + }); + }); + + it('should throw UnauthorizedException on 401 response', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponseByStatus('401', new MockResponse(401, 'Unauthorized')); + + const events: FeatureEvent[] | TrackingEvent[] = []; + const metadata: ExporterMetadata = new ExporterMetadata(); + + await expect(api.sendEventToDataCollector(events, metadata)).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException on 403 response', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponseByStatus('403', new MockResponse(403, 'Forbidden')); + + const events: FeatureEvent[] | TrackingEvent[] = []; + const metadata: ExporterMetadata = new ExporterMetadata(); + + await expect(api.sendEventToDataCollector(events, metadata)).rejects.toThrow(UnauthorizedException); + }); + + it('should throw ImpossibleToSendDataToTheCollectorException on 400 response', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponseByStatus('400', new MockResponse(400, 'Bad Request')); + + const events: FeatureEvent[] | TrackingEvent[] = []; + const metadata: ExporterMetadata = new ExporterMetadata(); + + await expect(api.sendEventToDataCollector(events, metadata)).rejects.toThrow( + ImpossibleToSendDataToTheCollectorException, + ); + }); + + it('should throw ImpossibleToSendDataToTheCollectorException on 500 response', async () => { + const api = new GoFeatureFlagApi(baseOptions); + mockFetch.setResponseByStatus('500', new MockResponse(500, 'Internal Server Error')); + + const events: FeatureEvent[] | TrackingEvent[] = []; + const metadata: ExporterMetadata = new ExporterMetadata(); + + await expect(api.sendEventToDataCollector(events, metadata)).rejects.toThrow( + ImpossibleToSendDataToTheCollectorException, + ); + }); + + it('should handle network errors', async () => { + const mockFetchWithError = async () => { + throw new Error('Network error'); + }; + + const optionsWithErrorFetch: GoFeatureFlagProviderOptions = { + ...baseOptions, + fetchImplementation: mockFetchWithError, + }; + const apiWithError = new GoFeatureFlagApi(optionsWithErrorFetch); + + const events: FeatureEvent[] | TrackingEvent[] = []; + const metadata: ExporterMetadata = new ExporterMetadata(); + + await expect(apiWithError.sendEventToDataCollector(events, metadata)).rejects.toThrow( + ImpossibleToSendDataToTheCollectorException, + ); + }); + + it('should handle timeout', async () => { + const mockFetchWithDelay = async (url: string, options: RequestInit = {}) => { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (options.signal && (options.signal as AbortSignal).aborted) { + throw new Error('Request aborted'); + } + return new MockResponse(200, 'Success'); + }; + + const optionsWithDelayFetch: GoFeatureFlagProviderOptions = { + ...baseOptions, + fetchImplementation: mockFetchWithDelay as unknown as FetchAPI, + timeout: 1, + }; + const apiWithDelay = new GoFeatureFlagApi(optionsWithDelayFetch); + + const events: FeatureEvent[] | TrackingEvent[] = []; + const metadata: ExporterMetadata = new ExporterMetadata(); + + await expect(apiWithDelay.sendEventToDataCollector(events, metadata)).rejects.toThrow( + ImpossibleToSendDataToTheCollectorException, + ); + }); + }); +}); diff --git a/libs/providers/go-feature-flag/src/lib/service/api.ts b/libs/providers/go-feature-flag/src/lib/service/api.ts new file mode 100644 index 000000000..889f3f5fa --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/service/api.ts @@ -0,0 +1,223 @@ +import { type FetchAPI, isomorphicFetch } from '../helper/fetch-api'; +import type { GoFeatureFlagProviderOptions } from '../go-feature-flag-provider-options'; +import type { ExporterMetadata, ExporterRequest, ExportEvent, FlagConfigRequest, FlagConfigResponse } from '../model'; +import type { Logger } from '@openfeature/server-sdk'; +import { + APPLICATION_JSON, + BEARER_TOKEN, + HTTP_HEADER_AUTHORIZATION, + HTTP_HEADER_CONTENT_TYPE, + HTTP_HEADER_ETAG, + HTTP_HEADER_IF_NONE_MATCH, + HTTP_HEADER_LAST_MODIFIED, + HTTP_STATUS, +} from '../helper/constants'; +import { + FlagConfigurationEndpointNotFoundException, + GoFeatureFlagException, + ImpossibleToRetrieveConfigurationException, + ImpossibleToSendDataToTheCollectorException, + InvalidOptionsException, + UnauthorizedException, +} from '../exception'; + +/** + * GOFeatureFlagApi is a class that provides methods to interact with the GO Feature Flag API. + */ +export class GoFeatureFlagApi { + private readonly endpoint: string; + private readonly timeout: number; + private readonly apiKey?: string; + private readonly fetchImplementation: FetchAPI; + private readonly logger?: Logger; + + /** + * Constructor for GoFeatureFlagApi. + * @param options Options provided during the initialization of the provider + * @throws Error when options are not provided + */ + constructor(options: GoFeatureFlagProviderOptions, logger?: Logger) { + if (!options) { + throw new InvalidOptionsException('Options cannot be null'); + } + + this.endpoint = options.endpoint; + this.timeout = options.timeout || 10000; + this.apiKey = options.apiKey; + this.fetchImplementation = options.fetchImplementation || isomorphicFetch(); + this.logger = logger; + } + + /** + * RetrieveFlagConfiguration is a method that retrieves the flag configuration from the GO Feature Flag API. + * @param etag If provided, we call the API with "If-None-Match" header. + * @param flags List of flags to retrieve, if not set or empty, we will retrieve all available flags. + * @returns A FlagConfigResponse returning the success data. + * @throws FlagConfigurationEndpointNotFoundException if the endpoint is not reachable. + * @throws ImpossibleToRetrieveConfigurationException if the endpoint is returning an error. + */ + async retrieveFlagConfiguration(etag?: string, flags?: string[]): Promise { + const requestBody: FlagConfigRequest = { flags: flags || [] }; + const requestStr = JSON.stringify(requestBody); + + const headers: Record = { + [HTTP_HEADER_CONTENT_TYPE]: APPLICATION_JSON, + }; + + // Adding the If-None-Match header if etag is provided + if (etag) { + headers[HTTP_HEADER_IF_NONE_MATCH] = etag; + } + + // Add authorization header if API key is provided + if (this.apiKey) { + headers[HTTP_HEADER_AUTHORIZATION] = `${BEARER_TOKEN}${this.apiKey}`; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await this.fetchImplementation(`${this.endpoint}/v1/flag/configuration`, { + method: 'POST', + headers, + body: requestStr, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + switch (response.status) { + case HTTP_STATUS.OK: + case HTTP_STATUS.NOT_MODIFIED: { + const body = await response.text(); + return this.handleFlagConfigurationSuccess(response, body); + } + case HTTP_STATUS.NOT_FOUND: + throw new FlagConfigurationEndpointNotFoundException(); + case HTTP_STATUS.UNAUTHORIZED: + case HTTP_STATUS.FORBIDDEN: + throw new UnauthorizedException( + 'Impossible to retrieve flag configuration: authentication/authorization error', + ); + case HTTP_STATUS.BAD_REQUEST: { + const badRequestErrBody = await response.text(); + throw new ImpossibleToRetrieveConfigurationException( + `retrieve flag configuration error: Bad request: ${badRequestErrBody}`, + ); + } + default: { + const defaultErrBody = (await response.text()) || ''; + throw new ImpossibleToRetrieveConfigurationException( + `retrieve flag configuration error: unexpected http code ${response.status}: ${defaultErrBody}`, + ); + } + } + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof GoFeatureFlagException) { + throw error; + } + throw new ImpossibleToRetrieveConfigurationException(`Network error: ${error}`); + } + } + + /** + * Sends a list of events to the GO Feature Flag data collector. + * @param eventsList List of events + * @param exporterMetadata Metadata associated. + * @throws UnauthorizedException when we are not authorized to call the API + * @throws ImpossibleToSendDataToTheCollectorException when an error occurred when calling the API + */ + async sendEventToDataCollector(eventsList: ExportEvent[], exporterMetadata: ExporterMetadata): Promise { + const requestBody: ExporterRequest = { + meta: exporterMetadata?.asObject() ?? {}, + events: eventsList, + }; + + const requestStr = JSON.stringify(requestBody); + + const headers: Record = { + [HTTP_HEADER_CONTENT_TYPE]: APPLICATION_JSON, + }; + + // Add authorization header if API key is provided + if (this.apiKey) { + headers[HTTP_HEADER_AUTHORIZATION] = `${BEARER_TOKEN}${this.apiKey}`; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await this.fetchImplementation(`${this.endpoint}/v1/data/collector`, { + method: 'POST', + headers, + body: requestStr, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + switch (response.status) { + case HTTP_STATUS.OK: { + const body = await response.text(); + this.logger?.info(`Published ${eventsList.length} events successfully: ${body}`); + return; + } + case HTTP_STATUS.UNAUTHORIZED: + case HTTP_STATUS.FORBIDDEN: + throw new UnauthorizedException('Impossible to send events: authentication/authorization error'); + case HTTP_STATUS.BAD_REQUEST: { + const badRequestErrBody = await response.text(); + throw new ImpossibleToSendDataToTheCollectorException(`Bad request: ${badRequestErrBody}`); + } + default: { + const defaultErrBody = (await response.text()) || ''; + throw new ImpossibleToSendDataToTheCollectorException( + `send data to the collector error: unexpected http code ${response.status}: ${defaultErrBody}`, + ); + } + } + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof GoFeatureFlagException) { + throw error; + } + throw new ImpossibleToSendDataToTheCollectorException(`Network error: ${error}`); + } + } + + /** + * HandleFlagConfigurationSuccess is handling the success response of the flag configuration request. + * @param response HTTP response. + * @param body String of the body. + * @returns A FlagConfigResponse object. + */ + private handleFlagConfigurationSuccess(response: Response, body: string): FlagConfigResponse { + const etagHeader = response.headers.get(HTTP_HEADER_ETAG) || undefined; + const lastModifiedHeader = response.headers.get(HTTP_HEADER_LAST_MODIFIED); + const lastUpdated = lastModifiedHeader ? new Date(lastModifiedHeader) : new Date(0); + + const result: FlagConfigResponse = { + etag: etagHeader, + lastUpdated, + flags: {}, + evaluationContextEnrichment: {}, + }; + + if (response.status === HTTP_STATUS.NOT_MODIFIED) { + return result; + } + + try { + const goffResp = JSON.parse(body) as FlagConfigResponse; + result.evaluationContextEnrichment = goffResp.evaluationContextEnrichment || {}; + result.flags = goffResp.flags || {}; + } catch (error) { + this.logger?.warn(`Failed to parse flag configuration response: ${error}. Response body: "${body}"`); + // Return the default result with empty flags and enrichment + } + return result; + } +} diff --git a/libs/providers/go-feature-flag/src/lib/service/event-publisher.test.ts b/libs/providers/go-feature-flag/src/lib/service/event-publisher.test.ts new file mode 100644 index 000000000..8925183b9 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/service/event-publisher.test.ts @@ -0,0 +1,303 @@ +import { EventPublisher } from './event-publisher'; +import type { GoFeatureFlagProviderOptions } from '../go-feature-flag-provider-options'; +import { ExporterMetadata, type FeatureEvent, type TrackingEvent } from '../model'; +import { InvalidOptionsException } from '../exception'; +import { mockLogger } from '../testutil/mock-logger'; + +// Mock the GoFeatureFlagApi +jest.mock('./api'); + +describe('EventPublisher', () => { + let eventPublisher: EventPublisher; + let mockApi: any; + let mockOptions: GoFeatureFlagProviderOptions; + + beforeEach(() => { + mockApi = { + sendEventToDataCollector: jest.fn().mockResolvedValue(undefined), + }; + + mockOptions = { + endpoint: 'http://localhost:1031', + dataFlushInterval: 100, + maxPendingEvents: 5, + exporterMetadata: new ExporterMetadata().add('test', 'metadata'), + }; + + eventPublisher = new EventPublisher(mockApi, mockOptions); + }); + + afterEach(async () => { + if (eventPublisher) { + await eventPublisher.stop(); + } + }); + + describe('constructor', () => { + it('should throw error when api is null', () => { + expect(() => new EventPublisher(null as any, mockOptions)).toThrow(InvalidOptionsException); + }); + + it('should throw error when options is null', () => { + expect(() => new EventPublisher(mockApi, null as any)).toThrow(InvalidOptionsException); + }); + + it('should create instance with valid parameters', () => { + expect(eventPublisher).toBeInstanceOf(EventPublisher); + }); + }); + + describe('start', () => { + it('should start the periodic publisher', async () => { + jest.useFakeTimers(); + + await eventPublisher.start(); + + // Add an event to trigger publishing + const mockEvent: FeatureEvent = { + kind: 'feature', + creationDate: Date.now() / 1000, + contextKind: 'user', + key: 'test-flag', + userKey: 'test-user', + default: false, + variation: 'test-variation', + }; + eventPublisher.addEvent(mockEvent); + + // Fast-forward time to trigger the interval + jest.advanceTimersByTime(150); + + expect(mockApi.sendEventToDataCollector).toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('should not start multiple times', async () => { + await eventPublisher.start(); + await eventPublisher.start(); + + // Should only start once + expect(eventPublisher).toBeDefined(); + }); + }); + + describe('stop', () => { + it('should stop the periodic publisher', async () => { + jest.useFakeTimers(); + + await eventPublisher.start(); + await eventPublisher.stop(); + + // Fast-forward time - should not trigger any more calls + jest.advanceTimersByTime(150); + + const callCount = mockApi.sendEventToDataCollector.mock.calls.length; + + // Fast-forward again to ensure no more calls + jest.advanceTimersByTime(150); + + expect(mockApi.sendEventToDataCollector.mock.calls.length).toBe(callCount); + + jest.useRealTimers(); + }); + + it('should publish remaining events when stopping', async () => { + const testPublisher = new EventPublisher(mockApi, mockOptions); + + const mockEvent: FeatureEvent = { + kind: 'feature', + creationDate: Date.now() / 1000, + contextKind: 'user', + key: 'test-flag', + userKey: 'test-user', + default: false, + variation: 'test-variation', + }; + + // Start the publisher first + await testPublisher.start(); + + // Add event and stop + testPublisher.addEvent(mockEvent); + await testPublisher.stop(); + + expect(mockApi.sendEventToDataCollector).toHaveBeenCalledWith([mockEvent], mockOptions.exporterMetadata); + }); + }); + + describe('addEvent', () => { + it('should add event to collection', () => { + const mockEvent: FeatureEvent = { + kind: 'feature', + creationDate: Date.now() / 1000, + contextKind: 'user', + key: 'test-flag', + userKey: 'test-user', + default: false, + variation: 'test-variation', + }; + + eventPublisher.addEvent(mockEvent); + + // We can't directly test the internal state, but we can verify it doesn't throw + expect(() => eventPublisher.addEvent(mockEvent)).not.toThrow(); + }); + + it('should add TrackingEvent to collection', () => { + const mockTrackingEvent: TrackingEvent = { + kind: 'tracking', + creationDate: Date.now() / 1000, + contextKind: 'user', + key: 'test-flag', + userKey: 'test-user', + evaluationContext: { targetingKey: 'test-user' }, + trackingEventDetails: { value: 42, metricName: 'test-metric' }, + }; + + eventPublisher.addEvent(mockTrackingEvent); + + // We can't directly test the internal state, but we can verify it doesn't throw + expect(() => eventPublisher.addEvent(mockTrackingEvent)).not.toThrow(); + }); + + it('should trigger publish when max pending events reached', async () => { + const mockEvent: FeatureEvent = { + kind: 'feature', + creationDate: Date.now() / 1000, + contextKind: 'user', + key: 'test-flag', + userKey: 'test-user', + default: false, + variation: 'test-variation', + }; + + // Add events up to the max pending limit + for (let i = 0; i < 5; i++) { + eventPublisher.addEvent(mockEvent); + } + + // Wait a bit for async operations + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockApi.sendEventToDataCollector).toHaveBeenCalled(); + }); + }); + + describe('publishEvents', () => { + it('should handle API errors gracefully', async () => { + const mockError = new Error('API Error'); + mockApi.sendEventToDataCollector.mockRejectedValueOnce(mockError); + + const mockEvent: FeatureEvent = { + kind: 'feature', + creationDate: Date.now() / 1000, + contextKind: 'user', + key: 'test-flag', + userKey: 'test-user', + default: false, + variation: 'test-variation', + }; + + const publisherWithLogger = new EventPublisher(mockApi, mockOptions, mockLogger); + + // Start the publisher first + await publisherWithLogger.start(); + + // Add event and stop + publisherWithLogger.addEvent(mockEvent); + await publisherWithLogger.stop(); + + expect(mockLogger.error).toHaveBeenCalledWith('An error occurred while publishing events:', mockError); + }); + + it('should not publish when no events are available', async () => { + await eventPublisher.stop(); + + expect(mockApi.sendEventToDataCollector).not.toHaveBeenCalled(); + }); + }); + + describe('default values', () => { + it('should use default flush interval when not provided', async () => { + const optionsWithoutFlushInterval = { + ...mockOptions, + dataFlushInterval: undefined, + }; + + const publisher = new EventPublisher(mockApi, optionsWithoutFlushInterval); + + jest.useFakeTimers(); + await publisher.start(); + + // Add an event to trigger publishing + const mockEvent: FeatureEvent = { + kind: 'feature', + creationDate: Date.now() / 1000, + contextKind: 'user', + key: 'test-flag', + userKey: 'test-user', + default: false, + variation: 'test-variation', + }; + publisher.addEvent(mockEvent); + + // Default should be 10000ms (from constants) + jest.advanceTimersByTime(120010); + + expect(mockApi.sendEventToDataCollector).toHaveBeenCalled(); + + jest.useRealTimers(); + await publisher.stop(); + }); + + it('should use default max pending events when not provided', async () => { + const optionsWithoutMaxPending = { + ...mockOptions, + maxPendingEvents: undefined, + }; + + const publisher = new EventPublisher(mockApi, optionsWithoutMaxPending); + + // Should not throw + expect(publisher).toBeInstanceOf(EventPublisher); + + // Test that default max pending events limit is respected with mixed event types + const mockFeatureEvent: FeatureEvent = { + kind: 'feature', + creationDate: Date.now() / 1000, + contextKind: 'user', + key: 'test-flag', + userKey: 'test-user', + default: false, + variation: 'test-variation', + }; + + const mockTrackingEvent: TrackingEvent = { + kind: 'tracking', + creationDate: Date.now() / 1000, + contextKind: 'user', + key: 'test-flag', + userKey: 'test-user', + evaluationContext: { targetingKey: 'test-user' }, + trackingEventDetails: { metricValue: 42 }, + }; + + // Add mixed events up to the default max pending limit (10000 from constants) + // We'll add 10001 events to trigger the publish + for (let i = 0; i < 10001; i++) { + if (i % 2 === 0) { + publisher.addEvent(mockFeatureEvent); + } else { + publisher.addEvent(mockTrackingEvent); + } + } + + // Wait a bit for async operations + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Should trigger publish when reaching the default limit + expect(mockApi.sendEventToDataCollector).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/providers/go-feature-flag/src/lib/service/event-publisher.ts b/libs/providers/go-feature-flag/src/lib/service/event-publisher.ts new file mode 100644 index 000000000..6c2e0511b --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/service/event-publisher.ts @@ -0,0 +1,120 @@ +import type { GoFeatureFlagProviderOptions } from '../go-feature-flag-provider-options'; +import type { GoFeatureFlagApi } from './api'; +import type { Logger } from '@openfeature/server-sdk'; +import { ExporterMetadata, type ExportEvent } from '../model'; +import { InvalidOptionsException } from '../exception'; +import { DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_MAX_PENDING_EVENTS } from '../helper/constants'; + +/** + * EventPublisher is used to collect events and publish them in batch before they are published. + */ +export class EventPublisher { + /** The API used to communicate with the GO Feature Flag relay proxy. */ + private readonly api: GoFeatureFlagApi; + /** The options for the event publisher. */ + private readonly options: GoFeatureFlagProviderOptions; + /** The events to publish. */ + private readonly events: ExportEvent[] = []; + /** The interval ID for the periodic runner. */ + private intervalId?: NodeJS.Timeout; + /** Whether the event publisher is running. */ + private isRunning = false; + /** The logger to use for logging. */ + private readonly logger?: Logger; + + /** + * Initialize the event publisher with a specified publication interval. + * @param {GoFeatureFlagApi} api - The API used to communicate with the GO Feature Flag relay proxy. + * @param {GoFeatureFlagProviderOptions} options - The options to initialise the provider. + * @throws {InvalidOptionsException} If api or options are null. + */ + constructor(api: GoFeatureFlagApi, options: GoFeatureFlagProviderOptions, logger?: Logger) { + if (!api) { + throw new InvalidOptionsException('API cannot be null'); + } + if (!options) { + throw new InvalidOptionsException('Options cannot be null'); + } + this.api = api; + this.options = options; + this.logger = logger; + } + + /** + * Starts the periodic runner that publishes events. + * @returns {Promise} A promise that resolves when the periodic runner has started. + */ + async start(): Promise { + if (this.isRunning) { + return; + } + this.isRunning = true; + this.runPublisher(); + } + + /** + * Runs the publisher and sets up a periodic runner. + * @returns {Promise} A promise that resolves when the publisher has run. + */ + private async runPublisher(): Promise { + await this.publishEvents(); + if (this.isRunning) { + const flushInterval = this.options.dataFlushInterval || DEFAULT_FLUSH_INTERVAL_MS; + this.intervalId = setTimeout(() => this.runPublisher(), flushInterval); + } + } + + /** + * Stops the periodic runner that publishes events and flushes any remaining events. + * @returns {Promise} A promise that resolves when the periodic runner has stopped and all events are published. + */ + async stop(): Promise { + if (!this.isRunning) { + return; + } + this.isRunning = false; + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = undefined; + } + // Publish any remaining events + await this.publishEvents(); + } + + /** + * Add event for aggregation before publishing. If the max pending events is reached, events are published immediately. + * @param {ExportEvent} eventToAdd - The event to add to the collection. + * @returns {void} + */ + addEvent(eventToAdd: ExportEvent): void { + this.events.push(eventToAdd); + if (this.events.length >= (this.options.maxPendingEvents || DEFAULT_MAX_PENDING_EVENTS)) { + // Fire and forget - don't await to avoid blocking + this.publishEvents().catch((error) => { + this.logger?.error('Error publishing events:', error); + }); + } + } + + /** + * @private + * Publishes the collected events to the GO Feature Flag relay proxy. + * @returns {Promise} A promise that resolves when the events have been published. + */ + private async publishEvents(): Promise { + let eventsToPublish: ExportEvent[] = []; + // Simple thread-safe check and clear + if (this.events.length === 0) { + return; + } + eventsToPublish = [...this.events]; + this.events.length = 0; // Clear the array + try { + await this.api.sendEventToDataCollector(eventsToPublish, this.options.exporterMetadata ?? new ExporterMetadata()); + } catch (error) { + this.logger?.error('An error occurred while publishing events:', error); + // Re-add events to the collection on failure + this.events.push(...eventsToPublish); + } + } +} diff --git a/libs/providers/go-feature-flag/src/lib/test-logger.ts b/libs/providers/go-feature-flag/src/lib/test-logger.ts deleted file mode 100644 index 4d578da76..000000000 --- a/libs/providers/go-feature-flag/src/lib/test-logger.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 { - this.inMemoryLogger['info'].push(args.join(' ')); - } - - debug(...args: unknown[]): void { - this.inMemoryLogger['debug'].push(args.join(' ')); - } - - reset() { - this.inMemoryLogger = { - error: [], - warn: [], - info: [], - debug: [], - }; - } -} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/flag-configuration/change-config-after.json b/libs/providers/go-feature-flag/src/lib/testdata/flag-configuration/change-config-after.json new file mode 100644 index 000000000..31ab2a970 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/flag-configuration/change-config-after.json @@ -0,0 +1,17 @@ +{ + "flags": { + "TEST": { + "variations": { + "disabled": false, + "enabled": true + }, + "defaultRule": { + "variation": "enabled" + }, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + } + } +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/flag-configuration/change-config-before.json b/libs/providers/go-feature-flag/src/lib/testdata/flag-configuration/change-config-before.json new file mode 100644 index 000000000..afbd35018 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/flag-configuration/change-config-before.json @@ -0,0 +1,17 @@ +{ + "flags": { + "TEST": { + "variations": { + "disabled": false, + "enabled": true + }, + "defaultRule": { + "variation": "disabled" + }, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + } + } +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/flag-configuration/default.json b/libs/providers/go-feature-flag/src/lib/testdata/flag-configuration/default.json new file mode 100644 index 000000000..bc855effc --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/flag-configuration/default.json @@ -0,0 +1,288 @@ +{ + "flags": { + "bool_targeting_match": { + "variations": { + "Default": false, + "False": false, + "True": true + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "True" + } + ], + "defaultRule": { + "percentage": { + "False": 0, + "True": 100 + } + }, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + }, + "disabled_bool": { + "variations": { + "Default": false, + "False": false, + "True": true + }, + "defaultRule": { + "percentage": { + "False": 0, + "True": 100 + } + }, + "disable": true, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + }, + "disabled_float": { + "variations": { + "Default": 103.25, + "False": 101.25, + "True": 100.25 + }, + "defaultRule": { + "percentage": { + "False": 0, + "True": 100 + } + }, + "disable": true, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + }, + "disabled_int": { + "variations": { + "Default": 103, + "False": 101, + "True": 100 + }, + "defaultRule": { + "percentage": { + "False": 0, + "True": 100 + } + }, + "disable": true, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + }, + "disabled_interface": { + "variations": { + "Default": { + "test": "default" + }, + "False": { + "test": "false" + }, + "True": { + "test": "test1", + "test2": false, + "test3": 123.3, + "test4": 1 + } + }, + "defaultRule": { + "percentage": { + "False": 0, + "True": 100 + } + }, + "disable": true, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + }, + "disabled_string": { + "variations": { + "Default": "CC0002", + "False": "CC0001", + "True": "CC0000" + }, + "defaultRule": { + "percentage": { + "False": 0, + "True": 100 + } + }, + "disable": true, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + }, + "double_key": { + "variations": { + "Default": 103.25, + "False": 101.25, + "True": 100.25 + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "True" + } + ], + "defaultRule": { + "percentage": { + "False": 0, + "True": 100 + } + }, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + }, + "integer_key": { + "variations": { + "Default": 103, + "False": 101, + "True": 100 + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "True" + } + ], + "defaultRule": { + "percentage": { + "False": 0, + "True": 100 + } + }, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + }, + "object_key": { + "variations": { + "Default": { + "test": "default" + }, + "False": { + "test": "false" + }, + "True": { + "test": "test1", + "test2": false, + "test3": 123.3, + "test4": 1 + } + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "True" + } + ], + "defaultRule": { + "percentage": { + "False": 0, + "True": 100 + } + }, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + }, + "list_key": { + "variations": { + "Default": ["default"], + "False": ["false"], + "True": ["true"] + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "True" + } + ], + "defaultRule": { + "percentage": { + "False": 0, + "True": 100 + } + }, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + }, + "string_key": { + "variations": { + "Default": "CC0002", + "False": "CC0001", + "True": "CC0000" + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "True" + } + ], + "defaultRule": { + "percentage": { + "False": 0, + "True": 100 + } + }, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + }, + "string_key_with_version": { + "variations": { + "Default": "CC0002", + "False": "CC0001", + "True": "CC0000" + }, + "targeting": [ + { + "query": "email eq \"john.doe@gofeatureflag.org\"", + "variation": "True" + } + ], + "defaultRule": { + "percentage": { + "False": 0, + "True": 100 + } + }, + "metadata": { + "description": "this is a test", + "pr_link": "https://github.com/thomaspoignant/go-feature-flag/pull/916" + } + }, + "flag-use-evaluation-context-enrichment": { + "variations": { + "A": "A", + "B": "B" + }, + "targeting": [ + { + "query": "environment eq \"integration-test\"", + "variation": "A" + } + ], + "defaultRule": { + "variation": "B" + } + } + } +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/flag-configuration/scheduled-rollout.json b/libs/providers/go-feature-flag/src/lib/testdata/flag-configuration/scheduled-rollout.json new file mode 100644 index 000000000..d93262364 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/flag-configuration/scheduled-rollout.json @@ -0,0 +1,58 @@ +{ + "flags": { + "my-flag": { + "variations": { + "disabled": false, + "enabled": true + }, + "defaultRule": { + "percentage": { + "enabled": 0, + "disabled": 100 + } + }, + "metadata": { + "description": "this is a test flag", + "defaultValue": false + }, + "scheduledRollout": [ + { + "targeting": [ + { + "query": "targetingKey eq \"d45e303a-38c2-11ed-a261-0242ac120002\"", + "variation": "enabled" + } + ], + "date": "2022-07-31T22:00:00.100Z" + } + ] + }, + "my-flag-scheduled-in-future": { + "variations": { + "disabled": false, + "enabled": true + }, + "defaultRule": { + "percentage": { + "enabled": 0, + "disabled": 100 + } + }, + "metadata": { + "description": "this is a test flag", + "defaultValue": false + }, + "scheduledRollout": [ + { + "targeting": [ + { + "query": "targetingKey eq \"d45e303a-38c2-11ed-a261-0242ac120002\"", + "variation": "enabled" + } + ], + "date": "3022-07-31T22:00:00.100Z" + } + ] + } + } +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/README.md b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/README.md new file mode 100644 index 000000000..142dedc6e --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/README.md @@ -0,0 +1,35 @@ +# OFREP Response Files + +This directory contains JSON response files for OFREP (OpenFeature Remote Evaluation Protocol) flag evaluations. + +## File Structure + +Each file is named after the flag key and contains the expected response for that flag evaluation. + +### Error Responses + +- `fail_500.json` - Internal Server Error (500) +- `api_key_missing.json` - API Key Missing (400) +- `invalid_api_key.json` - Invalid API Key (401) +- `flag_not_found.json` - Flag Not Found (404) + +### Flag Evaluation Responses + +- `bool_targeting_match.json` - Boolean flag with targeting match +- `disabled.json` - Disabled boolean flag +- `disabled_double.json` - Disabled double/float flag +- `disabled_integer.json` - Disabled integer flag +- `disabled_object.json` - Disabled object flag +- `disabled_string.json` - Disabled string flag +- `double_key.json` - Double/float flag with targeting match +- `integer_key.json` - Integer flag with targeting match +- `list_key.json` - List flag with targeting match +- `object_key.json` - Object flag with targeting match +- `string_key.json` - String flag with targeting match +- `unknown_reason.json` - Flag with custom reason +- `does_not_exists.json` - Flag that doesn't exist in configuration +- `integer_with_metadata.json` - Integer flag with metadata + +## Usage + +The mock automatically loads these files when handling OFREP evaluation requests. If a flag key doesn't have a corresponding file, it returns a "Flag not found" error. diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/api_key_missing.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/api_key_missing.json new file mode 100644 index 000000000..86283f8aa --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/api_key_missing.json @@ -0,0 +1,3 @@ +{ + "error": "API Key is missing" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/bool_targeting_match.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/bool_targeting_match.json new file mode 100644 index 000000000..dd436a53b --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/bool_targeting_match.json @@ -0,0 +1,6 @@ +{ + "value": true, + "key": "bool_targeting_match", + "reason": "TARGETING_MATCH", + "variant": "True" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled.json new file mode 100644 index 000000000..e7d8c06e3 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled.json @@ -0,0 +1,6 @@ +{ + "value": false, + "key": "disabled", + "reason": "DISABLED", + "variant": "defaultSdk" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled_double.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled_double.json new file mode 100644 index 000000000..4607fde1b --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled_double.json @@ -0,0 +1,6 @@ +{ + "value": 100.25, + "key": "disabled_double", + "reason": "DISABLED", + "variant": "defaultSdk" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled_integer.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled_integer.json new file mode 100644 index 000000000..e198fb2c3 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled_integer.json @@ -0,0 +1,6 @@ +{ + "value": 100, + "key": "disabled_integer", + "reason": "DISABLED", + "variant": "defaultSdk" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled_object.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled_object.json new file mode 100644 index 000000000..d7d9737a2 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled_object.json @@ -0,0 +1,6 @@ +{ + "value": null, + "key": "disabled_object", + "reason": "DISABLED", + "variant": "defaultSdk" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled_string.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled_string.json new file mode 100644 index 000000000..0477aff3c --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/disabled_string.json @@ -0,0 +1,6 @@ +{ + "value": "", + "key": "disabled_string", + "reason": "DISABLED", + "variant": "defaultSdk" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/does_not_exists.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/does_not_exists.json new file mode 100644 index 000000000..189996ceb --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/does_not_exists.json @@ -0,0 +1,7 @@ +{ + "value": "", + "key": "does_not_exists", + "errorCode": "FLAG_NOT_FOUND", + "variant": "defaultSdk", + "errorDetails": "flag does_not_exists was not found in your configuration" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/double_key.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/double_key.json new file mode 100644 index 000000000..fe6c90704 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/double_key.json @@ -0,0 +1,6 @@ +{ + "value": 100.25, + "key": "double_key", + "reason": "TARGETING_MATCH", + "variant": "True" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/fail_500.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/fail_500.json new file mode 100644 index 000000000..4d9c2a2e6 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/fail_500.json @@ -0,0 +1,3 @@ +{ + "error": "Internal Server Error" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/flag_not_found.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/flag_not_found.json new file mode 100644 index 000000000..56d87b133 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/flag_not_found.json @@ -0,0 +1,3 @@ +{ + "error": "Flag not found" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/integer_key.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/integer_key.json new file mode 100644 index 000000000..9f79203c4 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/integer_key.json @@ -0,0 +1,6 @@ +{ + "value": 100, + "key": "integer_key", + "reason": "TARGETING_MATCH", + "variant": "True" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/integer_with_metadata.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/integer_with_metadata.json new file mode 100644 index 000000000..d2211ec66 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/integer_with_metadata.json @@ -0,0 +1,12 @@ +{ + "value": 100, + "key": "integer_key", + "reason": "TARGETING_MATCH", + "variant": "True", + "metadata": { + "key1": "key1", + "key2": 1, + "key3": 1.345, + "key4": true + } +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/invalid_api_key.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/invalid_api_key.json new file mode 100644 index 000000000..28ecde0af --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/invalid_api_key.json @@ -0,0 +1,3 @@ +{ + "error": "Invalid API Key" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/list_key.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/list_key.json new file mode 100644 index 000000000..5c8e9e346 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/list_key.json @@ -0,0 +1,6 @@ +{ + "value": ["test", "test1", "test2", "false", "test3"], + "key": "list_key", + "reason": "TARGETING_MATCH", + "variant": "True" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/object_key.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/object_key.json new file mode 100644 index 000000000..b1da6bec2 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/object_key.json @@ -0,0 +1,12 @@ +{ + "value": { + "test": "test1", + "test2": false, + "test3": 123.3, + "test4": 1, + "test5": null + }, + "key": "object_key", + "reason": "TARGETING_MATCH", + "variant": "True" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/string_key.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/string_key.json new file mode 100644 index 000000000..a56910ec2 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/string_key.json @@ -0,0 +1,6 @@ +{ + "value": "CC0000", + "key": "string_key", + "reason": "TARGETING_MATCH", + "variant": "True" +} diff --git a/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/unknown_reason.json b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/unknown_reason.json new file mode 100644 index 000000000..055a30a4e --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testdata/ofrep-response/unknown_reason.json @@ -0,0 +1,6 @@ +{ + "value": "true", + "key": "unknown_reason", + "reason": "CUSTOM_REASON", + "variant": "True" +} diff --git a/libs/providers/go-feature-flag/src/lib/testutil/mock-fetch.ts b/libs/providers/go-feature-flag/src/lib/testutil/mock-fetch.ts new file mode 100644 index 000000000..7405142d8 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testutil/mock-fetch.ts @@ -0,0 +1,99 @@ +// Mock Response class +export class MockResponse { + public status: number; + public headers: Headers; + public body: string; + public ok: boolean; + + constructor(status: number, body = '', headers: Record = {}) { + this.status = status; + this.body = body; + this.headers = new Headers(headers); + this.ok = status >= 200 && status < 300; + } + + async text(): Promise { + return this.body; + } + + async json(): Promise { + return JSON.parse(this.body); + } +} + +// Mock fetch implementation +export class MockFetch { + private responses: Map = new Map(); + private lastRequest?: { + url: string; + options: RequestInit; + }; + + setResponse(url: string, response: MockResponse): void { + this.responses.set(url, response); + } + + setResponseByStatus(status: string, response: MockResponse): void { + this.responses.set(status, response); + } + + getLastRequest() { + return this.lastRequest; + } + + reset(): void { + this.responses.clear(); + this.lastRequest = undefined; + } + + async fetch(url: string, options: RequestInit = {}): Promise { + this.lastRequest = { url, options }; + + // Handle AbortSignal for timeout tests + if (options.signal) { + const signal = options.signal as AbortSignal; + if (signal.aborted) { + throw new Error('Request aborted'); + } + + // For timeout tests, we'll simulate a delay and then check if aborted + if (url.includes('timeout')) { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (signal.aborted) { + throw new Error('Request aborted'); + } + } + } + + // Check if we have a specific response for this URL + if (this.responses.has(url)) { + const response = this.responses.get(url); + if (typeof response === 'function') { + // Allow for dynamic response functions (for advanced mocking) + return (response as any)(url, options) as Response; + } + return response as unknown as Response; + } + + // Check if we have a response by status code + const statusMatch = url.match(/(\d{3})/); + if (statusMatch && this.responses.has(statusMatch[1])) { + const response = this.responses.get(statusMatch[1]); + if (typeof response === 'function') { + // Allow for dynamic response functions (for advanced mocking) + return (response as any)(url, options) as Response; + } + return response as unknown as Response; + } + + // Check if we have a response by status code in the responses map + for (const [key, response] of this.responses.entries()) { + if (key.match(/^\d{3}$/) && response.status === parseInt(key)) { + return response as unknown as Response; + } + } + + // Default response + return new MockResponse(200, '{}') as unknown as Response; + } +} diff --git a/libs/providers/go-feature-flag/src/lib/testutil/mock-logger.ts b/libs/providers/go-feature-flag/src/lib/testutil/mock-logger.ts new file mode 100644 index 000000000..7f7ce5484 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testutil/mock-logger.ts @@ -0,0 +1,10 @@ +import { jest } from '@jest/globals'; +import type { Logger } from '@openfeature/server-sdk'; + +// Mock logger to capture error calls +export const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +} as Logger; diff --git a/libs/providers/go-feature-flag/src/lib/testutil/ofrep-provider-mock.ts b/libs/providers/go-feature-flag/src/lib/testutil/ofrep-provider-mock.ts new file mode 100644 index 000000000..5db55c3ce --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/testutil/ofrep-provider-mock.ts @@ -0,0 +1,81 @@ +import type { EvaluationContext, ResolutionDetails } from '@openfeature/server-sdk'; +import type { JsonValue } from '@openfeature/server-sdk'; + +export class OfrepProviderMock { + lastEvaluationContext?: EvaluationContext; + + async evaluateString( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + ): Promise> { + this.lastEvaluationContext = context; + return { + value: 'this is a test value', + reason: 'TARGETING_MATCH', + variant: 'enabled', + errorCode: undefined, + errorMessage: undefined, + }; + } + + async evaluateBoolean( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + ): Promise> { + this.lastEvaluationContext = context; + return { + value: true, + reason: 'TARGETING_MATCH', + variant: 'enabled', + errorCode: undefined, + errorMessage: undefined, + }; + } + + async evaluateNumber( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + ): Promise> { + this.lastEvaluationContext = context; + return { + value: 12.21, + reason: 'TARGETING_MATCH', + variant: 'enabled', + errorCode: undefined, + errorMessage: undefined, + }; + } + + async evaluateInteger( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + ): Promise> { + this.lastEvaluationContext = context; + return { + value: 12, + reason: 'TARGETING_MATCH', + variant: 'enabled', + errorCode: undefined, + errorMessage: undefined, + }; + } + + async evaluateObject( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + ): Promise> { + this.lastEvaluationContext = context; + return { + value: 'this is a test value' as T, + reason: 'TARGETING_MATCH', + variant: 'enabled', + errorCode: undefined, + errorMessage: undefined, + }; + } +} diff --git a/libs/providers/go-feature-flag/src/lib/wasm/evaluate-wasm.ts b/libs/providers/go-feature-flag/src/lib/wasm/evaluate-wasm.ts new file mode 100644 index 000000000..eb47591e0 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/wasm/evaluate-wasm.ts @@ -0,0 +1,276 @@ +import type { EvaluationResponse } from '../model/evaluation-response'; +import type { WasmInput } from '../model/wasm-input'; +import { WasmNotLoadedException, WasmFunctionNotFoundException, WasmInvalidResultException } from '../exception'; +import './wasm_exec.js'; +import type { Logger } from '@openfeature/server-sdk'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * EvaluationWasm is a class that represents the evaluation of a feature flag + * it calls an external WASM module to evaluate the feature flag. + */ +export class EvaluateWasm { + private wasmInstance: WebAssembly.Instance | null = null; + private wasmMemory: WebAssembly.Memory | null = null; + private wasmExports: WebAssembly.Exports | null = null; + private go: Go; + private logger?: Logger; + + /** + * Constructor of the EvaluationWasm. It initializes the WASM module and the host functions. + */ + constructor(logger?: Logger) { + this.logger = logger; + this.go = new Go(); + } + + /** + * Initializes the WASM module. + * In a real implementation, this would load the WASM binary and instantiate it. + */ + public async initialize(): Promise { + try { + // Load the WASM binary + const wasmBuffer = await this.loadWasmBinary(); + + // Instantiate the WebAssembly module + const wasm = await WebAssembly.instantiate(wasmBuffer, this.go.importObject); + + // Run the Go runtime + this.go.run(wasm.instance); + + // Store the instance and exports + this.wasmInstance = wasm.instance; + this.wasmExports = wasm.instance.exports; + + // Get the required exports + this.wasmMemory = this.wasmExports['memory'] as WebAssembly.Memory; + + // Verify required functions exist + if (!this.wasmExports['malloc'] || !this.wasmExports['free'] || !this.wasmExports['evaluate']) { + throw new WasmFunctionNotFoundException('Required WASM functions not found'); + } + } catch (error) { + if (error instanceof WasmNotLoadedException || error instanceof WasmFunctionNotFoundException) { + throw error; + } + throw new WasmNotLoadedException( + `Failed to load WASM module: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + public async dispose(): Promise { + try { + // Clean up WASM memory and resources + if (this.wasmExports && this.wasmExports['free']) { + // If there are any remaining allocated pointers, free them + // This is a safety measure in case some memory wasn't freed during evaluation + this.wasmExports['free'] as (ptr: number) => void; + // Note: We can't track all allocated pointers easily, so this is mainly for cleanup + } + this.wasmMemory = null; + this.wasmExports = null; + this.wasmInstance = null; + if (this.go && typeof this.go.exit === 'function') { + try { + this.go.exit(0); + } catch (error) { + // Ignore errors during Go runtime cleanup + } + } + } catch (error) { + this.logger?.warn('Error during WASM disposal:', error); + } + } + + /** + * Loads the WASM binary file. + * @returns Promise - The WASM binary data + */ + private async loadWasmBinary(): Promise { + try { + // Construct the path to the WASM file relative to the current module + const wasmPath = path.join(__dirname, 'wasm-module', 'gofeatureflag-evaluation.wasm'); + + // Read the file as a buffer and convert to ArrayBuffer + const buffer = fs.readFileSync(wasmPath); + const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + return arrayBuffer as ArrayBuffer; + } catch (error) { + throw new WasmNotLoadedException( + `Failed to load WASM binary: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + /** + * Evaluates a feature flag using the WASM module. + * @param wasmInput - The input data for the evaluation + * @returns A Promise - A ResolutionDetails of the feature flag + * @throws WasmInvalidResultException - If for any reasons we have an issue calling the wasm module. + */ + public async evaluate(wasmInput: WasmInput): Promise { + try { + // Ensure WASM is initialized + if (!this.wasmExports || !this.wasmMemory) { + await this.initialize(); + } + + // Serialize the input to JSON + const wasmInputAsStr = JSON.stringify(wasmInput); + + // Copy input to WASM memory + const inputPtr = this.copyToMemory(wasmInputAsStr); + + try { + // Call the WASM evaluate function + const evaluateRes = this.callWasmEvaluate(inputPtr, wasmInputAsStr.length); + + // Read the result from WASM memory + const resAsString = this.readFromMemory(evaluateRes); + + // Deserialize the response + const goffResp = JSON.parse(resAsString) as EvaluationResponse; + + if (!goffResp) { + throw new WasmInvalidResultException('Deserialization of EvaluationResponse failed.'); + } + return goffResp; + } finally { + // Free the allocated memory + if (inputPtr !== 0) { + this.callWasmFree(inputPtr); + } + } + } catch (error) { + // Return error response if WASM evaluation fails + return { + errorCode: 'GENERAL', + reason: 'ERROR', + errorDetails: error instanceof Error ? error.message : 'Unknown error', + } as EvaluationResponse; + } + } + + /** + * Calls the WASM evaluate function. + * @param inputPtr - Pointer to the input string in WASM memory + * @param inputLength - Length of the input string + * @returns The result from the WASM evaluate function + */ + private callWasmEvaluate(inputPtr: number, inputLength: number): bigint { + if (!this.wasmExports) { + throw new WasmFunctionNotFoundException('evaluate'); + } + + const evaluateFunction = this.wasmExports['evaluate'] as (ptr: number, length: number) => bigint; + if (!evaluateFunction) { + throw new WasmFunctionNotFoundException('evaluate'); + } + + const result = evaluateFunction(inputPtr, inputLength); + if (typeof result !== 'bigint') { + throw new WasmInvalidResultException('Evaluate should return a bigint value.'); + } + + return result; + } + + /** + * Calls the WASM free function. + * @param ptr - Pointer to free in WASM memory + */ + private callWasmFree(ptr: number): void { + if (!this.wasmExports) { + throw new WasmFunctionNotFoundException('free'); + } + + const freeFunction = this.wasmExports['free'] as (ptr: number) => void; + if (!freeFunction) { + throw new WasmFunctionNotFoundException('free'); + } + + freeFunction(ptr); + } + + /** + * Copies the input string to the WASM memory and returns the pointer to the memory location. + * @param inputString - string to put in memory + * @returns the address location of this string + * @throws WasmInvalidResultException - If for any reasons we have an issue calling the wasm module. + */ + private copyToMemory(inputString: string): number { + if (!this.wasmExports) { + throw new WasmFunctionNotFoundException('malloc'); + } + + // Allocate memory in the Wasm module for the input string. + const mallocFunction = this.wasmExports['malloc'] as (size: number) => number; + if (!mallocFunction) { + throw new WasmFunctionNotFoundException('malloc'); + } + + const ptr = mallocFunction(inputString.length + 1); + if (typeof ptr !== 'number') { + throw new WasmInvalidResultException('Malloc should return a number value.'); + } + + // Write the string to WASM memory + this.writeStringToMemory(ptr, inputString); + return ptr; + } + + /** + * Writes a string to WASM memory. + * @param ptr - Pointer to write to + * @param str - String to write + */ + private writeStringToMemory(ptr: number, str: string): void { + if (!this.wasmMemory) { + throw new WasmInvalidResultException('WASM memory not available.'); + } + + const buffer = new Uint8Array(this.wasmMemory.buffer); + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + + for (let i = 0; i < bytes.length; i++) { + buffer[ptr + i] = bytes[i]; + } + buffer[ptr + bytes.length] = 0; // Null terminator + } + + /** + * Reads the output string from the WASM memory based on the result of the evaluation. + * @param evaluateRes - result of the evaluate function + * @returns A string containing the output of the evaluate function + * @throws WasmInvalidResultException - If for any reasons we have an issue calling the wasm module. + */ + private readFromMemory(evaluateRes: bigint): string { + // In the .NET implementation, the result is packed as: + // Higher 32 bits for pointer, lower 32 bits for length + const MASK = BigInt(2 ** 32) - BigInt(1); + const ptr = Number(evaluateRes >> BigInt(32)) & 0xffffffff; // Higher 32 bits for a pointer + const outputStringLength = Number(evaluateRes & MASK); // Lower 32 bits for length + + if (ptr === 0 || outputStringLength === 0) { + throw new WasmInvalidResultException('Output string pointer or length is invalid.'); + } + + if (!this.wasmMemory) { + throw new WasmInvalidResultException('WASM memory not available.'); + } + + const buffer = new Uint8Array(this.wasmMemory.buffer); + const bytes = new Uint8Array(outputStringLength); + + for (let i = 0; i < outputStringLength; i++) { + bytes[i] = buffer[ptr + i]; + } + + const decoder = new TextDecoder(); + return decoder.decode(bytes); + } +} diff --git a/libs/providers/go-feature-flag/src/lib/wasm/wasm-module/.gitignore b/libs/providers/go-feature-flag/src/lib/wasm/wasm-module/.gitignore new file mode 100644 index 000000000..746cba5df --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/wasm/wasm-module/.gitignore @@ -0,0 +1 @@ +gofeatureflag-evaluation.wasm \ No newline at end of file diff --git a/libs/providers/go-feature-flag/src/lib/wasm/wasm-module/.gitkeep b/libs/providers/go-feature-flag/src/lib/wasm/wasm-module/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/libs/providers/go-feature-flag/src/lib/wasm/wasm_exec.d.ts b/libs/providers/go-feature-flag/src/lib/wasm/wasm_exec.d.ts new file mode 100644 index 000000000..482a16647 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/wasm/wasm_exec.d.ts @@ -0,0 +1,9 @@ +declare class Go { + argv: string[]; + env: { [envKey: string]: string }; + exit: (code: number) => void; + importObject: WebAssembly.Imports; + exited: boolean; + mem: DataView; + run(instance: WebAssembly.Instance): Promise; +} diff --git a/libs/providers/go-feature-flag/src/lib/wasm/wasm_exec.js b/libs/providers/go-feature-flag/src/lib/wasm/wasm_exec.js new file mode 100644 index 000000000..5f02cc2e3 --- /dev/null +++ b/libs/providers/go-feature-flag/src/lib/wasm/wasm_exec.js @@ -0,0 +1,619 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// This file has been modified for use by the TinyGo compiler. + +(() => { + // Map multiple JavaScript environments to a single common API, + // preferring web standards over Node.js API. + // + // Environments considered: + // - Browsers + // - Node.js + // - Electron + // - Parcel + + if (typeof global !== 'undefined') { + // global already exists + } else if (typeof window !== 'undefined') { + window.global = window; + } else if (typeof self !== 'undefined') { + self.global = self; + } else { + throw new Error('cannot export Go (neither global, window nor self is defined)'); + } + + if (!global.require && typeof require !== 'undefined') { + global.require = require; + } + + if (!global.fs && global.require) { + global.fs = require('node:fs'); + } + + const enosys = () => { + const err = new Error('not implemented'); + err.code = 'ENOSYS'; + return err; + }; + + if (!global.fs) { + let outputBuf = ''; + global.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf('\n'); + if (nl != -1) { + console.log(outputBuf.substr(0, nl)); + outputBuf = outputBuf.substr(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { + callback(enosys()); + }, + chown(path, uid, gid, callback) { + callback(enosys()); + }, + close(fd, callback) { + callback(enosys()); + }, + fchmod(fd, mode, callback) { + callback(enosys()); + }, + fchown(fd, uid, gid, callback) { + callback(enosys()); + }, + fstat(fd, callback) { + callback(enosys()); + }, + fsync(fd, callback) { + callback(null); + }, + ftruncate(fd, length, callback) { + callback(enosys()); + }, + lchown(path, uid, gid, callback) { + callback(enosys()); + }, + link(path, link, callback) { + callback(enosys()); + }, + lstat(path, callback) { + callback(enosys()); + }, + mkdir(path, perm, callback) { + callback(enosys()); + }, + open(path, flags, mode, callback) { + callback(enosys()); + }, + read(fd, buffer, offset, length, position, callback) { + callback(enosys()); + }, + readdir(path, callback) { + callback(enosys()); + }, + readlink(path, callback) { + callback(enosys()); + }, + rename(from, to, callback) { + callback(enosys()); + }, + rmdir(path, callback) { + callback(enosys()); + }, + stat(path, callback) { + callback(enosys()); + }, + symlink(path, link, callback) { + callback(enosys()); + }, + truncate(path, length, callback) { + callback(enosys()); + }, + unlink(path, callback) { + callback(enosys()); + }, + utimes(path, atime, mtime, callback) { + callback(enosys()); + }, + }; + } + + if (!global.process) { + global.process = { + getuid() { + return -1; + }, + getgid() { + return -1; + }, + geteuid() { + return -1; + }, + getegid() { + return -1; + }, + getgroups() { + throw enosys(); + }, + pid: -1, + ppid: -1, + umask() { + throw enosys(); + }, + cwd() { + throw enosys(); + }, + chdir() { + throw enosys(); + }, + }; + } + + if (!global.crypto) { + const nodeCrypto = require('node:crypto'); + global.crypto = { + getRandomValues(b) { + nodeCrypto.randomFillSync(b); + }, + }; + } + + if (!global.performance) { + global.performance = { + now() { + const [sec, nsec] = process.hrtime(); + return sec * 1000 + nsec / 1000000; + }, + }; + } + + if (!global.TextEncoder) { + global.TextEncoder = require('node:util').TextEncoder; + } + + if (!global.TextDecoder) { + global.TextDecoder = require('node:util').TextDecoder; + } + + // End of polyfills for common API. + + const encoder = new TextEncoder('utf-8'); + const decoder = new TextDecoder('utf-8'); + let reinterpretBuf = new DataView(new ArrayBuffer(8)); + var logLine = []; + const wasmExit = {}; // thrown to exit via proc_exit (not an error) + + global.Go = class { + constructor() { + this._callbackTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const mem = () => { + // The buffer may change when requesting more memory. + return new DataView(this._inst.exports.memory.buffer); + }; + + const unboxValue = (v_ref) => { + reinterpretBuf.setBigInt64(0, v_ref, true); + const f = reinterpretBuf.getFloat64(0, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = v_ref & 0xffffffffn; + return this._values[id]; + }; + + const loadValue = (addr) => { + let v_ref = mem().getBigUint64(addr, true); + return unboxValue(v_ref); + }; + + const boxValue = (v) => { + const nanHead = 0x7ff80000n; + + if (typeof v === 'number') { + if (isNaN(v)) { + return nanHead << 32n; + } + if (v === 0) { + return (nanHead << 32n) | 1n; + } + reinterpretBuf.setFloat64(0, v, true); + return reinterpretBuf.getBigInt64(0, true); + } + + switch (v) { + case undefined: + return 0n; + case null: + return (nanHead << 32n) | 2n; + case true: + return (nanHead << 32n) | 3n; + case false: + return (nanHead << 32n) | 4n; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = BigInt(this._values.length); + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 1n; + switch (typeof v) { + case 'string': + typeFlag = 2n; + break; + case 'symbol': + typeFlag = 3n; + break; + case 'function': + typeFlag = 4n; + break; + } + return id | ((nanHead | typeFlag) << 32n); + }; + + const storeValue = (addr, v) => { + let v_ref = boxValue(v); + mem().setBigUint64(addr, v_ref, true); + }; + + const loadSlice = (array, len, cap) => { + return new Uint8Array(this._inst.exports.memory.buffer, array, len); + }; + + const loadSliceOfValues = (array, len, cap) => { + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + }; + + const loadString = (ptr, len) => { + return decoder.decode(new DataView(this._inst.exports.memory.buffer, ptr, len)); + }; + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + wasi_snapshot_preview1: { + // https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#fd_write + fd_write: function (fd, iovs_ptr, iovs_len, nwritten_ptr) { + let nwritten = 0; + if (fd == 1) { + for (let iovs_i = 0; iovs_i < iovs_len; iovs_i++) { + let iov_ptr = iovs_ptr + iovs_i * 8; // assuming wasm32 + let ptr = mem().getUint32(iov_ptr + 0, true); + let len = mem().getUint32(iov_ptr + 4, true); + nwritten += len; + for (let i = 0; i < len; i++) { + let c = mem().getUint8(ptr + i); + if (c == 13) { + // CR + // ignore + } else if (c == 10) { + // LF + // write line + let line = decoder.decode(new Uint8Array(logLine)); + logLine = []; + console.log(line); + } else { + logLine.push(c); + } + } + } + } else { + console.error('invalid file descriptor:', fd); + } + mem().setUint32(nwritten_ptr, nwritten, true); + return 0; + }, + fd_close: () => 0, // dummy + fd_fdstat_get: () => 0, // dummy + fd_seek: () => 0, // dummy + proc_exit: (code) => { + this.exited = true; + this.exitCode = code; + this._resolveExitPromise(); + throw wasmExit; + }, + random_get: (bufPtr, bufLen) => { + crypto.getRandomValues(loadSlice(bufPtr, bufLen)); + return 0; + }, + }, + gojs: { + // func ticks() float64 + 'runtime.ticks': () => { + return timeOrigin + performance.now(); + }, + + // func sleepTicks(timeout float64) + 'runtime.sleepTicks': (timeout) => { + // Do not sleep, only reactivate scheduler after the given timeout. + setTimeout(() => { + if (this.exited) return; + try { + this._inst.exports.go_scheduler(); + } catch (e) { + if (e !== wasmExit) throw e; + } + }, timeout); + }, + + // func finalizeRef(v ref) + 'syscall/js.finalizeRef': (v_ref) => { + // Note: TinyGo does not support finalizers so this is only called + // for one specific case, by js.go:jsString. and can/might leak memory. + const id = v_ref & 0xffffffffn; + if (this._goRefCounts?.[id] !== undefined) { + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + } else { + console.error('syscall/js.finalizeRef: unknown id', id); + } + }, + + // func stringVal(value string) ref + 'syscall/js.stringVal': (value_ptr, value_len) => { + value_ptr >>>= 0; + const s = loadString(value_ptr, value_len); + return boxValue(s); + }, + + // func valueGet(v ref, p string) ref + 'syscall/js.valueGet': (v_ref, p_ptr, p_len) => { + let prop = loadString(p_ptr, p_len); + let v = unboxValue(v_ref); + let result = Reflect.get(v, prop); + return boxValue(result); + }, + + // func valueSet(v ref, p string, x ref) + 'syscall/js.valueSet': (v_ref, p_ptr, p_len, x_ref) => { + const v = unboxValue(v_ref); + const p = loadString(p_ptr, p_len); + const x = unboxValue(x_ref); + Reflect.set(v, p, x); + }, + + // func valueDelete(v ref, p string) + 'syscall/js.valueDelete': (v_ref, p_ptr, p_len) => { + const v = unboxValue(v_ref); + const p = loadString(p_ptr, p_len); + Reflect.deleteProperty(v, p); + }, + + // func valueIndex(v ref, i int) ref + 'syscall/js.valueIndex': (v_ref, i) => { + return boxValue(Reflect.get(unboxValue(v_ref), i)); + }, + + // valueSetIndex(v ref, i int, x ref) + 'syscall/js.valueSetIndex': (v_ref, i, x_ref) => { + Reflect.set(unboxValue(v_ref), i, unboxValue(x_ref)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + 'syscall/js.valueCall': (ret_addr, v_ref, m_ptr, m_len, args_ptr, args_len, args_cap) => { + const v = unboxValue(v_ref); + const name = loadString(m_ptr, m_len); + const args = loadSliceOfValues(args_ptr, args_len, args_cap); + try { + const m = Reflect.get(v, name); + storeValue(ret_addr, Reflect.apply(m, v, args)); + mem().setUint8(ret_addr + 8, 1); + } catch (err) { + storeValue(ret_addr, err); + mem().setUint8(ret_addr + 8, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + 'syscall/js.valueInvoke': (ret_addr, v_ref, args_ptr, args_len, args_cap) => { + try { + const v = unboxValue(v_ref); + const args = loadSliceOfValues(args_ptr, args_len, args_cap); + storeValue(ret_addr, Reflect.apply(v, undefined, args)); + mem().setUint8(ret_addr + 8, 1); + } catch (err) { + storeValue(ret_addr, err); + mem().setUint8(ret_addr + 8, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + 'syscall/js.valueNew': (ret_addr, v_ref, args_ptr, args_len, args_cap) => { + const v = unboxValue(v_ref); + const args = loadSliceOfValues(args_ptr, args_len, args_cap); + try { + storeValue(ret_addr, Reflect.construct(v, args)); + mem().setUint8(ret_addr + 8, 1); + } catch (err) { + storeValue(ret_addr, err); + mem().setUint8(ret_addr + 8, 0); + } + }, + + // func valueLength(v ref) int + 'syscall/js.valueLength': (v_ref) => { + return unboxValue(v_ref).length; + }, + + // valuePrepareString(v ref) (ref, int) + 'syscall/js.valuePrepareString': (ret_addr, v_ref) => { + const s = String(unboxValue(v_ref)); + const str = encoder.encode(s); + storeValue(ret_addr, str); + mem().setInt32(ret_addr + 8, str.length, true); + }, + + // valueLoadString(v ref, b []byte) + 'syscall/js.valueLoadString': (v_ref, slice_ptr, slice_len, slice_cap) => { + const str = unboxValue(v_ref); + loadSlice(slice_ptr, slice_len, slice_cap).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + 'syscall/js.valueInstanceOf': (v_ref, t_ref) => { + return unboxValue(v_ref) instanceof unboxValue(t_ref); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + 'syscall/js.copyBytesToGo': (ret_addr, dest_addr, dest_len, dest_cap, src_ref) => { + let num_bytes_copied_addr = ret_addr; + let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable + + const dst = loadSlice(dest_addr, dest_len); + const src = unboxValue(src_ref); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + mem().setUint8(returned_status_addr, 0); // Return "not ok" status + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + mem().setUint32(num_bytes_copied_addr, toCopy.length, true); + mem().setUint8(returned_status_addr, 1); // Return "ok" status + }, + + // copyBytesToJS(dst ref, src []byte) (int, bool) + // Originally copied from upstream Go project, then modified: + // https://github.com/golang/go/blob/3f995c3f3b43033013013e6c7ccc93a9b1411ca9/misc/wasm/wasm_exec.js#L404-L416 + 'syscall/js.copyBytesToJS': (ret_addr, dst_ref, src_addr, src_len, src_cap) => { + let num_bytes_copied_addr = ret_addr; + let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable + + const dst = unboxValue(dst_ref); + const src = loadSlice(src_addr, src_len); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + mem().setUint8(returned_status_addr, 0); // Return "not ok" status + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + mem().setUint32(num_bytes_copied_addr, toCopy.length, true); + mem().setUint8(returned_status_addr, 1); // Return "ok" status + }, + }, + }; + + // Go 1.20 uses 'env'. Go 1.21 uses 'gojs'. + // For compatibility, we use both as long as Go 1.20 is supported. + this.importObject.env = this.importObject.gojs; + } + + async run(instance) { + this._inst = instance; + this._values = [ + // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + global, + this, + ]; + this._goRefCounts = []; // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map(); // mapping from JS values to reference ids + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + this.exitCode = 0; + + if (this._inst.exports._start) { + let exitPromise = new Promise((resolve, reject) => { + this._resolveExitPromise = resolve; + }); + + // Run program, but catch the wasmExit exception that's thrown + // to return back here. + try { + this._inst.exports._start(); + } catch (e) { + if (e !== wasmExit) throw e; + } + + await exitPromise; + return this.exitCode; + } else { + this._inst.exports._initialize(); + } + } + + _resume() { + if (this.exited) { + throw new Error('Go program has already exited'); + } + try { + this._inst.exports.resume(); + } catch (e) { + if (e !== wasmExit) throw e; + } + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + }; + + if ( + global.require && + global.require.main === module && + global.process && + global.process.versions && + !global.process.versions.electron + ) { + if (process.argv.length != 3) { + console.error('usage: go_js_wasm_exec [wasm binary] [arguments]'); + process.exit(1); + } + + const go = new Go(); + WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject) + .then(async (result) => { + let exitCode = await go.run(result.instance); + process.exit(exitCode); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); + } +})(); diff --git a/libs/providers/go-feature-flag/tsconfig.json b/libs/providers/go-feature-flag/tsconfig.json index 140e5a783..def479d79 100644 --- a/libs/providers/go-feature-flag/tsconfig.json +++ b/libs/providers/go-feature-flag/tsconfig.json @@ -7,7 +7,8 @@ "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "allowJs": true }, "files": [], "include": [], diff --git a/libs/providers/go-feature-flag/wasm-releases b/libs/providers/go-feature-flag/wasm-releases new file mode 160000 index 000000000..e2e22c255 --- /dev/null +++ b/libs/providers/go-feature-flag/wasm-releases @@ -0,0 +1 @@ +Subproject commit e2e22c25543ea2a4ee386acb34527d2b1f04aea0 diff --git a/release-please-config.json b/release-please-config.json index b807055d1..7cf1435f3 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -14,7 +14,8 @@ "prerelease": false, "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, - "versioning": "default" + "versioning": "default", + "release-as": "1.0.0" }, "libs/providers/flagd": { "release-type": "node",