diff --git a/unleash-client-java/pom.xml b/unleash-client-java/pom.xml index becac166be..f43b1dea18 100644 --- a/unleash-client-java/pom.xml +++ b/unleash-client-java/pom.xml @@ -19,6 +19,17 @@ + + + + com.google.code.gson + gson + 2.2.4 + + org.apache.logging.log4j log4j-api @@ -31,12 +42,22 @@ 4.3.1 + junit junit 4.11 + test + + org.mockito + mockito-all + 1.9.5 + test + + + diff --git a/unleash-client-java/src/main/no/finn/unleash/Toggle.java b/unleash-client-java/src/main/no/finn/unleash/Toggle.java new file mode 100644 index 0000000000..0b8cf1ecb2 --- /dev/null +++ b/unleash-client-java/src/main/no/finn/unleash/Toggle.java @@ -0,0 +1,33 @@ +package no.finn.unleash; + +import java.util.Map; + +public final class Toggle { + private final String name; + private final boolean enabled; + private final String strategy; + private final Map parameters; + + public Toggle(String name, boolean enabled, String strategy, Map parameters) { + this.name = name; + this.enabled = enabled; + this.strategy = strategy; + this.parameters = parameters; + } + + public String getName() { + return name; + } + + public boolean isEnabled() { + return enabled; + } + + public String getStrategy() { + return strategy; + } + + public Map getParameters() { + return parameters; + } +} diff --git a/unleash-client-java/src/main/no/finn/unleash/Unleash.java b/unleash-client-java/src/main/no/finn/unleash/Unleash.java index 34185b5c81..384220552b 100644 --- a/unleash-client-java/src/main/no/finn/unleash/Unleash.java +++ b/unleash-client-java/src/main/no/finn/unleash/Unleash.java @@ -1,4 +1,66 @@ package no.finn.unleash; +import java.util.HashMap; +import java.util.Map; +import no.finn.unleash.repository.ToggleException; +import no.finn.unleash.repository.ToggleRepository; +import no.finn.unleash.strategy.DefaultStrategy; +import no.finn.unleash.strategy.Strategy; +import no.finn.unleash.strategy.UnknownStrategy; + public final class Unleash { + private final ToggleRepository toggleRepository; + private final Map strategyMap; + private final UnknownStrategy unknownStrategy = new UnknownStrategy(); + private final DefaultStrategy defaultStrategy = new DefaultStrategy(); + + + public Unleash(ToggleRepository toggleRepository, Strategy... strategies) { + this.toggleRepository = toggleRepository; + this.strategyMap = buildStrategyMap(strategies); + } + + public boolean isEnabled(final String toggleName) { + return isEnabled(toggleName, false); + } + + public boolean isEnabled(final String toggleName, final boolean defaultSetting) { + try { + Toggle toggle = toggleRepository.getToggle(toggleName); + + if(toggle == null) { + return defaultSetting; + } + + Strategy strategy = getStrategy(toggle.getStrategy()); + return toggle.isEnabled() && strategy.isEnabled(toggle.getParameters()); + + } catch (ToggleException rx) { + return defaultSetting; + } + } + + private Map buildStrategyMap(Strategy[] strategies) { + Map map = new HashMap<>(); + + map.put(defaultStrategy.getName(), defaultStrategy); + + if(strategies != null) { + for(Strategy strategy : strategies) { + map.put(strategy.getName(), strategy); + } + } + + return map; + } + + + + private Strategy getStrategy(String strategy) { + if(strategyMap.containsKey(strategy)) { + return strategyMap.get(strategy); + } else { + return unknownStrategy; + } + } } diff --git a/unleash-client-java/src/main/no/finn/unleash/repository/HTTPToggleRepository.java b/unleash-client-java/src/main/no/finn/unleash/repository/HTTPToggleRepository.java new file mode 100644 index 0000000000..65a579cff3 --- /dev/null +++ b/unleash-client-java/src/main/no/finn/unleash/repository/HTTPToggleRepository.java @@ -0,0 +1,57 @@ +package no.finn.unleash.repository; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import no.finn.unleash.Toggle; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; + +public class HTTPToggleRepository implements ToggleRepository { + private static final Log LOG = LogFactory.getLog(HTTPToggleRepository.class); + + private final HttpClient httpClient; + private final String serverEndpoint; + + public HTTPToggleRepository(final String serverEndpoint) { + this.serverEndpoint = serverEndpoint; + this.httpClient = HttpClients.createDefault(); + } + + @Override + public Toggle getToggle(final String name) throws ToggleException { + try { + for (Toggle toggle : fetchToggles()) { + if (name.equals(toggle.getName())) { + return toggle; + } + } + } catch (IOException e) { + LOG.warn("Could not fetch toggles via HTTP", e); + throw new ToggleException("Could not fetch toggles via HTTP"); + } + + throw new ToggleException("unknown toggle: " + name); + } + + @Override + public Collection getToggles() { + try { + return fetchToggles(); + } catch (IOException e) { + LOG.warn("Could not fetch toggles via HTTP"); + throw new ToggleException("Could not fetch toggles via HTTP"); + } + } + + private List fetchToggles() throws IOException { + HttpResponse httpResponse = httpClient.execute(new HttpGet(serverEndpoint)); + final String jsonString = EntityUtils.toString(httpResponse.getEntity()); + return JsonParser.toListOfToggles(jsonString); + } +} diff --git a/unleash-client-java/src/main/no/finn/unleash/repository/JsonParser.java b/unleash-client-java/src/main/no/finn/unleash/repository/JsonParser.java new file mode 100644 index 0000000000..f8390ccab0 --- /dev/null +++ b/unleash-client-java/src/main/no/finn/unleash/repository/JsonParser.java @@ -0,0 +1,27 @@ +package no.finn.unleash.repository; + +import java.util.Collection; +import java.util.List; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import no.finn.unleash.Toggle; + +public final class JsonParser { + + public static Toggle toToggle(String jsonString) { + Gson gson = new GsonBuilder().create(); + return gson.fromJson(jsonString, Toggle.class); + } + + public static String toJsonString(Collection toggles) { + Gson gson = new GsonBuilder().create(); + return gson.toJson(toggles); + } + + + public static List toListOfToggles(String jsonString) { + Gson gson = new GsonBuilder().create(); + return gson.fromJson(jsonString, new TypeToken>(){}.getType()); + } +} diff --git a/unleash-client-java/src/main/no/finn/unleash/repository/PollingToggleRepository.java b/unleash-client-java/src/main/no/finn/unleash/repository/PollingToggleRepository.java new file mode 100644 index 0000000000..d762d14196 --- /dev/null +++ b/unleash-client-java/src/main/no/finn/unleash/repository/PollingToggleRepository.java @@ -0,0 +1,136 @@ +package no.finn.unleash.repository; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import no.finn.unleash.Toggle; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +public class PollingToggleRepository implements ToggleRepository { + private static final Log LOG = LogFactory.getLog(PollingToggleRepository.class); + private static final ScheduledThreadPoolExecutor TIMER = new ScheduledThreadPoolExecutor( + 1, + new ThreadFactory() { + @Override + public Thread newThread(final Runnable r) { + Thread thread = Executors.defaultThreadFactory().newThread(r); + thread.setName("unleash-toggle-repository"); + thread.setDaemon(true); + return thread; + } + }); + + static { + TIMER.setRemoveOnCancelPolicy(true); + } + + private final ToggleRepository toggleRepository; + private final int pollIntervalSeconds; + private Map togglesCache; + + + public PollingToggleRepository(final ToggleRepository toggleRepository, final int pollIntervalSeconds){ + this.toggleRepository = toggleRepository; + this.pollIntervalSeconds = pollIntervalSeconds; + + this.togglesCache = new HashMap<>(); + updateTogglesCache(); + startBackgroundPolling(); + } + + @Override + public Toggle getToggle(final String name){ + return togglesCache.get(name); + } + + @Override + public Collection getToggles() { + return Collections.unmodifiableCollection(togglesCache.values()); + } + + private void updateTogglesCache() { + try { + Map freshToggleMap = new HashMap<>(); + + for(Toggle toggle : fetchToggles()) { + freshToggleMap.put(toggle.getName(), toggle); + } + + this.togglesCache = Collections.unmodifiableMap(freshToggleMap); + + } catch (ToggleException e) { + //Do nothing + } + } + + private ScheduledFuture startBackgroundPolling() { + try { + return TIMER.scheduleAtFixedRate(new Runnable() { + @Override + public void run() { + updateTogglesCache(); + } + }, pollIntervalSeconds, pollIntervalSeconds, TimeUnit.SECONDS); + } catch (RejectedExecutionException ex) { + LOG.error("Unleash background task crashed"); + return null; + } + } + + + private Collection fetchToggles() throws ToggleException { + try { + Collection toggles = toggleRepository.getToggles(); + storeRepoAsTempFile(JsonParser.toJsonString(toggles)); + return toggles; + } catch (ToggleException ex) { + if(togglesCache.isEmpty()) { + return loadFromTempFile(); + } + throw ex; + } + } + + + private List loadFromTempFile() throws ToggleException { + LOG.info("Unleash will try to load feature toggle states from temporary backup"); + try(FileReader reader = new FileReader(pathToTmpBackupFile())) { + BufferedReader br = new BufferedReader(reader); + StringBuilder builder = new StringBuilder(); + String line; + while((line = br.readLine()) != null) { + builder.append(line); + } + return JsonParser.toListOfToggles(builder.toString()); + } catch (IOException e) { + LOG.error("Unleash was unable to feature toggle repo from temporary backup: " + pathToTmpBackupFile()); + throw new ToggleException("Unleash was unable to feature toggle states from temporary backup"); + } + } + + private void storeRepoAsTempFile(final String serverResponse) { + try(FileWriter writer = new FileWriter(pathToTmpBackupFile())) { + writer.write(serverResponse); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private static String pathToTmpBackupFile() { + return System.getProperty("java.io.tmpdir") + File.separatorChar + "unleash-repo.json"; + } +} diff --git a/unleash-client-java/src/main/no/finn/unleash/repository/ToggleException.java b/unleash-client-java/src/main/no/finn/unleash/repository/ToggleException.java new file mode 100644 index 0000000000..531fcbcca6 --- /dev/null +++ b/unleash-client-java/src/main/no/finn/unleash/repository/ToggleException.java @@ -0,0 +1,7 @@ +package no.finn.unleash.repository; + +public class ToggleException extends RuntimeException { + public ToggleException(String message) { + super(message); + } +} diff --git a/unleash-client-java/src/main/no/finn/unleash/repository/ToggleRepository.java b/unleash-client-java/src/main/no/finn/unleash/repository/ToggleRepository.java new file mode 100644 index 0000000000..4a49ef2c01 --- /dev/null +++ b/unleash-client-java/src/main/no/finn/unleash/repository/ToggleRepository.java @@ -0,0 +1,10 @@ +package no.finn.unleash.repository; + +import java.util.Collection; +import no.finn.unleash.Toggle; + +public interface ToggleRepository { + Toggle getToggle(String name) throws ToggleException; + + Collection getToggles() throws ToggleException; +} diff --git a/unleash-client-java/src/main/no/finn/unleash/strategy/DefaultStrategy.java b/unleash-client-java/src/main/no/finn/unleash/strategy/DefaultStrategy.java new file mode 100644 index 0000000000..f0db305f5b --- /dev/null +++ b/unleash-client-java/src/main/no/finn/unleash/strategy/DefaultStrategy.java @@ -0,0 +1,17 @@ +package no.finn.unleash.strategy; + +import java.util.Map; + +public final class DefaultStrategy implements Strategy { + public static final String NAME = "default"; + + @Override + public String getName() { + return NAME; + } + + @Override + public boolean isEnabled(Map parameters) { + return true; + } +} diff --git a/unleash-client-java/src/main/no/finn/unleash/strategy/Strategy.java b/unleash-client-java/src/main/no/finn/unleash/strategy/Strategy.java new file mode 100644 index 0000000000..81bc2136f8 --- /dev/null +++ b/unleash-client-java/src/main/no/finn/unleash/strategy/Strategy.java @@ -0,0 +1,9 @@ +package no.finn.unleash.strategy; + +import java.util.Map; + +public interface Strategy { + String getName(); + + boolean isEnabled(Map parameters); +} diff --git a/unleash-client-java/src/main/no/finn/unleash/strategy/UnknownStrategy.java b/unleash-client-java/src/main/no/finn/unleash/strategy/UnknownStrategy.java new file mode 100644 index 0000000000..4fb166a470 --- /dev/null +++ b/unleash-client-java/src/main/no/finn/unleash/strategy/UnknownStrategy.java @@ -0,0 +1,17 @@ +package no.finn.unleash.strategy; + +import java.util.Map; + +public final class UnknownStrategy implements Strategy { + public static final String NAME = "unknown"; + + @Override + public String getName() { + return NAME; + } + + @Override + public boolean isEnabled(Map parameters) { + return false; + } +} diff --git a/unleash-client-java/src/test/no/finn/unleash/UnleashTest.java b/unleash-client-java/src/test/no/finn/unleash/UnleashTest.java index 7cfc113a87..a38256681e 100644 --- a/unleash-client-java/src/test/no/finn/unleash/UnleashTest.java +++ b/unleash-client-java/src/test/no/finn/unleash/UnleashTest.java @@ -1,13 +1,67 @@ package no.finn.unleash; +import no.finn.unleash.repository.ToggleException; +import no.finn.unleash.repository.ToggleRepository; +import no.finn.unleash.strategy.Strategy; +import org.junit.Before; import org.junit.Test; -import static org.junit.Assert.assertTrue; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.*; public class UnleashTest { + private ToggleRepository toggleRepository; + private Unleash unleash; + + @Before + public void setup() { + toggleRepository = mock(ToggleRepository.class); + unleash = new Unleash(toggleRepository); + } + @Test - public void helloWorld() { - assertTrue(true); + public void known_toogle_and_strategy_should_be_active() { + when(toggleRepository.getToggle("test")).thenReturn(new Toggle("test", true, "default", null)); + + assertThat(unleash.isEnabled("test"), is(true)); + } + + @Test + public void unknown_strategy_should_be_considered_inactive() { + when(toggleRepository.getToggle("test")).thenReturn(new Toggle("test", true, "whoot_strat", null)); + + assertThat(unleash.isEnabled("test"), is(false)); + } + + @Test + public void failing_repository_should_obey_default() { + when(toggleRepository.getToggle("test")).thenThrow(new ToggleException("service down")); + + assertThat(unleash.isEnabled("test", true), is(true)); + } + + @Test + public void unknown_feature_should_be_considered_inactive() { + when(toggleRepository.getToggle("test")).thenReturn(null); + + assertThat(unleash.isEnabled("test"), is(false)); + } + + @Test + public void should_register_custom_strategies() { + //custom strategy + Strategy customStrategy = mock(Strategy.class); + when(customStrategy.getName()).thenReturn("custom"); + + //register custom strategy + unleash = new Unleash(toggleRepository, customStrategy); + when(toggleRepository.getToggle("test")).thenReturn(new Toggle("test", true, "custom", null)); + + unleash.isEnabled("test"); + + verify(customStrategy, times(1)).isEnabled(anyMap()); } }