diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 36d8519..ecc9113 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,8 +36,8 @@ jobs: id: version shell: bash run: | - if [[ ! "$GITHUB_REF_NAME" =~ ^v3\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "Expected a Featurevisor Java v3 semantic version tag" >&2 + if [[ ! "$GITHUB_REF_NAME" =~ ^v4\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Expected a Featurevisor Java v4 semantic version tag" >&2 exit 1 fi version="${GITHUB_REF_NAME#v}" diff --git a/README.md b/README.md index f2c4d13..093b3e9 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,9 @@ This SDK supports Featurevisor v3 behavior and v2 datafiles. Generated datafiles - [Getting variation](#getting-variation) - [Getting variables](#getting-variables) - [Type specific methods](#type-specific-methods) -- [Getting all evaluations](#getting-all-evaluations) -- [Sticky](#sticky) - - [Initialize with sticky](#initialize-with-sticky) - - [Set sticky afterwards](#set-sticky-afterwards) +- [Getting global variables](#getting-global-variables) +- [Getting aggregate evaluations](#getting-aggregate-evaluations) +- [Sticky features and variables](#sticky-features-and-variables) - [Setting datafile](#setting-datafile) - [Merging by default](#merging-by-default) - [Replacing](#replacing) @@ -38,7 +37,8 @@ This SDK supports Featurevisor v3 behavior and v2 datafiles. Generated datafiles - [Events](#events) - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - - [`sticky_set`](#sticky_set) + - [`sticky_features_set`](#sticky_features_set) + - [`sticky_variables_set`](#sticky_variables_set) - [`error`](#error) - [Evaluation details](#evaluation-details) - [Modules](#modules) @@ -86,7 +86,7 @@ Add the Featurevisor Java SDK as a dependency with your desired version: com.featurevisor featurevisor-java - 3.0.0 + 4.0.0 ``` @@ -136,7 +136,7 @@ Featurevisor f = Featurevisor.createFeaturevisor( Most applications only need `Featurevisor.createFeaturevisor`, the `Featurevisor` instance type, and `Featurevisor.FeaturevisorOptions`. Public extension and observability types include `FeaturevisorModule`, `FeaturevisorDiagnostic`, and the datafile model types. -Concurrent evaluations are safe after an instance is configured. Do not call state-changing methods such as `setDatafile`, `setContext`, `setSticky`, `addModule`, `removeModule`, or `close` concurrently with evaluations or with each other. Apply those changes from a serialized update path. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. +Concurrent evaluations are safe after an instance is configured. Do not call state changing methods such as `setDatafile`, `setContext`, `setStickyFeatures`, `setStickyVariables`, `addModule`, `removeModule`, or `close` concurrently with evaluations or with each other. Apply those changes from a serialized update path. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. ## Initialization @@ -167,11 +167,12 @@ We will learn about several different options in the next sections. ## Evaluation types -We can evaluate 3 types of values against a particular [feature](https://featurevisor.com/docs/features/): +We can evaluate flags, variations, variables inside features, and [global variables](https://featurevisor.com/docs/global-variables/): - [**Flag**](#check-if-enabled) (`boolean`): whether the feature is enabled or not - [**Variation**](#getting-variation) (`Object`): the variation of the feature (if any) - [**Variables**](#getting-variables): variable values of the feature (if any) +- [**Global variables**](#getting-global-variables): reusable values that are not owned by a feature These evaluations are run against the provided context. @@ -384,7 +385,21 @@ If a variable schema type is `json` and the resolved value is a malformed string - `getVariableJSONNode(...)` - `getVariableJSON(...)` -## Getting all evaluations +## Getting global variables + +Global variables use the same overloaded methods as variables inside features. A call with one key evaluates a global variable, while a call with a feature key and variable key evaluates a variable owned by that feature: + +```java +String message = f.getVariableString("welcomeMessage", context, null); +Object value = f.getVariable("checkoutSettings", context); +Evaluation evaluation = f.evaluateVariable("checkoutSettings", context); +``` + +The type specific methods are `getVariableBoolean`, `getVariableString`, `getVariableInteger`, `getVariableDouble`, `getVariableArray`, `getVariableObject`, and `getVariableJSON`. + +Global variables resolve sticky values first, then required features, then the first matching override, and finally their default value. If required features are unmet, `disabledValue` is used unless `useDefaultWhenDisabled` is enabled. Caller defaults are only used when the evaluation itself has no value. + +## Getting aggregate evaluations You can get evaluations of all features available in the SDK instance: @@ -392,7 +407,7 @@ You can get evaluations of all features available in the SDK instance: import com.featurevisor.sdk.EvaluatedFeatures; import com.featurevisor.sdk.EvaluatedFeature; -EvaluatedFeatures allEvaluations = f.getAllEvaluations(context); +EvaluatedFeatures allEvaluations = f.getFeatureEvaluations(context); // Access the evaluations map Map evaluations = allEvaluations.getValue(); @@ -416,13 +431,17 @@ System.out.println(evaluations); This is handy especially when you want to pass all evaluations from a backend application to the frontend. -## Sticky +Global variables can be evaluated together as well: -For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/): +```java +Map variables = f.getVariableEvaluations(context, null, null); +``` -Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides; use `new Featurevisor.SpawnOptions().sticky(...)` when a child needs its own sticky state. +## Sticky features and variables -### Initialize with sticky +For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/): + +Sticky values belong to an SDK or child instance. Feature sticky values and global variable sticky values are independent. ```java Map stickyFeatures = new HashMap<>(); @@ -443,20 +462,20 @@ stickyFeatures.put("anotherFeatureKey", anotherFeatureSticky); Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() .datafile(datafile) - .sticky(stickyFeatures)); + .stickyFeatures(stickyFeatures) + .stickyVariables(Map.of("welcomeMessage", "Hello"))); ``` Once initialized with sticky features, the SDK will look for values there first before evaluating the targeting conditions and going through the bucketing process. -### Set sticky afterwards - You can also set sticky features after the SDK is initialized: ```java Map stickyFeatures = new HashMap<>(); // ... build sticky features map -f.setSticky(stickyFeatures, true); // replace existing sticky features +f.setStickyFeatures(stickyFeatures, true); // replace existing sticky features +f.setStickyVariables(Map.of("welcomeMessage", "Welcome back"), true); ``` ## Setting datafile @@ -469,7 +488,7 @@ f.setDatafile(datafileContent); ### Merging by default -By default, `setDatafile(datafile)` merges the incoming datafile with the SDK's stored datafile. Incoming top-level metadata is used, and incoming segments/features override existing segments/features with the same keys. +By default, `setDatafile(datafile)` merges the incoming datafile with the SDK's stored datafile. Incoming top level metadata is used, and incoming segments, features, and global variables override existing entities with the same keys. This means you can call `setDatafile` more than once with different datafiles, and the SDK instance accumulates their features and segments together. This is what makes [loading datafiles on demand](#loading-datafiles-on-demand) possible. @@ -587,6 +606,9 @@ FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.DATAFILE_SET, ( @SuppressWarnings("unchecked") List features = (List) event.get("features"); + @SuppressWarnings("unchecked") + List variables = (List) event.get("variables"); + // handle here }); @@ -594,7 +616,7 @@ FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.DATAFILE_SET, ( unsubscribe.unsubscribe(); ``` -The `features` array will contain keys of features that have either been: +The `features` and `variables` arrays contain directly changed entities and entities affected through segment or required feature dependencies. - added, or - updated, or @@ -614,10 +636,10 @@ FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.CONTEXT_SET, (e }); ``` -### `sticky_set` +### `sticky_features_set` ```java -FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.STICKY_SET, (event) -> { +FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.STICKY_FEATURES_SET, (event) -> { Boolean replaced = (Boolean) event.get("replaced"); // true if sticky features got replaced @SuppressWarnings("unchecked") List features = (List) event.get("features"); // list of all affected feature keys @@ -626,6 +648,15 @@ FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.STICKY_SET, (ev }); ``` +### `sticky_variables_set` + +```java +FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.STICKY_VARIABLES_SET, (event) -> { + @SuppressWarnings("unchecked") + List variables = (List) event.get("variables"); +}); +``` + ### `error` ```java @@ -650,6 +681,9 @@ Evaluation evaluation = f.evaluateVariation(featureKey, context); // variable Evaluation evaluation = f.evaluateVariable(featureKey, variableKey, context); + +// global variable +Evaluation evaluation = f.evaluateVariable(variableKey, context); ``` The returned `Evaluation` exposes the following properties: @@ -673,6 +707,8 @@ And optionally these properties depending on whether you are evaluating a featur Modules allow you to intercept the evaluation process and customize it further as per your needs. +For feature evaluations, all `before` callbacks run in registration order, followed by all `beforeEvaluation` callbacks. After evaluation and caller defaults, all `afterEvaluation` callbacks run, followed by all `after` callbacks. Global variable evaluations use only `beforeEvaluation` and `afterEvaluation`. Required feature checks run through the complete module pipeline, and transformed defaults are preserved. + ### Defining a module A module is a `FeaturevisorModule` with a unique `name` and optional lifecycle functions: @@ -692,6 +728,9 @@ FeaturevisorModule myCustomModule = new FeaturevisorModule("my-custom-module") return options.copy().context(context); }) + // before any feature or global variable evaluation + .beforeEvaluation(options -> options) + // configure bucket key .bucketKey(options -> { String bucketKey = options.getBucketKey(); @@ -707,6 +746,9 @@ FeaturevisorModule myCustomModule = new FeaturevisorModule("my-custom-module") // after evaluation .after((evaluation, options) -> evaluation) + // after any feature or global variable evaluation + .afterEvaluation((evaluation, options) -> evaluation) + // called by f.close() .close(() -> { // clean up resources @@ -780,7 +822,8 @@ String variableValue = childF.getVariableString("my_feature", "my_variable"); Similar to parent SDK, child instances also support several additional methods: - `setContext` -- `setSticky` +- `setStickyFeatures` +- `setStickyVariables` - `evaluateFlag` - `isEnabled` - `evaluateVariation` @@ -795,7 +838,8 @@ Similar to parent SDK, child instances also support several additional methods: - `getVariableObject` - `getVariableJSON` - `getVariableJSONNode` -- `getAllEvaluations` +- `getFeatureEvaluations` +- `getVariableEvaluations` - `on` - `close` @@ -855,7 +899,7 @@ Add the provider with the same version as the Featurevisor Java SDK: com.featurevisor featurevisor-openfeature - FEATUREVISOR_VERSION + 4.0.0 ``` @@ -880,9 +924,9 @@ var client = api.getClient(); boolean enabled = client.getBooleanValue("checkout", false, new ImmutableContext("user-123")); ``` -Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Lists, structures, and JSON variables use the object resolver. +Use `checkout` for a flag, `checkout:variation` for its variation, `checkout:title` for its `title` variable, and `variable:welcomeMessage` for a global variable. Boolean variables use the boolean resolver. Lists, structures, and JSON variables use the object resolver. -OpenFeature's targeting key maps to `userId` by default. `targetingKeyField`, `keySeparator`, and `variationKey` on `FeaturevisorOpenFeatureProvider.Options` can customize the mapping. +OpenFeature's targeting key maps to `userId` by default. `targetingKeyField`, `keySeparator`, `variationKey`, and `globalVariablePrefix` on `FeaturevisorOpenFeatureProvider.Options` can customize the mapping. The global variable prefix defaults to `variable` and cannot contain the configured separator. You can also reuse an existing Featurevisor instance: @@ -927,7 +971,7 @@ $ make verify-artifacts ### Releasing 1. Merge the release changes into `main`. -2. Tag the release with a `v` prefix, such as `v3.0.0`, and push the tag. +2. Tag the release with a `v` prefix, such as `v4.0.0`, and push the tag. 3. GitHub Actions verifies and publishes the parent POM, Java SDK, and OpenFeature provider to [GitHub Packages](https://github.com/orgs/featurevisor/packages?repo_name=featurevisor-java). 4. Create the corresponding [GitHub release](https://github.com/featurevisor/featurevisor-java/releases). diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json index 49396ce..d5870a5 100644 --- a/conformance/sdk-v3.json +++ b/conformance/sdk-v3.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 6, "description": "Featurevisor v3 cross SDK compatibility contracts", "bucketing": { "minimum": 0, @@ -81,6 +81,594 @@ "schemaVersionIsInformational": true, "schemaVersionType": "string" }, + "globalVariables": { + "datafile": { + "schemaVersion": "2", + "revision": "global-variables", + "segments": { + "netherlands": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "enabledFeature": { + "bucketBy": "userId", + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "disabledFeature": { + "bucketBy": "userId", + "traffic": [] + }, + "variationFeature": { + "bucketBy": "userId", + "variations": [{ "value": "control" }, { "value": "treatment" }], + "force": [{ "segments": "*", "enabled": true, "variation": "treatment" }], + "traffic": [] + }, + "shared": { + "bucketBy": "userId", + "variablesSchema": { + "owned": { "type": "string", "defaultValue": "feature-value" } + }, + "force": [{ "segments": "*", "enabled": true }], + "traffic": [] + } + }, + "variables": { + "shared": { "type": "string", "defaultValue": "global-value" }, + "stringValue": { "type": "string", "defaultValue": "hello" }, + "integerValue": { "type": "integer", "defaultValue": 1 }, + "doubleValue": { "type": "double", "defaultValue": 1.5 }, + "booleanValue": { "type": "boolean", "defaultValue": true }, + "arrayValue": { "type": "array", "defaultValue": ["one", "two"] }, + "objectValue": { "type": "object", "defaultValue": { "enabled": true } }, + "jsonValue": { "type": "json", "defaultValue": "{\"enabled\":true}" }, + "requiredDisabled": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "requiredFeatures": ["disabledFeature"] + }, + "requiredMissingValue": { + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["disabledFeature"] + }, + "requiredUsesDefault": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "useDefaultWhenDisabled": true, + "requiredFeatures": ["disabledFeature"] + }, + "requiredVariation": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "disabled", + "requiredFeatures": [{ "feature": "variationFeature", "variation": "treatment" }] + }, + "overrideRequirement": { + "type": "string", + "defaultValue": "default", + "overrides": [ + { + "key": "blocked", + "segments": "*", + "requiredFeatures": ["disabledFeature"], + "value": "blocked" + } + ] + }, + "orderedOverrides": { + "type": "string", + "defaultValue": "default", + "overrides": [ + { + "key": "blocked", + "segments": "*", + "requiredFeatures": ["disabledFeature"], + "value": "blocked" + }, + { + "key": "nl-pro", + "keyPath": ["europe", "netherlands", "pro"], + "segments": "netherlands", + "conditions": { + "attribute": "plan", + "operator": "equals", + "value": "pro" + }, + "requiredFeatures": ["enabledFeature"], + "value": "matched" + }, + { "key": "catch-all", "segments": "*", "value": "fallback" } + ] + } + } + }, + "cases": [ + { + "name": "string default", + "key": "stringValue", + "expectedValue": "hello", + "expectedReason": "variable_default" + }, + { + "name": "integer default", + "key": "integerValue", + "expectedValue": 1, + "expectedReason": "variable_default" + }, + { + "name": "double default", + "key": "doubleValue", + "expectedValue": 1.5, + "expectedReason": "variable_default" + }, + { + "name": "boolean default", + "key": "booleanValue", + "expectedValue": true, + "expectedReason": "variable_default" + }, + { + "name": "array default", + "key": "arrayValue", + "expectedValue": ["one", "two"], + "expectedReason": "variable_default" + }, + { + "name": "object default", + "key": "objectValue", + "expectedValue": { "enabled": true }, + "expectedReason": "variable_default" + }, + { + "name": "json default", + "key": "jsonValue", + "expectedValue": "{\"enabled\":true}", + "expectedReason": "variable_default" + }, + { + "name": "required unmet with disabled value", + "key": "requiredDisabled", + "expectedValue": "disabled", + "expectedReason": "required_features_unmet" + }, + { + "name": "required unmet without value", + "key": "requiredMissingValue", + "expectedReason": "required_features_unmet" + }, + { + "name": "required unmet with caller default", + "key": "requiredMissingValue", + "defaultVariableValue": "caller", + "expectedValue": "caller", + "expectedReason": "required_features_unmet" + }, + { + "name": "required unmet using variable default", + "key": "requiredUsesDefault", + "expectedValue": "default", + "expectedReason": "required_features_unmet" + }, + { + "name": "required variation matched", + "key": "requiredVariation", + "expectedValue": "matched", + "expectedReason": "variable_default" + }, + { + "name": "unmet override requirement falls through", + "key": "overrideRequirement", + "expectedValue": "default", + "expectedReason": "variable_default" + }, + { + "name": "segment and condition override", + "key": "orderedOverrides", + "context": { "userId": "1", "country": "nl", "plan": "pro" }, + "expectedValue": "matched", + "expectedReason": "variable_override_rule", + "expectedOverrideIndex": 1, + "expectedOverrideKey": "nl-pro", + "expectedOverridePath": ["europe", "netherlands", "pro"] + }, + { + "name": "catch all override", + "key": "orderedOverrides", + "context": { "userId": "1", "country": "de", "plan": "pro" }, + "expectedValue": "fallback", + "expectedReason": "variable_override_rule", + "expectedOverrideIndex": 2, + "expectedOverrideKey": "catch-all" + }, + { + "name": "sticky precedence without definition", + "key": "absent", + "stickyVariables": { "absent": "sticky" }, + "expectedValue": "sticky", + "expectedReason": "sticky" + } + ], + "overloadCase": { + "sharedKey": "shared", + "featureVariableKey": "owned", + "expectedGlobalValue": "global-value", + "expectedFeatureValue": "feature-value" + }, + "datafileUpdateCase": { + "initial": { + "schemaVersion": "2", + "revision": "initial", + "segments": {}, + "features": { + "retained": { "hash": "feature-retained", "bucketBy": "userId", "traffic": [] }, + "changed": { "hash": "feature-old", "bucketBy": "userId", "traffic": [] } + }, + "variables": { + "retained": { "hash": "variable-retained", "type": "string", "defaultValue": "retained" }, + "changed": { "hash": "variable-old", "type": "string", "defaultValue": "old" } + } + }, + "merge": { + "schemaVersion": "2", + "revision": "merged", + "segments": {}, + "features": { + "changed": { "hash": "feature-new", "bucketBy": "userId", "traffic": [] }, + "added": { "hash": "feature-added", "bucketBy": "userId", "traffic": [] } + }, + "variables": { + "changed": { "hash": "variable-new", "type": "string", "defaultValue": "new" }, + "added": { "hash": "variable-added", "type": "string", "defaultValue": "added" } + } + }, + "expectedAfterMerge": { + "features": ["added", "changed", "retained"], + "variables": ["added", "changed", "retained"], + "changedFeatures": ["changed", "added"], + "changedVariables": ["changed", "added"] + }, + "replacement": { + "schemaVersion": "2", + "revision": "replaced", + "segments": {}, + "features": { + "added": { "hash": "feature-added", "bucketBy": "userId", "traffic": [] } + }, + "variables": { + "added": { "hash": "variable-added", "type": "string", "defaultValue": "added" } + } + }, + "expectedAfterReplacement": { + "features": ["added"], + "variables": ["added"], + "changedFeatures": ["retained", "changed"], + "changedVariables": ["retained", "changed"] + } + }, + "dependencyUpdateCase": { + "modes": [ + { "name": "merge", "replace": false }, + { "name": "replacement", "replace": true } + ], + "initial": { + "schemaVersion": "2", + "revision": "dependencies-initial", + "segments": { + "audience": { + "conditions": { "attribute": "country", "operator": "equals", "value": "nl" } + } + }, + "features": { + "segmentFeature": { + "hash": "segment-feature", + "bucketBy": "userId", + "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }] + }, + "segmentDependent": { + "hash": "segment-dependent", + "bucketBy": "userId", + "requiredFeatures": ["segmentFeature"], + "traffic": [] + }, + "prerequisite": { + "hash": "prerequisite-old", + "bucketBy": "userId", + "traffic": [] + }, + "requiredDependent": { + "hash": "required-dependent", + "bucketBy": "userId", + "requiredFeatures": ["prerequisite"], + "traffic": [] + } + }, + "variables": { + "bySegment": { + "hash": "by-segment", + "type": "string", + "defaultValue": "default", + "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }] + }, + "bySegmentFeature": { + "hash": "by-segment-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["segmentDependent"] + }, + "byRequiredFeature": { + "hash": "by-required-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["requiredDependent"] + } + } + }, + "updated": { + "schemaVersion": "2", + "revision": "dependencies-updated", + "segments": { + "audience": { + "conditions": { "attribute": "country", "operator": "equals", "value": "de" } + } + }, + "features": { + "segmentFeature": { + "hash": "segment-feature", + "bucketBy": "userId", + "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }] + }, + "segmentDependent": { + "hash": "segment-dependent", + "bucketBy": "userId", + "requiredFeatures": ["segmentFeature"], + "traffic": [] + }, + "prerequisite": { + "hash": "prerequisite-new", + "bucketBy": "userId", + "traffic": [] + }, + "requiredDependent": { + "hash": "required-dependent", + "bucketBy": "userId", + "requiredFeatures": ["prerequisite"], + "traffic": [] + } + }, + "variables": { + "bySegment": { + "hash": "by-segment", + "type": "string", + "defaultValue": "default", + "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }] + }, + "bySegmentFeature": { + "hash": "by-segment-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["segmentDependent"] + }, + "byRequiredFeature": { + "hash": "by-required-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["requiredDependent"] + } + } + }, + "withoutSegment": { + "schemaVersion": "2", + "revision": "dependencies-without-segment", + "segments": {}, + "features": { + "segmentFeature": { + "hash": "segment-feature", + "bucketBy": "userId", + "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }] + }, + "segmentDependent": { + "hash": "segment-dependent", + "bucketBy": "userId", + "requiredFeatures": ["segmentFeature"], + "traffic": [] + }, + "prerequisite": { + "hash": "prerequisite-old", + "bucketBy": "userId", + "traffic": [] + }, + "requiredDependent": { + "hash": "required-dependent", + "bucketBy": "userId", + "requiredFeatures": ["prerequisite"], + "traffic": [] + } + }, + "variables": { + "bySegment": { + "hash": "by-segment", + "type": "string", + "defaultValue": "default", + "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }] + }, + "bySegmentFeature": { + "hash": "by-segment-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["segmentDependent"] + }, + "byRequiredFeature": { + "hash": "by-required-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["requiredDependent"] + } + } + }, + "expectedChangedFeatures": [ + "prerequisite", + "requiredDependent", + "segmentDependent", + "segmentFeature" + ], + "expectedChangedVariables": ["byRequiredFeature", "bySegment", "bySegmentFeature"], + "expectedRemovedSegmentFeatures": ["segmentDependent", "segmentFeature"], + "expectedRemovedSegmentVariables": ["bySegment", "bySegmentFeature"] + } + }, + "requiredFeatures": { + "datafile": { + "schemaVersion": "2", + "revision": "required-features", + "segments": {}, + "features": { + "enabledFeature": { + "bucketBy": "userId", + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "disabledFeature": { "bucketBy": "userId", "traffic": [] }, + "disabledVariationFeature": { + "bucketBy": "userId", + "disabledVariationValue": "treatment", + "variations": [{ "value": "control" }, { "value": "treatment" }], + "traffic": [] + }, + "stringRequirement": { + "bucketBy": "userId", + "requiredFeatures": ["enabledFeature"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "explicitEnabledRequirement": { + "bucketBy": "userId", + "requiredFeatures": [{ "feature": "enabledFeature", "enabled": true }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "disabledRequirement": { + "bucketBy": "userId", + "requiredFeatures": [{ "feature": "disabledFeature", "enabled": false }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "missingDisabledRequirement": { + "bucketBy": "userId", + "requiredFeatures": [{ "feature": "missingFeature", "enabled": false }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "enabledAndVariationRequirement": { + "bucketBy": "userId", + "requiredFeatures": [ + { + "feature": "disabledVariationFeature", + "enabled": false, + "variation": "treatment" + } + ], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "multipleRequirements": { + "bucketBy": "userId", + "requiredFeatures": [ + "enabledFeature", + { "feature": "disabledFeature", "enabled": false } + ], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "unmetMultipleRequirements": { + "bucketBy": "userId", + "requiredFeatures": ["enabledFeature", { "feature": "disabledFeature", "enabled": true }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "canonicalPrecedence": { + "bucketBy": "userId", + "required": ["disabledFeature"], + "requiredFeatures": ["enabledFeature"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "featureVariableOverride": { + "bucketBy": "userId", + "variablesSchema": { + "message": { "type": "string", "defaultValue": "default" } + }, + "traffic": [ + { + "key": "all", + "segments": "*", + "percentage": 100000, + "variableOverrides": { + "message": [ + { + "key": "blocked", + "requiredFeatures": ["disabledFeature"], + "value": "blocked" + }, + { + "key": "matched", + "requiredFeatures": ["enabledFeature"], + "value": "matched" + } + ] + } + } + ] + } + } + }, + "cases": [ + { + "name": "string requirement defaults to enabled", + "feature": "stringRequirement", + "expectedEnabled": true + }, + { + "name": "explicit enabled true", + "feature": "explicitEnabledRequirement", + "expectedEnabled": true + }, + { + "name": "disabled feature satisfies enabled false", + "feature": "disabledRequirement", + "expectedEnabled": true + }, + { + "name": "missing feature satisfies enabled false", + "feature": "missingDisabledRequirement", + "expectedEnabled": true + }, + { + "name": "enabled and variation both match", + "feature": "enabledAndVariationRequirement", + "expectedEnabled": true + }, + { + "name": "multiple requirements use AND", + "feature": "multipleRequirements", + "expectedEnabled": true + }, + { + "name": "one unmet requirement disables feature", + "feature": "unmetMultipleRequirements", + "expectedEnabled": false + }, + { + "name": "requiredFeatures takes precedence over required", + "feature": "canonicalPrecedence", + "expectedEnabled": true + } + ], + "featureVariableCase": { + "feature": "featureVariableOverride", + "variable": "message", + "expectedValue": "matched", + "expectedOverrideKey": "matched" + } + }, "diagnostics": { "requiredFields": ["level", "code", "message", "details"], "detailsType": "object", @@ -107,11 +695,7 @@ "2024-01-01T00:00:00.250Z", "2024-01-01T01:00:00.250+01:00" ], - "semanticVersions": [ - "1.2.3", - "1.2.3-beta.1", - "1.2.3+build.5" - ], + "semanticVersions": ["1.2.3", "1.2.3-beta.1", "1.2.3+build.5"], "invalidSemanticVersion": "invalid", "invalidSemanticVersionDiagnosticCode": "condition_match_error" }, @@ -171,6 +755,7 @@ ], "childInstances": { "contextModel": "snapshot existing parent keys at spawn, inherit newly introduced parent keys, child keys win", + "stickyStateModel": "child sticky features and variables replace parent sticky state; omitted child sticky options mean empty sticky state", "closeRemovesLocalAndDelegatedSubscriptions": true, "detailedEvaluationMethods": ["flag", "variation", "variable"], "contextCase": { @@ -178,11 +763,52 @@ "child": { "country": "de" }, "parentAfterSpawn": { "country": "us", "plan": "pro", "region": "eu" }, "expected": { "country": "de", "plan": "free", "region": "eu" } + }, + "stickyCase": { + "datafile": { + "schemaVersion": "2", + "revision": "child-sticky", + "segments": {}, + "features": { + "flag": { + "key": "flag", + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "setting": { + "type": "string", + "defaultValue": "datafile" + } + } + }, + "parentStickyFeatures": { "flag": { "enabled": true } }, + "parentStickyVariables": { "setting": "parent-sticky" }, + "expectedParent": { "flag": true, "setting": "parent-sticky" }, + "expectedChildWithoutStickyOptions": { "flag": false, "setting": "datafile" } + }, + "globalJsonCase": { + "datafile": { + "schemaVersion": "2", + "revision": "child-global-json", + "segments": {}, + "features": {}, + "variables": { + "settings": { + "type": "json", + "defaultValue": "{\"enabled\":true}" + } + } + }, + "variableKey": "settings", + "expected": { "enabled": true } } }, "defaults": { "presenceBased": true, "values": ["", 0, false, null], + "explicitNullBeatsCallerDefault": true, "aggregateEvaluationPreservesEmptyVariation": true, "aggregateCase": { "datafile": { @@ -205,6 +831,38 @@ } } }, + "modulePipeline": { + "featureOrder": [ + "before:first", + "before:second", + "beforeEvaluation:first", + "beforeEvaluation:second", + "afterEvaluation:first", + "afterEvaluation:second", + "after:first", + "after:second" + ], + "globalOrder": [ + "beforeEvaluation:first", + "beforeEvaluation:second", + "afterEvaluation:first", + "afterEvaluation:second" + ], + "requiredFeaturesUseModules": true, + "transformedDefaultsAreApplied": true + }, + "lifecycle": { + "stickyFeatureEvent": "sticky_features_set", + "stickyFeatureDiagnostic": "sticky_features_set", + "stickyVariableEvent": "sticky_variables_set", + "stickyVariableDiagnostic": "sticky_variables_set", + "diagnosticBeforeEvent": true + }, + "openFeature": { + "reasonMappings": { + "required_features_unmet": "DISABLED" + } + }, "diagnosticCase": { "featureKey": "missing", "expectedLevel": "warn", diff --git a/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java b/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java index cf211e0..23d4480 100644 --- a/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java +++ b/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java @@ -38,6 +38,7 @@ public static final class Options { private String targetingKeyField = "userId"; private String keySeparator = ":"; private String variationKey = "variation"; + private String globalVariablePrefix = "variable"; private TrackingHandler onTrack; public Options featurevisor(Featurevisor value) { this.featurevisor = value; return this; } @@ -45,6 +46,7 @@ public static final class Options { public Options targetingKeyField(String value) { this.targetingKeyField = value; return this; } public Options keySeparator(String value) { this.keySeparator = value; return this; } public Options variationKey(String value) { this.variationKey = value; return this; } + public Options globalVariablePrefix(String value) { this.globalVariablePrefix = value; return this; } public Options onTrack(TrackingHandler value) { this.onTrack = value; return this; } } @@ -52,6 +54,7 @@ public static final class Options { private final String targetingKeyField; private final String keySeparator; private final String variationKey; + private final String globalVariablePrefix; private final TrackingHandler onTrack; private final FeaturevisorUnsubscribe datafileUnsubscribe; private final boolean ownsFeaturevisor; @@ -62,6 +65,10 @@ public FeaturevisorOpenFeatureProvider(Options options) { this.targetingKeyField = nonEmpty(resolved.targetingKeyField, "userId"); this.keySeparator = nonEmpty(resolved.keySeparator, ":"); this.variationKey = nonEmpty(resolved.variationKey, "variation"); + this.globalVariablePrefix = nonEmpty(resolved.globalVariablePrefix, "variable"); + if (this.globalVariablePrefix.contains(this.keySeparator)) { + throw new IllegalArgumentException("globalVariablePrefix cannot contain keySeparator"); + } this.onTrack = resolved.onTrack; this.ownsFeaturevisor = resolved.featurevisor == null; if (resolved.featurevisor != null) { @@ -142,7 +149,15 @@ private ProviderEvaluation resolve(String flagKey, Object defaultValue, Evaluation evaluation; Object value; - if (selector == null || selector.isEmpty()) { + if (featureKey.equals(globalVariablePrefix) && selector != null && !selector.isEmpty()) { + evaluation = featurevisor.evaluateVariable(selector, fvContext); + value = evaluation.getVariableValue(); + if (evaluation.getVariable() != null + && evaluation.getVariable().getType() == VariableType.JSON + && value instanceof String) { + try { value = OBJECT_MAPPER.readValue((String) value, Object.class); } catch (Exception ignored) { } + } + } else if (selector == null || selector.isEmpty()) { if (!"boolean".equals(expectedType)) return typeMismatch(flagKey, defaultValue, expectedType, ImmutableMetadata.EMPTY); evaluation = featurevisor.evaluateFlag(featureKey, fvContext); value = evaluation.getEnabled(); @@ -174,9 +189,9 @@ private ProviderEvaluation success(Object value, Evaluation evaluation, private ImmutableMetadata metadata(Evaluation evaluation) { ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder() - .addString("featureKey", evaluation.getFeatureKey()) .addString("featurevisorReason", evaluation.getReason()) .addString("schemaVersion", featurevisor.getSchemaVersion()); + if (evaluation.getFeatureKey() != null) builder.addString("featureKey", evaluation.getFeatureKey()); if (featurevisor.getRevision() != null) builder.addString("revision", featurevisor.getRevision()); if (evaluation.getVariableKey() != null) builder.addString("variableKey", evaluation.getVariableKey()); if (evaluation.getRuleKey() != null) builder.addString("ruleKey", evaluation.getRuleKey()); @@ -184,6 +199,7 @@ private ImmutableMetadata metadata(Evaluation evaluation) { if (evaluation.getBucketValue() != null) builder.addInteger("bucketValue", evaluation.getBucketValue()); if (evaluation.getForceIndex() != null) builder.addInteger("forceIndex", evaluation.getForceIndex()); if (evaluation.getVariableOverrideIndex() != null) builder.addInteger("variableOverrideIndex", evaluation.getVariableOverrideIndex()); + if (evaluation.getVariableOverrideKey() != null) builder.addString("variableOverrideKey", evaluation.getVariableOverrideKey()); return builder.build(); } @@ -191,7 +207,7 @@ private static Reason reason(String reason) { if (List.of(Evaluation.REASON_FEATURE_NOT_FOUND, Evaluation.REASON_VARIABLE_NOT_FOUND, Evaluation.REASON_NO_VARIATIONS, Evaluation.REASON_ERROR).contains(reason)) return Reason.ERROR; if (List.of(Evaluation.REASON_REQUIRED, Evaluation.REASON_FORCED, Evaluation.REASON_STICKY, Evaluation.REASON_RULE, Evaluation.REASON_VARIABLE_OVERRIDE_RULE, Evaluation.REASON_VARIABLE_OVERRIDE_VARIATION).contains(reason)) return Reason.TARGETING_MATCH; if (Evaluation.REASON_ALLOCATED.equals(reason)) return Reason.SPLIT; - if (List.of(Evaluation.REASON_DISABLED, Evaluation.REASON_VARIATION_DISABLED, Evaluation.REASON_VARIABLE_DISABLED).contains(reason)) return Reason.DISABLED; + if (List.of(Evaluation.REASON_DISABLED, Evaluation.REASON_VARIATION_DISABLED, Evaluation.REASON_VARIABLE_DISABLED, Evaluation.REASON_REQUIRED_FEATURES_UNMET).contains(reason)) return Reason.DISABLED; return Reason.DEFAULT; } diff --git a/featurevisor-openfeature/src/test/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProviderTest.java b/featurevisor-openfeature/src/test/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProviderTest.java index 21d6708..58b63dc 100644 --- a/featurevisor-openfeature/src/test/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProviderTest.java +++ b/featurevisor-openfeature/src/test/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProviderTest.java @@ -18,7 +18,7 @@ class FeaturevisorOpenFeatureProviderTest { private static final String DATAFILE = """ - {"schemaVersion":"2","revision":"openfeature-test","segments":{},"features":{"checkout":{ + {"schemaVersion":"2","revision":"openfeature-test","segments":{},"variables":{"welcome":{"hash":"welcome","type":"string","defaultValue":"Welcome","overrides":[{"key":"nl","conditions":{"attribute":"country","operator":"equals","value":"nl"},"value":"Welkom"}]}},"features":{"checkout":{ "bucketBy":"userId", "variations":[{"value":"on","variables":{"title":"Hello","count":3,"ratio":1.5,"visible":true,"items":["a"],"config":{"color":"blue"},"json":"{\\\"nested\\\":true}"}}], "variablesSchema":{"title":{"type":"string","defaultValue":"Default"},"count":{"type":"integer","defaultValue":0},"ratio":{"type":"double","defaultValue":0},"visible":{"type":"boolean","defaultValue":false},"items":{"type":"array","defaultValue":[]},"config":{"type":"object","defaultValue":{}},"json":{"type":"json","defaultValue":"{}"}}, @@ -60,6 +60,24 @@ private Featurevisor.FeaturevisorOptions options() throws Exception { provider.shutdown(); } + @Test void resolvesGlobalVariablesWithDefaultAndCustomPrefixes() throws Exception { + FeaturevisorOpenFeatureProvider provider = new FeaturevisorOpenFeatureProvider(options()); + assertEquals("Welcome", provider.getStringEvaluation("variable:welcome", "fallback", ImmutableContext.EMPTY).getValue()); + + FeaturevisorOpenFeatureProvider custom = new FeaturevisorOpenFeatureProvider( + new FeaturevisorOpenFeatureProvider.Options().featurevisorOptions(options()).globalVariablePrefix("global") + ); + ProviderEvaluation result = custom.getStringEvaluation( + "global:welcome", "fallback", new ImmutableContext("user", java.util.Map.of("country", new Value("nl"))) + ); + assertEquals("Welkom", result.getValue()); + assertEquals("nl", result.getFlagMetadata().getString("variableOverrideKey")); + assertNull(result.getFlagMetadata().getString("featureKey")); + assertThrows(IllegalArgumentException.class, () -> new FeaturevisorOpenFeatureProvider( + new FeaturevisorOpenFeatureProvider.Options().featurevisorOptions(options()).globalVariablePrefix("global:variable") + )); + } + @Test void reportsMalformedDatafileAndWorksThroughOpenFeatureApi() throws Exception { FeaturevisorOpenFeatureProvider malformed = new FeaturevisorOpenFeatureProvider(new Featurevisor.FeaturevisorOptions().datafileString("{").logLevel(FeaturevisorLogLevel.FATAL)); assertEquals(ErrorCode.PARSE_ERROR, malformed.getBooleanEvaluation("checkout", false, ImmutableContext.EMPTY).getErrorCode()); diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java b/featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java index 405a3ca..73b594a 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/cli/CLI.java @@ -10,6 +10,7 @@ import com.featurevisor.sdk.Conditions; import com.featurevisor.sdk.DatafileContent; import com.featurevisor.sdk.Segment; +import com.featurevisor.sdk.Evaluation; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.core.type.TypeReference; @@ -33,7 +34,7 @@ @Command( name = "featurevisor", mixinStandardHelpOptions = true, - version = "3.0.0", + version = "4.0.0", description = "Featurevisor Java Library CLI - Test runner, benchmark, and distribution assessment" ) public class CLI implements Runnable { @@ -364,10 +365,10 @@ private TestResult testFeature(Map assertion, String featureKey, // Update the SDK instance context and sticky values for this assertion if (f instanceof Featurevisor) { ((Featurevisor) f).setContext(context, true); - ((Featurevisor) f).setSticky(sticky, true); + ((Featurevisor) f).setStickyFeatures(sticky, true); } else if (f instanceof com.featurevisor.sdk.ChildInstance) { ((com.featurevisor.sdk.ChildInstance) f).setContext(context, true); - ((com.featurevisor.sdk.ChildInstance) f).setSticky(sticky, true); + ((com.featurevisor.sdk.ChildInstance) f).setStickyFeatures(sticky, true); } boolean hasError = false; @@ -535,6 +536,36 @@ private TestResult testFeature(Map assertion, String featureKey, return new TestResult(hasError, errors.toString(), duration); } + private TestResult testGlobalVariable(Map assertion, String variableKey, Featurevisor f) { + @SuppressWarnings("unchecked") Map context = (Map) assertion.getOrDefault("context", new HashMap<>()); + @SuppressWarnings("unchecked") Map stickyVariables = (Map) assertion.getOrDefault("stickyVariables", new HashMap<>()); + f.setContext(context, true); + f.setStickyVariables(stickyVariables, true); + Featurevisor.OverrideOptions options = new Featurevisor.OverrideOptions(); + if (assertion.containsKey("defaultVariableValue")) options.setDefaultVariableValue(assertion.get("defaultVariableValue")); + long startTime = System.nanoTime(); + Evaluation evaluation = f.evaluateVariable(variableKey, context, options); + boolean hasError = false; + StringBuilder errors = new StringBuilder(); + if (assertion.containsKey("expectedValue") && !Objects.equals(assertion.get("expectedValue"), evaluation.getVariableValue())) { + hasError = true; + errors.append(" ✘ expectedValue: expected ").append(assertion.get("expectedValue")) + .append(" but received ").append(evaluation.getVariableValue()).append("\n"); + } + if (assertion.containsKey("expectedEvaluation")) { + @SuppressWarnings("unchecked") Map expected = (Map) assertion.get("expectedEvaluation"); + for (Map.Entry entry : expected.entrySet()) { + Object actual = getEvaluationValue(evaluation, entry.getKey()); + if (!Objects.equals(entry.getValue(), actual)) { + hasError = true; + errors.append(" ✘ expectedEvaluation.").append(entry.getKey()).append(": expected ") + .append(entry.getValue()).append(" but received ").append(actual).append("\n"); + } + } + } + return new TestResult(hasError, errors.toString(), (System.nanoTime() - startTime) / 1_000_000.0); + } + /** * Helper methods to work with both Instance and ChildInstance */ @@ -616,6 +647,8 @@ private Object getEvaluationValue(com.featurevisor.sdk.Evaluation evaluation, St case "variableKey": return evaluation.getVariableKey(); case "variableValue": return evaluation.getVariableValue(); case "variableOverrideIndex": return evaluation.getVariableOverrideIndex(); + case "variableOverrideKey": return evaluation.getVariableOverrideKey(); + case "variableOverridePath": return evaluation.getVariableOverridePath(); case "bucketKey": return evaluation.getBucketKey(); case "bucketValue": return evaluation.getBucketValue(); case "ruleKey": return evaluation.getRuleKey(); @@ -624,6 +657,7 @@ private Object getEvaluationValue(com.featurevisor.sdk.Evaluation evaluation, St case "sticky": return evaluation.getSticky(); case "traffic": return evaluation.getTraffic(); case "required": return evaluation.getRequired() != null ? evaluation.getRequired() : new ArrayList<>(); + case "requiredFeatures": return evaluation.getRequiredFeatures() != null ? evaluation.getRequiredFeatures() : new ArrayList<>(); case "error": return evaluation.getError(); default: return null; } @@ -768,6 +802,14 @@ private void test() { testResult = testFeature(effectiveAssertion, (String) test.get("feature"), f, level); + } else if (test.containsKey("variable")) { + String assertionEnvironment = assertion.get("environment") instanceof String ? (String) assertion.get("environment") : null; + String selectedDatafileKey = selectDatafileKeyForAssertion(assertion, datafileCache); + DatafileContent selectedDatafile = datafileCache.get(selectedDatafileKey); + if (selectedDatafile == null) selectedDatafile = datafileCache.get(getEnvironmentKey(assertionEnvironment)); + if (selectedDatafile == null) throw new IOException("No datafile found for assertion environment: " + assertionEnvironment); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(selectedDatafile).logLevel(level)); + testResult = testGlobalVariable(assertion, (String) test.get("variable"), f); } else if (test.containsKey("segment")) { testResult = testSegment(assertion, segmentsByKey.get(test.get("segment")), level); } else { @@ -825,8 +867,8 @@ private void benchmark() { return; } - if (feature == null) { - System.out.println("Feature is required"); + if (feature == null && variable == null) { + System.out.println("Feature or variable is required"); return; } @@ -856,15 +898,17 @@ private void benchmark() { Object value = null; System.out.println("Benchmark Featurevisor feature"); - System.out.println(" Feature: " + feature); + if (feature != null) System.out.println(" Feature: " + feature); System.out.println(" Environment: " + environment); if (target != null) System.out.println(" Target: " + target); System.out.println(" Iterations: " + n); if (variation) { System.out.println("Benchmarking variation for feature '" + feature + "'..."); - } else if (variable != null) { + } else if (variable != null && feature != null) { System.out.println("Benchmarking variable '" + variable + "' for feature '" + feature + "'..."); + } else if (variable != null) { + System.out.println("Benchmarking global variable '" + variable + "'..."); } else { System.out.println("Benchmarking flag for feature '" + feature + "'..."); } @@ -879,8 +923,10 @@ private void benchmark() { long evaluationStartTime = System.nanoTime(); if (variation) { value = f.getVariation(feature, contextMap); - } else if (variable != null) { + } else if (variable != null && feature != null) { value = f.getVariable(feature, variable, contextMap); + } else if (variable != null) { + value = f.getVariable(variable, contextMap); } else { value = f.isEnabled(feature, contextMap); } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ChildInstance.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ChildInstance.java index cc51d05..7a8e437 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ChildInstance.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ChildInstance.java @@ -16,16 +16,18 @@ public class ChildInstance { private Featurevisor parent; private Map context; private Map sticky; + private Map stickyVariables; private Emitter emitter; private final List parentUnsubscribers = new ArrayList<>(); /** * Constructor */ - ChildInstance(Featurevisor parent, Map context, Map sticky) { + ChildInstance(Featurevisor parent, Map context, Map sticky, Map stickyVariables) { this.parent = parent; this.context = context != null ? new HashMap<>(context) : new HashMap<>(); this.sticky = sticky; + this.stickyVariables = stickyVariables; this.emitter = new Emitter(); } @@ -33,7 +35,7 @@ public class ChildInstance { * Subscribe to event */ public FeaturevisorUnsubscribe on(FeaturevisorEventName eventName, FeaturevisorEventHandler callback) { - if (FeaturevisorEventName.CONTEXT_SET.equals(eventName) || FeaturevisorEventName.STICKY_SET.equals(eventName)) { + if (FeaturevisorEventName.CONTEXT_SET.equals(eventName) || FeaturevisorEventName.STICKY_FEATURES_SET.equals(eventName) || FeaturevisorEventName.STICKY_VARIABLES_SET.equals(eventName)) { return this.emitter.on(eventName, callback); } @@ -99,7 +101,7 @@ public Map getContext() { /** * Set sticky features */ - public void setSticky(Map sticky, boolean replace) { + public void setStickyFeatures(Map sticky, boolean replace) { Map previousStickyFeatures = this.sticky != null ? new HashMap<>(this.sticky) : new HashMap<>(); @@ -110,15 +112,20 @@ public void setSticky(Map sticky, boolean replace) { this.sticky.putAll(sticky); } - FeaturevisorEventDetails params = Events.getParamsForStickySetEvent( + FeaturevisorEventDetails params = Events.getParamsForStickyFeaturesSetEvent( previousStickyFeatures, this.sticky, replace); - this.emitter.trigger(FeaturevisorEventName.STICKY_SET, params); + this.emitter.trigger(FeaturevisorEventName.STICKY_FEATURES_SET, params); } - public void setSticky(Map sticky) { - setSticky(sticky, false); + public void setStickyFeatures(Map sticky) { setStickyFeatures(sticky, false); } + public void setStickyVariables(Map sticky, boolean replace) { + Map previous = this.stickyVariables != null ? new HashMap<>(this.stickyVariables) : new HashMap<>(); + this.stickyVariables = replace ? new HashMap<>(sticky) : new HashMap<>(previous); + if (!replace) this.stickyVariables.putAll(sticky); + this.emitter.trigger(FeaturevisorEventName.STICKY_VARIABLES_SET, Events.getParamsForStickyVariablesSetEvent(previous, this.stickyVariables, replace)); } + public void setStickyVariables(Map sticky) { setStickyVariables(sticky, false); } /** * Flag @@ -227,6 +234,31 @@ public Object getVariable(String featureKey, String variableKey) { return getVariable(featureKey, variableKey, null, null); } + public Evaluation evaluateVariable(String variableKey, Map context, Featurevisor.OverrideOptions options) { + return parent.evaluateVariable(variableKey, mergeContexts(this.context, context), mergeOverrideOptions(options)); + } + public Evaluation evaluateVariable(String variableKey, Map context) { return evaluateVariable(variableKey, context, null); } + public Evaluation evaluateVariable(String variableKey) { return evaluateVariable(variableKey, (Map) null, null); } + public Object getVariable(String variableKey, Map context, Featurevisor.OverrideOptions options) { + return parent.getVariable(variableKey, mergeContexts(this.context, context), mergeOverrideOptions(options)); + } + public Object getVariable(String variableKey, Map context) { return getVariable(variableKey, context, null); } + public Object getVariable(String variableKey) { return getVariable(variableKey, (Map) null, null); } + public Boolean getVariableBoolean(String key, Map context, Featurevisor.OverrideOptions options) { return parent.getVariableBoolean(key, mergeContexts(this.context, context), mergeOverrideOptions(options)); } + public Boolean getVariableBoolean(String key) { return getVariableBoolean(key, (Map) null, null); } + public String getVariableString(String key, Map context, Featurevisor.OverrideOptions options) { return parent.getVariableString(key, mergeContexts(this.context, context), mergeOverrideOptions(options)); } + public String getVariableString(String key) { return getVariableString(key, (Map) null, null); } + public Integer getVariableInteger(String key, Map context, Featurevisor.OverrideOptions options) { return parent.getVariableInteger(key, mergeContexts(this.context, context), mergeOverrideOptions(options)); } + public Integer getVariableInteger(String key) { return getVariableInteger(key, (Map) null, null); } + public Double getVariableDouble(String key, Map context, Featurevisor.OverrideOptions options) { return parent.getVariableDouble(key, mergeContexts(this.context, context), mergeOverrideOptions(options)); } + public Double getVariableDouble(String key) { return getVariableDouble(key, (Map) null, null); } + public List getVariableArray(String key, Map context, Featurevisor.OverrideOptions options) { return parent.getVariableArray(key, mergeContexts(this.context, context), mergeOverrideOptions(options)); } + public List getVariableArray(String key) { return getVariableArray(key, (Map) null, (Featurevisor.OverrideOptions) null); } + public T getVariableObject(String key, Map context, Featurevisor.OverrideOptions options) { return parent.getVariableObject(key, mergeContexts(this.context, context), mergeOverrideOptions(options)); } + public T getVariableObject(String key) { return getVariableObject(key, (Map) null, (Featurevisor.OverrideOptions) null); } + public T getVariableJSON(String key, Map context, Featurevisor.OverrideOptions options) { return parent.getVariableJSON(key, mergeContexts(this.context, context), mergeOverrideOptions(options)); } + public T getVariableJSON(String key) { return getVariableJSON(key, (Map) null, (Featurevisor.OverrideOptions) null); } + public Boolean getVariableBoolean(String featureKey, String variableKey, Map context, Featurevisor.OverrideOptions options) { return this.parent.getVariableBoolean( featureKey, @@ -487,25 +519,27 @@ public JsonNode getVariableJSONNode(String featureKey, String variableKey) { /** * Get all evaluations */ - public EvaluatedFeatures getAllEvaluations(Map context, List featureKeys, Featurevisor.OverrideOptions options) { - return this.parent.getAllEvaluations( + public EvaluatedFeatures getFeatureEvaluations(Map context, List featureKeys, Featurevisor.OverrideOptions options) { + return this.parent.getFeatureEvaluations( mergeContexts(this.context, context), featureKeys, mergeOverrideOptions(options) ); } - public EvaluatedFeatures getAllEvaluations(Map context, List featureKeys) { - return getAllEvaluations(context, featureKeys, null); + public EvaluatedFeatures getFeatureEvaluations(Map context, List featureKeys) { + return getFeatureEvaluations(context, featureKeys, null); } - public EvaluatedFeatures getAllEvaluations(Map context) { - return getAllEvaluations(context, null, null); + public EvaluatedFeatures getFeatureEvaluations(Map context) { + return getFeatureEvaluations(context, null, null); } - public EvaluatedFeatures getAllEvaluations() { - return getAllEvaluations(null, null, null); + public EvaluatedFeatures getFeatureEvaluations() { + return getFeatureEvaluations(null, null, null); } + public Map getVariableEvaluations(Map context, List keys, Featurevisor.OverrideOptions options) { return parent.getVariableEvaluations(mergeContexts(this.context, context), keys, mergeOverrideOptions(options)); } + public Map getVariableEvaluations() { return getVariableEvaluations(null, null, null); } /** * Helper methods @@ -525,7 +559,8 @@ private Featurevisor.OverrideOptions mergeOverrideOptions(Featurevisor.OverrideO options = new Featurevisor.OverrideOptions(); } - options.setInternalSticky(this.sticky); + options.setInternalStickyFeatures(this.sticky); + options.setInternalStickyVariables(this.stickyVariables); return options; } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Conditions.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Conditions.java index cee2eb6..313dc35 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Conditions.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Conditions.java @@ -200,7 +200,7 @@ static boolean conditionIsMatched( /** * Check if all conditions are matched given a context. * This mirrors the JavaScript SDK's narrow root helper without exposing the - * internal datafile reader implementation. + * instance evaluation data provider. */ public static boolean allConditionsAreMatched(Object conditions, Map context) { DatafileContent datafile = new DatafileContent(); diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/DatafileContent.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/DatafileContent.java index b3ea75e..60faf12 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/DatafileContent.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/DatafileContent.java @@ -29,6 +29,9 @@ public class DatafileContent { @JsonDeserialize(using = com.featurevisor.sdk.FeaturesDeserializer.class) private Map features; + @JsonProperty("variables") + private Map variables; + // Constructors public DatafileContent() {} @@ -82,6 +85,9 @@ public void setFeatures(Map features) { } } + public Map getVariables() { return variables; } + public void setVariables(Map variables) { this.variables = variables; } + /** * Static method to parse JSON string into DatafileContent object * diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluate.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluate.java index 138d582..fc134af 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluate.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluate.java @@ -41,7 +41,7 @@ public static Evaluation evaluateWithModules(EvaluateOptions opts) { // default: variable if (options.hasDefaultVariableValue() && Evaluation.TYPE_VARIABLE.equals(evaluation.getType()) && - evaluation.getVariableValue() == null) { + !evaluation.hasVariableValue()) { evaluation.variableValue(options.getDefaultVariableValue()); } @@ -85,6 +85,9 @@ public static Evaluation evaluate(EvaluateOptions options) { Evaluation evaluation; try { + if (options.isGlobalVariable()) { + return evaluateGlobalVariable(options); + } // root Evaluation flag; @@ -124,10 +127,19 @@ public static Evaluation evaluate(EvaluateOptions options) { // required (only for flag evaluations) if (Evaluation.TYPE_FLAG.equals(type)) { + if (feature.getRequiredFeatures() != null) { + if (!requiredFeaturesAreMatched(feature.getRequiredFeatures(), options)) { + return new Evaluation(type, featureKey, variableKey) + .reason(Evaluation.REASON_REQUIRED) + .requiredFeatures(feature.getRequiredFeatures()) + .enabled(false); + } + } else { Evaluation requiredEvaluation = evaluateRequired(options, feature); if (requiredEvaluation != null) { return requiredEvaluation; } + } } // bucket @@ -139,6 +151,25 @@ public static Evaluation evaluate(EvaluateOptions options) { return bucketingResult.getEvaluation(); } + if (Evaluation.TYPE_VARIABLE.equals(type) && variableSchema == null && variableKey != null + && feature.getVariablesSchema() != null) { + variableSchema = feature.getVariablesSchema().get(variableKey); + } + if (Evaluation.TYPE_VARIABLE.equals(type) && variableSchema != null) { + Evaluation variableDefaultEvaluation = new Evaluation() + .type(type) + .featureKey(featureKey) + .reason(Evaluation.REASON_VARIABLE_DEFAULT) + .bucketKey(bucketKey) + .bucketValue(bucketValue) + .variableKey(variableKey) + .variableSchema(variableSchema); + if (variableSchema.hasDefaultValue()) { + variableDefaultEvaluation.variableValue(variableSchema.getDefaultValue()); + } + return variableDefaultEvaluation; + } + // nothing matched evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_NO_MATCH) @@ -168,6 +199,86 @@ public static Evaluation evaluate(EvaluateOptions options) { } } + static boolean requiredFeaturesAreMatched(List requirements, EvaluateOptions options) { + if (requirements == null || requirements.isEmpty()) { return true; } + EvaluateOptions clean = options.copy() + .defaultVariableValue(null, false) + .defaultVariationValue(null) + .globalVariable(false); + clean.setVariableKey(null); + for (Object item : requirements) { + String key; + boolean enabled = true; + String variation = null; + if (item instanceof String) { + key = (String) item; + } else if (item instanceof Map) { + @SuppressWarnings("unchecked") Map value = (Map) item; + key = (String) value.get("feature"); + if (value.containsKey("enabled")) { enabled = Boolean.TRUE.equals(value.get("enabled")); } + variation = (String) value.get("variation"); + } else { return false; } + if (key == null) { return false; } + Evaluation flag = evaluateWithModules(clean.copy().type(Evaluation.TYPE_FLAG).featureKey(key)); + if (Boolean.TRUE.equals(flag.getEnabled()) != enabled) { return false; } + if (variation != null) { + Evaluation variationEvaluation = evaluateWithModules(clean.copy().type(Evaluation.TYPE_VARIATION).featureKey(key)); + String actualVariation = variationEvaluation.getVariationValue(); + if (actualVariation == null && variationEvaluation.getVariation() != null) { + actualVariation = variationEvaluation.getVariation().getValue(); + } + if (!variation.equals(actualVariation)) { return false; } + } + } + return true; + } + + static boolean variableOverrideIsMatched(VariableOverride override, EvaluateOptions options) { + InstanceEvaluationDataProvider data = options.getInstanceEvaluationDataProvider(); + Map context = options.getContext(); + if (override.getConditions() != null && !data.allConditionsAreMatched(data.parseConditionsIfStringified(override.getConditions()), context)) { return false; } + if (override.getSegments() != null && !data.allSegmentsAreMatched(data.parseSegmentsIfStringified(override.getSegments()), context)) { return false; } + if (override.getRequiredFeatures() != null && !requiredFeaturesAreMatched(override.getRequiredFeatures(), options)) { return false; } + return override.getConditions() != null || override.getSegments() != null || override.getRequiredFeatures() != null; + } + + private static Evaluation evaluateGlobalVariable(EvaluateOptions options) { + String key = options.getVariableKey(); + if (options.getStickyVariables() != null && options.getStickyVariables().containsKey(key)) { + return new Evaluation(Evaluation.TYPE_VARIABLE, null, key) + .reason(Evaluation.REASON_STICKY).variableValue(options.getStickyVariables().get(key)); + } + GlobalVariable variable = options.getInstanceEvaluationDataProvider().getGlobalVariable(key); + if (variable == null) { + return new Evaluation(Evaluation.TYPE_VARIABLE, null, key).reason(Evaluation.REASON_VARIABLE_NOT_FOUND); + } + if (!requiredFeaturesAreMatched(variable.getRequiredFeatures(), options)) { + boolean useDefault = Boolean.TRUE.equals(variable.getUseDefaultWhenDisabled()); + Evaluation evaluation = new Evaluation(Evaluation.TYPE_VARIABLE, null, key) + .reason(Evaluation.REASON_REQUIRED_FEATURES_UNMET).variable(variable) + .requiredFeatures(variable.getRequiredFeatures()); + if (useDefault ? variable.hasDefaultValue() : variable.hasDisabledValue()) { + evaluation.variableValue(useDefault ? variable.getDefaultValue() : variable.getDisabledValue()); + } + return evaluation; + } + if (variable.getOverrides() != null) { + for (int index = 0; index < variable.getOverrides().size(); index++) { + VariableOverride override = variable.getOverrides().get(index); + if (variableOverrideIsMatched(override, options)) { + return new Evaluation(Evaluation.TYPE_VARIABLE, null, key) + .reason(Evaluation.REASON_VARIABLE_OVERRIDE_RULE).variable(variable) + .variableValue(override.getValue()).variableOverrideIndex(index) + .variableOverrideKey(override.getKey()).variableOverridePath(override.getKeyPath()); + } + } + } + Evaluation evaluation = new Evaluation(Evaluation.TYPE_VARIABLE, null, key) + .reason(Evaluation.REASON_VARIABLE_DEFAULT).variable(variable); + if (variable.hasDefaultValue()) evaluation.variableValue(variable.getDefaultValue()); + return evaluation; + } + /** * Evaluate required features * @param options The evaluation options diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateByBucketing.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateByBucketing.java index a592936..778d314 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateByBucketing.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateByBucketing.java @@ -192,8 +192,8 @@ public static EvaluateByBucketingResult evaluateByBucketing( .reason(Evaluation.REASON_RULE) .bucketKey(bucketKey) .bucketValue(bucketValue) - .ruleKey(matchedTraffic.getKey()) - .traffic(convertTrafficToMap(matchedTraffic)) + .ruleKey(matchedTraffic != null ? matchedTraffic.getKey() : null) + .traffic(matchedTraffic != null ? convertTrafficToMap(matchedTraffic) : null) .enabled(matchedTraffic.getEnabled()); Map details = new HashMap<>(); @@ -299,24 +299,7 @@ public static EvaluateByBucketingResult evaluateByBucketing( List overrides = matchedTraffic.getVariableOverrides().get(variableKey); for (int overrideIndex = 0; overrideIndex < overrides.size(); overrideIndex++) { VariableOverride override = overrides.get(overrideIndex); - boolean matches = false; - - if (override.getConditions() != null) { - Object conditions = override.getConditions(); - if (conditions instanceof String && !"*".equals(conditions)) { - try { - conditions = new com.fasterxml.jackson.databind.ObjectMapper() - .readValue((String) conditions, Object.class); - } catch (Exception ignored) { - conditions = override.getConditions(); - } - } - - matches = evaluationData.allConditionsAreMatched(conditions, context); - } else if (override.getSegments() != null) { - Object parsedSegments = evaluationData.parseSegmentsIfStringified(override.getSegments()); - matches = evaluationData.allSegmentsAreMatched(parsedSegments, context); - } + boolean matches = Evaluate.variableOverrideIsMatched(override, options); if (matches) { Evaluation evaluation = new Evaluation(type, featureKey, variableKey) @@ -327,7 +310,9 @@ public static EvaluateByBucketingResult evaluateByBucketing( .traffic(convertTrafficToMap(matchedTraffic)) .variableValue(override.getValue()) .variableSchema(variableSchema) - .variableOverrideIndex(overrideIndex); + .variableOverrideIndex(overrideIndex) + .variableOverrideKey(override.getKey()) + .variableOverridePath(override.getKeyPath()); Map details = new HashMap<>(); details.put("featureKey", featureKey); @@ -394,26 +379,7 @@ public static EvaluateByBucketingResult evaluateByBucketing( List overrides = variation.getVariableOverrides().get(variableKey); for (int overrideIndex = 0; overrideIndex < overrides.size(); overrideIndex++) { VariableOverride override = overrides.get(overrideIndex); - boolean matches = false; - - // Check conditions - if (override.getConditions() != null) { - Object conditions = override.getConditions(); - if (conditions instanceof String && !"*".equals(conditions)) { - try { - conditions = new com.fasterxml.jackson.databind.ObjectMapper() - .readValue((String) conditions, Object.class); - } catch (Exception ignored) { - conditions = override.getConditions(); - } - } - matches = evaluationData.allConditionsAreMatched(conditions, context); - } - // Check segments - else if (override.getSegments() != null) { - Object parsedSegments = evaluationData.parseSegmentsIfStringified(override.getSegments()); - matches = evaluationData.allSegmentsAreMatched(parsedSegments, context); - } + boolean matches = Evaluate.variableOverrideIsMatched(override, options); if (matches) { Evaluation evaluation = new Evaluation(type, featureKey, variableKey) @@ -424,7 +390,9 @@ else if (override.getSegments() != null) { .traffic(matchedTraffic != null ? convertTrafficToMap(matchedTraffic) : null) .variableValue(override.getValue()) .variableSchema(variableSchema) - .variableOverrideIndex(overrideIndex); + .variableOverrideIndex(overrideIndex) + .variableOverrideKey(override.getKey()) + .variableOverridePath(override.getKeyPath()); Map details = new HashMap<>(); details.put("featureKey", featureKey); @@ -466,16 +434,16 @@ else if (override.getSegments() != null) { // default value if (variableSchema != null) { - Object variableValue = variableSchema.getDefaultValue(); - Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_VARIABLE_DEFAULT) .bucketKey(bucketKey) .bucketValue(bucketValue) .ruleKey(matchedTraffic.getKey()) .traffic(convertTrafficToMap(matchedTraffic)) - .variableValue(variableValue) .variableSchema(variableSchema); + if (variableSchema.hasDefaultValue()) { + evaluation.variableValue(variableSchema.getDefaultValue()); + } Map details = new HashMap<>(); details.put("featureKey", featureKey); @@ -493,8 +461,8 @@ else if (override.getSegments() != null) { .reason(Evaluation.REASON_VARIABLE_NOT_FOUND) .bucketKey(bucketKey) .bucketValue(bucketValue) - .ruleKey(matchedTraffic.getKey()) - .traffic(convertTrafficToMap(matchedTraffic)) + .ruleKey(matchedTraffic != null ? matchedTraffic.getKey() : null) + .traffic(matchedTraffic != null ? convertTrafficToMap(matchedTraffic) : null) .variableSchema(variableSchema); Map details = new HashMap<>(); @@ -509,6 +477,22 @@ else if (override.getSegments() != null) { } } + if (Evaluation.TYPE_VARIABLE.equals(type) && variableSchema != null) { + Evaluation variableDefaultEvaluation = new Evaluation() + .type(type) + .featureKey(featureKey) + .reason(Evaluation.REASON_VARIABLE_DEFAULT) + .bucketKey(bucketKey) + .bucketValue(bucketValue) + .variableKey(variableKey) + .variableSchema(variableSchema); + if (variableSchema.hasDefaultValue()) { + variableDefaultEvaluation.variableValue(variableSchema.getDefaultValue()); + } + result.setEvaluation(variableDefaultEvaluation); + return result; + } + // Nothing matched Evaluation evaluation = new Evaluation(type, featureKey, variableKey) .reason(Evaluation.REASON_NO_MATCH) diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateDisabled.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateDisabled.java index b6f5ace..7016f89 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateDisabled.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateDisabled.java @@ -40,7 +40,7 @@ public static Evaluation evaluateDisabled(EvaluateOptions options, Evaluation fl VariableSchema variableSchema = feature.getVariablesSchema().get(variableKey); - if (variableSchema.getDisabledValue() != null) { + if (variableSchema.hasDisabledValue()) { // disabledValue: evaluation = new Evaluation() .type(type) @@ -50,7 +50,7 @@ public static Evaluation evaluateDisabled(EvaluateOptions options, Evaluation fl .variableValue(variableSchema.getDisabledValue()) .variableSchema(variableSchema) .enabled(false); - } else if (Boolean.TRUE.equals(variableSchema.getUseDefaultWhenDisabled())) { + } else if (Boolean.TRUE.equals(variableSchema.getUseDefaultWhenDisabled()) && variableSchema.hasDefaultValue()) { // useDefaultWhenDisabled: true evaluation = new Evaluation() .type(type) diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateForced.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateForced.java index f92fc0b..83f8a72 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateForced.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateForced.java @@ -115,10 +115,12 @@ public static EvaluateForcedResult evaluate(EvaluateOptions options, Feature fea // @NOTE: this implementation here deviated from PHP implementation. in PHP, it was partially delegated to EvaluateByBucketing if (variableKey != null) { Object variableValue = null; + boolean variableValueSet = false; // First check if force has direct variables if (force.getVariables() != null && force.getVariables().containsKey(variableKey)) { variableValue = force.getVariables().get(variableKey); + variableValueSet = true; } // If no direct variable, check if force has a variation with variable overrides else if (force.getVariation() != null && feature.getVariations() != null) { @@ -135,6 +137,7 @@ else if (force.getVariation() != null && feature.getVariations() != null) { // Get base variable value from variation if (forcedVariation.getVariables() != null && forcedVariation.getVariables().containsKey(variableKey)) { variableValue = forcedVariation.getVariables().get(variableKey); + variableValueSet = true; } // Apply variable overrides if they exist @@ -160,6 +163,7 @@ else if (override.getSegments() != null) { if (matches) { variableValue = override.getValue(); + variableValueSet = true; break; // Use the first matching override } } @@ -167,7 +171,7 @@ else if (override.getSegments() != null) { } } - if (variableValue != null) { + if (variableValueSet) { Evaluation evaluation = new Evaluation() .type(type) .featureKey(featureKey) diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateOptions.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateOptions.java index 8c75128..15b4857 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateOptions.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateOptions.java @@ -12,6 +12,7 @@ public class EvaluateOptions { private String type; private String featureKey; private String variableKey; + private boolean globalVariable; // Dependencies private Map context; @@ -20,7 +21,8 @@ public class EvaluateOptions { private InstanceEvaluationDataProvider evaluationData; // Override options - private Map sticky; + private Map stickyFeatures; + private Map stickyVariables; private String defaultVariationValue; private Object defaultVariableValue; private boolean defaultVariableValueSet; @@ -43,11 +45,13 @@ public EvaluateOptions(String type, String featureKey, String variableKey) { public String getType() { return type; } public String getFeatureKey() { return featureKey; } public String getVariableKey() { return variableKey; } + public boolean isGlobalVariable() { return globalVariable; } public Map getContext() { return context; } DiagnosticReporter getDiagnostics() { return diagnostics; } ModulesManager getModulesManager() { return modulesManager; } InstanceEvaluationDataProvider getInstanceEvaluationDataProvider() { return evaluationData; } - public Map getSticky() { return sticky; } + public Map getStickyFeatures() { return stickyFeatures; } + public Map getStickyVariables() { return stickyVariables; } public String getDefaultVariationValue() { return defaultVariationValue; } public Object getDefaultVariableValue() { return defaultVariableValue; } public boolean hasDefaultVariableValue() { return defaultVariableValueSet; } @@ -56,11 +60,13 @@ public EvaluateOptions(String type, String featureKey, String variableKey) { public void setType(String type) { this.type = type; } public void setFeatureKey(String featureKey) { this.featureKey = featureKey; } public void setVariableKey(String variableKey) { this.variableKey = variableKey; } + public void setGlobalVariable(boolean value) { this.globalVariable = value; } public void setContext(Map context) { this.context = context; } void setDiagnostics(DiagnosticReporter diagnostics) { this.diagnostics = diagnostics; } void setModulesManager(ModulesManager modulesManager) { this.modulesManager = modulesManager; } void setInstanceEvaluationDataProvider(InstanceEvaluationDataProvider evaluationData) { this.evaluationData = evaluationData; } - public void setSticky(Map sticky) { this.sticky = sticky; } + public void setStickyFeatures(Map value) { this.stickyFeatures = value; } + public void setStickyVariables(Map value) { this.stickyVariables = value; } public void setDefaultVariationValue(String defaultVariationValue) { this.defaultVariationValue = defaultVariationValue; } public void setDefaultVariableValue(Object defaultVariableValue) { this.defaultVariableValue = defaultVariableValue; @@ -103,10 +109,9 @@ EvaluateOptions evaluationData(InstanceEvaluationDataProvider evaluationData) { return this; } - public EvaluateOptions sticky(Map sticky) { - this.sticky = sticky; - return this; - } + public EvaluateOptions stickyFeatures(Map value) { this.stickyFeatures = value; return this; } + public EvaluateOptions stickyVariables(Map value) { this.stickyVariables = value; return this; } + public EvaluateOptions globalVariable(boolean value) { this.globalVariable = value; return this; } public EvaluateOptions defaultVariationValue(String defaultVariationValue) { this.defaultVariationValue = defaultVariationValue; @@ -138,7 +143,9 @@ public EvaluateOptions copy() { copy.diagnostics = this.diagnostics; copy.modulesManager = this.modulesManager; copy.evaluationData = this.evaluationData; - copy.sticky = this.sticky; + copy.stickyFeatures = this.stickyFeatures; + copy.stickyVariables = this.stickyVariables; + copy.globalVariable = this.globalVariable; copy.defaultVariationValue = this.defaultVariationValue; copy.defaultVariableValue = this.defaultVariableValue; copy.defaultVariableValueSet = this.defaultVariableValueSet; @@ -166,7 +173,8 @@ public String toString() { ", diagnostics=" + diagnostics + ", modulesManager=" + modulesManager + ", evaluationData=" + evaluationData + - ", sticky=" + sticky + + ", stickyFeatures=" + stickyFeatures + + ", stickyVariables=" + stickyVariables + ", defaultVariationValue=" + defaultVariationValue + ", defaultVariableValue=" + defaultVariableValue + '}'; diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateSticky.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateSticky.java index 7fb5a6d..96def47 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateSticky.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/EvaluateSticky.java @@ -11,14 +11,14 @@ final class EvaluateSticky { /** * Evaluates sticky scenarios and returns the appropriate evaluation result * - * @param options The evaluation options containing type, featureKey, variableKey, sticky, and diagnostics + * @param options The evaluation options containing type, featureKey, variableKey, sticky features, and diagnostics * @return Evaluation if sticky data is found and valid, null otherwise */ public static Evaluation evaluateSticky(EvaluateOptions options) { String type = options.getType(); String featureKey = options.getFeatureKey(); String variableKey = options.getVariableKey(); - Map sticky = options.getSticky(); + Map sticky = options.getStickyFeatures(); DiagnosticReporter diagnostics = options.getDiagnostics(); if (sticky != null && sticky.containsKey(featureKey)) { diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluation.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluation.java index 8f00a61..54aad70 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluation.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Evaluation.java @@ -21,6 +21,7 @@ public class Evaluation { public static final String REASON_FEATURE_NOT_FOUND = "feature_not_found"; public static final String REASON_DISABLED = "disabled"; public static final String REASON_REQUIRED = "required"; + public static final String REASON_REQUIRED_FEATURES_UNMET = "required_features_unmet"; public static final String REASON_OUT_OF_RANGE = "out_of_range"; public static final String REASON_NO_VARIATIONS = "no_variations"; public static final String REASON_VARIATION_DISABLED = "variation_disabled"; @@ -53,6 +54,7 @@ public class Evaluation { private Integer forceIndex; private Map force; private List> required; + private List requiredFeatures; private Map sticky; // Variation fields @@ -62,8 +64,12 @@ public class Evaluation { // Variable fields private String variableKey; private Object variableValue; + private boolean variableValueSet; private VariableSchema variableSchema; + private GlobalVariable variable; private Integer variableOverrideIndex; + private String variableOverrideKey; + private List variableOverridePath; // Required feature fields private String requiredFeatureKey; @@ -92,13 +98,18 @@ public Evaluation(String type, String featureKey, String reason) { public Integer getForceIndex() { return forceIndex; } public Map getForce() { return force; } public List> getRequired() { return required; } + public List getRequiredFeatures() { return requiredFeatures; } public Map getSticky() { return sticky; } public Variation getVariation() { return variation; } public String getVariationValue() { return variationValue; } public String getVariableKey() { return variableKey; } public Object getVariableValue() { return variableValue; } + public boolean hasVariableValue() { return variableValueSet; } public VariableSchema getVariableSchema() { return variableSchema; } + public GlobalVariable getVariable() { return variable; } public Integer getVariableOverrideIndex() { return variableOverrideIndex; } + public String getVariableOverrideKey() { return variableOverrideKey; } + public List getVariableOverridePath() { return variableOverridePath; } public String getRequiredFeatureKey() { return requiredFeatureKey; } public String getRequiredVariation() { return requiredVariation; } public String getActualVariation() { return actualVariation; } @@ -116,13 +127,17 @@ public Evaluation(String type, String featureKey, String reason) { public void setForceIndex(Integer forceIndex) { this.forceIndex = forceIndex; } public void setForce(Map force) { this.force = force; } public void setRequired(List> required) { this.required = required; } + public void setRequiredFeatures(List value) { this.requiredFeatures = value; } public void setSticky(Map sticky) { this.sticky = sticky; } public void setVariation(Variation variation) { this.variation = variation; } public void setVariationValue(String variationValue) { this.variationValue = variationValue; } public void setVariableKey(String variableKey) { this.variableKey = variableKey; } - public void setVariableValue(Object variableValue) { this.variableValue = variableValue; } + public void setVariableValue(Object variableValue) { this.variableValue = variableValue; this.variableValueSet = true; } public void setVariableSchema(VariableSchema variableSchema) { this.variableSchema = variableSchema; } + public void setVariable(GlobalVariable variable) { this.variable = variable; } public void setVariableOverrideIndex(Integer variableOverrideIndex) { this.variableOverrideIndex = variableOverrideIndex; } + public void setVariableOverrideKey(String value) { this.variableOverrideKey = value; } + public void setVariableOverridePath(List value) { this.variableOverridePath = value; } public void setRequiredFeatureKey(String requiredFeatureKey) { this.requiredFeatureKey = requiredFeatureKey; } public void setRequiredVariation(String requiredVariation) { this.requiredVariation = requiredVariation; } public void setActualVariation(String actualVariation) { this.actualVariation = actualVariation; } @@ -187,6 +202,7 @@ public Evaluation required(List> required) { this.required = required; return this; } + public Evaluation requiredFeatures(List value) { this.requiredFeatures = value; return this; } public Evaluation sticky(Map sticky) { this.sticky = sticky; @@ -210,6 +226,7 @@ public Evaluation variableKey(String variableKey) { public Evaluation variableValue(Object variableValue) { this.variableValue = variableValue; + this.variableValueSet = true; return this; } @@ -217,11 +234,14 @@ public Evaluation variableSchema(VariableSchema variableSchema) { this.variableSchema = variableSchema; return this; } + public Evaluation variable(GlobalVariable value) { this.variable = value; return this; } public Evaluation variableOverrideIndex(Integer variableOverrideIndex) { this.variableOverrideIndex = variableOverrideIndex; return this; } + public Evaluation variableOverrideKey(String value) { this.variableOverrideKey = value; return this; } + public Evaluation variableOverridePath(List value) { this.variableOverridePath = value; return this; } public Evaluation requiredFeatureKey(String requiredFeatureKey) { this.requiredFeatureKey = requiredFeatureKey; @@ -253,13 +273,18 @@ public Evaluation copy() { copy.forceIndex = this.forceIndex; copy.force = this.force; copy.required = this.required; + copy.requiredFeatures = this.requiredFeatures; copy.sticky = this.sticky; copy.variation = this.variation; copy.variationValue = this.variationValue; copy.variableKey = this.variableKey; copy.variableValue = this.variableValue; + copy.variableValueSet = this.variableValueSet; copy.variableSchema = this.variableSchema; + copy.variable = this.variable; copy.variableOverrideIndex = this.variableOverrideIndex; + copy.variableOverrideKey = this.variableOverrideKey; + copy.variableOverridePath = this.variableOverridePath; copy.requiredFeatureKey = this.requiredFeatureKey; copy.requiredVariation = this.requiredVariation; copy.actualVariation = this.actualVariation; diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Events.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Events.java index 942958e..f8ab141 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Events.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Events.java @@ -6,13 +6,90 @@ import java.util.List; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; +import com.fasterxml.jackson.databind.ObjectMapper; /** * Event parameter utilities for Featurevisor SDK * Provides methods to generate event details for various SDK events */ final class Events { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static String fingerprint(Object value, String hash) { + if (hash != null) return hash; + try { return OBJECT_MAPPER.writeValueAsString(value); } + catch (Exception ignored) { return String.valueOf(value); } + } + + private static void collectSegmentKeys(Object value, Set result) { + if (value == null) return; + if (value instanceof String) { + String string = (String) value; + if ("*".equals(string)) return; + if (string.startsWith("{") || string.startsWith("[")) { + try { collectSegmentKeys(OBJECT_MAPPER.readValue(string, Object.class), result); return; } + catch (Exception ignored) { } + } + result.add(string); + } else if (value instanceof List) { + for (Object item : (List) value) collectSegmentKeys(item, result); + } else if (value instanceof Map) { + for (Object item : ((Map) value).values()) collectSegmentKeys(item, result); + } + } + + private static void collectRequiredFeatureKeys(List requirements, Set result) { + if (requirements == null) return; + for (Object item : requirements) { + if (item instanceof String) result.add((String) item); + else if (item instanceof Map) { + Object key = ((Map) item).get("feature"); + if (key == null) key = ((Map) item).get("key"); + if (key instanceof String) result.add((String) key); + } + } + } + + private static void collectOverrides(Map> groups, Set segments, Set features) { + if (groups == null) return; + for (List overrides : groups.values()) { + if (overrides == null) continue; + for (VariableOverride override : overrides) { + collectSegmentKeys(override.getSegments(), segments); + collectRequiredFeatureKeys(override.getRequiredFeatures(), features); + } + } + } + + private static List> featureDependencies(Feature feature) { + Set segments = new HashSet<>(), features = new HashSet<>(); + List required = feature.getRequiredFeatures() != null ? feature.getRequiredFeatures() : feature.getRequired(); + collectRequiredFeatureKeys(required, features); + if (feature.getTraffic() != null) for (Traffic traffic : feature.getTraffic()) { + collectSegmentKeys(traffic.getSegments(), segments); + collectOverrides(traffic.getVariableOverrides(), segments, features); + } + if (feature.getForce() != null) for (Force force : feature.getForce()) collectSegmentKeys(force.getSegments(), segments); + if (feature.getVariations() != null) for (Variation variation : feature.getVariations()) { + collectOverrides(variation.getVariableOverrides(), segments, features); + } + return Arrays.asList(segments, features); + } + + private static List> variableDependencies(GlobalVariable variable) { + Set segments = new HashSet<>(), features = new HashSet<>(); + collectRequiredFeatureKeys(variable.getRequiredFeatures(), features); + if (variable.getOverrides() != null) for (VariableOverride override : variable.getOverrides()) { + collectSegmentKeys(override.getSegments(), segments); + collectRequiredFeatureKeys(override.getRequiredFeatures(), features); + } + return Arrays.asList(segments, features); + } /** * Get parameters for sticky set event @@ -21,7 +98,7 @@ final class Events { * @param replace Whether the sticky features were replaced * @return Event details for sticky set event */ - public static FeaturevisorEventDetails getParamsForStickySetEvent( + public static FeaturevisorEventDetails getParamsForStickyFeaturesSetEvent( Map previousStickyFeatures, Map newStickyFeatures, boolean replace) { @@ -52,6 +129,17 @@ public static FeaturevisorEventDetails getParamsForStickySetEvent( return details; } + public static FeaturevisorEventDetails getParamsForStickyVariablesSetEvent( + Map previous, Map current, boolean replace) { + java.util.Set keys = new java.util.HashSet<>(); + if (previous != null) keys.addAll(previous.keySet()); + if (current != null) keys.addAll(current.keySet()); + FeaturevisorEventDetails details = new FeaturevisorEventDetails(); + details.put("variables", new ArrayList<>(keys)); + details.put("replaced", replace); + return details; + } + /** * Get parameters for datafile set event * @param previousDatafileContent Previous datafile content @@ -82,56 +170,55 @@ public static FeaturevisorEventDetails getParamsForDatafileSetEvent( newFeatureKeys = new ArrayList<>(newDatafileContent.getFeatures().keySet()); } - // Results - List removedFeatures = new ArrayList<>(); - List changedFeatures = new ArrayList<>(); - List addedFeatures = new ArrayList<>(); - - // Check against existing datafile - for (String previousFeatureKey : previousFeatureKeys) { - if (!newFeatureKeys.contains(previousFeatureKey)) { - // Feature was removed in new datafile - removedFeatures.add(previousFeatureKey); - continue; - } - - // Feature exists in both datafiles, check if it was changed - Feature previousFeature = previousDatafileContent.getFeatures().get(previousFeatureKey); - Feature newFeature = newDatafileContent.getFeatures().get(previousFeatureKey); - - String previousHash = previousFeature != null ? previousFeature.getHash() : null; - String newHash = newFeature != null ? newFeature.getHash() : null; - - if (previousHash == null ? newHash != null : !previousHash.equals(newHash)) { - // Feature was changed in new datafile - changedFeatures.add(previousFeatureKey); - } + Map previousFeatures = previousDatafileContent.getFeatures() != null ? previousDatafileContent.getFeatures() : java.util.Collections.emptyMap(); + Map newFeatures = newDatafileContent.getFeatures() != null ? newDatafileContent.getFeatures() : java.util.Collections.emptyMap(); + Set changedFeatures = new HashSet<>(); + Set allFeatureKeys = new HashSet<>(previousFeatureKeys); allFeatureKeys.addAll(newFeatureKeys); + for (String key : allFeatureKeys) { + Feature before = previousFeatures.get(key), after = newFeatures.get(key); + if (before == null || after == null || !Objects.equals(fingerprint(before, before.getHash()), fingerprint(after, after.getHash()))) changedFeatures.add(key); } - // Check against new datafile - for (String newFeatureKey : newFeatureKeys) { - if (!previousFeatureKeys.contains(newFeatureKey)) { - // Feature was added in new datafile - addedFeatures.add(newFeatureKey); + Map previousSegments = previousDatafileContent.getSegments() != null ? previousDatafileContent.getSegments() : java.util.Collections.emptyMap(); + Map newSegments = newDatafileContent.getSegments() != null ? newDatafileContent.getSegments() : java.util.Collections.emptyMap(); + Set changedSegments = new HashSet<>(); + Set allSegmentKeys = new HashSet<>(previousSegments.keySet()); allSegmentKeys.addAll(newSegments.keySet()); + for (String key : allSegmentKeys) if (!Objects.equals(fingerprint(previousSegments.get(key), null), fingerprint(newSegments.get(key), null))) changedSegments.add(key); + + Map allFeatures = new HashMap<>(previousFeatures); allFeatures.putAll(newFeatures); + boolean updated; + do { + updated = false; + for (Map.Entry entry : allFeatures.entrySet()) { + if (changedFeatures.contains(entry.getKey())) continue; + List> dependencies = featureDependencies(entry.getValue()); + if (!java.util.Collections.disjoint(dependencies.get(0), changedSegments) + || !java.util.Collections.disjoint(dependencies.get(1), changedFeatures)) { + changedFeatures.add(entry.getKey()); updated = true; + } } - } - - // Combine all affected feature keys - List allAffectedFeatures = new ArrayList<>(); - allAffectedFeatures.addAll(removedFeatures); - allAffectedFeatures.addAll(changedFeatures); - allAffectedFeatures.addAll(addedFeatures); - - // Remove duplicates - List uniqueAffectedFeatures = allAffectedFeatures.stream() - .distinct() - .collect(Collectors.toList()); + } while (updated); FeaturevisorEventDetails details = new FeaturevisorEventDetails(); details.put("revision", newRevision); details.put("previousRevision", previousRevision); details.put("revisionChanged", !(previousRevision == null ? newRevision == null : previousRevision.equals(newRevision))); - details.put("features", uniqueAffectedFeatures); + Set variableKeys = new HashSet<>(); + Map previousVariables = previousDatafileContent.getVariables() != null ? previousDatafileContent.getVariables() : java.util.Collections.emptyMap(); + Map newVariables = newDatafileContent.getVariables() != null ? newDatafileContent.getVariables() : java.util.Collections.emptyMap(); + for (String key : previousVariables.keySet()) { + if (!newVariables.containsKey(key) || !Objects.equals(fingerprint(previousVariables.get(key), previousVariables.get(key).getHash()), fingerprint(newVariables.get(key), newVariables.get(key).getHash()))) variableKeys.add(key); + } + for (String key : newVariables.keySet()) if (!previousVariables.containsKey(key)) variableKeys.add(key); + Map allVariables = new HashMap<>(previousVariables); allVariables.putAll(newVariables); + for (Map.Entry entry : allVariables.entrySet()) { + if (variableKeys.contains(entry.getKey())) continue; + List> dependencies = variableDependencies(entry.getValue()); + if (!java.util.Collections.disjoint(dependencies.get(0), changedSegments) + || !java.util.Collections.disjoint(dependencies.get(1), changedFeatures)) variableKeys.add(entry.getKey()); + } + details.put("features", new ArrayList<>(changedFeatures)); + details.put("variables", new ArrayList<>(variableKeys)); details.put("replaced", replace); return details; diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Feature.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Feature.java index 5846654..dca7da3 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Feature.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Feature.java @@ -21,6 +21,9 @@ public class Feature { @JsonSetter(nulls = Nulls.AS_EMPTY) private List required = new ArrayList<>(); // Can be String or RequiredWithVariation + @JsonProperty("requiredFeatures") + private List requiredFeatures; + @JsonProperty("variablesSchema") private Map variablesSchema; @@ -86,6 +89,9 @@ public void setRequired(List required) { this.required = (required != null) ? required : new ArrayList<>(); } + public List getRequiredFeatures() { return requiredFeatures; } + public void setRequiredFeatures(List requiredFeatures) { this.requiredFeatures = requiredFeatures; } + public Map getVariablesSchema() { return variablesSchema; } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Featurevisor.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Featurevisor.java index 2b415c1..2c745d8 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Featurevisor.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/Featurevisor.java @@ -24,7 +24,8 @@ public class Featurevisor { // from options private Map context = new HashMap<>(); private DiagnosticReporter diagnostics; - private Map sticky; + private Map stickyFeatures; + private Map stickyVariables; private FeaturevisorDiagnosticHandler onDiagnostic; private boolean closed = false; @@ -66,7 +67,8 @@ public static class FeaturevisorOptions { private String datafileString; private Map context; private FeaturevisorLogLevel logLevel; - private Map sticky; + private Map stickyFeatures; + private Map stickyVariables; private List modules; private FeaturevisorDiagnosticHandler onDiagnostic; @@ -77,7 +79,8 @@ public FeaturevisorOptions() {} public String getDatafileString() { return datafileString; } public Map getContext() { return context; } public FeaturevisorLogLevel getLogLevel() { return logLevel; } - public Map getSticky() { return sticky; } + public Map getStickyFeatures() { return stickyFeatures; } + public Map getStickyVariables() { return stickyVariables; } public List getModules() { return modules; } public FeaturevisorDiagnosticHandler getOnDiagnostic() { return onDiagnostic; } @@ -86,7 +89,8 @@ public FeaturevisorOptions() {} public void setDatafileString(String datafileString) { this.datafileString = datafileString; } public void setContext(Map context) { this.context = context; } public void setLogLevel(FeaturevisorLogLevel logLevel) { this.logLevel = logLevel; } - public void setSticky(Map sticky) { this.sticky = sticky; } + public void setStickyFeatures(Map value) { this.stickyFeatures = value; } + public void setStickyVariables(Map value) { this.stickyVariables = value; } public void setModules(List modules) { this.modules = modules; } public void setOnDiagnostic(FeaturevisorDiagnosticHandler onDiagnostic) { this.onDiagnostic = onDiagnostic; } @@ -111,10 +115,8 @@ public FeaturevisorOptions logLevel(FeaturevisorLogLevel logLevel) { return this; } - public FeaturevisorOptions sticky(Map sticky) { - this.sticky = sticky; - return this; - } + public FeaturevisorOptions stickyFeatures(Map value) { this.stickyFeatures = value; return this; } + public FeaturevisorOptions stickyVariables(Map value) { this.stickyVariables = value; return this; } public FeaturevisorOptions modules(List modules) { this.modules = modules; @@ -145,7 +147,8 @@ private static class ModuleDiagnosticSubscription { * Options for overriding evaluation behavior */ public static class OverrideOptions { - private Map sticky; + private Map stickyFeatures; + private Map stickyVariables; private String defaultVariationValue; private Object defaultVariableValue; private boolean defaultVariableValueSet; @@ -153,13 +156,15 @@ public static class OverrideOptions { public OverrideOptions() {} // Getters - Map getInternalSticky() { return sticky; } + Map getInternalStickyFeatures() { return stickyFeatures; } + Map getInternalStickyVariables() { return stickyVariables; } public String getDefaultVariationValue() { return defaultVariationValue; } public Object getDefaultVariableValue() { return defaultVariableValue; } public boolean hasDefaultVariableValue() { return defaultVariableValueSet; } // Setters - void setInternalSticky(Map sticky) { this.sticky = sticky; } + void setInternalStickyFeatures(Map value) { this.stickyFeatures = value; } + void setInternalStickyVariables(Map value) { this.stickyVariables = value; } public void setDefaultVariationValue(String defaultVariationValue) { this.defaultVariationValue = defaultVariationValue; } public void setDefaultVariableValue(Object defaultVariableValue) { this.defaultVariableValue = defaultVariableValue; @@ -181,14 +186,15 @@ public OverrideOptions defaultVariableValue(Object defaultVariableValue) { /** Options used only when spawning a child instance. */ public static class SpawnOptions { - private Map sticky; + private Map stickyFeatures; + private Map stickyVariables; - public Map getSticky() { return sticky; } - public void setSticky(Map sticky) { this.sticky = sticky; } - public SpawnOptions sticky(Map sticky) { - this.sticky = sticky; - return this; - } + public Map getStickyFeatures() { return stickyFeatures; } + public Map getStickyVariables() { return stickyVariables; } + public void setStickyFeatures(Map value) { this.stickyFeatures = value; } + public void setStickyVariables(Map value) { this.stickyVariables = value; } + public SpawnOptions stickyFeatures(Map value) { this.stickyFeatures = value; return this; } + public SpawnOptions stickyVariables(Map value) { this.stickyVariables = value; return this; } } /** @@ -231,7 +237,8 @@ private Featurevisor(FeaturevisorOptions options) { })); this.emitter = new Emitter(); - this.sticky = options.getSticky(); + this.stickyFeatures = options.getStickyFeatures(); + this.stickyVariables = options.getStickyVariables(); this.onDiagnostic = options.getOnDiagnostic(); // datafile @@ -483,37 +490,51 @@ private DatafileContent mergeDatafiles(DatafileContent previous, DatafileContent } merged.setFeatures(features); + Map variables = new HashMap<>(); + if (previous.getVariables() != null) { variables.putAll(previous.getVariables()); } + if (incoming.getVariables() != null) { variables.putAll(incoming.getVariables()); } + merged.setVariables(variables); + return merged; } /** * Set sticky features */ - public void setSticky(Map sticky) { - setSticky(sticky, false); + public void setStickyFeatures(Map sticky) { + setStickyFeatures(sticky, false); } - public void setSticky(Map sticky, boolean replace) { - Map previousStickyFeatures = this.sticky != null ? - new HashMap<>(this.sticky) : new HashMap<>(); + public void setStickyFeatures(Map sticky, boolean replace) { + Map previousStickyFeatures = this.stickyFeatures != null ? new HashMap<>(this.stickyFeatures) : new HashMap<>(); if (replace) { - this.sticky = new HashMap<>(sticky); + this.stickyFeatures = new HashMap<>(sticky); } else { - this.sticky = new HashMap<>(this.sticky != null ? this.sticky : new HashMap<>()); - this.sticky.putAll(sticky); + this.stickyFeatures = new HashMap<>(this.stickyFeatures != null ? this.stickyFeatures : new HashMap<>()); + this.stickyFeatures.putAll(sticky); } - FeaturevisorEventDetails params = Events.getParamsForStickySetEvent( - previousStickyFeatures, this.sticky, replace); + FeaturevisorEventDetails params = Events.getParamsForStickyFeaturesSetEvent( + previousStickyFeatures, this.stickyFeatures, replace); reportDiagnostic(new FeaturevisorDiagnostic() .level(FeaturevisorLogLevel.INFO) - .code("sticky_set") + .code("sticky_features_set") .message("Sticky features set") .details(params), null); - this.emitter.trigger(FeaturevisorEventName.STICKY_SET, params); + this.emitter.trigger(FeaturevisorEventName.STICKY_FEATURES_SET, params); + } + + public void setStickyVariables(Map sticky, boolean replace) { + Map previous = this.stickyVariables != null ? new HashMap<>(this.stickyVariables) : new HashMap<>(); + this.stickyVariables = replace ? new HashMap<>(sticky) : new HashMap<>(previous); + if (!replace) { this.stickyVariables.putAll(sticky); } + FeaturevisorEventDetails params = Events.getParamsForStickyVariablesSetEvent(previous, this.stickyVariables, replace); + reportDiagnostic(new FeaturevisorDiagnostic().level(FeaturevisorLogLevel.INFO).code("sticky_variables_set").message("Sticky variables set").details(params), null); + this.emitter.trigger(FeaturevisorEventName.STICKY_VARIABLES_SET, params); } + public void setStickyVariables(Map sticky) { setStickyVariables(sticky, false); } /** * Get revision @@ -537,6 +558,7 @@ public List getFeatureKeys() { public List getVariableKeys(String featureKey) { return this.evaluationData.getVariableKeys(featureKey); } + public List getVariableKeys() { return this.evaluationData.getGlobalVariableKeys(); } public boolean hasVariations(String featureKey) { return this.evaluationData.hasVariations(featureKey); @@ -628,7 +650,7 @@ public ChildInstance spawn(Map context, SpawnOptions options) { options = new SpawnOptions(); } - return new ChildInstance(this, getContext(context), options.getSticky()); + return new ChildInstance(this, getContext(context), options.getStickyFeatures(), options.getStickyVariables()); } public ChildInstance spawn(Map context) { @@ -650,14 +672,16 @@ private EvaluateOptions getEvaluationDependencies(Map context, O options = new OverrideOptions(); } - Map mergedSticky = options.getInternalSticky() != null ? options.getInternalSticky() : this.sticky; + Map mergedStickyFeatures = options.getInternalStickyFeatures() != null ? options.getInternalStickyFeatures() : this.stickyFeatures; + Map mergedStickyVariables = options.getInternalStickyVariables() != null ? options.getInternalStickyVariables() : this.stickyVariables; return new EvaluateOptions() .context(getContext(context)) .diagnostics(this.diagnostics) .modulesManager(this.modulesManager) .evaluationData(this.evaluationData) - .sticky(mergedSticky) + .stickyFeatures(mergedStickyFeatures) + .stickyVariables(mergedStickyVariables) .defaultVariationValue(options.getDefaultVariationValue()) .defaultVariableValue(options.getDefaultVariableValue(), options.hasDefaultVariableValue()); } @@ -762,6 +786,39 @@ public Evaluation evaluateVariable(String featureKey, String variableKey) { return evaluateVariable(featureKey, variableKey, null, null); } + public Evaluation evaluateVariable(String variableKey, Map context, OverrideOptions options) { + return Evaluate.evaluateWithModules(getEvaluationDependencies(context, options) + .type(Evaluation.TYPE_VARIABLE).featureKey(null).variableKey(variableKey).globalVariable(true)); + } + public Evaluation evaluateVariable(String variableKey, Map context) { return evaluateVariable(variableKey, context, null); } + public Evaluation evaluateVariable(String variableKey) { return evaluateVariable(variableKey, (Map) null, null); } + + public Object getVariable(String variableKey, Map context, OverrideOptions options) { + Evaluation evaluation = evaluateVariable(variableKey, context, options); + Object value = evaluation.getVariableValue(); + if (value == null && options != null && options.hasDefaultVariableValue()) value = options.getDefaultVariableValue(); + if (value instanceof String && evaluation.getVariable() != null && VariableType.JSON == evaluation.getVariable().getType()) { + try { return OBJECT_MAPPER.readValue((String) value, Object.class); } catch (Exception ignored) { return null; } + } + return value; + } + public Object getVariable(String variableKey, Map context) { return getVariable(variableKey, context, null); } + public Object getVariable(String variableKey) { return getVariable(variableKey, (Map) null, null); } + public Boolean getVariableBoolean(String key, Map context, OverrideOptions options) { return Helpers.getValueByType(getVariable(key, context, options), "boolean"); } + public Boolean getVariableBoolean(String key) { return getVariableBoolean(key, (Map) null, null); } + public String getVariableString(String key, Map context, OverrideOptions options) { return Helpers.getValueByType(getVariable(key, context, options), "string"); } + public String getVariableString(String key) { return getVariableString(key, (Map) null, null); } + public Integer getVariableInteger(String key, Map context, OverrideOptions options) { return (Integer) Helpers.getValueByType(getVariable(key, context, options), "integer"); } + public Integer getVariableInteger(String key) { return getVariableInteger(key, (Map) null, null); } + public Double getVariableDouble(String key, Map context, OverrideOptions options) { return (Double) Helpers.getValueByType(getVariable(key, context, options), "double"); } + public Double getVariableDouble(String key) { return getVariableDouble(key, (Map) null, null); } + @SuppressWarnings("unchecked") public List getVariableArray(String key, Map context, OverrideOptions options) { return Helpers.getValueByType(getVariable(key, context, options), "array"); } + public List getVariableArray(String key) { return getVariableArray(key, (Map) null, (OverrideOptions) null); } + @SuppressWarnings("unchecked") public T getVariableObject(String key, Map context, OverrideOptions options) { return Helpers.getValueByType(getVariable(key, context, options), "object"); } + public T getVariableObject(String key) { return getVariableObject(key, (Map) null, (OverrideOptions) null); } + @SuppressWarnings("unchecked") public T getVariableJSON(String key, Map context, OverrideOptions options) { return Helpers.getValueByType(getVariable(key, context, options), "json"); } + public T getVariableJSON(String key) { return getVariableJSON(key, (Map) null, (OverrideOptions) null); } + public Object getVariable(String featureKey, String variableKey, Map context, OverrideOptions options) { try { Evaluation evaluation = evaluateVariable(featureKey, variableKey, context, options); @@ -1051,7 +1108,7 @@ public T getVariableObject(String featureKey, String variableKey, TypeRefere /** * Get all evaluations */ - public EvaluatedFeatures getAllEvaluations(Map context, List featureKeys, OverrideOptions options) { + public EvaluatedFeatures getFeatureEvaluations(Map context, List featureKeys, OverrideOptions options) { if (context == null) { context = new HashMap<>(); } @@ -1077,7 +1134,8 @@ public EvaluatedFeatures getAllEvaluations(Map context, List context, List context, List featureKeys) { - return getAllEvaluations(context, featureKeys, null); + public EvaluatedFeatures getFeatureEvaluations(Map context, List featureKeys) { + return getFeatureEvaluations(context, featureKeys, null); + } + + public EvaluatedFeatures getFeatureEvaluations(Map context) { + return getFeatureEvaluations(context, null, null); } - public EvaluatedFeatures getAllEvaluations(Map context) { - return getAllEvaluations(context, null, null); + public EvaluatedFeatures getFeatureEvaluations() { + return getFeatureEvaluations(null, null, null); } - public EvaluatedFeatures getAllEvaluations() { - return getAllEvaluations(null, null, null); + public Map getVariableEvaluations(Map context, List variableKeys, OverrideOptions options) { + Map result = new HashMap<>(); + List keys = variableKeys == null || variableKeys.isEmpty() ? evaluationData.getGlobalVariableKeys() : variableKeys; + for (String key : keys) result.put(key, getVariable(key, context, options)); + return result; } + public Map getVariableEvaluations() { return getVariableEvaluations(null, null, null); } } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeaturevisorEventName.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeaturevisorEventName.java index 867f1e8..b6db140 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeaturevisorEventName.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeaturevisorEventName.java @@ -3,7 +3,8 @@ public enum FeaturevisorEventName { DATAFILE_SET("datafile_set"), CONTEXT_SET("context_set"), - STICKY_SET("sticky_set"), + STICKY_FEATURES_SET("sticky_features_set"), + STICKY_VARIABLES_SET("sticky_variables_set"), ERROR("error"); private final String value; diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeaturevisorModule.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeaturevisorModule.java index 04bb175..1a8e34a 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeaturevisorModule.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/FeaturevisorModule.java @@ -10,9 +10,11 @@ public class FeaturevisorModule { private String name; private Consumer setup; private Function before; + private Function beforeEvaluation; private ConfigureBucketKey bucketKey; private ConfigureBucketValue bucketValue; private BiFunction after; + private BiFunction afterEvaluation; private Runnable close; public FeaturevisorModule(String name) { @@ -23,17 +25,21 @@ public FeaturevisorModule(String name) { public String getName() { return name; } public Consumer getSetup() { return setup; } public Function getBefore() { return before; } + public Function getBeforeEvaluation() { return beforeEvaluation; } public ConfigureBucketKey getBucketKey() { return bucketKey; } public ConfigureBucketValue getBucketValue() { return bucketValue; } public BiFunction getAfter() { return after; } + public BiFunction getAfterEvaluation() { return afterEvaluation; } public Runnable getClose() { return close; } public void setName(String name) { this.name = name; } public void setSetup(Consumer setup) { this.setup = setup; } public void setBefore(Function before) { this.before = before; } + public void setBeforeEvaluation(Function value) { this.beforeEvaluation = value; } public void setBucketKey(ConfigureBucketKey bucketKey) { this.bucketKey = bucketKey; } public void setBucketValue(ConfigureBucketValue bucketValue) { this.bucketValue = bucketValue; } public void setAfter(BiFunction after) { this.after = after; } + public void setAfterEvaluation(BiFunction value) { this.afterEvaluation = value; } public void setClose(Runnable close) { this.close = close; } public FeaturevisorModule setup(Consumer setup) { @@ -45,6 +51,7 @@ public FeaturevisorModule before(Function befo this.before = before; return this; } + public FeaturevisorModule beforeEvaluation(Function value) { this.beforeEvaluation = value; return this; } public FeaturevisorModule bucketKey(ConfigureBucketKey bucketKey) { this.bucketKey = bucketKey; @@ -60,6 +67,7 @@ public FeaturevisorModule after(BiFunction value) { this.afterEvaluation = value; return this; } public FeaturevisorModule close(Runnable close) { this.close = close; diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/GlobalVariable.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/GlobalVariable.java new file mode 100644 index 0000000..b2f0e60 --- /dev/null +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/GlobalVariable.java @@ -0,0 +1,37 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import java.util.List; + +public class GlobalVariable { + @JsonProperty("hash") private String hash; + @JsonProperty("type") private VariableType type; + private Object defaultValue; + private Object disabledValue; + private boolean defaultValueSet; + private boolean disabledValueSet; + @JsonProperty("useDefaultWhenDisabled") private Boolean useDefaultWhenDisabled; + @JsonProperty("requiredFeatures") private List requiredFeatures; + @JsonProperty("overrides") private List overrides; + + public String getHash() { return hash; } + public void setHash(String hash) { this.hash = hash; } + public VariableType getType() { return type; } + public void setType(VariableType type) { this.type = type; } + public Object getDefaultValue() { return defaultValue; } + @JsonSetter(value = "defaultValue", nulls = Nulls.SET) + public void setDefaultValue(Object defaultValue) { this.defaultValue = defaultValue; this.defaultValueSet = true; } + public boolean hasDefaultValue() { return defaultValueSet; } + public Object getDisabledValue() { return disabledValue; } + @JsonSetter(value = "disabledValue", nulls = Nulls.SET) + public void setDisabledValue(Object disabledValue) { this.disabledValue = disabledValue; this.disabledValueSet = true; } + public boolean hasDisabledValue() { return disabledValueSet; } + public Boolean getUseDefaultWhenDisabled() { return useDefaultWhenDisabled; } + public void setUseDefaultWhenDisabled(Boolean value) { this.useDefaultWhenDisabled = value; } + public List getRequiredFeatures() { return requiredFeatures; } + public void setRequiredFeatures(List value) { this.requiredFeatures = value; } + public List getOverrides() { return overrides; } + public void setOverrides(List overrides) { this.overrides = overrides; } +} diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/InstanceEvaluationDataProvider.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/InstanceEvaluationDataProvider.java index 717b172..c349973 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/InstanceEvaluationDataProvider.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/InstanceEvaluationDataProvider.java @@ -73,6 +73,7 @@ public ForceResult(Force force, Integer forceIndex) { private String featurevisorVersion; private Map segments; private Map features; + private Map variables; private DiagnosticReporter diagnostics; // Cache for regex patterns to avoid creating new objects for the same regex @@ -87,12 +88,14 @@ public InstanceEvaluationDataProvider(InstanceEvaluationDataProviderOptions opti this.featurevisorVersion = datafile.getFeaturevisorVersion(); this.segments = datafile.getSegments(); this.features = datafile.getFeatures(); + this.variables = datafile.getVariables(); if (this.segments == null) { this.segments = new HashMap<>(); } if (this.features == null) { this.features = new HashMap<>(); } + if (this.variables == null) { this.variables = new HashMap<>(); } this.regexCache = new ConcurrentHashMap<>(); } @@ -111,6 +114,7 @@ public DatafileContent getDatafile() { datafile.setFeaturevisorVersion(this.featurevisorVersion); datafile.setSegments(this.segments); datafile.setFeatures(this.features); + datafile.setVariables(this.variables); return datafile; } @@ -134,6 +138,9 @@ public Feature getFeature(String featureKey) { return features.get(featureKey); } + public List getGlobalVariableKeys() { return new ArrayList<>(variables.keySet()); } + public GlobalVariable getGlobalVariable(String key) { return variables.get(key); } + public List getVariableKeys(String featureKey) { Feature feature = getFeature(featureKey); diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ModulesManager.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ModulesManager.java index 0a460a0..4475e4f 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ModulesManager.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/ModulesManager.java @@ -124,17 +124,27 @@ public List getAll() { public EvaluateOptions executeBeforeModules(EvaluateOptions options) { EvaluateOptions currentOptions = options; for (FeaturevisorModule module : modules) { - if (module.getBefore() != null) { + if (!currentOptions.isGlobalVariable() && module.getBefore() != null) { currentOptions = module.getBefore().apply(currentOptions); } } + for (FeaturevisorModule module : modules) { + if (module.getBeforeEvaluation() != null) { + currentOptions = module.getBeforeEvaluation().apply(currentOptions); + } + } return currentOptions; } public Evaluation executeAfterModules(Evaluation evaluation, EvaluateOptions options) { Evaluation currentEvaluation = evaluation; for (FeaturevisorModule module : modules) { - if (module.getAfter() != null) { + if (module.getAfterEvaluation() != null) { + currentEvaluation = module.getAfterEvaluation().apply(currentEvaluation, options); + } + } + for (FeaturevisorModule module : modules) { + if (!options.isGlobalVariable() && module.getAfter() != null) { currentEvaluation = module.getAfter().apply(currentEvaluation, options); } } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableOverride.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableOverride.java index 12847b2..f35c4f0 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableOverride.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableOverride.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; public class VariableOverride { + @JsonProperty("key") private String key; + @JsonProperty("keyPath") private java.util.List keyPath; @JsonProperty("value") private Object value; @@ -11,6 +13,7 @@ public class VariableOverride { @JsonProperty("segments") private Object segments; // Can be GroupSegment, List + @JsonProperty("requiredFeatures") private java.util.List requiredFeatures; // Constructors public VariableOverride() {} @@ -43,4 +46,10 @@ public Object getSegments() { public void setSegments(Object segments) { this.segments = segments; } + public String getKey() { return key; } + public void setKey(String key) { this.key = key; } + public java.util.List getKeyPath() { return keyPath; } + public void setKeyPath(java.util.List keyPath) { this.keyPath = keyPath; } + public java.util.List getRequiredFeatures() { return requiredFeatures; } + public void setRequiredFeatures(java.util.List value) { this.requiredFeatures = value; } } diff --git a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableSchema.java b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableSchema.java index 28ecd6a..8f24aec 100644 --- a/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableSchema.java +++ b/featurevisor-sdk/src/main/java/com/featurevisor/sdk/VariableSchema.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; import java.util.List; import java.util.Map; @@ -16,8 +18,8 @@ public class VariableSchema { @JsonProperty("type") private VariableType type; - @JsonProperty("defaultValue") private Object defaultValue; + private boolean defaultValueSet; @JsonProperty("description") private String description; @@ -25,8 +27,8 @@ public class VariableSchema { @JsonProperty("useDefaultWhenDisabled") private Boolean useDefaultWhenDisabled; - @JsonProperty("disabledValue") private Object disabledValue; + private boolean disabledValueSet; @JsonProperty("schema") private String schema; @@ -82,6 +84,7 @@ public VariableSchema() {} public VariableSchema(VariableType type, Object defaultValue) { this.type = type; this.defaultValue = defaultValue; + this.defaultValueSet = true; } // Getters and Setters @@ -113,10 +116,14 @@ public Object getDefaultValue() { return defaultValue; } + @JsonSetter(value = "defaultValue", nulls = Nulls.SET) public void setDefaultValue(Object defaultValue) { this.defaultValue = defaultValue; + this.defaultValueSet = true; } + public boolean hasDefaultValue() { return defaultValueSet; } + public String getDescription() { return description; } @@ -137,10 +144,14 @@ public Object getDisabledValue() { return disabledValue; } + @JsonSetter(value = "disabledValue", nulls = Nulls.SET) public void setDisabledValue(Object disabledValue) { this.disabledValue = disabledValue; + this.disabledValueSet = true; } + public boolean hasDisabledValue() { return disabledValueSet; } + public String getSchema() { return schema; } diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ChildTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ChildTest.java index b620f9b..d6b4f52 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ChildTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ChildTest.java @@ -324,12 +324,12 @@ public void testCreateChildInstance() { assertFalse(childInstance.isEnabled("newFeature")); Map stickyFeature = new HashMap<>(); stickyFeature.put("enabled", true); - childInstance.setSticky(Map.of("newFeature", stickyFeature), false); + childInstance.setStickyFeatures(Map.of("newFeature", stickyFeature), false); assertTrue(childInstance.isEnabled("newFeature")); assertEquals("sticky", childInstance.evaluateFlag("newFeature").getReason()); - // Test getAllEvaluations - com.featurevisor.sdk.EvaluatedFeatures allEvaluations = childInstance.getAllEvaluations(); + // Test getFeatureEvaluations + com.featurevisor.sdk.EvaluatedFeatures allEvaluations = childInstance.getFeatureEvaluations(); assertNotNull(allEvaluations.getValue()); assertTrue(allEvaluations.getValue().containsKey("test")); assertTrue(allEvaluations.getValue().containsKey("anotherTest")); diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EmitterTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EmitterTest.java index 83d0553..5b3694f 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EmitterTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EmitterTest.java @@ -33,7 +33,7 @@ public void testAddListenerForEvent() { // Verify other events don't have listeners assertFalse(emitter.getListeners().containsKey(FeaturevisorEventName.CONTEXT_SET)); - assertFalse(emitter.getListeners().containsKey(FeaturevisorEventName.STICKY_SET)); + assertFalse(emitter.getListeners().containsKey(FeaturevisorEventName.STICKY_FEATURES_SET)); // Verify there's exactly one listener assertEquals(1, emitter.getListeners().get(FeaturevisorEventName.DATAFILE_SET).size()); @@ -50,7 +50,7 @@ public void testAddListenerForEvent() { // Trigger an unsubscribed event FeaturevisorEventDetails details2 = new FeaturevisorEventDetails(); details2.put("key", "value2"); - emitter.trigger(FeaturevisorEventName.STICKY_SET, details2); + emitter.trigger(FeaturevisorEventName.STICKY_FEATURES_SET, details2); // Verify the callback was not called for the unsubscribed event assertEquals(1, handledDetails.size()); @@ -110,14 +110,14 @@ public void testTriggerUsesListenerSnapshot() { List calls = new ArrayList<>(); final FeaturevisorUnsubscribe[] unsubscribeSecond = new FeaturevisorUnsubscribe[1]; - emitter.on(FeaturevisorEventName.STICKY_SET, details -> { + emitter.on(FeaturevisorEventName.STICKY_FEATURES_SET, details -> { calls.add("first"); unsubscribeSecond[0].unsubscribe(); }); - unsubscribeSecond[0] = emitter.on(FeaturevisorEventName.STICKY_SET, details -> calls.add("second")); + unsubscribeSecond[0] = emitter.on(FeaturevisorEventName.STICKY_FEATURES_SET, details -> calls.add("second")); - emitter.trigger(FeaturevisorEventName.STICKY_SET); - emitter.trigger(FeaturevisorEventName.STICKY_SET); + emitter.trigger(FeaturevisorEventName.STICKY_FEATURES_SET); + emitter.trigger(FeaturevisorEventName.STICKY_FEATURES_SET); assertEquals(List.of("first", "second", "first"), calls); } @@ -125,10 +125,10 @@ public void testTriggerUsesListenerSnapshot() { @Test public void testTriggerWithoutDetails() { // Add a listener - emitter.on(FeaturevisorEventName.STICKY_SET, this::handleDetails); + emitter.on(FeaturevisorEventName.STICKY_FEATURES_SET, this::handleDetails); // Trigger without details - emitter.trigger(FeaturevisorEventName.STICKY_SET); + emitter.trigger(FeaturevisorEventName.STICKY_FEATURES_SET); // Verify the callback was called with empty details assertEquals(1, handledDetails.size()); @@ -173,12 +173,12 @@ public void testEventNameEnum() { // Test enum values assertEquals("datafile_set", FeaturevisorEventName.DATAFILE_SET.getValue()); assertEquals("context_set", FeaturevisorEventName.CONTEXT_SET.getValue()); - assertEquals("sticky_set", FeaturevisorEventName.STICKY_SET.getValue()); + assertEquals("sticky_features_set", FeaturevisorEventName.STICKY_FEATURES_SET.getValue()); // Test fromString method assertEquals(FeaturevisorEventName.DATAFILE_SET, FeaturevisorEventName.fromString("datafile_set")); assertEquals(FeaturevisorEventName.CONTEXT_SET, FeaturevisorEventName.fromString("context_set")); - assertEquals(FeaturevisorEventName.STICKY_SET, FeaturevisorEventName.fromString("sticky_set")); + assertEquals(FeaturevisorEventName.STICKY_FEATURES_SET, FeaturevisorEventName.fromString("sticky_features_set")); // Test invalid event name assertThrows(IllegalArgumentException.class, () -> { diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluateDisabledTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluateDisabledTest.java index 4425bfe..2282e78 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluateDisabledTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluateDisabledTest.java @@ -197,8 +197,6 @@ public void testEvaluateDisabledWithVariableTypeAndUseDefaultWhenDisabled() { variableSchema.setType(VariableType.STRING); variableSchema.setDefaultValue("default-value"); variableSchema.setUseDefaultWhenDisabled(true); - variableSchema.setDisabledValue(null); // Explicitly set to null to test useDefaultWhenDisabled - Map variablesSchema = new HashMap<>(); variablesSchema.put("test-variable", variableSchema); testFeature.setVariablesSchema(variablesSchema); diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluateStickyTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluateStickyTest.java index 3875164..f68363f 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluateStickyTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EvaluateStickyTest.java @@ -41,7 +41,7 @@ public void testEvaluateStickyWithFeatureNotFound() { EvaluateOptions options = new EvaluateOptions() .type(Evaluation.TYPE_FLAG) .featureKey("test-feature") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -59,7 +59,7 @@ public void testEvaluateStickyWithFlagType() { EvaluateOptions options = new EvaluateOptions() .type(Evaluation.TYPE_FLAG) .featureKey("test-feature") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -82,7 +82,7 @@ public void testEvaluateStickyWithFlagTypeDisabled() { EvaluateOptions options = new EvaluateOptions() .type(Evaluation.TYPE_FLAG) .featureKey("test-feature") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -105,7 +105,7 @@ public void testEvaluateStickyWithFlagTypeNoEnabled() { EvaluateOptions options = new EvaluateOptions() .type(Evaluation.TYPE_FLAG) .featureKey("test-feature") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -123,7 +123,7 @@ public void testEvaluateStickyWithVariationType() { EvaluateOptions options = new EvaluateOptions() .type(Evaluation.TYPE_VARIATION) .featureKey("test-feature") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -145,7 +145,7 @@ public void testEvaluateStickyWithVariationTypeNull() { EvaluateOptions options = new EvaluateOptions() .type(Evaluation.TYPE_VARIATION) .featureKey("test-feature") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -163,7 +163,7 @@ public void testEvaluateStickyWithVariationTypeNoVariation() { EvaluateOptions options = new EvaluateOptions() .type(Evaluation.TYPE_VARIATION) .featureKey("test-feature") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -185,7 +185,7 @@ public void testEvaluateStickyWithVariableType() { .type(Evaluation.TYPE_VARIABLE) .featureKey("test-feature") .variableKey("test-variable") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -212,7 +212,7 @@ public void testEvaluateStickyWithVariableTypeNullValue() { .type(Evaluation.TYPE_VARIABLE) .featureKey("test-feature") .variableKey("test-variable") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -233,7 +233,7 @@ public void testEvaluateStickyWithVariableTypeNoVariableKey() { EvaluateOptions options = new EvaluateOptions() .type(Evaluation.TYPE_VARIABLE) .featureKey("test-feature") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -252,7 +252,7 @@ public void testEvaluateStickyWithVariableTypeNoVariables() { .type(Evaluation.TYPE_VARIABLE) .featureKey("test-feature") .variableKey("test-variable") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -274,7 +274,7 @@ public void testEvaluateStickyWithVariableTypeNoVariableFound() { .type(Evaluation.TYPE_VARIABLE) .featureKey("test-feature") .variableKey("test-variable") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -290,7 +290,7 @@ public void testEvaluateStickyWithNonMapStickyData() { EvaluateOptions options = new EvaluateOptions() .type(Evaluation.TYPE_FLAG) .featureKey("test-feature") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -309,7 +309,7 @@ public void testEvaluateStickyWithNonMapVariables() { .type(Evaluation.TYPE_VARIABLE) .featureKey("test-feature") .variableKey("test-variable") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -334,7 +334,7 @@ public void testEvaluateStickyWithComplexVariableValue() { .type(Evaluation.TYPE_VARIABLE) .featureKey("test-feature") .variableKey("test-variable") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); @@ -358,7 +358,7 @@ public void testEvaluateStickyWithNumericVariationValue() { EvaluateOptions options = new EvaluateOptions() .type(Evaluation.TYPE_VARIATION) .featureKey("test-feature") - .sticky(sticky) + .stickyFeatures(sticky) .diagnostics(diagnostics); Evaluation result = EvaluateSticky.evaluateSticky(options); diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EventsTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EventsTest.java index 6806562..63174b1 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EventsTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/EventsTest.java @@ -13,14 +13,14 @@ public class EventsTest { @Test - public void testGetParamsForStickySetEventEmptyToNew() { + public void testGetParamsForStickyFeaturesSetEventEmptyToNew() { Map previousStickyFeatures = new HashMap<>(); Map newStickyFeatures = new HashMap<>(); newStickyFeatures.put("feature2", Map.of("enabled", true)); newStickyFeatures.put("feature3", Map.of("enabled", true)); boolean replace = true; - FeaturevisorEventDetails result = Events.getParamsForStickySetEvent( + FeaturevisorEventDetails result = Events.getParamsForStickyFeaturesSetEvent( previousStickyFeatures, newStickyFeatures, replace); @SuppressWarnings("unchecked") @@ -34,7 +34,7 @@ public void testGetParamsForStickySetEventEmptyToNew() { } @Test - public void testGetParamsForStickySetEventAddChangeRemove() { + public void testGetParamsForStickyFeaturesSetEventAddChangeRemove() { Map previousStickyFeatures = new HashMap<>(); previousStickyFeatures.put("feature1", Map.of("enabled", true)); previousStickyFeatures.put("feature2", Map.of("enabled", true)); @@ -45,7 +45,7 @@ public void testGetParamsForStickySetEventAddChangeRemove() { boolean replace = true; - FeaturevisorEventDetails result = Events.getParamsForStickySetEvent( + FeaturevisorEventDetails result = Events.getParamsForStickyFeaturesSetEvent( previousStickyFeatures, newStickyFeatures, replace); @SuppressWarnings("unchecked") @@ -60,9 +60,9 @@ public void testGetParamsForStickySetEventAddChangeRemove() { } @Test - public void testGetParamsForStickySetEventWithNullInputs() { + public void testGetParamsForStickyFeaturesSetEventWithNullInputs() { // Test with null inputs - FeaturevisorEventDetails result = Events.getParamsForStickySetEvent(null, null, false); + FeaturevisorEventDetails result = Events.getParamsForStickyFeaturesSetEvent(null, null, false); @SuppressWarnings("unchecked") List features = (List) result.get("features"); diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/FeaturevisorTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/FeaturevisorTest.java index 9269f23..4d29e41 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/FeaturevisorTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/FeaturevisorTest.java @@ -62,11 +62,11 @@ public void testLifecycleMutationsReportDiagnostics() { datafile.setSegments(new HashMap<>()); datafile.setFeatures(new HashMap<>()); sdk.setDatafile(datafile); - sdk.setSticky(Map.of("test", Map.of("enabled", true)), false); + sdk.setStickyFeatures(Map.of("test", Map.of("enabled", true)), false); sdk.setContext(Map.of("country", "nl"), false); assertTrue(diagnostics.stream().anyMatch(diagnostic -> "datafile_set".equals(diagnostic.getCode()))); - assertTrue(diagnostics.stream().anyMatch(diagnostic -> "sticky_set".equals(diagnostic.getCode()))); + assertTrue(diagnostics.stream().anyMatch(diagnostic -> "sticky_features_set".equals(diagnostic.getCode()))); assertTrue(diagnostics.stream().anyMatch(diagnostic -> "context_set".equals(diagnostic.getCode()))); } @@ -231,7 +231,7 @@ public void testCreateInstanceWithStickyFeatures() { sticky.put("test", testSticky); Featurevisor sdk = Featurevisor.createFeaturevisor( - new Featurevisor.FeaturevisorOptions().sticky(sticky) + new Featurevisor.FeaturevisorOptions().stickyFeatures(sticky) ); assertNotNull(sdk); @@ -810,7 +810,7 @@ public void testInitializeWithStickyFeatures() throws InterruptedException { sticky.put("test", testSticky); - Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().sticky(sticky)); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().stickyFeatures(sticky)); // initially control Map context = Map.of( @@ -828,7 +828,7 @@ public void testInitializeWithStickyFeatures() throws InterruptedException { assertEquals("control", sdk.getVariation("test", context)); // unsetting sticky features will make it treatment - sdk.setSticky(new HashMap<>(), true); + sdk.setStickyFeatures(new HashMap<>(), true); assertEquals("treatment", sdk.getVariation("test", context)); } @@ -1932,8 +1932,8 @@ public void testGetVariableComprehensive() { "userId", "123" ); - // Test getAllEvaluations - com.featurevisor.sdk.EvaluatedFeatures evaluatedFeatures = sdk.getAllEvaluations(context); + // Test getFeatureEvaluations + com.featurevisor.sdk.EvaluatedFeatures evaluatedFeatures = sdk.getFeatureEvaluations(context); assertNotNull(evaluatedFeatures); assertNotNull(evaluatedFeatures.getValue()); assertTrue(evaluatedFeatures.getValue().containsKey("test")); diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/GlobalVariablesConformanceTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/GlobalVariablesConformanceTest.java new file mode 100644 index 0000000..ccff656 --- /dev/null +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/GlobalVariablesConformanceTest.java @@ -0,0 +1,208 @@ +package com.featurevisor.sdk; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.InputStream; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.ArrayList; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class GlobalVariablesConformanceTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + private JsonNode fixture() throws Exception { + try (InputStream stream = getClass().getResourceAsStream("/conformance/sdk-v3.json")) { + assertNotNull(stream); + return objectMapper.readTree(stream); + } + } + + private Map map(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) return Collections.emptyMap(); + return objectMapper.convertValue(node, new TypeReference>() {}); + } + + private Object value(JsonNode node) { + return node == null || node.isMissingNode() || node.isNull() + ? null : objectMapper.convertValue(node, Object.class); + } + + @Test void evaluatesGlobalVariablesAndRequiredFeaturesFromSharedFixture() throws Exception { + JsonNode root = fixture(); + assertEquals(6, root.get("version").asInt()); + + JsonNode global = root.get("globalVariables"); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(objectMapper.treeToValue(global.get("datafile"), DatafileContent.class)) + .logLevel(FeaturevisorLogLevel.FATAL)); + + for (JsonNode testCase : global.get("cases")) { + f.setStickyVariables(map(testCase.get("stickyVariables")), true); + Featurevisor.OverrideOptions options = new Featurevisor.OverrideOptions(); + if (testCase.has("defaultVariableValue")) options.setDefaultVariableValue(value(testCase.get("defaultVariableValue"))); + Evaluation evaluation = f.evaluateVariable(testCase.get("key").asText(), map(testCase.get("context")), options); + assertEquals(value(testCase.get("expectedValue")), evaluation.getVariableValue(), testCase.get("name").asText()); + assertEquals(testCase.get("expectedReason").asText(), evaluation.getReason(), testCase.get("name").asText()); + assertEquals(testCase.has("expectedOverrideIndex") ? testCase.get("expectedOverrideIndex").asInt() : null, + evaluation.getVariableOverrideIndex(), testCase.get("name").asText()); + assertEquals(testCase.has("expectedOverrideKey") ? testCase.get("expectedOverrideKey").asText() : null, + evaluation.getVariableOverrideKey(), testCase.get("name").asText()); + List expectedPath = testCase.has("expectedOverridePath") + ? objectMapper.convertValue(testCase.get("expectedOverridePath"), new TypeReference>() {}) : null; + assertEquals(expectedPath, evaluation.getVariableOverridePath(), testCase.get("name").asText()); + } + + JsonNode overload = global.get("overloadCase"); + assertEquals(value(overload.get("expectedGlobalValue")), f.getVariable(overload.get("sharedKey").asText())); + assertEquals("hello", f.getVariableString("stringValue")); + assertEquals(1, f.getVariableInteger("integerValue")); + assertEquals(1.5, f.getVariableDouble("doubleValue")); + assertEquals(true, f.getVariableBoolean("booleanValue")); + assertEquals(List.of("one", "two"), f.getVariableArray("arrayValue")); + assertEquals(Map.of("enabled", true), f.>getVariableObject("objectValue")); + assertEquals(Map.of("enabled", true), f.>getVariableJSON("jsonValue")); + assertTrue(f.getVariableKeys(overload.get("sharedKey").asText()).contains(overload.get("featureVariableKey").asText())); + Featurevisor featureF = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(objectMapper.treeToValue(global.get("datafile"), DatafileContent.class)) + .logLevel(FeaturevisorLogLevel.FATAL)); + Evaluation featureVariable = featureF.evaluateVariable(overload.get("sharedKey").asText(), overload.get("featureVariableKey").asText()); + assertTrue(featureF.getVariableKeys("shared").contains("owned")); + assertEquals(value(overload.get("expectedFeatureValue")), featureVariable.getVariableValue(), featureVariable.toString()); + assertTrue(f.getVariableKeys().contains("shared")); + ChildInstance child = f.spawn(Map.of("country", "nl")); + assertEquals("global-value", child.getVariableString("shared")); + assertEquals("global-value", child.evaluateVariable("shared").getVariableValue()); + + JsonNode required = root.get("requiredFeatures"); + Featurevisor requiredF = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(objectMapper.treeToValue(required.get("datafile"), DatafileContent.class)) + .logLevel(FeaturevisorLogLevel.FATAL)); + for (JsonNode testCase : required.get("cases")) { + assertEquals(testCase.get("expectedEnabled").asBoolean(), + requiredF.isEnabled(testCase.get("feature").asText()), testCase.get("name").asText()); + } + JsonNode variableCase = required.get("featureVariableCase"); + Evaluation evaluation = requiredF.evaluateVariable( + variableCase.get("feature").asText(), variableCase.get("variable").asText()); + assertEquals(value(variableCase.get("expectedValue")), evaluation.getVariableValue()); + assertEquals(variableCase.get("expectedOverrideKey").asText(), evaluation.getVariableOverrideKey()); + } + + @SuppressWarnings("unchecked") + private void assertSameKeys(Object actual, JsonNode expected) { + List actualValues = new ArrayList<>((List) actual); + List expectedValues = objectMapper.convertValue(expected, new TypeReference>() {}); + Collections.sort(actualValues); + Collections.sort(expectedValues); + assertEquals(expectedValues, actualValues); + } + + @Test void reportsDirectAndDependencyAwareDatafileChanges() throws Exception { + JsonNode global = fixture().get("globalVariables"); + JsonNode update = global.get("datafileUpdateCase"); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(objectMapper.treeToValue(update.get("initial"), DatafileContent.class)) + .logLevel(FeaturevisorLogLevel.FATAL)); + FeaturevisorEventDetails[] details = new FeaturevisorEventDetails[1]; + f.on(FeaturevisorEventName.DATAFILE_SET, value -> details[0] = value); + f.setDatafile(objectMapper.treeToValue(update.get("merge"), DatafileContent.class)); + assertSameKeys(f.getFeatureKeys(), update.path("expectedAfterMerge").path("features")); + assertSameKeys(f.getVariableKeys(), update.path("expectedAfterMerge").path("variables")); + assertSameKeys(details[0].get("features"), update.path("expectedAfterMerge").path("changedFeatures")); + assertSameKeys(details[0].get("variables"), update.path("expectedAfterMerge").path("changedVariables")); + f.setDatafile(objectMapper.treeToValue(update.get("replacement"), DatafileContent.class), true); + assertSameKeys(details[0].get("features"), update.path("expectedAfterReplacement").path("changedFeatures")); + assertSameKeys(details[0].get("variables"), update.path("expectedAfterReplacement").path("changedVariables")); + + JsonNode dependency = global.get("dependencyUpdateCase"); + for (JsonNode mode : dependency.get("modes")) { + Featurevisor instance = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(objectMapper.treeToValue(dependency.get("initial"), DatafileContent.class)) + .logLevel(FeaturevisorLogLevel.FATAL)); + FeaturevisorEventDetails[] dependencyDetails = new FeaturevisorEventDetails[1]; + instance.on(FeaturevisorEventName.DATAFILE_SET, value -> dependencyDetails[0] = value); + instance.setDatafile(objectMapper.treeToValue(dependency.get("updated"), DatafileContent.class), mode.get("replace").asBoolean()); + assertSameKeys(dependencyDetails[0].get("features"), dependency.get("expectedChangedFeatures")); + assertSameKeys(dependencyDetails[0].get("variables"), dependency.get("expectedChangedVariables")); + } + } + + @Test void explicitNullVariableValueBeatsCallerDefault() throws Exception { + DatafileContent datafile = objectMapper.readValue(""" + { + "schemaVersion": "2", + "revision": "null-default", + "segments": {}, + "features": { + "feature": { + "key": "feature", + "bucketBy": "userId", + "variablesSchema": { + "nullable": { + "type": "json", + "defaultValue": null, + "useDefaultWhenDisabled": true + } + }, + "traffic": [] + }, + "allocatedFeature": { + "key": "allocatedFeature", + "bucketBy": "userId", + "variablesSchema": { + "nullable": { + "type": "json", + "defaultValue": null + }, + "missing": { + "type": "json" + } + }, + "traffic": [{ + "key": "all", + "segments": "*", + "percentage": 100000 + }] + } + }, + "variables": { + "nullable": { + "type": "json", + "defaultValue": null + } + } + } + """, DatafileContent.class); + assertTrue(datafile.getVariables().get("nullable").hasDefaultValue()); + assertTrue(datafile.getFeatures().get("feature").getVariablesSchema().get("nullable").hasDefaultValue()); + Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile) + .logLevel(FeaturevisorLogLevel.FATAL)); + Featurevisor.OverrideOptions options = new Featurevisor.OverrideOptions(); + options.setDefaultVariableValue(Map.of("fallback", true)); + + Evaluation evaluation = f.evaluateVariable("nullable", Collections.emptyMap(), options); + + assertTrue(evaluation.hasVariableValue()); + assertNull(evaluation.getVariableValue()); + assertEquals(Evaluation.REASON_VARIABLE_DEFAULT, evaluation.getReason()); + + Evaluation featureEvaluation = f.evaluateVariable("feature", "nullable", Collections.emptyMap(), options); + assertTrue(featureEvaluation.hasVariableValue()); + assertNull(featureEvaluation.getVariableValue()); + assertEquals(Evaluation.REASON_VARIABLE_DEFAULT, featureEvaluation.getReason()); + + Map context = Map.of("userId", "user"); + Evaluation allocatedNull = f.evaluateVariable("allocatedFeature", "nullable", context, options); + assertTrue(allocatedNull.hasVariableValue()); + assertNull(allocatedNull.getVariableValue()); + + Evaluation allocatedMissing = f.evaluateVariable("allocatedFeature", "missing", context, options); + assertTrue(allocatedMissing.hasVariableValue()); + assertEquals(Map.of("fallback", true), allocatedMissing.getVariableValue()); + } +} diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/InstanceEvaluationDataProviderTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/InstanceEvaluationDataProviderTest.java index c38f69d..592831f 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/InstanceEvaluationDataProviderTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/InstanceEvaluationDataProviderTest.java @@ -64,7 +64,7 @@ public void testSharedV3ConformanceFixture() throws Exception { assertNotNull(fixtureStream); ObjectMapper objectMapper = new ObjectMapper(); JsonNode fixture = objectMapper.readTree(fixtureStream); - assertEquals(2, fixture.get("version").asInt()); + assertEquals(6, fixture.get("version").asInt()); for (JsonNode testCase : fixture.get("numericBucketKeys")) { String bucketKey = Bucketer.getBucketKey( new Bucketer.GetBucketKeyOptions() @@ -137,7 +137,7 @@ public void testSharedV3ConformanceFixture() throws Exception { Featurevisor aggregateFeaturevisor = Featurevisor.createFeaturevisor( new Featurevisor.FeaturevisorOptions().datafile(aggregateDatafile) ); - EvaluatedFeature evaluated = aggregateFeaturevisor.getAllEvaluations( + EvaluatedFeature evaluated = aggregateFeaturevisor.getFeatureEvaluations( Map.of(), List.of(), new Featurevisor.OverrideOptions().defaultVariationValue( diff --git a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ModulesManagerTest.java b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ModulesManagerTest.java index 1dbfb23..7b5a0a3 100644 --- a/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ModulesManagerTest.java +++ b/featurevisor-sdk/src/test/java/com/featurevisor/sdk/ModulesManagerTest.java @@ -255,4 +255,26 @@ void testMultipleModules() { assertTrue((Boolean) context.get("module1")); assertTrue((Boolean) context.get("module2")); } + + @Test + void testCanonicalModulePhaseOrder() { + List order = new ArrayList<>(); + for (String name : List.of("first", "second")) { + modulesManager.add(new FeaturevisorModule(name) + .before(options -> { order.add("before:" + name); return options; }) + .beforeEvaluation(options -> { order.add("beforeEvaluation:" + name); return options; }) + .afterEvaluation((evaluation, options) -> { order.add("afterEvaluation:" + name); return evaluation; }) + .after((evaluation, options) -> { order.add("after:" + name); return evaluation; })); + } + + EvaluateOptions options = modulesManager.executeBeforeModules(new EvaluateOptions("flag", "test")); + modulesManager.executeAfterModules(new Evaluation("flag", "test", "allocated"), options); + + assertEquals(List.of( + "before:first", "before:second", + "beforeEvaluation:first", "beforeEvaluation:second", + "afterEvaluation:first", "afterEvaluation:second", + "after:first", "after:second" + ), order); + } } diff --git a/pom.xml b/pom.xml index 1164d5e..aba7c45 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ - 3.0.0 + 4.0.0 11 11 15