statuses = []
@@ -190,7 +198,7 @@ class RepositorySpec extends Specification {
def "ack and bye are ignored"() {
given: "i have features"
def features = [
- new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(false).type(FeatureValueType.BOOLEAN),
+ fs().key('banana').value(false).type(FeatureValueType.BOOLEAN),
]
and: "i notify the repo"
repo.updateFeatures(features)
@@ -201,13 +209,65 @@ class RepositorySpec extends Specification {
repo.readyness == Readiness.Ready
}
- def "i can attach to a feature before it is added and receive notifications when it is"() {
+ def "a FeatureListener can be removed via the returned FeatureListenerHandler"() {
+ given: "a feature exists in the repository"
+ repo.updateFeatures([fs().key('banana').value(false).type(FeatureValueType.BOOLEAN)])
+ and: "a listener is added after the initial state is loaded"
+ def listener = Mock(FeatureListener)
+ def handler = repo.getFeat('banana').addListener(listener)
+ when: "the feature value changes"
+ repo.updateFeatures([fs().key('banana').value(true).type(FeatureValueType.BOOLEAN)])
+ then: "the listener is notified"
+ 1 * listener.notify(_)
+ when: "the handler is cancelled and the feature changes again"
+ handler.cancel()
+ repo.updateFeatures([fs().key('banana').value(false).type(FeatureValueType.BOOLEAN)])
+ then: "the listener is no longer notified"
+ 0 * listener.notify(_)
+ }
+
+ def "cancelling one handler does not affect other listeners on the same feature"() {
+ given: "a feature exists in the repository"
+ repo.updateFeatures([fs().key('banana').value(false).type(FeatureValueType.BOOLEAN)])
+ and: "two listeners are registered"
+ def listenerA = Mock(FeatureListener)
+ def listenerB = Mock(FeatureListener)
+ def handlerA = repo.getFeat('banana').addListener(listenerA)
+ repo.getFeat('banana').addListener(listenerB)
+ when: "listenerA's handler is cancelled and the feature changes"
+ handlerA.cancel()
+ repo.updateFeatures([fs().key('banana').value(true).type(FeatureValueType.BOOLEAN)])
+ then: "only listenerB is notified"
+ 0 * listenerA.notify(_)
+ 1 * listenerB.notify(_)
+ }
+
+ def "a context-scoped FeatureListener can be removed via the returned FeatureListenerHandler"() {
+ given: "a feature exists in the repository"
+ repo.updateFeatures([fs().key('banana').value(false).type(FeatureValueType.BOOLEAN)])
+ and: "a listener is added to a context-scoped view of the feature"
+ def ctx = new BaseClientContext(repo, Mock(EdgeService))
+ def contextFeature = ctx.feature('banana')
+ def listener = Mock(FeatureListener)
+ def handler = contextFeature.addListener(listener)
+ when: "the feature value changes"
+ repo.updateFeatures([fs().key('banana').value(true).type(FeatureValueType.BOOLEAN)])
+ then: "the listener is notified via the context wrapper"
+ 1 * listener.notify(_)
+ when: "the handler is cancelled and the feature changes again"
+ handler.cancel()
+ repo.updateFeatures([fs().key('banana').value(false).type(FeatureValueType.BOOLEAN)])
+ then: "the context wrapper is removed and the listener is no longer notified"
+ 0 * listener.notify(_)
+ }
+
+ def "i can support phantom features by asking for feature before it is added to the repository and receive notifications when it is"() {
given: "i have one of each feature type"
def features = [
- new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(false).type(FeatureValueType.BOOLEAN),
- new FeatureState().id(UUID.randomUUID()).key('peach').version(1L).value("orange").type(FeatureValueType.STRING),
- new FeatureState().id(UUID.randomUUID()).key('peach_quantity').version(1L).value(17).type(FeatureValueType.NUMBER),
- new FeatureState().id(UUID.randomUUID()).key('peach_config').version(1L).value("{}").type(FeatureValueType.JSON),
+ fs().key('banana').value(false).type(FeatureValueType.BOOLEAN),
+ fs().key('peach').value("orange").type(FeatureValueType.STRING),
+ fs().key('peach_quantity').value(17).type(FeatureValueType.NUMBER),
+ fs().key('peach_config').value("{}").type(FeatureValueType.JSON),
]
and: "I listen for updates for those features"
def updateListener = []
diff --git a/core/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy
index dab003d..d0d493f 100644
--- a/core/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy
+++ b/core/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy
@@ -37,6 +37,7 @@ class StrategySpec extends Specification {
.id(UUID.randomUUID())
.version(1)
.type(FeatureValueType.BOOLEAN)
+ .environmentId(UUID.randomUUID())
.strategies([new FeatureRolloutStrategy().value(false).attributes(
[new FeatureRolloutStrategyAttribute().type(RolloutStrategyFieldType.STRING)
.conditional(RolloutStrategyAttributeConditional.EQUALS)
@@ -67,6 +68,7 @@ class StrategySpec extends Specification {
.value(16)
.version(1)
.type(FeatureValueType.NUMBER)
+ .environmentId(UUID.randomUUID())
.strategies([new FeatureRolloutStrategy().value(6).attributes(
[new FeatureRolloutStrategyAttribute().type(RolloutStrategyFieldType.NUMBER)
.conditional(RolloutStrategyAttributeConditional.GREATER_EQUALS)
@@ -103,6 +105,7 @@ class StrategySpec extends Specification {
.id(UUID.randomUUID())
.version(1)
.type(FeatureValueType.STRING)
+ .environmentId(UUID.randomUUID())
.strategies([new FeatureRolloutStrategy().value("not-mobile").attributes(
[new FeatureRolloutStrategyAttribute().type(RolloutStrategyFieldType.STRING)
.conditional(RolloutStrategyAttributeConditional.EXCLUDES)
@@ -143,6 +146,7 @@ class StrategySpec extends Specification {
.value("feature")
.version(1)
.type(FeatureValueType.JSON)
+ .environmentId(UUID.randomUUID())
.strategies([new FeatureRolloutStrategy().value("not-mobile").attributes(
[new FeatureRolloutStrategyAttribute().type(RolloutStrategyFieldType.STRING)
.conditional(RolloutStrategyAttributeConditional.EXCLUDES)
diff --git a/core/local-yaml/pom.xml b/core/local-yaml/pom.xml
new file mode 100644
index 0000000..c5839f7
--- /dev/null
+++ b/core/local-yaml/pom.xml
@@ -0,0 +1,87 @@
+
+
+ 4.0.0
+
+ io.featurehub.sdk
+ local-yaml
+ local-yaml
+ 1.1-SNAPSHOT
+
+ Provides a local yaml interceptor that will watch for changes in the defined file and reload them
+ as an extended feature value interceptor.
+
+
+ https://featurehub.io
+
+
+ irina@featurehub.io
+ isouthwell
+ Irina Southwell
+ Anyways Labs Ltd
+
+
+
+ richard@featurehub.io
+ rvowles
+ Richard Vowles
+ Anyways Labs Ltd
+
+
+
+
+
+ MIT
+ https://opensource.org/licenses/MIT
+ This code resides in the customer's codebase and therefore has an MIT license.
+
+
+
+
+ scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git
+ scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git
+ git@github.com:featurehub-io/featurehub-java-sdk.git
+ HEAD
+
+
+
+
+
+ io.featurehub.sdk
+ java-client-core
+ [5, 6)
+
+
+
+ org.yaml
+ snakeyaml
+ 2.6
+
+
+
+ io.featurehub.sdk.composites
+ sdk-composite-test
+ [2, 3)
+ test
+
+
+
+
+
+
+
+ io.repaint.maven
+ tiles-maven-plugin
+ 2.32
+ true
+
+ false
+
+ io.featurehub.sdk.tiles:tile-java11:[1.1,2)
+ io.featurehub.sdk.tiles:tile-release:[1.1,2)
+
+
+
+
+
+
+
diff --git a/core/local-yaml/src/main/java/io/featurehub/sdk/yaml/LocalYamlFeatureStore.java b/core/local-yaml/src/main/java/io/featurehub/sdk/yaml/LocalYamlFeatureStore.java
new file mode 100644
index 0000000..8cd4b84
--- /dev/null
+++ b/core/local-yaml/src/main/java/io/featurehub/sdk/yaml/LocalYamlFeatureStore.java
@@ -0,0 +1,136 @@
+package io.featurehub.sdk.yaml;
+
+import io.featurehub.client.FeatureHubConfig;
+import io.featurehub.client.InternalFeatureRepository;
+import io.featurehub.sse.model.FeatureState;
+import io.featurehub.sse.model.FeatureValueType;
+import java.io.File;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Reads a YAML file in the same {@code flagValues} format as {@link LocalYamlValueInterceptor}
+ * and pushes the entries as {@link FeatureState} objects directly into the repository via
+ * {@link InternalFeatureRepository#updateFeatures(List, String)} with source {@code "local-yaml-store"}.
+ *
+ * The file is read exactly once at construction time. No watching is performed.
+ *
+ *
Each feature's {@code id} is a deterministic UUID derived from a SHA-1 hash of the feature key,
+ * and all features share the same {@code environmentId} UUID created when this instance is constructed.
+ */
+public class LocalYamlFeatureStore {
+ private static final Logger log = LoggerFactory.getLogger(LocalYamlFeatureStore.class);
+ static final String SOURCE = "local-yaml-store";
+
+ public LocalYamlFeatureStore(@NotNull FeatureHubConfig config) {
+ this(config, null);
+ }
+
+ public LocalYamlFeatureStore(@NotNull FeatureHubConfig config, @Nullable String filename) {
+ final UUID environmentId = config.getEnvironmentId();
+ InternalFeatureRepository repository = config.getInternalRepository();
+ if (repository == null) {
+ log.warn("FeatureHubConfig is closed; LocalYamlFeatureStore will not load features");
+ return;
+ }
+
+ String resolved = filename != null
+ ? filename
+ : io.featurehub.client.FeatureHubConfig.getConfig(LocalYamlValueInterceptor.ENV_VAR,
+ LocalYamlValueInterceptor.DEFAULT_FILE);
+
+ Map flagValues = YamlLoader.readFlagValues(new File(resolved), log);
+
+ if (flagValues.isEmpty()) {
+ return;
+ }
+
+ List features = new ArrayList<>(flagValues.size());
+ for (Map.Entry entry : flagValues.entrySet()) {
+ String key = entry.getKey();
+ Object raw = entry.getValue();
+ FeatureValueType type = detectType(raw);
+ Object value = convertValue(raw, type, key, repository);
+
+ features.add(new FeatureState()
+ .id(keyToId(key))
+ .key(key)
+ .version(1L)
+ .environmentId(environmentId)
+ .type(type)
+ .value(value)
+ .l(false));
+ }
+
+ log.debug("Pushing {} feature(s) from local YAML store into repository", features.size());
+ repository.updateFeatures(features, SOURCE);
+ }
+
+ private static FeatureValueType detectType(@Nullable Object value) {
+ if (value instanceof Boolean) {
+ return FeatureValueType.BOOLEAN;
+ }
+ if (value instanceof String) {
+ String s = (String) value;
+ if (s.equalsIgnoreCase("true") || s.equalsIgnoreCase("false")) {
+ return FeatureValueType.BOOLEAN;
+ }
+ }
+ if (value == null) {
+ return FeatureValueType.STRING;
+ }
+ if (value instanceof Number) {
+ return FeatureValueType.NUMBER;
+ }
+ if (value instanceof String) {
+ return FeatureValueType.STRING;
+ }
+ // Map, List, or any other non-scalar
+ return FeatureValueType.JSON;
+ }
+
+ @Nullable
+ private static Object convertValue(@Nullable Object value, FeatureValueType type, String key,
+ InternalFeatureRepository repository) {
+ switch (type) {
+ case BOOLEAN:
+ if (value instanceof Boolean) return value;
+ return Boolean.parseBoolean(value.toString());
+ case NUMBER:
+ return new BigDecimal(value.toString());
+ case STRING:
+ return value == null ? null : value.toString();
+ case JSON:
+ return repository.getJsonObjectMapper().writeValueAsString(value);
+ default:
+ log.warn("Unhandled FeatureValueType {} for key '{}'", type, key);
+ return null;
+ }
+ }
+
+ /**
+ * Returns a deterministic UUID for a feature key by taking the first 16 bytes of its SHA-1 hash.
+ */
+ static UUID keyToId(String key) {
+ try {
+ MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
+ byte[] hash = sha1.digest(key.getBytes(StandardCharsets.UTF_8));
+ long msb = 0, lsb = 0;
+ for (int i = 0; i < 8; i++) msb = (msb << 8) | (hash[i] & 0xFF);
+ for (int i = 8; i < 16; i++) lsb = (lsb << 8) | (hash[i] & 0xFF);
+ return new UUID(msb, lsb);
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("SHA-1 not available", e);
+ }
+ }
+}
diff --git a/core/local-yaml/src/main/java/io/featurehub/sdk/yaml/LocalYamlValueInterceptor.java b/core/local-yaml/src/main/java/io/featurehub/sdk/yaml/LocalYamlValueInterceptor.java
new file mode 100644
index 0000000..863d406
--- /dev/null
+++ b/core/local-yaml/src/main/java/io/featurehub/sdk/yaml/LocalYamlValueInterceptor.java
@@ -0,0 +1,147 @@
+package io.featurehub.sdk.yaml;
+
+import io.featurehub.client.ExtendedFeatureValueInterceptor;
+import io.featurehub.client.FeatureHubConfig;
+import io.featurehub.client.InternalFeatureRepository;
+import io.featurehub.client.utils.Conversion;
+import io.featurehub.sse.model.FeatureState;
+import io.featurehub.sse.model.FeatureValueType;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.ClosedWatchServiceException;
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import java.nio.file.StandardWatchEventKinds;
+import java.nio.file.WatchEvent;
+import java.nio.file.WatchKey;
+import java.nio.file.WatchService;
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class LocalYamlValueInterceptor implements ExtendedFeatureValueInterceptor {
+ private static final Logger log = LoggerFactory.getLogger(LocalYamlValueInterceptor.class);
+ static final String ENV_VAR = "FEATUREHUB_LOCAL_YAML";
+ static final String DEFAULT_FILE = "featurehub-features.yaml";
+
+ private final File yamlFile;
+ private final InternalFeatureRepository repository;
+ private final AtomicReference