diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index e95785915..fb48bc1dd 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -41,22 +41,23 @@ public static FeatureStoreFactory inMemoryFeatureStore() { } /** - * Returns a factory with builder methods for creating a Redis-backed implementation of {@link FeatureStore}, - * using {@link RedisFeatureStoreBuilder#DEFAULT_URI}. + * Deprecated name for {@link com.launchdarkly.client.integrations.Redis#dataStore()}. * @return a factory/builder object - * @see LDConfig.Builder#dataStore(FeatureStoreFactory) + * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()}. */ + @Deprecated public static RedisFeatureStoreBuilder redisFeatureStore() { return new RedisFeatureStoreBuilder(); } /** - * Returns a factory with builder methods for creating a Redis-backed implementation of {@link FeatureStore}, - * specifying the Redis URI. + * Deprecated name for {@link com.launchdarkly.client.integrations.Redis#dataStore()}. * @param redisUri the URI of the Redis host * @return a factory/builder object - * @see LDConfig.Builder#dataStore(FeatureStoreFactory) + * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} and + * {@link com.launchdarkly.client.integrations.RedisDataStoreBuilder#uri(URI)}. */ + @Deprecated public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { return new RedisFeatureStoreBuilder(redisUri); } diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index 55091fa40..9713704a7 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -1,73 +1,58 @@ package com.launchdarkly.client; -import com.google.common.annotations.VisibleForTesting; import com.google.common.cache.CacheStats; import com.launchdarkly.client.utils.CachingStoreWrapper; -import com.launchdarkly.client.utils.FeatureStoreCore; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; -import java.util.HashMap; -import java.util.List; import java.util.Map; -import static com.launchdarkly.client.utils.FeatureStoreHelpers.marshalJson; -import static com.launchdarkly.client.utils.FeatureStoreHelpers.unmarshalJson; - -import redis.clients.jedis.Jedis; -import redis.clients.jedis.JedisPool; -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Transaction; -import redis.clients.util.JedisURIHelper; - /** - * An implementation of {@link FeatureStore} backed by Redis. Also - * supports an optional in-memory cache configuration that can be used to improve performance. + * Deprecated implementation class for the Redis-based persistent data store. + *

+ * Instead of referencing this class directly, use {@link com.launchdarkly.client.integrations.Redis#dataStore()} to obtain a builder object. + * + * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} */ +@Deprecated public class RedisFeatureStore implements FeatureStore { - private static final Logger logger = LoggerFactory.getLogger(RedisFeatureStore.class); - - // Note that we could avoid the indirection of delegating everything to CachingStoreWrapper if we - // simply returned the wrapper itself as the FeatureStore; however, for historical reasons we can't, - // because we have already exposed the RedisFeatureStore type. - private final CachingStoreWrapper wrapper; - private final Core core; + // The actual implementation is now in the com.launchdarkly.integrations package. This class remains + // visible for backward compatibility, but simply delegates to an instance of the underlying store. + + private final FeatureStore wrappedStore; @Override public void init(Map, Map> allData) { - wrapper.init(allData); + wrappedStore.init(allData); } @Override public T get(VersionedDataKind kind, String key) { - return wrapper.get(kind, key); + return wrappedStore.get(kind, key); } @Override public Map all(VersionedDataKind kind) { - return wrapper.all(kind); + return wrappedStore.all(kind); } @Override public void upsert(VersionedDataKind kind, T item) { - wrapper.upsert(kind, item); + wrappedStore.upsert(kind, item); } @Override public void delete(VersionedDataKind kind, String key, int version) { - wrapper.delete(kind, key, version); + wrappedStore.delete(kind, key, version); } @Override public boolean initialized() { - return wrapper.initialized(); + return wrappedStore.initialized(); } @Override public void close() throws IOException { - wrapper.close(); + wrappedStore.close(); } /** @@ -76,7 +61,7 @@ public void close() throws IOException { * @return the cache statistics object. */ public CacheStats getCacheStats() { - return wrapper.getCacheStats(); + return ((CachingStoreWrapper)wrappedStore).getCacheStats(); } /** @@ -87,192 +72,15 @@ public CacheStats getCacheStats() { * @param builder the configured builder to construct the store with. */ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { - // There is no builder for JedisPool, just a large number of constructor overloads. Unfortunately, - // the overloads that accept a URI do not accept the other parameters we need to set, so we need - // to decompose the URI. - String host = builder.uri.getHost(); - int port = builder.uri.getPort(); - String password = builder.password == null ? JedisURIHelper.getPassword(builder.uri) : builder.password; - int database = builder.database == null ? JedisURIHelper.getDBIndex(builder.uri): builder.database.intValue(); - boolean tls = builder.tls || builder.uri.getScheme().equals("rediss"); - - String extra = tls ? " with TLS" : ""; - if (password != null) { - extra = extra + (extra.isEmpty() ? " with" : " and") + " password"; - } - logger.info(String.format("Connecting to Redis feature store at %s:%d/%d%s", host, port, database, extra)); - - JedisPoolConfig poolConfig = (builder.poolConfig != null) ? builder.poolConfig : new JedisPoolConfig(); - JedisPool pool = new JedisPool(poolConfig, - host, - port, - builder.connectTimeout, - builder.socketTimeout, - password, - database, - null, // clientName - tls, - null, // sslSocketFactory - null, // sslParameters - null // hostnameVerifier - ); - - String prefix = (builder.prefix == null || builder.prefix.isEmpty()) ? - RedisFeatureStoreBuilder.DEFAULT_PREFIX : - builder.prefix; - - this.core = new Core(pool, prefix); - this.wrapper = CachingStoreWrapper.builder(this.core).caching(builder.caching) - .build(); + wrappedStore = builder.wrappedBuilder.createFeatureStore(); } /** * Creates a new store instance that connects to Redis with a default connection (localhost port 6379) and no in-memory cache. * @deprecated Please use {@link Components#redisFeatureStore()} instead. */ + @Deprecated public RedisFeatureStore() { this(new RedisFeatureStoreBuilder().caching(FeatureStoreCacheConfig.disabled())); } - - static class Core implements FeatureStoreCore { - private final JedisPool pool; - private final String prefix; - private UpdateListener updateListener; - - Core(JedisPool pool, String prefix) { - this.pool = pool; - this.prefix = prefix; - } - - @Override - public VersionedData getInternal(VersionedDataKind kind, String key) { - try (Jedis jedis = pool.getResource()) { - VersionedData item = getRedis(kind, key, jedis); - if (item != null) { - logger.debug("[get] Key: {} with version: {} found in \"{}\".", key, item.getVersion(), kind.getNamespace()); - } - return item; - } - } - - @Override - public Map getAllInternal(VersionedDataKind kind) { - try (Jedis jedis = pool.getResource()) { - Map allJson = jedis.hgetAll(itemsKey(kind)); - Map result = new HashMap<>(); - - for (Map.Entry entry : allJson.entrySet()) { - VersionedData item = unmarshalJson(kind, entry.getValue()); - result.put(entry.getKey(), item); - } - return result; - } - } - - @Override - public void initInternal(Map, Map> allData) { - try (Jedis jedis = pool.getResource()) { - Transaction t = jedis.multi(); - - for (Map.Entry, Map> entry: allData.entrySet()) { - String baseKey = itemsKey(entry.getKey()); - t.del(baseKey); - for (VersionedData item: entry.getValue().values()) { - t.hset(baseKey, item.getKey(), marshalJson(item)); - } - } - - t.set(initedKey(), ""); - t.exec(); - } - } - - @Override - public VersionedData upsertInternal(VersionedDataKind kind, VersionedData newItem) { - while (true) { - Jedis jedis = null; - try { - jedis = pool.getResource(); - String baseKey = itemsKey(kind); - jedis.watch(baseKey); - - if (updateListener != null) { - updateListener.aboutToUpdate(baseKey, newItem.getKey()); - } - - VersionedData oldItem = getRedis(kind, newItem.getKey(), jedis); - - if (oldItem != null && oldItem.getVersion() >= newItem.getVersion()) { - logger.debug("Attempted to {} key: {} version: {}" + - " with a version that is the same or older: {} in \"{}\"", - newItem.isDeleted() ? "delete" : "update", - newItem.getKey(), oldItem.getVersion(), newItem.getVersion(), kind.getNamespace()); - return oldItem; - } - - Transaction tx = jedis.multi(); - tx.hset(baseKey, newItem.getKey(), marshalJson(newItem)); - List result = tx.exec(); - if (result.isEmpty()) { - // if exec failed, it means the watch was triggered and we should retry - logger.debug("Concurrent modification detected, retrying"); - continue; - } - - return newItem; - } finally { - if (jedis != null) { - jedis.unwatch(); - jedis.close(); - } - } - } - } - - @Override - public boolean initializedInternal() { - try (Jedis jedis = pool.getResource()) { - return jedis.exists(initedKey()); - } - } - - @Override - public void close() throws IOException { - logger.info("Closing LaunchDarkly RedisFeatureStore"); - pool.destroy(); - } - - @VisibleForTesting - void setUpdateListener(UpdateListener updateListener) { - this.updateListener = updateListener; - } - - private String itemsKey(VersionedDataKind kind) { - return prefix + ":" + kind.getNamespace(); - } - - private String initedKey() { - return prefix + ":$inited"; - } - - private T getRedis(VersionedDataKind kind, String key, Jedis jedis) { - String json = jedis.hget(itemsKey(kind), key); - - if (json == null) { - logger.debug("[get] Key: {} not found in \"{}\". Returning null", key, kind.getNamespace()); - return null; - } - - return unmarshalJson(kind, json); - } - } - - static interface UpdateListener { - void aboutToUpdate(String baseKey, String itemKey); - } - - @VisibleForTesting - void setUpdateListener(UpdateListener updateListener) { - core.setUpdateListener(updateListener); - } } diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index 7c5661e82..d84e3aa58 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -1,25 +1,23 @@ package com.launchdarkly.client; -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Protocol; +import com.launchdarkly.client.integrations.Redis; +import com.launchdarkly.client.integrations.RedisDataStoreBuilder; import java.net.URI; import java.net.URISyntaxException; import java.util.concurrent.TimeUnit; +import redis.clients.jedis.JedisPoolConfig; + /** - * A builder for configuring the Redis-based persistent feature store. - * - * Obtain an instance of this class by calling {@link Components#redisFeatureStore()} or {@link Components#redisFeatureStore(URI)}. - * Builder calls can be chained, for example: - * - *

- * FeatureStore store = Components.redisFeatureStore()
- *      .database(1)
- *      .caching(FeatureStoreCacheConfig.enabled().ttlSeconds(60))
- *      .build();
- * 
+ * Deprecated builder class for the Redis-based persistent data store. + *

+ * The replacement for this class is {@link com.launchdarkly.client.integrations.RedisDataStoreBuilder}. + * This class is retained for backward compatibility and will be removed in a future version. + * + * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} */ +@Deprecated public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { /** * The default value for the Redis URI: {@code redis://localhost:6379} @@ -40,25 +38,21 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { */ public static final long DEFAULT_CACHE_TIME_SECONDS = FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS; - final URI uri; - String prefix = DEFAULT_PREFIX; - int connectTimeout = Protocol.DEFAULT_TIMEOUT; - int socketTimeout = Protocol.DEFAULT_TIMEOUT; - Integer database = null; - String password = null; - boolean tls = false; + final RedisDataStoreBuilder wrappedBuilder; + + // We have to keep track of these caching parameters separately in order to support some deprecated setters FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; - boolean refreshStaleValues = false; // this and asyncRefresh are redundant with FeatureStoreCacheConfig, but are used by deprecated setters + boolean refreshStaleValues = false; boolean asyncRefresh = false; - JedisPoolConfig poolConfig = null; - // These constructors are called only from Implementations + // These constructors are called only from Components RedisFeatureStoreBuilder() { - this.uri = DEFAULT_URI; + wrappedBuilder = Redis.dataStore(); } RedisFeatureStoreBuilder(URI uri) { - this.uri = uri; + this(); + wrappedBuilder.uri(uri); } /** @@ -69,8 +63,10 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { * @deprecated Please use {@link Components#redisFeatureStore(java.net.URI)}. */ public RedisFeatureStoreBuilder(URI uri, long cacheTimeSecs) { - this.uri = uri; - this.cacheTime(cacheTimeSecs, TimeUnit.SECONDS); + this(); + wrappedBuilder.uri(uri); + caching = caching.ttlSeconds(cacheTimeSecs); + wrappedBuilder.caching(caching); } /** @@ -84,8 +80,10 @@ public RedisFeatureStoreBuilder(URI uri, long cacheTimeSecs) { * @deprecated Please use {@link Components#redisFeatureStore(java.net.URI)}. */ public RedisFeatureStoreBuilder(String scheme, String host, int port, long cacheTimeSecs) throws URISyntaxException { - this.uri = new URI(scheme, null, host, port, null, null, null); - this.cacheTime(cacheTimeSecs, TimeUnit.SECONDS); + this(); + wrappedBuilder.uri(new URI(scheme, null, host, port, null, null, null)); + caching = caching.ttlSeconds(cacheTimeSecs); + wrappedBuilder.caching(caching); } /** @@ -100,7 +98,7 @@ public RedisFeatureStoreBuilder(String scheme, String host, int port, long cache * @since 4.7.0 */ public RedisFeatureStoreBuilder database(Integer database) { - this.database = database; + wrappedBuilder.database(database); return this; } @@ -116,7 +114,7 @@ public RedisFeatureStoreBuilder database(Integer database) { * @since 4.7.0 */ public RedisFeatureStoreBuilder password(String password) { - this.password = password; + wrappedBuilder.password(password); return this; } @@ -133,7 +131,7 @@ public RedisFeatureStoreBuilder password(String password) { * @since 4.7.0 */ public RedisFeatureStoreBuilder tls(boolean tls) { - this.tls = tls; + wrappedBuilder.tls(tls); return this; } @@ -149,6 +147,7 @@ public RedisFeatureStoreBuilder tls(boolean tls) { */ public RedisFeatureStoreBuilder caching(FeatureStoreCacheConfig caching) { this.caching = caching; + wrappedBuilder.caching(caching); return this; } @@ -187,13 +186,14 @@ public RedisFeatureStoreBuilder asyncRefresh(boolean enabled) { private void updateCachingStaleValuesPolicy() { // We need this logic in order to support the existing behavior of the deprecated methods above: // asyncRefresh is supposed to have no effect unless refreshStaleValues is true - if (this.refreshStaleValues) { - this.caching = this.caching.staleValuesPolicy(this.asyncRefresh ? + if (refreshStaleValues) { + caching = caching.staleValuesPolicy(this.asyncRefresh ? FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC : FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH); } else { - this.caching = this.caching.staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT); + caching = caching.staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT); } + wrappedBuilder.caching(caching); } /** @@ -203,7 +203,7 @@ private void updateCachingStaleValuesPolicy() { * @return the builder */ public RedisFeatureStoreBuilder prefix(String prefix) { - this.prefix = prefix; + wrappedBuilder.prefix(prefix); return this; } @@ -218,8 +218,9 @@ public RedisFeatureStoreBuilder prefix(String prefix) { * @deprecated use {@link #caching(FeatureStoreCacheConfig)} and {@link FeatureStoreCacheConfig#ttl(long, TimeUnit)}. */ public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { - this.caching = this.caching.ttl(cacheTime, timeUnit) + caching = caching.ttl(cacheTime, timeUnit) .staleValuesPolicy(this.caching.getStaleValuesPolicy()); + wrappedBuilder.caching(caching); return this; } @@ -230,7 +231,7 @@ public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { * @return the builder */ public RedisFeatureStoreBuilder poolConfig(JedisPoolConfig poolConfig) { - this.poolConfig = poolConfig; + wrappedBuilder.poolConfig(poolConfig); return this; } @@ -243,7 +244,7 @@ public RedisFeatureStoreBuilder poolConfig(JedisPoolConfig poolConfig) { * @return the builder */ public RedisFeatureStoreBuilder connectTimeout(int connectTimeout, TimeUnit timeUnit) { - this.connectTimeout = (int) timeUnit.toMillis(connectTimeout); + wrappedBuilder.connectTimeout(connectTimeout, timeUnit); return this; } @@ -256,7 +257,7 @@ public RedisFeatureStoreBuilder connectTimeout(int connectTimeout, TimeUnit time * @return the builder */ public RedisFeatureStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUnit) { - this.socketTimeout = (int) timeUnit.toMillis(socketTimeout); + wrappedBuilder.socketTimeout(socketTimeout, timeUnit); return this; } diff --git a/src/main/java/com/launchdarkly/client/files/DataBuilder.java b/src/main/java/com/launchdarkly/client/files/DataBuilder.java deleted file mode 100644 index e9bc580a9..000000000 --- a/src/main/java/com/launchdarkly/client/files/DataBuilder.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.launchdarkly.client.files; - -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; - -import java.util.HashMap; -import java.util.Map; - -/** - * Internal data structure that organizes flag/segment data into the format that the feature store - * expects. Will throw an exception if we try to add the same flag or segment key more than once. - */ -class DataBuilder { - private final Map, Map> allData = new HashMap<>(); - - public Map, Map> build() { - return allData; - } - - public void add(VersionedDataKind kind, VersionedData item) throws DataLoaderException { - @SuppressWarnings("unchecked") - Map items = (Map)allData.get(kind); - if (items == null) { - items = new HashMap(); - allData.put(kind, items); - } - if (items.containsKey(item.getKey())) { - throw new DataLoaderException("in " + kind.getNamespace() + ", key \"" + item.getKey() + "\" was already defined", null, null); - } - items.put(item.getKey(), item); - } -} diff --git a/src/main/java/com/launchdarkly/client/files/DataLoader.java b/src/main/java/com/launchdarkly/client/files/DataLoader.java deleted file mode 100644 index 0b4ad431c..000000000 --- a/src/main/java/com/launchdarkly/client/files/DataLoader.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.launchdarkly.client.files; - -import com.google.gson.JsonElement; -import com.launchdarkly.client.VersionedDataKind; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -/** - * Implements the loading of flag data from one or more files. Will throw an exception if any file can't - * be read or parsed, or if any flag or segment keys are duplicates. - */ -final class DataLoader { - private final List files; - - public DataLoader(List files) { - this.files = new ArrayList(files); - } - - public Iterable getFiles() { - return files; - } - - public void load(DataBuilder builder) throws DataLoaderException - { - for (Path p: files) { - try { - byte[] data = Files.readAllBytes(p); - FlagFileParser parser = FlagFileParser.selectForContent(data); - FlagFileRep fileContents = parser.parse(new ByteArrayInputStream(data)); - if (fileContents.flags != null) { - for (Map.Entry e: fileContents.flags.entrySet()) { - builder.add(VersionedDataKind.FEATURES, FlagFactory.flagFromJson(e.getValue())); - } - } - if (fileContents.flagValues != null) { - for (Map.Entry e: fileContents.flagValues.entrySet()) { - builder.add(VersionedDataKind.FEATURES, FlagFactory.flagWithValue(e.getKey(), e.getValue())); - } - } - if (fileContents.segments != null) { - for (Map.Entry e: fileContents.segments.entrySet()) { - builder.add(VersionedDataKind.SEGMENTS, FlagFactory.segmentFromJson(e.getValue())); - } - } - } catch (DataLoaderException e) { - throw new DataLoaderException(e.getMessage(), e.getCause(), p); - } catch (IOException e) { - throw new DataLoaderException(null, e, p); - } - } - } -} diff --git a/src/main/java/com/launchdarkly/client/files/DataLoaderException.java b/src/main/java/com/launchdarkly/client/files/DataLoaderException.java deleted file mode 100644 index 184a3211a..000000000 --- a/src/main/java/com/launchdarkly/client/files/DataLoaderException.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.launchdarkly.client.files; - -import java.nio.file.Path; - -/** - * Indicates that the file processor encountered an error in one of the input files. This exception is - * not surfaced to the host application, it is only logged, and we don't do anything different programmatically - * with different kinds of exceptions, therefore it has no subclasses. - */ -@SuppressWarnings("serial") -class DataLoaderException extends Exception { - private final Path filePath; - - public DataLoaderException(String message, Throwable cause, Path filePath) { - super(message, cause); - this.filePath = filePath; - } - - public DataLoaderException(String message, Throwable cause) { - this(message, cause, null); - } - - public Path getFilePath() { - return filePath; - } - - public String getDescription() { - StringBuilder s = new StringBuilder(); - if (getMessage() != null) { - s.append(getMessage()); - if (getCause() != null) { - s.append(" "); - } - } - if (getCause() != null) { - s.append(" [").append(getCause().toString()).append("]"); - } - if (filePath != null) { - s.append(": ").append(filePath); - } - return s.toString(); - } -} diff --git a/src/main/java/com/launchdarkly/client/files/FileComponents.java b/src/main/java/com/launchdarkly/client/files/FileComponents.java index f893b7f47..63a575555 100644 --- a/src/main/java/com/launchdarkly/client/files/FileComponents.java +++ b/src/main/java/com/launchdarkly/client/files/FileComponents.java @@ -1,100 +1,11 @@ package com.launchdarkly.client.files; /** - * The entry point for the file data source, which allows you to use local files as a source of - * feature flag state. This would typically be used in a test environment, to operate using a - * predetermined feature flag state without an actual LaunchDarkly connection. - *

- * To use this component, call {@link #fileDataSource()} to obtain a factory object, call one or - * methods to configure it, and then add it to your LaunchDarkly client configuration. At a - * minimum, you will want to call {@link FileDataSourceFactory#filePaths(String...)} to specify - * your data file(s); you can also use {@link FileDataSourceFactory#autoUpdate(boolean)} to - * specify that flags should be reloaded when a file is modified. See {@link FileDataSourceFactory} - * for all configuration options. - *

- *     FileDataSourceFactory f = FileComponents.fileDataSource()
- *         .filePaths("./testData/flags.json")
- *         .autoUpdate(true);
- *     LDConfig config = new LDConfig.Builder()
- *         .dataSource(f)
- *         .build();
- * 
- *

- * This will cause the client not to connect to LaunchDarkly to get feature flags. The - * client may still make network connections to send analytics events, unless you have disabled - * this with {@link com.launchdarkly.client.LDConfig.Builder#sendEvents(boolean)} or - * {@link com.launchdarkly.client.LDConfig.Builder#offline(boolean)}. - *

- * Flag data files can be either JSON or YAML. They contain an object with three possible - * properties: - *

    - *
  • {@code flags}: Feature flag definitions. - *
  • {@code flagVersions}: Simplified feature flags that contain only a value. - *
  • {@code segments}: User segment definitions. - *
- *

- * The format of the data in {@code flags} and {@code segments} is defined by the LaunchDarkly application - * and is subject to change. Rather than trying to construct these objects yourself, it is simpler - * to request existing flags directly from the LaunchDarkly server in JSON format, and use this - * output as the starting point for your file. In Linux you would do this: - *

- *     curl -H "Authorization: {your sdk key}" https://app.launchdarkly.com/sdk/latest-all
- * 
- *

- * The output will look something like this (but with many more properties): - *

- * {
- *     "flags": {
- *         "flag-key-1": {
- *             "key": "flag-key-1",
- *             "on": true,
- *             "variations": [ "a", "b" ]
- *         },
- *         "flag-key-2": {
- *             "key": "flag-key-2",
- *             "on": true,
- *             "variations": [ "c", "d" ]
- *         }
- *     },
- *     "segments": {
- *         "segment-key-1": {
- *             "key": "segment-key-1",
- *             "includes": [ "user-key-1" ]
- *         }
- *     }
- * }
- * 
- *

- * Data in this format allows the SDK to exactly duplicate all the kinds of flag behavior supported - * by LaunchDarkly. However, in many cases you will not need this complexity, but will just want to - * set specific flag keys to specific values. For that, you can use a much simpler format: - *

- * {
- *     "flagValues": {
- *         "my-string-flag-key": "value-1",
- *         "my-boolean-flag-key": true,
- *         "my-integer-flag-key": 3
- *     }
- * }
- * 
- *

- * Or, in YAML: - *

- * flagValues:
- *   my-string-flag-key: "value-1"
- *   my-boolean-flag-key: true
- *   my-integer-flag-key: 3
- * 
- *

- * It is also possible to specify both {@code flags} and {@code flagValues}, if you want some flags - * to have simple values and others to have complex behavior. However, it is an error to use the - * same flag key or segment key more than once, either in a single file or across multiple files. - *

- * If the data source encounters any error in any file-- malformed content, a missing file, or a - * duplicate key-- it will not load flags from any of the files. - * + * Deprecated entry point for the file data source. * @since 4.5.0 + * @deprecated Use {@link com.launchdarkly.client.integrations.FileData}. */ +@Deprecated public abstract class FileComponents { /** * Creates a {@link FileDataSourceFactory} which you can use to configure the file data diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java index 8f0d36cf0..ded4a2dd5 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java +++ b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java @@ -4,25 +4,21 @@ import com.launchdarkly.client.LDConfig; import com.launchdarkly.client.UpdateProcessor; import com.launchdarkly.client.UpdateProcessorFactory; +import com.launchdarkly.client.integrations.FileDataSourceBuilder; +import com.launchdarkly.client.integrations.FileData; import java.nio.file.InvalidPathException; import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; /** - * To use the file data source, obtain a new instance of this class with {@link FileComponents#fileDataSource()}, - * call the builder method {@link #filePaths(String...)} to specify file path(s), - * then pass the resulting object to {@link com.launchdarkly.client.LDConfig.Builder#dataSource(UpdateProcessorFactory)}. - *

- * For more details, see {@link FileComponents}. + * Deprecated name for {@link FileDataSourceBuilder}. Use {@link FileData#dataSource()} to obtain the + * new builder. * * @since 4.5.0 + * @deprecated */ public class FileDataSourceFactory implements UpdateProcessorFactory { - private final List sources = new ArrayList<>(); - private boolean autoUpdate = false; + private final FileDataSourceBuilder wrappedBuilder = new FileDataSourceBuilder(); /** * Adds any number of source files for loading flag data, specifying each file path as a string. The files will @@ -36,9 +32,7 @@ public class FileDataSourceFactory implements UpdateProcessorFactory { * @throws InvalidPathException if one of the parameters is not a valid file path */ public FileDataSourceFactory filePaths(String... filePaths) throws InvalidPathException { - for (String p: filePaths) { - sources.add(Paths.get(p)); - } + wrappedBuilder.filePaths(filePaths); return this; } @@ -52,9 +46,7 @@ public FileDataSourceFactory filePaths(String... filePaths) throws InvalidPathEx * @return the same factory object */ public FileDataSourceFactory filePaths(Path... filePaths) { - for (Path p: filePaths) { - sources.add(p); - } + wrappedBuilder.filePaths(filePaths); return this; } @@ -69,7 +61,7 @@ public FileDataSourceFactory filePaths(Path... filePaths) { * @return the same factory object */ public FileDataSourceFactory autoUpdate(boolean autoUpdate) { - this.autoUpdate = autoUpdate; + wrappedBuilder.autoUpdate(autoUpdate); return this; } @@ -78,6 +70,6 @@ public FileDataSourceFactory autoUpdate(boolean autoUpdate) { */ @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return new FileDataSource(featureStore, new DataLoader(sources), autoUpdate); + return wrappedBuilder.createUpdateProcessor(sdkKey, config, featureStore); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/files/FlagFactory.java b/src/main/java/com/launchdarkly/client/files/FlagFactory.java deleted file mode 100644 index 19af56282..000000000 --- a/src/main/java/com/launchdarkly/client/files/FlagFactory.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.launchdarkly.client.files; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; - -/** - * Creates flag or segment objects from raw JSON. - * - * Note that the {@code FeatureFlag} and {@code Segment} classes are not public in the Java - * client, so we refer to those class objects indirectly via {@code VersionedDataKind}; and - * if we want to construct a flag from scratch, we can't use the constructor but instead must - * build some JSON and then parse that. - */ -class FlagFactory { - private static final Gson gson = new Gson(); - - public static VersionedData flagFromJson(String jsonString) { - return flagFromJson(gson.fromJson(jsonString, JsonElement.class)); - } - - public static VersionedData flagFromJson(JsonElement jsonTree) { - return gson.fromJson(jsonTree, VersionedDataKind.FEATURES.getItemClass()); - } - - /** - * Constructs a flag that always returns the same value. This is done by giving it a single - * variation and setting the fallthrough variation to that. - */ - public static VersionedData flagWithValue(String key, JsonElement value) { - JsonElement jsonValue = gson.toJsonTree(value); - JsonObject o = new JsonObject(); - o.addProperty("key", key); - o.addProperty("on", true); - JsonArray vs = new JsonArray(); - vs.add(jsonValue); - o.add("variations", vs); - // Note that LaunchDarkly normally prevents you from creating a flag with just one variation, - // but it's the application that validates that; the SDK doesn't care. - JsonObject ft = new JsonObject(); - ft.addProperty("variation", 0); - o.add("fallthrough", ft); - return flagFromJson(o); - } - - public static VersionedData segmentFromJson(String jsonString) { - return segmentFromJson(gson.fromJson(jsonString, JsonElement.class)); - } - - public static VersionedData segmentFromJson(JsonElement jsonTree) { - return gson.fromJson(jsonTree, VersionedDataKind.SEGMENTS.getItemClass()); - } -} diff --git a/src/main/java/com/launchdarkly/client/files/FlagFileParser.java b/src/main/java/com/launchdarkly/client/files/FlagFileParser.java deleted file mode 100644 index ed0de72a0..000000000 --- a/src/main/java/com/launchdarkly/client/files/FlagFileParser.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.launchdarkly.client.files; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.Reader; - -abstract class FlagFileParser { - private static final FlagFileParser jsonParser = new JsonFlagFileParser(); - private static final FlagFileParser yamlParser = new YamlFlagFileParser(); - - public abstract FlagFileRep parse(InputStream input) throws DataLoaderException, IOException; - - public static FlagFileParser selectForContent(byte[] data) { - Reader r = new InputStreamReader(new ByteArrayInputStream(data)); - return detectJson(r) ? jsonParser : yamlParser; - } - - private static boolean detectJson(Reader r) { - // A valid JSON file for our purposes must be an object, i.e. it must start with '{' - while (true) { - try { - int ch = r.read(); - if (ch < 0) { - return false; - } - if (ch == '{') { - return true; - } - if (!Character.isWhitespace(ch)) { - return false; - } - } catch (IOException e) { - return false; - } - } - } -} diff --git a/src/main/java/com/launchdarkly/client/files/FlagFileRep.java b/src/main/java/com/launchdarkly/client/files/FlagFileRep.java deleted file mode 100644 index db04fb51b..000000000 --- a/src/main/java/com/launchdarkly/client/files/FlagFileRep.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.launchdarkly.client.files; - -import com.google.gson.JsonElement; - -import java.util.Map; - -/** - * The basic data structure that we expect all source files to contain. Note that we don't try to - * parse the flags or segments at this level; that will be done by {@link FlagFactory}. - */ -final class FlagFileRep { - Map flags; - Map flagValues; - Map segments; - - FlagFileRep() {} - - FlagFileRep(Map flags, Map flagValues, Map segments) { - this.flags = flags; - this.flagValues = flagValues; - this.segments = segments; - } -} diff --git a/src/main/java/com/launchdarkly/client/files/JsonFlagFileParser.java b/src/main/java/com/launchdarkly/client/files/JsonFlagFileParser.java deleted file mode 100644 index c895fd6ab..000000000 --- a/src/main/java/com/launchdarkly/client/files/JsonFlagFileParser.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.launchdarkly.client.files; - -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonSyntaxException; - -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; - -final class JsonFlagFileParser extends FlagFileParser { - private static final Gson gson = new Gson(); - - @Override - public FlagFileRep parse(InputStream input) throws DataLoaderException, IOException { - try { - return parseJson(gson.fromJson(new InputStreamReader(input), JsonElement.class)); - } catch (JsonSyntaxException e) { - throw new DataLoaderException("cannot parse JSON", e); - } - } - - public FlagFileRep parseJson(JsonElement tree) throws DataLoaderException, IOException { - try { - return gson.fromJson(tree, FlagFileRep.class); - } catch (JsonSyntaxException e) { - throw new DataLoaderException("cannot parse JSON", e); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/files/YamlFlagFileParser.java b/src/main/java/com/launchdarkly/client/files/YamlFlagFileParser.java deleted file mode 100644 index f4e352dfc..000000000 --- a/src/main/java/com/launchdarkly/client/files/YamlFlagFileParser.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.launchdarkly.client.files; - -import com.google.gson.Gson; -import com.google.gson.JsonElement; - -import org.yaml.snakeyaml.Yaml; -import org.yaml.snakeyaml.error.YAMLException; - -import java.io.IOException; -import java.io.InputStream; - -/** - * Parses a FlagFileRep from a YAML file. Two notes about this implementation: - *

- * 1. We already have logic for parsing (and building) flags using Gson, and would rather not repeat - * that logic. So, rather than telling SnakeYAML to parse the file directly into a FlagFileRep object - - * and providing SnakeYAML-specific methods for building flags - we are just parsing the YAML into - * simple Java objects and then feeding that data into the Gson parser. This is admittedly inefficient, - * but it also means that we don't have to worry about any differences between how Gson unmarshals an - * object and how the YAML parser does it. We already know Gson does the right thing for the flag and - * segment classes, because that's what we use in the SDK. - *

- * 2. Ideally, it should be possible to have just one parser, since any valid JSON document is supposed - * to also be parseable as YAML. However, at present, that doesn't work: - *

    - *
  • SnakeYAML (1.19) rejects many valid JSON documents due to simple things like whitespace. - * Apparently this is due to supporting only YAML 1.1, not YAML 1.2 which has full JSON support. - *
  • snakeyaml-engine (https://bitbucket.org/asomov/snakeyaml-engine) says it can handle any JSON, - * but it's only for Java 8 and above. - *
  • YamlBeans (https://github.com/EsotericSoftware/yamlbeans) only works right if you're parsing - * directly into a Java bean instance (which FeatureFlag is not). If you try the "parse to simple - * Java types (and then feed them into Gson)" approach, it does not correctly parse non-string types - * (i.e. it treats true as "true"). (https://github.com/EsotericSoftware/yamlbeans/issues/7) - *
- */ -final class YamlFlagFileParser extends FlagFileParser { - private static final Yaml yaml = new Yaml(); - private static final Gson gson = new Gson(); - private static final JsonFlagFileParser jsonFileParser = new JsonFlagFileParser(); - - @Override - public FlagFileRep parse(InputStream input) throws DataLoaderException, IOException { - Object root; - try { - root = yaml.load(input); - } catch (YAMLException e) { - throw new DataLoaderException("unable to parse YAML", e); - } - JsonElement jsonRoot = gson.toJsonTree(root); - return jsonFileParser.parseJson(jsonRoot); - } -} diff --git a/src/main/java/com/launchdarkly/client/files/package-info.java b/src/main/java/com/launchdarkly/client/files/package-info.java index a5a3eafa4..da8abb785 100644 --- a/src/main/java/com/launchdarkly/client/files/package-info.java +++ b/src/main/java/com/launchdarkly/client/files/package-info.java @@ -1,6 +1,4 @@ /** - * Package for the file data source component, which may be useful in tests. - *

- * The entry point is {@link com.launchdarkly.client.files.FileComponents}. + * Deprecated package replaced by {@link com.launchdarkly.client.integrations.FileData}. */ package com.launchdarkly.client.files; diff --git a/src/main/java/com/launchdarkly/client/integrations/FileData.java b/src/main/java/com/launchdarkly/client/integrations/FileData.java new file mode 100644 index 000000000..a6f65f3e2 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/FileData.java @@ -0,0 +1,116 @@ +package com.launchdarkly.client.integrations; + +/** + * Integration between the LaunchDarkly SDK and file data. + *

+ * The file data source allows you to use local files as a source of feature flag state. This would + * typically be used in a test environment, to operate using a predetermined feature flag state + * without an actual LaunchDarkly connection. See {@link #dataSource()} for details. + * + * @since 4.11.0 + */ +public abstract class FileData { + /** + * Creates a {@link FileDataSourceBuilder} which you can use to configure the file data source. + * This allows you to use local files as a source of feature flag state, instead of using an actual + * LaunchDarkly connection. + *

+ * This object can be modified with {@link FileDataSourceBuilder} methods for any desired + * custom settings, before including it in the SDK configuration with + * {@link com.launchdarkly.client.LDConfig.Builder#dataSource(com.launchdarkly.client.UpdateProcessorFactory)}. + *

+ * At a minimum, you will want to call {@link FileDataSourceBuilder#filePaths(String...)} to specify + * your data file(s); you can also use {@link FileDataSourceBuilder#autoUpdate(boolean)} to + * specify that flags should be reloaded when a file is modified. See {@link FileDataSourceBuilder} + * for all configuration options. + *

+   *     FileDataSourceFactory f = FileData.dataSource()
+   *         .filePaths("./testData/flags.json")
+   *         .autoUpdate(true);
+   *     LDConfig config = new LDConfig.Builder()
+   *         .dataSource(f)
+   *         .build();
+   * 
+ *

+ * This will cause the client not to connect to LaunchDarkly to get feature flags. The + * client may still make network connections to send analytics events, unless you have disabled + * this with {@link com.launchdarkly.client.LDConfig.Builder#sendEvents(boolean)} or + * {@link com.launchdarkly.client.LDConfig.Builder#offline(boolean)}. + *

+ * Flag data files can be either JSON or YAML. They contain an object with three possible + * properties: + *

    + *
  • {@code flags}: Feature flag definitions. + *
  • {@code flagVersions}: Simplified feature flags that contain only a value. + *
  • {@code segments}: User segment definitions. + *
+ *

+ * The format of the data in {@code flags} and {@code segments} is defined by the LaunchDarkly application + * and is subject to change. Rather than trying to construct these objects yourself, it is simpler + * to request existing flags directly from the LaunchDarkly server in JSON format, and use this + * output as the starting point for your file. In Linux you would do this: + *

+   *     curl -H "Authorization: {your sdk key}" https://app.launchdarkly.com/sdk/latest-all
+   * 
+ *

+ * The output will look something like this (but with many more properties): + *

+   * {
+   *     "flags": {
+   *         "flag-key-1": {
+   *             "key": "flag-key-1",
+   *             "on": true,
+   *             "variations": [ "a", "b" ]
+   *         },
+   *         "flag-key-2": {
+   *             "key": "flag-key-2",
+   *             "on": true,
+   *             "variations": [ "c", "d" ]
+   *         }
+   *     },
+   *     "segments": {
+   *         "segment-key-1": {
+   *             "key": "segment-key-1",
+   *             "includes": [ "user-key-1" ]
+   *         }
+   *     }
+   * }
+   * 
+ *

+ * Data in this format allows the SDK to exactly duplicate all the kinds of flag behavior supported + * by LaunchDarkly. However, in many cases you will not need this complexity, but will just want to + * set specific flag keys to specific values. For that, you can use a much simpler format: + *

+   * {
+   *     "flagValues": {
+   *         "my-string-flag-key": "value-1",
+   *         "my-boolean-flag-key": true,
+   *         "my-integer-flag-key": 3
+   *     }
+   * }
+   * 
+ *

+ * Or, in YAML: + *

+   * flagValues:
+   *   my-string-flag-key: "value-1"
+   *   my-boolean-flag-key: true
+   *   my-integer-flag-key: 3
+   * 
+ *

+ * It is also possible to specify both {@code flags} and {@code flagValues}, if you want some flags + * to have simple values and others to have complex behavior. However, it is an error to use the + * same flag key or segment key more than once, either in a single file or across multiple files. + *

+ * If the data source encounters any error in any file-- malformed content, a missing file, or a + * duplicate key-- it will not load flags from any of the files. + * + * @return a data source configuration object + * @since 4.11.0 + */ + public static FileDataSourceBuilder dataSource() { + return new FileDataSourceBuilder(); + } + + private FileData() {} +} diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java new file mode 100644 index 000000000..4c3cd1993 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java @@ -0,0 +1,83 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.LDConfig; +import com.launchdarkly.client.UpdateProcessor; +import com.launchdarkly.client.UpdateProcessorFactory; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +/** + * To use the file data source, obtain a new instance of this class with {@link FileData#dataSource()}, + * call the builder method {@link #filePaths(String...)} to specify file path(s), + * then pass the resulting object to {@link com.launchdarkly.client.LDConfig.Builder#dataSource(UpdateProcessorFactory)}. + *

+ * For more details, see {@link FileData}. + * + * @since 4.11.0 + */ +public final class FileDataSourceBuilder implements UpdateProcessorFactory { + private final List sources = new ArrayList<>(); + private boolean autoUpdate = false; + + /** + * Adds any number of source files for loading flag data, specifying each file path as a string. The files will + * not actually be loaded until the LaunchDarkly client starts up. + *

+ * Files will be parsed as JSON if their first non-whitespace character is '{'. Otherwise, they will be parsed as YAML. + * + * @param filePaths path(s) to the source file(s); may be absolute or relative to the current working directory + * @return the same factory object + * + * @throws InvalidPathException if one of the parameters is not a valid file path + */ + public FileDataSourceBuilder filePaths(String... filePaths) throws InvalidPathException { + for (String p: filePaths) { + sources.add(Paths.get(p)); + } + return this; + } + + /** + * Adds any number of source files for loading flag data, specifying each file path as a Path. The files will + * not actually be loaded until the LaunchDarkly client starts up. + *

+ * Files will be parsed as JSON if their first non-whitespace character is '{'. Otherwise, they will be parsed as YAML. + * + * @param filePaths path(s) to the source file(s); may be absolute or relative to the current working directory + * @return the same factory object + */ + public FileDataSourceBuilder filePaths(Path... filePaths) { + for (Path p: filePaths) { + sources.add(p); + } + return this; + } + + /** + * Specifies whether the data source should watch for changes to the source file(s) and reload flags + * whenever there is a change. By default, it will not, so the flags will only be loaded once. + *

+ * Note that auto-updating will only work if all of the files you specified have valid directory paths at + * startup time; if a directory does not exist, creating it later will not result in files being loaded from it. + * + * @param autoUpdate true if flags should be reloaded whenever a source file changes + * @return the same factory object + */ + public FileDataSourceBuilder autoUpdate(boolean autoUpdate) { + this.autoUpdate = autoUpdate; + return this; + } + + /** + * Used internally by the LaunchDarkly client. + */ + @Override + public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { + return new FileDataSourceImpl(featureStore, sources, autoUpdate); + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSource.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java similarity index 56% rename from src/main/java/com/launchdarkly/client/files/FileDataSource.java rename to src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java index e040e7902..cd2244564 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSource.java +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java @@ -1,21 +1,34 @@ -package com.launchdarkly.client.files; +package com.launchdarkly.client.integrations; import com.google.common.util.concurrent.Futures; +import com.google.gson.JsonElement; import com.launchdarkly.client.FeatureStore; import com.launchdarkly.client.UpdateProcessor; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFactory; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileParser; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileRep; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.file.FileSystem; import java.nio.file.FileSystems; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.WatchEvent; import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.nio.file.Watchable; +import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; +import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; @@ -25,20 +38,20 @@ import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; /** - * Implements taking flag data from files and putting it into the feature store, at startup time and + * Implements taking flag data from files and putting it into the data store, at startup time and * optionally whenever files change. */ -class FileDataSource implements UpdateProcessor { - private static final Logger logger = LoggerFactory.getLogger(FileDataSource.class); +final class FileDataSourceImpl implements UpdateProcessor { + private static final Logger logger = LoggerFactory.getLogger(FileDataSourceImpl.class); private final FeatureStore store; private final DataLoader dataLoader; private final AtomicBoolean inited = new AtomicBoolean(false); private final FileWatcher fileWatcher; - FileDataSource(FeatureStore store, DataLoader dataLoader, boolean autoUpdate) { + FileDataSourceImpl(FeatureStore store, List sources, boolean autoUpdate) { this.store = store; - this.dataLoader = dataLoader; + this.dataLoader = new DataLoader(sources); FileWatcher fw = null; if (autoUpdate) { @@ -65,7 +78,7 @@ public Future start() { if (fileWatcher != null) { fileWatcher.start(new Runnable() { public void run() { - FileDataSource.this.reload(); + FileDataSourceImpl.this.reload(); } }); } @@ -77,7 +90,7 @@ private boolean reload() { DataBuilder builder = new DataBuilder(); try { dataLoader.load(builder); - } catch (DataLoaderException e) { + } catch (FileDataException e) { logger.error(e.getDescription()); return false; } @@ -101,7 +114,7 @@ public void close() throws IOException { /** * If auto-updating is enabled, this component watches for file changes on a worker thread. */ - private static class FileWatcher implements Runnable { + private static final class FileWatcher implements Runnable { private final WatchService watchService; private final Set watchedFilePaths; private Runnable fileModifiedAction; @@ -165,7 +178,7 @@ public void run() { public void start(Runnable fileModifiedAction) { this.fileModifiedAction = fileModifiedAction; - thread = new Thread(this, FileDataSource.class.getName()); + thread = new Thread(this, FileDataSourceImpl.class.getName()); thread.setDaemon(true); thread.start(); } @@ -177,4 +190,75 @@ public void stop() { } } } + + /** + * Implements the loading of flag data from one or more files. Will throw an exception if any file can't + * be read or parsed, or if any flag or segment keys are duplicates. + */ + static final class DataLoader { + private final List files; + + public DataLoader(List files) { + this.files = new ArrayList(files); + } + + public Iterable getFiles() { + return files; + } + + public void load(DataBuilder builder) throws FileDataException + { + for (Path p: files) { + try { + byte[] data = Files.readAllBytes(p); + FlagFileParser parser = FlagFileParser.selectForContent(data); + FlagFileRep fileContents = parser.parse(new ByteArrayInputStream(data)); + if (fileContents.flags != null) { + for (Map.Entry e: fileContents.flags.entrySet()) { + builder.add(VersionedDataKind.FEATURES, FlagFactory.flagFromJson(e.getValue())); + } + } + if (fileContents.flagValues != null) { + for (Map.Entry e: fileContents.flagValues.entrySet()) { + builder.add(VersionedDataKind.FEATURES, FlagFactory.flagWithValue(e.getKey(), e.getValue())); + } + } + if (fileContents.segments != null) { + for (Map.Entry e: fileContents.segments.entrySet()) { + builder.add(VersionedDataKind.SEGMENTS, FlagFactory.segmentFromJson(e.getValue())); + } + } + } catch (FileDataException e) { + throw new FileDataException(e.getMessage(), e.getCause(), p); + } catch (IOException e) { + throw new FileDataException(null, e, p); + } + } + } + } + + /** + * Internal data structure that organizes flag/segment data into the format that the feature store + * expects. Will throw an exception if we try to add the same flag or segment key more than once. + */ + static final class DataBuilder { + private final Map, Map> allData = new HashMap<>(); + + public Map, Map> build() { + return allData; + } + + public void add(VersionedDataKind kind, VersionedData item) throws FileDataException { + @SuppressWarnings("unchecked") + Map items = (Map)allData.get(kind); + if (items == null) { + items = new HashMap(); + allData.put(kind, items); + } + if (items.containsKey(item.getKey())) { + throw new FileDataException("in " + kind.getNamespace() + ", key \"" + item.getKey() + "\" was already defined", null, null); + } + items.put(item.getKey(), item); + } + } } diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java new file mode 100644 index 000000000..08083e4d9 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java @@ -0,0 +1,223 @@ +package com.launchdarkly.client.integrations; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; + +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.error.YAMLException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.file.Path; +import java.util.Map; + +abstract class FileDataSourceParsing { + /** + * Indicates that the file processor encountered an error in one of the input files. This exception is + * not surfaced to the host application, it is only logged, and we don't do anything different programmatically + * with different kinds of exceptions, therefore it has no subclasses. + */ + @SuppressWarnings("serial") + static final class FileDataException extends Exception { + private final Path filePath; + + public FileDataException(String message, Throwable cause, Path filePath) { + super(message, cause); + this.filePath = filePath; + } + + public FileDataException(String message, Throwable cause) { + this(message, cause, null); + } + + public Path getFilePath() { + return filePath; + } + + public String getDescription() { + StringBuilder s = new StringBuilder(); + if (getMessage() != null) { + s.append(getMessage()); + if (getCause() != null) { + s.append(" "); + } + } + if (getCause() != null) { + s.append(" [").append(getCause().toString()).append("]"); + } + if (filePath != null) { + s.append(": ").append(filePath); + } + return s.toString(); + } + } + + /** + * The basic data structure that we expect all source files to contain. Note that we don't try to + * parse the flags or segments at this level; that will be done by {@link FlagFactory}. + */ + static final class FlagFileRep { + Map flags; + Map flagValues; + Map segments; + + FlagFileRep() {} + + FlagFileRep(Map flags, Map flagValues, Map segments) { + this.flags = flags; + this.flagValues = flagValues; + this.segments = segments; + } + } + + static abstract class FlagFileParser { + private static final FlagFileParser jsonParser = new JsonFlagFileParser(); + private static final FlagFileParser yamlParser = new YamlFlagFileParser(); + + public abstract FlagFileRep parse(InputStream input) throws FileDataException, IOException; + + public static FlagFileParser selectForContent(byte[] data) { + Reader r = new InputStreamReader(new ByteArrayInputStream(data)); + return detectJson(r) ? jsonParser : yamlParser; + } + + private static boolean detectJson(Reader r) { + // A valid JSON file for our purposes must be an object, i.e. it must start with '{' + while (true) { + try { + int ch = r.read(); + if (ch < 0) { + return false; + } + if (ch == '{') { + return true; + } + if (!Character.isWhitespace(ch)) { + return false; + } + } catch (IOException e) { + return false; + } + } + } + } + + static final class JsonFlagFileParser extends FlagFileParser { + private static final Gson gson = new Gson(); + + @Override + public FlagFileRep parse(InputStream input) throws FileDataException, IOException { + try { + return parseJson(gson.fromJson(new InputStreamReader(input), JsonElement.class)); + } catch (JsonSyntaxException e) { + throw new FileDataException("cannot parse JSON", e); + } + } + + public FlagFileRep parseJson(JsonElement tree) throws FileDataException, IOException { + try { + return gson.fromJson(tree, FlagFileRep.class); + } catch (JsonSyntaxException e) { + throw new FileDataException("cannot parse JSON", e); + } + } + } + + /** + * Parses a FlagFileRep from a YAML file. Two notes about this implementation: + *

+ * 1. We already have logic for parsing (and building) flags using Gson, and would rather not repeat + * that logic. So, rather than telling SnakeYAML to parse the file directly into a FlagFileRep object - + * and providing SnakeYAML-specific methods for building flags - we are just parsing the YAML into + * simple Java objects and then feeding that data into the Gson parser. This is admittedly inefficient, + * but it also means that we don't have to worry about any differences between how Gson unmarshals an + * object and how the YAML parser does it. We already know Gson does the right thing for the flag and + * segment classes, because that's what we use in the SDK. + *

+ * 2. Ideally, it should be possible to have just one parser, since any valid JSON document is supposed + * to also be parseable as YAML. However, at present, that doesn't work: + *

    + *
  • SnakeYAML (1.19) rejects many valid JSON documents due to simple things like whitespace. + * Apparently this is due to supporting only YAML 1.1, not YAML 1.2 which has full JSON support. + *
  • snakeyaml-engine (https://bitbucket.org/asomov/snakeyaml-engine) says it can handle any JSON, + * but it's only for Java 8 and above. + *
  • YamlBeans (https://github.com/EsotericSoftware/yamlbeans) only works right if you're parsing + * directly into a Java bean instance (which FeatureFlag is not). If you try the "parse to simple + * Java types (and then feed them into Gson)" approach, it does not correctly parse non-string types + * (i.e. it treats true as "true"). (https://github.com/EsotericSoftware/yamlbeans/issues/7) + *
+ */ + static final class YamlFlagFileParser extends FlagFileParser { + private static final Yaml yaml = new Yaml(); + private static final Gson gson = new Gson(); + private static final JsonFlagFileParser jsonFileParser = new JsonFlagFileParser(); + + @Override + public FlagFileRep parse(InputStream input) throws FileDataException, IOException { + Object root; + try { + root = yaml.load(input); + } catch (YAMLException e) { + throw new FileDataException("unable to parse YAML", e); + } + JsonElement jsonRoot = gson.toJsonTree(root); + return jsonFileParser.parseJson(jsonRoot); + } + } + + /** + * Creates flag or segment objects from raw JSON. + * + * Note that the {@code FeatureFlag} and {@code Segment} classes are not public in the Java + * client, so we refer to those class objects indirectly via {@code VersionedDataKind}; and + * if we want to construct a flag from scratch, we can't use the constructor but instead must + * build some JSON and then parse that. + */ + static final class FlagFactory { + private static final Gson gson = new Gson(); + + static VersionedData flagFromJson(String jsonString) { + return flagFromJson(gson.fromJson(jsonString, JsonElement.class)); + } + + static VersionedData flagFromJson(JsonElement jsonTree) { + return gson.fromJson(jsonTree, VersionedDataKind.FEATURES.getItemClass()); + } + + /** + * Constructs a flag that always returns the same value. This is done by giving it a single + * variation and setting the fallthrough variation to that. + */ + static VersionedData flagWithValue(String key, JsonElement value) { + JsonElement jsonValue = gson.toJsonTree(value); + JsonObject o = new JsonObject(); + o.addProperty("key", key); + o.addProperty("on", true); + JsonArray vs = new JsonArray(); + vs.add(jsonValue); + o.add("variations", vs); + // Note that LaunchDarkly normally prevents you from creating a flag with just one variation, + // but it's the application that validates that; the SDK doesn't care. + JsonObject ft = new JsonObject(); + ft.addProperty("variation", 0); + o.add("fallthrough", ft); + return flagFromJson(o); + } + + static VersionedData segmentFromJson(String jsonString) { + return segmentFromJson(gson.fromJson(jsonString, JsonElement.class)); + } + + static VersionedData segmentFromJson(JsonElement jsonTree) { + return gson.fromJson(jsonTree, VersionedDataKind.SEGMENTS.getItemClass()); + } + } +} diff --git a/src/main/java/com/launchdarkly/client/integrations/Redis.java b/src/main/java/com/launchdarkly/client/integrations/Redis.java new file mode 100644 index 000000000..050b581bb --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/Redis.java @@ -0,0 +1,23 @@ +package com.launchdarkly.client.integrations; + +/** + * Integration between the LaunchDarkly SDK and Redis. + * + * @since 4.11.0 + */ +public abstract class Redis { + /** + * Returns a builder object for creating a Redis-backed data store. + *

+ * This object can be modified with {@link RedisDataStoreBuilder} methods for any desired + * custom settings, before including it in the SDK configuration with + * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(com.launchdarkly.client.FeatureStoreFactory)}. + * + * @return a data store configuration object + */ + public static RedisDataStoreBuilder dataStore() { + return new RedisDataStoreBuilder(); + } + + private Redis() {} +} diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java new file mode 100644 index 000000000..bbc50491d --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java @@ -0,0 +1,187 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.FeatureStoreCacheConfig; +import com.launchdarkly.client.FeatureStoreFactory; +import com.launchdarkly.client.utils.CachingStoreWrapper; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +import static com.google.common.base.Preconditions.checkNotNull; + +import redis.clients.jedis.JedisPoolConfig; +import redis.clients.jedis.Protocol; + +/** + * A builder for configuring the Redis-based persistent data store. + *

+ * Obtain an instance of this class by calling {@link Redis#dataStore()}. After calling its methods + * to specify any desired custom settings, you can pass it directly into the SDK configuration with + * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(com.launchdarkly.client.FeatureStoreFactory)}. + * You do not need to call {@link #createFeatureStore()} yourself to build the actual data store; that + * will be done by the SDK. + *

+ * Builder calls can be chained, for example: + * + *


+ * LDConfig config = new LDConfig.Builder()
+ *      .dataStore(
+ *           Redis.dataStore()
+ *               .database(1)
+ *               .caching(FeatureStoreCacheConfig.enabled().ttlSeconds(60))
+ *      )
+ *      .build();
+ * 
+ * + * @since 4.11.0 + */ +public final class RedisDataStoreBuilder implements FeatureStoreFactory { + /** + * The default value for the Redis URI: {@code redis://localhost:6379} + */ + public static final URI DEFAULT_URI = URI.create("redis://localhost:6379"); + + /** + * The default value for {@link #prefix(String)}. + */ + public static final String DEFAULT_PREFIX = "launchdarkly"; + + URI uri = DEFAULT_URI; + String prefix = DEFAULT_PREFIX; + int connectTimeout = Protocol.DEFAULT_TIMEOUT; + int socketTimeout = Protocol.DEFAULT_TIMEOUT; + Integer database = null; + String password = null; + boolean tls = false; + FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; + JedisPoolConfig poolConfig = null; + + // These constructors are called only from Implementations + RedisDataStoreBuilder() { + } + + /** + * Specifies the database number to use. + *

+ * The database number can also be specified in the Redis URI, in the form {@code redis://host:port/NUMBER}. Any + * non-null value that you set with {@link #database(Integer)} will override the URI. + * + * @param database the database number, or null to fall back to the URI or the default + * @return the builder + */ + public RedisDataStoreBuilder database(Integer database) { + this.database = database; + return this; + } + + /** + * Specifies a password that will be sent to Redis in an AUTH command. + *

+ * It is also possible to include a password in the Redis URI, in the form {@code redis://:PASSWORD@host:port}. Any + * password that you set with {@link #password(String)} will override the URI. + * + * @param password the password + * @return the builder + */ + public RedisDataStoreBuilder password(String password) { + this.password = password; + return this; + } + + /** + * Optionally enables TLS for secure connections to Redis. + *

+ * This is equivalent to specifying a Redis URI that begins with {@code rediss:} rather than {@code redis:}. + *

+ * Note that not all Redis server distributions support TLS. + * + * @param tls true to enable TLS + * @return the builder + */ + public RedisDataStoreBuilder tls(boolean tls) { + this.tls = tls; + return this; + } + + /** + * Specifies a Redis host URI other than {@link #DEFAULT_URI}. + * + * @param redisUri the URI of the Redis host + * @return the builder + */ + public RedisDataStoreBuilder uri(URI redisUri) { + this.uri = checkNotNull(uri); + return this; + } + + /** + * Specifies whether local caching should be enabled and if so, sets the cache properties. Local + * caching is enabled by default; see {@link FeatureStoreCacheConfig#DEFAULT}. To disable it, pass + * {@link FeatureStoreCacheConfig#disabled()} to this method. + * + * @param caching a {@link FeatureStoreCacheConfig} object specifying caching parameters + * @return the builder + */ + public RedisDataStoreBuilder caching(FeatureStoreCacheConfig caching) { + this.caching = caching; + return this; + } + + /** + * Optionally configures the namespace prefix for all keys stored in Redis. + * + * @param prefix the namespace prefix + * @return the builder + */ + public RedisDataStoreBuilder prefix(String prefix) { + this.prefix = prefix; + return this; + } + + /** + * Optional override if you wish to specify your own configuration to the underlying Jedis pool. + * + * @param poolConfig the Jedis pool configuration. + * @return the builder + */ + public RedisDataStoreBuilder poolConfig(JedisPoolConfig poolConfig) { + this.poolConfig = poolConfig; + return this; + } + + /** + * Optional override which sets the connection timeout for the underlying Jedis pool which otherwise defaults to + * {@link redis.clients.jedis.Protocol#DEFAULT_TIMEOUT} + * + * @param connectTimeout the timeout + * @param timeUnit the time unit for the timeout + * @return the builder + */ + public RedisDataStoreBuilder connectTimeout(int connectTimeout, TimeUnit timeUnit) { + this.connectTimeout = (int) timeUnit.toMillis(connectTimeout); + return this; + } + + /** + * Optional override which sets the connection timeout for the underlying Jedis pool which otherwise defaults to + * {@link redis.clients.jedis.Protocol#DEFAULT_TIMEOUT} + * + * @param socketTimeout the socket timeout + * @param timeUnit the time unit for the timeout + * @return the builder + */ + public RedisDataStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUnit) { + this.socketTimeout = (int) timeUnit.toMillis(socketTimeout); + return this; + } + + /** + * Called internally by the SDK to create the actual data store instance. + * @return the data store configured by this builder + */ + public FeatureStore createFeatureStore() { + RedisDataStoreImpl core = new RedisDataStoreImpl(this); + return CachingStoreWrapper.builder(core).caching(this.caching).build(); + } +} diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java new file mode 100644 index 000000000..24e3968b7 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java @@ -0,0 +1,196 @@ +package com.launchdarkly.client.integrations; + +import com.google.common.annotations.VisibleForTesting; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.utils.FeatureStoreCore; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.launchdarkly.client.utils.FeatureStoreHelpers.marshalJson; +import static com.launchdarkly.client.utils.FeatureStoreHelpers.unmarshalJson; + +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.JedisPoolConfig; +import redis.clients.jedis.Transaction; +import redis.clients.util.JedisURIHelper; + +class RedisDataStoreImpl implements FeatureStoreCore { + private static final Logger logger = LoggerFactory.getLogger(RedisDataStoreImpl.class); + + private final JedisPool pool; + private final String prefix; + private UpdateListener updateListener; + + RedisDataStoreImpl(RedisDataStoreBuilder builder) { + // There is no builder for JedisPool, just a large number of constructor overloads. Unfortunately, + // the overloads that accept a URI do not accept the other parameters we need to set, so we need + // to decompose the URI. + String host = builder.uri.getHost(); + int port = builder.uri.getPort(); + String password = builder.password == null ? JedisURIHelper.getPassword(builder.uri) : builder.password; + int database = builder.database == null ? JedisURIHelper.getDBIndex(builder.uri): builder.database.intValue(); + boolean tls = builder.tls || builder.uri.getScheme().equals("rediss"); + + String extra = tls ? " with TLS" : ""; + if (password != null) { + extra = extra + (extra.isEmpty() ? " with" : " and") + " password"; + } + logger.info(String.format("Connecting to Redis feature store at %s:%d/%d%s", host, port, database, extra)); + + JedisPoolConfig poolConfig = (builder.poolConfig != null) ? builder.poolConfig : new JedisPoolConfig(); + JedisPool pool = new JedisPool(poolConfig, + host, + port, + builder.connectTimeout, + builder.socketTimeout, + password, + database, + null, // clientName + tls, + null, // sslSocketFactory + null, // sslParameters + null // hostnameVerifier + ); + + String prefix = (builder.prefix == null || builder.prefix.isEmpty()) ? + RedisDataStoreBuilder.DEFAULT_PREFIX : + builder.prefix; + + this.pool = pool; + this.prefix = prefix; + } + + @Override + public VersionedData getInternal(VersionedDataKind kind, String key) { + try (Jedis jedis = pool.getResource()) { + VersionedData item = getRedis(kind, key, jedis); + if (item != null) { + logger.debug("[get] Key: {} with version: {} found in \"{}\".", key, item.getVersion(), kind.getNamespace()); + } + return item; + } + } + + @Override + public Map getAllInternal(VersionedDataKind kind) { + try (Jedis jedis = pool.getResource()) { + Map allJson = jedis.hgetAll(itemsKey(kind)); + Map result = new HashMap<>(); + + for (Map.Entry entry : allJson.entrySet()) { + VersionedData item = unmarshalJson(kind, entry.getValue()); + result.put(entry.getKey(), item); + } + return result; + } + } + + @Override + public void initInternal(Map, Map> allData) { + try (Jedis jedis = pool.getResource()) { + Transaction t = jedis.multi(); + + for (Map.Entry, Map> entry: allData.entrySet()) { + String baseKey = itemsKey(entry.getKey()); + t.del(baseKey); + for (VersionedData item: entry.getValue().values()) { + t.hset(baseKey, item.getKey(), marshalJson(item)); + } + } + + t.set(initedKey(), ""); + t.exec(); + } + } + + @Override + public VersionedData upsertInternal(VersionedDataKind kind, VersionedData newItem) { + while (true) { + Jedis jedis = null; + try { + jedis = pool.getResource(); + String baseKey = itemsKey(kind); + jedis.watch(baseKey); + + if (updateListener != null) { + updateListener.aboutToUpdate(baseKey, newItem.getKey()); + } + + VersionedData oldItem = getRedis(kind, newItem.getKey(), jedis); + + if (oldItem != null && oldItem.getVersion() >= newItem.getVersion()) { + logger.debug("Attempted to {} key: {} version: {}" + + " with a version that is the same or older: {} in \"{}\"", + newItem.isDeleted() ? "delete" : "update", + newItem.getKey(), oldItem.getVersion(), newItem.getVersion(), kind.getNamespace()); + return oldItem; + } + + Transaction tx = jedis.multi(); + tx.hset(baseKey, newItem.getKey(), marshalJson(newItem)); + List result = tx.exec(); + if (result.isEmpty()) { + // if exec failed, it means the watch was triggered and we should retry + logger.debug("Concurrent modification detected, retrying"); + continue; + } + + return newItem; + } finally { + if (jedis != null) { + jedis.unwatch(); + jedis.close(); + } + } + } + } + + @Override + public boolean initializedInternal() { + try (Jedis jedis = pool.getResource()) { + return jedis.exists(initedKey()); + } + } + + @Override + public void close() throws IOException { + logger.info("Closing LaunchDarkly RedisFeatureStore"); + pool.destroy(); + } + + @VisibleForTesting + void setUpdateListener(UpdateListener updateListener) { + this.updateListener = updateListener; + } + + private String itemsKey(VersionedDataKind kind) { + return prefix + ":" + kind.getNamespace(); + } + + private String initedKey() { + return prefix + ":$inited"; + } + + private T getRedis(VersionedDataKind kind, String key, Jedis jedis) { + String json = jedis.hget(itemsKey(kind), key); + + if (json == null) { + logger.debug("[get] Key: {} not found in \"{}\". Returning null", key, kind.getNamespace()); + return null; + } + + return unmarshalJson(kind, json); + } + + static interface UpdateListener { + void aboutToUpdate(String baseKey, String itemKey); + } +} diff --git a/src/main/java/com/launchdarkly/client/integrations/package-info.java b/src/main/java/com/launchdarkly/client/integrations/package-info.java new file mode 100644 index 000000000..589a2c63a --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/package-info.java @@ -0,0 +1,12 @@ +/** + * This package contains integration tools for connecting the SDK to other software components. + *

+ * In the current main LaunchDarkly Java SDK library, this package contains {@link com.launchdarkly.client.integrations.Redis} + * (for using Redis as a store for flag data) and {@link com.launchdarkly.client.integrations.FileData} + * (for reading flags from a file in testing). Other SDK add-on libraries, such as database integrations, + * will define their classes in {@code com.launchdarkly.client.integrations} as well. + *

+ * The general pattern for factory methods in this package is {@code ToolName#componentType()}, + * such as {@code Redis#dataStore()} or {@code FileData#dataSource()}. + */ +package com.launchdarkly.client.integrations; diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java similarity index 66% rename from src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java rename to src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java index e58d56388..08febe5ad 100644 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java @@ -1,8 +1,5 @@ package com.launchdarkly.client; -import com.launchdarkly.client.RedisFeatureStore.UpdateListener; - -import org.junit.Assume; import org.junit.BeforeClass; import java.net.URI; @@ -11,11 +8,12 @@ import redis.clients.jedis.Jedis; -public class RedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { +@SuppressWarnings({ "javadoc", "deprecation" }) +public class DeprecatedRedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { private static final URI REDIS_URI = URI.create("redis://localhost:6379"); - public RedisFeatureStoreTest(boolean cached) { + public DeprecatedRedisFeatureStoreTest(boolean cached) { super(cached); } @@ -43,15 +41,4 @@ protected void clearAllData() { client.flushDB(); } } - - @Override - protected boolean setUpdateHook(RedisFeatureStore storeUnderTest, final Runnable hook) { - storeUnderTest.setUpdateListener(new UpdateListener() { - @Override - public void aboutToUpdate(String baseKey, String itemKey) { - hook.run(); - } - }); - return true; - } } diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java deleted file mode 100644 index 64fb15068..000000000 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.launchdarkly.client; - -import org.junit.Test; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.concurrent.TimeUnit; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; - -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Protocol; - -public class RedisFeatureStoreBuilderTest { - @Test - public void testDefaultValues() { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_URI, conf.uri); - assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); - assertNull(conf.poolConfig); - } - - @Test - public void testConstructorSpecifyingUri() { - URI uri = URI.create("redis://host:1234"); - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(uri); - assertEquals(uri, conf.uri); - assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); - assertNull(conf.poolConfig); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedUriBuildingConstructor() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder("badscheme", "example", 1234, 100); - assertEquals(URI.create("badscheme://example:1234"), conf.uri); - assertEquals(100, conf.caching.getCacheTime()); - assertEquals(TimeUnit.SECONDS, conf.caching.getCacheTimeUnit()); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); - assertNull(conf.poolConfig); - } - - @SuppressWarnings("deprecation") - @Test - public void testRefreshStaleValues() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().refreshStaleValues(true); - assertEquals(FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH, conf.caching.getStaleValuesPolicy()); - } - - @SuppressWarnings("deprecation") - @Test - public void testAsyncRefresh() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().refreshStaleValues(true).asyncRefresh(true); - assertEquals(FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC, conf.caching.getStaleValuesPolicy()); - } - - @SuppressWarnings("deprecation") - @Test - public void testRefreshStaleValuesWithoutAsyncRefresh() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().asyncRefresh(true); - assertEquals(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); - } - - @Test - public void testPrefixConfigured() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().prefix("prefix"); - assertEquals("prefix", conf.prefix); - } - - @Test - public void testConnectTimeoutConfigured() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().connectTimeout(1, TimeUnit.SECONDS); - assertEquals(1000, conf.connectTimeout); - } - - @Test - public void testSocketTimeoutConfigured() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().socketTimeout(1, TimeUnit.SECONDS); - assertEquals(1000, conf.socketTimeout); - } - - @SuppressWarnings("deprecation") - @Test - public void testCacheTimeWithUnit() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().cacheTime(2000, TimeUnit.MILLISECONDS); - assertEquals(2000, conf.caching.getCacheTime()); - assertEquals(TimeUnit.MILLISECONDS, conf.caching.getCacheTimeUnit()); - } - - @Test - public void testPoolConfigConfigured() throws URISyntaxException { - JedisPoolConfig poolConfig = new JedisPoolConfig(); - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().poolConfig(poolConfig); - assertEquals(poolConfig, conf.poolConfig); - } -} diff --git a/src/test/java/com/launchdarkly/client/files/JsonFlagFileParserTest.java b/src/test/java/com/launchdarkly/client/files/JsonFlagFileParserTest.java deleted file mode 100644 index 0110105f6..000000000 --- a/src/test/java/com/launchdarkly/client/files/JsonFlagFileParserTest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.launchdarkly.client.files; - -public class JsonFlagFileParserTest extends FlagFileParserTestBase { - public JsonFlagFileParserTest() { - super(new JsonFlagFileParser(), ".json"); - } -} diff --git a/src/test/java/com/launchdarkly/client/files/YamlFlagFileParserTest.java b/src/test/java/com/launchdarkly/client/files/YamlFlagFileParserTest.java deleted file mode 100644 index 9b94e3801..000000000 --- a/src/test/java/com/launchdarkly/client/files/YamlFlagFileParserTest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.launchdarkly.client.files; - -public class YamlFlagFileParserTest extends FlagFileParserTestBase { - public YamlFlagFileParserTest() { - super(new YamlFlagFileParser(), ".yml"); - } -} diff --git a/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java b/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java similarity index 65% rename from src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java rename to src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java index cc8e344bd..f7c365b41 100644 --- a/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.files; +package com.launchdarkly.client.integrations; import com.google.gson.JsonPrimitive; import com.launchdarkly.client.LDClient; @@ -7,22 +7,23 @@ import org.junit.Test; -import static com.launchdarkly.client.files.TestData.FLAG_VALUE_1; -import static com.launchdarkly.client.files.TestData.FLAG_VALUE_1_KEY; -import static com.launchdarkly.client.files.TestData.FULL_FLAG_1_KEY; -import static com.launchdarkly.client.files.TestData.FULL_FLAG_1_VALUE; -import static com.launchdarkly.client.files.TestData.resourceFilePath; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUE_1; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_FLAG_1_KEY; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_FLAG_1_VALUE; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +@SuppressWarnings("javadoc") public class ClientWithFileDataSourceTest { private static final LDUser user = new LDUser.Builder("userkey").build(); private LDClient makeClient() throws Exception { - FileDataSourceFactory fdsf = FileComponents.fileDataSource() + FileDataSourceBuilder fdsb = FileData.dataSource() .filePaths(resourceFilePath("all-properties.json")); LDConfig config = new LDConfig.Builder() - .dataSource(fdsf) + .dataSource(fdsb) .sendEvents(false) .build(); return new LDClient("sdkKey", config); diff --git a/src/test/java/com/launchdarkly/client/files/DataLoaderTest.java b/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java similarity index 86% rename from src/test/java/com/launchdarkly/client/files/DataLoaderTest.java rename to src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java index 9145c7d32..b78585783 100644 --- a/src/test/java/com/launchdarkly/client/files/DataLoaderTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.files; +package com.launchdarkly.client.integrations; import com.google.common.collect.ImmutableList; import com.google.gson.Gson; @@ -6,6 +6,9 @@ import com.google.gson.JsonObject; import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.integrations.FileDataSourceImpl.DataBuilder; +import com.launchdarkly.client.integrations.FileDataSourceImpl.DataLoader; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; import org.junit.Assert; import org.junit.Test; @@ -14,12 +17,13 @@ import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; -import static com.launchdarkly.client.files.TestData.FLAG_VALUE_1_KEY; -import static com.launchdarkly.client.files.TestData.resourceFilePath; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; +@SuppressWarnings("javadoc") public class DataLoaderTest { private static final Gson gson = new Gson(); private DataBuilder builder = new DataBuilder(); @@ -70,7 +74,7 @@ public void duplicateFlagKeyInFlagsThrowsException() throws Exception { DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("flag-only.json"), resourceFilePath("flag-with-duplicate-key.json"))); ds.load(builder); - } catch (DataLoaderException e) { + } catch (FileDataException e) { assertThat(e.getMessage(), containsString("key \"flag1\" was already defined")); } } @@ -81,7 +85,7 @@ public void duplicateFlagKeyInFlagsAndFlagValuesThrowsException() throws Excepti DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("flag-only.json"), resourceFilePath("value-with-duplicate-key.json"))); ds.load(builder); - } catch (DataLoaderException e) { + } catch (FileDataException e) { assertThat(e.getMessage(), containsString("key \"flag1\" was already defined")); } } @@ -92,7 +96,7 @@ public void duplicateSegmentKeyThrowsException() throws Exception { DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("segment-only.json"), resourceFilePath("segment-with-duplicate-key.json"))); ds.load(builder); - } catch (DataLoaderException e) { + } catch (FileDataException e) { assertThat(e.getMessage(), containsString("key \"seg1\" was already defined")); } } diff --git a/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java similarity index 88% rename from src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java rename to src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java index 62924d9d5..0d933e967 100644 --- a/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.files; +package com.launchdarkly.client.integrations; import com.launchdarkly.client.FeatureStore; import com.launchdarkly.client.InMemoryFeatureStore; @@ -14,28 +14,28 @@ import java.nio.file.Paths; import java.util.concurrent.Future; -import static com.launchdarkly.client.files.FileComponents.fileDataSource; -import static com.launchdarkly.client.files.TestData.ALL_FLAG_KEYS; -import static com.launchdarkly.client.files.TestData.ALL_SEGMENT_KEYS; -import static com.launchdarkly.client.files.TestData.getResourceContents; -import static com.launchdarkly.client.files.TestData.resourceFilePath; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.ALL_SEGMENT_KEYS; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.getResourceContents; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.fail; +@SuppressWarnings("javadoc") public class FileDataSourceTest { private static final Path badFilePath = Paths.get("no-such-file.json"); private final FeatureStore store = new InMemoryFeatureStore(); private final LDConfig config = new LDConfig.Builder().build(); - private final FileDataSourceFactory factory; + private final FileDataSourceBuilder factory; public FileDataSourceTest() throws Exception { factory = makeFactoryWithFile(resourceFilePath("all-properties.json")); } - private static FileDataSourceFactory makeFactoryWithFile(Path path) { - return fileDataSource().filePaths(path); + private static FileDataSourceBuilder makeFactoryWithFile(Path path) { + return FileData.dataSource().filePaths(path); } @Test @@ -94,7 +94,7 @@ public void initializedIsFalseAfterUnsuccessfulLoad() throws Exception { @Test public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { File file = makeTempFlagFile(); - FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()); try { setFileContents(file, getResourceContents("flag-only.json")); try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { @@ -115,7 +115,7 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { @Test public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { File file = makeTempFlagFile(); - FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); long maxMsToWait = 10000; try { setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag @@ -142,7 +142,7 @@ public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws Exception { File file = makeTempFlagFile(); setFileContents(file, "not valid"); - FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); long maxMsToWait = 10000; try { try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { diff --git a/src/test/java/com/launchdarkly/client/files/TestData.java b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java similarity index 90% rename from src/test/java/com/launchdarkly/client/files/TestData.java rename to src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java index d1f098d7c..d222f4c77 100644 --- a/src/test/java/com/launchdarkly/client/files/TestData.java +++ b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.files; +package com.launchdarkly.client.integrations; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; @@ -14,7 +14,8 @@ import java.util.Map; import java.util.Set; -public class TestData { +@SuppressWarnings("javadoc") +public class FileDataSourceTestData { private static final Gson gson = new Gson(); // These should match the data in our test files @@ -40,12 +41,11 @@ public class TestData { public static final Set ALL_SEGMENT_KEYS = ImmutableSet.of(FULL_SEGMENT_1_KEY); public static Path resourceFilePath(String filename) throws URISyntaxException { - URL resource = TestData.class.getClassLoader().getResource("filesource/" + filename); + URL resource = FileDataSourceTestData.class.getClassLoader().getResource("filesource/" + filename); return Paths.get(resource.toURI()); } public static String getResourceContents(String filename) throws Exception { return new String(Files.readAllBytes(resourceFilePath(filename))); } - } diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java new file mode 100644 index 000000000..c23a66772 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java @@ -0,0 +1,10 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.integrations.FileDataSourceParsing.JsonFlagFileParser; + +@SuppressWarnings("javadoc") +public class FlagFileParserJsonTest extends FlagFileParserTestBase { + public FlagFileParserJsonTest() { + super(new JsonFlagFileParser(), ".json"); + } +} diff --git a/src/test/java/com/launchdarkly/client/files/FlagFileParserTestBase.java b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java similarity index 76% rename from src/test/java/com/launchdarkly/client/files/FlagFileParserTestBase.java rename to src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java index d6165e279..fd2be268f 100644 --- a/src/test/java/com/launchdarkly/client/files/FlagFileParserTestBase.java +++ b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java @@ -1,4 +1,8 @@ -package com.launchdarkly.client.files; +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileParser; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileRep; import org.junit.Test; @@ -6,14 +10,15 @@ import java.io.FileNotFoundException; import java.net.URISyntaxException; -import static com.launchdarkly.client.files.TestData.FLAG_VALUES; -import static com.launchdarkly.client.files.TestData.FULL_FLAGS; -import static com.launchdarkly.client.files.TestData.FULL_SEGMENTS; -import static com.launchdarkly.client.files.TestData.resourceFilePath; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUES; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_FLAGS; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_SEGMENTS; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.nullValue; +@SuppressWarnings("javadoc") public abstract class FlagFileParserTestBase { private final FlagFileParser parser; private final String fileExtension; @@ -63,7 +68,7 @@ public void canParseFileWithOnlySegment() throws Exception { } } - @Test(expected = DataLoaderException.class) + @Test(expected = FileDataException.class) public void throwsExpectedErrorForBadFile() throws Exception { try (FileInputStream input = openFile("malformed")) { parser.parse(input); diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java new file mode 100644 index 000000000..3ad640e92 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java @@ -0,0 +1,10 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.integrations.FileDataSourceParsing.YamlFlagFileParser; + +@SuppressWarnings("javadoc") +public class FlagFileParserYamlTest extends FlagFileParserTestBase { + public FlagFileParserYamlTest() { + super(new YamlFlagFileParser(), ".yml"); + } +} diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java new file mode 100644 index 000000000..de8cf2570 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java @@ -0,0 +1,53 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.FeatureStoreCacheConfig; + +import org.junit.Test; + +import java.net.URISyntaxException; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import redis.clients.jedis.JedisPoolConfig; +import redis.clients.jedis.Protocol; + +@SuppressWarnings("javadoc") +public class RedisFeatureStoreBuilderTest { + @Test + public void testDefaultValues() { + RedisDataStoreBuilder conf = Redis.dataStore(); + assertEquals(RedisDataStoreBuilder.DEFAULT_URI, conf.uri); + assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); + assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); + assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); + assertEquals(RedisDataStoreBuilder.DEFAULT_PREFIX, conf.prefix); + assertNull(conf.poolConfig); + } + + @Test + public void testPrefixConfigured() throws URISyntaxException { + RedisDataStoreBuilder conf = Redis.dataStore().prefix("prefix"); + assertEquals("prefix", conf.prefix); + } + + @Test + public void testConnectTimeoutConfigured() throws URISyntaxException { + RedisDataStoreBuilder conf = Redis.dataStore().connectTimeout(1, TimeUnit.SECONDS); + assertEquals(1000, conf.connectTimeout); + } + + @Test + public void testSocketTimeoutConfigured() throws URISyntaxException { + RedisDataStoreBuilder conf = Redis.dataStore().socketTimeout(1, TimeUnit.SECONDS); + assertEquals(1000, conf.socketTimeout); + } + + @Test + public void testPoolConfigConfigured() throws URISyntaxException { + JedisPoolConfig poolConfig = new JedisPoolConfig(); + RedisDataStoreBuilder conf = Redis.dataStore().poolConfig(poolConfig); + assertEquals(poolConfig, conf.poolConfig); + } +} diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java new file mode 100644 index 000000000..8e4f6ea1b --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java @@ -0,0 +1,62 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.FeatureStoreCacheConfig; +import com.launchdarkly.client.FeatureStoreDatabaseTestBase; +import com.launchdarkly.client.integrations.RedisDataStoreImpl.UpdateListener; +import com.launchdarkly.client.utils.CachingStoreWrapper; + +import org.junit.BeforeClass; + +import java.net.URI; + +import static org.junit.Assume.assumeTrue; + +import redis.clients.jedis.Jedis; + +@SuppressWarnings("javadoc") +public class RedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { + + private static final URI REDIS_URI = URI.create("redis://localhost:6379"); + + public RedisFeatureStoreTest(boolean cached) { + super(cached); + } + + @BeforeClass + public static void maybeSkipDatabaseTests() { + String skipParam = System.getenv("LD_SKIP_DATABASE_TESTS"); + assumeTrue(skipParam == null || skipParam.equals("")); + } + + @Override + protected FeatureStore makeStore() { + RedisDataStoreBuilder builder = Redis.dataStore().uri(REDIS_URI); + builder.caching(cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : FeatureStoreCacheConfig.disabled()); + return builder.createFeatureStore(); + } + + @Override + protected FeatureStore makeStoreWithPrefix(String prefix) { + return Redis.dataStore().uri(REDIS_URI).caching(FeatureStoreCacheConfig.disabled()).prefix(prefix).createFeatureStore(); + } + + @Override + protected void clearAllData() { + try (Jedis client = new Jedis("localhost")) { + client.flushDB(); + } + } + + @Override + protected boolean setUpdateHook(FeatureStore storeUnderTest, final Runnable hook) { + RedisDataStoreImpl core = (RedisDataStoreImpl)((CachingStoreWrapper)storeUnderTest).getCore(); + core.setUpdateListener(new UpdateListener() { + @Override + public void aboutToUpdate(String baseKey, String itemKey) { + hook.run(); + } + }); + return true; + } +}