diff --git a/Makefile b/Makefile
index 45907e7..bbe8978 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: install build test setup-monorepo update-monorepo setup-golang-sdk update-golang-sdk setup-references update-references
+.PHONY: install build test test-example-1 setup-monorepo update-monorepo setup-golang-sdk update-golang-sdk setup-references update-references
install:
mvn install
@@ -9,6 +9,10 @@ build:
test:
mvn test
+test-example-1:
+ mvn test
+ mvn exec:java -Dexec.mainClass="com.featurevisor.cli.CLI" -Dexec.args="test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures"
+
##
# Monorepo
#
diff --git a/README.md b/README.md
index bd46fe3..d0d9342 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,13 @@
# Featurevisor Java SDK
-This is a port of Featurevisor [Javascript SDK](https://featurevisor.com/docs/sdks/javascript/) v2.x to Java, providing a way to evaluate feature flags, variations, and variables in your Java applications.
+This is a port of Featurevisor [Javascript SDK](https://featurevisor.com/docs/sdks/javascript/) v3.x to Java, providing a way to evaluate feature flags, variations, and variables in your Java applications.
-This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 projects and above.
+This SDK supports Featurevisor v3 behavior and v2 datafiles. Generated datafiles continue to carry `schemaVersion: "2"`.
## Table of contents
- [Installation](#installation)
+- [Public API](#public-api)
- [Repository](#repository)
- [Dependency](#dependency)
- [Authentication](#authentication)
@@ -26,20 +27,23 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 proje
- [Initialize with sticky](#initialize-with-sticky)
- [Set sticky afterwards](#set-sticky-afterwards)
- [Setting datafile](#setting-datafile)
+ - [Merging by default](#merging-by-default)
+ - [Replacing](#replacing)
+ - [Loading datafiles on demand](#loading-datafiles-on-demand)
- [Updating datafile](#updating-datafile)
- [Interval-based update](#interval-based-update)
-- [Logging](#logging)
+- [Diagnostics](#diagnostics)
- [Levels](#levels)
- - [Customizing levels](#customizing-levels)
- [Handler](#handler)
- [Events](#events)
- [`datafile_set`](#datafile_set)
- [`context_set`](#context_set)
- [`sticky_set`](#sticky_set)
+ - [`error`](#error)
- [Evaluation details](#evaluation-details)
-- [Hooks](#hooks)
- - [Defining a hook](#defining-a-hook)
- - [Registering hooks](#registering-hooks)
+- [Modules](#modules)
+ - [Defining a module](#defining-a-module)
+ - [Registering modules](#registering-modules)
- [Child instance](#child-instance)
- [Close](#close)
- [CLI usage](#cli-usage)
@@ -81,7 +85,7 @@ Add Featurevisor Java SDK as a dependency with your desired version:
com.featurevisor
featurevisor-java
- 0.1.0/version>
+ 0.1.0
```
@@ -114,6 +118,20 @@ You can generate a new GitHub token with `read:packages` scope here: [https://gi
See example application here: [https://github.com/featurevisor/featurevisor-example-java](https://github.com/featurevisor/featurevisor-example-java)
+## Public API
+
+The main runtime API is `Featurevisor.createFeaturevisor()`:
+
+```java
+import com.featurevisor.sdk.FeaturevisorLogLevel;
+
+Featurevisor f = Featurevisor.createFeaturevisor(
+ new Featurevisor.FeaturevisorOptions().datafile(datafileContent)
+);
+```
+
+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.
+
## Initialization
The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly:
@@ -126,13 +144,15 @@ String datafileUrl = "https://cdn.yoursite.com/datafile.json";
String datafileContent = "..." // load your datafile content
// Create SDK instance
-Featurevisor f = Featurevisor.createInstance(datafileContent);
+Featurevisor f = Featurevisor.createFeaturevisor(
+ new Featurevisor.FeaturevisorOptions().datafile(datafileContent)
+);
```
-or by constructing a `Featurevisor.Options` object:
+or by constructing a `Featurevisor.FeaturevisorOptions` object:
```java
-Featurevisor f = Featurevisor.createInstance(new Featurevisor.Options()
+Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
.datafile(datafileContent)
);
```
@@ -175,7 +195,7 @@ Map initialContext = new HashMap<>();
initialContext.put("deviceId", "123");
initialContext.put("country", "nl");
-Featurevisor f = Featurevisor.createInstance(new Featurevisor.Options()
+Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
.datafile(datafileContent)
.context(initialContext));
```
@@ -308,6 +328,8 @@ f.getVariableJSON(featureKey, variableKey, context);
f.getVariableJSONNode(featureKey, variableKey, context);
```
+Type specific methods do not coerce values. `getVariableInteger()` returns `null` for the string `"1"`, and boolean getters return `null` for non-boolean values.
+
For strongly typed decoding, additional overloads are available:
```java
@@ -392,6 +414,8 @@ This is handy especially when you want to pass all evaluations from a backend ap
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. Evaluation options do not accept sticky overrides; use `new Featurevisor.SpawnOptions().sticky(...)` when a child needs its own sticky state.
+
### Initialize with sticky
```java
@@ -411,7 +435,7 @@ Map anotherFeatureSticky = new HashMap<>();
anotherFeatureSticky.put("enabled", false);
stickyFeatures.put("anotherFeatureKey", anotherFeatureSticky);
-Featurevisor f = Featurevisor.createInstance(new Featurevisor.Options()
+Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
.datafile(datafile)
.sticky(stickyFeatures));
```
@@ -437,6 +461,44 @@ You may also initialize the SDK without passing `datafile`, and set it later on:
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.
+
+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.
+
+### Replacing
+
+To replace the stored datafile entirely, pass `true`:
+
+```java
+f.setDatafile(datafileContent, true);
+```
+
+### Loading datafiles on demand
+
+Because merging is the default, a single SDK instance can start with a small datafile and load more datafiles later as your application needs them, instead of downloading every feature upfront.
+
+This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application:
+
+```java
+Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions());
+
+void loadDatafile(String target) throws Exception {
+ String url = "https://cdn.yoursite.com/production/featurevisor-" + target + ".json";
+ String json = fetchJson(url); // use your HTTP client of choice
+ DatafileContent datafile = DatafileContent.fromJson(json);
+
+ // merges into whatever was loaded before
+ f.setDatafile(datafile);
+}
+
+loadDatafile("products");
+
+// later, when the user reaches checkout
+loadDatafile("checkout");
+```
+
### Updating datafile
You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile_set) event that you can listen and react to accordingly.
@@ -460,76 +522,45 @@ scheduler.scheduleAtFixedRate(() -> {
String newDatafileContent = // ... fetch from your CDN
DatafileContent newDatafile = DatafileContent.fromJson(newDatafileContent);
- // Update the SDK
+ // Merge into the SDK's existing datafile
f.setDatafile(newDatafile);
}, 0, 5, TimeUnit.MINUTES);
```
-## Logging
+## Diagnostics
-By default, Featurevisor SDKs will print out logs to the console for `info` level and above.
+By default, Featurevisor reports diagnostics to the console for `info` level and above with a `[Featurevisor]` prefix.
### Levels
-These are all the available log levels:
-
-- `error`
-- `warn`
-- `info`
-- `debug`
+Available diagnostic levels are `FATAL`, `ERROR`, `WARN`, `INFO`, and `DEBUG`.
-### Customizing levels
-
-If you choose `debug` level to make the logs more verbose, you can set it at the time of SDK initialization.
-
-Setting `debug` level will print out all logs, including `info`, `warn`, and `error` levels.
+Set the level during initialization or update it afterwards:
```java
-import com.featurevisor.sdk.Logger;
-
-Featurevisor f = Featurevisor.createInstance(new Featurevisor.Options()
- .datafile(datafile)
- .logLevel(Logger.LogLevel.DEBUG));
-```
-
-You can also set log level from SDK instance afterwards:
+Featurevisor f = Featurevisor.createFeaturevisor(
+ new Featurevisor.FeaturevisorOptions().logLevel(FeaturevisorLogLevel.DEBUG)
+);
-```java
-f.setLogLevel(Logger.LogLevel.DEBUG);
+f.setLogLevel(FeaturevisorLogLevel.INFO);
```
### Handler
-You can also pass your own log handler, if you do not wish to print the logs to the console:
+Use `onDiagnostic` to send structured diagnostics to your observability system:
```java
-// Create a custom logger with a custom handler
-Logger customLogger = Logger.createLogger(new Logger.CreateLoggerOptions()
- .level(Logger.LogLevel.INFO)
- .handler((level, message, details) -> {
- // do something with the log
- System.out.println("[" + level + "] " + message);
+Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
+ .logLevel(FeaturevisorLogLevel.INFO)
+ .onDiagnostic(diagnostic -> {
+ System.out.println(diagnostic.getLevel() + ": " + diagnostic.getCode());
}));
-
-Featurevisor f = Featurevisor.createInstance(new Featurevisor.Options()
- .datafile(datafile)
- .logger(customLogger));
```
-Alternatively, you can create a custom logger directly:
+Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` map. Optional `module`, `moduleName`, and `originalError` fields describe provenance. Evaluation metadata belongs in `details`.
-```java
-Logger customLogger = new Logger(Logger.LogLevel.INFO, (level, message, details) -> {
- // do something with the log
- System.out.println("[" + level + "] " + message);
-});
+Diagnostic handlers are isolated from SDK behavior. An exception in a handler does not stop other handlers or evaluations.
-Featurevisor f = Featurevisor.createInstance(new Featurevisor.Options()
- .datafile(datafile)
- .logger(customLogger));
-```
-
-Further log levels like `info` and `debug` will help you understand how the feature variations and variables are evaluated in the runtime against given context.
## Events
@@ -589,6 +620,17 @@ Runnable unsubscribe = f.on("sticky_set", (event) -> {
});
```
+### `error`
+
+```java
+Emitter.UnsubscribeFunction unsubscribe = f.on(Emitter.EventName.ERROR, (event) -> {
+ FeaturevisorDiagnostic diagnostic = (FeaturevisorDiagnostic) event.get("diagnostic");
+ System.err.println(diagnostic.getMessage());
+});
+```
+
+The `error` event is emitted for diagnostics whose level is `ERROR`.
+
## Evaluation details
Besides logging with debug level enabled, you can also get more details about how the feature variations and variables are evaluated in the runtime against given context:
@@ -621,87 +663,87 @@ And optionally these properties depending on whether you are evaluating a featur
- `variableValue`: the variable value
- `variableSchema`: the variable schema
-## Hooks
+## Modules
-Hooks allow you to intercept the evaluation process and customize it further as per your needs.
+Modules allow you to intercept the evaluation process and customize it further as per your needs.
-### Defining a hook
+### Defining a module
-A hook is a simple object with a unique required `name` and optional functions:
+A module is a `FeaturevisorModule` with a unique `name` and optional lifecycle functions:
+
+If `setup` throws, the module is not registered. Featurevisor removes subscriptions created during setup, reports `module_setup_error`, and calls `close` when present.
```java
-Map myCustomHook = new HashMap<>();
-myCustomHook.put("name", "my-custom-hook");
+FeaturevisorModule myCustomModule = new FeaturevisorModule("my-custom-module")
+ .setup(api -> {
+ System.out.println("Current revision: " + api.getRevision());
+ })
-// before evaluation
-myCustomHook.put("before", (options) -> {
- String type = (String) options.get("type"); // "feature" | "variation" | "variable"
- String featureKey = (String) options.get("featureKey");
- String variableKey = (String) options.get("variableKey"); // if type is "variable"
- @SuppressWarnings("unchecked")
- Map context = (Map) options.get("context");
+ // before evaluation
+ .before(options -> {
+ Map context = new HashMap<>(options.getContext());
+ context.put("someAdditionalAttribute", "value");
+ return options.copy().context(context);
+ })
- // update context before evaluation
- context.put("someAdditionalAttribute", "value");
- options.put("context", context);
+ // configure bucket key
+ .bucketKey(options -> {
+ String bucketKey = options.getBucketKey();
+ return bucketKey;
+ })
- return options;
-});
+ // configure bucket value (between 0 and 100,000)
+ .bucketValue(options -> {
+ int bucketValue = options.getBucketValue();
+ return bucketValue;
+ })
-// after evaluation
-myCustomHook.put("after", (evaluation, options) -> {
- String reason = (String) evaluation.get("reason"); // "error" | "feature_not_found" | "variable_not_found" | ...
+ // after evaluation
+ .after((evaluation, options) -> evaluation)
- if ("error".equals(reason)) {
- // log error
- return;
- }
-});
+ // called by f.close()
+ .close(() -> {
+ // clean up resources
+ });
+```
-// configure bucket key
-myCustomHook.put("bucketKey", (options) -> {
- String featureKey = (String) options.get("featureKey");
- @SuppressWarnings("unchecked")
- Map context = (Map) options.get("context");
- String bucketBy = (String) options.get("bucketBy");
- String bucketKey = (String) options.get("bucketKey"); // default bucket key
+### Registering modules
- // return custom bucket key
- return bucketKey;
-});
+You can register modules at the time of SDK initialization:
-// configure bucket value (between 0 and 100,000)
-myCustomHook.put("bucketValue", (options) -> {
- String featureKey = (String) options.get("featureKey");
- @SuppressWarnings("unchecked")
- Map context = (Map) options.get("context");
- String bucketKey = (String) options.get("bucketKey");
- Integer bucketValue = (Integer) options.get("bucketValue"); // default bucket value
+```java
+List modules = new ArrayList<>();
+modules.add(myCustomModule);
- // return custom bucket value
- return bucketValue;
-});
+Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
+ .datafile(datafile)
+ .modules(modules));
```
-### Registering hooks
-
-You can register hooks at the time of SDK initialization:
+Or after initialization:
```java
-List> hooks = new ArrayList<>();
-hooks.add(myCustomHook);
+Runnable removeModule = f.addModule(myCustomModule);
-Featurevisor f = Featurevisor.createInstance(new Featurevisor.Options()
- .datafile(datafile)
- .hooks(hooks));
+// removeModule.run();
+// or:
+f.removeModule("my-custom-module");
```
-Or after initialization:
+Modules receive an API during `setup` and can subscribe to diagnostics or report their own:
```java
-Runnable removeHook = f.addHook(myCustomHook);
+FeaturevisorModule module = new FeaturevisorModule("diagnostic-module")
+ .setup(api -> {
+ Runnable unsubscribe = api.onDiagnostic(diagnostic -> {
+ // observe diagnostics from the SDK and other modules
+ });
-// removeHook.run();
+ api.reportDiagnostic(new FeaturevisorDiagnostic()
+ .level(FeaturevisorLogLevel.WARN)
+ .code("custom_module_warning")
+ .message("Something notable happened"));
+ });
```
## Child instance
@@ -758,6 +800,8 @@ f.close();
This package also provides a CLI tool for running your Featurevisor project's test specs and benchmarking against this Java SDK:
+All three commands accept repeatable `--target=` options. `test` builds only the selected Target datafiles and runs untargeted assertions plus assertions for those targets. `benchmark` and `assess-distribution` run independently against every selected Target datafile. Without `--target`, existing project-wide behavior is preserved. Project definitions, test specs, Target discovery, and datafile generation continue to come from the Node.js CLI.
+
### Test
Learn more about testing [here](https://featurevisor.com/docs/testing/).
@@ -769,15 +813,10 @@ $ mvn exec:java -Dexec.mainClass="com.featurevisor.cli.CLI" -Dexec.args="test --
Additional options that are available:
```bash
-$ mvn exec:java -Dexec.mainClass="com.featurevisor.cli.CLI" -Dexec.args="test --projectDirectoryPath=/absolute/path/to/your/featurevisor/project --quiet --onlyFailures --keyPattern=myFeatureKey --assertionPattern=#1 --with-tags --with-scopes --showDatafile --schemaVersion=2 --inflate=1"
+$ mvn exec:java -Dexec.mainClass="com.featurevisor.cli.CLI" -Dexec.args="test --projectDirectoryPath=/absolute/path/to/your/featurevisor/project --quiet --onlyFailures --keyPattern=myFeatureKey --assertionPattern=#1 --showDatafile --inflate=1"
```
-Scoped and tagged test behavior mirrors the JavaScript tester:
-
-- `--with-tags`: builds and tests assertions against tagged datafiles.
-- `--with-scopes`: builds scoped datafiles and tests scoped assertions against those scoped files.
-- without `--with-scopes`: scoped assertions still run by merging scope context into assertion context (fallback behavior).
-- if both `scope` and `tag` are present in an assertion, scope datafile takes precedence.
+The test runner builds base datafiles and Target datafiles, then uses a Target datafile when an assertion contains `target`.
### Benchmark
diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json
new file mode 100644
index 0000000..ceae692
--- /dev/null
+++ b/conformance/sdk-v3.json
@@ -0,0 +1,49 @@
+{
+ "version": 1,
+ "description": "Featurevisor v3 cross SDK compatibility contracts",
+ "bucketing": {
+ "minimum": 0,
+ "maximum": 100000,
+ "percentage": {
+ "percentage": 50000,
+ "enabledAt": [0, 50000],
+ "disabledAt": [50001, 100000]
+ },
+ "allocations": [
+ { "variation": "control", "range": [0, 50000] },
+ { "variation": "treatment", "range": [50000, 100000] }
+ ],
+ "allocationExpectations": {
+ "0": "control",
+ "49999": "control",
+ "50000": "control",
+ "50001": "treatment",
+ "99999": "treatment",
+ "100000": "treatment"
+ }
+ },
+ "regularExpressions": {
+ "pattern": "chrome",
+ "flags": "g",
+ "values": ["chrome", "chrome", "firefox", "chrome"],
+ "matches": [true, true, false, true]
+ },
+ "typedVariables": [
+ { "type": "integer", "value": 1, "valid": true },
+ { "type": "integer", "value": 1.5, "valid": false },
+ { "type": "integer", "value": "1", "valid": false },
+ { "type": "double", "value": 1.5, "valid": true },
+ { "type": "double", "value": "1.5", "valid": false },
+ { "type": "boolean", "value": true, "valid": true },
+ { "type": "boolean", "value": "true", "valid": false }
+ ],
+ "datafile": {
+ "schemaVersionIsInformational": true,
+ "schemaVersionType": "string"
+ },
+ "diagnostics": {
+ "requiredFields": ["level", "code", "message", "details"],
+ "detailsType": "object",
+ "emptyDetailsJson": "{}"
+ }
+}
diff --git a/src/main/java/com/featurevisor/cli/CLI.java b/src/main/java/com/featurevisor/cli/CLI.java
index 6e11716..33d196c 100644
--- a/src/main/java/com/featurevisor/cli/CLI.java
+++ b/src/main/java/com/featurevisor/cli/CLI.java
@@ -6,11 +6,12 @@
import picocli.CommandLine.Parameters;
import com.featurevisor.sdk.Featurevisor;
-import com.featurevisor.sdk.Logger;
-import com.featurevisor.sdk.DatafileReader;
+import com.featurevisor.sdk.FeaturevisorLogLevel;
+import com.featurevisor.sdk.Conditions;
import com.featurevisor.sdk.DatafileContent;
import com.featurevisor.sdk.Segment;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.commons.exec.DefaultExecutor;
@@ -19,14 +20,9 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
import java.util.*;
-import java.util.concurrent.ThreadLocalRandom;
-import java.util.regex.Pattern;
-import com.featurevisor.sdk.HooksManager;
+import com.featurevisor.sdk.FeaturevisorModule;
/**
* Command Line Interface for Featurevisor Java Library
@@ -63,7 +59,7 @@ public class CLI implements Runnable {
@Option(names = {"--keyPattern"}, description = "Key pattern filter")
private String keyPattern;
- @Option(names = {"-n"}, description = "Number of iterations")
+ @Option(names = {"-n", "--n"}, description = "Number of iterations")
private Integer n = 1000;
@Option(names = {"--onlyFailures"}, description = "Show only failures")
@@ -84,16 +80,16 @@ public class CLI implements Runnable {
@Option(names = {"--inflate"}, description = "Inflate number")
private Integer inflate = 0;
- @Option(names = {"--with-scopes"}, description = "Test with scoped datafiles")
+ @Option(names = {"--with-scopes"}, description = "Legacy option accepted for compatibility and ignored")
private Boolean withScopes = false;
- @Option(names = {"--with-tags"}, description = "Test with tagged datafiles")
+ @Option(names = {"--with-tags"}, description = "Legacy option accepted for compatibility and ignored")
private Boolean withTags = false;
@Option(names = {"--showDatafile"}, description = "Show datafile content for assertion")
private Boolean showDatafile = false;
- @Option(names = {"--schemaVersion"}, description = "Datafile schema version")
+ @Option(names = {"--schemaVersion", "--schema-version"}, description = "Legacy option accepted for compatibility and ignored")
private String schemaVersion;
@Option(names = {"--rootDirectoryPath"}, description = "Root directory path")
@@ -105,6 +101,9 @@ public class CLI implements Runnable {
@Option(names = {"--populateUuid"}, description = "Populate UUID for specified keys")
private List populateUuid = new ArrayList<>();
+ @Option(names = {"--target"}, description = "Target datafile; repeat for multiple targets")
+ private List targets = new ArrayList<>();
+
private String cwd;
private ObjectMapper objectMapper;
@@ -197,16 +196,26 @@ private Map getSegments(String featurevisorProjectPath) throws
return segmentsByKey;
}
- private String getEnvironmentKey(String environment) {
+ String getEnvironmentKey(String environment) {
return environment == null ? "__no_environment__" : environment;
}
- private String scopedDatafileCacheKey(String environment, String scope) {
- return getEnvironmentKey(environment) + "-scope-" + scope;
+ String targetDatafileCacheKey(String environment, String target) {
+ return (environment == null ? "false" : environment) + "-target-" + target;
}
- private String taggedDatafileCacheKey(String environment, String tag) {
- return getEnvironmentKey(environment) + "-tag-" + tag;
+ String selectDatafileKeyForAssertion(Map assertion, Map datafileCache) {
+ String assertionEnvironment = assertion.get("environment") instanceof String
+ ? (String) assertion.get("environment")
+ : null;
+ String baseDatafileKey = getEnvironmentKey(assertionEnvironment);
+ String target = assertion.get("target") instanceof String ? (String) assertion.get("target") : null;
+
+ if (target != null && datafileCache.containsKey(targetDatafileCacheKey(assertionEnvironment, target))) {
+ return targetDatafileCacheKey(assertionEnvironment, target);
+ }
+
+ return baseDatafileKey;
}
private DatafileContent parseDatafileContent(String datafileOutput, String contextForError) throws IOException {
@@ -220,17 +229,14 @@ private DatafileContent parseDatafileContent(String datafileOutput, String conte
private DatafileContent buildDatafile(
String featurevisorProjectPath,
String environment,
- String tag
+ String target
) throws IOException {
StringBuilder command = new StringBuilder("npx featurevisor build --json");
if (environment != null) {
command.append(" --environment=").append(environment);
}
- if (tag != null) {
- command.append(" --tag=").append(tag);
- }
- if (schemaVersion != null && !schemaVersion.isBlank()) {
- command.append(" --schema-version=").append(schemaVersion);
+ if (target != null) {
+ command.append(" --target=").append(target);
}
if (inflate != null && inflate > 0) {
command.append(" --inflate=").append(inflate);
@@ -252,6 +258,21 @@ private Map buildBaseDatafiles(
return datafilesByEnvironment;
}
+ private Map buildTargetDatafiles(
+ String featurevisorProjectPath,
+ List environments,
+ List targets
+ ) throws IOException {
+ Map datafilesByTarget = new HashMap<>();
+ for (String env : environments) {
+ for (String target : targets) {
+ System.out.println("Building datafile for target: " + target + " environment: " + (env == null ? "default" : env) + "...");
+ datafilesByTarget.put(targetDatafileCacheKey(env, target), buildDatafile(featurevisorProjectPath, env, target));
+ }
+ }
+ return datafilesByTarget;
+ }
+
private List getEnvironmentList(Map config) {
Object environmentsValue = config.get("environments");
if (Boolean.FALSE.equals(environmentsValue)) {
@@ -269,73 +290,42 @@ private List getEnvironmentList(Map config) {
return Collections.singletonList(null);
}
- private List getTags(Map config) {
- Object tagsValue = config.get("tags");
- if (!(tagsValue instanceof List)) {
- return Collections.emptyList();
- }
-
- @SuppressWarnings("unchecked")
- List rawTags = (List) tagsValue;
- List tags = new ArrayList<>();
- for (Object tag : rawTags) {
- if (tag instanceof String) {
- tags.add((String) tag);
- }
- }
- return tags;
- }
-
- private Map> getScopesByName(Map config) {
- Object scopesValue = config.get("scopes");
- if (!(scopesValue instanceof List)) {
- return Collections.emptyMap();
- }
-
- @SuppressWarnings("unchecked")
- List scopes = (List) scopesValue;
- Map> scopesByName = new HashMap<>();
-
- for (Object scopeObj : scopes) {
- if (!(scopeObj instanceof Map)) {
- continue;
+ private List getTargets(String featurevisorProjectPath) throws IOException {
+ System.out.println("Getting targets...");
+ String targetsOutput = executeCommandInDirectory(featurevisorProjectPath, "npx featurevisor list --targets --json");
+ JsonNode targetsNode = objectMapper.readTree(targetsOutput);
+ List targets = new ArrayList<>();
+
+ if (targetsNode.isArray()) {
+ for (JsonNode targetNode : targetsNode) {
+ if (targetNode.isTextual()) {
+ targets.add(targetNode.asText());
+ } else if (targetNode.has("name")) {
+ targets.add(targetNode.get("name").asText());
+ } else if (targetNode.has("key")) {
+ targets.add(targetNode.get("key").asText());
+ }
}
-
- @SuppressWarnings("unchecked")
- Map scope = (Map) scopeObj;
- Object name = scope.get("name");
- if (name instanceof String && !((String) name).isBlank()) {
- scopesByName.put((String) name, scope);
+ } else if (targetsNode.isObject()) {
+ Iterator fieldNames = targetsNode.fieldNames();
+ while (fieldNames.hasNext()) {
+ targets.add(fieldNames.next());
}
}
- return scopesByName;
- }
-
- private Path getScopedDatafilePath(String featurevisorProjectPath, Map config, String environment, String scopeName) {
- String datafilesDirectory = "datafiles";
- Object configuredDir = config.get("datafilesDirectoryPath");
- if (configuredDir instanceof String && !((String) configuredDir).isBlank()) {
- datafilesDirectory = (String) configuredDir;
- }
-
- if (environment != null) {
- return Paths.get(featurevisorProjectPath, datafilesDirectory, environment, "featurevisor-scope-" + scopeName + ".json");
- }
-
- return Paths.get(featurevisorProjectPath, datafilesDirectory, "featurevisor-scope-" + scopeName + ".json");
+ return targets;
}
/**
* Get logger level based on CLI options
*/
- private Logger.LogLevel getLoggerLevel() {
+ private FeaturevisorLogLevel getLoggerLevel() {
if (verbose) {
- return Logger.LogLevel.DEBUG;
+ return FeaturevisorLogLevel.DEBUG;
} else if (quiet) {
- return Logger.LogLevel.ERROR;
+ return FeaturevisorLogLevel.ERROR;
} else {
- return Logger.LogLevel.WARN;
+ return FeaturevisorLogLevel.WARN;
}
}
@@ -365,7 +355,7 @@ private List> getTests(String featurevisorProjectPath) throw
/**
* Test a feature
*/
- private TestResult testFeature(Map assertion, String featureKey, Object f, Logger.LogLevel level) {
+ private TestResult testFeature(Map assertion, String featureKey, Object f, FeaturevisorLogLevel level) {
@SuppressWarnings("unchecked")
Map context = (Map) assertion.getOrDefault("context", new HashMap<>());
@SuppressWarnings("unchecked")
@@ -645,27 +635,17 @@ private Object getEvaluationValue(com.featurevisor.sdk.Evaluation evaluation, St
/**
* Test a segment
*/
- private TestResult testSegment(Map assertion, Segment segment, Logger.LogLevel level) {
+ private TestResult testSegment(Map assertion, Segment segment, FeaturevisorLogLevel level) {
@SuppressWarnings("unchecked")
Map context = (Map) assertion.getOrDefault("context", new HashMap<>());
Object conditions = segment.getConditions();
- DatafileContent datafile = new DatafileContent();
- datafile.setSchemaVersion("2");
- datafile.setRevision("tester");
- datafile.setFeatures(new HashMap<>());
- datafile.setSegments(new HashMap<>());
-
- DatafileReader datafileReader = new DatafileReader(new DatafileReader.DatafileReaderOptions()
- .datafile(datafile)
- .logger(Logger.createLogger(new Logger.CreateLoggerOptions().level(level))));
-
boolean hasError = false;
StringBuilder errors = new StringBuilder();
long startTime = System.nanoTime();
if (assertion.containsKey("expectedToMatch")) {
- boolean actual = datafileReader.allConditionsAreMatched(conditions, context);
+ boolean actual = Conditions.allConditionsAreMatched(conditions, context);
boolean expected = (Boolean) assertion.get("expectedToMatch");
if (actual != expected) {
hasError = true;
@@ -686,48 +666,20 @@ private void test() {
Map config = getConfig(featurevisorProjectPath);
List environments = getEnvironmentList(config);
- List tags = getTags(config);
- Map> scopesByName = getScopesByName(config);
+ List availableTargets = getTargets(featurevisorProjectPath);
+ List selectedTargets = targets.isEmpty()
+ ? availableTargets
+ : new ArrayList<>(new java.util.LinkedHashSet<>(targets));
Map segmentsByKey = getSegments(featurevisorProjectPath);
Map datafileCache = new HashMap<>();
datafileCache.putAll(buildBaseDatafiles(featurevisorProjectPath, environments));
-
- if (Boolean.TRUE.equals(withTags)) {
- for (String env : environments) {
- for (String tag : tags) {
- datafileCache.put(
- taggedDatafileCacheKey(env, tag),
- buildDatafile(featurevisorProjectPath, env, tag)
- );
- }
- }
- }
-
- if (Boolean.TRUE.equals(withScopes) && !scopesByName.isEmpty()) {
- // Ensure scoped datafiles are materialized on disk.
- executeCommandInDirectory(featurevisorProjectPath, "npx featurevisor build");
-
- for (String env : environments) {
- for (String scopeName : scopesByName.keySet()) {
- Path scopedPath = getScopedDatafilePath(featurevisorProjectPath, config, env, scopeName);
- if (!Files.exists(scopedPath)) {
- continue;
- }
-
- String scopedDatafileOutput = Files.readString(scopedPath);
- datafileCache.put(
- scopedDatafileCacheKey(env, scopeName),
- parseDatafileContent(scopedDatafileOutput, scopedPath.toString())
- );
- }
- }
- }
+ datafileCache.putAll(buildTargetDatafiles(featurevisorProjectPath, environments, selectedTargets));
System.out.println();
- Logger.LogLevel level = getLoggerLevel();
+ FeaturevisorLogLevel level = getLoggerLevel();
List> tests = getTests(featurevisorProjectPath);
if (tests.isEmpty()) {
@@ -745,6 +697,16 @@ private void test() {
@SuppressWarnings("unchecked")
List> assertions = (List>) test.get("assertions");
+ if (test.containsKey("feature") && !targets.isEmpty()) {
+ assertions = assertions.stream().filter(assertion -> {
+ Object assertionTarget = assertion.get("target");
+ return assertionTarget == null || targets.contains(assertionTarget.toString());
+ }).collect(java.util.stream.Collectors.toList());
+ if (assertions.isEmpty()) {
+ continue;
+ }
+ }
+
StringBuilder results = new StringBuilder();
@@ -759,18 +721,7 @@ private void test() {
? (String) assertion.get("environment")
: null;
String baseDatafileKey = getEnvironmentKey(assertionEnvironment);
- String selectedDatafileKey = baseDatafileKey;
-
- String scope = assertion.get("scope") instanceof String ? (String) assertion.get("scope") : null;
- String tag = assertion.get("tag") instanceof String ? (String) assertion.get("tag") : null;
-
- if (scope != null && datafileCache.containsKey(scopedDatafileCacheKey(assertionEnvironment, scope))) {
- selectedDatafileKey = scopedDatafileCacheKey(assertionEnvironment, scope);
- }
-
- if (scope == null && tag != null && datafileCache.containsKey(taggedDatafileCacheKey(assertionEnvironment, tag))) {
- selectedDatafileKey = taggedDatafileCacheKey(assertionEnvironment, tag);
- }
+ String selectedDatafileKey = selectDatafileKeyForAssertion(assertion, datafileCache);
DatafileContent selectedDatafile = datafileCache.get(selectedDatafileKey);
if (selectedDatafile == null) {
@@ -787,28 +738,17 @@ private void test() {
new TypeReference>() {}
);
- if (scope != null && !Boolean.TRUE.equals(withScopes) && scopesByName.containsKey(scope)) {
- @SuppressWarnings("unchecked")
- Map currentContext = (Map) effectiveAssertion.getOrDefault("context", new HashMap<>());
- @SuppressWarnings("unchecked")
- Map scopeContext = (Map) scopesByName.get(scope).getOrDefault("context", new HashMap<>());
-
- Map mergedContext = new HashMap<>(scopeContext);
- mergedContext.putAll(currentContext);
- effectiveAssertion.put("context", mergedContext);
- }
-
if (Boolean.TRUE.equals(showDatafile)) {
System.out.println();
System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(selectedDatafile));
System.out.println();
}
- Featurevisor f = Featurevisor.createInstance(new Featurevisor.Options()
+ Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
.datafile(selectedDatafile)
.logLevel(level));
- // If "at" parameter is provided, create a new SDK instance with the specific hook
+ // If "at" parameter is provided, create a new SDK instance with the specific module
if (effectiveAssertion.containsKey("at")) {
Object atObj = effectiveAssertion.get("at");
double atValue;
@@ -819,17 +759,13 @@ private void test() {
atValue = Double.parseDouble(atObj.toString());
}
- // Create a hook that sets the bucket value to at * 1000
- Logger logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(level));
- HooksManager hooksManager = new HooksManager(new HooksManager.HooksManagerOptions(logger));
-
- hooksManager.add(new HooksManager.Hook("at-parameter")
- .bucketValue((options) -> (int) (atValue * 1000)));
+ FeaturevisorModule testModule = new FeaturevisorModule("test-module")
+ .bucketValue((options) -> (int) (atValue * 1000));
- f = Featurevisor.createInstance(new Featurevisor.Options()
+ f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
.datafile(selectedDatafile)
.logLevel(level)
- .hooks(hooksManager.getAll()));
+ .modules(Collections.singletonList(testModule)));
}
testResult = testFeature(effectiveAssertion, (String) test.get("feature"), f, level);
@@ -844,18 +780,18 @@ private void test() {
testDuration += testResult.duration;
if (testResult.hasError) {
- results.append(" ✘ ").append(assertion.get("description")).append(" (").append(String.format("%.2f", testResult.duration)).append("ms)\n");
+ results.append(" ✘ ").append(assertion.get("description")).append(" (").append(String.format(Locale.ROOT, "%.2f", testResult.duration)).append("ms)\n");
results.append(testResult.errors);
testHasError = true;
failedAssertionsCount++;
} else {
- results.append(" ✔ ").append(assertion.get("description")).append(" (").append(String.format("%.2f", testResult.duration)).append("ms)\n");
+ results.append(" ✔ ").append(assertion.get("description")).append(" (").append(String.format(Locale.ROOT, "%.2f", testResult.duration)).append("ms)\n");
passedAssertionsCount++;
}
}
if (!onlyFailures || (onlyFailures && testHasError)) {
- System.out.println("\nTesting: " + testKey + " (" + String.format("%.2f", testDuration) + "ms)");
+ System.out.println("\nTesting: " + testKey + " (" + String.format(Locale.ROOT, "%.2f", testDuration) + "ms)");
System.out.print(results);
}
@@ -897,20 +833,37 @@ private void benchmark() {
return;
}
+ if (targets.size() > 1) {
+ List requested = new ArrayList<>(new java.util.LinkedHashSet<>(targets));
+ for (String target : requested) {
+ targets = Collections.singletonList(target);
+ benchmark();
+ }
+ targets = requested;
+ return;
+ }
+
Map contextMap = new HashMap<>();
if (context != null) {
contextMap = objectMapper.readValue(context, new TypeReference>() {});
}
- Logger.LogLevel level = getLoggerLevel();
- DatafileContent datafile = buildDatafile(rootDirectoryPath, environment, null);
+ FeaturevisorLogLevel level = getLoggerLevel();
+ String target = targets.isEmpty() ? null : targets.get(0);
+ DatafileContent datafile = buildDatafile(rootDirectoryPath, environment, target);
- Featurevisor f = Featurevisor.createInstance(new Featurevisor.Options()
+ Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
.datafile(datafile)
.logLevel(level));
Object value = null;
+ System.out.println("Benchmark Featurevisor feature");
+ 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) {
@@ -922,8 +875,11 @@ private void benchmark() {
System.out.println("Against context: " + contextMap);
System.out.println("Running " + n + " times...");
- long startTime = System.nanoTime();
+ long totalDurationNs = 0L;
+ long minDurationNs = 0L;
+ long maxDurationNs = 0L;
for (int i = 0; i < n; i++) {
+ long evaluationStartTime = System.nanoTime();
if (variation) {
value = f.getVariation(feature, contextMap);
} else if (variable != null) {
@@ -931,13 +887,24 @@ private void benchmark() {
} else {
value = f.isEnabled(feature, contextMap);
}
+ long evaluationDurationNs = System.nanoTime() - evaluationStartTime;
+
+ totalDurationNs += evaluationDurationNs;
+ if (i == 0 || evaluationDurationNs < minDurationNs) {
+ minDurationNs = evaluationDurationNs;
+ }
+ if (evaluationDurationNs > maxDurationNs) {
+ maxDurationNs = evaluationDurationNs;
+ }
}
- double duration = (System.nanoTime() - startTime) / 1_000_000.0; // Convert to milliseconds
+ double duration = totalDurationNs / 1_000_000.0; // Convert to milliseconds
System.out.println("Evaluated value: " + value);
- System.out.println("Total duration: " + String.format("%.3f", duration) + "ms");
- System.out.println("Average duration: " + String.format("%.3f", duration / n) + "ms");
+ System.out.println("Total duration: " + String.format(Locale.ROOT, "%.3f", duration) + "ms");
+ System.out.println("Minimum duration: " + String.format(Locale.ROOT, "%.6f", minDurationNs / 1_000_000.0) + "ms");
+ System.out.println("Average duration: " + String.format(Locale.ROOT, "%.6f", duration / n) + "ms");
+ System.out.println("Maximum duration: " + String.format(Locale.ROOT, "%.6f", maxDurationNs / 1_000_000.0) + "ms");
} catch (Exception e) {
System.err.println("Error running benchmark: " + e.getMessage());
@@ -961,19 +928,36 @@ private void assessDistribution() {
return;
}
+ if (targets.size() > 1) {
+ List requested = new ArrayList<>(new java.util.LinkedHashSet<>(targets));
+ for (String target : requested) {
+ targets = Collections.singletonList(target);
+ assessDistribution();
+ }
+ targets = requested;
+ return;
+ }
+
Map contextMap = new HashMap<>();
if (context != null) {
contextMap = objectMapper.readValue(context, new TypeReference>() {});
}
- DatafileContent datafile = buildDatafile(rootDirectoryPath, environment, null);
+ String target = targets.isEmpty() ? null : targets.get(0);
+ DatafileContent datafile = buildDatafile(rootDirectoryPath, environment, target);
- Featurevisor f = Featurevisor.createInstance(new Featurevisor.Options()
+ Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
.datafile(datafile)
.logLevel(getLoggerLevel()));
Object value = null;
+ System.out.println("Assess Featurevisor distribution");
+ 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("Assessing distribution for feature '" + feature + "'...");
} else if (variable != null) {
@@ -1012,7 +996,7 @@ private void assessDistribution() {
Object val = entry.getKey();
Integer count = entry.getValue();
double percentage = (count.doubleValue() / n) * 100;
- System.out.println(" - " + val + ": " + count + " (" + String.format("%.2f", percentage) + "%)");
+ System.out.println(" - " + val + ": " + count + " (" + String.format(Locale.ROOT, "%.2f", percentage) + "%)");
}
} catch (Exception e) {
diff --git a/src/main/java/com/featurevisor/sdk/Bucketer.java b/src/main/java/com/featurevisor/sdk/Bucketer.java
index e3b915c..7a68ab7 100644
--- a/src/main/java/com/featurevisor/sdk/Bucketer.java
+++ b/src/main/java/com/featurevisor/sdk/Bucketer.java
@@ -9,7 +9,7 @@
* Bucketer for Featurevisor SDK
* Handles bucketing logic for feature flags
*/
-public class Bucketer {
+final class Bucketer {
/**
* Bucket key type
diff --git a/src/main/java/com/featurevisor/sdk/ChildInstance.java b/src/main/java/com/featurevisor/sdk/ChildInstance.java
index 0221a45..a97cebe 100644
--- a/src/main/java/com/featurevisor/sdk/ChildInstance.java
+++ b/src/main/java/com/featurevisor/sdk/ChildInstance.java
@@ -458,13 +458,7 @@ private Featurevisor.OverrideOptions mergeOverrideOptions(Featurevisor.OverrideO
options = new Featurevisor.OverrideOptions();
}
- if (this.sticky != null) {
- Map mergedSticky = new HashMap<>(this.sticky);
- if (options.getSticky() != null) {
- mergedSticky.putAll(options.getSticky());
- }
- options.sticky(mergedSticky);
- }
+ options.setInternalSticky(this.sticky);
return options;
}
diff --git a/src/main/java/com/featurevisor/sdk/Conditions.java b/src/main/java/com/featurevisor/sdk/Conditions.java
index 0275f2f..6db1732 100644
--- a/src/main/java/com/featurevisor/sdk/Conditions.java
+++ b/src/main/java/com/featurevisor/sdk/Conditions.java
@@ -14,6 +14,13 @@
* Provides condition matching functionality
*/
public class Conditions {
+ /**
+ * Functional interface for getting regex patterns.
+ */
+ @FunctionalInterface
+ public interface GetRegex {
+ Pattern getRegex(String regexString, String regexFlags);
+ }
/**
* Check if a path exists in a context object
@@ -54,7 +61,7 @@ private static boolean pathExists(Map context, String path) {
public static boolean conditionIsMatched(
Condition condition,
Map context,
- DatafileReader.GetRegex getRegex) {
+ GetRegex getRegex) {
if (condition == null) {
return false;
@@ -99,14 +106,14 @@ public static boolean conditionIsMatched(
if (condition.isNotCondition()) {
List notConditions = condition.getNot();
if (notConditions == null || notConditions.isEmpty()) {
- return true;
+ return false;
}
for (Condition subCondition : notConditions) {
- if (conditionIsMatched(subCondition, context, getRegex)) {
- return false;
+ if (!conditionIsMatched(subCondition, context, getRegex)) {
+ return true;
}
}
- return true;
+ return false;
}
// Handle plain condition
@@ -189,6 +196,24 @@ public 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.
+ */
+ public static boolean allConditionsAreMatched(Object conditions, Map context) {
+ DatafileContent datafile = new DatafileContent();
+ datafile.setSchemaVersion("2");
+ datafile.setRevision("matcher");
+ datafile.setFeatures(new java.util.HashMap<>());
+ datafile.setSegments(new java.util.HashMap<>());
+
+ return new DatafileReader(new DatafileReader.DatafileReaderOptions()
+ .datafile(datafile)
+ .logger(Logger.createLogger(new Logger.CreateLoggerOptions())))
+ .allConditionsAreMatched(conditions, context);
+ }
+
private static boolean equals(Object contextValue, Object conditionValue) {
if (contextValue == null && conditionValue == null) {
return true;
@@ -270,7 +295,7 @@ private static boolean lessThanOrEquals(Object contextValue, Object conditionVal
return ((Number) contextValue).doubleValue() <= ((Number) conditionValue).doubleValue();
}
- private static boolean matches(Object contextValue, Object conditionValue, String regexFlags, DatafileReader.GetRegex getRegex) {
+ private static boolean matches(Object contextValue, Object conditionValue, String regexFlags, GetRegex getRegex) {
if (!(contextValue instanceof String) || !(conditionValue instanceof String)) {
return false;
}
diff --git a/src/main/java/com/featurevisor/sdk/DatafileContent.java b/src/main/java/com/featurevisor/sdk/DatafileContent.java
index 717c206..b3ea75e 100644
--- a/src/main/java/com/featurevisor/sdk/DatafileContent.java
+++ b/src/main/java/com/featurevisor/sdk/DatafileContent.java
@@ -19,6 +19,9 @@ public class DatafileContent {
@JsonProperty("revision")
private String revision;
+ @JsonProperty("featurevisorVersion")
+ private String featurevisorVersion;
+
@JsonProperty("segments")
private Map segments;
@@ -51,6 +54,14 @@ public void setRevision(String revision) {
this.revision = revision;
}
+ public String getFeaturevisorVersion() {
+ return featurevisorVersion;
+ }
+
+ public void setFeaturevisorVersion(String featurevisorVersion) {
+ this.featurevisorVersion = featurevisorVersion;
+ }
+
public Map getSegments() {
return segments;
}
diff --git a/src/main/java/com/featurevisor/sdk/DatafileReader.java b/src/main/java/com/featurevisor/sdk/DatafileReader.java
index 430be3a..6a5e6fb 100644
--- a/src/main/java/com/featurevisor/sdk/DatafileReader.java
+++ b/src/main/java/com/featurevisor/sdk/DatafileReader.java
@@ -21,15 +21,7 @@
* DatafileReader for Featurevisor SDK
* Handles reading and parsing datafile content
*/
-public class DatafileReader {
-
- /**
- * Functional interface for getting regex patterns
- */
- @FunctionalInterface
- public interface GetRegex {
- Pattern getRegex(String regexString, String regexFlags);
- }
+class DatafileReader {
/**
* Options for creating a DatafileReader
@@ -78,6 +70,7 @@ public ForceResult(Force force, Integer forceIndex) {
private String schemaVersion;
private String revision;
+ private String featurevisorVersion;
private Map segments;
private Map features;
private Logger logger;
@@ -91,8 +84,15 @@ public DatafileReader(DatafileReaderOptions options) {
this.schemaVersion = datafile.getSchemaVersion();
this.revision = datafile.getRevision();
+ this.featurevisorVersion = datafile.getFeaturevisorVersion();
this.segments = datafile.getSegments();
this.features = datafile.getFeatures();
+ if (this.segments == null) {
+ this.segments = new HashMap<>();
+ }
+ if (this.features == null) {
+ this.features = new HashMap<>();
+ }
this.regexCache = new HashMap<>();
}
@@ -108,6 +108,7 @@ public DatafileContent getDatafile() {
DatafileContent datafile = new DatafileContent();
datafile.setSchemaVersion(this.schemaVersion);
datafile.setRevision(this.revision);
+ datafile.setFeaturevisorVersion(this.featurevisorVersion);
datafile.setSegments(this.segments);
datafile.setFeatures(this.features);
return datafile;
@@ -190,7 +191,7 @@ public boolean allConditionsAreMatched(Object conditions, Map co
return false;
}
- GetRegex getRegex = (regexString, regexFlags) -> this.getRegex(regexString, regexFlags);
+ Conditions.GetRegex getRegex = (regexString, regexFlags) -> this.getRegex(regexString, regexFlags);
// Handle Condition object
if (conditions instanceof Condition) {
@@ -262,8 +263,6 @@ public boolean allConditionsAreMatched(Object conditions, Map co
// Handle NOT conditions
if (conditionsMap.containsKey("not") && conditionsMap.get("not") instanceof List) {
List notConditions = (List) conditionsMap.get("not");
- // NOT conditions are true if ALL conditions are false
- // This matches the TypeScript implementation: conditions.not.every(() => allConditionsAreMatched({and: conditions.not}, context) === false)
Map andCondition = new HashMap<>();
andCondition.put("and", notConditions);
return !allConditionsAreMatched(andCondition, context);
@@ -328,8 +327,7 @@ public boolean allSegmentsAreMatched(Object groupSegments, Map c
if (groupSegmentsMap.containsKey("not") && groupSegmentsMap.get("not") instanceof List) {
@SuppressWarnings("unchecked")
List notSegments = (List) groupSegmentsMap.get("not");
- // This matches the TypeScript implementation: groupSegments.not.every((groupSegment) => allSegmentsAreMatched(groupSegment, context) === false)
- return notSegments.stream().allMatch(s -> !allSegmentsAreMatched(s, context));
+ return !notSegments.stream().allMatch(s -> allSegmentsAreMatched(s, context));
}
}
diff --git a/src/main/java/com/featurevisor/sdk/Emitter.java b/src/main/java/com/featurevisor/sdk/Emitter.java
index ef40e1f..f89a0b9 100644
--- a/src/main/java/com/featurevisor/sdk/Emitter.java
+++ b/src/main/java/com/featurevisor/sdk/Emitter.java
@@ -19,7 +19,8 @@ public class Emitter {
public enum EventName {
DATAFILE_SET("datafile_set"),
CONTEXT_SET("context_set"),
- STICKY_SET("sticky_set");
+ STICKY_SET("sticky_set"),
+ ERROR("error");
private final String value;
diff --git a/src/main/java/com/featurevisor/sdk/Evaluate.java b/src/main/java/com/featurevisor/sdk/Evaluate.java
index 895afd7..f01afb4 100644
--- a/src/main/java/com/featurevisor/sdk/Evaluate.java
+++ b/src/main/java/com/featurevisor/sdk/Evaluate.java
@@ -11,24 +11,21 @@
* Main evaluation logic for Featurevisor SDK
* Handles the evaluation of features, variations, and variables
*/
-public class Evaluate {
+final class Evaluate {
/**
- * Evaluate with hooks
+ * Evaluate with modules
* @param opts The evaluation options
* @return The evaluation result
*/
- public static Evaluation evaluateWithHooks(EvaluateOptions opts) {
+ public static Evaluation evaluateWithModules(EvaluateOptions opts) {
try {
- HooksManager hooksManager = opts.getHooksManager();
- java.util.List hooks = hooksManager.getAll();
+ ModulesManager modulesManager = opts.getModulesManager();
- // run before hooks
+ // run before modules
EvaluateOptions options = opts;
- for (HooksManager.Hook hook : hooksManager.getAll()) {
- if (hook.getBefore() != null) {
- options = hook.getBefore().apply(options);
- }
+ if (modulesManager != null) {
+ options = modulesManager.executeBeforeModules(options);
}
// evaluate
@@ -48,11 +45,9 @@ public static Evaluation evaluateWithHooks(EvaluateOptions opts) {
evaluation.variableValue(options.getDefaultVariableValue());
}
- // run after hooks
- for (HooksManager.Hook hook : hooks) {
- if (hook.getAfter() != null) {
- evaluation = hook.getAfter().apply(evaluation, options);
- }
+ // run after modules
+ if (modulesManager != null) {
+ evaluation = modulesManager.executeAfterModules(evaluation, options);
}
return evaluation;
diff --git a/src/main/java/com/featurevisor/sdk/EvaluateByBucketing.java b/src/main/java/com/featurevisor/sdk/EvaluateByBucketing.java
index 19f8239..2eb532a 100644
--- a/src/main/java/com/featurevisor/sdk/EvaluateByBucketing.java
+++ b/src/main/java/com/featurevisor/sdk/EvaluateByBucketing.java
@@ -17,7 +17,7 @@
* EvaluateByBucketing for Featurevisor SDK
* Handles bucketing evaluation logic for feature flags
*/
-public class EvaluateByBucketing {
+final class EvaluateByBucketing {
/**
* Result of bucketing evaluation
@@ -69,7 +69,7 @@ public static EvaluateByBucketingResult evaluateByBucketing(
String variableKey = options.getVariableKey();
Map context = options.getContext();
Logger logger = options.getLogger();
- HooksManager hooksManager = options.getHooksManager();
+ ModulesManager modulesManager = options.getModulesManager();
DatafileReader datafileReader = options.getDatafileReader();
// Get bucket key
@@ -79,18 +79,18 @@ public static EvaluateByBucketingResult evaluateByBucketing(
.context(context)
.logger(logger));
- // Apply bucket key hooks
- if (hooksManager != null) {
- bucketKey = hooksManager.executeBucketKeyHooks(new HooksManager.ConfigureBucketKeyOptions(
+ // Apply bucket key modules
+ if (modulesManager != null) {
+ bucketKey = modulesManager.executeBucketKeyModules(new ModulesManager.ConfigureBucketKeyOptions(
featureKey, context, feature.getBucketBy(), bucketKey));
}
// Get bucket value
Integer bucketValue = Bucketer.getBucketedNumber(bucketKey);
- // Apply bucket value hooks
- if (hooksManager != null) {
- bucketValue = hooksManager.executeBucketValueHooks(new HooksManager.ConfigureBucketValueOptions(
+ // Apply bucket value modules
+ if (modulesManager != null) {
+ bucketValue = modulesManager.executeBucketValueModules(new ModulesManager.ConfigureBucketValueOptions(
featureKey, bucketKey, context, bucketValue));
}
diff --git a/src/main/java/com/featurevisor/sdk/EvaluateDisabled.java b/src/main/java/com/featurevisor/sdk/EvaluateDisabled.java
index 76a7f6a..060874f 100644
--- a/src/main/java/com/featurevisor/sdk/EvaluateDisabled.java
+++ b/src/main/java/com/featurevisor/sdk/EvaluateDisabled.java
@@ -7,7 +7,7 @@
* Evaluates disabled features and returns appropriate evaluation results
* This class handles the logic for evaluating disabled features, variables, and variations
*/
-public class EvaluateDisabled {
+final class EvaluateDisabled {
/**
* Evaluates a disabled feature and returns the appropriate evaluation result
diff --git a/src/main/java/com/featurevisor/sdk/EvaluateForced.java b/src/main/java/com/featurevisor/sdk/EvaluateForced.java
index f8bc65e..ea2b856 100644
--- a/src/main/java/com/featurevisor/sdk/EvaluateForced.java
+++ b/src/main/java/com/featurevisor/sdk/EvaluateForced.java
@@ -14,7 +14,7 @@
* Evaluates forced features and returns appropriate evaluation results
* This class handles the logic for evaluating forced features, variables, and variations
*/
-public class EvaluateForced {
+final class EvaluateForced {
/**
* Result of forced evaluation containing evaluation, force, and forceIndex
diff --git a/src/main/java/com/featurevisor/sdk/EvaluateNotFound.java b/src/main/java/com/featurevisor/sdk/EvaluateNotFound.java
index ffa18cf..a7ee38e 100644
--- a/src/main/java/com/featurevisor/sdk/EvaluateNotFound.java
+++ b/src/main/java/com/featurevisor/sdk/EvaluateNotFound.java
@@ -7,7 +7,7 @@
* Evaluates not found scenarios and returns appropriate evaluation results
* This class handles the logic for evaluating when features or variables are not found
*/
-public class EvaluateNotFound {
+final class EvaluateNotFound {
/**
* Result of not found evaluation containing evaluation, feature, and variableSchema
diff --git a/src/main/java/com/featurevisor/sdk/EvaluateOptions.java b/src/main/java/com/featurevisor/sdk/EvaluateOptions.java
index 8dd3faf..b933418 100644
--- a/src/main/java/com/featurevisor/sdk/EvaluateOptions.java
+++ b/src/main/java/com/featurevisor/sdk/EvaluateOptions.java
@@ -16,7 +16,7 @@ public class EvaluateOptions {
// Dependencies
private Map context;
private Logger logger;
- private HooksManager hooksManager;
+ private ModulesManager modulesManager;
private DatafileReader datafileReader;
// Override options
@@ -44,9 +44,9 @@ public EvaluateOptions(String type, String featureKey, String variableKey) {
public String getFeatureKey() { return featureKey; }
public String getVariableKey() { return variableKey; }
public Map getContext() { return context; }
- public Logger getLogger() { return logger; }
- public HooksManager getHooksManager() { return hooksManager; }
- public DatafileReader getDatafileReader() { return datafileReader; }
+ Logger getLogger() { return logger; }
+ ModulesManager getModulesManager() { return modulesManager; }
+ DatafileReader getDatafileReader() { return datafileReader; }
public Map getSticky() { return sticky; }
public String getDefaultVariationValue() { return defaultVariationValue; }
public Object getDefaultVariableValue() { return defaultVariableValue; }
@@ -57,9 +57,9 @@ public EvaluateOptions(String type, String featureKey, String variableKey) {
public void setFeatureKey(String featureKey) { this.featureKey = featureKey; }
public void setVariableKey(String variableKey) { this.variableKey = variableKey; }
public void setContext(Map context) { this.context = context; }
- public void setLogger(Logger logger) { this.logger = logger; }
- public void setHooksManager(HooksManager hooksManager) { this.hooksManager = hooksManager; }
- public void setDatafileReader(DatafileReader datafileReader) { this.datafileReader = datafileReader; }
+ void setLogger(Logger logger) { this.logger = logger; }
+ void setModulesManager(ModulesManager modulesManager) { this.modulesManager = modulesManager; }
+ void setDatafileReader(DatafileReader datafileReader) { this.datafileReader = datafileReader; }
public void setSticky(Map sticky) { this.sticky = sticky; }
public void setDefaultVariationValue(String defaultVariationValue) { this.defaultVariationValue = defaultVariationValue; }
public void setDefaultVariableValue(Object defaultVariableValue) { this.defaultVariableValue = defaultVariableValue; }
@@ -86,17 +86,17 @@ public EvaluateOptions context(Map context) {
return this;
}
- public EvaluateOptions logger(Logger logger) {
+ EvaluateOptions logger(Logger logger) {
this.logger = logger;
return this;
}
- public EvaluateOptions hooksManager(HooksManager hooksManager) {
- this.hooksManager = hooksManager;
+ EvaluateOptions modulesManager(ModulesManager modulesManager) {
+ this.modulesManager = modulesManager;
return this;
}
- public EvaluateOptions datafileReader(DatafileReader datafileReader) {
+ EvaluateOptions datafileReader(DatafileReader datafileReader) {
this.datafileReader = datafileReader;
return this;
}
@@ -132,7 +132,7 @@ public EvaluateOptions copy() {
copy.variableKey = this.variableKey;
copy.context = this.context;
copy.logger = this.logger;
- copy.hooksManager = this.hooksManager;
+ copy.modulesManager = this.modulesManager;
copy.datafileReader = this.datafileReader;
copy.sticky = this.sticky;
copy.defaultVariationValue = this.defaultVariationValue;
@@ -160,7 +160,7 @@ public String toString() {
", variableKey='" + variableKey + '\'' +
", context=" + context +
", logger=" + logger +
- ", hooksManager=" + hooksManager +
+ ", modulesManager=" + modulesManager +
", datafileReader=" + datafileReader +
", sticky=" + sticky +
", defaultVariationValue=" + defaultVariationValue +
diff --git a/src/main/java/com/featurevisor/sdk/EvaluateSticky.java b/src/main/java/com/featurevisor/sdk/EvaluateSticky.java
index 95b4a87..1547df4 100644
--- a/src/main/java/com/featurevisor/sdk/EvaluateSticky.java
+++ b/src/main/java/com/featurevisor/sdk/EvaluateSticky.java
@@ -6,7 +6,7 @@
* Evaluates sticky features and returns appropriate evaluation results
* This class handles the logic for evaluating sticky features, variables, and variations
*/
-public class EvaluateSticky {
+final class EvaluateSticky {
/**
* Evaluates sticky scenarios and returns the appropriate evaluation result
diff --git a/src/main/java/com/featurevisor/sdk/Events.java b/src/main/java/com/featurevisor/sdk/Events.java
index a47a8ad..825c77b 100644
--- a/src/main/java/com/featurevisor/sdk/Events.java
+++ b/src/main/java/com/featurevisor/sdk/Events.java
@@ -60,7 +60,8 @@ public static Emitter.EventDetails getParamsForStickySetEvent(
*/
public static Emitter.EventDetails getParamsForDatafileSetEvent(
DatafileContent previousDatafileContent,
- DatafileContent newDatafileContent) {
+ DatafileContent newDatafileContent,
+ boolean replace) {
if (previousDatafileContent == null) {
previousDatafileContent = new DatafileContent();
@@ -131,6 +132,7 @@ public static Emitter.EventDetails getParamsForDatafileSetEvent(
details.put("previousRevision", previousRevision);
details.put("revisionChanged", !(previousRevision == null ? newRevision == null : previousRevision.equals(newRevision)));
details.put("features", uniqueAffectedFeatures);
+ details.put("replaced", replace);
return details;
}
diff --git a/src/main/java/com/featurevisor/sdk/Featurevisor.java b/src/main/java/com/featurevisor/sdk/Featurevisor.java
index b7bb5b0..f7f17b4 100644
--- a/src/main/java/com/featurevisor/sdk/Featurevisor.java
+++ b/src/main/java/com/featurevisor/sdk/Featurevisor.java
@@ -12,6 +12,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.ArrayList;
+import java.util.UUID;
/**
* Main Featurevisor SDK class
@@ -24,11 +25,14 @@ public class Featurevisor {
private Map context = new HashMap<>();
private Logger logger;
private Map sticky;
+ private FeaturevisorDiagnosticHandler onDiagnostic;
+ private boolean closed = false;
// internally created
private DatafileReader datafileReader;
- private HooksManager hooksManager;
+ private ModulesManager modulesManager;
private Emitter emitter;
+ private final List moduleDiagnosticSubscriptions = new ArrayList<>();
private static final DatafileContent emptyDatafile;
@@ -43,114 +47,100 @@ public class Featurevisor {
/**
* Factory methods
*/
- public static Featurevisor createInstance(Options options) {
+ public static Featurevisor createFeaturevisor(FeaturevisorOptions options) {
if (options == null) {
- options = new Options();
+ options = new FeaturevisorOptions();
}
return new Featurevisor(options);
}
- public static Featurevisor createInstance() {
- return createInstance(new Options());
- }
-
- public static Featurevisor createInstance(DatafileContent datafile) {
- return createInstance(new Options().datafile(datafile));
- }
-
- public static Featurevisor createInstance(String datafileString) {
- return createInstance(new Options().datafileString(datafileString));
- }
-
- public static Featurevisor createInstance(Map context) {
- return createInstance(new Options().context(context));
- }
-
- public static Featurevisor createInstance(Logger.LogLevel logLevel) {
- return createInstance(new Options().logLevel(logLevel));
- }
-
- public static Featurevisor createInstance(Logger logger) {
- return createInstance(new Options().logger(logger));
- }
-
- public static Featurevisor createInstance(Map sticky, boolean isSticky) {
- if (isSticky) {
- return createInstance(new Options().sticky(sticky));
- } else {
- return createInstance(new Options().context(sticky));
- }
+ public static Featurevisor createFeaturevisor() {
+ return createFeaturevisor(new FeaturevisorOptions());
}
/**
* Options for creating an instance
*/
- public static class Options {
+ public static class FeaturevisorOptions {
private DatafileContent datafile;
private String datafileString;
private Map context;
- private Logger.LogLevel logLevel;
- private Logger logger;
+ private FeaturevisorLogLevel logLevel;
private Map sticky;
- private List hooks;
+ private List modules;
+ private FeaturevisorDiagnosticHandler onDiagnostic;
- public Options() {}
+ public FeaturevisorOptions() {}
// Getters
public DatafileContent getDatafile() { return datafile; }
public String getDatafileString() { return datafileString; }
public Map getContext() { return context; }
- public Logger.LogLevel getLogLevel() { return logLevel; }
- public Logger getLogger() { return logger; }
+ public FeaturevisorLogLevel getLogLevel() { return logLevel; }
public Map getSticky() { return sticky; }
- public List getHooks() { return hooks; }
+ public List getModules() { return modules; }
+ public FeaturevisorDiagnosticHandler getOnDiagnostic() { return onDiagnostic; }
// Setters
public void setDatafile(DatafileContent datafile) { this.datafile = datafile; }
public void setDatafileString(String datafileString) { this.datafileString = datafileString; }
public void setContext(Map context) { this.context = context; }
- public void setLogLevel(Logger.LogLevel logLevel) { this.logLevel = logLevel; }
- public void setLogger(Logger logger) { this.logger = logger; }
+ public void setLogLevel(FeaturevisorLogLevel logLevel) { this.logLevel = logLevel; }
public void setSticky(Map sticky) { this.sticky = sticky; }
- public void setHooks(List hooks) { this.hooks = hooks; }
+ public void setModules(List modules) { this.modules = modules; }
+ public void setOnDiagnostic(FeaturevisorDiagnosticHandler onDiagnostic) { this.onDiagnostic = onDiagnostic; }
// Builder pattern methods
- public Options datafile(DatafileContent datafile) {
+ public FeaturevisorOptions datafile(DatafileContent datafile) {
this.datafile = datafile;
return this;
}
- public Options datafileString(String datafileString) {
+ public FeaturevisorOptions datafileString(String datafileString) {
this.datafileString = datafileString;
return this;
}
- public Options context(Map context) {
+ public FeaturevisorOptions context(Map context) {
this.context = context;
return this;
}
- public Options logLevel(Logger.LogLevel logLevel) {
+ public FeaturevisorOptions logLevel(FeaturevisorLogLevel logLevel) {
this.logLevel = logLevel;
return this;
}
- public Options logger(Logger logger) {
- this.logger = logger;
+ public FeaturevisorOptions sticky(Map sticky) {
+ this.sticky = sticky;
return this;
}
- public Options sticky(Map sticky) {
- this.sticky = sticky;
+ public FeaturevisorOptions modules(List modules) {
+ this.modules = modules;
return this;
}
- public Options hooks(List hooks) {
- this.hooks = hooks;
+ public FeaturevisorOptions onDiagnostic(FeaturevisorDiagnosticHandler onDiagnostic) {
+ this.onDiagnostic = onDiagnostic;
return this;
}
}
+ private static class ModuleDiagnosticSubscription {
+ private final String id;
+ private final String moduleId;
+ private final FeaturevisorDiagnosticHandler handler;
+ private final FeaturevisorLogLevel logLevel;
+
+ ModuleDiagnosticSubscription(String moduleId, FeaturevisorDiagnosticHandler handler, FeaturevisorLogLevel logLevel) {
+ this.id = UUID.randomUUID().toString();
+ this.moduleId = moduleId;
+ this.handler = handler;
+ this.logLevel = logLevel != null ? logLevel : FeaturevisorLogLevel.INFO;
+ }
+ }
+
/**
* Options for overriding evaluation behavior
*/
@@ -163,23 +153,18 @@ public static class OverrideOptions {
public OverrideOptions() {}
// Getters
- public Map getSticky() { return sticky; }
+ Map getInternalSticky() { return sticky; }
public String getDefaultVariationValue() { return defaultVariationValue; }
public Object getDefaultVariableValue() { return defaultVariableValue; }
public Evaluation getFlagEvaluation() { return flagEvaluation; }
// Setters
- public void setSticky(Map sticky) { this.sticky = sticky; }
+ void setInternalSticky(Map sticky) { this.sticky = sticky; }
public void setDefaultVariationValue(String defaultVariationValue) { this.defaultVariationValue = defaultVariationValue; }
public void setDefaultVariableValue(Object defaultVariableValue) { this.defaultVariableValue = defaultVariableValue; }
public void setFlagEvaluation(Evaluation flagEvaluation) { this.flagEvaluation = flagEvaluation; }
// Builder pattern methods
- public OverrideOptions sticky(Map sticky) {
- this.sticky = sticky;
- return this;
- }
-
public OverrideOptions defaultVariationValue(String defaultVariationValue) {
this.defaultVariationValue = defaultVariationValue;
return this;
@@ -196,75 +181,241 @@ public OverrideOptions flagEvaluation(Evaluation flagEvaluation) {
}
}
+ /** Options used only when spawning a child instance. */
+ public static class SpawnOptions {
+ private Map sticky;
+
+ public Map getSticky() { return sticky; }
+ public void setSticky(Map sticky) { this.sticky = sticky; }
+ public SpawnOptions sticky(Map sticky) {
+ this.sticky = sticky;
+ return this;
+ }
+ }
+
/**
* Constructor
*/
- public Featurevisor(Options options) {
+ private Featurevisor(FeaturevisorOptions options) {
// from options
if (options.getContext() != null) {
this.context = new HashMap<>(options.getContext());
}
- this.logger = options.getLogger() != null ?
- options.getLogger() :
- Logger.createLogger(new Logger.CreateLoggerOptions().level(
- options.getLogLevel() != null ? options.getLogLevel() : Logger.LogLevel.INFO
- ));
-
- this.hooksManager = new HooksManager(new HooksManager.HooksManagerOptions(this.logger)
- .hooks(options.getHooks() != null ? options.getHooks() : new ArrayList<>()));
+ this.logger = Logger.createLogger(new Logger.CreateLoggerOptions()
+ .level(options.getLogLevel() != null ? options.getLogLevel() : FeaturevisorLogLevel.INFO)
+ .handler((level, message, details) -> {
+ Map normalizedDetails = details != null ? details : new HashMap<>();
+ Object reason = normalizedDetails.get("reason");
+ String code = reason != null ? reason.toString() : message;
+ if ("feature is deprecated".equals(message)) code = "deprecated_feature";
+ if ("variable is deprecated".equals(message)) code = "deprecated_variable";
+ if ("feature not found".equals(message)) code = "feature_not_found";
+ if ("variable schema not found".equals(message)) code = "variable_not_found";
+ if ("no variations".equals(message)) code = "no_variations";
+ if ("invalid bucketBy".equals(message)) code = "invalid_bucket_by";
+ reportDiagnostic(new FeaturevisorDiagnostic(level, code, message).details(normalizedDetails), null);
+ }));
this.emitter = new Emitter();
this.sticky = options.getSticky();
+ this.onDiagnostic = options.getOnDiagnostic();
// datafile
this.datafileReader = new DatafileReader(new DatafileReader.DatafileReaderOptions()
.datafile(emptyDatafile)
.logger(this.logger));
+ this.modulesManager = new ModulesManager(new ModulesManager.ModulesManagerOptions()
+ .modules(options.getModules() != null ? options.getModules() : new ArrayList<>())
+ .diagnosticReporter(this::reportDiagnostic)
+ .moduleApiFactory(this::createModuleApi)
+ .clearModuleDiagnosticSubscriptions(this::clearModuleDiagnosticSubscriptions));
+
if (options.getDatafile() != null) {
- this.datafileReader = new DatafileReader(new DatafileReader.DatafileReaderOptions()
- .datafile(options.getDatafile())
- .logger(this.logger));
+ setDatafile(options.getDatafile(), true);
} else if (options.getDatafileString() != null) {
- try {
- DatafileContent datafile = DatafileContent.fromJson(options.getDatafileString());
- this.datafileReader = new DatafileReader(new DatafileReader.DatafileReaderOptions()
- .datafile(datafile)
- .logger(this.logger));
- } catch (Exception e) {
- this.logger.error("could not parse datafile string", Map.of("error", e.getMessage()));
- }
+ setDatafile(options.getDatafileString(), true);
}
- this.logger.info("Featurevisor SDK initialized", null);
+ reportDiagnostic(new FeaturevisorDiagnostic()
+ .level(FeaturevisorLogLevel.INFO)
+ .code("sdk_initialized")
+ .message("SDK initialized"), null);
}
/**
* Set log level
*/
- public void setLogLevel(Logger.LogLevel level) {
+ public void setLogLevel(FeaturevisorLogLevel level) {
this.logger.setLevel(level);
}
+ private FeaturevisorModuleApi createModuleApi(FeaturevisorModule module) {
+ return new FeaturevisorModuleApi() {
+ @Override
+ public String getRevision() {
+ return Featurevisor.this.getRevision();
+ }
+
+ @Override
+ public Runnable onDiagnostic(FeaturevisorDiagnosticHandler handler) {
+ return onDiagnostic(handler, new FeaturevisorModuleDiagnosticOptions());
+ }
+
+ @Override
+ public Runnable onDiagnostic(FeaturevisorDiagnosticHandler handler, FeaturevisorModuleDiagnosticOptions options) {
+ if (handler == null) {
+ return () -> {};
+ }
+
+ ModuleDiagnosticSubscription subscription = new ModuleDiagnosticSubscription(
+ module.getId(),
+ handler,
+ options != null ? options.getLogLevel() : FeaturevisorLogLevel.INFO
+ );
+ moduleDiagnosticSubscriptions.add(subscription);
+
+ return () -> moduleDiagnosticSubscriptions.removeIf(item -> item.id.equals(subscription.id));
+ }
+
+ @Override
+ public void reportDiagnostic(FeaturevisorDiagnostic diagnostic) {
+ Featurevisor.this.reportDiagnostic(diagnostic, module);
+ }
+ };
+ }
+
+ private void clearModuleDiagnosticSubscriptions(FeaturevisorModule module) {
+ if (module == null) {
+ return;
+ }
+ moduleDiagnosticSubscriptions.removeIf(item -> item.moduleId.equals(module.getId()));
+ }
+
+ private boolean shouldReport(FeaturevisorLogLevel diagnosticLevel, FeaturevisorLogLevel subscriptionLevel) {
+ return getLogLevelIndex(diagnosticLevel) >= getLogLevelIndex(subscriptionLevel);
+ }
+
+ private int getLogLevelIndex(FeaturevisorLogLevel level) {
+ if (level == null) {
+ level = FeaturevisorLogLevel.INFO;
+ }
+ switch (level) {
+ case DEBUG: return 0;
+ case INFO: return 1;
+ case WARN: return 2;
+ case ERROR: return 3;
+ case FATAL: return 4;
+ default: return 1;
+ }
+ }
+
+ private Map diagnosticDetails(FeaturevisorDiagnostic diagnostic) {
+ Map details = new HashMap<>();
+ details.put("code", diagnostic.getCode());
+ if (diagnostic.getModule() != null) {
+ details.put("module", diagnostic.getModule());
+ }
+ if (diagnostic.getModuleName() != null) {
+ details.put("moduleName", diagnostic.getModuleName());
+ }
+ if (diagnostic.getOriginalError() != null) {
+ details.put("originalError", diagnostic.getOriginalError());
+ }
+ if (diagnostic.getDetails() != null) {
+ details.putAll(diagnostic.getDetails());
+ }
+ return details;
+ }
+
+ void reportDiagnostic(FeaturevisorDiagnostic diagnostic) {
+ reportDiagnostic(diagnostic, null);
+ }
+
+ void reportDiagnostic(FeaturevisorDiagnostic diagnostic, FeaturevisorModule sourceModule) {
+ if (diagnostic == null) {
+ return;
+ }
+ if (diagnostic.getLevel() == null) {
+ diagnostic.setLevel(FeaturevisorLogLevel.INFO);
+ }
+
+ if (sourceModule != null && sourceModule.getName() != null) {
+ diagnostic.setModule(sourceModule.getName());
+ }
+
+ for (ModuleDiagnosticSubscription subscription : new ArrayList<>(moduleDiagnosticSubscriptions)) {
+ if (sourceModule != null && sourceModule.getId().equals(subscription.moduleId)) {
+ continue;
+ }
+ if (shouldReport(diagnostic.getLevel(), subscription.logLevel)) {
+ try {
+ subscription.handler.handle(diagnostic);
+ } catch (Throwable error) {
+ System.err.println("[Featurevisor] Diagnostic handler failed: " + error);
+ }
+ }
+ }
+
+ if (onDiagnostic != null) {
+ if (shouldReport(diagnostic.getLevel(), this.logger.getLevel())) {
+ try {
+ onDiagnostic.handle(diagnostic);
+ } catch (Throwable error) {
+ System.err.println("[Featurevisor] Diagnostic handler failed: " + error);
+ }
+ }
+ } else if (shouldReport(diagnostic.getLevel(), this.logger.getLevel())) {
+ Logger.writeToConsole(diagnostic.getLevel(), diagnostic.getMessage(), diagnosticDetails(diagnostic));
+ }
+
+ if (FeaturevisorLogLevel.ERROR.equals(diagnostic.getLevel())) {
+ this.emitter.trigger(Emitter.EventName.ERROR, new Emitter.EventDetails(Map.of("diagnostic", diagnostic)));
+ }
+ }
+
/**
* Set datafile
*/
public void setDatafile(DatafileContent datafile) {
+ setDatafile(datafile, false);
+ }
+
+ public void setDatafile(DatafileContent datafile, boolean replace) {
+ if (this.closed) {
+ return;
+ }
try {
+ if (datafile == null) {
+ throw new IllegalArgumentException("Datafile must be an object");
+ }
+ if (datafile.getSchemaVersion() == null || datafile.getRevision() == null ||
+ datafile.getSegments() == null || datafile.getFeatures() == null) {
+ throw new IllegalArgumentException("Invalid datafile");
+ }
+ DatafileContent nextDatafile = replace ? datafile : mergeDatafiles(this.datafileReader.getDatafile(), datafile);
DatafileReader newDatafileReader = new DatafileReader(new DatafileReader.DatafileReaderOptions()
- .datafile(datafile)
+ .datafile(nextDatafile)
.logger(this.logger));
Emitter.EventDetails details = Events.getParamsForDatafileSetEvent(
- this.datafileReader.getDatafile(), newDatafileReader.getDatafile());
+ this.datafileReader.getDatafile(), newDatafileReader.getDatafile(), replace);
this.datafileReader = newDatafileReader;
- this.logger.info("datafile set", details);
+ reportDiagnostic(new FeaturevisorDiagnostic()
+ .level(FeaturevisorLogLevel.INFO)
+ .code("datafile_set")
+ .message("Datafile set")
+ .details(details), null);
this.emitter.trigger(Emitter.EventName.DATAFILE_SET, details);
} catch (Exception e) {
- this.logger.error("could not parse datafile", Map.of("error", e.getMessage()));
+ reportDiagnostic(new FeaturevisorDiagnostic()
+ .level(FeaturevisorLogLevel.ERROR)
+ .code("invalid_datafile")
+ .message("Could not parse datafile")
+ .originalError(e), null);
}
}
@@ -272,14 +423,56 @@ public void setDatafile(DatafileContent datafile) {
* Set datafile from string
*/
public void setDatafile(String datafileString) {
+ setDatafile(datafileString, false);
+ }
+
+ public void setDatafile(String datafileString, boolean replace) {
try {
DatafileContent datafile = DatafileContent.fromJson(datafileString);
- setDatafile(datafile);
+ setDatafile(datafile, replace);
} catch (Exception e) {
- this.logger.error("could not parse datafile string", Map.of("error", e.getMessage()));
+ reportDiagnostic(new FeaturevisorDiagnostic()
+ .level(FeaturevisorLogLevel.ERROR)
+ .code("invalid_datafile")
+ .message("Could not parse datafile")
+ .originalError(e), null);
}
}
+ private DatafileContent mergeDatafiles(DatafileContent previous, DatafileContent incoming) {
+ if (previous == null) {
+ previous = emptyDatafile;
+ }
+ if (incoming == null) {
+ incoming = emptyDatafile;
+ }
+
+ DatafileContent merged = new DatafileContent();
+ merged.setSchemaVersion(incoming.getSchemaVersion());
+ merged.setRevision(incoming.getRevision());
+ merged.setFeaturevisorVersion(incoming.getFeaturevisorVersion());
+
+ Map segments = new HashMap<>();
+ if (previous.getSegments() != null) {
+ segments.putAll(previous.getSegments());
+ }
+ if (incoming.getSegments() != null) {
+ segments.putAll(incoming.getSegments());
+ }
+ merged.setSegments(segments);
+
+ Map features = new HashMap<>();
+ if (previous.getFeatures() != null) {
+ features.putAll(previous.getFeatures());
+ }
+ if (incoming.getFeatures() != null) {
+ features.putAll(incoming.getFeatures());
+ }
+ merged.setFeatures(features);
+
+ return merged;
+ }
+
/**
* Set sticky features
*/
@@ -297,7 +490,11 @@ public void setSticky(Map sticky, boolean replace) {
Emitter.EventDetails params = Events.getParamsForStickySetEvent(
previousStickyFeatures, this.sticky, replace);
- this.logger.info("sticky features set", params);
+ reportDiagnostic(new FeaturevisorDiagnostic()
+ .level(FeaturevisorLogLevel.INFO)
+ .code("sticky_set")
+ .message("Sticky features set")
+ .details(params), null);
this.emitter.trigger(Emitter.EventName.STICKY_SET, params);
}
@@ -308,6 +505,26 @@ public String getRevision() {
return this.datafileReader.getRevision();
}
+ public String getSchemaVersion() {
+ return this.datafileReader.getSchemaVersion();
+ }
+
+ public Segment getSegment(String segmentKey) {
+ return this.datafileReader.getSegment(segmentKey);
+ }
+
+ public List getFeatureKeys() {
+ return this.datafileReader.getFeatureKeys();
+ }
+
+ public List getVariableKeys(String featureKey) {
+ return this.datafileReader.getVariableKeys(featureKey);
+ }
+
+ public boolean hasVariations(String featureKey) {
+ return this.datafileReader.hasVariations(featureKey);
+ }
+
/**
* Get feature
*/
@@ -316,10 +533,14 @@ public Feature getFeature(String featureKey) {
}
/**
- * Add hook
+ * Add module
*/
- public Runnable addHook(HooksManager.Hook hook) {
- return this.hooksManager.add(hook);
+ public Runnable addModule(FeaturevisorModule module) {
+ return this.modulesManager.add(module);
+ }
+
+ public void removeModule(String name) {
+ this.modulesManager.remove(name);
}
/**
@@ -333,6 +554,9 @@ public Emitter.UnsubscribeFunction on(Emitter.EventName eventName, Emitter.Event
* Close instance
*/
public void close() {
+ this.closed = true;
+ this.modulesManager.closeAll();
+ this.moduleDiagnosticSubscriptions.clear();
this.emitter.clearAll();
}
@@ -352,7 +576,11 @@ public void setContext(Map context, boolean replace) {
eventDetails.put("replaced", replace);
this.emitter.trigger(Emitter.EventName.CONTEXT_SET, eventDetails);
- this.logger.debug(replace ? "context replaced" : "context updated", eventDetails);
+ reportDiagnostic(new FeaturevisorDiagnostic()
+ .level(FeaturevisorLogLevel.DEBUG)
+ .code("context_set")
+ .message(replace ? "Context replaced" : "Context updated")
+ .details(eventDetails), null);
}
public Map getContext(Map context) {
@@ -371,12 +599,12 @@ public Map getContext() {
/**
* Spawn child instance
*/
- public ChildInstance spawn(Map context, OverrideOptions options) {
+ public ChildInstance spawn(Map context, SpawnOptions options) {
if (context == null) {
context = new HashMap<>();
}
if (options == null) {
- options = new OverrideOptions();
+ options = new SpawnOptions();
}
return new ChildInstance(this, getContext(context), options.getSticky());
@@ -401,20 +629,12 @@ private EvaluateOptions getEvaluationDependencies(Map context, O
options = new OverrideOptions();
}
- Map mergedSticky = this.sticky;
- if (options.getSticky() != null) {
- if (this.sticky != null) {
- mergedSticky = new HashMap<>(this.sticky);
- mergedSticky.putAll(options.getSticky());
- } else {
- mergedSticky = options.getSticky();
- }
- }
+ Map mergedSticky = options.getInternalSticky() != null ? options.getInternalSticky() : this.sticky;
return new EvaluateOptions()
.context(getContext(context))
.logger(this.logger)
- .hooksManager(this.hooksManager)
+ .modulesManager(this.modulesManager)
.datafileReader(this.datafileReader)
.sticky(mergedSticky)
.defaultVariationValue(options.getDefaultVariationValue())
@@ -427,7 +647,7 @@ public Evaluation evaluateFlag(String featureKey, Map context, O
.type(Evaluation.TYPE_FLAG)
.featureKey(featureKey);
- return Evaluate.evaluateWithHooks(evaluateOptions);
+ return Evaluate.evaluateWithModules(evaluateOptions);
}
public Evaluation evaluateFlag(String featureKey, Map context) {
@@ -464,7 +684,7 @@ public Evaluation evaluateVariation(String featureKey, Map conte
.type(Evaluation.TYPE_VARIATION)
.featureKey(featureKey);
- return Evaluate.evaluateWithHooks(evaluateOptions);
+ return Evaluate.evaluateWithModules(evaluateOptions);
}
public Evaluation evaluateVariation(String featureKey, Map context) {
@@ -511,7 +731,7 @@ public Evaluation evaluateVariable(String featureKey, String variableKey, Map context) {
@@ -833,10 +1053,10 @@ public EvaluatedFeatures getAllEvaluations(Map context, List details = new HashMap<>();
+
+ public FeaturevisorDiagnostic() {}
+
+ public FeaturevisorDiagnostic(FeaturevisorLogLevel level, String code, String message) {
+ this.level = level;
+ this.code = code;
+ this.message = message;
+ }
+
+ public FeaturevisorLogLevel getLevel() { return level; }
+ public String getCode() { return code; }
+ public String getMessage() { return message; }
+ public String getModule() { return module; }
+ public String getModuleName() { return moduleName; }
+ public Object getOriginalError() { return originalError; }
+ public Map getDetails() { return details; }
+
+ public void setLevel(FeaturevisorLogLevel level) { this.level = level; }
+ public void setCode(String code) { this.code = code; }
+ public void setMessage(String message) { this.message = message; }
+ public void setModule(String module) { this.module = module; }
+ public void setModuleName(String moduleName) { this.moduleName = moduleName; }
+ public void setOriginalError(Object originalError) { this.originalError = originalError; }
+ public void setDetails(Map details) {
+ this.details = details != null ? details : new HashMap<>();
+ }
+
+ public FeaturevisorDiagnostic level(FeaturevisorLogLevel level) {
+ this.level = level;
+ return this;
+ }
+
+ public FeaturevisorDiagnostic code(String code) {
+ this.code = code;
+ return this;
+ }
+
+ public FeaturevisorDiagnostic message(String message) {
+ this.message = message;
+ return this;
+ }
+
+ public FeaturevisorDiagnostic module(String module) {
+ this.module = module;
+ return this;
+ }
+
+ public FeaturevisorDiagnostic moduleName(String moduleName) {
+ this.moduleName = moduleName;
+ return this;
+ }
+
+ public FeaturevisorDiagnostic originalError(Object originalError) {
+ this.originalError = originalError;
+ return this;
+ }
+
+ public FeaturevisorDiagnostic details(Map details) {
+ setDetails(details);
+ return this;
+ }
+}
diff --git a/src/main/java/com/featurevisor/sdk/FeaturevisorDiagnosticHandler.java b/src/main/java/com/featurevisor/sdk/FeaturevisorDiagnosticHandler.java
new file mode 100644
index 0000000..017aec4
--- /dev/null
+++ b/src/main/java/com/featurevisor/sdk/FeaturevisorDiagnosticHandler.java
@@ -0,0 +1,6 @@
+package com.featurevisor.sdk;
+
+@FunctionalInterface
+public interface FeaturevisorDiagnosticHandler {
+ void handle(FeaturevisorDiagnostic diagnostic);
+}
diff --git a/src/main/java/com/featurevisor/sdk/FeaturevisorDiagnosticReporter.java b/src/main/java/com/featurevisor/sdk/FeaturevisorDiagnosticReporter.java
new file mode 100644
index 0000000..976fcb0
--- /dev/null
+++ b/src/main/java/com/featurevisor/sdk/FeaturevisorDiagnosticReporter.java
@@ -0,0 +1,6 @@
+package com.featurevisor.sdk;
+
+@FunctionalInterface
+interface FeaturevisorDiagnosticReporter {
+ void report(FeaturevisorDiagnostic diagnostic, FeaturevisorModule sourceModule);
+}
diff --git a/src/main/java/com/featurevisor/sdk/FeaturevisorLogLevel.java b/src/main/java/com/featurevisor/sdk/FeaturevisorLogLevel.java
new file mode 100644
index 0000000..37d5252
--- /dev/null
+++ b/src/main/java/com/featurevisor/sdk/FeaturevisorLogLevel.java
@@ -0,0 +1,12 @@
+package com.featurevisor.sdk;
+
+/**
+ * Diagnostic verbosity for a Featurevisor instance.
+ */
+public enum FeaturevisorLogLevel {
+ DEBUG,
+ INFO,
+ WARN,
+ ERROR,
+ FATAL
+}
diff --git a/src/main/java/com/featurevisor/sdk/FeaturevisorModule.java b/src/main/java/com/featurevisor/sdk/FeaturevisorModule.java
new file mode 100644
index 0000000..ef69ae8
--- /dev/null
+++ b/src/main/java/com/featurevisor/sdk/FeaturevisorModule.java
@@ -0,0 +1,68 @@
+package com.featurevisor.sdk;
+
+import java.util.UUID;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+public class FeaturevisorModule {
+ private final String id = UUID.randomUUID().toString();
+ private String name;
+ private Consumer setup;
+ private Function before;
+ private ModulesManager.ConfigureBucketKey bucketKey;
+ private ModulesManager.ConfigureBucketValue bucketValue;
+ private BiFunction after;
+ private Runnable close;
+
+ public FeaturevisorModule(String name) {
+ this.name = name;
+ }
+
+ public String getId() { return id; }
+ public String getName() { return name; }
+ public Consumer getSetup() { return setup; }
+ public Function getBefore() { return before; }
+ public ModulesManager.ConfigureBucketKey getBucketKey() { return bucketKey; }
+ public ModulesManager.ConfigureBucketValue getBucketValue() { return bucketValue; }
+ public BiFunction getAfter() { return after; }
+ 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 setBucketKey(ModulesManager.ConfigureBucketKey bucketKey) { this.bucketKey = bucketKey; }
+ public void setBucketValue(ModulesManager.ConfigureBucketValue bucketValue) { this.bucketValue = bucketValue; }
+ public void setAfter(BiFunction after) { this.after = after; }
+ public void setClose(Runnable close) { this.close = close; }
+
+ public FeaturevisorModule setup(Consumer setup) {
+ this.setup = setup;
+ return this;
+ }
+
+ public FeaturevisorModule before(Function before) {
+ this.before = before;
+ return this;
+ }
+
+ public FeaturevisorModule bucketKey(ModulesManager.ConfigureBucketKey bucketKey) {
+ this.bucketKey = bucketKey;
+ return this;
+ }
+
+ public FeaturevisorModule bucketValue(ModulesManager.ConfigureBucketValue bucketValue) {
+ this.bucketValue = bucketValue;
+ return this;
+ }
+
+ public FeaturevisorModule after(BiFunction after) {
+ this.after = after;
+ return this;
+ }
+
+ public FeaturevisorModule close(Runnable close) {
+ this.close = close;
+ return this;
+ }
+}
diff --git a/src/main/java/com/featurevisor/sdk/FeaturevisorModuleApi.java b/src/main/java/com/featurevisor/sdk/FeaturevisorModuleApi.java
new file mode 100644
index 0000000..d16cb3d
--- /dev/null
+++ b/src/main/java/com/featurevisor/sdk/FeaturevisorModuleApi.java
@@ -0,0 +1,11 @@
+package com.featurevisor.sdk;
+
+public interface FeaturevisorModuleApi {
+ String getRevision();
+
+ Runnable onDiagnostic(FeaturevisorDiagnosticHandler handler);
+
+ Runnable onDiagnostic(FeaturevisorDiagnosticHandler handler, FeaturevisorModuleDiagnosticOptions options);
+
+ void reportDiagnostic(FeaturevisorDiagnostic diagnostic);
+}
diff --git a/src/main/java/com/featurevisor/sdk/FeaturevisorModuleApiFactory.java b/src/main/java/com/featurevisor/sdk/FeaturevisorModuleApiFactory.java
new file mode 100644
index 0000000..9905b8b
--- /dev/null
+++ b/src/main/java/com/featurevisor/sdk/FeaturevisorModuleApiFactory.java
@@ -0,0 +1,6 @@
+package com.featurevisor.sdk;
+
+@FunctionalInterface
+interface FeaturevisorModuleApiFactory {
+ FeaturevisorModuleApi create(FeaturevisorModule module);
+}
diff --git a/src/main/java/com/featurevisor/sdk/FeaturevisorModuleDiagnosticOptions.java b/src/main/java/com/featurevisor/sdk/FeaturevisorModuleDiagnosticOptions.java
new file mode 100644
index 0000000..34e5309
--- /dev/null
+++ b/src/main/java/com/featurevisor/sdk/FeaturevisorModuleDiagnosticOptions.java
@@ -0,0 +1,24 @@
+package com.featurevisor.sdk;
+
+public class FeaturevisorModuleDiagnosticOptions {
+ private FeaturevisorLogLevel logLevel = FeaturevisorLogLevel.INFO;
+
+ public FeaturevisorModuleDiagnosticOptions() {}
+
+ public FeaturevisorModuleDiagnosticOptions(FeaturevisorLogLevel logLevel) {
+ this.logLevel = logLevel;
+ }
+
+ public FeaturevisorLogLevel getLogLevel() {
+ return logLevel;
+ }
+
+ public void setLogLevel(FeaturevisorLogLevel logLevel) {
+ this.logLevel = logLevel;
+ }
+
+ public FeaturevisorModuleDiagnosticOptions logLevel(FeaturevisorLogLevel logLevel) {
+ this.logLevel = logLevel;
+ return this;
+ }
+}
diff --git a/src/main/java/com/featurevisor/sdk/Helpers.java b/src/main/java/com/featurevisor/sdk/Helpers.java
index 6fa1950..6eacb6c 100644
--- a/src/main/java/com/featurevisor/sdk/Helpers.java
+++ b/src/main/java/com/featurevisor/sdk/Helpers.java
@@ -7,7 +7,7 @@
* Helper utilities for Featurevisor SDK
* Provides common utility functions used throughout the SDK
*/
-public class Helpers {
+final class Helpers {
/**
* Get value by type, converting the value to the specified type if possible
@@ -26,31 +26,35 @@ public static T getValueByType(Object value, String fieldType) {
case "string":
return (T) (value instanceof String ? value : null);
case "integer":
- if (value instanceof Number) {
+ if (value instanceof Byte || value instanceof Short || value instanceof Integer) {
return (T) Integer.valueOf(((Number) value).intValue());
- } else if (value instanceof String) {
- return (T) Integer.valueOf((String) value);
+ }
+ if (value instanceof Long) {
+ long longValue = ((Long) value).longValue();
+ return longValue >= Integer.MIN_VALUE && longValue <= Integer.MAX_VALUE
+ ? (T) Integer.valueOf((int) longValue)
+ : null;
+ }
+ if (value instanceof Float || value instanceof Double) {
+ double doubleValue = ((Number) value).doubleValue();
+ return Double.isFinite(doubleValue) && doubleValue == Math.rint(doubleValue)
+ && doubleValue >= Integer.MIN_VALUE && doubleValue <= Integer.MAX_VALUE
+ ? (T) Integer.valueOf((int) doubleValue)
+ : null;
}
return null;
case "double":
if (value instanceof Number) {
- return (T) Double.valueOf(((Number) value).doubleValue());
- } else if (value instanceof String) {
- return (T) Double.valueOf((String) value);
+ double doubleValue = ((Number) value).doubleValue();
+ return Double.isFinite(doubleValue) ? (T) Double.valueOf(doubleValue) : null;
}
return null;
case "boolean":
- return (T) Boolean.valueOf(Boolean.TRUE.equals(value));
+ return (T) (value instanceof Boolean ? value : null);
case "array":
return (T) (value instanceof List ? value : null);
case "object":
- if (value instanceof Map) {
- return (T) value;
- }
- if (value instanceof List) {
- return (T) value;
- }
- return null;
+ return (T) (value instanceof Map ? value : null);
case "json":
// JSON type is handled specially in the calling code
return (T) value;
diff --git a/src/main/java/com/featurevisor/sdk/HooksManager.java b/src/main/java/com/featurevisor/sdk/HooksManager.java
deleted file mode 100644
index cec4585..0000000
--- a/src/main/java/com/featurevisor/sdk/HooksManager.java
+++ /dev/null
@@ -1,287 +0,0 @@
-package com.featurevisor.sdk;
-
-import com.featurevisor.sdk.Bucket;
-import java.util.List;
-import java.util.Map;
-import java.util.ArrayList;
-import java.util.function.Function;
-import java.util.function.BiFunction;
-
-/**
- * Hooks utility for Featurevisor SDK
- * Provides hook management functionality for customizing evaluation behavior
- */
-public class HooksManager {
- private List hooks = new ArrayList<>();
- private Logger logger;
-
- /**
- * Options for configuring bucket key
- */
- public static class ConfigureBucketKeyOptions {
- private String featureKey;
- private Map context;
- private Bucket bucketBy;
- private String bucketKey; // the initial bucket key, which can be modified by hooks
-
- public ConfigureBucketKeyOptions(String featureKey, Map context, Bucket bucketBy, String bucketKey) {
- this.featureKey = featureKey;
- this.context = context;
- this.bucketBy = bucketBy;
- this.bucketKey = bucketKey;
- }
-
- // Getters
- public String getFeatureKey() { return featureKey; }
- public Map getContext() { return context; }
- public Bucket getBucketBy() { return bucketBy; }
- public String getBucketKey() { return bucketKey; }
-
- // Setters
- public void setFeatureKey(String featureKey) { this.featureKey = featureKey; }
- public void setContext(Map context) { this.context = context; }
- public void setBucketBy(Bucket bucketBy) { this.bucketBy = bucketBy; }
- public void setBucketKey(String bucketKey) { this.bucketKey = bucketKey; }
- }
-
- /**
- * Options for configuring bucket value
- */
- public static class ConfigureBucketValueOptions {
- private String featureKey;
- private String bucketKey;
- private Map context;
- private int bucketValue; // the initial bucket value, which can be modified by hooks
-
- public ConfigureBucketValueOptions(String featureKey, String bucketKey, Map context, int bucketValue) {
- this.featureKey = featureKey;
- this.bucketKey = bucketKey;
- this.context = context;
- this.bucketValue = bucketValue;
- }
-
- // Getters
- public String getFeatureKey() { return featureKey; }
- public String getBucketKey() { return bucketKey; }
- public Map getContext() { return context; }
- public int getBucketValue() { return bucketValue; }
-
- // Setters
- public void setFeatureKey(String featureKey) { this.featureKey = featureKey; }
- public void setBucketKey(String bucketKey) { this.bucketKey = bucketKey; }
- public void setContext(Map context) { this.context = context; }
- public void setBucketValue(int bucketValue) { this.bucketValue = bucketValue; }
- }
-
- /**
- * Functional interface for configuring bucket key
- */
- @FunctionalInterface
- public interface ConfigureBucketKey {
- String configure(ConfigureBucketKeyOptions options);
- }
-
- /**
- * Functional interface for configuring bucket value
- */
- @FunctionalInterface
- public interface ConfigureBucketValue {
- int configure(ConfigureBucketValueOptions options);
- }
-
- /**
- * Hook interface for customizing evaluation behavior
- */
- public static class Hook {
- private String name;
- private Function before;
- private ConfigureBucketKey bucketKey;
- private ConfigureBucketValue bucketValue;
- private BiFunction after;
-
- public Hook(String name) {
- this.name = name;
- }
-
- // Getters
- public String getName() { return name; }
- public Function getBefore() { return before; }
- public ConfigureBucketKey getBucketKey() { return bucketKey; }
- public ConfigureBucketValue getBucketValue() { return bucketValue; }
- public BiFunction getAfter() { return after; }
-
- // Setters
- public void setBefore(Function before) { this.before = before; }
- public void setBucketKey(ConfigureBucketKey bucketKey) { this.bucketKey = bucketKey; }
- public void setBucketValue(ConfigureBucketValue bucketValue) { this.bucketValue = bucketValue; }
- public void setAfter(BiFunction after) { this.after = after; }
-
- // Builder pattern methods
- public Hook before(Function before) {
- this.before = before;
- return this;
- }
-
- public Hook bucketKey(ConfigureBucketKey bucketKey) {
- this.bucketKey = bucketKey;
- return this;
- }
-
- public Hook bucketValue(ConfigureBucketValue bucketValue) {
- this.bucketValue = bucketValue;
- return this;
- }
-
- public Hook after(BiFunction after) {
- this.after = after;
- return this;
- }
- }
-
- /**
- * Options for HooksManager constructor
- */
- public static class HooksManagerOptions {
- private List hooks;
- private Logger logger;
-
- public HooksManagerOptions(Logger logger) {
- this.logger = logger;
- }
-
- public HooksManagerOptions hooks(List hooks) {
- this.hooks = hooks;
- return this;
- }
-
- public HooksManagerOptions logger(Logger logger) {
- this.logger = logger;
- return this;
- }
-
- // Getters
- public List getHooks() { return hooks; }
- public Logger getLogger() { return logger; }
- }
-
- /**
- * Constructor
- */
- public HooksManager(HooksManagerOptions options) {
- this.logger = options.getLogger();
-
- if (options.getHooks() != null) {
- for (Hook hook : options.getHooks()) {
- add(hook);
- }
- }
- }
-
- /**
- * Add a hook
- * @param hook The hook to add
- * @return A function to remove the hook, or null if hook with same name already exists
- */
- public Runnable add(Hook hook) {
- if (hooks.stream().anyMatch(existingHook -> existingHook.getName().equals(hook.getName()))) {
- logger.error("Hook with name \"" + hook.getName() + "\" already exists.", Map.of(
- "name", hook.getName(),
- "hook", hook
- ));
- return null;
- }
-
- hooks.add(hook);
-
- return () -> remove(hook.getName());
- }
-
- /**
- * Remove a hook by name
- * @param name The name of the hook to remove
- */
- public void remove(String name) {
- hooks.removeIf(hook -> hook.getName().equals(name));
- }
-
- /**
- * Get all hooks
- * @return List of all hooks
- */
- public List getAll() {
- return new ArrayList<>(hooks);
- }
-
- /**
- * Execute before hooks
- * @param options The evaluation options
- * @return Modified evaluation options
- */
- public EvaluateOptions executeBeforeHooks(EvaluateOptions options) {
- EvaluateOptions currentOptions = options;
- for (Hook hook : hooks) {
- if (hook.getBefore() != null) {
- currentOptions = hook.getBefore().apply(currentOptions);
- }
- }
- return currentOptions;
- }
-
- /**
- * Execute after hooks
- * @param evaluation The evaluation result
- * @param options The evaluation options
- * @return Modified evaluation result
- */
- public Evaluation executeAfterHooks(Evaluation evaluation, EvaluateOptions options) {
- Evaluation currentEvaluation = evaluation;
- for (Hook hook : hooks) {
- if (hook.getAfter() != null) {
- currentEvaluation = hook.getAfter().apply(currentEvaluation, options);
- }
- }
- return currentEvaluation;
- }
-
- /**
- * Execute bucket key hooks
- * @param options The bucket key options
- * @return Modified bucket key
- */
- public String executeBucketKeyHooks(ConfigureBucketKeyOptions options) {
- String currentBucketKey = options.getBucketKey();
- for (Hook hook : hooks) {
- if (hook.getBucketKey() != null) {
- ConfigureBucketKeyOptions hookOptions = new ConfigureBucketKeyOptions(
- options.getFeatureKey(),
- options.getContext(),
- options.getBucketBy(),
- currentBucketKey
- );
- currentBucketKey = hook.getBucketKey().configure(hookOptions);
- }
- }
- return currentBucketKey;
- }
-
- /**
- * Execute bucket value hooks
- * @param options The bucket value options
- * @return Modified bucket value
- */
- public int executeBucketValueHooks(ConfigureBucketValueOptions options) {
- int currentBucketValue = options.getBucketValue();
- for (Hook hook : hooks) {
- if (hook.getBucketValue() != null) {
- ConfigureBucketValueOptions hookOptions = new ConfigureBucketValueOptions(
- options.getFeatureKey(),
- options.getBucketKey(),
- options.getContext(),
- currentBucketValue
- );
- currentBucketValue = hook.getBucketValue().configure(hookOptions);
- }
- }
- return currentBucketValue;
- }
-}
diff --git a/src/main/java/com/featurevisor/sdk/Logger.java b/src/main/java/com/featurevisor/sdk/Logger.java
index 42cd3d0..5bcd098 100644
--- a/src/main/java/com/featurevisor/sdk/Logger.java
+++ b/src/main/java/com/featurevisor/sdk/Logger.java
@@ -6,30 +6,26 @@
/**
* Logger for Featurevisor SDK
*/
-public class Logger {
- public enum LogLevel {
- DEBUG, INFO, WARN, ERROR, FATAL
- }
-
- private static final LogLevel[] ALL_LEVELS = {
- LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL
+final class Logger {
+ private static final FeaturevisorLogLevel[] ALL_LEVELS = {
+ FeaturevisorLogLevel.DEBUG, FeaturevisorLogLevel.INFO, FeaturevisorLogLevel.WARN, FeaturevisorLogLevel.ERROR, FeaturevisorLogLevel.FATAL
};
- private static final LogLevel DEFAULT_LEVEL = LogLevel.INFO;
+ private static final FeaturevisorLogLevel DEFAULT_LEVEL = FeaturevisorLogLevel.INFO;
private static final String LOGGER_PREFIX = "[Featurevisor]";
- private LogLevel level;
+ private FeaturevisorLogLevel level;
private LogHandler handler;
- public interface LogHandler {
- void handle(LogLevel level, String message, Map details);
+ interface LogHandler {
+ void handle(FeaturevisorLogLevel level, String message, Map details);
}
- public static class CreateLoggerOptions {
- private LogLevel level;
+ static class CreateLoggerOptions {
+ private FeaturevisorLogLevel level;
private LogHandler handler;
- public CreateLoggerOptions level(LogLevel level) {
+ public CreateLoggerOptions level(FeaturevisorLogLevel level) {
this.level = level;
return this;
}
@@ -39,7 +35,7 @@ public CreateLoggerOptions handler(LogHandler handler) {
return this;
}
- public LogLevel getLevel() {
+ public FeaturevisorLogLevel getLevel() {
return level;
}
@@ -48,27 +44,27 @@ public LogHandler getHandler() {
}
}
- public Logger() {
+ Logger() {
this.level = DEFAULT_LEVEL;
this.handler = this::defaultLogHandler;
}
- public Logger(LogLevel level) {
+ Logger(FeaturevisorLogLevel level) {
this.level = level != null ? level : DEFAULT_LEVEL;
this.handler = this::defaultLogHandler;
}
- public Logger(LogHandler handler) {
+ Logger(LogHandler handler) {
this.level = DEFAULT_LEVEL;
this.handler = handler != null ? handler : this::defaultLogHandler;
}
- public Logger(LogLevel level, LogHandler handler) {
+ Logger(FeaturevisorLogLevel level, LogHandler handler) {
this.level = level != null ? level : DEFAULT_LEVEL;
this.handler = handler != null ? handler : this::defaultLogHandler;
}
- public Logger(CreateLoggerOptions options) {
+ Logger(CreateLoggerOptions options) {
this.level = options.getLevel() != null ? options.getLevel() : DEFAULT_LEVEL;
this.handler = options.getHandler() != null ? options.getHandler() : this::defaultLogHandler;
}
@@ -78,7 +74,7 @@ public void debug(String message) {
}
public void debug(String message, Map details) {
- log(LogLevel.DEBUG, message, details);
+ log(FeaturevisorLogLevel.DEBUG, message, details);
}
public void info(String message) {
@@ -86,7 +82,7 @@ public void info(String message) {
}
public void info(String message, Map details) {
- log(LogLevel.INFO, message, details);
+ log(FeaturevisorLogLevel.INFO, message, details);
}
public void warn(String message) {
@@ -94,7 +90,7 @@ public void warn(String message) {
}
public void warn(String message, Map details) {
- log(LogLevel.WARN, message, details);
+ log(FeaturevisorLogLevel.WARN, message, details);
}
public void error(String message) {
@@ -102,7 +98,7 @@ public void error(String message) {
}
public void error(String message, Map details) {
- log(LogLevel.ERROR, message, details);
+ log(FeaturevisorLogLevel.ERROR, message, details);
}
public void fatal(String message) {
@@ -110,16 +106,16 @@ public void fatal(String message) {
}
public void fatal(String message, Map details) {
- log(LogLevel.FATAL, message, details);
+ log(FeaturevisorLogLevel.FATAL, message, details);
}
- public void log(LogLevel logLevel, String message, Map details) {
+ public void log(FeaturevisorLogLevel logLevel, String message, Map details) {
if (shouldLog(logLevel)) {
handler.handle(logLevel, message, details);
}
}
- private boolean shouldLog(LogLevel logLevel) {
+ private boolean shouldLog(FeaturevisorLogLevel logLevel) {
int currentLevelIndex = getLevelIndex(this.level);
int messageLevelIndex = getLevelIndex(logLevel);
@@ -127,7 +123,7 @@ private boolean shouldLog(LogLevel logLevel) {
return messageLevelIndex >= currentLevelIndex;
}
- private int getLevelIndex(LogLevel level) {
+ private int getLevelIndex(FeaturevisorLogLevel level) {
for (int i = 0; i < ALL_LEVELS.length; i++) {
if (ALL_LEVELS[i] == level) {
return i;
@@ -136,7 +132,11 @@ private int getLevelIndex(LogLevel level) {
return 0;
}
- private void defaultLogHandler(LogLevel level, String message, Map details) {
+ private void defaultLogHandler(FeaturevisorLogLevel level, String message, Map details) {
+ writeToConsole(level, message, details);
+ }
+
+ static void writeToConsole(FeaturevisorLogLevel level, String message, Map details) {
String levelStr = level.name().toLowerCase();
String logMessage = String.format("%s %s: %s", LOGGER_PREFIX, levelStr, message);
@@ -147,27 +147,27 @@ private void defaultLogHandler(LogLevel level, String message, Map modules = new ArrayList<>();
+ private final FeaturevisorDiagnosticReporter diagnosticReporter;
+ private final FeaturevisorModuleApiFactory moduleApiFactory;
+ private final java.util.function.Consumer clearModuleDiagnosticSubscriptions;
+
+ public static class ConfigureBucketKeyOptions {
+ private String featureKey;
+ private Map context;
+ private Bucket bucketBy;
+ private String bucketKey;
+
+ public ConfigureBucketKeyOptions(String featureKey, Map context, Bucket bucketBy, String bucketKey) {
+ this.featureKey = featureKey;
+ this.context = context;
+ this.bucketBy = bucketBy;
+ this.bucketKey = bucketKey;
+ }
+
+ public String getFeatureKey() { return featureKey; }
+ public Map getContext() { return context; }
+ public Bucket getBucketBy() { return bucketBy; }
+ public String getBucketKey() { return bucketKey; }
+
+ public void setFeatureKey(String featureKey) { this.featureKey = featureKey; }
+ public void setContext(Map context) { this.context = context; }
+ public void setBucketBy(Bucket bucketBy) { this.bucketBy = bucketBy; }
+ public void setBucketKey(String bucketKey) { this.bucketKey = bucketKey; }
+ }
+
+ public static class ConfigureBucketValueOptions {
+ private String featureKey;
+ private String bucketKey;
+ private Map context;
+ private int bucketValue;
+
+ public ConfigureBucketValueOptions(String featureKey, String bucketKey, Map context, int bucketValue) {
+ this.featureKey = featureKey;
+ this.bucketKey = bucketKey;
+ this.context = context;
+ this.bucketValue = bucketValue;
+ }
+
+ public String getFeatureKey() { return featureKey; }
+ public String getBucketKey() { return bucketKey; }
+ public Map getContext() { return context; }
+ public int getBucketValue() { return bucketValue; }
+
+ public void setFeatureKey(String featureKey) { this.featureKey = featureKey; }
+ public void setBucketKey(String bucketKey) { this.bucketKey = bucketKey; }
+ public void setContext(Map context) { this.context = context; }
+ public void setBucketValue(int bucketValue) { this.bucketValue = bucketValue; }
+ }
+
+ @FunctionalInterface
+ public interface ConfigureBucketKey {
+ String configure(ConfigureBucketKeyOptions options);
+ }
+
+ @FunctionalInterface
+ public interface ConfigureBucketValue {
+ int configure(ConfigureBucketValueOptions options);
+ }
+
+ public static class ModulesManagerOptions {
+ private List modules;
+ private FeaturevisorDiagnosticReporter diagnosticReporter;
+ private FeaturevisorModuleApiFactory moduleApiFactory;
+ private java.util.function.Consumer clearModuleDiagnosticSubscriptions;
+
+ public ModulesManagerOptions modules(List modules) {
+ this.modules = modules;
+ return this;
+ }
+
+ public ModulesManagerOptions diagnosticReporter(FeaturevisorDiagnosticReporter diagnosticReporter) {
+ this.diagnosticReporter = diagnosticReporter;
+ return this;
+ }
+
+ public ModulesManagerOptions moduleApiFactory(FeaturevisorModuleApiFactory moduleApiFactory) {
+ this.moduleApiFactory = moduleApiFactory;
+ return this;
+ }
+
+ public ModulesManagerOptions clearModuleDiagnosticSubscriptions(java.util.function.Consumer clearModuleDiagnosticSubscriptions) {
+ this.clearModuleDiagnosticSubscriptions = clearModuleDiagnosticSubscriptions;
+ return this;
+ }
+
+ public List getModules() { return modules; }
+ public FeaturevisorDiagnosticReporter getDiagnosticReporter() { return diagnosticReporter; }
+ public FeaturevisorModuleApiFactory getModuleApiFactory() { return moduleApiFactory; }
+ public java.util.function.Consumer getClearModuleDiagnosticSubscriptions() { return clearModuleDiagnosticSubscriptions; }
+ }
+
+ public ModulesManager(ModulesManagerOptions options) {
+ this.diagnosticReporter = options.getDiagnosticReporter();
+ this.moduleApiFactory = options.getModuleApiFactory();
+ this.clearModuleDiagnosticSubscriptions = options.getClearModuleDiagnosticSubscriptions();
+
+ if (options.getModules() != null) {
+ for (FeaturevisorModule module : options.getModules()) {
+ add(module);
+ }
+ }
+ }
+
+ public Runnable add(FeaturevisorModule module) {
+ if (module == null) {
+ return null;
+ }
+
+ String name = module.getName();
+ if (name != null && !name.isBlank() && modules.stream().anyMatch(existingModule -> name.equals(existingModule.getName()))) {
+ report(new FeaturevisorDiagnostic()
+ .level(FeaturevisorLogLevel.ERROR)
+ .code("duplicate_module")
+ .message("Duplicate module name")
+ .moduleName(name), null);
+ return null;
+ }
+
+ if (module.getSetup() != null && moduleApiFactory != null) {
+ try {
+ module.getSetup().accept(moduleApiFactory.create(module));
+ } catch (Throwable error) {
+ if (clearModuleDiagnosticSubscriptions != null) {
+ clearModuleDiagnosticSubscriptions.accept(module);
+ }
+ report(new FeaturevisorDiagnostic()
+ .level(FeaturevisorLogLevel.ERROR)
+ .code("module_setup_error")
+ .message("Module setup failed")
+ .moduleName(module.getName())
+ .originalError(error), null);
+ closeModule(module);
+ return null;
+ }
+ }
+
+ modules.add(module);
+ return () -> remove(module);
+ }
+
+ public void remove(String name) {
+ if (name == null) {
+ return;
+ }
+ List toRemove = new ArrayList<>();
+ for (FeaturevisorModule module : modules) {
+ if (name.equals(module.getName())) {
+ toRemove.add(module);
+ }
+ }
+ for (FeaturevisorModule module : toRemove) {
+ remove(module);
+ }
+ }
+
+ public void remove(FeaturevisorModule module) {
+ if (module == null) {
+ return;
+ }
+ if (modules.remove(module)) {
+ if (clearModuleDiagnosticSubscriptions != null) {
+ clearModuleDiagnosticSubscriptions.accept(module);
+ }
+ closeModule(module);
+ }
+ }
+
+ public List getAll() {
+ return new ArrayList<>(modules);
+ }
+
+ public EvaluateOptions executeBeforeModules(EvaluateOptions options) {
+ EvaluateOptions currentOptions = options;
+ for (FeaturevisorModule module : modules) {
+ if (module.getBefore() != null) {
+ currentOptions = module.getBefore().apply(currentOptions);
+ }
+ }
+ return currentOptions;
+ }
+
+ public Evaluation executeAfterModules(Evaluation evaluation, EvaluateOptions options) {
+ Evaluation currentEvaluation = evaluation;
+ for (FeaturevisorModule module : modules) {
+ if (module.getAfter() != null) {
+ currentEvaluation = module.getAfter().apply(currentEvaluation, options);
+ }
+ }
+ return currentEvaluation;
+ }
+
+ public String executeBucketKeyModules(ConfigureBucketKeyOptions options) {
+ String currentBucketKey = options.getBucketKey();
+ for (FeaturevisorModule module : modules) {
+ if (module.getBucketKey() != null) {
+ currentBucketKey = module.getBucketKey().configure(new ConfigureBucketKeyOptions(
+ options.getFeatureKey(),
+ options.getContext(),
+ options.getBucketBy(),
+ currentBucketKey
+ ));
+ }
+ }
+ return currentBucketKey;
+ }
+
+ public int executeBucketValueModules(ConfigureBucketValueOptions options) {
+ int currentBucketValue = options.getBucketValue();
+ for (FeaturevisorModule module : modules) {
+ if (module.getBucketValue() != null) {
+ currentBucketValue = module.getBucketValue().configure(new ConfigureBucketValueOptions(
+ options.getFeatureKey(),
+ options.getBucketKey(),
+ options.getContext(),
+ currentBucketValue
+ ));
+ }
+ }
+ return currentBucketValue;
+ }
+
+ public void closeAll() {
+ for (FeaturevisorModule module : new ArrayList<>(modules)) {
+ if (clearModuleDiagnosticSubscriptions != null) {
+ clearModuleDiagnosticSubscriptions.accept(module);
+ }
+ closeModule(module);
+ }
+ modules.clear();
+ }
+
+ private void closeModule(FeaturevisorModule module) {
+ if (module == null || module.getClose() == null) {
+ return;
+ }
+
+ try {
+ module.getClose().run();
+ } catch (Throwable error) {
+ report(new FeaturevisorDiagnostic()
+ .level(FeaturevisorLogLevel.ERROR)
+ .code("module_close_error")
+ .message("Module close failed")
+ .moduleName(module.getName())
+ .originalError(error), null);
+ }
+ }
+
+ private void report(FeaturevisorDiagnostic diagnostic, FeaturevisorModule module) {
+ if (diagnosticReporter != null) {
+ diagnosticReporter.report(diagnostic, module);
+ }
+ }
+}
diff --git a/src/test/java/com/featurevisor/cli/CLIOptionsTest.java b/src/test/java/com/featurevisor/cli/CLIOptionsTest.java
index 823c867..709a29f 100644
--- a/src/test/java/com/featurevisor/cli/CLIOptionsTest.java
+++ b/src/test/java/com/featurevisor/cli/CLIOptionsTest.java
@@ -3,26 +3,89 @@
import org.junit.jupiter.api.Test;
import picocli.CommandLine;
+import com.featurevisor.sdk.DatafileContent;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class CLIOptionsTest {
@Test
- public void testParseScopedAndTaggedOptions() {
+ public void testParseLegacyIgnoredOptions() {
CLI cli = new CLI();
CommandLine.ParseResult result = new CommandLine(cli).parseArgs(
"test",
"--with-scopes",
"--with-tags",
"--showDatafile",
- "--schemaVersion=2",
+ "--schema-version=2",
"--inflate=3"
);
assertTrue(result.hasMatchedOption("--with-scopes"));
assertTrue(result.hasMatchedOption("--with-tags"));
assertTrue(result.hasMatchedOption("--showDatafile"));
- assertTrue(result.hasMatchedOption("--schemaVersion"));
+ assertTrue(result.hasMatchedOption("--schema-version"));
assertTrue(result.hasMatchedOption("--inflate"));
}
+
+ @Test
+ public void testTargetDatafileCacheKey() {
+ CLI cli = new CLI();
+
+ assertEquals("false-target-checkout", cli.targetDatafileCacheKey(null, "checkout"));
+ assertEquals("production-target-checkout", cli.targetDatafileCacheKey("production", "checkout"));
+ }
+
+ @Test
+ public void testRepeatedTargets() {
+ CLI cli = new CLI();
+ CommandLine.ParseResult result = new CommandLine(cli).parseArgs(
+ "benchmark", "--target=web", "--target=mobile"
+ );
+ assertEquals(java.util.Arrays.asList("web", "mobile"), result.matchedOption("--target").getValue());
+ }
+
+ @Test
+ public void testTargetAssertionSelectsTargetDatafile() {
+ CLI cli = new CLI();
+ Map assertion = new HashMap<>();
+ assertion.put("environment", "production");
+ assertion.put("target", "checkout");
+
+ Map cache = new HashMap<>();
+ cache.put("production", new DatafileContent("2", "base"));
+ cache.put("production-target-checkout", new DatafileContent("2", "target"));
+
+ assertEquals("production-target-checkout", cli.selectDatafileKeyForAssertion(assertion, cache));
+ }
+
+ @Test
+ public void testTargetAssertionFallsBackToBaseDatafile() {
+ CLI cli = new CLI();
+ Map assertion = new HashMap<>();
+ assertion.put("environment", "production");
+ assertion.put("target", "checkout");
+
+ Map cache = new HashMap<>();
+ cache.put("production", new DatafileContent("2", "base"));
+
+ assertEquals("production", cli.selectDatafileKeyForAssertion(assertion, cache));
+ }
+
+ @Test
+ public void testNoEnvironmentTargetAssertionSelectsTargetDatafile() {
+ CLI cli = new CLI();
+ Map assertion = new HashMap<>();
+ assertion.put("target", "checkout");
+
+ Map cache = new HashMap<>();
+ cache.put("__no_environment__", new DatafileContent("2", "base"));
+ cache.put("false-target-checkout", new DatafileContent("2", "target"));
+
+ assertEquals("false-target-checkout", cli.selectDatafileKeyForAssertion(assertion, cache));
+ }
}
diff --git a/src/test/java/com/featurevisor/sdk/BucketerTest.java b/src/test/java/com/featurevisor/sdk/BucketerTest.java
index 614174b..a8c5d65 100644
--- a/src/test/java/com/featurevisor/sdk/BucketerTest.java
+++ b/src/test/java/com/featurevisor/sdk/BucketerTest.java
@@ -16,7 +16,7 @@ public class BucketerTest {
@BeforeEach
public void setUp() {
- logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.WARN));
+ logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.WARN));
}
@Test
diff --git a/src/test/java/com/featurevisor/sdk/ChildTest.java b/src/test/java/com/featurevisor/sdk/ChildTest.java
index ee5172c..e85d5d3 100644
--- a/src/test/java/com/featurevisor/sdk/ChildTest.java
+++ b/src/test/java/com/featurevisor/sdk/ChildTest.java
@@ -31,7 +31,7 @@ public class ChildTest {
@BeforeEach
public void setUp() {
- logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.WARN));
+ logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.WARN));
}
@Test
@@ -249,7 +249,7 @@ public void testCreateChildInstance() {
Map parentContext = new HashMap<>();
parentContext.put("appVersion", "1.0.0");
- Featurevisor parentInstance = new Featurevisor(new Featurevisor.Options()
+ Featurevisor parentInstance = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
.datafile(datafile)
.context(parentContext));
diff --git a/src/test/java/com/featurevisor/sdk/ConditionsTest.java b/src/test/java/com/featurevisor/sdk/ConditionsTest.java
index 54b7b91..4e345e7 100644
--- a/src/test/java/com/featurevisor/sdk/ConditionsTest.java
+++ b/src/test/java/com/featurevisor/sdk/ConditionsTest.java
@@ -11,6 +11,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.ArrayList;
+import java.util.Arrays;
public class ConditionsTest {
@@ -19,7 +20,7 @@ public class ConditionsTest {
@BeforeEach
public void setUp() {
- logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.WARN));
+ logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.WARN));
DatafileContent datafile = new DatafileContent();
datafile.setSchemaVersion("2.0");
@@ -851,6 +852,32 @@ public void testNotConditionMultipleConditions() {
assertTrue(datafileReader.allConditionsAreMatched(conditions, context));
}
+ @Test
+ public void testNotConditionNestedOrMeansNoneMatchAndEmptyNotIsFalse() {
+ Map chromeCondition = new HashMap<>();
+ chromeCondition.put("attribute", "browser_type");
+ chromeCondition.put("operator", "equals");
+ chromeCondition.put("value", "chrome");
+
+ Map firefoxCondition = new HashMap<>();
+ firefoxCondition.put("attribute", "browser_type");
+ firefoxCondition.put("operator", "equals");
+ firefoxCondition.put("value", "firefox");
+
+ Map orCondition = new HashMap<>();
+ orCondition.put("or", Arrays.asList(chromeCondition, firefoxCondition));
+
+ Map notCondition = new HashMap<>();
+ notCondition.put("not", List.of(orCondition));
+
+ assertFalse(datafileReader.allConditionsAreMatched(List.of(notCondition), Map.of("browser_type", "chrome")));
+ assertTrue(datafileReader.allConditionsAreMatched(List.of(notCondition), Map.of("browser_type", "edge")));
+
+ Map emptyNotCondition = new HashMap<>();
+ emptyNotCondition.put("not", List.of());
+ assertFalse(datafileReader.allConditionsAreMatched(List.of(emptyNotCondition), Map.of()));
+ }
+
@Test
public void testNestedConditionsOrInsideAnd() {
List> conditions = new ArrayList<>();
diff --git a/src/test/java/com/featurevisor/sdk/DatafileContentTest.java b/src/test/java/com/featurevisor/sdk/DatafileContentTest.java
index 393307c..4c32915 100644
--- a/src/test/java/com/featurevisor/sdk/DatafileContentTest.java
+++ b/src/test/java/com/featurevisor/sdk/DatafileContentTest.java
@@ -13,7 +13,7 @@ public class DatafileContentTest {
public void testParseJson() throws Exception {
// Example JSON string that matches the DatafileContent structure
String jsonString = "{\n" +
- " \"schemaVersion\": \"1.0\",\n" +
+ " \"schemaVersion\": \"2\",\n" +
" \"revision\": \"abc123\",\n" +
" \"segments\": {\n" +
" \"netherlands\": {\n" +
@@ -44,7 +44,7 @@ public void testParseJson() throws Exception {
DatafileContent datafile = DatafileContent.fromJson(jsonString);
// Verify the parsed data
- assertEquals("1.0", datafile.getSchemaVersion());
+ assertEquals("2", datafile.getSchemaVersion());
assertEquals("abc123", datafile.getRevision());
assertEquals(1, datafile.getSegmentCount());
assertEquals(1, datafile.getFeatureCount());
@@ -65,7 +65,7 @@ public void testParseJson() throws Exception {
@Test
public void testToJson() throws Exception {
// Create a DatafileContent object
- DatafileContent datafile = new DatafileContent("1.0", "test123");
+ DatafileContent datafile = new DatafileContent("2", "test123");
// Convert to JSON
String json = datafile.toJson();
@@ -81,7 +81,7 @@ public void testToJson() throws Exception {
@Test
public void testComplexFeature() throws Exception {
String jsonString = "{\n" +
- " \"schemaVersion\": \"1.0\",\n" +
+ " \"schemaVersion\": \"2\",\n" +
" \"revision\": \"complex123\",\n" +
" \"segments\": {},\n" +
" \"features\": {\n" +
diff --git a/src/test/java/com/featurevisor/sdk/DatafileReaderComprehensiveTest.java b/src/test/java/com/featurevisor/sdk/DatafileReaderComprehensiveTest.java
index c213348..ce64f9a 100644
--- a/src/test/java/com/featurevisor/sdk/DatafileReaderComprehensiveTest.java
+++ b/src/test/java/com/featurevisor/sdk/DatafileReaderComprehensiveTest.java
@@ -232,6 +232,28 @@ void testMatchNotVersion55() {
assertFalse(datafileReader.allSegmentsAreMatched(segments, Map.of("version", 5.5)));
}
+ @Test
+ void testNotSegmentsNegateImplicitAnd() {
+ Map segments = Map.of("not", Arrays.asList("mobileUsers", "netherlands"));
+
+ assertFalse(datafileReader.allSegmentsAreMatched(segments,
+ Map.of("country", "nl", "deviceType", "mobile")));
+ assertTrue(datafileReader.allSegmentsAreMatched(segments,
+ Map.of("country", "nl", "deviceType", "desktop")));
+ }
+
+ @Test
+ void testNotSegmentsWithNestedOrMeanNoneMatchAndEmptyNotIsFalse() {
+ Map segments = Map.of(
+ "not",
+ List.of(Map.of("or", Arrays.asList("mobileUsers", "desktopUsers")))
+ );
+
+ assertFalse(datafileReader.allSegmentsAreMatched(segments, Map.of("deviceType", "mobile")));
+ assertTrue(datafileReader.allSegmentsAreMatched(segments, Map.of("deviceType", "tv")));
+ assertFalse(datafileReader.allSegmentsAreMatched(Map.of("not", List.of()), Map.of()));
+ }
+
@Test
void testConditionsWithWildcard() {
// Test "*" conditions - should match everything
diff --git a/src/test/java/com/featurevisor/sdk/DatafileReaderTest.java b/src/test/java/com/featurevisor/sdk/DatafileReaderTest.java
index 1343f14..b9ec59c 100644
--- a/src/test/java/com/featurevisor/sdk/DatafileReaderTest.java
+++ b/src/test/java/com/featurevisor/sdk/DatafileReaderTest.java
@@ -1,5 +1,10 @@
package com.featurevisor.sdk;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
import com.featurevisor.sdk.Allocation;
import com.featurevisor.sdk.Bucket;
import com.featurevisor.sdk.Condition;
@@ -23,11 +28,19 @@
public class DatafileReaderTest {
+ @Test
+ public void testSharedV3ConformanceFixture() throws Exception {
+ JsonNode fixture = new ObjectMapper().readTree(Files.readString(Path.of("conformance/sdk-v3.json")));
+ assertEquals(1, fixture.get("version").asInt());
+ assertEquals("control", fixture.get("bucketing").get("allocationExpectations").get("50000").asText());
+ assertEquals("treatment", fixture.get("bucketing").get("allocationExpectations").get("50001").asText());
+ }
+
private Logger logger;
@BeforeEach
public void setUp() {
- logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.WARN));
+ logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.WARN));
}
@Test
@@ -403,6 +416,10 @@ public void testTrafficMatching() {
assertNotNull(matchedAllocation2);
assertEquals("treatment", matchedAllocation2.getVariation());
+ assertEquals("control", reader.getMatchedAllocation(matchedTraffic, 50000).getVariation());
+ assertEquals("treatment", reader.getMatchedAllocation(matchedTraffic, 50001).getVariation());
+ assertEquals("treatment", reader.getMatchedAllocation(matchedTraffic, 100000).getVariation());
+
Allocation unmatchedAllocation = reader.getMatchedAllocation(matchedTraffic, 150000);
assertNull(unmatchedAllocation);
}
diff --git a/src/test/java/com/featurevisor/sdk/EmitterTest.java b/src/test/java/com/featurevisor/sdk/EmitterTest.java
index 7ba59fc..dfb8eb4 100644
--- a/src/test/java/com/featurevisor/sdk/EmitterTest.java
+++ b/src/test/java/com/featurevisor/sdk/EmitterTest.java
@@ -105,6 +105,23 @@ public void testMultipleListeners() {
emitter.clearAll();
}
+ @Test
+ public void testTriggerUsesListenerSnapshot() {
+ List calls = new ArrayList<>();
+ final Emitter.UnsubscribeFunction[] unsubscribeSecond = new Emitter.UnsubscribeFunction[1];
+
+ emitter.on(Emitter.EventName.STICKY_SET, details -> {
+ calls.add("first");
+ unsubscribeSecond[0].unsubscribe();
+ });
+ unsubscribeSecond[0] = emitter.on(Emitter.EventName.STICKY_SET, details -> calls.add("second"));
+
+ emitter.trigger(Emitter.EventName.STICKY_SET);
+ emitter.trigger(Emitter.EventName.STICKY_SET);
+
+ assertEquals(List.of("first", "second", "first"), calls);
+ }
+
@Test
public void testTriggerWithoutDetails() {
// Add a listener
diff --git a/src/test/java/com/featurevisor/sdk/EvaluateDisabledTest.java b/src/test/java/com/featurevisor/sdk/EvaluateDisabledTest.java
index 7225546..ad15b32 100644
--- a/src/test/java/com/featurevisor/sdk/EvaluateDisabledTest.java
+++ b/src/test/java/com/featurevisor/sdk/EvaluateDisabledTest.java
@@ -19,7 +19,7 @@ public class EvaluateDisabledTest {
@BeforeEach
public void setUp() {
- logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.WARN));
+ logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.WARN));
DatafileContent datafile = new DatafileContent();
datafile.setSchemaVersion("2.0");
diff --git a/src/test/java/com/featurevisor/sdk/EvaluateForcedTest.java b/src/test/java/com/featurevisor/sdk/EvaluateForcedTest.java
index 3126cea..51703bd 100644
--- a/src/test/java/com/featurevisor/sdk/EvaluateForcedTest.java
+++ b/src/test/java/com/featurevisor/sdk/EvaluateForcedTest.java
@@ -23,7 +23,7 @@ public class EvaluateForcedTest {
@BeforeEach
public void setUp() {
- logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.WARN));
+ logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.WARN));
DatafileContent datafile = new DatafileContent();
datafile.setSchemaVersion("2.0");
diff --git a/src/test/java/com/featurevisor/sdk/EvaluateNotFoundTest.java b/src/test/java/com/featurevisor/sdk/EvaluateNotFoundTest.java
index 8ab627a..dd2d4c1 100644
--- a/src/test/java/com/featurevisor/sdk/EvaluateNotFoundTest.java
+++ b/src/test/java/com/featurevisor/sdk/EvaluateNotFoundTest.java
@@ -22,7 +22,7 @@ public class EvaluateNotFoundTest {
@BeforeEach
public void setUp() {
- logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.WARN));
+ logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.WARN));
// Create a test feature with variations
feature = new Feature("test-feature");
diff --git a/src/test/java/com/featurevisor/sdk/EvaluateStickyTest.java b/src/test/java/com/featurevisor/sdk/EvaluateStickyTest.java
index b583701..4a412f3 100644
--- a/src/test/java/com/featurevisor/sdk/EvaluateStickyTest.java
+++ b/src/test/java/com/featurevisor/sdk/EvaluateStickyTest.java
@@ -14,7 +14,7 @@ public class EvaluateStickyTest {
@BeforeEach
public void setUp() {
- logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.WARN));
+ logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.WARN));
sticky = new HashMap<>();
}
diff --git a/src/test/java/com/featurevisor/sdk/EventsTest.java b/src/test/java/com/featurevisor/sdk/EventsTest.java
index fa46fa5..f7cb4cc 100644
--- a/src/test/java/com/featurevisor/sdk/EventsTest.java
+++ b/src/test/java/com/featurevisor/sdk/EventsTest.java
@@ -76,14 +76,14 @@ public void testGetParamsForStickySetEventWithNullInputs() {
public void testGetParamsForDatafileSetEventEmptyToNew() {
// Create empty previous datafile
DatafileContent previousDatafileContent = new DatafileContent();
- previousDatafileContent.setSchemaVersion("1.0.0");
+ previousDatafileContent.setSchemaVersion("2");
previousDatafileContent.setRevision("1");
previousDatafileContent.setFeatures(new HashMap<>());
previousDatafileContent.setSegments(new HashMap<>());
// Create new datafile with features
DatafileContent newDatafileContent = new DatafileContent();
- newDatafileContent.setSchemaVersion("1.0.0");
+ newDatafileContent.setSchemaVersion("2");
newDatafileContent.setRevision("2");
Map features = new HashMap<>();
@@ -104,7 +104,7 @@ public void testGetParamsForDatafileSetEventEmptyToNew() {
newDatafileContent.setSegments(new HashMap<>());
Emitter.EventDetails result = Events.getParamsForDatafileSetEvent(
- previousDatafileContent, newDatafileContent);
+ previousDatafileContent, newDatafileContent, false);
String revision = (String) result.get("revision");
String previousRevision = (String) result.get("previousRevision");
@@ -115,6 +115,7 @@ public void testGetParamsForDatafileSetEventEmptyToNew() {
assertEquals("2", revision);
assertEquals("1", previousRevision);
assertEquals(true, revisionChanged);
+ assertEquals(false, result.get("replaced"));
assertEquals(2, featuresList.size());
assertTrue(featuresList.contains("feature1"));
assertTrue(featuresList.contains("feature2"));
@@ -124,7 +125,7 @@ public void testGetParamsForDatafileSetEventEmptyToNew() {
public void testGetParamsForDatafileSetEventChangeHashAddition() {
// Create previous datafile with features
DatafileContent previousDatafileContent = new DatafileContent();
- previousDatafileContent.setSchemaVersion("1.0.0");
+ previousDatafileContent.setSchemaVersion("2");
previousDatafileContent.setRevision("1");
Map previousFeatures = new HashMap<>();
@@ -146,7 +147,7 @@ public void testGetParamsForDatafileSetEventChangeHashAddition() {
// Create new datafile with changed and added features
DatafileContent newDatafileContent = new DatafileContent();
- newDatafileContent.setSchemaVersion("1.0.0");
+ newDatafileContent.setSchemaVersion("2");
newDatafileContent.setRevision("2");
Map newFeatures = new HashMap<>();
@@ -173,7 +174,7 @@ public void testGetParamsForDatafileSetEventChangeHashAddition() {
newDatafileContent.setSegments(new HashMap<>());
Emitter.EventDetails result = Events.getParamsForDatafileSetEvent(
- previousDatafileContent, newDatafileContent);
+ previousDatafileContent, newDatafileContent, true);
String revision = (String) result.get("revision");
String previousRevision = (String) result.get("previousRevision");
@@ -184,6 +185,7 @@ public void testGetParamsForDatafileSetEventChangeHashAddition() {
assertEquals("2", revision);
assertEquals("1", previousRevision);
assertEquals(true, revisionChanged);
+ assertEquals(true, result.get("replaced"));
assertEquals(2, featuresList.size());
assertTrue(featuresList.contains("feature2")); // Changed
assertTrue(featuresList.contains("feature3")); // Added
@@ -194,7 +196,7 @@ public void testGetParamsForDatafileSetEventChangeHashAddition() {
public void testGetParamsForDatafileSetEventChangeHashRemoval() {
// Create previous datafile with features
DatafileContent previousDatafileContent = new DatafileContent();
- previousDatafileContent.setSchemaVersion("1.0.0");
+ previousDatafileContent.setSchemaVersion("2");
previousDatafileContent.setRevision("1");
Map previousFeatures = new HashMap<>();
@@ -216,7 +218,7 @@ public void testGetParamsForDatafileSetEventChangeHashRemoval() {
// Create new datafile with one feature removed and one changed
DatafileContent newDatafileContent = new DatafileContent();
- newDatafileContent.setSchemaVersion("1.0.0");
+ newDatafileContent.setSchemaVersion("2");
newDatafileContent.setRevision("2");
Map newFeatures = new HashMap<>();
@@ -232,7 +234,7 @@ public void testGetParamsForDatafileSetEventChangeHashRemoval() {
newDatafileContent.setSegments(new HashMap<>());
Emitter.EventDetails result = Events.getParamsForDatafileSetEvent(
- previousDatafileContent, newDatafileContent);
+ previousDatafileContent, newDatafileContent, false);
String revision = (String) result.get("revision");
String previousRevision = (String) result.get("previousRevision");
@@ -251,7 +253,7 @@ public void testGetParamsForDatafileSetEventChangeHashRemoval() {
@Test
public void testGetParamsForDatafileSetEventWithNullInputs() {
// Test with null inputs
- Emitter.EventDetails result = Events.getParamsForDatafileSetEvent(null, null);
+ Emitter.EventDetails result = Events.getParamsForDatafileSetEvent(null, null, false);
String revision = (String) result.get("revision");
String previousRevision = (String) result.get("previousRevision");
@@ -277,7 +279,7 @@ public void testGetParamsForDatafileSetEventSameRevision() {
newDatafileContent.setFeatures(new HashMap<>());
Emitter.EventDetails result = Events.getParamsForDatafileSetEvent(
- previousDatafileContent, newDatafileContent);
+ previousDatafileContent, newDatafileContent, false);
Boolean revisionChanged = (Boolean) result.get("revisionChanged");
assertEquals(false, revisionChanged);
diff --git a/src/test/java/com/featurevisor/sdk/FeaturevisorTest.java b/src/test/java/com/featurevisor/sdk/FeaturevisorTest.java
index f72bcfa..c0371f6 100644
--- a/src/test/java/com/featurevisor/sdk/FeaturevisorTest.java
+++ b/src/test/java/com/featurevisor/sdk/FeaturevisorTest.java
@@ -30,7 +30,7 @@ public class FeaturevisorTest {
@BeforeEach
public void setUp() {
- logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.WARN));
+ logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.WARN));
}
@Test
@@ -41,15 +41,35 @@ public void testCreateInstanceIsFunction() {
@Test
public void testCreateInstanceWithNoParameters() {
- // Test the simplest createInstance() method
- Featurevisor sdk = Featurevisor.createInstance();
+ // Test the simplest createFeaturevisor() method
+ Featurevisor sdk = Featurevisor.createFeaturevisor();
assertNotNull(sdk);
+ assertEquals("2", sdk.getSchemaVersion());
// Should have default logger and empty datafile
assertNotNull(sdk.getRevision());
assertNull(sdk.getVariation("nonExistentFeature"));
}
+ @Test
+ public void testLifecycleMutationsReportDiagnostics() {
+ List diagnostics = new ArrayList<>();
+ Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
+ .logLevel(FeaturevisorLogLevel.DEBUG)
+ .onDiagnostic(diagnostics::add));
+
+ DatafileContent datafile = new DatafileContent("2", "1");
+ datafile.setSegments(new HashMap<>());
+ datafile.setFeatures(new HashMap<>());
+ sdk.setDatafile(datafile);
+ sdk.setSticky(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 -> "context_set".equals(diagnostic.getCode())));
+ }
+
@Test
public void testCreateInstanceWithDatafileContent() {
// Create datafile content using JSON string for better readability
@@ -99,8 +119,10 @@ public void testCreateInstanceWithDatafileContent() {
return;
}
- // Test createInstance with DatafileContent
- Featurevisor sdk = Featurevisor.createInstance(datafile);
+ // Test createFeaturevisor with DatafileContent
+ Featurevisor sdk = Featurevisor.createFeaturevisor(
+ new Featurevisor.FeaturevisorOptions().datafile(datafile)
+ );
assertNotNull(sdk);
assertEquals("1.0", sdk.getRevision());
@@ -109,7 +131,7 @@ public void testCreateInstanceWithDatafileContent() {
@Test
public void testCreateInstanceWithDatafileString() {
- // Test createInstance with datafile string
+ // Test createFeaturevisor with datafile string
String datafileJson = """
{
"schemaVersion": "2",
@@ -148,8 +170,10 @@ public void testCreateInstanceWithDatafileString() {
"segments": {}
}""";
- // Test createInstance with datafile string
- Featurevisor sdk = Featurevisor.createInstance(datafileJson);
+ // Test createFeaturevisor with datafile string
+ Featurevisor sdk = Featurevisor.createFeaturevisor(
+ new Featurevisor.FeaturevisorOptions().datafileString(datafileJson)
+ );
assertNotNull(sdk);
assertEquals("2.0", sdk.getRevision());
@@ -158,13 +182,15 @@ public void testCreateInstanceWithDatafileString() {
@Test
public void testCreateInstanceWithContext() {
- // Test createInstance with context
+ // Test createFeaturevisor with context
Map context = Map.of(
"userId", "123",
"country", "us"
);
- Featurevisor sdk = Featurevisor.createInstance(context);
+ Featurevisor sdk = Featurevisor.createFeaturevisor(
+ new Featurevisor.FeaturevisorOptions().context(context)
+ );
assertNotNull(sdk);
// Context should be set
@@ -175,8 +201,10 @@ public void testCreateInstanceWithContext() {
@Test
public void testCreateInstanceWithLogLevel() {
- // Test createInstance with log level
- Featurevisor sdk = Featurevisor.createInstance(Logger.LogLevel.DEBUG);
+ // Test createFeaturevisor with log level
+ Featurevisor sdk = Featurevisor.createFeaturevisor(
+ new Featurevisor.FeaturevisorOptions().logLevel(FeaturevisorLogLevel.DEBUG)
+ );
assertNotNull(sdk);
// The logger should be set with DEBUG level
@@ -185,31 +213,26 @@ public void testCreateInstanceWithLogLevel() {
}
@Test
- public void testCreateInstanceWithLogger() {
- // Test createInstance with custom logger
- Logger customLogger = Logger.createLogger(new Logger.CreateLoggerOptions()
- .level(Logger.LogLevel.ERROR)
- .handler((level, message, details) -> {
- // Custom handler
- }));
-
- Featurevisor sdk = Featurevisor.createInstance(customLogger);
-
+ public void testCreateFeaturevisorWithDiagnosticHandler() {
+ List diagnostics = new ArrayList<>();
+ Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
+ .onDiagnostic(diagnostics::add));
assertNotNull(sdk);
- // The custom logger should be used
- assertNotNull(sdk.getRevision());
+ assertEquals("sdk_initialized", diagnostics.get(0).getCode());
}
@Test
public void testCreateInstanceWithStickyFeatures() {
- // Test createInstance with sticky features (isSticky = true)
+ // Test createFeaturevisor with sticky features (isSticky = true)
Map sticky = new HashMap<>();
Map testSticky = new HashMap<>();
testSticky.put("enabled", true);
testSticky.put("variation", "control");
sticky.put("test", testSticky);
- Featurevisor sdk = Featurevisor.createInstance(sticky, true);
+ Featurevisor sdk = Featurevisor.createFeaturevisor(
+ new Featurevisor.FeaturevisorOptions().sticky(sticky)
+ );
assertNotNull(sdk);
// Sticky features should be set
@@ -218,13 +241,15 @@ public void testCreateInstanceWithStickyFeatures() {
@Test
public void testCreateInstanceWithContextAsSticky() {
- // Test createInstance with context as sticky (isSticky = false)
+ // Test createFeaturevisor with context as sticky (isSticky = false)
Map context = Map.of(
"userId", "123",
"country", "us"
);
- Featurevisor sdk = Featurevisor.createInstance(context, false);
+ Featurevisor sdk = Featurevisor.createFeaturevisor(
+ new Featurevisor.FeaturevisorOptions().context(context)
+ );
assertNotNull(sdk);
// Context should be set (not sticky)
@@ -235,8 +260,8 @@ public void testCreateInstanceWithContextAsSticky() {
@Test
public void testCreateInstanceWithNullOptions() {
- // Test createInstance with null options (should use defaults)
- Featurevisor sdk = Featurevisor.createInstance((Featurevisor.Options) null);
+ // Test createFeaturevisor with null options (should use defaults)
+ Featurevisor sdk = Featurevisor.createFeaturevisor((Featurevisor.FeaturevisorOptions) null);
assertNotNull(sdk);
assertNotNull(sdk.getRevision());
@@ -244,11 +269,13 @@ public void testCreateInstanceWithNullOptions() {
@Test
public void testCreateInstanceWithInvalidDatafileString() {
- // Test createInstance with invalid datafile string
+ // Test createFeaturevisor with invalid datafile string
String invalidJson = "{ invalid json }";
// Should not throw exception, but should log error
- Featurevisor sdk = Featurevisor.createInstance(invalidJson);
+ Featurevisor sdk = Featurevisor.createFeaturevisor(
+ new Featurevisor.FeaturevisorOptions().datafileString(invalidJson)
+ );
assertNotNull(sdk);
// Should have default empty datafile
@@ -257,7 +284,7 @@ public void testCreateInstanceWithInvalidDatafileString() {
@Test
public void testCreateInstanceWithOptionsBuilder() {
- // Test createInstance with Options builder pattern
+ // Test createFeaturevisor with Options builder pattern
String datafileJson = """
{
"schemaVersion": "2",
@@ -305,15 +332,12 @@ public void testCreateInstanceWithOptionsBuilder() {
}
Map context = Map.of("userId", "123");
- Logger customLogger = Logger.createLogger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.INFO));
-
// Test with Options builder
- Featurevisor.Options options = new Featurevisor.Options()
+ Featurevisor.FeaturevisorOptions options = new Featurevisor.FeaturevisorOptions()
.datafile(datafile)
- .context(context)
- .logger(customLogger);
+ .context(context);
- Featurevisor sdk = Featurevisor.createInstance(options);
+ Featurevisor sdk = Featurevisor.createFeaturevisor(options);
assertNotNull(sdk);
assertEquals("3.0", sdk.getRevision());
@@ -375,19 +399,19 @@ public void testConfigurePlainBucketBy() {
return;
}
- // Create hook to capture bucket key
- HooksManager.Hook hook = new HooksManager.Hook("unit-test");
- hook.setBucketKey(options -> {
+ // Create module to capture bucket key
+ FeaturevisorModule module = new FeaturevisorModule("unit-test");
+ module.setBucketKey(options -> {
capturedBucketKey[0] = options.getBucketKey();
return options.getBucketKey();
});
- List hooks = new ArrayList<>();
- hooks.add(hook);
+ List modules = new ArrayList<>();
+ modules.add(module);
- Featurevisor sdk = new Featurevisor(new Featurevisor.Options()
+ Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
.datafile(datafile)
- .hooks(hooks));
+ .modules(modules));
String featureKey = "test";
Map