diff --git a/build.gradle b/build.gradle index 17ca58cb5..2466c904f 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ repositories { allprojects { group = 'com.launchdarkly' - version = "0.14.0" + version = "0.15.0-SNAPSHOT" sourceCompatibility = 1.6 targetCompatibility = 1.6 } diff --git a/src/main/java/com/launchdarkly/client/FeatureRequestor.java b/src/main/java/com/launchdarkly/client/FeatureRequestor.java new file mode 100644 index 000000000..2bab25df6 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/FeatureRequestor.java @@ -0,0 +1,158 @@ +package com.launchdarkly.client; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.http.HttpStatus; +import org.apache.http.client.cache.CacheResponseStatus; +import org.apache.http.client.cache.HttpCacheContext; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.cache.CacheConfig; +import org.apache.http.impl.client.cache.CachingHttpClients; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.Map; + +class FeatureRequestor { + + private final String apiKey; + private final LDConfig config; + private final CloseableHttpClient client; + private static final Logger logger = LoggerFactory.getLogger(FeatureRequestor.class); + + FeatureRequestor(String apiKey, LDConfig config) { + this.apiKey = apiKey; + this.config = config; + this.client = createClient(); + } + + protected CloseableHttpClient createClient() { + CloseableHttpClient client; + PoolingHttpClientConnectionManager manager = new PoolingHttpClientConnectionManager(); + manager.setMaxTotal(100); + manager.setDefaultMaxPerRoute(20); + + CacheConfig cacheConfig = CacheConfig.custom() + .setMaxCacheEntries(1000) + .setMaxObjectSize(131072) + .setSharedCache(false) + .build(); + + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(config.connectTimeout) + .setSocketTimeout(config.socketTimeout) + .setProxy(config.proxyHost) + .build(); + client = CachingHttpClients.custom() + .setCacheConfig(cacheConfig) + .setConnectionManager(manager) + .setDefaultRequestConfig(requestConfig) + .build(); + return client; + } + + Map> makeAllRequest(boolean latest) throws IOException { + Gson gson = new Gson(); + HttpCacheContext context = HttpCacheContext.create(); + + String resource = latest ? "/api/eval/latest-features" : "/api/eval/features"; + + HttpGet request = config.getRequest(apiKey, resource); + + CloseableHttpResponse response = null; + try { + response = client.execute(request, context); + + logCacheResponse(context.getCacheResponseStatus()); + + handleResponseStatus(response.getStatusLine().getStatusCode(), null); + + Type type = new TypeToken>>() {}.getType(); + + Map> result = gson.fromJson(EntityUtils.toString(response.getEntity()), type); + return result; + } + finally { + try { + if (response != null) response.close(); + } catch (IOException e) { + } + } + } + + void logCacheResponse(CacheResponseStatus status) { + switch (status) { + case CACHE_HIT: + logger.debug("A response was generated from the cache with " + + "no requests sent upstream"); + break; + case CACHE_MODULE_RESPONSE: + logger.debug("The response was generated directly by the " + + "caching module"); + break; + case CACHE_MISS: + logger.debug("The response came from an upstream server"); + break; + case VALIDATED: + logger.debug("The response was generated from the cache " + + "after validating the entry with the origin server"); + break; + } + } + + void handleResponseStatus(int status, String featureKey) throws IOException { + + if (status != HttpStatus.SC_OK) { + if (status == HttpStatus.SC_UNAUTHORIZED) { + logger.error("Invalid API key"); + } else if (status == HttpStatus.SC_NOT_FOUND) { + if (featureKey != null) { + logger.error("Unknown feature key: " + featureKey); + } + else { + logger.error("Resource not found"); + } + } else { + logger.error("Unexpected status code: " + status); + } + throw new IOException("Failed to fetch flag"); + } + + } + + FeatureRep makeRequest(String featureKey, boolean latest) throws IOException { + Gson gson = new Gson(); + HttpCacheContext context = HttpCacheContext.create(); + + String resource = latest ? "/api/eval/latest-features/" : "/api/eval/features/"; + + HttpGet request = config.getRequest(apiKey,resource + featureKey); + + CloseableHttpResponse response = null; + try { + response = client.execute(request, context); + + logCacheResponse(context.getCacheResponseStatus()); + + handleResponseStatus(response.getStatusLine().getStatusCode(), featureKey); + + Type type = new TypeToken>() {}.getType(); + + FeatureRep result = gson.fromJson(EntityUtils.toString(response.getEntity()), type); + return result; + } + finally { + try { + if (response != null) response.close(); + } catch (IOException e) { + } + } + } +} diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index c0f34db02..6a5d037d4 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -1,32 +1,17 @@ package com.launchdarkly.client; -import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.google.gson.reflect.TypeToken; -import org.apache.http.HttpStatus; import org.apache.http.annotation.ThreadSafe; -import org.apache.http.client.cache.CacheResponseStatus; -import org.apache.http.client.cache.HttpCacheContext; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.cache.CacheConfig; -import org.apache.http.impl.client.cache.CachingHttpClients; -import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; -import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.Closeable; import java.io.IOException; -import java.lang.reflect.Type; import java.net.URL; import java.util.jar.Attributes; import java.util.jar.Manifest; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - /** * * A client for the LaunchDarkly API. Client instances are thread-safe. Applications should instantiate @@ -37,10 +22,9 @@ public class LDClient implements Closeable { private static final Logger logger = LoggerFactory.getLogger(LDClient.class); private final LDConfig config; - private final CloseableHttpClient client; + private final FeatureRequestor requestor; private final EventProcessor eventProcessor; private final StreamProcessor streamProcessor; - private final String apiKey; protected static final String CLIENT_VERSION = getClientVersion(); private volatile boolean offline = false; @@ -63,14 +47,13 @@ public LDClient(String apiKey) { * @param config a client configuration object */ public LDClient(String apiKey, LDConfig config) { - this.apiKey = apiKey; this.config = config; - this.client = createClient(); + this.requestor = createFeatureRequestor(apiKey, config); this.eventProcessor = createEventProcessor(apiKey, config); if (config.stream) { logger.debug("Enabling streaming API"); - this.streamProcessor = createStreamProcessor(apiKey, config); + this.streamProcessor = createStreamProcessor(apiKey, config, requestor); this.streamProcessor.subscribe(); } else { logger.debug("Streaming API disabled"); @@ -78,38 +61,18 @@ public LDClient(String apiKey, LDConfig config) { } } + protected FeatureRequestor createFeatureRequestor(String apiKey, LDConfig config) { + return new FeatureRequestor(apiKey, config); + } + protected EventProcessor createEventProcessor(String apiKey, LDConfig config) { return new EventProcessor(apiKey, config); } - protected StreamProcessor createStreamProcessor(String apiKey, LDConfig config) { - return new StreamProcessor(apiKey, config); + protected StreamProcessor createStreamProcessor(String apiKey, LDConfig config, FeatureRequestor requestor) { + return new StreamProcessor(apiKey, config, requestor); } - protected CloseableHttpClient createClient() { - CloseableHttpClient client; - PoolingHttpClientConnectionManager manager = new PoolingHttpClientConnectionManager(); - manager.setMaxTotal(100); - manager.setDefaultMaxPerRoute(20); - - CacheConfig cacheConfig = CacheConfig.custom() - .setMaxCacheEntries(1000) - .setMaxObjectSize(8192) - .setSharedCache(false) - .build(); - - RequestConfig requestConfig = RequestConfig.custom() - .setConnectTimeout(config.connectTimeout) - .setSocketTimeout(config.socketTimeout) - .setProxy(config.proxyHost) - .build(); - client = CachingHttpClients.custom() - .setCacheConfig(cacheConfig) - .setConnectionManager(manager) - .setDefaultRequestConfig(requestConfig) - .build(); - return client; - } /** * Tracks that a user performed an event. @@ -191,13 +154,14 @@ public boolean toggle(String featureKey, LDUser user, boolean defaultValue) { logger.debug("Using feature flag stored from streaming API"); result = (FeatureRep) this.streamProcessor.getFeature(featureKey); if (config.debugStreaming) { - FeatureRep pollingResult = fetchFeature(featureKey); + FeatureRep pollingResult = requestor.makeRequest(featureKey, true); if (!result.equals(pollingResult)) { logger.warn("Mismatch between streaming and polling feature! Streaming: {} Polling: {}", result, pollingResult); } } } else { - result = fetchFeature(featureKey); + // If streaming is enabled, always get the latest version of the feature while polling + result = requestor.makeRequest(featureKey, this.config.stream); } if (result == null) { logger.warn("Unknown feature flag " + featureKey + "; returning default value"); @@ -221,60 +185,7 @@ public boolean toggle(String featureKey, LDUser user, boolean defaultValue) { } } - private FeatureRep fetchFeature(String featureKey) throws IOException { - Gson gson = new Gson(); - HttpCacheContext context = HttpCacheContext.create(); - HttpGet request = config.getRequest(apiKey, "/api/eval/features/" + featureKey); - - CloseableHttpResponse response = null; - try { - response = client.execute(request, context); - - CacheResponseStatus responseStatus = context.getCacheResponseStatus(); - - switch (responseStatus) { - case CACHE_HIT: - logger.debug("A response was generated from the cache with " + - "no requests sent upstream"); - break; - case CACHE_MODULE_RESPONSE: - logger.debug("The response was generated directly by the " + - "caching module"); - break; - case CACHE_MISS: - logger.debug("The response came from an upstream server"); - break; - case VALIDATED: - logger.debug("The response was generated from the cache " + - "after validating the entry with the origin server"); - break; - } - - int status = response.getStatusLine().getStatusCode(); - - if (status != HttpStatus.SC_OK) { - if (status == HttpStatus.SC_UNAUTHORIZED) { - logger.error("Invalid API key"); - } else if (status == HttpStatus.SC_NOT_FOUND) { - logger.error("Unknown feature key: " + featureKey); - } else { - logger.error("Unexpected status code: " + status); - } - throw new IOException("Failed to fetch flag"); - } - - Type boolType = new TypeToken>() {}.getType(); - FeatureRep result = gson.fromJson(EntityUtils.toString(response.getEntity()), boolType); - return result; - } - finally { - try { - if (response != null) response.close(); - } catch (IOException e) { - } - } - } /** * Closes the LaunchDarkly client event processing thread and flushes all pending events. This should only diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index 92617894c..76fe8a9ac 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -254,7 +254,9 @@ public Builder email(String email) { * @return the builder */ public Builder custom(String k, String v) { - custom.put(k, new JsonPrimitive(v)); + if (key != null && v != null) { + custom.put(k, new JsonPrimitive(v)); + } return this; } @@ -265,7 +267,9 @@ public Builder custom(String k, String v) { * @return the builder */ public Builder custom(String k, Number n) { - custom.put(k, new JsonPrimitive(n)); + if (key != null && n != null) { + custom.put(k, new JsonPrimitive(n)); + } return this; } @@ -276,7 +280,9 @@ public Builder custom(String k, Number n) { * @return the builder */ public Builder custom(String k, Boolean b) { - custom.put(k, new JsonPrimitive(b)); + if (key != null && b != null) { + custom.put(k, new JsonPrimitive(b)); + } return this; } @@ -289,7 +295,9 @@ public Builder custom(String k, Boolean b) { public Builder custom(String k, List vs) { JsonArray array = new JsonArray(); for (String v : vs) { - array.add(new JsonPrimitive(v)); + if (v != null) { + array.add(new JsonPrimitive(v)); + } } custom.put(k, array); return this; diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 7ecf604b0..d91554e6a 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -5,6 +5,8 @@ import org.glassfish.jersey.internal.util.collection.StringKeyIgnoreCaseMultivaluedMap; import org.glassfish.jersey.media.sse.InboundEvent; import org.glassfish.jersey.media.sse.SseFeature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import javax.ws.rs.client.Client; import javax.ws.rs.client.ClientBuilder; @@ -19,18 +21,24 @@ class StreamProcessor implements Closeable { private static final String PUT = "put"; private static final String PATCH = "patch"; private static final String DELETE = "delete"; + private static final String INDIRECT_PUT = "indirect/put"; + private static final String INDIRECT_PATCH = "indirect/patch"; + private static final Logger logger = LoggerFactory.getLogger(StreamProcessor.class); private final Client client; private final FeatureStore store; private final LDConfig config; private final String apiKey; + private final FeatureRequestor requestor; private EventSource es; - StreamProcessor(String apiKey, LDConfig config) { + + StreamProcessor(String apiKey, LDConfig config, FeatureRequestor requestor) { this.client = ClientBuilder.newBuilder().register(SseFeature.class).build(); this.store = new InMemoryFeatureStore(); this.config = config; this.apiKey = apiKey; + this.requestor = requestor; } void subscribe() { @@ -58,8 +66,25 @@ else if (event.getName().equals(DELETE)) { FeatureDeleteData data = gson.fromJson(event.readData(), FeatureDeleteData.class); store.delete(data.key(), data.version()); } + else if (event.getName().equals(INDIRECT_PUT)) { + try { + Map> features = requestor.makeAllRequest(true); + store.init(features); + } catch (IOException e) { + logger.error("Encountered exception in LaunchDarkly client", e); + } + } + else if (event.getName().equals(INDIRECT_PATCH)) { + String key = event.readData(); + try { + FeatureRep feature = requestor.makeRequest(key, true); + store.upsert(key, feature); + } catch (IOException e) { + logger.error("Encountered exception in LaunchDarkly client", e); + } + } else { - // TODO log an error + logger.warn("Unexpected event found in stream: " + event.getName()); } } }; diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index c95a2f6a0..3e3bae21e 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -20,28 +20,24 @@ public class LDClientTest extends EasyMockSupport { private CloseableHttpClient httpClient = createMock(CloseableHttpClient.class); - private EventProcessor eventProcessor = createMock(EventProcessor.class); + private FeatureRequestor requestor = createMock(FeatureRequestor.class); LDClient client = new LDClient("API_KEY") { - @Override - protected CloseableHttpClient createClient() { - return httpClient; - } @Override - protected EventProcessor createEventProcessor(String apiKey, LDConfig config) { - return eventProcessor; + protected FeatureRequestor createFeatureRequestor(String apiKey, LDConfig config) { + return requestor; } }; @Test public void testExceptionThrownByHttpClientReturnsDefaultValue() throws IOException { - expect(httpClient.execute(anyObject(HttpUriRequest.class), anyObject(HttpContext.class))).andThrow(new RuntimeException()); - replay(httpClient); + expect(requestor.makeRequest(anyString(), anyBoolean())).andThrow(new IOException()); + replay(requestor); - boolean result = client.getFlag("test", new LDUser("test.key"), true); + boolean result = client.toggle("test", new LDUser("test.key"), true); assertEquals(true, result); - verify(httpClient); + verify(requestor); } }