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 context = Map.of( @@ -450,19 +474,19 @@ public void testConfigureAndBucketBy() { 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 context = Map.of( @@ -527,19 +551,19 @@ public void testConfigureOrBucketBy() { 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)); // Test with both userId and deviceId Map context1 = new HashMap<>(); @@ -559,7 +583,7 @@ public void testConfigureOrBucketBy() { } @Test - public void testInterceptContextBeforeHook() { + public void testInterceptContextBeforeModule() { final boolean[] intercepted = {false}; final String[] interceptedFeatureKey = {""}; final String[] interceptedVariableKey = {""}; @@ -611,30 +635,29 @@ public void testInterceptContextBeforeHook() { return; } - // Create hook to intercept before - HooksManager.Hook hook = new HooksManager.Hook("unit-test"); - hook.setBefore(options -> { + // Create module to intercept before + FeaturevisorModule module = new FeaturevisorModule("unit-test"); + module.setBefore(options -> { intercepted[0] = true; interceptedFeatureKey[0] = options.getFeatureKey(); interceptedVariableKey[0] = options.getVariableKey(); return options; }); - 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)); Map context = Map.of( "userId", "123" ); Object variation = sdk.getVariation("test", context); - System.out.println("DEBUG: testInterceptContextBeforeHook variation = " + variation); - // The hook should be called, but the variation might be null if the feature doesn't exist + // The module should be called, but the variation might be null if the feature doesn't exist assertTrue(intercepted[0]); assertEquals("test", interceptedFeatureKey[0]); assertEquals(null, interceptedVariableKey[0]); @@ -643,7 +666,7 @@ public void testInterceptContextBeforeHook() { } @Test - public void testInterceptValueAfterHook() { + public void testInterceptValueAfterModule() { final boolean[] intercepted = {false}; final String[] interceptedFeatureKey = {""}; final String[] interceptedVariableKey = {""}; @@ -695,9 +718,9 @@ public void testInterceptValueAfterHook() { return; } - // Create hook to intercept after and manipulate value - HooksManager.Hook hook = new HooksManager.Hook("unit-test"); - hook.setAfter((evaluation, options) -> { + // Create module to intercept after and manipulate value + FeaturevisorModule module = new FeaturevisorModule("unit-test"); + module.setAfter((evaluation, options) -> { intercepted[0] = true; interceptedFeatureKey[0] = options.getFeatureKey(); interceptedVariableKey[0] = options.getVariableKey(); @@ -705,25 +728,24 @@ public void testInterceptValueAfterHook() { return evaluation; }); - 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)); Map context = Map.of( "userId", "123" ); Object variation = sdk.getVariation("test", context); - System.out.println("DEBUG: testInterceptValueAfterHook variation = " + variation); - // The hook should be called and the value should be intercepted + // The module should be called and the value should be intercepted assertTrue(intercepted[0]); assertEquals("test", interceptedFeatureKey[0]); assertEquals(null, interceptedVariableKey[0]); - // The variation should be "control_intercepted" based on the after hook + // The variation should be "control_intercepted" based on the after module assertEquals("control_intercepted", variation); } @@ -788,7 +810,7 @@ public void testInitializeWithStickyFeatures() throws InterruptedException { sticky.put("test", testSticky); - Featurevisor sdk = new Featurevisor(new Featurevisor.Options().sticky(sticky)); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().sticky(sticky)); // initially control Map context = Map.of( @@ -810,6 +832,175 @@ public void testInitializeWithStickyFeatures() throws InterruptedException { assertEquals("treatment", sdk.getVariation("test", context)); } + @Test + public void testSetDatafileMergesByDefaultAndReplacesWhenRequested() throws Exception { + DatafileContent firstDatafile = DatafileContent.fromJson(""" + { + "schemaVersion": "2", + "revision": "first", + "features": { + "first": { + "key": "first", + "bucketBy": "userId", + "traffic": [] + } + }, + "segments": {} + }"""); + + DatafileContent secondDatafile = DatafileContent.fromJson(""" + { + "schemaVersion": "2", + "revision": "second", + "featurevisorVersion": "3.0.0", + "features": { + "second": { + "key": "second", + "bucketBy": "userId", + "traffic": [] + } + }, + "segments": {} + }"""); + + Featurevisor sdk = Featurevisor.createFeaturevisor( + new Featurevisor.FeaturevisorOptions().datafile(firstDatafile) + ); + + sdk.setDatafile(secondDatafile); + + assertNotNull(sdk.getFeature("first")); + assertNotNull(sdk.getFeature("second")); + assertEquals("second", sdk.getRevision()); + + sdk.setDatafile(secondDatafile, true); + + assertNull(sdk.getFeature("first")); + assertNotNull(sdk.getFeature("second")); + } + + @Test + public void testSetDatafileEventIncludesReplaced() throws Exception { + DatafileContent datafile = DatafileContent.fromJson(""" + { + "schemaVersion": "2", + "revision": "test", + "features": {}, + "segments": {} + }"""); + + Featurevisor sdk = Featurevisor.createFeaturevisor(); + final Object[] replaced = {null}; + sdk.on(Emitter.EventName.DATAFILE_SET, details -> replaced[0] = details.get("replaced")); + + sdk.setDatafile(datafile, true); + + assertEquals(true, replaced[0]); + } + + @Test + public void testDuplicateModuleReportsDiagnostic() { + List diagnostics = new ArrayList<>(); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .onDiagnostic(diagnostics::add) + .modules(List.of(new FeaturevisorModule("duplicate")))); + + Runnable unsubscribe = sdk.addModule(new FeaturevisorModule("duplicate")); + + assertNull(unsubscribe); + assertTrue(diagnostics.stream().anyMatch(diagnostic -> + "duplicate_module".equals(diagnostic.getCode()) && + "duplicate".equals(diagnostic.getModuleName()) && + FeaturevisorLogLevel.ERROR.equals(diagnostic.getLevel()) + )); + } + + @Test + public void testDiagnosticHandlerFailureIsIsolated() { + assertDoesNotThrow(() -> { + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .onDiagnostic(diagnostic -> { + throw new RuntimeException("handler failed"); + })); + + sdk.isEnabled("missing", Map.of()); + sdk.close(); + }); + } + + @Test + public void testModuleCloseErrorReportsDiagnosticAndErrorEvent() { + List diagnostics = new ArrayList<>(); + List errorEvents = new ArrayList<>(); + + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .onDiagnostic(diagnostics::add) + .modules(List.of(new FeaturevisorModule("closer") + .close(() -> { + throw new RuntimeException("close failed"); + })))); + + sdk.on(Emitter.EventName.ERROR, errorEvents::add); + sdk.close(); + + assertTrue(diagnostics.stream().anyMatch(diagnostic -> + "module_close_error".equals(diagnostic.getCode()) && + "closer".equals(diagnostic.getModuleName()) && + FeaturevisorLogLevel.ERROR.equals(diagnostic.getLevel()) && + diagnostic.getOriginalError() instanceof RuntimeException && + ((RuntimeException) diagnostic.getOriginalError()).getMessage().contains("close failed") + )); + assertTrue(errorEvents.stream().anyMatch(details -> + details.get("diagnostic") instanceof FeaturevisorDiagnostic && + "module_close_error".equals(((FeaturevisorDiagnostic) details.get("diagnostic")).getCode()) && + "closer".equals(((FeaturevisorDiagnostic) details.get("diagnostic")).getModuleName()) + )); + } + + @Test + public void testModuleDiagnosticSubscriptionsAndCleanup() { + List received = new ArrayList<>(); + final FeaturevisorModuleApi[] apiRef = {null}; + + FeaturevisorModule observer = new FeaturevisorModule("observer") + .setup(api -> api.onDiagnostic(received::add)); + FeaturevisorModule reporter = new FeaturevisorModule("reporter") + .setup(api -> apiRef[0] = api); + + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .modules(List.of(observer, reporter))); + + received.clear(); + + apiRef[0].reportDiagnostic(new FeaturevisorDiagnostic() + .level(FeaturevisorLogLevel.WARN) + .code("from_reporter") + .message("from reporter")); + + assertEquals(1, received.size()); + assertEquals("from_reporter", received.get(0).getCode()); + + sdk.removeModule("observer"); + apiRef[0].reportDiagnostic(new FeaturevisorDiagnostic() + .level(FeaturevisorLogLevel.WARN) + .code("after_remove") + .message("after remove")); + + assertEquals(1, received.size()); + } + + @Test + public void testCloseClosesModules() { + final boolean[] closed = {false}; + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .modules(List.of(new FeaturevisorModule("close-test") + .close(() -> closed[0] = true)))); + + sdk.close(); + + assertTrue(closed[0]); + } + @Test public void testHonourSimpleRequiredFeatures() { // Test with required feature disabled @@ -855,7 +1046,7 @@ public void testHonourSimpleRequiredFeatures() { return; } - Featurevisor sdk = new Featurevisor(new Featurevisor.Options().datafile(datafile)); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafile)); // should be disabled because required is disabled assertFalse(sdk.isEnabled("myKey")); @@ -903,7 +1094,7 @@ public void testHonourSimpleRequiredFeatures() { return; } - Featurevisor sdk2 = new Featurevisor(new Featurevisor.Options().datafile(datafileEnabled)); + Featurevisor sdk2 = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafileEnabled)); assertTrue(sdk2.isEnabled("myKey")); } @@ -974,7 +1165,7 @@ public void testHonourRequiredFeaturesWithVariation() { return; } - Featurevisor sdk = new Featurevisor(new Featurevisor.Options().datafile(datafile)); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafile)); assertFalse(sdk.isEnabled("myKey")); @@ -1043,7 +1234,7 @@ public void testHonourRequiredFeaturesWithVariation() { return; } - Featurevisor sdk2 = new Featurevisor(new Featurevisor.Options().datafile(datafileDesired)); + Featurevisor sdk2 = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafileDesired)); assertTrue(sdk2.isEnabled("myKey")); } @@ -1128,17 +1319,14 @@ public void testEmitWarningsForDeprecatedFeature() { final int[] deprecatedCount = {0}; - Logger customLogger = Logger.createLogger(new Logger.CreateLoggerOptions() - .handler((level, message, details) -> { - if (level == Logger.LogLevel.WARN && message.contains("is deprecated")) { + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() + .datafile(datafile) + .onDiagnostic(diagnostic -> { + if ("deprecated_feature".equals(diagnostic.getCode())) { deprecatedCount[0] += 1; } })); - Featurevisor sdk = new Featurevisor(new Featurevisor.Options() - .datafile(datafile) - .logger(customLogger)); - Map context = Map.of( "userId", "123" ); @@ -1212,7 +1400,7 @@ public void testGetVariation() { return; } - Featurevisor sdk = new Featurevisor(new Featurevisor.Options().datafile(datafile)); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafile)); Map context = Map.of( "userId", "123" @@ -1291,7 +1479,7 @@ public void testGetVariable() { return; } - Featurevisor sdk = new Featurevisor(new Featurevisor.Options().datafile(datafile)); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafile)); Map context = Map.of( "userId", "123" @@ -1353,7 +1541,7 @@ public void testCheckIfEnabledForOverriddenFlagsFromRules() { return; } - Featurevisor sdk = new Featurevisor(new Featurevisor.Options().datafile(datafile)); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafile)); // Test with German user (should be enabled) Map context1 = new HashMap<>(); @@ -1450,7 +1638,7 @@ public void testGetVariationWithForceRules() { return; } - Featurevisor sdk = new Featurevisor(new Featurevisor.Options().datafile(datafile)); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafile)); Map context = Map.of( "userId", "123" @@ -1481,12 +1669,12 @@ public void testGetVariationWithForceRules() { public void testCheckIfEnabledForMutuallyExclusiveFeatures() { final int[] bucketValue = {10000}; - // Create hook to control bucket value - HooksManager.Hook hook = new HooksManager.Hook("unit-test"); - hook.setBucketValue(options -> bucketValue[0]); + // Create module to control bucket value + FeaturevisorModule module = new FeaturevisorModule("unit-test"); + module.setBucketValue(options -> bucketValue[0]); - List hooks = new ArrayList<>(); - hooks.add(hook); + List modules = new ArrayList<>(); + modules.add(module); // Create datafile content using JSON string for better readability String datafileJson = """ @@ -1521,9 +1709,9 @@ public void testCheckIfEnabledForMutuallyExclusiveFeatures() { return; } - Featurevisor sdk = new Featurevisor(new Featurevisor.Options() + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions() .datafile(datafile) - .hooks(hooks)); + .modules(modules)); // Test with no context (should be disabled) assertFalse(sdk.isEnabled("test")); @@ -1715,7 +1903,7 @@ public void testGetVariableComprehensive() { return; } - Featurevisor sdk = new Featurevisor(new Featurevisor.Options().datafile(datafile)); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafile)); Map context = Map.of( "userId", "123" @@ -1834,7 +2022,7 @@ public void testGetVariablesWithoutAnyVariationsWithSegments() { return; } - Featurevisor sdk = new Featurevisor(new Featurevisor.Options().datafile(datafile)); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafile)); Map defaultContext = Map.of( "userId", "123" @@ -1901,7 +2089,7 @@ public void testCheckIfEnabledForIndividuallyNamedSegments() { return; } - Featurevisor sdk = new Featurevisor(new Featurevisor.Options().datafile(datafile)); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafile)); // Test with no context (should be disabled) assertFalse(sdk.isEnabled("test")); diff --git a/src/test/java/com/featurevisor/sdk/HelpersTest.java b/src/test/java/com/featurevisor/sdk/HelpersTest.java index 5ca6d86..7b5cf19 100644 --- a/src/test/java/com/featurevisor/sdk/HelpersTest.java +++ b/src/test/java/com/featurevisor/sdk/HelpersTest.java @@ -27,8 +27,8 @@ public void testBooleanValueReturnedAsIs() { @Test public void testBooleanStrictness() { - assertEquals(Boolean.FALSE, Helpers.getValueByType("true", "boolean")); - assertEquals(Boolean.FALSE, Helpers.getValueByType(1, "boolean")); + assertNull(Helpers.getValueByType("true", "boolean")); + assertNull(Helpers.getValueByType(1, "boolean")); } @Test @@ -51,12 +51,13 @@ public void testArrayValueReturnedAsIs() { @Test public void testIntegerParsing() { - assertEquals(Integer.valueOf(1), Helpers.getValueByType("1", "integer")); + assertNull(Helpers.getValueByType("1", "integer")); + assertNull(Helpers.getValueByType(1.5d, "integer")); } @Test public void testDoubleParsing() { - assertEquals(1.1d, Helpers.getValueByType("1.1", "double")); + assertNull(Helpers.getValueByType("1.1", "double")); } @Test diff --git a/src/test/java/com/featurevisor/sdk/HooksManagerTest.java b/src/test/java/com/featurevisor/sdk/HooksManagerTest.java deleted file mode 100644 index ad97326..0000000 --- a/src/test/java/com/featurevisor/sdk/HooksManagerTest.java +++ /dev/null @@ -1,222 +0,0 @@ -package com.featurevisor.sdk; - -import com.featurevisor.sdk.Bucket; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.BeforeEach; -import static org.junit.jupiter.api.Assertions.*; - -import java.util.Map; -import java.util.HashMap; -import java.util.List; - -/** - * Test class for HooksManager functionality - */ -public class HooksManagerTest { - - private HooksManager hooksManager; - private Logger logger; - - @BeforeEach - void setUp() { - logger = new Logger(); - HooksManager.HooksManagerOptions options = new HooksManager.HooksManagerOptions(logger); - hooksManager = new HooksManager(options); - } - - @Test - void testAddAndRemoveHook() { - // Create a simple hook - HooksManager.Hook hook = new HooksManager.Hook("test-hook"); - - // Add hook - Runnable removeHook = hooksManager.add(hook); - assertNotNull(removeHook); - - // Verify hook was added - List hooks = hooksManager.getAll(); - assertEquals(1, hooks.size()); - assertEquals("test-hook", hooks.get(0).getName()); - - // Remove hook using returned function - removeHook.run(); - - // Verify hook was removed - hooks = hooksManager.getAll(); - assertEquals(0, hooks.size()); - } - - @Test - void testRemoveHookByName() { - // Create hooks - HooksManager.Hook hook1 = new HooksManager.Hook("hook1"); - HooksManager.Hook hook2 = new HooksManager.Hook("hook2"); - - // Add hooks - hooksManager.add(hook1); - hooksManager.add(hook2); - - // Verify both hooks are present - assertEquals(2, hooksManager.getAll().size()); - - // Remove one hook by name - hooksManager.remove("hook1"); - - // Verify only one hook remains - List hooks = hooksManager.getAll(); - assertEquals(1, hooks.size()); - assertEquals("hook2", hooks.get(0).getName()); - } - - @Test - void testDuplicateHookName() { - // Create hooks with same name - HooksManager.Hook hook1 = new HooksManager.Hook("duplicate"); - HooksManager.Hook hook2 = new HooksManager.Hook("duplicate"); - - // Add first hook - Runnable removeHook1 = hooksManager.add(hook1); - assertNotNull(removeHook1); - - // Try to add second hook with same name - Runnable removeHook2 = hooksManager.add(hook2); - assertNull(removeHook2); // Should return null for duplicate - - // Verify only one hook exists - assertEquals(1, hooksManager.getAll().size()); - } - - @Test - void testBeforeHook() { - // Create before hook that modifies context - HooksManager.Hook beforeHook = new HooksManager.Hook("before-test") - .before(options -> { - Map context = options.getContext(); - if (context == null) { - context = new HashMap<>(); - } - context.put("modified", true); - return options.copy().context(context); - }); - - hooksManager.add(beforeHook); - - // Create test options - Map originalContext = new HashMap<>(); - originalContext.put("original", true); - EvaluateOptions options = new EvaluateOptions("flag", "test-feature") - .context(originalContext); - - // Execute before hooks - EvaluateOptions modifiedOptions = hooksManager.executeBeforeHooks(options); - - // Verify context was modified - Map modifiedContext = modifiedOptions.getContext(); - assertTrue((Boolean) modifiedContext.get("original")); - assertTrue((Boolean) modifiedContext.get("modified")); - } - - @Test - void testBucketKeyHook() { - // Create bucket key hook - HooksManager.Hook bucketKeyHook = new HooksManager.Hook("bucket-key-test") - .bucketKey(options -> { - String originalKey = options.getBucketKey(); - return "modified:" + originalKey; - }); - - hooksManager.add(bucketKeyHook); - - // Create test options - Map context = new HashMap<>(); - Bucket bucketBy = new Bucket("userId"); - HooksManager.ConfigureBucketKeyOptions options = - new HooksManager.ConfigureBucketKeyOptions("test-feature", context, bucketBy, "original-key"); - - // Execute bucket key hooks - String modifiedKey = hooksManager.executeBucketKeyHooks(options); - - // Verify key was modified - assertEquals("modified:original-key", modifiedKey); - } - - @Test - void testBucketValueHook() { - // Create bucket value hook - HooksManager.Hook bucketValueHook = new HooksManager.Hook("bucket-value-test") - .bucketValue(options -> { - int originalValue = options.getBucketValue(); - return originalValue + 10; - }); - - hooksManager.add(bucketValueHook); - - // Create test options - Map context = new HashMap<>(); - HooksManager.ConfigureBucketValueOptions options = - new HooksManager.ConfigureBucketValueOptions("test-feature", "bucket-key", context, 50); - - // Execute bucket value hooks - int modifiedValue = hooksManager.executeBucketValueHooks(options); - - // Verify value was modified - assertEquals(60, modifiedValue); - } - - @Test - void testAfterHook() { - // Create after hook that modifies evaluation - HooksManager.Hook afterHook = new HooksManager.Hook("after-test") - .after((evaluation, options) -> { - return evaluation.copy().enabled(true); - }); - - hooksManager.add(afterHook); - - // Create test evaluation and options - Evaluation evaluation = new Evaluation("flag", "test-feature", "allocated") - .enabled(false); - EvaluateOptions options = new EvaluateOptions("flag", "test-feature"); - - // Execute after hooks - Evaluation modifiedEvaluation = hooksManager.executeAfterHooks(evaluation, options); - - // Verify evaluation was modified - assertTrue(modifiedEvaluation.getEnabled()); - } - - @Test - void testMultipleHooks() { - // Create multiple hooks - HooksManager.Hook beforeHook = new HooksManager.Hook("before1") - .before(options -> { - Map context = options.getContext(); - if (context == null) { - context = new HashMap<>(); - } - context.put("hook1", true); - return options.copy().context(context); - }); - - HooksManager.Hook beforeHook2 = new HooksManager.Hook("before2") - .before(options -> { - Map context = options.getContext(); - context.put("hook2", true); - return options.copy().context(context); - }); - - hooksManager.add(beforeHook); - hooksManager.add(beforeHook2); - - // Create test options - EvaluateOptions options = new EvaluateOptions("flag", "test-feature"); - - // Execute before hooks - EvaluateOptions modifiedOptions = hooksManager.executeBeforeHooks(options); - - // Verify both hooks were executed - Map context = modifiedOptions.getContext(); - assertTrue((Boolean) context.get("hook1")); - assertTrue((Boolean) context.get("hook2")); - } -} diff --git a/src/test/java/com/featurevisor/sdk/JsonNodeVariableMethodsTest.java b/src/test/java/com/featurevisor/sdk/JsonNodeVariableMethodsTest.java index 345f695..696c235 100644 --- a/src/test/java/com/featurevisor/sdk/JsonNodeVariableMethodsTest.java +++ b/src/test/java/com/featurevisor/sdk/JsonNodeVariableMethodsTest.java @@ -61,8 +61,8 @@ public class JsonNodeVariableMethodsTest { @Test public void testJsonNodeMethodsAndMalformedJsonHandling() throws Exception { - Featurevisor sdk = Featurevisor.createInstance( - new Featurevisor.Options().datafile(DatafileContent.fromJson(DATAFILE_JSON)) + Featurevisor sdk = Featurevisor.createFeaturevisor( + new Featurevisor.FeaturevisorOptions().datafile(DatafileContent.fromJson(DATAFILE_JSON)) ); Map context = Map.of("userId", "123"); @@ -94,8 +94,8 @@ public void testJsonNodeMethodsAndMalformedJsonHandling() throws Exception { @Test public void testChildInstanceJsonNodeParity() throws Exception { - Featurevisor sdk = Featurevisor.createInstance( - new Featurevisor.Options().datafile(DatafileContent.fromJson(DATAFILE_JSON)) + Featurevisor sdk = Featurevisor.createFeaturevisor( + new Featurevisor.FeaturevisorOptions().datafile(DatafileContent.fromJson(DATAFILE_JSON)) ); ChildInstance child = sdk.spawn(Map.of("userId", "123")); diff --git a/src/test/java/com/featurevisor/sdk/LoggerTest.java b/src/test/java/com/featurevisor/sdk/LoggerTest.java index 803017f..13e9867 100644 --- a/src/test/java/com/featurevisor/sdk/LoggerTest.java +++ b/src/test/java/com/featurevisor/sdk/LoggerTest.java @@ -42,7 +42,7 @@ public void testCreateLoggerWithDefaultOptions() { @Test public void testCreateLoggerWithCustomLevel() { - Logger logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.DEBUG)); + Logger logger = Logger.createLogger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.DEBUG)); assertNotNull(logger); assertTrue(logger instanceof Logger); } @@ -82,7 +82,7 @@ public void testLoggerConstructorWithDefaultLevel() { @Test public void testLoggerConstructorWithProvidedLevel() { - Logger logger = new Logger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.DEBUG)); + Logger logger = new Logger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.DEBUG)); logger.debug("debug message"); // Debug should be logged with debug level @@ -126,7 +126,7 @@ public void testLoggerConstructorWithProvidedHandler() { @Test public void testSetLevel() { - Logger logger = new Logger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.INFO)); + Logger logger = new Logger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.INFO)); // Debug should not be logged initially logger.debug("debug message"); @@ -137,7 +137,7 @@ public void testSetLevel() { outputStream.reset(); // Set to debug level - logger.setLevel(Logger.LogLevel.DEBUG); + logger.setLevel(FeaturevisorLogLevel.DEBUG); logger.debug("debug message"); output = outputStream.toString(); assertTrue(output.contains("debug message")); @@ -145,9 +145,9 @@ public void testSetLevel() { @Test public void testLogLevelFilteringErrorMessages() { - Logger.LogLevel[] levels = {Logger.LogLevel.DEBUG, Logger.LogLevel.INFO, Logger.LogLevel.WARN, Logger.LogLevel.ERROR}; + FeaturevisorLogLevel[] levels = {FeaturevisorLogLevel.DEBUG, FeaturevisorLogLevel.INFO, FeaturevisorLogLevel.WARN, FeaturevisorLogLevel.ERROR}; - for (Logger.LogLevel level : levels) { + for (FeaturevisorLogLevel level : levels) { Logger logger = new Logger(new Logger.CreateLoggerOptions().level(level)); logger.error("error message"); @@ -161,7 +161,7 @@ public void testLogLevelFilteringErrorMessages() { @Test public void testLogLevelFilteringWarnMessages() { - Logger logger = new Logger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.WARN)); + Logger logger = new Logger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.WARN)); logger.warn("warn message"); String output = outputStream.toString(); @@ -176,7 +176,7 @@ public void testLogLevelFilteringWarnMessages() { @Test public void testLogLevelFilteringInfoMessages() { - Logger logger = new Logger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.WARN)); + Logger logger = new Logger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.WARN)); logger.info("info message"); String output = outputStream.toString(); @@ -185,7 +185,7 @@ public void testLogLevelFilteringInfoMessages() { @Test public void testLogLevelFilteringDebugMessages() { - Logger logger = new Logger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.INFO)); + Logger logger = new Logger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.INFO)); logger.debug("debug message"); String output = outputStream.toString(); @@ -194,7 +194,7 @@ public void testLogLevelFilteringDebugMessages() { @Test public void testLogLevelFilteringDebugLevel() { - Logger logger = new Logger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.DEBUG)); + Logger logger = new Logger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.DEBUG)); logger.debug("debug message"); String output = outputStream.toString(); @@ -221,7 +221,7 @@ public void testLogLevelFilteringDebugLevel() { @Test public void testConvenienceMethods() { - Logger logger = new Logger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.DEBUG)); + Logger logger = new Logger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.DEBUG)); // Test debug method logger.debug("debug message"); @@ -252,7 +252,7 @@ public void testConvenienceMethods() { @Test public void testHandleDetailsParameter() { - Logger logger = new Logger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.INFO)); + Logger logger = new Logger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.INFO)); Map details = new HashMap<>(); details.put("key", "value"); @@ -280,12 +280,12 @@ public void testLogMethodWithCustomHandler() { Logger logger = new Logger(new Logger.CreateLoggerOptions() .handler(customHandler) - .level(Logger.LogLevel.DEBUG)); + .level(FeaturevisorLogLevel.DEBUG)); Map details = new HashMap<>(); details.put("test", true); - logger.log(Logger.LogLevel.INFO, "test message", details); + logger.log(FeaturevisorLogLevel.INFO, "test message", details); assertTrue(handlerCalled[0]); assertEquals("info", capturedLevel[0]); @@ -303,9 +303,9 @@ public void testLogMethodWithLevelFiltering() { Logger logger = new Logger(new Logger.CreateLoggerOptions() .handler(customHandler) - .level(Logger.LogLevel.WARN)); + .level(FeaturevisorLogLevel.WARN)); - logger.log(Logger.LogLevel.DEBUG, "debug message", null); + logger.log(FeaturevisorLogLevel.DEBUG, "debug message", null); assertFalse(handlerCalled[0]); } @@ -313,7 +313,7 @@ public void testLogMethodWithLevelFiltering() { @Test public void testDefaultLogHandler() { // Test that default handler works through the logger - Logger logger = new Logger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.DEBUG)); + Logger logger = new Logger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.DEBUG)); // Test debug level logger.debug("debug message"); @@ -370,18 +370,18 @@ public void testDefaultLogHandlerWithProvidedDetails() { @Test public void testLogLevelEnumValues() { - assertEquals("DEBUG", Logger.LogLevel.DEBUG.name()); - assertEquals("INFO", Logger.LogLevel.INFO.name()); - assertEquals("WARN", Logger.LogLevel.WARN.name()); - assertEquals("ERROR", Logger.LogLevel.ERROR.name()); - assertEquals("FATAL", Logger.LogLevel.FATAL.name()); + assertEquals("DEBUG", FeaturevisorLogLevel.DEBUG.name()); + assertEquals("INFO", FeaturevisorLogLevel.INFO.name()); + assertEquals("WARN", FeaturevisorLogLevel.WARN.name()); + assertEquals("ERROR", FeaturevisorLogLevel.ERROR.name()); + assertEquals("FATAL", FeaturevisorLogLevel.FATAL.name()); } @Test public void testCreateLoggerOptionsBuilder() { Logger.LogHandler handler = (level, message, details) -> {}; Logger.CreateLoggerOptions options = new Logger.CreateLoggerOptions() - .level(Logger.LogLevel.DEBUG) + .level(FeaturevisorLogLevel.DEBUG) .handler(handler); // Test that the builder pattern works correctly @@ -392,15 +392,15 @@ public void testCreateLoggerOptionsBuilder() { @Test public void testLogLevelEnumOrdinal() { // Test that log levels are in the correct order - assertTrue(Logger.LogLevel.DEBUG.ordinal() < Logger.LogLevel.INFO.ordinal()); - assertTrue(Logger.LogLevel.INFO.ordinal() < Logger.LogLevel.WARN.ordinal()); - assertTrue(Logger.LogLevel.WARN.ordinal() < Logger.LogLevel.ERROR.ordinal()); - assertTrue(Logger.LogLevel.ERROR.ordinal() < Logger.LogLevel.FATAL.ordinal()); + assertTrue(FeaturevisorLogLevel.DEBUG.ordinal() < FeaturevisorLogLevel.INFO.ordinal()); + assertTrue(FeaturevisorLogLevel.INFO.ordinal() < FeaturevisorLogLevel.WARN.ordinal()); + assertTrue(FeaturevisorLogLevel.WARN.ordinal() < FeaturevisorLogLevel.ERROR.ordinal()); + assertTrue(FeaturevisorLogLevel.ERROR.ordinal() < FeaturevisorLogLevel.FATAL.ordinal()); } @Test public void testLoggerWithNullMessage() { - Logger logger = new Logger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.DEBUG)); + Logger logger = new Logger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.DEBUG)); // Should not throw exception with null message assertDoesNotThrow(() -> { @@ -410,7 +410,7 @@ public void testLoggerWithNullMessage() { @Test public void testLoggerWithEmptyMessage() { - Logger logger = new Logger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.DEBUG)); + Logger logger = new Logger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.DEBUG)); // Should handle empty message assertDoesNotThrow(() -> { @@ -423,7 +423,7 @@ public void testLoggerWithEmptyMessage() { @Test public void testLoggerWithNullDetails() { - Logger logger = new Logger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.DEBUG)); + Logger logger = new Logger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.DEBUG)); // Should handle null details assertDoesNotThrow(() -> { @@ -436,7 +436,7 @@ public void testLoggerWithNullDetails() { @Test public void testLoggerWithComplexDetails() { - Logger logger = new Logger(new Logger.CreateLoggerOptions().level(Logger.LogLevel.DEBUG)); + Logger logger = new Logger(new Logger.CreateLoggerOptions().level(FeaturevisorLogLevel.DEBUG)); Map complexDetails = new HashMap<>(); complexDetails.put("string", "value"); diff --git a/src/test/java/com/featurevisor/sdk/ModulesManagerTest.java b/src/test/java/com/featurevisor/sdk/ModulesManagerTest.java new file mode 100644 index 0000000..859baa1 --- /dev/null +++ b/src/test/java/com/featurevisor/sdk/ModulesManagerTest.java @@ -0,0 +1,258 @@ +package com.featurevisor.sdk; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +public class ModulesManagerTest { + + private ModulesManager modulesManager; + private List diagnostics; + + @BeforeEach + void setUp() { + diagnostics = new ArrayList<>(); + modulesManager = new ModulesManager(new ModulesManager.ModulesManagerOptions() + .diagnosticReporter((diagnostic, module) -> diagnostics.add(diagnostic)) + .moduleApiFactory(module -> new FeaturevisorModuleApi() { + @Override + public String getRevision() { return "unknown"; } + + @Override + public Runnable onDiagnostic(FeaturevisorDiagnosticHandler handler) { return () -> {}; } + + @Override + public Runnable onDiagnostic(FeaturevisorDiagnosticHandler handler, FeaturevisorModuleDiagnosticOptions options) { return () -> {}; } + + @Override + public void reportDiagnostic(FeaturevisorDiagnostic diagnostic) {} + })); + } + + @Test + void testAddAndRemoveModule() { + AtomicBoolean closeCalled = new AtomicBoolean(false); + FeaturevisorModule module = new FeaturevisorModule("test-module") + .close(() -> closeCalled.set(true)); + + Runnable removeModule = modulesManager.add(module); + assertNotNull(removeModule); + + List modules = modulesManager.getAll(); + assertEquals(1, modules.size()); + assertEquals("test-module", modules.get(0).getName()); + + removeModule.run(); + removeModule.run(); + + assertEquals(0, modulesManager.getAll().size()); + assertTrue(closeCalled.get()); + } + + @Test + void testRemoveModuleByName() { + AtomicBoolean closeCalled = new AtomicBoolean(false); + modulesManager.add(new FeaturevisorModule("module1") + .close(() -> closeCalled.set(true))); + modulesManager.add(new FeaturevisorModule("module2")); + + assertEquals(2, modulesManager.getAll().size()); + + modulesManager.remove("module1"); + + List modules = modulesManager.getAll(); + assertEquals(1, modules.size()); + assertEquals("module2", modules.get(0).getName()); + assertTrue(closeCalled.get()); + } + + @Test + void testDuplicateModuleNameReportsDiagnostic() { + Runnable removeModule1 = modulesManager.add(new FeaturevisorModule("duplicate")); + assertNotNull(removeModule1); + + Runnable removeModule2 = modulesManager.add(new FeaturevisorModule("duplicate")); + assertNull(removeModule2); + + assertEquals(1, modulesManager.getAll().size()); + assertEquals(1, diagnostics.size()); + assertEquals("duplicate_module", diagnostics.get(0).getCode()); + assertEquals("duplicate", diagnostics.get(0).getModuleName()); + assertEquals(FeaturevisorLogLevel.ERROR, diagnostics.get(0).getLevel()); + } + + @Test + void testSetupAndCloseModule() { + AtomicBoolean setupCalled = new AtomicBoolean(false); + AtomicBoolean closeCalled = new AtomicBoolean(false); + + modulesManager.add(new FeaturevisorModule("lifecycle") + .setup(api -> { + setupCalled.set(true); + assertEquals("unknown", api.getRevision()); + }) + .close(() -> closeCalled.set(true))); + + assertTrue(setupCalled.get()); + + modulesManager.closeAll(); + + assertTrue(closeCalled.get()); + assertEquals(0, modulesManager.getAll().size()); + } + + @Test + void testSetupFailureDoesNotRegisterModuleAndClosesIt() { + AtomicBoolean closeCalled = new AtomicBoolean(false); + + Runnable unsubscribe = modulesManager.add(new FeaturevisorModule("broken-setup") + .setup(api -> { throw new RuntimeException("setup failed"); }) + .close(() -> closeCalled.set(true))); + + assertNull(unsubscribe); + assertTrue(closeCalled.get()); + assertTrue(modulesManager.getAll().isEmpty()); + assertTrue(diagnostics.stream().anyMatch(diagnostic -> + "module_setup_error".equals(diagnostic.getCode()) && + "broken-setup".equals(diagnostic.getModuleName()) + )); + } + + @Test + void testCloseErrorsAreReportedAndDoNotStopCleanup() { + List closed = new ArrayList<>(); + + modulesManager.add(new FeaturevisorModule("first") + .close(() -> { + closed.add("first"); + throw new RuntimeException("first close failed"); + })); + modulesManager.add(new FeaturevisorModule("second") + .close(() -> closed.add("second"))); + + modulesManager.closeAll(); + + assertEquals(List.of("first", "second"), closed); + assertTrue(diagnostics.stream().anyMatch(diagnostic -> + "module_close_error".equals(diagnostic.getCode()) && + "first".equals(diagnostic.getModuleName()) && + FeaturevisorLogLevel.ERROR.equals(diagnostic.getLevel()) && + diagnostic.getOriginalError() instanceof RuntimeException && + ((RuntimeException) diagnostic.getOriginalError()).getMessage().contains("first close failed") + )); + } + + @Test + void testUnsubscribeReportsCloseErrorsOnce() { + Runnable unsubscribe = modulesManager.add(new FeaturevisorModule("dynamic") + .close(() -> { + throw new RuntimeException("dynamic close failed"); + })); + + assertNotNull(unsubscribe); + unsubscribe.run(); + unsubscribe.run(); + + assertEquals(1, diagnostics.stream().filter(diagnostic -> + "module_close_error".equals(diagnostic.getCode()) && + "dynamic".equals(diagnostic.getModuleName()) + ).count()); + } + + @Test + void testBeforeModule() { + FeaturevisorModule beforeModule = new FeaturevisorModule("before-test") + .before(options -> { + Map context = options.getContext(); + if (context == null) { + context = new HashMap<>(); + } + context.put("modified", true); + return options.copy().context(context); + }); + + modulesManager.add(beforeModule); + + Map originalContext = new HashMap<>(); + originalContext.put("original", true); + EvaluateOptions options = new EvaluateOptions("flag", "test-feature") + .context(originalContext); + + EvaluateOptions modifiedOptions = modulesManager.executeBeforeModules(options); + + Map modifiedContext = modifiedOptions.getContext(); + assertTrue((Boolean) modifiedContext.get("original")); + assertTrue((Boolean) modifiedContext.get("modified")); + } + + @Test + void testBucketKeyModule() { + modulesManager.add(new FeaturevisorModule("bucket-key-test") + .bucketKey(options -> "modified:" + options.getBucketKey())); + + Map context = new HashMap<>(); + Bucket bucketBy = new Bucket("userId"); + ModulesManager.ConfigureBucketKeyOptions options = + new ModulesManager.ConfigureBucketKeyOptions("test-feature", context, bucketBy, "original-key"); + + assertEquals("modified:original-key", modulesManager.executeBucketKeyModules(options)); + } + + @Test + void testBucketValueModule() { + modulesManager.add(new FeaturevisorModule("bucket-value-test") + .bucketValue(options -> options.getBucketValue() + 10)); + + Map context = new HashMap<>(); + ModulesManager.ConfigureBucketValueOptions options = + new ModulesManager.ConfigureBucketValueOptions("test-feature", "bucket-key", context, 50); + + assertEquals(60, modulesManager.executeBucketValueModules(options)); + } + + @Test + void testAfterModule() { + modulesManager.add(new FeaturevisorModule("after-test") + .after((evaluation, options) -> evaluation.copy().enabled(true))); + + Evaluation evaluation = new Evaluation("flag", "test-feature", "allocated") + .enabled(false); + EvaluateOptions options = new EvaluateOptions("flag", "test-feature"); + + Evaluation modifiedEvaluation = modulesManager.executeAfterModules(evaluation, options); + + assertTrue(modifiedEvaluation.getEnabled()); + } + + @Test + void testMultipleModules() { + modulesManager.add(new FeaturevisorModule("before1") + .before(options -> { + Map context = options.getContext(); + if (context == null) { + context = new HashMap<>(); + } + context.put("module1", true); + return options.copy().context(context); + })); + + modulesManager.add(new FeaturevisorModule("before2") + .before(options -> { + Map context = options.getContext(); + context.put("module2", true); + return options.copy().context(context); + })); + + EvaluateOptions modifiedOptions = modulesManager.executeBeforeModules(new EvaluateOptions("flag", "test-feature")); + + Map context = modifiedOptions.getContext(); + assertTrue((Boolean) context.get("module1")); + assertTrue((Boolean) context.get("module2")); + } +} diff --git a/src/test/java/com/featurevisor/sdk/TypedVariableMethodsTest.java b/src/test/java/com/featurevisor/sdk/TypedVariableMethodsTest.java index 1380434..9f9303e 100644 --- a/src/test/java/com/featurevisor/sdk/TypedVariableMethodsTest.java +++ b/src/test/java/com/featurevisor/sdk/TypedVariableMethodsTest.java @@ -63,7 +63,7 @@ public void testTypedVariableDecoding() throws Exception { } """; - Featurevisor sdk = Featurevisor.createInstance(new Featurevisor.Options().datafile(DatafileContent.fromJson(datafileJson))); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(DatafileContent.fromJson(datafileJson))); Map context = Map.of("userId", "123"); List rollout = sdk.getVariableArray("typed", "rollout", context, RolloutStep.class); diff --git a/src/test/java/com/featurevisor/sdk/VariableOverrideParityTest.java b/src/test/java/com/featurevisor/sdk/VariableOverrideParityTest.java index 77d7315..1eeae30 100644 --- a/src/test/java/com/featurevisor/sdk/VariableOverrideParityTest.java +++ b/src/test/java/com/featurevisor/sdk/VariableOverrideParityTest.java @@ -65,7 +65,7 @@ public void testRuleVariableOverrideHasPriorityAndIndex() throws Exception { """; DatafileContent datafile = DatafileContent.fromJson(datafileJson); - Featurevisor sdk = Featurevisor.createInstance(new Featurevisor.Options().datafile(datafile)); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafile)); Evaluation evaluation = sdk.evaluateVariable("test", "config", Map.of("userId", "123", "country", "de")); assertEquals(Evaluation.REASON_VARIABLE_OVERRIDE_RULE, evaluation.getReason()); @@ -129,7 +129,7 @@ public void testVariationVariableOverrideHasReasonAndIndex() throws Exception { """; DatafileContent datafile = DatafileContent.fromJson(datafileJson); - Featurevisor sdk = Featurevisor.createInstance(new Featurevisor.Options().datafile(datafile)); + Featurevisor sdk = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions().datafile(datafile)); Evaluation evaluation = sdk.evaluateVariable("test", "config", Map.of("userId", "123", "country", "de")); assertEquals(Evaluation.REASON_VARIABLE_OVERRIDE_VARIATION, evaluation.getReason());