Compare commits
84 Commits
flagsmith-
...
main
Author | SHA1 | Date |
---|---|---|
|
91ba360d36 | |
|
0ff5c88135 | |
|
601e7de199 | |
|
fca83c925c | |
|
e2404a480f | |
|
6776e9735a | |
|
0a6e44302d | |
|
d6c4817948 | |
|
1a913b8dba | |
|
1b5ecc0959 | |
|
18268f8a21 | |
|
ddbde614e9 | |
|
e501475536 | |
|
ea14c71e4d | |
|
6a6d23ba32 | |
|
82fbf1e042 | |
|
375193081a | |
|
fbc5023097 | |
|
e6766c07e5 | |
|
9c0f895af5 | |
|
cf9fe09cc1 | |
|
4e1202982e | |
|
6ab7f1abfc | |
|
1ffe54af81 | |
|
ec5309a0a9 | |
|
02a671ca7c | |
|
4f30cda7a3 | |
|
141a37df58 | |
|
d3ad93e3a9 | |
|
d495ee346f | |
|
b9ff7e1448 | |
|
70518ac4c1 | |
|
6edf52e7c2 | |
|
61b46c9687 | |
|
9e8496a384 | |
|
6dd71ac73a | |
|
8cdd5b4524 | |
|
4fe9c7e232 | |
|
e83fa88790 | |
|
771dcd522b | |
|
182077cf32 | |
|
fbd9f9155d | |
|
24a7e0e3bb | |
|
c1b00f69ee | |
|
3045e0cac8 | |
|
4dbcbf1870 | |
|
8d2fd484db | |
|
a0cdef59fd | |
|
d9ffcec165 | |
|
136b1c9858 | |
|
7083655c78 | |
|
df1ec47c3f | |
|
de161ad178 | |
|
a80f5ce3d7 | |
|
afae82c1a1 | |
|
b9231018c4 | |
|
4a48caa78a | |
|
92c67281c8 | |
|
358a04f3f0 | |
|
ce6a8e1a80 | |
|
ee676d8495 | |
|
18a0cb906a | |
|
d7f8746434 | |
|
fbe9adc832 | |
|
29f8b84685 | |
|
ca98f7c6dc | |
|
241e36edf6 | |
|
e6d3e9902f | |
|
7be62ae45f | |
|
a4fa64ec24 | |
|
9323b14968 | |
|
dba2a28001 | |
|
4180281715 | |
|
e8774fdd8c | |
|
650a598dbd | |
|
04256197bf | |
|
a164bcaa2f | |
|
043be44de1 | |
|
ea9e62a8aa | |
|
d81ef890f3 | |
|
819a247c41 | |
|
80ec86b938 | |
|
7a145e3084 | |
|
852257c31b |
|
@ -7,6 +7,14 @@
|
|||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
"disallowTypeAnnotations": true,
|
||||
"fixStyle": "separate-type-imports",
|
||||
"prefer": "type-imports"
|
||||
}
|
||||
],
|
||||
"@nx/enforce-module-boundaries": [
|
||||
"error",
|
||||
{
|
||||
|
@ -56,7 +64,7 @@
|
|||
"checkObsoleteDependencies": true,
|
||||
"checkVersionMismatches": true,
|
||||
"ignoredDependencies": ["jest-cucumber", "jest"],
|
||||
"ignoredFiles": ["**/test/**", "**/spec/**", "**/*.spec.ts", "**/*.spec.js", "**/*.test.ts", "**/*.test.js"]
|
||||
"ignoredFiles": ["**/test/**", "**/tests/*", "**/spec/**", "**/*.spec.ts", "**/*.spec.js", "**/*.test.ts", "**/*.test.js", "**/jest.*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ components:
|
|||
libs/hooks/open-telemetry:
|
||||
- beeme1mr
|
||||
- toddbaert
|
||||
libs/providers/aws-ssm:
|
||||
- gdegiorgio
|
||||
libs/providers/config-cat:
|
||||
- lukas-reining
|
||||
- adams85
|
||||
|
|
|
@ -14,14 +14,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
node-version: [20.x, 22.x, 24.x]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
|
@ -31,9 +31,9 @@ jobs:
|
|||
- run: git branch --track main origin/main || true
|
||||
|
||||
- run: if ! npx nx format:check ; then echo "Format check failed. Please run 'npx nx format:write'."; fi
|
||||
- run: npx nx affected --target=lint --parallel=3
|
||||
- run: npx nx affected --target=test --parallel=3 --ci --code-coverage
|
||||
- run: npx nx affected --target=build --parallel=3
|
||||
- run: npx nx affected --target=lint --parallel=3 --exclude=js-sdk-contrib
|
||||
- run: npx nx affected --target=test --parallel=3 --ci --code-coverage --exclude=js-sdk-contrib
|
||||
- run: npx nx affected --target=build --parallel=3 --exclude=js-sdk-contrib
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
|
@ -43,10 +43,9 @@ jobs:
|
|||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
# we need 'fetch' for this test, which is only in 18+
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install
|
||||
|
|
|
@ -5,6 +5,7 @@ on:
|
|||
name: Run Release Please
|
||||
jobs:
|
||||
release-please:
|
||||
environment: publish
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Release-please creates a PR that tracks all changes
|
||||
|
@ -28,10 +29,10 @@ jobs:
|
|||
with:
|
||||
github_token: ${{ github.token }}
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
if: ${{ steps.release.outputs.releases_created }}
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Build Packages
|
||||
if: ${{ steps.release.outputs.releases_created }}
|
||||
|
|
|
@ -46,3 +46,7 @@ proto
|
|||
# yalc stuff
|
||||
.yalc
|
||||
yalc.lock
|
||||
|
||||
|
||||
# Generated by @nx/js
|
||||
.verdaccio
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
[submodule "libs/shared/flagd-core/test-harness"]
|
||||
path = libs/shared/flagd-core/test-harness
|
||||
url = https://github.com/open-feature/flagd-testbed
|
||||
branch = v0.5.21
|
||||
branch = v2.8.0
|
||||
[submodule "libs/shared/flagd-core/spec"]
|
||||
path = libs/shared/flagd-core/spec
|
||||
url = https://github.com/open-feature/spec
|
||||
|
|
|
@ -1,24 +1,25 @@
|
|||
{
|
||||
"libs/hooks/open-telemetry": "0.4.0",
|
||||
"libs/providers/go-feature-flag": "0.7.6",
|
||||
"libs/providers/go-feature-flag": "0.7.8",
|
||||
"libs/providers/flagd": "0.13.3",
|
||||
"libs/providers/flagd-web": "0.7.2",
|
||||
"libs/providers/flagd-web": "0.7.3",
|
||||
"libs/providers/env-var": "0.3.1",
|
||||
"libs/providers/config-cat": "0.7.4",
|
||||
"libs/providers/config-cat": "0.7.6",
|
||||
"libs/providers/launchdarkly-client": "0.3.2",
|
||||
"libs/providers/go-feature-flag-web": "0.2.5",
|
||||
"libs/shared/flagd-core": "1.0.0",
|
||||
"libs/providers/go-feature-flag-web": "0.2.6",
|
||||
"libs/shared/flagd-core": "1.1.0",
|
||||
"libs/shared/ofrep-core": "1.0.1",
|
||||
"libs/providers/ofrep": "0.2.1",
|
||||
"libs/providers/ofrep-web": "0.3.2",
|
||||
"libs/providers/flipt": "0.1.2",
|
||||
"libs/providers/ofrep-web": "0.3.3",
|
||||
"libs/providers/flipt": "0.1.3",
|
||||
"libs/providers/flagsmith-client": "0.1.3",
|
||||
"libs/providers/flipt-web": "0.1.2",
|
||||
"libs/providers/flipt-web": "0.1.5",
|
||||
"libs/providers/multi-provider": "0.1.2",
|
||||
"libs/providers/multi-provider-web": "0.0.3",
|
||||
"libs/providers/growthbook-client": "0.1.2",
|
||||
"libs/providers/config-cat-web": "0.1.5",
|
||||
"libs/providers/config-cat-web": "0.1.6",
|
||||
"libs/shared/config-cat-core": "0.1.1",
|
||||
"libs/providers/unleash-web": "0.1.1",
|
||||
"libs/providers/growthbook": "0.1.2"
|
||||
"libs/providers/growthbook": "0.1.2",
|
||||
"libs/providers/aws-ssm": "0.1.3"
|
||||
}
|
||||
|
|
|
@ -3,4 +3,4 @@
|
|||
#
|
||||
# Managed by Peribolos: https://github.com/open-feature/community/blob/main/config/open-feature/sdk-javascript/workgroup.yaml
|
||||
#
|
||||
* @open-feature/sdk-javascript-maintainers
|
||||
* @open-feature/sdk-javascript-maintainers @open-feature/maintainers
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 106 KiB |
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
After Width: | Height: | Size: 54 KiB |
|
@ -1,6 +1,8 @@
|
|||
import { BeforeHookContext, EvaluationDetails, HookContext, StandardResolutionReasons } from '@openfeature/server-sdk';
|
||||
import type { BeforeHookContext, EvaluationDetails, HookContext } from '@openfeature/server-sdk';
|
||||
import { StandardResolutionReasons } from '@openfeature/server-sdk';
|
||||
import opentelemetry from '@opentelemetry/api';
|
||||
import { DataPoint, MeterProvider, MetricReader, ScopeMetrics } from '@opentelemetry/sdk-metrics';
|
||||
import type { DataPoint, ScopeMetrics } from '@opentelemetry/sdk-metrics';
|
||||
import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics';
|
||||
import {
|
||||
ACTIVE_COUNT_NAME,
|
||||
ERROR_TOTAL_NAME,
|
||||
|
@ -12,7 +14,7 @@ import {
|
|||
VARIANT_ATTR,
|
||||
} from '../conventions';
|
||||
import { MetricsHook } from './metrics-hook';
|
||||
import { AttributeMapper } from '../otel-hook';
|
||||
import type { AttributeMapper } from '../otel-hook';
|
||||
|
||||
// no-op "in-memory" reader
|
||||
class InMemoryMetricReader extends MetricReader {
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
import type { BeforeHookContext, Logger } from '@openfeature/server-sdk';
|
||||
import {
|
||||
BeforeHookContext,
|
||||
Logger,
|
||||
StandardResolutionReasons,
|
||||
type EvaluationDetails,
|
||||
type FlagValue,
|
||||
type Hook,
|
||||
type HookContext,
|
||||
} from '@openfeature/server-sdk';
|
||||
import { Attributes, Counter, UpDownCounter, ValueType, metrics } from '@opentelemetry/api';
|
||||
import type { Attributes, Counter, UpDownCounter } from '@opentelemetry/api';
|
||||
import { ValueType, metrics } from '@opentelemetry/api';
|
||||
import type { EvaluationAttributes, ExceptionAttributes } from '../conventions';
|
||||
import {
|
||||
ACTIVE_COUNT_NAME,
|
||||
ERROR_TOTAL_NAME,
|
||||
EXCEPTION_ATTR,
|
||||
EvaluationAttributes,
|
||||
ExceptionAttributes,
|
||||
KEY_ATTR,
|
||||
PROVIDER_NAME_ATTR,
|
||||
REASON_ATTR,
|
||||
|
@ -21,7 +20,8 @@ import {
|
|||
SUCCESS_TOTAL_NAME,
|
||||
VARIANT_ATTR,
|
||||
} from '../conventions';
|
||||
import { OpenTelemetryHook, OpenTelemetryHookOptions } from '../otel-hook';
|
||||
import type { OpenTelemetryHookOptions } from '../otel-hook';
|
||||
import { OpenTelemetryHook } from '../otel-hook';
|
||||
|
||||
type ErrorEvaluationAttributes = EvaluationAttributes & ExceptionAttributes;
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { FlagMetadata, Logger } from '@openfeature/server-sdk';
|
||||
import { Attributes } from '@opentelemetry/api';
|
||||
import type { FlagMetadata, Logger } from '@openfeature/server-sdk';
|
||||
import type { Attributes } from '@opentelemetry/api';
|
||||
|
||||
export type AttributeMapper = (flagMetadata: FlagMetadata) => Attributes;
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { EvaluationDetails, HookContext } from '@openfeature/server-sdk';
|
||||
import type { EvaluationDetails, HookContext } from '@openfeature/server-sdk';
|
||||
|
||||
const addEvent = jest.fn();
|
||||
const recordException = jest.fn();
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { Hook, HookContext, EvaluationDetails, FlagValue, Logger } from '@openfeature/server-sdk';
|
||||
import type { Hook, HookContext, EvaluationDetails, FlagValue, Logger } from '@openfeature/server-sdk';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import { FEATURE_FLAG, KEY_ATTR, PROVIDER_NAME_ATTR, VARIANT_ATTR } from '../conventions';
|
||||
import { OpenTelemetryHook, OpenTelemetryHookOptions } from '../otel-hook';
|
||||
import type { OpenTelemetryHookOptions } from '../otel-hook';
|
||||
import { OpenTelemetryHook } from '../otel-hook';
|
||||
|
||||
export type TracingHookOptions = OpenTelemetryHookOptions;
|
||||
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"extends": ["../../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.json"],
|
||||
"parser": "jsonc-eslint-parser",
|
||||
"rules": {
|
||||
"@nx/dependency-checks": [
|
||||
"error",
|
||||
{
|
||||
"ignoredFiles": ["{projectRoot}/eslint.config.{js,cjs,mjs}"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
# Changelog
|
||||
|
||||
## [0.1.3](https://github.com/open-feature/js-sdk-contrib/compare/aws-ssm-provider-v0.1.2...aws-ssm-provider-v0.1.3) (2025-06-04)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* **deps:** update dependency @aws-sdk/client-ssm to v3.787.0 ([#1278](https://github.com/open-feature/js-sdk-contrib/issues/1278)) ([afae82c](https://github.com/open-feature/js-sdk-contrib/commit/afae82c1a1472d33b884105edaac2976c19e7423))
|
||||
* **deps:** update dependency lru-cache to v11.1.0 ([#1279](https://github.com/open-feature/js-sdk-contrib/issues/1279)) ([a80f5ce](https://github.com/open-feature/js-sdk-contrib/commit/a80f5ce3d7a6e74e762a75ba8fa9f5b70ca2a179))
|
||||
|
||||
## [0.1.2](https://github.com/open-feature/js-sdk-contrib/compare/aws-ssm-provider-v0.1.1...aws-ssm-provider-v0.1.2) (2025-03-27)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* **aws-ssm:** add decryption support for `SecureString` parameters ([#1241](https://github.com/open-feature/js-sdk-contrib/issues/1241)) ([043be44](https://github.com/open-feature/js-sdk-contrib/commit/043be44de1442b89876e9857478afe619fcf0b04))
|
||||
|
||||
## [0.1.1](https://github.com/open-feature/js-sdk-contrib/compare/aws-ssm-provider-v0.1.0...aws-ssm-provider-v0.1.1) (2025-03-20)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* **aws-ssm:** implement AWS SSM provider ([#1221](https://github.com/open-feature/js-sdk-contrib/issues/1221)) ([819a247](https://github.com/open-feature/js-sdk-contrib/commit/819a247c41112c2873aa025ac0abd3c62eb53aca))
|
|
@ -0,0 +1,73 @@
|
|||
# AWS SSM Provider
|
||||
|
||||
## What is AWS SSM?
|
||||
AWS Systems Manager (SSM) is a service provided by Amazon Web Services (AWS) that enables users to manage and automate operational tasks across their AWS infrastructure. One of its key components is AWS Systems Manager Parameter Store, which allows users to store, retrieve, and manage configuration data and secrets securely.
|
||||
SSM Parameter Store can be used to manage application configuration settings, database connection strings, API keys, and other sensitive information. It provides integration with AWS Identity and Access Management (IAM) to control access and encryption through AWS Key Management Service (KMS).
|
||||
The aws-ssm provider for OpenFeature allows applications to fetch feature flag configurations from AWS SSM Parameter Store, enabling centralized and dynamic configuration management.
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
$ npm install @openfeature/aws-ssm-provider
|
||||
```
|
||||
|
||||
## Set AWS Provider
|
||||
|
||||
```
|
||||
OpenFeature.setProvider(
|
||||
new AwsSsmProvider({
|
||||
ssmClientConfig: {
|
||||
region: 'eu-west-1', // Change this to your desired AWS region
|
||||
// You can setup your aws credentials here or it will be automatically retrieved from env vars
|
||||
// See https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-credentials-node.html
|
||||
},
|
||||
// Use an LRUCache for improve performance and optimize AWS SDK Calls to SSM (cost awareness)
|
||||
cacheOpts: {
|
||||
enabled: true, // Enable caching
|
||||
size: 1, // Cache size
|
||||
ttl: 10, // Time-to-live in seconds
|
||||
},
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
# AWS SSM Provider Configuration
|
||||
|
||||
## AwsSsmProviderConfig
|
||||
|
||||
| Property | Type | Description | Default |
|
||||
|-----------------|--------------------|----------------------------------------------|---------|
|
||||
| `ssmClientConfig` | `SSMClientConfig` | AWS SSM Client configuration options. | See [here](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/ssm/) |
|
||||
| `enableDecryption` | `boolean` | Enable decryption for SecureString parameters | false |
|
||||
| `cacheOpts` | `LRUCacheConfig` | Configuration for the local LRU cache. | See below |
|
||||
|
||||
## LRUCacheConfig
|
||||
|
||||
| Property | Type | Description | Default |
|
||||
|-----------|--------|------------------------------------------------|---------|
|
||||
| `enabled` | `boolean` | Whether caching is enabled. | `false` |
|
||||
| `ttl` | `number` | Time-to-live (TTL) for cached items (in ms). | `300000` (5 minutes) |
|
||||
| `size` | `number` | Maximum number of items in the cache. | `1000` |
|
||||
|
||||
|
||||
|
||||
|
||||
## Retrieve Feature Flag!
|
||||
|
||||
Open your AWS Management Console and go to AWS System Manager service
|
||||
|
||||

|
||||
|
||||
Go to Parameter Store
|
||||
|
||||

|
||||
|
||||
Create a new SSM Param called 'my-feature-flag' in your AWS Account and then retrieve it via OpenFeature Client!
|
||||
|
||||

|
||||
|
||||
```
|
||||
const featureFlags = OpenFeature.getClient();
|
||||
const flagValue = await featureFlags.getBooleanValue('my-feature-flag', false);
|
||||
console.log(`Feature flag value: ${flagValue}`);
|
||||
```
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"presets": [["minify", { "builtIns": false }]]
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: 'aws-ssm',
|
||||
preset: '../../../jest.preset.js',
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
},
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': 'ts-jest',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../coverage/libs/providers/aws-ssm',
|
||||
testEnvironment: 'node',
|
||||
};
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "@openfeature/aws-ssm-provider",
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ssm": "^3.759.0",
|
||||
"lru-cache": "^11.0.2",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"main": "./src/index.js",
|
||||
"typings": "./src/index.d.ts",
|
||||
"scripts": {
|
||||
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
|
||||
"current-version": "echo $npm_package_version"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@openfeature/server-sdk": "^1.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@smithy/types": "^4.1.0",
|
||||
"aws-sdk-client-mock": "^4.1.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"name": "providers-aws-ssm",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/providers/aws-ssm/src",
|
||||
"projectType": "library",
|
||||
"targets": {
|
||||
"publish": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "npm run publish-if-not-exists",
|
||||
"cwd": "dist/libs/providers/aws-ssm"
|
||||
},
|
||||
"dependsOn": [
|
||||
{
|
||||
"target": "package"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"]
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/providers/aws-ssm/jest.config.ts"
|
||||
}
|
||||
},
|
||||
"package": {
|
||||
"executor": "@nx/rollup:rollup",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"project": "libs/providers/aws-ssm/package.json",
|
||||
"outputPath": "dist/libs/providers/aws-ssm",
|
||||
"entryFile": "libs/providers/aws-ssm/src/index.ts",
|
||||
"tsConfig": "libs/providers/aws-ssm/tsconfig.lib.json",
|
||||
"compiler": "tsc",
|
||||
"generateExportsField": true,
|
||||
"umdName": "aws-ssm",
|
||||
"external": "all",
|
||||
"format": ["cjs", "esm"],
|
||||
"assets": [
|
||||
{
|
||||
"glob": "package.json",
|
||||
"input": "./assets",
|
||||
"output": "./src/"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "./",
|
||||
"output": "./"
|
||||
},
|
||||
{
|
||||
"glob": "README.md",
|
||||
"input": "./libs/providers/aws-ssm",
|
||||
"output": "./"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from './lib/aws-ssm-provider';
|
|
@ -0,0 +1,86 @@
|
|||
import { OpenFeature } from '@openfeature/server-sdk';
|
||||
import { AwsSsmProvider } from '../lib/aws-ssm-provider';
|
||||
import type { GetParameterCommandOutput } from '@aws-sdk/client-ssm';
|
||||
import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm';
|
||||
import { mockClient } from 'aws-sdk-client-mock';
|
||||
|
||||
const ssmMock = mockClient(SSMClient);
|
||||
|
||||
describe('AWS SSM Provider E2E', () => {
|
||||
const featureFlags = OpenFeature.getClient();
|
||||
OpenFeature.setProvider(
|
||||
new AwsSsmProvider({
|
||||
ssmClientConfig: {
|
||||
region: 'eu-west-1',
|
||||
},
|
||||
cacheOpts: {
|
||||
enabled: true,
|
||||
size: 1,
|
||||
ttl: 10,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
describe('when using OpenFeature with AWS SSM Provider to retrieve a boolean', () => {
|
||||
it('should use AWS SSM in order to retrieve the value', async () => {
|
||||
const res: GetParameterCommandOutput = {
|
||||
Parameter: {
|
||||
Name: '/lambda/loggingEnabled',
|
||||
Value: 'true',
|
||||
},
|
||||
$metadata: {},
|
||||
};
|
||||
ssmMock.on(GetParameterCommand).resolves(res);
|
||||
const flagValue = await featureFlags.getBooleanValue('/lambda/loggingEnabled', false);
|
||||
expect(flagValue).toBe(true);
|
||||
});
|
||||
});
|
||||
describe('when using OpenFeature with AWS SSM Provider to retrieve a string', () => {
|
||||
it('should use AWS SSM in order to retrieve the value', async () => {
|
||||
const res: GetParameterCommandOutput = {
|
||||
Parameter: {
|
||||
Name: '/lambda/logLevel',
|
||||
Value: 'ERROR',
|
||||
},
|
||||
$metadata: {},
|
||||
};
|
||||
ssmMock.on(GetParameterCommand).resolves(res);
|
||||
const flagValue = await featureFlags.getStringValue('/lambda/logLevel', 'INFO');
|
||||
expect(flagValue).toBe('ERROR');
|
||||
});
|
||||
});
|
||||
describe('when using OpenFeature with AWS SSM Provider to retrieve a number', () => {
|
||||
it('should use AWS SSM in order to retrieve the value', async () => {
|
||||
const res: GetParameterCommandOutput = {
|
||||
Parameter: {
|
||||
Name: '/lambda/logRetentionInDays',
|
||||
Value: '3',
|
||||
},
|
||||
$metadata: {},
|
||||
};
|
||||
ssmMock.on(GetParameterCommand).resolves(res);
|
||||
const flagValue = await featureFlags.getNumberValue('/lambda/logRetentionInDays', 14);
|
||||
expect(flagValue).toBe(3);
|
||||
});
|
||||
});
|
||||
describe('when using OpenFeature with AWS SSM Provider to retrieve an object', () => {
|
||||
it('should use AWS SSM in order to retrieve the value', async () => {
|
||||
const res: GetParameterCommandOutput = {
|
||||
Parameter: {
|
||||
Name: '/lambda/env',
|
||||
Value: JSON.stringify({
|
||||
PROCESS_NUMBER: 3,
|
||||
SOME_ENV_VAR: 4,
|
||||
}),
|
||||
},
|
||||
$metadata: {},
|
||||
};
|
||||
ssmMock.on(GetParameterCommand).resolves(res);
|
||||
const flagValue = await featureFlags.getObjectValue('/lambda/env', {});
|
||||
expect(flagValue).toStrictEqual({
|
||||
PROCESS_NUMBER: 3,
|
||||
SOME_ENV_VAR: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,203 @@
|
|||
import type { SSMClientConfig } from '@aws-sdk/client-ssm';
|
||||
import { AwsSsmProvider } from './aws-ssm-provider';
|
||||
import { ErrorCode, StandardResolutionReasons } from '@openfeature/core';
|
||||
|
||||
const MOCK_SSM_CLIENT_CONFIG: SSMClientConfig = {
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: 'accessKeyId',
|
||||
secretAccessKey: 'secretAccessKey',
|
||||
},
|
||||
};
|
||||
|
||||
const provider: AwsSsmProvider = new AwsSsmProvider({
|
||||
ssmClientConfig: MOCK_SSM_CLIENT_CONFIG,
|
||||
cacheOpts: {
|
||||
enabled: true,
|
||||
ttl: 1000,
|
||||
size: 100,
|
||||
},
|
||||
});
|
||||
|
||||
describe(AwsSsmProvider.name, () => {
|
||||
describe(AwsSsmProvider.prototype.resolveBooleanEvaluation.name, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('when flag is cached', () => {
|
||||
afterAll(() => {
|
||||
provider.cache.clear();
|
||||
});
|
||||
it('should return cached value', async () => {
|
||||
provider.cache.set('test', {
|
||||
value: true,
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
});
|
||||
await expect(provider.resolveBooleanEvaluation('test', false, {})).resolves.toEqual({
|
||||
value: true,
|
||||
reason: StandardResolutionReasons.CACHED,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('when flag is not cached', () => {
|
||||
describe('when getBooleanValue rejects', () => {
|
||||
it('should return default value', async () => {
|
||||
jest.spyOn(provider.service, 'getBooleanValue').mockRejectedValue(new Error());
|
||||
await expect(provider.resolveBooleanEvaluation('test', false, {})).resolves.toEqual({
|
||||
value: false,
|
||||
reason: StandardResolutionReasons.ERROR,
|
||||
errorMessage: 'An unknown error occurred',
|
||||
errorCode: ErrorCode.GENERAL,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('when getBooleanValue resolves', () => {
|
||||
it('should resolve with expected value', async () => {
|
||||
jest.spyOn(provider.service, 'getBooleanValue').mockResolvedValue({
|
||||
value: true,
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
});
|
||||
await expect(provider.resolveBooleanEvaluation('test', false, {})).resolves.toEqual({
|
||||
value: true,
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(AwsSsmProvider.prototype.resolveStringEvaluation.name, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('when flag is cached', () => {
|
||||
afterAll(() => {
|
||||
provider.cache.clear();
|
||||
});
|
||||
it('should return cached value', async () => {
|
||||
provider.cache.set('test', {
|
||||
value: 'somestring',
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
});
|
||||
await expect(provider.resolveStringEvaluation('test', 'default', {})).resolves.toEqual({
|
||||
value: 'somestring',
|
||||
reason: StandardResolutionReasons.CACHED,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('when flag is not cached', () => {
|
||||
describe('when getStringValue rejects', () => {
|
||||
it('should return default value', async () => {
|
||||
jest.spyOn(provider.service, 'getStringValue').mockRejectedValue(new Error());
|
||||
await expect(provider.resolveStringEvaluation('test', 'default', {})).resolves.toEqual({
|
||||
value: 'default',
|
||||
reason: StandardResolutionReasons.ERROR,
|
||||
errorMessage: 'An unknown error occurred',
|
||||
errorCode: ErrorCode.GENERAL,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('when getStringValue resolves', () => {
|
||||
it('should resolve with expected value', async () => {
|
||||
jest.spyOn(provider.service, 'getStringValue').mockResolvedValue({
|
||||
value: 'somestring',
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
});
|
||||
await expect(provider.resolveStringEvaluation('test', 'default', {})).resolves.toEqual({
|
||||
value: 'somestring',
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(AwsSsmProvider.prototype.resolveNumberEvaluation.name, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('when flag is cached', () => {
|
||||
afterAll(() => {
|
||||
provider.cache.clear();
|
||||
});
|
||||
it('should return cached value', async () => {
|
||||
provider.cache.set('test', {
|
||||
value: 489,
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
});
|
||||
await expect(provider.resolveNumberEvaluation('test', -1, {})).resolves.toEqual({
|
||||
value: 489,
|
||||
reason: StandardResolutionReasons.CACHED,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('when flag is not cached', () => {
|
||||
describe('when getNumberValue rejects', () => {
|
||||
it('should return default value', async () => {
|
||||
jest.spyOn(provider.service, 'getNumberValue').mockRejectedValue(new Error());
|
||||
await expect(provider.resolveNumberEvaluation('test', -1, {})).resolves.toEqual({
|
||||
value: -1,
|
||||
reason: StandardResolutionReasons.ERROR,
|
||||
errorMessage: 'An unknown error occurred',
|
||||
errorCode: ErrorCode.GENERAL,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('when getNumberValue resolves', () => {
|
||||
it('should resolve with expected value', async () => {
|
||||
jest.spyOn(provider.service, 'getNumberValue').mockResolvedValue({
|
||||
value: 489,
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
});
|
||||
await expect(provider.resolveNumberEvaluation('test', -1, {})).resolves.toEqual({
|
||||
value: 489,
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(AwsSsmProvider.prototype.resolveObjectEvaluation.name, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('when flag is cached', () => {
|
||||
afterAll(() => {
|
||||
provider.cache.clear();
|
||||
});
|
||||
it('should return cached value', async () => {
|
||||
provider.cache.set('test', {
|
||||
value: { default: false },
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
});
|
||||
await expect(provider.resolveObjectEvaluation('test', { default: true }, {})).resolves.toEqual({
|
||||
value: { default: false },
|
||||
reason: StandardResolutionReasons.CACHED,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('when flag is not cached', () => {
|
||||
describe('when getObjectValue rejects', () => {
|
||||
it('should return default value', async () => {
|
||||
jest.spyOn(provider.service, 'getObjectValue').mockRejectedValue(new Error());
|
||||
await expect(provider.resolveObjectEvaluation('test', { default: true }, {})).resolves.toEqual({
|
||||
value: { default: true },
|
||||
reason: StandardResolutionReasons.ERROR,
|
||||
errorMessage: 'An unknown error occurred',
|
||||
errorCode: ErrorCode.GENERAL,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('when getObjectValue resolves', () => {
|
||||
it('should resolve with expected value', async () => {
|
||||
jest.spyOn(provider.service, 'getObjectValue').mockResolvedValue({
|
||||
value: { default: true },
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
});
|
||||
await expect(provider.resolveObjectEvaluation('test', -1, {})).resolves.toEqual({
|
||||
value: { default: true },
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,146 @@
|
|||
import type { EvaluationContext, Provider, JsonValue, ResolutionDetails } from '@openfeature/server-sdk';
|
||||
import { StandardResolutionReasons, ErrorCode } from '@openfeature/server-sdk';
|
||||
import { InternalServerError } from '@aws-sdk/client-ssm';
|
||||
import type { AwsSsmProviderConfig } from './types';
|
||||
import { SSMService } from './ssm-service';
|
||||
import { Cache } from './cache';
|
||||
|
||||
export class AwsSsmProvider implements Provider {
|
||||
metadata = {
|
||||
name: AwsSsmProvider.name,
|
||||
};
|
||||
|
||||
readonly runsOn = 'server';
|
||||
readonly service: SSMService;
|
||||
hooks = [];
|
||||
cache: Cache;
|
||||
|
||||
constructor(config: AwsSsmProviderConfig) {
|
||||
this.service = new SSMService(config.ssmClientConfig, config.enableDecryption);
|
||||
this.cache = new Cache(config.cacheOpts);
|
||||
}
|
||||
|
||||
async resolveBooleanEvaluation(
|
||||
flagKey: string,
|
||||
defaultValue: boolean,
|
||||
context: EvaluationContext,
|
||||
): Promise<ResolutionDetails<boolean>> {
|
||||
const cachedValue = this.cache.get(flagKey);
|
||||
if (cachedValue) {
|
||||
return {
|
||||
value: cachedValue.value,
|
||||
reason: StandardResolutionReasons.CACHED,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const res = await this.service.getBooleanValue(flagKey);
|
||||
this.cache.set(flagKey, res);
|
||||
return res;
|
||||
} catch (err) {
|
||||
let errMsg = 'An unknown error occurred';
|
||||
|
||||
if (err instanceof InternalServerError) {
|
||||
errMsg = err.message;
|
||||
}
|
||||
|
||||
return {
|
||||
value: defaultValue,
|
||||
reason: StandardResolutionReasons.ERROR,
|
||||
errorCode: ErrorCode.GENERAL,
|
||||
errorMessage: errMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async resolveStringEvaluation(
|
||||
flagKey: string,
|
||||
defaultValue: string,
|
||||
context: EvaluationContext,
|
||||
): Promise<ResolutionDetails<string>> {
|
||||
const cachedValue = this.cache.get(flagKey);
|
||||
if (cachedValue) {
|
||||
return {
|
||||
value: cachedValue.value,
|
||||
reason: StandardResolutionReasons.CACHED,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const res = await this.service.getStringValue(flagKey);
|
||||
this.cache.set(flagKey, res);
|
||||
return res;
|
||||
} catch (err) {
|
||||
let errMsg = 'An unknown error occurred';
|
||||
|
||||
if (err instanceof InternalServerError) {
|
||||
errMsg = err.message;
|
||||
}
|
||||
|
||||
return {
|
||||
value: defaultValue,
|
||||
reason: StandardResolutionReasons.ERROR,
|
||||
errorCode: ErrorCode.GENERAL,
|
||||
errorMessage: errMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async resolveNumberEvaluation(
|
||||
flagKey: string,
|
||||
defaultValue: number,
|
||||
context: EvaluationContext,
|
||||
): Promise<ResolutionDetails<number>> {
|
||||
const cachedValue = this.cache.get(flagKey);
|
||||
if (cachedValue) {
|
||||
return {
|
||||
value: cachedValue.value,
|
||||
reason: StandardResolutionReasons.CACHED,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await this.service.getNumberValue(flagKey);
|
||||
} catch (err) {
|
||||
let errMsg = 'An unknown error occurred';
|
||||
|
||||
if (err instanceof InternalServerError) {
|
||||
errMsg = err.message;
|
||||
}
|
||||
|
||||
return {
|
||||
value: defaultValue,
|
||||
reason: StandardResolutionReasons.ERROR,
|
||||
errorCode: ErrorCode.GENERAL,
|
||||
errorMessage: errMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async resolveObjectEvaluation<U extends JsonValue>(
|
||||
flagKey: string,
|
||||
defaultValue: U,
|
||||
context: EvaluationContext,
|
||||
): Promise<ResolutionDetails<U>> {
|
||||
const cachedValue = this.cache.get(flagKey);
|
||||
if (cachedValue) {
|
||||
return {
|
||||
value: cachedValue.value,
|
||||
reason: StandardResolutionReasons.CACHED,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await this.service.getObjectValue(flagKey);
|
||||
} catch (err) {
|
||||
let errMsg = 'An unknown error occurred';
|
||||
|
||||
if (err instanceof InternalServerError) {
|
||||
errMsg = err.message;
|
||||
}
|
||||
|
||||
return {
|
||||
value: defaultValue,
|
||||
reason: StandardResolutionReasons.ERROR,
|
||||
errorCode: ErrorCode.GENERAL,
|
||||
errorMessage: errMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import { Cache } from './cache';
|
||||
|
||||
describe(Cache.name, () => {
|
||||
describe(Cache.prototype.get.name, () => {
|
||||
describe('when cache is disabled', () => {
|
||||
it('should return undefined', () => {
|
||||
const cache = new Cache({ enabled: false, size: 1, ttl: 1 });
|
||||
expect(cache.get('test')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('when cache is enabled', () => {
|
||||
describe('when key is not in cache', () => {
|
||||
it('should return undefined', () => {
|
||||
const cache = new Cache({ enabled: true, size: 1, ttl: 1 });
|
||||
expect(cache.get('test')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('when key is in cache', () => {
|
||||
it('should return the value', () => {
|
||||
const cache = new Cache({ enabled: true, size: 1, ttl: 1 });
|
||||
cache.set('test', { value: true, reason: 'test' });
|
||||
expect(cache.get('test')).toEqual({ value: true, reason: 'test' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(Cache.prototype.set.name, () => {
|
||||
describe('when cache is disabled', () => {
|
||||
it('should not set the value', () => {
|
||||
const spy = jest.spyOn(Cache.prototype, 'set');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('when cache is enabled', () => {
|
||||
it('should set the value', () => {
|
||||
const cache = new Cache({ enabled: true, size: 1, ttl: 1 });
|
||||
cache.set('test', { value: true, reason: 'test' });
|
||||
expect(cache.get('test')).toEqual({ value: true, reason: 'test' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(Cache.prototype.clear.name, () => {
|
||||
describe('when cache is disabled', () => {
|
||||
it('should not clear the cache', () => {
|
||||
const spy = jest.spyOn(Cache.prototype, 'clear');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('when cache is enabled', () => {
|
||||
it('should clear the cache', () => {
|
||||
const cache = new Cache({ enabled: true, size: 1, ttl: 1 });
|
||||
cache.set('test', { value: true, reason: 'test' });
|
||||
cache.clear();
|
||||
expect(cache.get('test')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,38 @@
|
|||
import type { ResolutionDetails } from '@openfeature/core';
|
||||
import type { LRUCacheConfig } from './types';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
export class Cache {
|
||||
private cache: LRUCache<string, ResolutionDetails<any>>;
|
||||
private ttl: number;
|
||||
private enabled: boolean;
|
||||
constructor(opts: LRUCacheConfig) {
|
||||
this.cache = new LRUCache({
|
||||
maxSize: opts.size ?? 1000,
|
||||
sizeCalculation: () => 1,
|
||||
});
|
||||
this.ttl = opts.ttl ?? 300000;
|
||||
this.enabled = opts.enabled;
|
||||
}
|
||||
|
||||
get(key: string): ResolutionDetails<any> | undefined {
|
||||
if (!this.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
return this.cache.get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: ResolutionDetails<any>): void {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
this.cache.set(key, value, { ttl: this.ttl });
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
import { ParseError, StandardResolutionReasons, TypeMismatchError } from '@openfeature/core';
|
||||
import { SSMService } from './ssm-service';
|
||||
|
||||
describe(SSMService.name, () => {
|
||||
describe(SSMService.prototype.getBooleanValue.name, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe(`when _getParamFromSSM returns "true"`, () => {
|
||||
it(`should return a ResolutionDetails with value true`, async () => {
|
||||
jest
|
||||
.spyOn(SSMService.prototype, '_getValueFromSSM')
|
||||
.mockResolvedValue({ val: 'true', metadata: { httpStatusCode: 200 } });
|
||||
const service = new SSMService({});
|
||||
const result = await service.getBooleanValue('test');
|
||||
expect(result).toEqual({
|
||||
value: true,
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
flagMetadata: { httpStatusCode: 200 },
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(`when _getParamFromSSM returns "false"`, () => {
|
||||
it(`should return a ResolutionDetails with value true`, async () => {
|
||||
jest
|
||||
.spyOn(SSMService.prototype, '_getValueFromSSM')
|
||||
.mockResolvedValue({ val: 'false', metadata: { httpStatusCode: 200 } });
|
||||
const service = new SSMService({});
|
||||
const result = await service.getBooleanValue('test');
|
||||
expect(result).toEqual({
|
||||
value: false,
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
flagMetadata: { httpStatusCode: 200 },
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(`when _getParamFromSSM returns an invalid value`, () => {
|
||||
it('should throw a TypeMismatchError', () => {
|
||||
jest
|
||||
.spyOn(SSMService.prototype, '_getValueFromSSM')
|
||||
.mockResolvedValue({ val: 'invalid boolean', metadata: { httpStatusCode: 400 } });
|
||||
const service = new SSMService({});
|
||||
expect(() => service.getBooleanValue('test')).rejects.toThrow(TypeMismatchError);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(SSMService.prototype.getStringValue.name, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe(`when _getParamFromSSM returns a valid value`, () => {
|
||||
it(`should return a ResolutionDetails with that value`, async () => {
|
||||
jest
|
||||
.spyOn(SSMService.prototype, '_getValueFromSSM')
|
||||
.mockResolvedValue({ val: 'example', metadata: { httpStatusCode: 200 } });
|
||||
const service = new SSMService({});
|
||||
const result = await service.getStringValue('example');
|
||||
expect(result).toEqual({
|
||||
value: 'example',
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
flagMetadata: { httpStatusCode: 200 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(SSMService.prototype.getNumberValue.name, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe(`when _getParamFromSSM returns a valid number`, () => {
|
||||
it(`should return a ResolutionDetails with value true`, async () => {
|
||||
jest
|
||||
.spyOn(SSMService.prototype, '_getValueFromSSM')
|
||||
.mockResolvedValue({ val: '1478', metadata: { httpStatusCode: 200 } });
|
||||
const service = new SSMService({});
|
||||
const result = await service.getNumberValue('test');
|
||||
expect(result).toEqual({
|
||||
value: 1478,
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
flagMetadata: { httpStatusCode: 200 },
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(`when _getParamFromSSM returns a value that is not a number`, () => {
|
||||
it(`should return a TypeMismatchError`, async () => {
|
||||
jest
|
||||
.spyOn(SSMService.prototype, '_getValueFromSSM')
|
||||
.mockResolvedValue({ val: 'invalid number', metadata: { httpStatusCode: 400 } });
|
||||
const service = new SSMService({});
|
||||
expect(() => service.getNumberValue('test')).rejects.toThrow(TypeMismatchError);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(SSMService.prototype.getObjectValue.name, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe(`when _getParamFromSSM returns a valid object`, () => {
|
||||
it(`should return a ResolutionDetails with that object`, async () => {
|
||||
jest
|
||||
.spyOn(SSMService.prototype, '_getValueFromSSM')
|
||||
.mockResolvedValue({ val: JSON.stringify({ test: true }), metadata: { httpStatusCode: 400 } });
|
||||
const service = new SSMService({});
|
||||
const result = await service.getObjectValue('test');
|
||||
expect(result).toEqual({
|
||||
value: { test: true },
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
flagMetadata: { httpStatusCode: 400 },
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(`when _getParamFromSSM returns an invalid object`, () => {
|
||||
it(`should return a ParseError`, async () => {
|
||||
jest
|
||||
.spyOn(SSMService.prototype, '_getValueFromSSM')
|
||||
.mockResolvedValue({ val: 'invalid object', metadata: { httpStatusCode: 400 } });
|
||||
const service = new SSMService({});
|
||||
expect(() => service.getObjectValue('test')).rejects.toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,118 @@
|
|||
import type { SSMClientConfig, GetParameterCommandInput } from '@aws-sdk/client-ssm';
|
||||
import { GetParameterCommand, SSMClient, DescribeParametersCommand } from '@aws-sdk/client-ssm';
|
||||
import type { ResponseMetadata } from '@smithy/types';
|
||||
import type { JsonValue, ResolutionDetails } from '@openfeature/core';
|
||||
import { FlagNotFoundError, TypeMismatchError, ParseError, StandardResolutionReasons } from '@openfeature/core';
|
||||
|
||||
export class SSMService {
|
||||
client: SSMClient;
|
||||
enableDecryption: boolean;
|
||||
constructor(config: SSMClientConfig, enableDecryption?: boolean) {
|
||||
this.client = new SSMClient(config);
|
||||
this.enableDecryption = enableDecryption ?? false;
|
||||
}
|
||||
|
||||
async getBooleanValue(name: string): Promise<ResolutionDetails<boolean>> {
|
||||
const res = await this._getValueFromSSM(name);
|
||||
|
||||
const { val, metadata } = res;
|
||||
|
||||
let result: boolean;
|
||||
switch (val) {
|
||||
case 'true':
|
||||
result = true;
|
||||
break;
|
||||
case 'false':
|
||||
result = false;
|
||||
break;
|
||||
default:
|
||||
throw new TypeMismatchError(`${val} is not a valid boolean value`);
|
||||
}
|
||||
|
||||
return {
|
||||
value: result,
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
flagMetadata: { ...metadata },
|
||||
};
|
||||
}
|
||||
|
||||
async getStringValue(name: string): Promise<ResolutionDetails<string>> {
|
||||
const res = await this._getValueFromSSM(name);
|
||||
const { val, metadata } = res;
|
||||
return {
|
||||
value: val,
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
flagMetadata: { ...metadata },
|
||||
};
|
||||
}
|
||||
|
||||
async getNumberValue(name: string): Promise<ResolutionDetails<number>> {
|
||||
const res = await this._getValueFromSSM(name);
|
||||
const { val, metadata } = res;
|
||||
|
||||
if (Number.isNaN(Number(val))) {
|
||||
throw new TypeMismatchError(`${val} is not a number`);
|
||||
}
|
||||
return {
|
||||
value: Number(val),
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
flagMetadata: { ...metadata },
|
||||
};
|
||||
}
|
||||
|
||||
async getObjectValue<U extends JsonValue>(name: string): Promise<ResolutionDetails<U>> {
|
||||
const res = await this._getValueFromSSM(name);
|
||||
const { val, metadata } = res;
|
||||
try {
|
||||
return {
|
||||
value: JSON.parse(val),
|
||||
reason: StandardResolutionReasons.STATIC,
|
||||
flagMetadata: { ...metadata },
|
||||
};
|
||||
} catch (e) {
|
||||
throw new ParseError(`Unable to parse value as JSON: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async _isSecureString(name: string): Promise<boolean> {
|
||||
const res = await this.client.send(
|
||||
new DescribeParametersCommand({
|
||||
ParameterFilters: [
|
||||
{
|
||||
Key: 'Name',
|
||||
Values: [name],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
if (!res.Parameters) {
|
||||
throw new FlagNotFoundError(`Unable to find an SSM Parameter with key ${name}`);
|
||||
}
|
||||
return res.Parameters[0].Type === 'SecureString';
|
||||
}
|
||||
|
||||
async _getValueFromSSM(name: string): Promise<{ val: string; metadata: ResponseMetadata }> {
|
||||
const param: GetParameterCommandInput = {
|
||||
Name: name,
|
||||
};
|
||||
|
||||
if (this.enableDecryption) {
|
||||
param.WithDecryption = await this._isSecureString(name);
|
||||
}
|
||||
|
||||
const command: GetParameterCommand = new GetParameterCommand(param);
|
||||
|
||||
const res = await this.client.send(command);
|
||||
|
||||
if (!res.Parameter) {
|
||||
throw new FlagNotFoundError(`Unable to find an SSM Parameter with key ${name}`);
|
||||
}
|
||||
|
||||
if (!res.Parameter.Value) {
|
||||
throw new ParseError(`Value is empty`);
|
||||
}
|
||||
|
||||
return { val: res.Parameter.Value, metadata: res.$metadata };
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import type { SSMClientConfig } from '@aws-sdk/client-ssm';
|
||||
|
||||
export type AwsSsmProviderConfig = {
|
||||
ssmClientConfig: SSMClientConfig;
|
||||
cacheOpts: LRUCacheConfig;
|
||||
enableDecryption?: boolean;
|
||||
};
|
||||
|
||||
export type LRUCacheConfig = {
|
||||
enabled: boolean;
|
||||
ttl?: number;
|
||||
size?: number;
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ES6",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||
}
|
|
@ -1,5 +1,12 @@
|
|||
# Changelog
|
||||
|
||||
## [0.1.6](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-web-provider-v0.1.5...config-cat-web-provider-v0.1.6) (2025-04-09)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* **config-cat:** Rework error reporting ([#1242](https://github.com/open-feature/js-sdk-contrib/issues/1242)) ([0425619](https://github.com/open-feature/js-sdk-contrib/commit/04256197bf6e7da70afd4ac1c31bdaf55ce4b789))
|
||||
|
||||
## [0.1.5](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-web-provider-v0.1.4...config-cat-web-provider-v0.1.5) (2025-03-14)
|
||||
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@openfeature/config-cat-web-provider",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openfeature/config-cat-web-provider",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"peerDependencies": {
|
||||
"@openfeature/web-sdk": "^1.0.0",
|
||||
"configcat-js-ssr": "^8.4.3"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openfeature/config-cat-web-provider",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
|
||||
|
|
|
@ -1,13 +1,7 @@
|
|||
import { ConfigCatWebProvider } from './config-cat-web-provider';
|
||||
import {
|
||||
createConsoleLogger,
|
||||
createFlagOverridesFromMap,
|
||||
HookEvents,
|
||||
ISettingUnion,
|
||||
LogLevel,
|
||||
OverrideBehaviour,
|
||||
} from 'configcat-js-ssr';
|
||||
import { EventEmitter } from 'events';
|
||||
import type { HookEvents, IConfigCatCache, ISettingUnion } from 'configcat-js-ssr';
|
||||
import { createConsoleLogger, createFlagOverridesFromMap, LogLevel, OverrideBehaviour } from 'configcat-js-ssr';
|
||||
import type { EventEmitter } from 'events';
|
||||
import { ProviderEvents, ParseError, FlagNotFoundError, TypeMismatchError } from '@openfeature/web-sdk';
|
||||
|
||||
describe('ConfigCatWebProvider', () => {
|
||||
|
@ -81,30 +75,47 @@ describe('ConfigCatWebProvider', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should emit PROVIDER_ERROR event', () => {
|
||||
const handler = jest.fn();
|
||||
const eventData: [string, unknown] = ['error', { error: 'error' }];
|
||||
it("should emit PROVIDER_READY event when underlying client is initialized after provider's initialize", async () => {
|
||||
const cacheValue = '253370761200000\nW/"12345678-90a"\n{"f":{"booleanTrue":{"t":0,"v":{"b":true}}}}';
|
||||
|
||||
provider.events.addHandler(ProviderEvents.Error, handler);
|
||||
configCatEmitter.emit('clientError', ...eventData);
|
||||
const fakeSharedCache = new (class implements IConfigCatCache {
|
||||
private _value?: string;
|
||||
get(key: string) {
|
||||
return this._value;
|
||||
}
|
||||
set(key: string, value: string) {
|
||||
this._value = value;
|
||||
}
|
||||
})();
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
message: eventData[0],
|
||||
metadata: eventData[1],
|
||||
const provider = ConfigCatWebProvider.create('configcat-sdk-1/1234567890123456789012/1234567890123456789012', {
|
||||
cache: fakeSharedCache,
|
||||
logger: createConsoleLogger(LogLevel.Off),
|
||||
offline: true,
|
||||
maxInitWaitTimeSeconds: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit PROVIDER_READY event after successful evaluation during ERROR condition', async () => {
|
||||
const errorHandler = jest.fn();
|
||||
provider.events.addHandler(ProviderEvents.Error, errorHandler);
|
||||
|
||||
configCatEmitter.emit('clientError', 'error', { error: 'error' });
|
||||
expect(errorHandler).toHaveBeenCalled();
|
||||
|
||||
const readyHandler = jest.fn();
|
||||
provider.events.addHandler(ProviderEvents.Ready, readyHandler);
|
||||
|
||||
await provider.resolveBooleanEvaluation('booleanTrue', false, { targetingKey });
|
||||
try {
|
||||
await provider.initialize();
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toContain('underlying ConfigCat client could not initialize');
|
||||
}
|
||||
|
||||
expect(readyHandler).toHaveBeenCalledTimes(0);
|
||||
|
||||
fakeSharedCache.set('', cacheValue);
|
||||
|
||||
// Make sure that the internal cache is refreshed.
|
||||
await provider.configCatClient?.forceRefreshAsync();
|
||||
|
||||
provider.resolveBooleanEvaluation('booleanTrue', false, { targetingKey });
|
||||
|
||||
// Wait a little while for the Ready event to be emitted.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(readyHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,36 +1,20 @@
|
|||
import type { EvaluationContext, JsonValue, Paradigm, Provider, ResolutionDetails } from '@openfeature/web-sdk';
|
||||
import {
|
||||
EvaluationContext,
|
||||
JsonValue,
|
||||
OpenFeatureEventEmitter,
|
||||
Paradigm,
|
||||
ParseError,
|
||||
Provider,
|
||||
ProviderEvents,
|
||||
ProviderNotReadyError,
|
||||
ResolutionDetails,
|
||||
TypeMismatchError,
|
||||
} from '@openfeature/web-sdk';
|
||||
import {
|
||||
isType,
|
||||
parseError,
|
||||
PrimitiveType,
|
||||
PrimitiveTypeName,
|
||||
toResolutionDetails,
|
||||
transformContext,
|
||||
} from '@openfeature/config-cat-core';
|
||||
import {
|
||||
getClient,
|
||||
IConfig,
|
||||
IConfigCatClient,
|
||||
OptionsForPollingMode,
|
||||
PollingMode,
|
||||
SettingValue,
|
||||
} from 'configcat-js-ssr';
|
||||
import type { PrimitiveType, PrimitiveTypeName } from '@openfeature/config-cat-core';
|
||||
import { isType, parseError, toResolutionDetails, transformContext } from '@openfeature/config-cat-core';
|
||||
import type { IConfig, IConfigCatClient, OptionsForPollingMode, SettingValue } from 'configcat-js-ssr';
|
||||
import { ClientCacheState, getClient, PollingMode } from 'configcat-js-ssr';
|
||||
|
||||
export class ConfigCatWebProvider implements Provider {
|
||||
public readonly events = new OpenFeatureEventEmitter();
|
||||
private readonly _clientFactory: (provider: ConfigCatWebProvider) => IConfigCatClient;
|
||||
private _hasError = false;
|
||||
private _isProviderReady = false;
|
||||
private _client?: IConfigCatClient;
|
||||
|
||||
public runsOn: Paradigm = 'client';
|
||||
|
@ -53,19 +37,11 @@ export class ConfigCatWebProvider implements Provider {
|
|||
options.setupHooks = (hooks) => {
|
||||
oldSetupHooks?.(hooks);
|
||||
|
||||
hooks.on('configChanged', (projectConfig: IConfig | undefined) =>
|
||||
hooks.on('configChanged', (config: IConfig) =>
|
||||
provider.events.emit(ProviderEvents.ConfigurationChanged, {
|
||||
flagsChanged: projectConfig ? Object.keys(projectConfig.settings) : undefined,
|
||||
flagsChanged: Object.keys(config.settings),
|
||||
}),
|
||||
);
|
||||
|
||||
hooks.on('clientError', (message: string, error) => {
|
||||
provider._hasError = true;
|
||||
provider.events.emit(ProviderEvents.Error, {
|
||||
message: message,
|
||||
metadata: error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return getClient(sdkKey, PollingMode.AutoPoll, options);
|
||||
|
@ -74,8 +50,19 @@ export class ConfigCatWebProvider implements Provider {
|
|||
|
||||
public async initialize(): Promise<void> {
|
||||
const client = this._clientFactory(this);
|
||||
await client.waitForReady();
|
||||
const clientCacheState = await client.waitForReady();
|
||||
this._client = client;
|
||||
|
||||
if (clientCacheState !== ClientCacheState.NoFlagData) {
|
||||
this._isProviderReady = true;
|
||||
} else {
|
||||
// OpenFeature provider defines ready state like this: "The provider is ready to resolve flags."
|
||||
// However, ConfigCat client's behavior is different: in some cases ready state may be reached
|
||||
// even if the client's internal, in-memory cache hasn't been populated yet, that is,
|
||||
// the client is not able to evaluate feature flags yet. In such cases we throw an error to
|
||||
// prevent the provider from being set ready right away, and check for the ready state later.
|
||||
throw Error('The underlying ConfigCat client could not initialize within maxInitWaitTimeSeconds.');
|
||||
}
|
||||
}
|
||||
|
||||
public get configCatClient() {
|
||||
|
@ -137,13 +124,22 @@ export class ConfigCatWebProvider implements Provider {
|
|||
|
||||
const configCatDefaultValue = flagType !== 'object' ? (defaultValue as SettingValue) : JSON.stringify(defaultValue);
|
||||
|
||||
const { value, ...evaluationData } = this._client
|
||||
.snapshot()
|
||||
.getValueDetails(flagKey, configCatDefaultValue, transformContext(context));
|
||||
const snapshot = this._client.snapshot();
|
||||
|
||||
if (this._hasError && !evaluationData.errorMessage && !evaluationData.errorException) {
|
||||
this._hasError = false;
|
||||
this.events.emit(ProviderEvents.Ready);
|
||||
const { value, ...evaluationData } = snapshot.getValueDetails(
|
||||
flagKey,
|
||||
configCatDefaultValue,
|
||||
transformContext(context),
|
||||
);
|
||||
|
||||
if (!this._isProviderReady && snapshot.cacheState !== ClientCacheState.NoFlagData) {
|
||||
// Ideally, we would check ConfigCat client's initialization state in its "background" polling loop.
|
||||
// This is not possible at the moment, so as a workaround, we do the check on feature flag evaluation.
|
||||
// There are plans to improve this situation, so let's revise this
|
||||
// as soon as ConfigCat SDK implements the necessary event.
|
||||
|
||||
this._isProviderReady = true;
|
||||
setTimeout(() => this.events.emit(ProviderEvents.Ready), 0);
|
||||
}
|
||||
|
||||
if (evaluationData.isDefaultValue) {
|
||||
|
|
|
@ -1,5 +1,19 @@
|
|||
# Changelog
|
||||
|
||||
## [0.7.6](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.7.5...config-cat-provider-v0.7.6) (2025-07-04)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* **security:** update dependency configcat-common to v9.4.0 ([#1348](https://github.com/open-feature/js-sdk-contrib/issues/1348)) ([601e7de](https://github.com/open-feature/js-sdk-contrib/commit/601e7de19948bc826778a076f27b46a8cb1fabca))
|
||||
|
||||
## [0.7.5](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.7.4...config-cat-provider-v0.7.5) (2025-04-09)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* **config-cat:** Rework error reporting ([#1242](https://github.com/open-feature/js-sdk-contrib/issues/1242)) ([0425619](https://github.com/open-feature/js-sdk-contrib/commit/04256197bf6e7da70afd4ac1c31bdaf55ce4b789))
|
||||
|
||||
## [0.7.4](https://github.com/open-feature/js-sdk-contrib/compare/config-cat-provider-v0.7.3...config-cat-provider-v0.7.4) (2025-03-14)
|
||||
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@openfeature/config-cat-provider",
|
||||
"version": "0.7.4",
|
||||
"version": "0.7.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openfeature/config-cat-provider",
|
||||
"version": "0.7.4",
|
||||
"version": "0.7.6",
|
||||
"peerDependencies": {
|
||||
"@openfeature/server-sdk": "^1.13.5",
|
||||
"configcat-node": "^11.3.1"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openfeature/config-cat-provider",
|
||||
"version": "0.7.4",
|
||||
"version": "0.7.6",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
|
||||
|
@ -10,6 +10,6 @@
|
|||
"@openfeature/server-sdk": "^1.13.5",
|
||||
"configcat-node": "^11.3.1",
|
||||
"@openfeature/config-cat-core": "0.1.1",
|
||||
"configcat-common": "9.3.1"
|
||||
"configcat-common": "9.4.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
import { ConfigCatProvider } from './config-cat-provider';
|
||||
import { ProviderEvents, ParseError, FlagNotFoundError, TypeMismatchError } from '@openfeature/web-sdk';
|
||||
import type { HookEvents, IConfigCatCache, ISettingUnion } from 'configcat-js-ssr';
|
||||
import {
|
||||
createConsoleLogger,
|
||||
createFlagOverridesFromMap,
|
||||
HookEvents,
|
||||
ISettingUnion,
|
||||
LogLevel,
|
||||
OverrideBehaviour,
|
||||
PollingMode,
|
||||
} from 'configcat-js-ssr';
|
||||
import { EventEmitter } from 'events';
|
||||
import type { EventEmitter } from 'events';
|
||||
|
||||
describe('ConfigCatProvider', () => {
|
||||
const targetingKey = 'abc';
|
||||
|
@ -82,30 +81,51 @@ describe('ConfigCatProvider', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should emit PROVIDER_ERROR event', () => {
|
||||
const handler = jest.fn();
|
||||
const eventData: [string, unknown] = ['error', { error: 'error' }];
|
||||
it("should emit PROVIDER_READY event when underlying client is initialized after provider's initialize", async () => {
|
||||
const cacheValue = '253370761200000\nW/"12345678-90a"\n{"f":{"booleanTrue":{"t":0,"v":{"b":true}}}}';
|
||||
|
||||
provider.events.addHandler(ProviderEvents.Error, handler);
|
||||
configCatEmitter.emit('clientError', ...eventData);
|
||||
const fakeSharedCache = new (class implements IConfigCatCache {
|
||||
private _value?: string;
|
||||
get(key: string) {
|
||||
return this._value;
|
||||
}
|
||||
set(key: string, value: string) {
|
||||
this._value = value;
|
||||
}
|
||||
})();
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
message: eventData[0],
|
||||
metadata: eventData[1],
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit PROVIDER_READY event after successful evaluation during ERROR condition', async () => {
|
||||
const errorHandler = jest.fn();
|
||||
provider.events.addHandler(ProviderEvents.Error, errorHandler);
|
||||
|
||||
configCatEmitter.emit('clientError', 'error', { error: 'error' });
|
||||
expect(errorHandler).toHaveBeenCalled();
|
||||
const provider = ConfigCatProvider.create(
|
||||
'configcat-sdk-1/1234567890123456789012/1234567890123456789012',
|
||||
PollingMode.AutoPoll,
|
||||
{
|
||||
cache: fakeSharedCache,
|
||||
logger: createConsoleLogger(LogLevel.Off),
|
||||
offline: true,
|
||||
maxInitWaitTimeSeconds: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const readyHandler = jest.fn();
|
||||
provider.events.addHandler(ProviderEvents.Ready, readyHandler);
|
||||
|
||||
await provider.resolveBooleanEvaluation('booleanTrue', false, { targetingKey });
|
||||
try {
|
||||
await provider.initialize();
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toContain('underlying ConfigCat client could not initialize');
|
||||
}
|
||||
|
||||
expect(readyHandler).toHaveBeenCalledTimes(0);
|
||||
|
||||
fakeSharedCache.set('', cacheValue);
|
||||
|
||||
// Make sure that the internal cache is refreshed.
|
||||
await provider.configCatClient?.forceRefreshAsync();
|
||||
|
||||
provider.resolveBooleanEvaluation('booleanTrue', false, { targetingKey });
|
||||
|
||||
// Wait a little while for the Ready event to be emitted.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(readyHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,30 +1,23 @@
|
|||
import type { EvaluationContext, JsonValue, Provider, ResolutionDetails, Paradigm } from '@openfeature/server-sdk';
|
||||
import {
|
||||
EvaluationContext,
|
||||
JsonValue,
|
||||
OpenFeatureEventEmitter,
|
||||
Provider,
|
||||
ProviderEvents,
|
||||
ResolutionDetails,
|
||||
Paradigm,
|
||||
ProviderNotReadyError,
|
||||
TypeMismatchError,
|
||||
ParseError,
|
||||
} from '@openfeature/server-sdk';
|
||||
import {
|
||||
isType,
|
||||
parseError,
|
||||
PrimitiveType,
|
||||
PrimitiveTypeName,
|
||||
toResolutionDetails,
|
||||
transformContext,
|
||||
} from '@openfeature/config-cat-core';
|
||||
import { PollingMode, SettingValue } from 'configcat-common';
|
||||
import { IConfigCatClient, getClient, IConfig, OptionsForPollingMode } from 'configcat-node';
|
||||
import type { PrimitiveType, PrimitiveTypeName } from '@openfeature/config-cat-core';
|
||||
import { isType, parseError, toResolutionDetails, transformContext } from '@openfeature/config-cat-core';
|
||||
import type { SettingValue } from 'configcat-common';
|
||||
import { ClientCacheState, PollingMode } from 'configcat-common';
|
||||
import type { IConfigCatClient, IConfig, OptionsForPollingMode } from 'configcat-node';
|
||||
import { getClient } from 'configcat-node';
|
||||
|
||||
export class ConfigCatProvider implements Provider {
|
||||
public readonly events = new OpenFeatureEventEmitter();
|
||||
private readonly _clientFactory: (provider: ConfigCatProvider) => IConfigCatClient;
|
||||
private _hasError = false;
|
||||
private readonly _pollingMode: PollingMode;
|
||||
private _isProviderReady = false;
|
||||
private _client?: IConfigCatClient;
|
||||
|
||||
public runsOn: Paradigm = 'server';
|
||||
|
@ -33,8 +26,9 @@ export class ConfigCatProvider implements Provider {
|
|||
name: ConfigCatProvider.name,
|
||||
};
|
||||
|
||||
protected constructor(clientFactory: (provider: ConfigCatProvider) => IConfigCatClient) {
|
||||
protected constructor(clientFactory: (provider: ConfigCatProvider) => IConfigCatClient, pollingMode: PollingMode) {
|
||||
this._clientFactory = clientFactory;
|
||||
this._pollingMode = pollingMode;
|
||||
}
|
||||
|
||||
public static create<TMode extends PollingMode>(
|
||||
|
@ -50,29 +44,32 @@ export class ConfigCatProvider implements Provider {
|
|||
options.setupHooks = (hooks) => {
|
||||
oldSetupHooks?.(hooks);
|
||||
|
||||
hooks.on('configChanged', (projectConfig: IConfig | undefined) =>
|
||||
hooks.on('configChanged', (config: IConfig) =>
|
||||
provider.events.emit(ProviderEvents.ConfigurationChanged, {
|
||||
flagsChanged: projectConfig ? Object.keys(projectConfig.settings) : undefined,
|
||||
flagsChanged: Object.keys(config.settings),
|
||||
}),
|
||||
);
|
||||
|
||||
hooks.on('clientError', (message: string, error) => {
|
||||
provider._hasError = true;
|
||||
provider.events.emit(ProviderEvents.Error, {
|
||||
message: message,
|
||||
metadata: error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return getClient(sdkKey, pollingMode, options);
|
||||
});
|
||||
}, pollingMode ?? PollingMode.AutoPoll);
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
const client = this._clientFactory(this);
|
||||
await client.waitForReady();
|
||||
const clientCacheState = await client.waitForReady();
|
||||
this._client = client;
|
||||
|
||||
if (this._pollingMode !== PollingMode.AutoPoll || clientCacheState !== ClientCacheState.NoFlagData) {
|
||||
this._isProviderReady = true;
|
||||
} else {
|
||||
// OpenFeature provider defines ready state like this: "The provider is ready to resolve flags."
|
||||
// However, ConfigCat client's behavior is different: in some cases ready state may be reached
|
||||
// even if the client's internal, in-memory cache hasn't been populated yet, that is,
|
||||
// the client is not able to evaluate feature flags yet. In such cases we throw an error to
|
||||
// prevent the provider from being set ready right away, and check for the ready state later.
|
||||
throw Error('The underlying ConfigCat client could not initialize within maxInitWaitTimeSeconds.');
|
||||
}
|
||||
}
|
||||
|
||||
public get configCatClient() {
|
||||
|
@ -140,9 +137,14 @@ export class ConfigCatProvider implements Provider {
|
|||
transformContext(context),
|
||||
);
|
||||
|
||||
if (this._hasError && !evaluationData.errorMessage && !evaluationData.errorException) {
|
||||
this._hasError = false;
|
||||
this.events.emit(ProviderEvents.Ready);
|
||||
if (!this._isProviderReady && this._client.snapshot().cacheState !== ClientCacheState.NoFlagData) {
|
||||
// Ideally, we would check ConfigCat client's initialization state in its "background" polling loop.
|
||||
// This is not possible at the moment, so as a workaround, we do the check on feature flag evaluation.
|
||||
// There are plans to improve this situation, so let's revise this
|
||||
// as soon as ConfigCat SDK implements the necessary event.
|
||||
|
||||
this._isProviderReady = true;
|
||||
setTimeout(() => this.events.emit(ProviderEvents.Ready), 0);
|
||||
}
|
||||
|
||||
if (evaluationData.isDefaultValue) {
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
import {
|
||||
FlagNotFoundError,
|
||||
JsonValue,
|
||||
ParseError,
|
||||
Provider,
|
||||
ResolutionDetails,
|
||||
StandardResolutionReasons,
|
||||
} from '@openfeature/server-sdk';
|
||||
import type { JsonValue, Provider, ResolutionDetails } from '@openfeature/server-sdk';
|
||||
import { FlagNotFoundError, ParseError, StandardResolutionReasons } from '@openfeature/server-sdk';
|
||||
import { constantCase } from './constant-case';
|
||||
|
||||
export type Config = {
|
||||
|
|
|
@ -1,5 +1,22 @@
|
|||
# Changelog
|
||||
|
||||
## [0.7.3](https://github.com/open-feature/js-sdk-contrib/compare/flagd-web-provider-v0.7.2...flagd-web-provider-v0.7.3) (2025-03-19)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* **flagd:** add flag metadata ([#1151](https://github.com/open-feature/js-sdk-contrib/issues/1151)) ([b1c6d23](https://github.com/open-feature/js-sdk-contrib/commit/b1c6d235565f6cce02519d7c08bb6ad2dd791332))
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* **deps:** update libs/providers/flagd-web/schemas digest to 37baa2c ([#1148](https://github.com/open-feature/js-sdk-contrib/issues/1148)) ([36ec82a](https://github.com/open-feature/js-sdk-contrib/commit/36ec82a5581e436b699d4b8238908fa4d5817deb))
|
||||
* **deps:** update libs/providers/flagd-web/schemas digest to b81a56e ([#1131](https://github.com/open-feature/js-sdk-contrib/issues/1131)) ([828145a](https://github.com/open-feature/js-sdk-contrib/commit/828145a89da13bbd90bca352a6488aecb62b764b))
|
||||
* **deps:** update libs/providers/flagd-web/schemas digest to bb76343 ([#1167](https://github.com/open-feature/js-sdk-contrib/issues/1167)) ([cb7bdb1](https://github.com/open-feature/js-sdk-contrib/commit/cb7bdb12a19ea403eb834d0eb552f30b42921b6b))
|
||||
* removing build dependencies and using testcontainers for container spin up ([#982](https://github.com/open-feature/js-sdk-contrib/issues/982)) ([2d64331](https://github.com/open-feature/js-sdk-contrib/commit/2d6433101b76ba9ad266095fe31b58314f82a105))
|
||||
* update nx packages ([#1147](https://github.com/open-feature/js-sdk-contrib/issues/1147)) ([7f310fe](https://github.com/open-feature/js-sdk-contrib/commit/7f310fe87101b8aa793e1436e63c7602ccc202e3))
|
||||
* various gherkin improvements for e2e tests ([#1008](https://github.com/open-feature/js-sdk-contrib/issues/1008)) ([40abd8e](https://github.com/open-feature/js-sdk-contrib/commit/40abd8eca76b47bb5c084b377302821968acd19c))
|
||||
|
||||
## [0.7.2](https://github.com/open-feature/js-sdk-contrib/compare/flagd-web-provider-v0.7.1...flagd-web-provider-v0.7.2) (2024-07-08)
|
||||
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@openfeature/flagd-web-provider",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openfeature/flagd-web-provider",
|
||||
"version": "0.7.2"
|
||||
"version": "0.7.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openfeature/flagd-web-provider",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
|
||||
|
@ -10,7 +10,7 @@
|
|||
"@openfeature/web-sdk": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openfeature/flagd-core": "1.0.0",
|
||||
"@openfeature/flagd-core": "1.1.0",
|
||||
"@connectrpc/connect": "^1.4.0",
|
||||
"@connectrpc/connect-web": "^1.4.0",
|
||||
"@bufbuild/protobuf": "^1.2.0"
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit bb763438abc5b0e97dc26a795bc72b7a9f3e4020
|
||||
Subproject commit 2852d7772e6b8674681a6ee6b88db10dbe3f6899
|
|
@ -2,4 +2,7 @@ import { getGherkinTestPath } from '@openfeature/flagd-core';
|
|||
|
||||
export const FLAGD_NAME = 'flagd';
|
||||
|
||||
export const GHERKIN_EVALUATION_FEATURE = getGherkinTestPath('flagd.feature');
|
||||
export const GHERKIN_EVALUATION_FEATURE = getGherkinTestPath(
|
||||
'evaluation.feature',
|
||||
'spec/specification/assets/gherkin/',
|
||||
);
|
||||
|
|
|
@ -1,12 +1,6 @@
|
|||
import { StepDefinitions } from 'jest-cucumber';
|
||||
import {
|
||||
EvaluationDetails,
|
||||
FlagValue,
|
||||
JsonObject,
|
||||
OpenFeature,
|
||||
ProviderEvents,
|
||||
StandardResolutionReasons,
|
||||
} from '@openfeature/web-sdk';
|
||||
import type { StepDefinitions } from 'jest-cucumber';
|
||||
import type { EvaluationContext, EvaluationDetails, FlagValue, JsonObject } from '@openfeature/web-sdk';
|
||||
import { OpenFeature, ProviderEvents, StandardResolutionReasons } from '@openfeature/web-sdk';
|
||||
import { E2E_CLIENT_NAME } from '@openfeature/flagd-core';
|
||||
|
||||
export const flagStepDefinitions: StepDefinitions = ({ given, and, when, then }) => {
|
||||
|
@ -14,6 +8,7 @@ export const flagStepDefinitions: StepDefinitions = ({ given, and, when, then })
|
|||
let value: FlagValue;
|
||||
let details: EvaluationDetails<FlagValue>;
|
||||
let fallback: FlagValue;
|
||||
let context: EvaluationContext;
|
||||
|
||||
const client = OpenFeature.getClient(E2E_CLIENT_NAME);
|
||||
|
||||
|
@ -23,7 +18,7 @@ export const flagStepDefinitions: StepDefinitions = ({ given, and, when, then })
|
|||
});
|
||||
});
|
||||
|
||||
given('a provider is registered', () => undefined);
|
||||
given('a stable provider', () => undefined);
|
||||
given('a flagd provider is set', () => undefined);
|
||||
|
||||
when(
|
||||
|
@ -85,6 +80,32 @@ export const flagStepDefinitions: StepDefinitions = ({ given, and, when, then })
|
|||
value = client.getObjectValue(key, defaultValue);
|
||||
});
|
||||
|
||||
and(/^a flag with key "(.*)" is evaluated with default value "(.*)"$/, async (key, defaultValue) => {
|
||||
await OpenFeature.setContext(context);
|
||||
flagKey = key;
|
||||
fallback = defaultValue;
|
||||
value = client.getStringValue(flagKey, fallback as string);
|
||||
});
|
||||
|
||||
when(
|
||||
/^context contains keys "(.*)", "(.*)", "(.*)", "(.*)" with values "(.*)", "(.*)", (\d+), "(.*)"$/,
|
||||
(key0, key1, key2, key3, stringVal1, stringVal2, intVal, boolVal) => {
|
||||
context = {
|
||||
[key0]: stringVal1,
|
||||
[key1]: stringVal2,
|
||||
[key2]: Number.parseInt(intVal),
|
||||
[key3]: boolVal === true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
and(/^the resolved flag value is "(.*)" when the context is empty$/, async (expectedValue) => {
|
||||
context = {};
|
||||
await OpenFeature.setContext(context);
|
||||
value = client.getStringValue(flagKey, fallback as string);
|
||||
expect(value).toEqual(expectedValue);
|
||||
});
|
||||
|
||||
then(
|
||||
/^the resolved object value should be contain fields "(.*)", "(.*)", and "(.*)", with values "(.*)", "(.*)" and (\d+), respectively$/,
|
||||
(field1: string, field2: string, field3: string, boolValue: string, stringValue: string, intValue: string) => {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import assert from 'assert';
|
||||
import { OpenFeature } from '@openfeature/web-sdk';
|
||||
import { GenericContainer, StartedTestContainer } from 'testcontainers';
|
||||
import type { StartedTestContainer } from 'testcontainers';
|
||||
import { GenericContainer } from 'testcontainers';
|
||||
import { FlagdWebProvider } from '../../lib/flagd-web-provider';
|
||||
import { autoBindSteps, loadFeature } from 'jest-cucumber';
|
||||
import { FLAGD_NAME, GHERKIN_EVALUATION_FEATURE } from '../constants';
|
||||
|
|
|
@ -1,16 +1,11 @@
|
|||
import { CallbackClient, Code, ConnectError, PromiseClient } from '@connectrpc/connect';
|
||||
import type { CallbackClient, ConnectError, PromiseClient } from '@connectrpc/connect';
|
||||
import { Code } from '@connectrpc/connect';
|
||||
import { Struct } from '@bufbuild/protobuf';
|
||||
import {
|
||||
Client,
|
||||
ErrorCode,
|
||||
JsonValue,
|
||||
OpenFeature,
|
||||
ProviderEvents,
|
||||
StandardResolutionReasons,
|
||||
} from '@openfeature/web-sdk';
|
||||
import type { Client, JsonValue } from '@openfeature/web-sdk';
|
||||
import { ErrorCode, OpenFeature, ProviderEvents, StandardResolutionReasons } from '@openfeature/web-sdk';
|
||||
import fetchMock from 'jest-fetch-mock';
|
||||
import { Service } from '../proto/ts/flagd/evaluation/v1/evaluation_connect';
|
||||
import { AnyFlag, EventStreamResponse, ResolveAllResponse } from '../proto/ts/flagd/evaluation/v1/evaluation_pb';
|
||||
import type { Service } from '../proto/ts/flagd/evaluation/v1/evaluation_connect';
|
||||
import type { AnyFlag, EventStreamResponse, ResolveAllResponse } from '../proto/ts/flagd/evaluation/v1/evaluation_pb';
|
||||
import { FlagdWebProvider } from './flagd-web-provider';
|
||||
|
||||
const EVENT_CONFIGURATION_CHANGE = 'configuration_change';
|
||||
|
|
|
@ -1,23 +1,27 @@
|
|||
import { CallbackClient, createCallbackClient, createPromiseClient, PromiseClient } from '@connectrpc/connect';
|
||||
import type { CallbackClient, PromiseClient } from '@connectrpc/connect';
|
||||
import { createCallbackClient, createPromiseClient } from '@connectrpc/connect';
|
||||
import { createConnectTransport } from '@connectrpc/connect-web';
|
||||
import { Struct } from '@bufbuild/protobuf';
|
||||
import {
|
||||
import type {
|
||||
EvaluationContext,
|
||||
FlagNotFoundError,
|
||||
FlagValue,
|
||||
JsonValue,
|
||||
Logger,
|
||||
Provider,
|
||||
ResolutionDetails,
|
||||
} from '@openfeature/web-sdk';
|
||||
import {
|
||||
FlagNotFoundError,
|
||||
OpenFeature,
|
||||
OpenFeatureEventEmitter,
|
||||
Provider,
|
||||
ProviderEvents,
|
||||
ResolutionDetails,
|
||||
StandardResolutionReasons,
|
||||
TypeMismatchError,
|
||||
} from '@openfeature/web-sdk';
|
||||
import { Service } from '../proto/ts/flagd/evaluation/v1/evaluation_connect';
|
||||
import { AnyFlag } from '../proto/ts/flagd/evaluation/v1/evaluation_pb';
|
||||
import { FlagdProviderOptions, getOptions } from './options';
|
||||
import type { AnyFlag } from '../proto/ts/flagd/evaluation/v1/evaluation_pb';
|
||||
import type { FlagdProviderOptions } from './options';
|
||||
import { getOptions } from './options';
|
||||
|
||||
export const ERROR_DISABLED = 'DISABLED';
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit bb763438abc5b0e97dc26a795bc72b7a9f3e4020
|
||||
Subproject commit 2852d7772e6b8674681a6ee6b88db10dbe3f6899
|
|
@ -1 +1 @@
|
|||
Subproject commit a69f748db2edfec7015ca6bb702ca22fd8c5ef30
|
||||
Subproject commit a3678719bf9f62880375ba835ebe1bb7a77de409
|
|
@ -4,9 +4,15 @@ export const FLAGD_NAME = 'flagd';
|
|||
export const UNSTABLE_CLIENT_NAME = 'unstable';
|
||||
export const UNAVAILABLE_CLIENT_NAME = 'unavailable';
|
||||
|
||||
export const GHERKIN_FLAGD_FEATURE = getGherkinTestPath('flagd.feature');
|
||||
export const GHERKIN_FLAGD_JSON_EVALUATOR_FEATURE = getGherkinTestPath('flagd-json-evaluator.feature');
|
||||
export const GHERKIN_FLAGD_RECONNECT_FEATURE = getGherkinTestPath('flagd-reconnect.feature');
|
||||
export const GHERKIN_FLAGD = getGherkinTestPath('*.feature');
|
||||
export const CONNECTION_FEATURE = getGherkinTestPath('connection.feature');
|
||||
export const CONTEXT_ENRICHMENT_FEATURE = getGherkinTestPath('contextEnrichment.feature');
|
||||
export const EVALUATION_FEATURE = getGherkinTestPath('evaluation.feature');
|
||||
export const EVENTS_FEATURE = getGherkinTestPath('events.feature');
|
||||
export const METADATA_FEATURE = getGherkinTestPath('metadata.feature');
|
||||
export const RPC_CACHING_FEATURE = getGherkinTestPath('rpc-caching.feature');
|
||||
export const SELECTOR_FEATURE = getGherkinTestPath('selector.feature');
|
||||
export const TARGETING_FEATURE = getGherkinTestPath('targeting.feature');
|
||||
export const GHERKIN_EVALUATION_FEATURE = getGherkinTestPath(
|
||||
'evaluation.feature',
|
||||
'spec/specification/assets/gherkin/',
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
export * from './constants';
|
||||
export * from './step-definitions';
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
import type { StepsDefinitionCallbackOptions } from 'jest-cucumber/dist/src/feature-definition-creation';
|
||||
import type { State, Steps } from './state';
|
||||
import { CacheOption, getConfig, ResolverType } from '../../lib/configuration';
|
||||
import { mapValueToType } from './utils';
|
||||
|
||||
export const configSteps: Steps = (state: State) => {
|
||||
function mapName(name: string): string {
|
||||
switch (name) {
|
||||
case 'resolver':
|
||||
return 'resolverType';
|
||||
default:
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
return ({ given, when, then }: StepsDefinitionCallbackOptions) => {
|
||||
beforeEach(() => {
|
||||
state.options = {};
|
||||
});
|
||||
given(/^an option "(.*)" of type "(.*)" with value "(.*)"$/, (name: string, type: string, value: string) => {
|
||||
state.options[mapName(name)] = mapValueToType(value, type);
|
||||
});
|
||||
given(/^an environment variable "(.*)" with value "(.*)"$/, (name, value) => {
|
||||
process.env[name] = value;
|
||||
});
|
||||
when('a config was initialized', () => {
|
||||
state.config = getConfig(state.options);
|
||||
});
|
||||
|
||||
then(
|
||||
/^the option "(.*)" of type "(.*)" should have the value "(.*)"$/,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
(_name: string, _type: string, _value: string) => {
|
||||
// TODO: implement with configuration unification, see: https://github.com/open-feature/js-sdk-contrib/issues/1096
|
||||
// const expected = mapValueToType(value, type);
|
||||
// const propertyName = mapName(name);
|
||||
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// // @ts-ignore
|
||||
// const configElement = state.config[propertyName];
|
||||
// expect(configElement).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
then('we should have an error', () => {});
|
||||
};
|
||||
};
|
|
@ -0,0 +1,33 @@
|
|||
import type { JsonObject } from '@openfeature/server-sdk';
|
||||
import type { State, Steps } from './state';
|
||||
import { mapValueToType } from './utils';
|
||||
|
||||
export const contextSteps: Steps =
|
||||
(state: State) =>
|
||||
({ given, when, then }) => {
|
||||
beforeEach(() => (state.context = undefined));
|
||||
given(
|
||||
/^a context containing a key "(.*)", with type "(.*)" and with value "(.*)"$/,
|
||||
(key: string, type: string, value: string) => {
|
||||
if (state.context == undefined) {
|
||||
state.context = {};
|
||||
}
|
||||
state.context[key] = mapValueToType(value, type);
|
||||
},
|
||||
);
|
||||
given(
|
||||
/^a context containing a nested property with outer key "(.*)" and inner key "(.*)", with value "(.*)"$/,
|
||||
(outer: string, inner: string, value) => {
|
||||
if (state.context == undefined) {
|
||||
state.context = {};
|
||||
}
|
||||
state.context[outer] = { ...(state.context[outer] as JsonObject), [inner]: value };
|
||||
},
|
||||
);
|
||||
given(/^a context containing a targeting key with value "(.*)"$/, (key) => {
|
||||
if (state.context == undefined) {
|
||||
state.context = {};
|
||||
}
|
||||
state.context.targetingKey = key;
|
||||
});
|
||||
};
|
|
@ -0,0 +1,49 @@
|
|||
import { ServerProviderEvents } from '@openfeature/server-sdk';
|
||||
import type { State, Steps } from './state';
|
||||
import { waitFor } from './utils';
|
||||
|
||||
export const eventSteps: Steps =
|
||||
(state: State) =>
|
||||
({ given, when, then }) => {
|
||||
function map(eventType: string): ServerProviderEvents {
|
||||
switch (eventType) {
|
||||
case 'error':
|
||||
return ServerProviderEvents.Error;
|
||||
case 'ready':
|
||||
return ServerProviderEvents.Ready;
|
||||
case 'stale':
|
||||
return ServerProviderEvents.Stale;
|
||||
case 'change':
|
||||
return ServerProviderEvents.ConfigurationChanged;
|
||||
|
||||
default:
|
||||
throw new Error('unknown eventtype');
|
||||
}
|
||||
}
|
||||
|
||||
given(/a (.*) event handler/, async (type: string) => {
|
||||
state.client?.addHandler(map(type), (details) => {
|
||||
state.events.push({ type, details });
|
||||
});
|
||||
});
|
||||
|
||||
then(/^the (.*) event handler should have been executed$/, async (type: string) => {
|
||||
await waitFor(() => expect(state.events.find((value) => value.type == type)).toBeDefined(), { timeout: 20000 });
|
||||
expect(state.events.find((value) => value.type == type)).toBeDefined();
|
||||
});
|
||||
|
||||
then(/^the (.*) event handler should have been executed within (\d+)ms$/, async (type: string, ms: number) => {
|
||||
await waitFor(() => expect(state.events.find((value) => value.type == type)).toBeDefined(), { timeout: ms });
|
||||
const actual = state.events.find((value) => value.type == type);
|
||||
expect(actual).toBeDefined();
|
||||
});
|
||||
|
||||
when(/^a (.*) event was fired$/, async (type: string) => {
|
||||
await waitFor(() => expect(state.events.find((value) => value.type == type)), { timeout: 2000 });
|
||||
expect(state.events.find((value) => value.type == type)).toBeDefined();
|
||||
});
|
||||
|
||||
then('the flag should be part of the event payload', async () => {
|
||||
await waitFor(() => expect(state.events.find((value) => value.type == 'change')), { timeout: 2000 });
|
||||
});
|
||||
};
|
|
@ -1,13 +1,6 @@
|
|||
import { StepDefinitions } from 'jest-cucumber';
|
||||
import {
|
||||
EvaluationContext,
|
||||
EvaluationDetails,
|
||||
FlagValue,
|
||||
JsonObject,
|
||||
OpenFeature,
|
||||
ProviderEvents,
|
||||
StandardResolutionReasons,
|
||||
} from '@openfeature/server-sdk';
|
||||
import type { StepDefinitions } from 'jest-cucumber';
|
||||
import type { EvaluationContext, EvaluationDetails, FlagValue, JsonObject } from '@openfeature/server-sdk';
|
||||
import { OpenFeature, ProviderEvents, StandardResolutionReasons } from '@openfeature/server-sdk';
|
||||
import { E2E_CLIENT_NAME } from '@openfeature/flagd-core';
|
||||
|
||||
export const flagStepDefinitions: StepDefinitions = ({ given, and, when, then }) => {
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
import type { State, Steps } from './state';
|
||||
import { mapValueToType } from './utils';
|
||||
|
||||
export const flagSteps: Steps =
|
||||
(state: State) =>
|
||||
({ given, when, then }) => {
|
||||
beforeEach(() => {
|
||||
state.flag = undefined;
|
||||
});
|
||||
|
||||
given(
|
||||
/^a (.*)-flag with key "(.*)" and a default value "(.*)"$/,
|
||||
(type: string, name: string, defaultValue: string) => {
|
||||
state.flag = {
|
||||
name,
|
||||
type,
|
||||
defaultValue: mapValueToType(defaultValue, type),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
when('the flag was evaluated with details', async () => {
|
||||
switch (state.flag?.type) {
|
||||
case 'Boolean':
|
||||
state.details = await state.client?.getBooleanDetails(
|
||||
state.flag?.name,
|
||||
<boolean>state.flag?.defaultValue,
|
||||
state.context,
|
||||
);
|
||||
break;
|
||||
case 'String':
|
||||
state.details = await state.client?.getStringDetails(
|
||||
state.flag?.name,
|
||||
<string>state.flag?.defaultValue,
|
||||
state.context,
|
||||
);
|
||||
break;
|
||||
case 'Integer':
|
||||
case 'Float':
|
||||
state.details = await state.client?.getNumberDetails(
|
||||
state.flag?.name,
|
||||
<number>state.flag?.defaultValue,
|
||||
state.context,
|
||||
);
|
||||
break;
|
||||
case 'Object':
|
||||
state.details = await state.client?.getObjectDetails(
|
||||
state.flag?.name,
|
||||
<boolean>state.flag?.defaultValue,
|
||||
state.context,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error('unknown type');
|
||||
}
|
||||
});
|
||||
|
||||
then(/^the resolved details value should be "(.*)"$/, (arg0) => {
|
||||
expect(state.details?.value).toEqual(mapValueToType(arg0, state.flag!.type));
|
||||
});
|
||||
|
||||
then(/^the reason should be "(.*)"$/, (expectedReason) => {
|
||||
expect(state.details?.reason).toBe(expectedReason);
|
||||
});
|
||||
|
||||
then(/^the variant should be "(.*)"$/, (expectedVariant) => {
|
||||
expect(state.details?.variant).toBe(expectedVariant);
|
||||
});
|
||||
|
||||
then('the resolved metadata should contain', (table) => {
|
||||
// TODO: implement metadata tests, https://github.com/open-feature/js-sdk-contrib/issues/1290
|
||||
});
|
||||
|
||||
then('the resolved metadata is empty', () => {
|
||||
// TODO: implement metadata tests, https://github.com/open-feature/js-sdk-contrib/issues/1290
|
||||
});
|
||||
};
|
|
@ -1,2 +0,0 @@
|
|||
export * from './flag';
|
||||
export * from './reconnect';
|
|
@ -0,0 +1,78 @@
|
|||
import { OpenFeature } from '@openfeature/server-sdk';
|
||||
import { FlagdContainer } from '../tests/flagdContainer';
|
||||
import type { State, Steps } from './state';
|
||||
import { FlagdProvider } from '../../lib/flagd-provider';
|
||||
import type { FlagdProviderOptions } from '../../lib/configuration';
|
||||
|
||||
export const providerSteps: Steps =
|
||||
(state: State) =>
|
||||
({ given, when, then }) => {
|
||||
const container: FlagdContainer = FlagdContainer.build();
|
||||
beforeAll(async () => {
|
||||
console.log('Setting flagd provider...');
|
||||
|
||||
return await container.start();
|
||||
}, 50000);
|
||||
|
||||
afterAll(async () => {
|
||||
await OpenFeature.close();
|
||||
await container.stop();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// everything breaks without this
|
||||
await OpenFeature.close();
|
||||
if (state.client) {
|
||||
await fetch('http://' + container.getLaunchpadUrl() + '/stop');
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, 50000);
|
||||
|
||||
given(/a (.*) flagd provider/, async (providerType: string) => {
|
||||
const flagdOptions: FlagdProviderOptions = {
|
||||
resolverType: state.resolverType,
|
||||
deadlineMs: 2000,
|
||||
};
|
||||
let type = 'default';
|
||||
switch (providerType) {
|
||||
default:
|
||||
flagdOptions['port'] = container.getPort(state.resolverType);
|
||||
if (state?.options?.['selector']) {
|
||||
flagdOptions['selector'] = state?.options?.['selector'] as string;
|
||||
}
|
||||
break;
|
||||
case 'unavailable':
|
||||
flagdOptions['port'] = 9999;
|
||||
break;
|
||||
case 'ssl':
|
||||
// TODO: modify this to support ssl
|
||||
flagdOptions['port'] = container.getPort(state.resolverType);
|
||||
if (state?.config?.selector) {
|
||||
flagdOptions['selector'] = state.config.selector;
|
||||
}
|
||||
type = 'ssl';
|
||||
break;
|
||||
}
|
||||
|
||||
await fetch('http://' + container.getLaunchpadUrl() + '/start?config=' + type);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
if (providerType == 'unavailable') {
|
||||
OpenFeature.setProvider(providerType, new FlagdProvider(flagdOptions));
|
||||
} else {
|
||||
await OpenFeature.setProviderAndWait(providerType, new FlagdProvider(flagdOptions));
|
||||
}
|
||||
|
||||
state.client = OpenFeature.getClient(providerType);
|
||||
state.providerType = providerType;
|
||||
});
|
||||
|
||||
when(/^the connection is lost for (\d+)s$/, async (time) => {
|
||||
console.log('stopping flagd');
|
||||
await fetch('http://' + container.getLaunchpadUrl() + '/restart?seconds=' + time);
|
||||
});
|
||||
|
||||
when('the flag was modified', async () => {
|
||||
await fetch('http://' + container.getLaunchpadUrl() + '/change');
|
||||
});
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import { StepDefinitions } from 'jest-cucumber';
|
||||
import type { StepDefinitions } from 'jest-cucumber';
|
||||
import { OpenFeature, ProviderEvents } from '@openfeature/server-sdk';
|
||||
import { UNAVAILABLE_CLIENT_NAME, UNSTABLE_CLIENT_NAME } from '../constants';
|
||||
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
import type { StepDefinitions } from 'jest-cucumber';
|
||||
import type { CacheOption, ResolverType } from '../../lib/configuration';
|
||||
import type { Client, EvaluationContext, EvaluationDetails, EventDetails, FlagValue } from '@openfeature/server-sdk';
|
||||
|
||||
interface Flag {
|
||||
name: string;
|
||||
type: string;
|
||||
defaultValue: unknown;
|
||||
}
|
||||
|
||||
interface Event {
|
||||
type: string;
|
||||
details?: EventDetails;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
flagsChanged?: string[];
|
||||
providerType?: string;
|
||||
details?: EvaluationDetails<FlagValue>;
|
||||
client?: Client;
|
||||
resolverType: ResolverType;
|
||||
context?: EvaluationContext;
|
||||
config?: {
|
||||
cache?: CacheOption;
|
||||
socketPath?: string;
|
||||
port: number;
|
||||
maxCacheSize?: number;
|
||||
resolverType?: ResolverType;
|
||||
host: string;
|
||||
offlineFlagSourcePath?: string;
|
||||
tls: boolean;
|
||||
selector?: string;
|
||||
};
|
||||
options: Record<string, unknown>;
|
||||
events: Event[];
|
||||
flag?: Flag;
|
||||
}
|
||||
|
||||
export type Steps = (state: State) => StepDefinitions;
|
|
@ -0,0 +1,53 @@
|
|||
import type { CacheOption, ResolverType } from '../../lib/configuration';
|
||||
|
||||
export function mapValueToType(value: string, type: string): any {
|
||||
switch (type) {
|
||||
case 'String':
|
||||
if (value == 'null') {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
case 'Integer':
|
||||
return Number.parseInt(value);
|
||||
case 'Float':
|
||||
return Number.parseFloat(value);
|
||||
case 'Long':
|
||||
return Number.parseFloat(value);
|
||||
case 'Boolean':
|
||||
return value.toLowerCase() === 'true';
|
||||
case 'ResolverType':
|
||||
return value.toLowerCase() as ResolverType;
|
||||
case 'CacheType':
|
||||
return value as CacheOption;
|
||||
case 'Object':
|
||||
if (value == 'null') {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(value);
|
||||
default:
|
||||
throw new Error('type not supported');
|
||||
}
|
||||
}
|
||||
|
||||
export function waitFor<T>(check: () => T, options: { timeout?: number; interval?: number } = {}): Promise<T> {
|
||||
const { timeout = 5000, interval = 50 } = options; // Default 5s timeout, 50ms polling interval
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const checkCondition = () => {
|
||||
try {
|
||||
const result = check();
|
||||
resolve(result); // If condition passes, resolve the promise
|
||||
} catch (error) {
|
||||
if (Date.now() - startTime > timeout) {
|
||||
reject(new Error('Timeout while waiting for condition'));
|
||||
} else {
|
||||
setTimeout(checkCondition, interval); // Retry after interval
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkCondition();
|
||||
});
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import type { StartedTestContainer, StoppedTestContainer } from 'testcontainers';
|
||||
import { GenericContainer } from 'testcontainers';
|
||||
import fs from 'fs';
|
||||
import * as path from 'node:path';
|
||||
import type { ResolverType } from '../../lib/configuration';
|
||||
|
||||
export class FlagdContainer extends GenericContainer {
|
||||
private static imageBase = 'ghcr.io/open-feature/flagd-testbed';
|
||||
private started: StartedTestContainer | undefined;
|
||||
private stopped: StoppedTestContainer | undefined;
|
||||
|
||||
public static build() {
|
||||
return new FlagdContainer(this.generateImageName());
|
||||
}
|
||||
|
||||
private constructor(image: string) {
|
||||
super(image);
|
||||
this.withExposedPorts(8080, 8013, 8014, 8015, 8016);
|
||||
}
|
||||
|
||||
isStarted(): boolean {
|
||||
return this.started !== undefined;
|
||||
}
|
||||
|
||||
async start(): Promise<StartedTestContainer> {
|
||||
if (this.isStarted()) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return Promise.resolve(this.started);
|
||||
}
|
||||
const containerPromise = super.start();
|
||||
this.started = await containerPromise;
|
||||
this.stopped = undefined;
|
||||
return containerPromise;
|
||||
}
|
||||
|
||||
async stop(): Promise<StoppedTestContainer> {
|
||||
if (!this.started) {
|
||||
throw new Error('container not started');
|
||||
}
|
||||
const containerPromise = this.started.stop();
|
||||
this.stopped = await containerPromise;
|
||||
this.started = undefined;
|
||||
return containerPromise;
|
||||
}
|
||||
|
||||
getLaunchpadUrl() {
|
||||
return this.started?.getHost() + ':' + this.started?.getMappedPort(8080);
|
||||
}
|
||||
|
||||
private static generateImageName(): string {
|
||||
const image = this.imageBase;
|
||||
const file = path.join(__dirname, './../../../../../shared/flagd-core/test-harness/', 'version.txt');
|
||||
const version = fs.readFileSync(file, 'utf8').trim();
|
||||
return `${image}:v${version}`;
|
||||
}
|
||||
|
||||
getPort(resolverType: ResolverType) {
|
||||
switch (resolverType) {
|
||||
default:
|
||||
return this.started?.getMappedPort(8013);
|
||||
case 'in-process':
|
||||
return this.started?.getMappedPort(8015);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
import assert from 'assert';
|
||||
import { OpenFeature } from '@openfeature/server-sdk';
|
||||
import { FlagdProvider } from '../../lib/flagd-provider';
|
||||
import { GenericContainer, StartedTestContainer } from 'testcontainers';
|
||||
import { autoBindSteps, loadFeature } from 'jest-cucumber';
|
||||
import {
|
||||
FLAGD_NAME,
|
||||
GHERKIN_FLAGD_RECONNECT_FEATURE,
|
||||
UNAVAILABLE_CLIENT_NAME,
|
||||
UNSTABLE_CLIENT_NAME,
|
||||
} from '../constants';
|
||||
import { IMAGE_VERSION } from '@openfeature/flagd-core';
|
||||
import { reconnectStepDefinitions } from '../step-definitions';
|
||||
|
||||
// register the flagd provider before the tests.
|
||||
async function setup() {
|
||||
const containers: StartedTestContainer[] = [];
|
||||
|
||||
console.log('Setting flagd provider...');
|
||||
const unstable = await new GenericContainer(`ghcr.io/open-feature/flagd-testbed-unstable:${IMAGE_VERSION}`)
|
||||
.withExposedPorts(8015)
|
||||
.start();
|
||||
containers.push(unstable);
|
||||
await OpenFeature.setProviderAndWait(
|
||||
UNSTABLE_CLIENT_NAME,
|
||||
new FlagdProvider({ resolverType: 'in-process', host: 'localhost', port: unstable.getFirstMappedPort() }),
|
||||
);
|
||||
|
||||
OpenFeature.setProvider(
|
||||
UNAVAILABLE_CLIENT_NAME,
|
||||
new FlagdProvider({ resolverType: 'in-process', host: 'localhost', port: 9092 }),
|
||||
);
|
||||
assert(
|
||||
OpenFeature.getProviderMetadata(UNSTABLE_CLIENT_NAME).name === FLAGD_NAME,
|
||||
new Error(
|
||||
`Expected ${FLAGD_NAME} provider to be configured, instead got: ${
|
||||
OpenFeature.getProviderMetadata(UNSTABLE_CLIENT_NAME).name
|
||||
}`,
|
||||
),
|
||||
);
|
||||
assert(
|
||||
OpenFeature.getProviderMetadata(UNAVAILABLE_CLIENT_NAME).name === FLAGD_NAME,
|
||||
new Error(
|
||||
`Expected ${FLAGD_NAME} provider to be configured, instead got: ${
|
||||
OpenFeature.getProviderMetadata(UNAVAILABLE_CLIENT_NAME).name
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
console.log('flagd provider configured!');
|
||||
return containers;
|
||||
}
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('in process', () => {
|
||||
let containers: StartedTestContainer[] = [];
|
||||
beforeAll(async () => {
|
||||
containers = await setup();
|
||||
}, 60000);
|
||||
afterAll(async () => {
|
||||
await OpenFeature.close();
|
||||
for (const container of containers) {
|
||||
await container.stop();
|
||||
}
|
||||
});
|
||||
const features = [loadFeature(GHERKIN_FLAGD_RECONNECT_FEATURE)];
|
||||
autoBindSteps(features, [reconnectStepDefinitions]);
|
||||
});
|
|
@ -1,60 +1,32 @@
|
|||
import assert from 'assert';
|
||||
import { OpenFeature } from '@openfeature/server-sdk';
|
||||
import { FlagdProvider } from '../../lib/flagd-provider';
|
||||
import { GenericContainer, StartedTestContainer } from 'testcontainers';
|
||||
import { autoBindSteps, loadFeature } from 'jest-cucumber';
|
||||
import {
|
||||
FLAGD_NAME,
|
||||
GHERKIN_EVALUATION_FEATURE,
|
||||
GHERKIN_FLAGD_FEATURE,
|
||||
GHERKIN_FLAGD_JSON_EVALUATOR_FEATURE,
|
||||
} from '../constants';
|
||||
import { flagStepDefinitions } from '../step-definitions';
|
||||
import { E2E_CLIENT_NAME, IMAGE_VERSION } from '@openfeature/flagd-core';
|
||||
import { autoBindSteps, loadFeatures } from 'jest-cucumber';
|
||||
import { GHERKIN_FLAGD } from '../constants';
|
||||
import { providerSteps } from '../step-definitions/providerSteps';
|
||||
import { configSteps } from '../step-definitions/configSteps';
|
||||
import type { State } from '../step-definitions/state';
|
||||
import { eventSteps } from '../step-definitions/eventSteps';
|
||||
import { flagSteps } from '../step-definitions/flagSteps';
|
||||
import { contextSteps } from '../step-definitions/contextSteps';
|
||||
|
||||
// register the flagd provider before the tests.
|
||||
async function setup() {
|
||||
const containers: StartedTestContainer[] = [];
|
||||
const steps = [providerSteps, configSteps, eventSteps, flagSteps, contextSteps];
|
||||
|
||||
console.log('Setting flagd provider...');
|
||||
|
||||
const stable = await new GenericContainer(`ghcr.io/open-feature/flagd-testbed:${IMAGE_VERSION}`)
|
||||
.withExposedPorts(8015)
|
||||
.start();
|
||||
containers.push(stable);
|
||||
OpenFeature.setProvider(
|
||||
E2E_CLIENT_NAME,
|
||||
new FlagdProvider({ resolverType: 'in-process', host: 'localhost', port: stable.getFirstMappedPort() }),
|
||||
jest.setTimeout(50000);
|
||||
describe('in-process', () => {
|
||||
const state: State = {
|
||||
resolverType: 'in-process',
|
||||
options: {},
|
||||
config: undefined,
|
||||
events: [],
|
||||
};
|
||||
autoBindSteps(
|
||||
loadFeatures(GHERKIN_FLAGD, {
|
||||
// remove filters as we add support for features
|
||||
// see: https://github.com/open-feature/js-sdk-contrib/issues/1096 and child issues
|
||||
tagFilter:
|
||||
'@in-process and not @targetURI and not @customCert and not @events and not @sync and not @grace and not @metadata and not @contextEnrichment',
|
||||
scenarioNameTemplate: (vars) => {
|
||||
return `${vars.scenarioTitle} (${vars.scenarioTags.join(',')} ${vars.featureTags.join(',')})`;
|
||||
},
|
||||
}),
|
||||
steps.map((step) => step(state)),
|
||||
);
|
||||
|
||||
assert(
|
||||
OpenFeature.getProviderMetadata(E2E_CLIENT_NAME).name === FLAGD_NAME,
|
||||
new Error(
|
||||
`Expected ${FLAGD_NAME} provider to be configured, instead got: ${
|
||||
OpenFeature.getProviderMetadata(E2E_CLIENT_NAME).name
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
console.log('flagd provider configured!');
|
||||
return containers;
|
||||
}
|
||||
|
||||
describe('in process', () => {
|
||||
let containers: StartedTestContainer[] = [];
|
||||
beforeAll(async () => {
|
||||
containers = await setup();
|
||||
}, 60000);
|
||||
afterAll(async () => {
|
||||
await OpenFeature.close();
|
||||
for (const container of containers) {
|
||||
await container.stop();
|
||||
}
|
||||
});
|
||||
const features = [
|
||||
loadFeature(GHERKIN_FLAGD_FEATURE),
|
||||
loadFeature(GHERKIN_EVALUATION_FEATURE),
|
||||
loadFeature(GHERKIN_FLAGD_JSON_EVALUATOR_FEATURE),
|
||||
];
|
||||
autoBindSteps(features, [flagStepDefinitions]);
|
||||
});
|
||||
|
|
|
@ -1,69 +0,0 @@
|
|||
import assert from 'assert';
|
||||
import { OpenFeature } from '@openfeature/server-sdk';
|
||||
import { FlagdProvider } from '../../lib/flagd-provider';
|
||||
import { GenericContainer, StartedTestContainer } from 'testcontainers';
|
||||
import { autoBindSteps, loadFeature } from 'jest-cucumber';
|
||||
import {
|
||||
FLAGD_NAME,
|
||||
GHERKIN_FLAGD_RECONNECT_FEATURE,
|
||||
UNAVAILABLE_CLIENT_NAME,
|
||||
UNSTABLE_CLIENT_NAME,
|
||||
} from '../constants';
|
||||
import { reconnectStepDefinitions } from '../step-definitions';
|
||||
import { IMAGE_VERSION } from '@openfeature/flagd-core';
|
||||
|
||||
// register the flagd provider before the tests.
|
||||
async function setup() {
|
||||
const containers: StartedTestContainer[] = [];
|
||||
|
||||
console.log('Setting flagd provider...');
|
||||
const unstable = await new GenericContainer(`ghcr.io/open-feature/flagd-testbed-unstable:${IMAGE_VERSION}`)
|
||||
.withExposedPorts(8013)
|
||||
.start();
|
||||
containers.push(unstable);
|
||||
OpenFeature.setProvider(
|
||||
UNSTABLE_CLIENT_NAME,
|
||||
new FlagdProvider({ cache: 'disabled', port: unstable.getFirstMappedPort() }),
|
||||
);
|
||||
OpenFeature.setProvider(UNAVAILABLE_CLIENT_NAME, new FlagdProvider({ cache: 'disabled', port: 8015 }));
|
||||
assert(
|
||||
OpenFeature.getProviderMetadata(UNSTABLE_CLIENT_NAME).name === FLAGD_NAME,
|
||||
new Error(
|
||||
`Expected ${FLAGD_NAME} provider to be configured, instead got: ${
|
||||
OpenFeature.getProviderMetadata(UNSTABLE_CLIENT_NAME).name
|
||||
}`,
|
||||
),
|
||||
);
|
||||
assert(
|
||||
OpenFeature.getProviderMetadata(UNAVAILABLE_CLIENT_NAME).name === FLAGD_NAME,
|
||||
new Error(
|
||||
`Expected ${FLAGD_NAME} provider to be configured, instead got: ${
|
||||
OpenFeature.getProviderMetadata(UNAVAILABLE_CLIENT_NAME).name
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
console.log('flagd provider configured!');
|
||||
return containers;
|
||||
}
|
||||
|
||||
jest.setTimeout(30000);
|
||||
describe('rpc', () => {
|
||||
let containers: StartedTestContainer[] = [];
|
||||
beforeAll(async () => {
|
||||
containers = await setup();
|
||||
}, 60000);
|
||||
afterAll(async () => {
|
||||
await OpenFeature.close();
|
||||
for (const container of containers) {
|
||||
try {
|
||||
await container.stop();
|
||||
} catch {
|
||||
console.warn(`Failed to stop container ${container.getName()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const features = [loadFeature(GHERKIN_FLAGD_RECONNECT_FEATURE)];
|
||||
autoBindSteps(features, [reconnectStepDefinitions]);
|
||||
});
|
|
@ -1,56 +1,33 @@
|
|||
import assert from 'assert';
|
||||
import { OpenFeature } from '@openfeature/server-sdk';
|
||||
import { FlagdProvider } from '../../lib/flagd-provider';
|
||||
import { GenericContainer, StartedTestContainer } from 'testcontainers';
|
||||
import { autoBindSteps, loadFeature } from 'jest-cucumber';
|
||||
import {
|
||||
FLAGD_NAME,
|
||||
GHERKIN_EVALUATION_FEATURE,
|
||||
GHERKIN_FLAGD_FEATURE,
|
||||
GHERKIN_FLAGD_JSON_EVALUATOR_FEATURE,
|
||||
} from '../constants';
|
||||
import { E2E_CLIENT_NAME, IMAGE_VERSION } from '@openfeature/flagd-core';
|
||||
import { flagStepDefinitions } from '../step-definitions';
|
||||
import { autoBindSteps, loadFeatures } from 'jest-cucumber';
|
||||
import { providerSteps } from '../step-definitions/providerSteps';
|
||||
import { configSteps } from '../step-definitions/configSteps';
|
||||
import type { State } from '../step-definitions/state';
|
||||
import { eventSteps } from '../step-definitions/eventSteps';
|
||||
import { flagSteps } from '../step-definitions/flagSteps';
|
||||
import { contextSteps } from '../step-definitions/contextSteps';
|
||||
import { GHERKIN_FLAGD } from '../constants';
|
||||
|
||||
// register the flagd provider before the tests.
|
||||
async function setup() {
|
||||
const containers: StartedTestContainer[] = [];
|
||||
const steps = [providerSteps, configSteps, eventSteps, flagSteps, contextSteps];
|
||||
|
||||
console.log('Setting flagd provider...');
|
||||
const stable = await new GenericContainer(`ghcr.io/open-feature/flagd-testbed:${IMAGE_VERSION}`)
|
||||
.withExposedPorts(8013)
|
||||
.start();
|
||||
containers.push(stable);
|
||||
OpenFeature.setProvider(E2E_CLIENT_NAME, new FlagdProvider({ cache: 'disabled', port: stable.getFirstMappedPort() }));
|
||||
|
||||
assert(
|
||||
OpenFeature.getProviderMetadata(E2E_CLIENT_NAME).name === FLAGD_NAME,
|
||||
new Error(
|
||||
`Expected ${FLAGD_NAME} provider to be configured, instead got: ${
|
||||
OpenFeature.getProviderMetadata(E2E_CLIENT_NAME).name
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
console.log('flagd provider configured!');
|
||||
return containers;
|
||||
}
|
||||
jest.setTimeout(50000);
|
||||
|
||||
describe('rpc', () => {
|
||||
let containers: StartedTestContainer[] = [];
|
||||
beforeAll(async () => {
|
||||
containers = await setup();
|
||||
}, 60000);
|
||||
afterAll(async () => {
|
||||
await OpenFeature.close();
|
||||
for (const container of containers) {
|
||||
await container.stop();
|
||||
}
|
||||
});
|
||||
const features = [
|
||||
loadFeature(GHERKIN_FLAGD_FEATURE),
|
||||
loadFeature(GHERKIN_EVALUATION_FEATURE),
|
||||
loadFeature(GHERKIN_FLAGD_JSON_EVALUATOR_FEATURE),
|
||||
];
|
||||
autoBindSteps(features, [flagStepDefinitions]);
|
||||
const state: State = {
|
||||
resolverType: 'rpc',
|
||||
options: {},
|
||||
config: undefined,
|
||||
events: [],
|
||||
};
|
||||
autoBindSteps(
|
||||
loadFeatures(GHERKIN_FLAGD, {
|
||||
tagFilter:
|
||||
// remove filters as we add support for features
|
||||
// see: https://github.com/open-feature/js-sdk-contrib/issues/1096 and child issues
|
||||
'@rpc and not @targetURI and not @customCert and not @events and not @stream and not @grace and not @metadata and not @contextEnrichment and not @caching',
|
||||
scenarioNameTemplate: (vars) => {
|
||||
return `${vars.scenarioTitle} (${vars.scenarioTags.join(',')} ${vars.featureTags.join(',')})`;
|
||||
},
|
||||
}),
|
||||
steps.map((step) => step(state)),
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Config, FlagdProviderOptions, getConfig } from './configuration';
|
||||
import type { Config, FlagdProviderOptions } from './configuration';
|
||||
import { getConfig } from './configuration';
|
||||
import { DEFAULT_MAX_CACHE_SIZE } from './constants';
|
||||
|
||||
describe('Configuration', () => {
|
||||
|
@ -18,6 +19,7 @@ describe('Configuration', () => {
|
|||
cache: 'lru',
|
||||
resolverType: 'rpc',
|
||||
selector: '',
|
||||
deadlineMs: 500,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -55,6 +57,7 @@ describe('Configuration', () => {
|
|||
selector,
|
||||
offlineFlagSourcePath,
|
||||
defaultAuthority,
|
||||
deadlineMs: 500,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -68,6 +71,7 @@ describe('Configuration', () => {
|
|||
resolverType: 'rpc',
|
||||
selector: '',
|
||||
defaultAuthority: '',
|
||||
deadlineMs: 500,
|
||||
};
|
||||
|
||||
process.env['FLAGD_HOST'] = 'override';
|
||||
|
|
|
@ -18,6 +18,13 @@ export interface Config {
|
|||
*/
|
||||
port: number;
|
||||
|
||||
/**
|
||||
* The deadline for connections.
|
||||
*
|
||||
* @default 500
|
||||
*/
|
||||
deadlineMs: number;
|
||||
|
||||
/**
|
||||
* Determines if TLS should be used.
|
||||
*
|
||||
|
@ -79,6 +86,7 @@ export interface Config {
|
|||
export type FlagdProviderOptions = Partial<Config>;
|
||||
|
||||
const DEFAULT_CONFIG: Omit<Config, 'port' | 'resolverType'> = {
|
||||
deadlineMs: 500,
|
||||
host: 'localhost',
|
||||
tls: false,
|
||||
selector: '',
|
||||
|
@ -93,6 +101,7 @@ const DEFAULT_IN_PROCESS_CONFIG: Config = { ...DEFAULT_CONFIG, resolverType: 'in
|
|||
enum ENV_VAR {
|
||||
FLAGD_HOST = 'FLAGD_HOST',
|
||||
FLAGD_PORT = 'FLAGD_PORT',
|
||||
FLAGD_DEADLINE_MS = 'FLAGD_DEADLINE_MS',
|
||||
FLAGD_TLS = 'FLAGD_TLS',
|
||||
FLAGD_SOCKET_PATH = 'FLAGD_SOCKET_PATH',
|
||||
FLAGD_CACHE = 'FLAGD_CACHE',
|
||||
|
@ -103,38 +112,60 @@ enum ENV_VAR {
|
|||
FLAGD_DEFAULT_AUTHORITY = 'FLAGD_DEFAULT_AUTHORITY',
|
||||
}
|
||||
|
||||
const getEnvVarConfig = (): Partial<Config> => ({
|
||||
...(process.env[ENV_VAR.FLAGD_HOST] && {
|
||||
host: process.env[ENV_VAR.FLAGD_HOST],
|
||||
}),
|
||||
...(Number(process.env[ENV_VAR.FLAGD_PORT]) && {
|
||||
port: Number(process.env[ENV_VAR.FLAGD_PORT]),
|
||||
}),
|
||||
...(process.env[ENV_VAR.FLAGD_TLS] && {
|
||||
tls: process.env[ENV_VAR.FLAGD_TLS]?.toLowerCase() === 'true',
|
||||
}),
|
||||
...(process.env[ENV_VAR.FLAGD_SOCKET_PATH] && {
|
||||
socketPath: process.env[ENV_VAR.FLAGD_SOCKET_PATH],
|
||||
}),
|
||||
...((process.env[ENV_VAR.FLAGD_CACHE] === 'lru' || process.env[ENV_VAR.FLAGD_CACHE] === 'disabled') && {
|
||||
cache: process.env[ENV_VAR.FLAGD_CACHE],
|
||||
}),
|
||||
...(process.env[ENV_VAR.FLAGD_MAX_CACHE_SIZE] && {
|
||||
maxCacheSize: Number(process.env[ENV_VAR.FLAGD_MAX_CACHE_SIZE]),
|
||||
}),
|
||||
...(process.env[ENV_VAR.FLAGD_SOURCE_SELECTOR] && {
|
||||
selector: process.env[ENV_VAR.FLAGD_SOURCE_SELECTOR],
|
||||
}),
|
||||
...((process.env[ENV_VAR.FLAGD_RESOLVER] === 'rpc' || process.env[ENV_VAR.FLAGD_RESOLVER] === 'in-process') && {
|
||||
resolverType: process.env[ENV_VAR.FLAGD_RESOLVER],
|
||||
}),
|
||||
...(process.env[ENV_VAR.FLAGD_OFFLINE_FLAG_SOURCE_PATH] && {
|
||||
offlineFlagSourcePath: process.env[ENV_VAR.FLAGD_OFFLINE_FLAG_SOURCE_PATH],
|
||||
}),
|
||||
...(process.env[ENV_VAR.FLAGD_DEFAULT_AUTHORITY] && {
|
||||
defaultAuthority: process.env[ENV_VAR.FLAGD_DEFAULT_AUTHORITY],
|
||||
}),
|
||||
});
|
||||
function checkEnvVarResolverType() {
|
||||
return (
|
||||
process.env[ENV_VAR.FLAGD_RESOLVER] &&
|
||||
(process.env[ENV_VAR.FLAGD_RESOLVER].toLowerCase() === 'rpc' ||
|
||||
process.env[ENV_VAR.FLAGD_RESOLVER].toLowerCase() === 'in-process')
|
||||
);
|
||||
}
|
||||
|
||||
const getEnvVarConfig = (): Partial<Config> => {
|
||||
let provider = undefined;
|
||||
if (
|
||||
process.env[ENV_VAR.FLAGD_RESOLVER] &&
|
||||
(process.env[ENV_VAR.FLAGD_RESOLVER].toLowerCase() === 'rpc' ||
|
||||
process.env[ENV_VAR.FLAGD_RESOLVER].toLowerCase() === 'in-process')
|
||||
) {
|
||||
provider = process.env[ENV_VAR.FLAGD_RESOLVER].toLowerCase();
|
||||
}
|
||||
|
||||
return {
|
||||
...(process.env[ENV_VAR.FLAGD_HOST] && {
|
||||
host: process.env[ENV_VAR.FLAGD_HOST],
|
||||
}),
|
||||
...(Number(process.env[ENV_VAR.FLAGD_PORT]) && {
|
||||
port: Number(process.env[ENV_VAR.FLAGD_PORT]),
|
||||
}),
|
||||
...(Number(process.env[ENV_VAR.FLAGD_DEADLINE_MS]) && {
|
||||
deadlineMs: Number(process.env[ENV_VAR.FLAGD_DEADLINE_MS]),
|
||||
}),
|
||||
...(process.env[ENV_VAR.FLAGD_TLS] && {
|
||||
tls: process.env[ENV_VAR.FLAGD_TLS]?.toLowerCase() === 'true',
|
||||
}),
|
||||
...(process.env[ENV_VAR.FLAGD_SOCKET_PATH] && {
|
||||
socketPath: process.env[ENV_VAR.FLAGD_SOCKET_PATH],
|
||||
}),
|
||||
...((process.env[ENV_VAR.FLAGD_CACHE] === 'lru' || process.env[ENV_VAR.FLAGD_CACHE] === 'disabled') && {
|
||||
cache: process.env[ENV_VAR.FLAGD_CACHE],
|
||||
}),
|
||||
...(process.env[ENV_VAR.FLAGD_MAX_CACHE_SIZE] && {
|
||||
maxCacheSize: Number(process.env[ENV_VAR.FLAGD_MAX_CACHE_SIZE]),
|
||||
}),
|
||||
...(process.env[ENV_VAR.FLAGD_SOURCE_SELECTOR] && {
|
||||
selector: process.env[ENV_VAR.FLAGD_SOURCE_SELECTOR],
|
||||
}),
|
||||
...(provider && {
|
||||
resolverType: provider as ResolverType,
|
||||
}),
|
||||
...(process.env[ENV_VAR.FLAGD_OFFLINE_FLAG_SOURCE_PATH] && {
|
||||
offlineFlagSourcePath: process.env[ENV_VAR.FLAGD_OFFLINE_FLAG_SOURCE_PATH],
|
||||
}),
|
||||
...(process.env[ENV_VAR.FLAGD_DEFAULT_AUTHORITY] && {
|
||||
defaultAuthority: process.env[ENV_VAR.FLAGD_DEFAULT_AUTHORITY],
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export function getConfig(options: FlagdProviderOptions = {}) {
|
||||
const envVarConfig = getEnvVarConfig();
|
||||
|
|
|
@ -1,16 +1,10 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { ServiceError, status } from '@grpc/grpc-js';
|
||||
import {
|
||||
Client,
|
||||
ErrorCode,
|
||||
EvaluationContext,
|
||||
FlagMetadata,
|
||||
OpenFeature,
|
||||
ProviderEvents,
|
||||
StandardResolutionReasons,
|
||||
} from '@openfeature/server-sdk';
|
||||
import type { ServiceError } from '@grpc/grpc-js';
|
||||
import { status } from '@grpc/grpc-js';
|
||||
import type { Client, EvaluationContext, FlagMetadata } from '@openfeature/server-sdk';
|
||||
import { ErrorCode, OpenFeature, ProviderEvents, StandardResolutionReasons } from '@openfeature/server-sdk';
|
||||
import type { UnaryCall } from '@protobuf-ts/runtime-rpc';
|
||||
import {
|
||||
import type {
|
||||
EventStreamResponse,
|
||||
ResolveBooleanRequest,
|
||||
ResolveBooleanResponse,
|
||||
|
@ -22,10 +16,11 @@ import {
|
|||
ResolveObjectResponse,
|
||||
ResolveStringRequest,
|
||||
ResolveStringResponse,
|
||||
ServiceClient,
|
||||
} from '../proto/ts/flagd/evaluation/v1/evaluation';
|
||||
import { ServiceClient } from '../proto/ts/flagd/evaluation/v1/evaluation';
|
||||
import { FlagdProvider } from './flagd-provider';
|
||||
import { FlagChangeMessage, GRPCService } from './service/grpc/grpc-service';
|
||||
import type { FlagChangeMessage } from './service/grpc/grpc-service';
|
||||
import { GRPCService } from './service/grpc/grpc-service';
|
||||
import { ConnectivityState } from '@grpc/grpc-js/build/src/connectivity-state';
|
||||
import { EVENT_CONFIGURATION_CHANGE, EVENT_PROVIDER_READY } from './constants';
|
||||
|
||||
|
@ -149,7 +144,7 @@ describe(FlagdProvider.name, () => {
|
|||
new FlagdProvider(
|
||||
undefined,
|
||||
undefined,
|
||||
new GRPCService({ host: '', port: 123, tls: false }, basicServiceClientMock),
|
||||
new GRPCService({ deadlineMs: 100, host: '', port: 123, tls: false }, basicServiceClientMock),
|
||||
),
|
||||
);
|
||||
client = OpenFeature.getClient('basic test');
|
||||
|
@ -306,7 +301,10 @@ describe(FlagdProvider.name, () => {
|
|||
new FlagdProvider(
|
||||
undefined,
|
||||
undefined,
|
||||
new GRPCService({ host: '', port: 123, tls: false, cache: 'lru' }, streamingServiceClientMock),
|
||||
new GRPCService(
|
||||
{ deadlineMs: 100, host: '', port: 123, tls: false, cache: 'lru' },
|
||||
streamingServiceClientMock,
|
||||
),
|
||||
)
|
||||
.initialize()
|
||||
.then(() => {
|
||||
|
@ -331,7 +329,10 @@ describe(FlagdProvider.name, () => {
|
|||
new FlagdProvider(
|
||||
undefined,
|
||||
undefined,
|
||||
new GRPCService({ host: '', port: 123, tls: false, cache: 'lru' }, streamingServiceClientMock),
|
||||
new GRPCService(
|
||||
{ deadlineMs: 100, host: '', port: 123, tls: false, cache: 'lru' },
|
||||
streamingServiceClientMock,
|
||||
),
|
||||
),
|
||||
);
|
||||
// fire message saying provider is ready
|
||||
|
@ -380,7 +381,10 @@ describe(FlagdProvider.name, () => {
|
|||
new FlagdProvider(
|
||||
undefined,
|
||||
undefined,
|
||||
new GRPCService({ host: '', port: 123, tls: false, cache: 'lru' }, streamingServiceClientMock),
|
||||
new GRPCService(
|
||||
{ deadlineMs: 100, host: '', port: 123, tls: false, cache: 'lru' },
|
||||
streamingServiceClientMock,
|
||||
),
|
||||
),
|
||||
);
|
||||
// fire message saying provider is ready
|
||||
|
@ -412,7 +416,10 @@ describe(FlagdProvider.name, () => {
|
|||
new FlagdProvider(
|
||||
undefined,
|
||||
undefined,
|
||||
new GRPCService({ host: '', port: 123, tls: false, cache: 'lru' }, streamingServiceClientMock),
|
||||
new GRPCService(
|
||||
{ deadlineMs: 100, host: '', port: 123, tls: false, cache: 'lru' },
|
||||
streamingServiceClientMock,
|
||||
),
|
||||
),
|
||||
);
|
||||
// fire message saying provider is ready
|
||||
|
@ -501,7 +508,10 @@ describe(FlagdProvider.name, () => {
|
|||
const provider = new FlagdProvider(
|
||||
undefined,
|
||||
undefined,
|
||||
new GRPCService({ host: '', port: 123, tls: false, cache: 'lru' }, streamingServiceClientMock),
|
||||
new GRPCService(
|
||||
{ deadlineMs: 100, host: '', port: 123, tls: false, cache: 'lru' },
|
||||
streamingServiceClientMock,
|
||||
),
|
||||
);
|
||||
provider.initialize().catch(() => {
|
||||
// ignore
|
||||
|
@ -579,7 +589,7 @@ describe(FlagdProvider.name, () => {
|
|||
new FlagdProvider(
|
||||
undefined,
|
||||
undefined,
|
||||
new GRPCService({ host: '', port: 123, tls: false }, errServiceClientMock),
|
||||
new GRPCService({ deadlineMs: 100, host: '', port: 123, tls: false }, errServiceClientMock),
|
||||
),
|
||||
);
|
||||
client = OpenFeature.getClient('errors test');
|
||||
|
@ -666,7 +676,7 @@ describe(FlagdProvider.name, () => {
|
|||
new FlagdProvider(
|
||||
undefined,
|
||||
undefined,
|
||||
new GRPCService({ host: '', port: 123, tls: false }, errServiceClientMock),
|
||||
new GRPCService({ deadlineMs: 100, host: '', port: 123, tls: false }, errServiceClientMock),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,15 +1,9 @@
|
|||
import {
|
||||
EvaluationContext,
|
||||
JsonValue,
|
||||
Logger,
|
||||
OpenFeatureEventEmitter,
|
||||
Provider,
|
||||
ProviderEvents,
|
||||
ResolutionDetails,
|
||||
} from '@openfeature/server-sdk';
|
||||
import { FlagdProviderOptions, getConfig } from './configuration';
|
||||
import type { EvaluationContext, JsonValue, Logger, Provider, ResolutionDetails } from '@openfeature/server-sdk';
|
||||
import { OpenFeatureEventEmitter, ProviderEvents } from '@openfeature/server-sdk';
|
||||
import type { FlagdProviderOptions } from './configuration';
|
||||
import { getConfig } from './configuration';
|
||||
import { GRPCService } from './service/grpc/grpc-service';
|
||||
import { Service } from './service/service';
|
||||
import type { Service } from './service/service';
|
||||
import { InProcessService } from './service/in-process/in-process-service';
|
||||
|
||||
export class FlagdProvider implements Provider {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ClientReadableStream } from '@grpc/grpc-js';
|
||||
import type { ClientReadableStream } from '@grpc/grpc-js';
|
||||
|
||||
export const closeStreamIfDefined = (stream: ClientReadableStream<unknown> | undefined) => {
|
||||
/**
|
||||
|
|
|
@ -1,20 +1,17 @@
|
|||
import { ClientReadableStream, ClientUnaryCall, ServiceError, credentials, status, ClientOptions } from '@grpc/grpc-js';
|
||||
import type { ClientOptions, ClientReadableStream, ClientUnaryCall, ServiceError } from '@grpc/grpc-js';
|
||||
import { credentials, status } from '@grpc/grpc-js';
|
||||
import { ConnectivityState } from '@grpc/grpc-js/build/src/connectivity-state';
|
||||
import type { EvaluationContext, FlagValue, JsonValue, Logger, ResolutionDetails } from '@openfeature/server-sdk';
|
||||
import {
|
||||
EvaluationContext,
|
||||
FlagNotFoundError,
|
||||
FlagValue,
|
||||
GeneralError,
|
||||
JsonValue,
|
||||
Logger,
|
||||
ParseError,
|
||||
ResolutionDetails,
|
||||
StandardResolutionReasons,
|
||||
TypeMismatchError,
|
||||
} from '@openfeature/server-sdk';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { promisify } from 'node:util';
|
||||
import {
|
||||
import type {
|
||||
EventStreamResponse,
|
||||
ResolveBooleanRequest,
|
||||
ResolveBooleanResponse,
|
||||
|
@ -26,12 +23,12 @@ import {
|
|||
ResolveObjectResponse,
|
||||
ResolveStringRequest,
|
||||
ResolveStringResponse,
|
||||
ServiceClient,
|
||||
} from '../../../proto/ts/flagd/evaluation/v1/evaluation';
|
||||
import { Config } from '../../configuration';
|
||||
import { ServiceClient } from '../../../proto/ts/flagd/evaluation/v1/evaluation';
|
||||
import type { Config } from '../../configuration';
|
||||
import { DEFAULT_MAX_CACHE_SIZE, EVENT_CONFIGURATION_CHANGE, EVENT_PROVIDER_READY } from '../../constants';
|
||||
import { FlagdProvider } from '../../flagd-provider';
|
||||
import { Service } from '../service';
|
||||
import type { Service } from '../service';
|
||||
import { closeStreamIfDefined } from '../common';
|
||||
|
||||
type AnyResponse =
|
||||
|
@ -70,6 +67,8 @@ export class GRPCService implements Service {
|
|||
private _cache: LRUCache<string, ResolutionDetails<FlagValue>> | undefined;
|
||||
private _cacheEnabled = false;
|
||||
private _eventStream: ClientReadableStream<EventStreamResponse> | undefined = undefined;
|
||||
private _deadline: number;
|
||||
|
||||
private get _cacheActive() {
|
||||
// the cache is "active" (able to be used) if the config enabled it, AND the gRPC stream is live
|
||||
return this._cacheEnabled && this._client.getChannel().getConnectivityState(false) === ConnectivityState.READY;
|
||||
|
@ -95,6 +94,7 @@ export class GRPCService implements Service {
|
|||
tls ? credentials.createSsl() : credentials.createInsecure(),
|
||||
clientOptions,
|
||||
);
|
||||
this._deadline = config.deadlineMs;
|
||||
|
||||
if (config.cache === 'lru') {
|
||||
this._cacheEnabled = true;
|
||||
|
@ -165,7 +165,7 @@ export class GRPCService implements Service {
|
|||
// close the previous stream if we're reconnecting
|
||||
closeStreamIfDefined(this._eventStream);
|
||||
|
||||
const stream = this._client.eventStream({}, {});
|
||||
const stream = this._client.eventStream({ waitForReady: true }, {});
|
||||
stream.on('error', (err: Error) => {
|
||||
rejectConnect?.(err);
|
||||
this.handleError(reconnectCallback, changedCallback, disconnectCallback);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import fs from 'fs';
|
||||
import { FileFetch } from './file-fetch';
|
||||
import { FlagdCore } from '@openfeature/flagd-core';
|
||||
import { Logger } from '@openfeature/server-sdk';
|
||||
import type { Logger } from '@openfeature/server-sdk';
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
...jest.requireActual('fs'),
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Logger, OpenFeatureError, GeneralError } from '@openfeature/server-sdk';
|
||||
import { DataFetch } from '../data-fetch';
|
||||
import type { Logger } from '@openfeature/server-sdk';
|
||||
import { OpenFeatureError, GeneralError } from '@openfeature/server-sdk';
|
||||
import type { DataFetch } from '../data-fetch';
|
||||
import { promises as fsPromises, watchFile, unwatchFile } from 'fs';
|
||||
|
||||
const encoding = 'utf8';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { GrpcFetch } from './grpc-fetch';
|
||||
import { Config } from '../../../configuration';
|
||||
import { FlagSyncServiceClient, SyncFlagsResponse } from '../../../../proto/ts/flagd/sync/v1/sync';
|
||||
import type { Config } from '../../../configuration';
|
||||
import type { FlagSyncServiceClient, SyncFlagsResponse } from '../../../../proto/ts/flagd/sync/v1/sync';
|
||||
import { ConnectivityState } from '@grpc/grpc-js/build/src/connectivity-state';
|
||||
|
||||
let watchStateCallback: () => void = () => ({});
|
||||
|
@ -44,7 +44,14 @@ const serviceMock: FlagSyncServiceClient = {
|
|||
} as unknown as FlagSyncServiceClient;
|
||||
|
||||
describe('grpc fetch', () => {
|
||||
const cfg: Config = { host: 'localhost', port: 8000, tls: false, socketPath: '', defaultAuthority: 'test-authority' };
|
||||
const cfg: Config = {
|
||||
deadlineMs: 500,
|
||||
host: 'localhost',
|
||||
port: 8000,
|
||||
tls: false,
|
||||
socketPath: '',
|
||||
defaultAuthority: 'test-authority',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import { ClientReadableStream, ServiceError, credentials, ClientOptions } from '@grpc/grpc-js';
|
||||
import { GeneralError, Logger } from '@openfeature/server-sdk';
|
||||
import { FlagSyncServiceClient, SyncFlagsRequest, SyncFlagsResponse } from '../../../../proto/ts/flagd/sync/v1/sync';
|
||||
import { Config } from '../../../configuration';
|
||||
import type { ClientReadableStream, ServiceError, ClientOptions } from '@grpc/grpc-js';
|
||||
import { credentials } from '@grpc/grpc-js';
|
||||
import type { Logger } from '@openfeature/server-sdk';
|
||||
import { GeneralError } from '@openfeature/server-sdk';
|
||||
import type { SyncFlagsRequest, SyncFlagsResponse } from '../../../../proto/ts/flagd/sync/v1/sync';
|
||||
import { FlagSyncServiceClient } from '../../../../proto/ts/flagd/sync/v1/sync';
|
||||
import type { Config } from '../../../configuration';
|
||||
import { closeStreamIfDefined } from '../../common';
|
||||
import { DataFetch } from '../data-fetch';
|
||||
import type { DataFetch } from '../data-fetch';
|
||||
|
||||
/**
|
||||
* Implements the gRPC sync contract to fetch flag data.
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { DataFetch } from './data-fetch';
|
||||
import type { DataFetch } from './data-fetch';
|
||||
import { InProcessService } from './in-process-service';
|
||||
|
||||
describe('In-process-service', () => {
|
||||
|
@ -13,7 +13,7 @@ describe('In-process-service', () => {
|
|||
|
||||
it('should sync and allow to resolve flags', async () => {
|
||||
// given
|
||||
const service = new InProcessService({ host: '', port: 0, tls: false }, dataFetcher);
|
||||
const service = new InProcessService({ deadlineMs: 500, host: '', port: 0, tls: false }, dataFetcher);
|
||||
|
||||
// when
|
||||
await service.connect(jest.fn, jest.fn, jest.fn);
|
||||
|
@ -31,7 +31,7 @@ describe('In-process-service', () => {
|
|||
it('should include scope as flag metadata', async () => {
|
||||
// given
|
||||
const selector = 'devFlags';
|
||||
const service = new InProcessService({ host: '', port: 0, tls: false, selector }, dataFetcher);
|
||||
const service = new InProcessService({ deadlineMs: 500, host: '', port: 0, tls: false, selector }, dataFetcher);
|
||||
|
||||
// when
|
||||
await service.connect(jest.fn, jest.fn, jest.fn);
|
||||
|
@ -44,7 +44,7 @@ describe('In-process-service', () => {
|
|||
it('should not override existing scope in flag metadata', async () => {
|
||||
// given
|
||||
const selector = 'devFlags';
|
||||
const service = new InProcessService({ host: '', port: 0, tls: false, selector }, dataFetcher);
|
||||
const service = new InProcessService({ deadlineMs: 500, host: '', port: 0, tls: false, selector }, dataFetcher);
|
||||
|
||||
// when
|
||||
await service.connect(jest.fn, jest.fn, jest.fn);
|
||||
|
|
|
@ -7,9 +7,9 @@ import type {
|
|||
Logger,
|
||||
ResolutionDetails,
|
||||
} from '@openfeature/server-sdk';
|
||||
import { Config } from '../../configuration';
|
||||
import { Service } from '../service';
|
||||
import { DataFetch } from './data-fetch';
|
||||
import type { Config } from '../../configuration';
|
||||
import type { Service } from '../service';
|
||||
import type { DataFetch } from './data-fetch';
|
||||
import { FileFetch } from './file/file-fetch';
|
||||
import { GrpcFetch } from './grpc/grpc-fetch';
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { EvaluationContext, JsonValue, Logger, ResolutionDetails } from '@openfeature/server-sdk';
|
||||
import type { EvaluationContext, JsonValue, Logger, ResolutionDetails } from '@openfeature/server-sdk';
|
||||
|
||||
export interface Service {
|
||||
connect(
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
import {
|
||||
import type {
|
||||
EvaluationContext,
|
||||
FlagValue,
|
||||
JsonValue,
|
||||
Logger,
|
||||
OpenFeatureEventEmitter,
|
||||
Provider,
|
||||
ProviderEvents,
|
||||
ProviderMetadata,
|
||||
ResolutionDetails,
|
||||
ResolutionReason,
|
||||
TypeMismatchError,
|
||||
} from '@openfeature/web-sdk';
|
||||
import { OpenFeatureEventEmitter, ProviderEvents, TypeMismatchError } from '@openfeature/web-sdk';
|
||||
import { createFlagsmithInstance } from 'flagsmith';
|
||||
import { IFlagsmith, IInitConfig, IState } from 'flagsmith/types';
|
||||
import { FlagType, typeFactory } from './type-factory';
|
||||
import type { IFlagsmith, IInitConfig, IState } from 'flagsmith/types';
|
||||
import type { FlagType } from './type-factory';
|
||||
import { typeFactory } from './type-factory';
|
||||
|
||||
export class FlagsmithClientProvider implements Provider {
|
||||
readonly metadata: ProviderMetadata = {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { IFlagsmithResponse, IInitConfig } from 'flagsmith/types';
|
||||
import type { IFlagsmithResponse, IInitConfig } from 'flagsmith/types';
|
||||
type Flatten<T> = T extends unknown[] ? T[number] : T;
|
||||
type FeatureResponse = Flatten<IFlagsmithResponse['flags']>;
|
||||
type Callback = (err: Error | null, val: string | null) => void;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { FlagValue } from '@openfeature/web-sdk';
|
||||
import type { FlagValue } from '@openfeature/web-sdk';
|
||||
|
||||
export type FlagType = 'string' | 'number' | 'object' | 'boolean';
|
||||
|
||||
|
|
|
@ -1,5 +1,26 @@
|
|||
# Changelog
|
||||
|
||||
## [0.1.5](https://github.com/open-feature/js-sdk-contrib/compare/flipt-web-provider-v0.1.4...flipt-web-provider-v0.1.5) (2025-06-06)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* **flipt-web:** update types to match flipt-client-js ([#1303](https://github.com/open-feature/js-sdk-contrib/issues/1303)) ([9e8496a](https://github.com/open-feature/js-sdk-contrib/commit/9e8496a384e65bee8d6cb096bdfaa909e2bfa311))
|
||||
|
||||
## [0.1.4](https://github.com/open-feature/js-sdk-contrib/compare/flipt-web-provider-v0.1.3...flipt-web-provider-v0.1.4) (2025-06-04)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* **deps:** update dependency @flipt-io/flipt-client-js to v0.0.2 ([#1272](https://github.com/open-feature/js-sdk-contrib/issues/1272)) ([d7f8746](https://github.com/open-feature/js-sdk-contrib/commit/d7f8746434f58333a2458418c35749c125932369))
|
||||
|
||||
## [0.1.3](https://github.com/open-feature/js-sdk-contrib/compare/flipt-web-provider-v0.1.2...flipt-web-provider-v0.1.3) (2025-04-10)
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* **flipt:** swap underlying flipt web sdk ([#1244](https://github.com/open-feature/js-sdk-contrib/issues/1244)) ([dba2a28](https://github.com/open-feature/js-sdk-contrib/commit/dba2a280014e998341487fc2cb1fcb410275d8d6))
|
||||
|
||||
## [0.1.2](https://github.com/open-feature/js-sdk-contrib/compare/flipt-web-provider-v0.1.1...flipt-web-provider-v0.1.2) (2025-02-11)
|
||||
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
[Flipt](https://www.flipt.io/) is an open source developer friendly feature flagging solution, that allows for easy management and fast feature evaluation.
|
||||
|
||||
This provider is an implementation on top of the official [Flipt Browser Client Side SDK](https://www.npmjs.com/package/@flipt-io/flipt-client-browser).
|
||||
This provider is an implementation on top of the official [Flipt JavaScript Client Side SDK](https://www.npmjs.com/package/@flipt-io/flipt-client-js).
|
||||
|
||||
The main difference between this provider and [`@openfeature/flipt-provider`](https://www.npmjs.com/package/@openfeature/flipt-provider) is that it uses a **static evaluation context**.
|
||||
This provider is more sustainable for client-side implementation.
|
||||
|
|
|
@ -1,27 +1,30 @@
|
|||
{
|
||||
"name": "@openfeature/flipt-web-provider",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openfeature/flipt-web-provider",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@flipt-io/flipt-client-browser": "^0.3.1",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"undici": "^6.13.0"
|
||||
},
|
||||
"version": "0.1.5",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@flipt-io/flipt-client-js": "^0.0.1 || ^0.0.2 || ^0.0.6 || ^0.2.0",
|
||||
"@openfeature/web-sdk": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@flipt-io/flipt-client-browser": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@flipt-io/flipt-client-browser/-/flipt-client-browser-0.3.1.tgz",
|
||||
"integrity": "sha512-1MFuQuHRENnzVooxrfQjFBLNBfE5uGBJmF2NuPFXTYMZn+sGelFovuNVuKlHqegI3Dqzz9Al2qJlkeFo+MhHxg=="
|
||||
"node_modules/@flipt-io/flipt-client-js": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@flipt-io/flipt-client-js/-/flipt-client-js-0.2.0.tgz",
|
||||
"integrity": "sha512-3VIU2pDUyHafp3ahgxHOde81fKP+5hvSm9Q+8hqt9+D7MCIWNvHTLqFo5hc87WSgq3TlR8ILRWfFC18BE1AmeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"node-fetch": "^3.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@openfeature/core": {
|
||||
"version": "1.4.0",
|
||||
|
@ -38,18 +41,94 @@
|
|||
"@openfeature/core": "1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz",
|
||||
"integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA=="
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.20.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.20.1.tgz",
|
||||
"integrity": "sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA==",
|
||||
"dev": true,
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openfeature/flipt-web-provider",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.5",
|
||||
"license": "Apache-2.0",
|
||||
"main": "./src/index.js",
|
||||
"typings": "./src/index.d.ts",
|
||||
|
@ -9,13 +9,7 @@
|
|||
"current-version": "echo $npm_package_version"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@openfeature/web-sdk": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"undici": "^6.13.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flipt-io/flipt-client-browser": "^0.3.1",
|
||||
"undici": "^5.0.0"
|
||||
"@openfeature/web-sdk": "^1.0.0",
|
||||
"@flipt-io/flipt-client-js": "^0.0.1 || ^0.0.2 || ^0.0.6 || ^0.2.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export * from './lib/flipt-web-provider';
|
||||
export * from './lib/models';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { EvaluationContext } from '@openfeature/server-sdk';
|
||||
import type { EvaluationContext } from '@openfeature/server-sdk';
|
||||
import { transformContext } from './context-transformer';
|
||||
|
||||
describe('context-transformer', () => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { EvaluationContext } from '@openfeature/web-sdk';
|
||||
import type { EvaluationContext } from '@openfeature/web-sdk';
|
||||
|
||||
export function transformContext(context: EvaluationContext): Record<string, string> {
|
||||
const evalContext: Record<string, string> = {};
|
||||
|
|
|
@ -28,12 +28,12 @@ describe('FliptWebProvider', () => {
|
|||
describe('method resolveStringEvaluation', () => {
|
||||
it('should throw general error for non-existent flag', () => {
|
||||
expect(() => {
|
||||
provider.resolveStringEvaluation('nonExistent', 'default', { fizz: 'buzz' });
|
||||
provider.resolveStringEvaluation('nonExistent', 'default', { targetingKey: '1234', fizz: 'buzz' });
|
||||
}).toThrow(GeneralError);
|
||||
});
|
||||
|
||||
it('should return right value if key exists', () => {
|
||||
const value = provider.resolveStringEvaluation('flag_string', 'default', { fizz: 'buzz' });
|
||||
const value = provider.resolveStringEvaluation('flag_string', 'default', { targetingKey: '1234', fizz: 'buzz' });
|
||||
expect(value).toHaveProperty('value', 'variant1');
|
||||
expect(value).toHaveProperty('reason', 'TARGETING_MATCH');
|
||||
});
|
||||
|
@ -42,12 +42,12 @@ describe('FliptWebProvider', () => {
|
|||
describe('method resolveNumberEvaluation', () => {
|
||||
it('should throw general error for non-existent flag', () => {
|
||||
expect(() => {
|
||||
provider.resolveNumberEvaluation('nonExistent', 1, { fizz: 'buzz' });
|
||||
provider.resolveNumberEvaluation('nonExistent', 1, { targetingKey: '1234', fizz: 'buzz' });
|
||||
}).toThrow(GeneralError);
|
||||
});
|
||||
|
||||
it('should return right value if key exists', () => {
|
||||
const value = provider.resolveNumberEvaluation('flag_number', 0, { fizz: 'buzz' });
|
||||
const value = provider.resolveNumberEvaluation('flag_number', 0, { targetingKey: '1234', fizz: 'buzz' });
|
||||
expect(value).toHaveProperty('value', 5);
|
||||
expect(value).toHaveProperty('reason', 'TARGETING_MATCH');
|
||||
});
|
||||
|
@ -56,12 +56,12 @@ describe('FliptWebProvider', () => {
|
|||
describe('method resolveBooleanEvaluation', () => {
|
||||
it('should throw general error for non-existent flag', () => {
|
||||
expect(() => {
|
||||
provider.resolveBooleanEvaluation('nonExistent', false, { fizz: 'buzz' });
|
||||
provider.resolveBooleanEvaluation('nonExistent', false, { targetingKey: '1234', fizz: 'buzz' });
|
||||
}).toThrow(GeneralError);
|
||||
});
|
||||
|
||||
it('should return right value if key exists', () => {
|
||||
const value = provider.resolveBooleanEvaluation('flag_boolean', false, { fizz: 'buzz' });
|
||||
const value = provider.resolveBooleanEvaluation('flag_boolean', false, { targetingKey: '1234', fizz: 'buzz' });
|
||||
expect(value).toHaveProperty('value', true);
|
||||
expect(value).toHaveProperty('reason', 'TARGETING_MATCH');
|
||||
});
|
||||
|
@ -70,12 +70,16 @@ describe('FliptWebProvider', () => {
|
|||
describe('method resolveObjectEvaluation', () => {
|
||||
it('should throw general error for non-existent flag', () => {
|
||||
expect(() => {
|
||||
provider.resolveObjectEvaluation('nonExistent', {}, { fizz: 'buzz' });
|
||||
provider.resolveObjectEvaluation('nonExistent', {}, { targetingKey: '1234', fizz: 'buzz' });
|
||||
}).toThrow(GeneralError);
|
||||
});
|
||||
|
||||
it('should return right value if key exists', () => {
|
||||
const value = provider.resolveObjectEvaluation('flag_object', { fizz: 'buzz' }, { fizz: 'buzz' });
|
||||
const value = provider.resolveObjectEvaluation(
|
||||
'flag_object',
|
||||
{ fizz: 'buzz' },
|
||||
{ targetingKey: '1234', fizz: 'buzz' },
|
||||
);
|
||||
expect(value).toHaveProperty('value', { foo: 'bar' });
|
||||
expect(value).toHaveProperty('reason', 'TARGETING_MATCH');
|
||||
});
|
||||
|
@ -83,7 +87,7 @@ describe('FliptWebProvider', () => {
|
|||
|
||||
it('should throw TypeMismatchError on non-number value', () => {
|
||||
expect(() => {
|
||||
provider.resolveNumberEvaluation('flag_string', 0, { fizz: 'buzz' });
|
||||
provider.resolveNumberEvaluation('flag_string', 0, { targetingKey: '1234', fizz: 'buzz' });
|
||||
}).toThrow(TypeMismatchError);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,16 +1,8 @@
|
|||
import {
|
||||
EvaluationContext,
|
||||
Provider,
|
||||
JsonValue,
|
||||
ResolutionDetails,
|
||||
Logger,
|
||||
StandardResolutionReasons,
|
||||
TypeMismatchError,
|
||||
GeneralError,
|
||||
ProviderFatalError,
|
||||
} from '@openfeature/web-sdk';
|
||||
import { FliptEvaluationClient } from '@flipt-io/flipt-client-browser';
|
||||
import { EvaluationReason, FliptWebProviderOptions } from './models';
|
||||
import type { EvaluationContext, Provider, JsonValue, ResolutionDetails, Logger } from '@openfeature/web-sdk';
|
||||
import { StandardResolutionReasons, TypeMismatchError, GeneralError, ProviderFatalError } from '@openfeature/web-sdk';
|
||||
import { FliptClient } from '@flipt-io/flipt-client-js/browser';
|
||||
import type { FliptWebProviderOptions } from './models';
|
||||
import { EvaluationReason } from './models';
|
||||
import { transformContext } from './context-transformer';
|
||||
|
||||
export class FliptWebProvider implements Provider {
|
||||
|
@ -28,7 +20,7 @@ export class FliptWebProvider implements Provider {
|
|||
private _options?: FliptWebProviderOptions;
|
||||
|
||||
// client is the Flipt client reference
|
||||
private _client?: FliptEvaluationClient;
|
||||
private _client?: FliptClient;
|
||||
|
||||
readonly runsOn = 'client';
|
||||
|
||||
|
@ -48,7 +40,8 @@ export class FliptWebProvider implements Provider {
|
|||
|
||||
async initializeClient() {
|
||||
try {
|
||||
this._client = await FliptEvaluationClient.init(this._namespace || 'default', {
|
||||
this._client = await FliptClient.init({
|
||||
namespace: this._namespace || 'default',
|
||||
url: this._options?.url || 'http://localhost:8080',
|
||||
fetcher: this._options?.fetcher,
|
||||
authentication: this._options?.authentication,
|
||||
|
@ -70,7 +63,11 @@ export class FliptWebProvider implements Provider {
|
|||
const evalContext: Record<string, string> = transformContext(context);
|
||||
|
||||
try {
|
||||
const result = this._client?.evaluateBoolean(flagKey, context.targetingKey ?? '', evalContext);
|
||||
const result = this._client?.evaluateBoolean({
|
||||
flagKey,
|
||||
entityId: context.targetingKey ?? '',
|
||||
context: evalContext,
|
||||
});
|
||||
|
||||
switch (result?.reason) {
|
||||
case EvaluationReason.DEFAULT:
|
||||
|
@ -135,7 +132,11 @@ export class FliptWebProvider implements Provider {
|
|||
const evalContext: Record<string, string> = transformContext(context);
|
||||
|
||||
try {
|
||||
const result = this._client?.evaluateVariant(flagKey, context.targetingKey ?? '', evalContext);
|
||||
const result = this._client?.evaluateVariant({
|
||||
flagKey,
|
||||
entityId: context.targetingKey ?? '',
|
||||
context: evalContext,
|
||||
});
|
||||
|
||||
if (result?.reason === EvaluationReason.FLAG_DISABLED) {
|
||||
return {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue