From e6942349b3eb889bb512a499770f2f453745399f Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Tue, 14 Jul 2026 21:34:42 +0200 Subject: [PATCH] feat: OpenFeature provider for Java --- README.md | 45 ++++ pom.xml | 9 + .../FeaturevisorOpenFeatureProvider.java | 255 ++++++++++++++++++ .../FeaturevisorOpenFeatureProviderTest.java | 89 ++++++ 4 files changed, 398 insertions(+) create mode 100644 src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java create mode 100644 src/test/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProviderTest.java diff --git a/README.md b/README.md index d0d9342..32e8ae8 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ This SDK supports Featurevisor v3 behavior and v2 datafiles. Generated datafiles - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) +- [OpenFeature](#openfeature) - [CLI usage](#cli-usage) - [Test](#test) - [Benchmark](#benchmark) @@ -834,6 +835,50 @@ Learn more about assessing distribution [here](https://featurevisor.com/docs/cli $ mvn exec:java -Dexec.mainClass="com.featurevisor.cli.CLI" -Dexec.args="assess-distribution --projectDirectoryPath=/absolute/path/to/your/featurevisor/project --environment=production --feature=foo --variation --context='{\"country\": \"nl\"}' --populateUuid=userId --populateUuid=deviceId --n=1000" ``` +## OpenFeature + +Add the official OpenFeature SDK next to Featurevisor: + +```xml + + dev.openfeature + sdk + 1.20.2 + +``` + +```java +import com.featurevisor.openfeature.FeaturevisorOpenFeatureProvider; +import com.featurevisor.sdk.Featurevisor; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; + +var provider = new FeaturevisorOpenFeatureProvider( + new Featurevisor.FeaturevisorOptions().datafile(datafileContent) +); + +var api = OpenFeatureAPI.getInstance(); +api.setProviderAndWait(provider); + +var client = api.getClient(); +boolean enabled = client.getBooleanValue("checkout", false, new ImmutableContext("user-123")); +``` + +Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Lists, structures, and JSON variables use the object resolver. + +OpenFeature's targeting key maps to `userId` by default. `targetingKeyField`, `keySeparator`, and `variationKey` on `FeaturevisorOpenFeatureProvider.Options` can customize the mapping. + +You can also reuse an existing Featurevisor instance: + +```java +Featurevisor featurevisor = Featurevisor.createFeaturevisor(featurevisorOptions); +var provider = new FeaturevisorOpenFeatureProvider(featurevisor); +``` + +The caller owns an instance passed this way. Provider shutdown does not close it. Call `featurevisor.close()` when every consumer is finished with it. When the provider creates the instance from options, the provider owns and closes it. If both are configured, the existing instance takes precedence. + +See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, metadata, tracking, lifecycle, and providers for other languages. + ## Development of this package diff --git a/pom.xml b/pom.xml index c45a62a..b5b93d3 100644 --- a/pom.xml +++ b/pom.xml @@ -101,6 +101,15 @@ commons-exec 1.3 + + + + dev.openfeature + sdk + 1.20.2 + true + diff --git a/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java b/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java new file mode 100644 index 0000000..c282e37 --- /dev/null +++ b/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java @@ -0,0 +1,255 @@ +package com.featurevisor.openfeature; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.featurevisor.sdk.Evaluation; +import com.featurevisor.sdk.Emitter; +import com.featurevisor.sdk.Featurevisor; +import com.featurevisor.sdk.FeaturevisorDiagnosticHandler; +import com.featurevisor.sdk.VariableType; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.TrackingEventDetails; +import dev.openfeature.sdk.Value; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** OpenFeature provider backed by the Featurevisor v3 SDK. */ +public final class FeaturevisorOpenFeatureProvider implements FeatureProvider { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @FunctionalInterface + public interface TrackingHandler { + void track(String eventName, EvaluationContext context, TrackingEventDetails details); + } + + public static final class Options { + private Featurevisor featurevisor; + private Featurevisor.FeaturevisorOptions featurevisorOptions = new Featurevisor.FeaturevisorOptions(); + private String targetingKeyField = "userId"; + private String keySeparator = ":"; + private String variationKey = "variation"; + private TrackingHandler onTrack; + + public Options featurevisor(Featurevisor value) { this.featurevisor = value; return this; } + public Options featurevisorOptions(Featurevisor.FeaturevisorOptions value) { this.featurevisorOptions = value; return this; } + public Options targetingKeyField(String value) { this.targetingKeyField = value; return this; } + public Options keySeparator(String value) { this.keySeparator = value; return this; } + public Options variationKey(String value) { this.variationKey = value; return this; } + public Options onTrack(TrackingHandler value) { this.onTrack = value; return this; } + } + + private final Featurevisor featurevisor; + private final String targetingKeyField; + private final String keySeparator; + private final String variationKey; + private final TrackingHandler onTrack; + private final Emitter.UnsubscribeFunction datafileUnsubscribe; + private final boolean ownsFeaturevisor; + private String datafileError; + + public FeaturevisorOpenFeatureProvider(Options options) { + Options resolved = options != null ? options : new Options(); + this.targetingKeyField = nonEmpty(resolved.targetingKeyField, "userId"); + this.keySeparator = nonEmpty(resolved.keySeparator, ":"); + this.variationKey = nonEmpty(resolved.variationKey, "variation"); + this.onTrack = resolved.onTrack; + this.ownsFeaturevisor = resolved.featurevisor == null; + if (resolved.featurevisor != null) { + this.featurevisor = resolved.featurevisor; + } else { + Featurevisor.FeaturevisorOptions fvOptions = resolved.featurevisorOptions != null + ? resolved.featurevisorOptions : new Featurevisor.FeaturevisorOptions(); + if (fvOptions.getDatafileString() != null) { + try { + com.featurevisor.sdk.DatafileContent.fromJson(fvOptions.getDatafileString()); + } catch (Exception ignored) { + datafileError = "Could not parse datafile"; + } + } + FeaturevisorDiagnosticHandler original = fvOptions.getOnDiagnostic(); + fvOptions.onDiagnostic(diagnostic -> { + if ("invalid_datafile".equals(diagnostic.getCode())) datafileError = diagnostic.getMessage(); + if ("datafile_set".equals(diagnostic.getCode())) datafileError = null; + if (original != null) original.handle(diagnostic); + }); + this.featurevisor = Featurevisor.createFeaturevisor(fvOptions); + } + this.datafileUnsubscribe = this.featurevisor.on(Emitter.EventName.DATAFILE_SET, details -> datafileError = null); + } + + public FeaturevisorOpenFeatureProvider(Featurevisor.FeaturevisorOptions options) { + this(new Options().featurevisorOptions(options)); + } + + public FeaturevisorOpenFeatureProvider(Featurevisor featurevisor) { + this(new Options().featurevisor(featurevisor)); + } + + public Featurevisor getFeaturevisor() { return featurevisor; } + @Override public Metadata getMetadata() { return () -> "Featurevisor"; } + @Override public void shutdown() { + datafileUnsubscribe.unsubscribe(); + if (ownsFeaturevisor) featurevisor.close(); + } + @Override public void track(String name, EvaluationContext context, TrackingEventDetails details) { + if (onTrack != null) onTrack.track(name, context, details); + } + + @Override public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext context) { + return cast(resolve(key, defaultValue, context, "boolean"), defaultValue, Boolean.class); + } + @Override public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext context) { + return cast(resolve(key, defaultValue, context, "string"), defaultValue, String.class); + } + @Override public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext context) { + ProviderEvaluation result = resolve(key, defaultValue, context, "integer"); + Integer value = result.getValue() instanceof Number ? ((Number) result.getValue()).intValue() : defaultValue; + return copy(result, value); + } + @Override public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext context) { + ProviderEvaluation result = resolve(key, defaultValue, context, "number"); + Double value = result.getValue() instanceof Number ? ((Number) result.getValue()).doubleValue() : defaultValue; + return copy(result, value); + } + @Override public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext context) { + ProviderEvaluation result = resolve(key, defaultValue, context, "object"); + try { + return copy(result, toValue(result.getValue())); + } catch (Exception exception) { + return error(defaultValue, ErrorCode.TYPE_MISMATCH, "Flag \"" + key + "\" did not resolve to an object value", result.getFlagMetadata()); + } + } + + private ProviderEvaluation resolve(String flagKey, Object defaultValue, EvaluationContext context, String expectedType) { + if (datafileError != null) return error(defaultValue, ErrorCode.PARSE_ERROR, datafileError, ImmutableMetadata.EMPTY); + int separatorIndex = flagKey.indexOf(keySeparator); + String featureKey = separatorIndex < 0 ? flagKey : flagKey.substring(0, separatorIndex); + String selector = separatorIndex < 0 ? null : flagKey.substring(separatorIndex + keySeparator.length()); + Map fvContext = context != null ? normalizeMap(context.asObjectMap()) : new HashMap<>(); + if (context != null && context.getTargetingKey() != null && !context.getTargetingKey().isEmpty()) { + fvContext.put(targetingKeyField, context.getTargetingKey()); + } + + Evaluation evaluation; + Object value; + if (selector == null || selector.isEmpty()) { + if (!"boolean".equals(expectedType)) return typeMismatch(flagKey, defaultValue, expectedType, ImmutableMetadata.EMPTY); + evaluation = featurevisor.evaluateFlag(featureKey, fvContext); + value = evaluation.getEnabled(); + } else if (selector.equals(variationKey)) { + evaluation = featurevisor.evaluateVariation(featureKey, fvContext); + value = evaluation.getVariationValue() != null ? evaluation.getVariationValue() + : evaluation.getVariation() != null ? evaluation.getVariation().getValue() : null; + } else { + evaluation = featurevisor.evaluateVariable(featureKey, selector, fvContext); + value = evaluation.getVariableValue(); + if (evaluation.getVariableSchema() != null + && evaluation.getVariableSchema().getType() == VariableType.JSON + && value instanceof String) { + try { value = OBJECT_MAPPER.readValue((String) value, Object.class); } catch (Exception ignored) { } + } + } + + ImmutableMetadata metadata = metadata(evaluation); + ErrorCode errorCode = errorCode(evaluation.getReason()); + if (errorCode != null) return error(defaultValue, errorCode, errorMessage(evaluation), metadata); + if (value == null) return success(defaultValue, evaluation, metadata); + if (!matches(value, expectedType)) return typeMismatch(flagKey, defaultValue, expectedType, metadata); + return success(value, evaluation, metadata); + } + + private ProviderEvaluation success(Object value, Evaluation evaluation, ImmutableMetadata metadata) { + return ProviderEvaluation.builder().value(value).variant(variant(evaluation)).reason(reason(evaluation.getReason()).name()).flagMetadata(metadata).build(); + } + + private ImmutableMetadata metadata(Evaluation evaluation) { + ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder() + .addString("featureKey", evaluation.getFeatureKey()) + .addString("featurevisorReason", evaluation.getReason()) + .addString("schemaVersion", featurevisor.getSchemaVersion()); + if (featurevisor.getRevision() != null) builder.addString("revision", featurevisor.getRevision()); + if (evaluation.getVariableKey() != null) builder.addString("variableKey", evaluation.getVariableKey()); + if (evaluation.getRuleKey() != null) builder.addString("ruleKey", evaluation.getRuleKey()); + if (evaluation.getBucketKey() != null) builder.addString("bucketKey", evaluation.getBucketKey()); + if (evaluation.getBucketValue() != null) builder.addInteger("bucketValue", evaluation.getBucketValue()); + if (evaluation.getForceIndex() != null) builder.addInteger("forceIndex", evaluation.getForceIndex()); + if (evaluation.getVariableOverrideIndex() != null) builder.addInteger("variableOverrideIndex", evaluation.getVariableOverrideIndex()); + return builder.build(); + } + + private static Reason reason(String reason) { + if (List.of(Evaluation.REASON_FEATURE_NOT_FOUND, Evaluation.REASON_VARIABLE_NOT_FOUND, Evaluation.REASON_NO_VARIATIONS, Evaluation.REASON_ERROR).contains(reason)) return Reason.ERROR; + if (List.of(Evaluation.REASON_REQUIRED, Evaluation.REASON_FORCED, Evaluation.REASON_STICKY, Evaluation.REASON_RULE, Evaluation.REASON_VARIABLE_OVERRIDE_RULE, Evaluation.REASON_VARIABLE_OVERRIDE_VARIATION).contains(reason)) return Reason.TARGETING_MATCH; + if (Evaluation.REASON_ALLOCATED.equals(reason)) return Reason.SPLIT; + if (List.of(Evaluation.REASON_DISABLED, Evaluation.REASON_VARIATION_DISABLED, Evaluation.REASON_VARIABLE_DISABLED).contains(reason)) return Reason.DISABLED; + return Reason.DEFAULT; + } + + private static ErrorCode errorCode(String reason) { + if (List.of(Evaluation.REASON_FEATURE_NOT_FOUND, Evaluation.REASON_VARIABLE_NOT_FOUND, Evaluation.REASON_NO_VARIATIONS).contains(reason)) return ErrorCode.FLAG_NOT_FOUND; + if (Evaluation.REASON_ERROR.equals(reason)) return ErrorCode.GENERAL; + return null; + } + + private static String errorMessage(Evaluation evaluation) { + if (evaluation.getError() != null) return evaluation.getError().getMessage(); + if (Evaluation.REASON_FEATURE_NOT_FOUND.equals(evaluation.getReason())) return "Feature \"" + evaluation.getFeatureKey() + "\" was not found"; + if (Evaluation.REASON_VARIABLE_NOT_FOUND.equals(evaluation.getReason())) return "Variable \"" + evaluation.getVariableKey() + "\" was not found for feature \"" + evaluation.getFeatureKey() + "\""; + if (Evaluation.REASON_NO_VARIATIONS.equals(evaluation.getReason())) return "Feature \"" + evaluation.getFeatureKey() + "\" has no variations"; + return "Featurevisor evaluation failed"; + } + + private static String variant(Evaluation evaluation) { + if (evaluation.getVariationValue() != null) return evaluation.getVariationValue(); + return evaluation.getVariation() != null ? evaluation.getVariation().getValue() : null; + } + + private static boolean matches(Object value, String expected) { + if ("boolean".equals(expected)) return value instanceof Boolean; + if ("string".equals(expected)) return value instanceof String; + if ("integer".equals(expected)) return value instanceof Number && Math.rint(((Number) value).doubleValue()) == ((Number) value).doubleValue(); + if ("number".equals(expected)) return value instanceof Number && Double.isFinite(((Number) value).doubleValue()); + return value instanceof Map || value instanceof List; + } + + private static Map normalizeMap(Map input) { + Map result = new HashMap<>(); + input.forEach((key, value) -> result.put(key, normalize(value))); + return result; + } + private static Object normalize(Object value) { + if (value instanceof Instant) return value.toString(); + if (value instanceof Map) return normalizeMap((Map) value); + if (value instanceof List) { List result = new ArrayList<>(); for (Object item : (List) value) result.add(normalize(item)); return result; } + return value; + } + private static Value toValue(Object value) throws InstantiationException { + if (value instanceof Value) return (Value) value; + if (value instanceof Map) return new Value(Structure.mapToStructure((Map) value)); + if (value instanceof List) { List result = new ArrayList<>(); for (Object item : (List) value) result.add(toValue(item)); return new Value(result); } + return new Value(value); + } + private static ProviderEvaluation typeMismatch(String key, Object fallback, String expected, ImmutableMetadata metadata) { + return error(fallback, ErrorCode.TYPE_MISMATCH, "Flag \"" + key + "\" did not resolve to a " + expected + " value", metadata); + } + private static ProviderEvaluation error(T value, ErrorCode code, String message, ImmutableMetadata metadata) { + return ProviderEvaluation.builder().value(value).reason(Reason.ERROR.name()).errorCode(code).errorMessage(message).flagMetadata(metadata).build(); + } + private static ProviderEvaluation cast(ProviderEvaluation source, T fallback, Class type) { + T value = type.isInstance(source.getValue()) ? type.cast(source.getValue()) : fallback; + return copy(source, value); + } + private static ProviderEvaluation copy(ProviderEvaluation source, T value) { + return ProviderEvaluation.builder().value(value).variant(source.getVariant()).reason(source.getReason()).errorCode(source.getErrorCode()).errorMessage(source.getErrorMessage()).flagMetadata(source.getFlagMetadata()).build(); + } + private static String nonEmpty(String value, String fallback) { return value == null || value.isEmpty() ? fallback : value; } +} diff --git a/src/test/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProviderTest.java b/src/test/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProviderTest.java new file mode 100644 index 0000000..21d6708 --- /dev/null +++ b/src/test/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProviderTest.java @@ -0,0 +1,89 @@ +package com.featurevisor.openfeature; + +import com.featurevisor.sdk.DatafileContent; +import com.featurevisor.sdk.Featurevisor; +import com.featurevisor.sdk.FeaturevisorLogLevel; +import com.featurevisor.sdk.FeaturevisorModule; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class FeaturevisorOpenFeatureProviderTest { + private static final String DATAFILE = """ + {"schemaVersion":"2","revision":"openfeature-test","segments":{},"features":{"checkout":{ + "bucketBy":"userId", + "variations":[{"value":"on","variables":{"title":"Hello","count":3,"ratio":1.5,"visible":true,"items":["a"],"config":{"color":"blue"},"json":"{\\\"nested\\\":true}"}}], + "variablesSchema":{"title":{"type":"string","defaultValue":"Default"},"count":{"type":"integer","defaultValue":0},"ratio":{"type":"double","defaultValue":0},"visible":{"type":"boolean","defaultValue":false},"items":{"type":"array","defaultValue":[]},"config":{"type":"object","defaultValue":{}},"json":{"type":"json","defaultValue":"{}"}}, + "force":[{"conditions":{"attribute":"userId","operator":"equals","value":"forced-user"},"enabled":true,"variation":"on"}], + "traffic":[{"key":"all","segments":"*","percentage":100000,"variation":"on"}] + }}}"""; + + private Featurevisor.FeaturevisorOptions options() throws Exception { + return new Featurevisor.FeaturevisorOptions().datafile(DatafileContent.fromJson(DATAFILE)).logLevel(FeaturevisorLogLevel.FATAL); + } + + @AfterEach void closeOpenFeature() { OpenFeatureAPI.getInstance().shutdown(); } + + @Test void resolvesEveryTypeAndMapsTargetingKey() throws Exception { + FeaturevisorOpenFeatureProvider provider = new FeaturevisorOpenFeatureProvider(options()); + ImmutableContext context = new ImmutableContext("forced-user"); + assertTrue(provider.getBooleanEvaluation("checkout", false, context).getValue()); + assertEquals("on", provider.getStringEvaluation("checkout:variation", "fallback", context).getValue()); + assertEquals("Hello", provider.getStringEvaluation("checkout:title", "fallback", context).getValue()); + assertEquals(3, provider.getIntegerEvaluation("checkout:count", 0, context).getValue()); + assertEquals(1.5, provider.getDoubleEvaluation("checkout:ratio", 0.0, context).getValue()); + assertTrue(provider.getBooleanEvaluation("checkout:visible", false, context).getValue()); + assertEquals("a", provider.getObjectEvaluation("checkout:items", new Value(new ArrayList()), context).getValue().asList().get(0).asString()); + assertEquals("blue", provider.getObjectEvaluation("checkout:config", new Value(), context).getValue().asStructure().getValue("color").asString()); + assertTrue(provider.getObjectEvaluation("checkout:json", new Value(), context).getValue().asStructure().getValue("nested").asBoolean()); + } + + @Test void handlesErrorsCustomGrammarTrackingAndLifecycle() throws Exception { + List tracked = new ArrayList<>(); + FeaturevisorOpenFeatureProvider provider = new FeaturevisorOpenFeatureProvider( + new FeaturevisorOpenFeatureProvider.Options().featurevisorOptions(options()).keySeparator("/").variationKey("$variation").onTrack((name, context, details) -> tracked.add(name))); + assertEquals("on", provider.getStringEvaluation("checkout/$variation", "fallback", ImmutableContext.EMPTY).getValue()); + assertEquals(ErrorCode.TYPE_MISMATCH, provider.getStringEvaluation("missing", "fallback", ImmutableContext.EMPTY).getErrorCode()); + ProviderEvaluation missing = provider.getBooleanEvaluation("missing", true, ImmutableContext.EMPTY); + assertTrue(missing.getValue()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, missing.getErrorCode()); + provider.track("purchase", ImmutableContext.EMPTY, null); + assertEquals(List.of("purchase"), tracked); + provider.shutdown(); + } + + @Test void reportsMalformedDatafileAndWorksThroughOpenFeatureApi() throws Exception { + FeaturevisorOpenFeatureProvider malformed = new FeaturevisorOpenFeatureProvider(new Featurevisor.FeaturevisorOptions().datafileString("{").logLevel(FeaturevisorLogLevel.FATAL)); + assertEquals(ErrorCode.PARSE_ERROR, malformed.getBooleanEvaluation("checkout", false, ImmutableContext.EMPTY).getErrorCode()); + assertEquals("Could not parse datafile", malformed.getBooleanEvaluation("checkout", false, ImmutableContext.EMPTY).getErrorMessage()); + malformed.getFeaturevisor().setDatafile(DATAFILE, true); + assertTrue(malformed.getBooleanEvaluation("checkout", false, new ImmutableContext("forced-user")).getValue()); + + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(new FeaturevisorOpenFeatureProvider(options())); + assertTrue(api.getClient().getBooleanValue("checkout", false, new ImmutableContext("forced-user"))); + } + + @Test void borrowsExistingFeaturevisor() throws Exception { + AtomicBoolean closed = new AtomicBoolean(false); + FeaturevisorModule module = new FeaturevisorModule("owner").close(() -> closed.set(true)); + Featurevisor.FeaturevisorOptions featurevisorOptions = options().modules(List.of(module)); + Featurevisor featurevisor = Featurevisor.createFeaturevisor(featurevisorOptions); + FeaturevisorOpenFeatureProvider provider = new FeaturevisorOpenFeatureProvider(featurevisor); + + assertSame(featurevisor, provider.getFeaturevisor()); + provider.shutdown(); + assertFalse(closed.get()); + + featurevisor.close(); + assertTrue(closed.get()); + } +}