diff --git a/spring-boot/feature-flags/pom.xml b/spring-boot/feature-flags/pom.xml index 7c1bf86..3e864ca 100644 --- a/spring-boot/feature-flags/pom.xml +++ b/spring-boot/feature-flags/pom.xml @@ -21,10 +21,18 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-jdbc + org.springframework.boot spring-boot-starter-thymeleaf + + com.h2database + h2 + diff --git a/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/FeatureFlagService.java b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/FeatureFlagService.java new file mode 100644 index 0000000..f6e5ae1 --- /dev/null +++ b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/FeatureFlagService.java @@ -0,0 +1,9 @@ +package io.reflectoring.featureflags.implementations; + +public interface FeatureFlagService { + + Boolean featureOne(); + + Integer featureTwo(); + +} diff --git a/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/code/CodeBackedFeatureFlagService.java b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/code/CodeBackedFeatureFlagService.java new file mode 100644 index 0000000..aaeef0a --- /dev/null +++ b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/code/CodeBackedFeatureFlagService.java @@ -0,0 +1,15 @@ +package io.reflectoring.featureflags.implementations.code; + +import io.reflectoring.featureflags.implementations.FeatureFlagService; + +public class CodeBackedFeatureFlagService implements FeatureFlagService { + @Override + public Boolean featureOne() { + return true; + } + + @Override + public Integer featureTwo() { + return 42; + } +} diff --git a/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/contextsensitive/ContextSensitiveFeatureFlagService.java b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/contextsensitive/ContextSensitiveFeatureFlagService.java new file mode 100644 index 0000000..0f224fe --- /dev/null +++ b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/contextsensitive/ContextSensitiveFeatureFlagService.java @@ -0,0 +1,52 @@ +package io.reflectoring.featureflags.implementations.contextsensitive; + +import io.reflectoring.featureflags.implementations.FeatureFlagService; +import io.reflectoring.featureflags.implementations.contextsensitive.Feature.RolloutStrategy; +import io.reflectoring.featureflags.web.UserSession; +import org.jetbrains.annotations.Nullable; +import org.springframework.jdbc.core.JdbcTemplate; + +public class ContextSensitiveFeatureFlagService implements FeatureFlagService { + + private final JdbcTemplate jdbcTemplate; + private final UserSession userSession; + + public ContextSensitiveFeatureFlagService(JdbcTemplate jdbcTemplate, UserSession userSession) { + this.jdbcTemplate = jdbcTemplate; + this.userSession = userSession; + } + + @Override + public Boolean featureOne() { + Feature feature = getFeatureFromDatabase(); + if (feature == null) { + return Boolean.FALSE; + } + return feature.evaluateBoolean(userSession.getUsername()); + } + + @Override + public Integer featureTwo() { + Feature feature = getFeatureFromDatabase(); + if (feature == null) { + return null; + } + return feature.evaluateInt(userSession.getUsername()); + } + + @Nullable + private Feature getFeatureFromDatabase() { + return jdbcTemplate.query("select targeting, value, defaultValue, percentage from features where feature_key='FEATURE_ONE'", resultSet -> { + if (!resultSet.next()) { + return null; + } + + RolloutStrategy rolloutStrategy = Enum.valueOf(RolloutStrategy.class, resultSet.getString(1)); + String value = resultSet.getString(2); + String defaultValue = resultSet.getString(3); + int percentage = resultSet.getInt(4); + + return new Feature(rolloutStrategy, value, defaultValue, percentage); + }); + } +} diff --git a/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/contextsensitive/Feature.java b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/contextsensitive/Feature.java new file mode 100644 index 0000000..b1d74e7 --- /dev/null +++ b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/contextsensitive/Feature.java @@ -0,0 +1,102 @@ +package io.reflectoring.featureflags.implementations.contextsensitive; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +public class Feature { + + public enum RolloutStrategy { + GLOBAL, + PERCENTAGE; + } + + private final RolloutStrategy rolloutStrategy; + + private final int percentage; + private final String value; + private final String defaultValue; + + public Feature(RolloutStrategy rolloutStrategy, String value, String defaultValue, int percentage) { + this.rolloutStrategy = rolloutStrategy; + this.percentage = percentage; + this.value = value; + this.defaultValue = defaultValue; + } + + public boolean evaluateBoolean(String userId) { + switch (this.rolloutStrategy) { + case GLOBAL: + return this.getBooleanValue(); + case PERCENTAGE: + if (percentageHashCode(userId) <= this.percentage) { + return this.getBooleanValue(); + } else { + return this.getBooleanDefaultValue(); + } + } + + return this.getBooleanDefaultValue(); + } + + public Integer evaluateInt(String userId) { + switch (this.rolloutStrategy) { + case GLOBAL: + return this.getIntValue(); + case PERCENTAGE: + if (percentageHashCode(userId) <= this.percentage) { + return this.getIntValue(); + } else { + return this.getIntDefaultValue(); + } + } + + return this.getIntDefaultValue(); + } + + double percentageHashCode(String text) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] encodedhash = digest.digest( + text.getBytes(StandardCharsets.UTF_8)); + double INTEGER_RANGE = 1L << 32; + return (((long) Arrays.hashCode(encodedhash) - Integer.MIN_VALUE) / INTEGER_RANGE) * 100; + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + public RolloutStrategy getTargeting() { + return rolloutStrategy; + } + + public int getPercentage() { + return percentage; + } + + public int getIntValue() { + return Integer.parseInt(this.value); + } + + public int getIntDefaultValue() { + return Integer.parseInt(this.defaultValue); + } + + + public boolean getBooleanValue() { + return Boolean.parseBoolean(this.value); + } + + public boolean getBooleanDefaultValue() { + return Boolean.parseBoolean(this.defaultValue); + } + + public String getStringValue() { + return value; + } + + public String getDefaultValue() { + return defaultValue; + } +} diff --git a/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/database/DatabaseBackedFeatureFlagService.java b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/database/DatabaseBackedFeatureFlagService.java new file mode 100644 index 0000000..2b4cb8b --- /dev/null +++ b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/database/DatabaseBackedFeatureFlagService.java @@ -0,0 +1,32 @@ +package io.reflectoring.featureflags.implementations.database; + +import io.reflectoring.featureflags.implementations.FeatureFlagService; +import org.springframework.jdbc.core.JdbcTemplate; + +public class DatabaseBackedFeatureFlagService implements FeatureFlagService { + + private JdbcTemplate jdbcTemplate; + + @Override + public Boolean featureOne() { + return jdbcTemplate.query("select value from features where feature_key='FEATURE_ONE'", resultSet -> { + if(!resultSet.next()){ + return false; + } + + boolean value = Boolean.parseBoolean(resultSet.getString(1)); + return value ? Boolean.TRUE : Boolean.FALSE; + }); + } + + @Override + public Integer featureTwo() { + return jdbcTemplate.query("select value from features where feature_key='FEATURE_TWO'", resultSet -> { + if(!resultSet.next()){ + return null; + } + + return Integer.valueOf(resultSet.getString(1)); + }); + } +} diff --git a/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/launchdarkly/LaunchDarklyFeatureFlagService.java b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/launchdarkly/LaunchDarklyFeatureFlagService.java new file mode 100644 index 0000000..f99bfce --- /dev/null +++ b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/launchdarkly/LaunchDarklyFeatureFlagService.java @@ -0,0 +1,32 @@ +package io.reflectoring.featureflags.implementations.launchdarkly; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.server.LDClient; +import io.reflectoring.featureflags.implementations.FeatureFlagService; +import io.reflectoring.featureflags.web.UserSession; + +public class LaunchDarklyFeatureFlagService implements FeatureFlagService { + + private final LDClient launchdarklyClient; + private final UserSession userSession; + + public LaunchDarklyFeatureFlagService(LDClient launchdarklyClient, UserSession userSession) { + this.launchdarklyClient = launchdarklyClient; + this.userSession = userSession; + } + + @Override + public Boolean featureOne() { + return launchdarklyClient.boolVariation("feature-one", getLaunchdarklyUserFromSession(), false); + } + + @Override + public Integer featureTwo() { + return launchdarklyClient.intVariation("feature-two", getLaunchdarklyUserFromSession(), 0); + } + + private LDUser getLaunchdarklyUserFromSession() { + return new LDUser.Builder(userSession.getUsername()) + .build(); + } +} diff --git a/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/properties/FeatureProperties.java b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/properties/FeatureProperties.java new file mode 100644 index 0000000..7df00a4 --- /dev/null +++ b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/properties/FeatureProperties.java @@ -0,0 +1,31 @@ +package io.reflectoring.featureflags.implementations.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties("features") +public class FeatureProperties { + + private boolean featureOne; + private int featureTwo; + + public FeatureProperties() { + } + + public boolean getFeatureOne() { + return featureOne; + } + + public void setFeatureOne(boolean featureOne) { + this.featureOne = featureOne; + } + + public int getFeatureTwo() { + return featureTwo; + } + + public void setFeatureTwo(int featureTwo) { + this.featureTwo = featureTwo; + } +} diff --git a/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/properties/PropertiesBackedFeatureFlagService.java b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/properties/PropertiesBackedFeatureFlagService.java new file mode 100644 index 0000000..2d596b4 --- /dev/null +++ b/spring-boot/feature-flags/src/main/java/io/reflectoring/featureflags/implementations/properties/PropertiesBackedFeatureFlagService.java @@ -0,0 +1,25 @@ +package io.reflectoring.featureflags.implementations.properties; + + +import io.reflectoring.featureflags.implementations.FeatureFlagService; +import org.springframework.stereotype.Component; + +@Component +public class PropertiesBackedFeatureFlagService implements FeatureFlagService { + + private final FeatureProperties featureProperties; + + public PropertiesBackedFeatureFlagService(FeatureProperties featureProperties) { + this.featureProperties = featureProperties; + } + + @Override + public Boolean featureOne() { + return featureProperties.getFeatureOne(); + } + + @Override + public Integer featureTwo() { + return featureProperties.getFeatureTwo(); + } +} diff --git a/spring-boot/feature-flags/src/main/resources/application.yml b/spring-boot/feature-flags/src/main/resources/application.yml index 091bb99..f2b0c7a 100644 --- a/spring-boot/feature-flags/src/main/resources/application.yml +++ b/spring-boot/feature-flags/src/main/resources/application.yml @@ -15,4 +15,8 @@ togglz: enabled: true secured: false path: /togglz - use-management-port: false \ No newline at end of file + use-management-port: false + +features: + featureOne: true + featureTwo: 42 \ No newline at end of file diff --git a/spring-boot/feature-flags/src/test/java/io/reflectoring/featureflags/implementations/contextsensitive/FeatureTest.java b/spring-boot/feature-flags/src/test/java/io/reflectoring/featureflags/implementations/contextsensitive/FeatureTest.java new file mode 100644 index 0000000..c86b0f6 --- /dev/null +++ b/spring-boot/feature-flags/src/test/java/io/reflectoring/featureflags/implementations/contextsensitive/FeatureTest.java @@ -0,0 +1,19 @@ +package io.reflectoring.featureflags.implementations.contextsensitive; + +import io.reflectoring.featureflags.implementations.contextsensitive.Feature.RolloutStrategy; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.offset; + +public class FeatureTest { + + @Test + void testHashCode(){ + Feature feature = new Feature(RolloutStrategy.PERCENTAGE, "true", "false", 50); + assertThat(feature.percentageHashCode("1")).isCloseTo(27.74d, offset(0.01d)); + assertThat(feature.percentageHashCode("2")).isCloseTo(81.12d, offset(0.01d)); + assertThat(feature.percentageHashCode("3")).isCloseTo(21.69d, offset(0.01d)); + } + +}