From f5a34d0cbfe3f95a24fd533caa81efd8b8d20cda Mon Sep 17 00:00:00 2001 From: Stephen Duncan Date: Thu, 11 Feb 2021 14:26:29 -0800 Subject: [PATCH] Moirai 3.x Redesign * Move config-reading abstract methods from each decider into a ConfigReader Interface * Add featureGroup support to control what features use the same hashing for proportionOfUsers decider * Move the provided config decider predicates to a single class as static factory methods that depend on a ConfigReader instead of separate abstract classes that must be implemented in a specific config module --- README.md | 10 +- build.sbt | 3 +- .../com/nike/moirai/FeatureCheckInput.java | 2 +- .../moirai/config/ConfigDeciderSupport.java | 37 +++++ .../nike/moirai/config/ConfigDeciders.java | 58 +++++--- .../com/nike/moirai/config/ConfigReader.java | 48 +++++++ .../EnabledCustomDimensionConfigDecider.java | 3 +- .../config/EnabledUsersConfigDecider.java | 34 ----- .../config/FeatureEnabledConfigDecider.java | 26 ---- .../ProportionOfUsersConfigDecider.java | 39 ------ .../ConfigDeciderSupportSpec.scala} | 18 +-- .../config/ConfigDecisionInputSpec.scala | 0 ...bledCustomDimensionConfigDeciderSpec.scala | 0 .../EnabledUsersConfigDeciderSpec.scala | 13 +- .../ProportionOfUsersConfigDeciderSpec.scala | 132 ++++++++++++++++++ .../ProportionOfUsersConfigDeciderSpec.scala | 72 ---------- .../com/nike/moirairiposteexample/Main.java | 15 +- .../endpoints/GetShoeListEndpointTest.java | 12 +- .../TypesafeConfigCustomDeciders.java | 59 ++++++++ .../typesafeconfig/TypesafeConfigDecider.java | 114 --------------- .../typesafeconfig/TypesafeConfigLoader.java | 17 +++ .../typesafeconfig/TypesafeConfigReader.java | 84 ++++++++++- .../test/resources/moirai-feature-group.conf | 25 ++++ .../TypesafeConfigUserDecidersSpec.scala | 59 +++++++- 24 files changed, 529 insertions(+), 351 deletions(-) create mode 100644 moirai-core/src/main/java/com/nike/moirai/config/ConfigDeciderSupport.java create mode 100644 moirai-core/src/main/java/com/nike/moirai/config/ConfigReader.java delete mode 100644 moirai-core/src/main/java/com/nike/moirai/config/EnabledUsersConfigDecider.java delete mode 100644 moirai-core/src/main/java/com/nike/moirai/config/FeatureEnabledConfigDecider.java delete mode 100644 moirai-core/src/main/java/com/nike/moirai/config/ProportionOfUsersConfigDecider.java rename moirai-core/src/test/scala/com/nike/moirai/{server/config/ConfigDecidersSpec.scala => config/ConfigDeciderSupportSpec.scala} (63%) rename moirai-core/src/test/scala/com/nike/moirai/{server => }/config/ConfigDecisionInputSpec.scala (100%) rename moirai-core/src/test/scala/com/nike/moirai/{server => }/config/EnabledCustomDimensionConfigDeciderSpec.scala (100%) rename moirai-core/src/test/scala/com/nike/moirai/{server => }/config/EnabledUsersConfigDeciderSpec.scala (74%) create mode 100644 moirai-core/src/test/scala/com/nike/moirai/config/ProportionOfUsersConfigDeciderSpec.scala delete mode 100644 moirai-core/src/test/scala/com/nike/moirai/server/config/ProportionOfUsersConfigDeciderSpec.scala create mode 100644 moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigCustomDeciders.java delete mode 100644 moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigDecider.java create mode 100644 moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigLoader.java create mode 100644 moirai-typesafeconfig/src/test/resources/moirai-feature-group.conf diff --git a/README.md b/README.md index 1c25514..de1db2c 100644 --- a/README.md +++ b/README.md @@ -24,20 +24,20 @@ import com.nike.moirai.ConfigFeatureFlagChecker; import com.nike.moirai.FeatureCheckInput; import com.nike.moirai.FeatureFlagChecker; import com.nike.moirai.Suppliers; +import com.nike.moirai.config.ConfigDeciders; import com.nike.moirai.resource.FileResourceLoaders; import com.nike.moirai.resource.reload.ResourceReloader; -import com.nike.moirai.typesafeconfig.TypesafeConfigDecider; -import com.nike.moirai.typesafeconfig.TypesafeConfigReader; +import com.nike.moirai.typesafeconfig.TypesafeConfigLoader; import com.typesafe.config.Config; import java.io.File; import java.util.function.Supplier; -import static com.nike.moirai.Suppliers.supplierAndThen; +import static com.nike.moirai.typesafeconfig.TypesafeConfigReader.TYPESAFE_CONFIG_READER; public class Usage { Supplier fileSupplier = FileResourceLoaders.forFile(new File("/path/to/conf/file/moirai.conf")); - Supplier configSupplier = supplierAndThen(fileSupplier, TypesafeConfigReader.FROM_STRING); + Supplier configSupplier = Suppliers.supplierAndThen(fileSupplier, TypesafeConfigReader.FROM_STRING); ResourceReloader resourceReloader = ResourceReloader.withDefaultSettings( Suppliers.async(configSupplier), @@ -46,7 +46,7 @@ public class Usage { FeatureFlagChecker featureFlagChecker = ConfigFeatureFlagChecker.forReloadableResource( resourceReloader, - TypesafeConfigDecider.ENABLED_USERS.or(TypesafeConfigDecider.PROPORTION_OF_USERS) + ConfigDeciders.enabledUsers(TYPESAFE_CONFIG_READER).or(ConfigDeciders.proportionOfUsers(TYPESAFE_CONFIG_READER)) ); public int getNumber(String userIdentity) { diff --git a/build.sbt b/build.sbt index a38cebd..b8c2e08 100644 --- a/build.sbt +++ b/build.sbt @@ -1,7 +1,7 @@ import sbt.Keys.javacOptions import Dependencies._ -lazy val commonSettings = Seq( +val commonSettings = Seq( organization := "com.nike.moirai", organizationName := "Nike", organizationHomepage := Some(url("http://engineering.nike.com")), @@ -85,6 +85,7 @@ lazy val `moirai-typesafeconfig` = project libraryDependencies ++= Seq( typesafeConfig, scalaTest, + scalaCheck, logback ) ) diff --git a/moirai-core/src/main/java/com/nike/moirai/FeatureCheckInput.java b/moirai-core/src/main/java/com/nike/moirai/FeatureCheckInput.java index a7f8f45..0bbdb20 100644 --- a/moirai-core/src/main/java/com/nike/moirai/FeatureCheckInput.java +++ b/moirai-core/src/main/java/com/nike/moirai/FeatureCheckInput.java @@ -17,7 +17,7 @@ public class FeatureCheckInput { public enum DimensionKey { USER_ID, DATE_TIME; - private static Set KEYS = Arrays.stream(DimensionKey.values()).map(DimensionKey::name).collect(Collectors.toSet()); + private static final Set KEYS = Arrays.stream(DimensionKey.values()).map(DimensionKey::name).collect(Collectors.toSet()); } private final Map dimensions; diff --git a/moirai-core/src/main/java/com/nike/moirai/config/ConfigDeciderSupport.java b/moirai-core/src/main/java/com/nike/moirai/config/ConfigDeciderSupport.java new file mode 100644 index 0000000..f2e2411 --- /dev/null +++ b/moirai-core/src/main/java/com/nike/moirai/config/ConfigDeciderSupport.java @@ -0,0 +1,37 @@ +package com.nike.moirai.config; + +import com.nike.moirai.FeatureCheckInput; + +import java.util.function.Predicate; + +/** + * Support functions for creating a {@link java.util.function.Predicate} for {@link ConfigDecisionInput}. + */ +public class ConfigDeciderSupport { + /** + * Apply a predicate to the userId from a feature check input. + * + * @param featureCheckInput the input to check + * @param userIdCheck the predicate to apply + * @return false if there is no userId in the input, otherwise the result of the predicate applied to the userId + */ + public static boolean userIdCheck(FeatureCheckInput featureCheckInput, Predicate userIdCheck) { + return featureCheckInput.getUserId().map(userIdCheck::test).orElse(false); + } + + /** + * Apply a predicate to the dimension value from a feature check input. + * + * @param featureCheckInput the input to check + * @param dimensionKey the dimension to check + * @param dimensionCheck the predicate to apply + * @return false if there is no userId in the input, otherwise the result of the predicate applied to the userId + */ + public static boolean customDimensionCheck(FeatureCheckInput featureCheckInput, String dimensionKey, Predicate dimensionCheck) { + return featureCheckInput.getDimension(dimensionKey).map(dimensionCheck::test).orElse(false); + } + + private ConfigDeciderSupport() { + // Prevent instantiation + } +} diff --git a/moirai-core/src/main/java/com/nike/moirai/config/ConfigDeciders.java b/moirai-core/src/main/java/com/nike/moirai/config/ConfigDeciders.java index 5d99411..0bbf74d 100644 --- a/moirai-core/src/main/java/com/nike/moirai/config/ConfigDeciders.java +++ b/moirai-core/src/main/java/com/nike/moirai/config/ConfigDeciders.java @@ -2,33 +2,51 @@ import com.nike.moirai.FeatureCheckInput; +import java.util.Collection; +import java.util.Optional; import java.util.function.Predicate; +import static com.nike.moirai.config.ConfigDeciderSupport.userIdCheck; + /** - * Useful methods for implementing a Predicate<ConfigDecisionInput<T>> + * {@link Predicate} factories given a {@link ConfigReader} */ public class ConfigDeciders { - /** - * Apply a predicate to the userId from a feature check input. - * - * @param featureCheckInput the input to check - * @param userIdCheck the predicate to apply - * @return false if there is no userId in the input, otherwise the result of the predicate applied to the userId - */ - public static boolean userIdCheck(FeatureCheckInput featureCheckInput, Predicate userIdCheck) { - return featureCheckInput.getUserId().map(userIdCheck::test).orElse(false); + public static Predicate> featureEnabled(ConfigReader configReader) { + return configDecisionInput -> + configReader.featureEnabled(configDecisionInput.getConfig(), configDecisionInput.getFeatureIdentifier()).orElse(false); + } + + public static Predicate> enabledUsers(ConfigReader configReader) { + return new EnabledValuesConfigDecider() { + @Override + protected Collection enabledValues(C config, String featureIdentifier) { + return configReader.enabledUsers(config, featureIdentifier); + } + + @Override + protected boolean checkValue(FeatureCheckInput featureCheckInput, Predicate check) { + return userIdCheck(featureCheckInput, check); + } + }; + } + + public static Predicate> proportionOfUsers(ConfigReader configReader) { + return configDecisionInput -> + userIdCheck(configDecisionInput.getFeatureCheckInput(), userId -> + configReader.enabledProportion(configDecisionInput.getConfig(), configDecisionInput.getFeatureIdentifier()).map(enabledProportion -> + userHashEnabled( + userId, + configDecisionInput.getFeatureIdentifier(), + configReader.featureGroup(configDecisionInput.getConfig(), configDecisionInput.getFeatureIdentifier()), + enabledProportion) + ).orElse(false) + ); } - /** - * Apply a predicate to the dimension value from a feature check input. - * - * @param featureCheckInput the input to check - * @param dimensionKey the dimension to check - * @param dimensionCheck the predicate to apply - * @return false if there is no userId in the input, otherwise the result of the predicate applied to the userId - */ - public static boolean customDimensionCheck(FeatureCheckInput featureCheckInput, String dimensionKey, Predicate dimensionCheck) { - return featureCheckInput.getDimension(dimensionKey).map(dimensionCheck::test).orElse(false); + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + private static boolean userHashEnabled(String userId, String featureIdentifier, Optional featureGroup, double proportion) { + return (Math.abs((userId + featureGroup.orElse(featureIdentifier)).hashCode()) % 100) / 100.0 < proportion; } private ConfigDeciders() { diff --git a/moirai-core/src/main/java/com/nike/moirai/config/ConfigReader.java b/moirai-core/src/main/java/com/nike/moirai/config/ConfigReader.java new file mode 100644 index 0000000..7232098 --- /dev/null +++ b/moirai-core/src/main/java/com/nike/moirai/config/ConfigReader.java @@ -0,0 +1,48 @@ +package com.nike.moirai.config; + +import java.util.Collection; +import java.util.Optional; + +public interface ConfigReader { + /** + * Provide the collection of users that should have the given feature enabled. Return an empty list if no configuration is provided for the feature. + * + * @param config the config source + * @param featureIdentifier the feature + * @return the collection of userIds that should be enabled for the feature + */ + Collection enabledUsers(C config, String featureIdentifier); + + /** + * Provide the boolean value on whether the feature should be enabled. + * Returning Optional.empty() is equivalent to false. + * + * @param config the config source + * @param featureIdentifier the feature + * @return true, false, or {@link Optional#empty()} + */ + Optional featureEnabled(C config, String featureIdentifier); + + /** + * Provide the proportion of users that should be enabled for the given feature. The proportion should be a double from 0.0 to 1.0. + * 0.0 means no users will have the feature enabled, and 1.0 will mean that all users will have the feature enabled. + * Values below zero will be treated the same as 0.0 and values above 1.0 will be treated the same as 1.0. + * Returning Optional.empty() is equivalent to 0.0; both will return false for all users. + * + * @param config the config source + * @param featureIdentifier the feature + * @return some proportion between 0.0 and 1.0, or {@link Optional#empty()} + */ + Optional enabledProportion(C config, String featureIdentifier); + + /** + * Provide the featureGroup that the feature should belong to. This will affect the {@link ConfigDeciders#proportionOfUsers(ConfigReader)} so that + * if two features with the same featureGroup have the same enabled proportion, the same users will be included in that proportion. + * Returning Optional.empty() will result in the feature being grouped by itself (the featureIdentifier will be used as the featureGroup). + * + * @param config the config source + * @param featureIdentifier the feature + * @return the identifier of the feature group or {@link Optional#empty()} + */ + Optional featureGroup(C config, String featureIdentifier); +} diff --git a/moirai-core/src/main/java/com/nike/moirai/config/EnabledCustomDimensionConfigDecider.java b/moirai-core/src/main/java/com/nike/moirai/config/EnabledCustomDimensionConfigDecider.java index e8f7c47..e1a8e41 100644 --- a/moirai-core/src/main/java/com/nike/moirai/config/EnabledCustomDimensionConfigDecider.java +++ b/moirai-core/src/main/java/com/nike/moirai/config/EnabledCustomDimensionConfigDecider.java @@ -6,7 +6,7 @@ import java.util.function.Predicate; -import static com.nike.moirai.config.ConfigDeciders.customDimensionCheck; +import static com.nike.moirai.config.ConfigDeciderSupport.customDimensionCheck; /** * Returns true for a configured collection of values for some custom dimension. @@ -15,6 +15,7 @@ * @param the type of value for the dimension */ public abstract class EnabledCustomDimensionConfigDecider extends EnabledValuesConfigDecider { + private final Logger logger = LoggerFactory.getLogger(this.getClass()); @Override diff --git a/moirai-core/src/main/java/com/nike/moirai/config/EnabledUsersConfigDecider.java b/moirai-core/src/main/java/com/nike/moirai/config/EnabledUsersConfigDecider.java deleted file mode 100644 index bf853c5..0000000 --- a/moirai-core/src/main/java/com/nike/moirai/config/EnabledUsersConfigDecider.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.nike.moirai.config; - -import com.nike.moirai.FeatureCheckInput; - -import java.util.Collection; -import java.util.function.Predicate; - -import static com.nike.moirai.config.ConfigDeciders.userIdCheck; - -/** - * Returns true for a configured list of users. - * - * @param the type of config - */ -public abstract class EnabledUsersConfigDecider extends EnabledValuesConfigDecider { - @Override - protected boolean checkValue(FeatureCheckInput featureCheckInput, Predicate check) { - return userIdCheck(featureCheckInput, check); - } - - @Override - protected Collection enabledValues(T config, String featureIdentifier) { - return enabledUsers(config, featureIdentifier); - } - - /** - * Provide the collection of users that should have the given feature enabled. Return an empty list if no configuration is provided for the feature. - * - * @param config the config source - * @param featureIdentifier the feature - * @return the collection of userIds that should be enabled for the feature - */ - protected abstract Collection enabledUsers(T config, String featureIdentifier); -} diff --git a/moirai-core/src/main/java/com/nike/moirai/config/FeatureEnabledConfigDecider.java b/moirai-core/src/main/java/com/nike/moirai/config/FeatureEnabledConfigDecider.java deleted file mode 100644 index 15baf4f..0000000 --- a/moirai-core/src/main/java/com/nike/moirai/config/FeatureEnabledConfigDecider.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.nike.moirai.config; - -import java.util.Optional; -import java.util.function.Predicate; - -/** - * Returns the boolean value provided in the configuration. Returns false if no configuration is provided. - * - * @param the type of config - */ -public abstract class FeatureEnabledConfigDecider implements Predicate> { - @Override - public boolean test(ConfigDecisionInput configDecisionInput) { - return featureEnabled(configDecisionInput.getConfig(), configDecisionInput.getFeatureIdentifier()).orElse(false); - } - - /** - * Provide the boolean value on whether the feature should be enabled. - * Returning Optional.empty() is equivalent to false. - * - * @param config the config source - * @param featureIdentifier the feature - * @return true, false, or {@link Optional#empty()} - */ - protected abstract Optional featureEnabled(T config, String featureIdentifier); -} diff --git a/moirai-core/src/main/java/com/nike/moirai/config/ProportionOfUsersConfigDecider.java b/moirai-core/src/main/java/com/nike/moirai/config/ProportionOfUsersConfigDecider.java deleted file mode 100644 index 935085a..0000000 --- a/moirai-core/src/main/java/com/nike/moirai/config/ProportionOfUsersConfigDecider.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.nike.moirai.config; - -import java.util.Optional; -import java.util.function.Predicate; - -import static com.nike.moirai.config.ConfigDeciders.userIdCheck; - -/** - * Returns true for a configured proportion of users. The proportion is based on the hashCode() of the userId concatenated with the featureIdentifier, - * so a consistent answer will be returned for each userId for a feature. Returns false if no proportion configuration is provided for the feature identifier. - * - * @param the type of config - */ -public abstract class ProportionOfUsersConfigDecider implements Predicate> { - @Override - public boolean test(ConfigDecisionInput configDecisionInput) { - return userIdCheck(configDecisionInput.getFeatureCheckInput(), userId -> - enabledProportion(configDecisionInput.getConfig(), configDecisionInput.getFeatureIdentifier()).map(enabledProportion -> - userHashEnabled(userId, configDecisionInput.getFeatureIdentifier(), enabledProportion) - ).orElse(false) - ); - } - - private boolean userHashEnabled(String userId, String featureIdentifier, double proportion) { - return (Math.abs((userId + featureIdentifier).hashCode()) % 100) / 100.0 < proportion; - } - - /** - * Provide the proportion of users that should be enabled for the given feature. The proportion should be a double from 0.0 to 1.0. - * 0.0 means no users will have the feature enabled, and 1.0 will mean that all users will have the feature enabled. - * Values below zero will be treated the same as 0.0 and values above 1.0 will be treated the same as 1.0. - * Returning Optional.empty() is equivalent to 0.0; both will return false for all users. - * - * @param config the config source - * @param featureIdentifier the feature - * @return some proportion between 0.0 and 1.0, or {@link Optional#empty()} - */ - protected abstract Optional enabledProportion(T config, String featureIdentifier); -} diff --git a/moirai-core/src/test/scala/com/nike/moirai/server/config/ConfigDecidersSpec.scala b/moirai-core/src/test/scala/com/nike/moirai/config/ConfigDeciderSupportSpec.scala similarity index 63% rename from moirai-core/src/test/scala/com/nike/moirai/server/config/ConfigDecidersSpec.scala rename to moirai-core/src/test/scala/com/nike/moirai/config/ConfigDeciderSupportSpec.scala index 910504d..832fa31 100644 --- a/moirai-core/src/test/scala/com/nike/moirai/server/config/ConfigDecidersSpec.scala +++ b/moirai-core/src/test/scala/com/nike/moirai/config/ConfigDeciderSupportSpec.scala @@ -6,17 +6,17 @@ import com.nike.moirai.FeatureCheckInput import org.scalatest.{FunSpec, Matchers} //noinspection ComparingUnrelatedTypes -class ConfigDecidersSpec extends FunSpec with Matchers { +class ConfigDeciderSupportSpec extends FunSpec with Matchers { describe("userIdCheck") { describe("for FeatureCheckInput with a userId") { val featureCheckInput = FeatureCheckInput.forUser("8675309") it("should return true for a predicate that returns true for that user") { - ConfigDeciders.userIdCheck(featureCheckInput, userId => userId == "8675309") shouldBe true + ConfigDeciderSupport.userIdCheck(featureCheckInput, userId => userId == "8675309") shouldBe true } it("should return false for a predicate that returns false for that user") { - ConfigDeciders.userIdCheck(featureCheckInput, userId => userId != "8675309") shouldBe false + ConfigDeciderSupport.userIdCheck(featureCheckInput, userId => userId != "8675309") shouldBe false } } @@ -24,11 +24,11 @@ class ConfigDecidersSpec extends FunSpec with Matchers { val featureCheckInput = new FeatureCheckInput.Builder().dateTime(Instant.now()).build() it("should return false for a predicate that returns true for any user") { - ConfigDeciders.userIdCheck(featureCheckInput, _ => true) shouldBe false + ConfigDeciderSupport.userIdCheck(featureCheckInput, _ => true) shouldBe false } it("should return false for a predicate that returns false for any user") { - ConfigDeciders.userIdCheck(featureCheckInput, _ => false) shouldBe false + ConfigDeciderSupport.userIdCheck(featureCheckInput, _ => false) shouldBe false } } } @@ -40,11 +40,11 @@ class ConfigDecidersSpec extends FunSpec with Matchers { val featureCheckInput = FeatureCheckInput.forUser("8675309").withAdditionalDimension(dimensionKey, 8) it("should return true for a predicate that returns true for that dimension value") { - ConfigDeciders.customDimensionCheck(featureCheckInput, dimensionKey, numberOfThings => numberOfThings == 8) shouldBe true + ConfigDeciderSupport.customDimensionCheck(featureCheckInput, dimensionKey, numberOfThings => numberOfThings == 8) shouldBe true } it("should return false for a predicate that returns false for that value") { - ConfigDeciders.customDimensionCheck(featureCheckInput, dimensionKey, numberOfThings => numberOfThings != 8) shouldBe false + ConfigDeciderSupport.customDimensionCheck(featureCheckInput, dimensionKey, numberOfThings => numberOfThings != 8) shouldBe false } } @@ -52,11 +52,11 @@ class ConfigDecidersSpec extends FunSpec with Matchers { val featureCheckInput = FeatureCheckInput.forUser("8675309") it("should return false for a predicate that returns true for any user") { - ConfigDeciders.customDimensionCheck(featureCheckInput, dimensionKey, _ => true) shouldBe false + ConfigDeciderSupport.customDimensionCheck(featureCheckInput, dimensionKey, _ => true) shouldBe false } it("should return false for a predicate that returns false for any user") { - ConfigDeciders.customDimensionCheck(featureCheckInput, dimensionKey, _ => false) shouldBe false + ConfigDeciderSupport.customDimensionCheck(featureCheckInput, dimensionKey, _ => false) shouldBe false } } } diff --git a/moirai-core/src/test/scala/com/nike/moirai/server/config/ConfigDecisionInputSpec.scala b/moirai-core/src/test/scala/com/nike/moirai/config/ConfigDecisionInputSpec.scala similarity index 100% rename from moirai-core/src/test/scala/com/nike/moirai/server/config/ConfigDecisionInputSpec.scala rename to moirai-core/src/test/scala/com/nike/moirai/config/ConfigDecisionInputSpec.scala diff --git a/moirai-core/src/test/scala/com/nike/moirai/server/config/EnabledCustomDimensionConfigDeciderSpec.scala b/moirai-core/src/test/scala/com/nike/moirai/config/EnabledCustomDimensionConfigDeciderSpec.scala similarity index 100% rename from moirai-core/src/test/scala/com/nike/moirai/server/config/EnabledCustomDimensionConfigDeciderSpec.scala rename to moirai-core/src/test/scala/com/nike/moirai/config/EnabledCustomDimensionConfigDeciderSpec.scala diff --git a/moirai-core/src/test/scala/com/nike/moirai/server/config/EnabledUsersConfigDeciderSpec.scala b/moirai-core/src/test/scala/com/nike/moirai/config/EnabledUsersConfigDeciderSpec.scala similarity index 74% rename from moirai-core/src/test/scala/com/nike/moirai/server/config/EnabledUsersConfigDeciderSpec.scala rename to moirai-core/src/test/scala/com/nike/moirai/config/EnabledUsersConfigDeciderSpec.scala index d9cd12f..33f5455 100644 --- a/moirai-core/src/test/scala/com/nike/moirai/server/config/EnabledUsersConfigDeciderSpec.scala +++ b/moirai-core/src/test/scala/com/nike/moirai/config/EnabledUsersConfigDeciderSpec.scala @@ -1,20 +1,25 @@ package com.nike.moirai.config -import java.util - import com.nike.moirai.FeatureCheckInput import org.scalatest.prop.GeneratorDrivenPropertyChecks import org.scalatest.{FunSpec, Matchers} +import java.util.Optional import scala.collection.JavaConverters._ class EnabledUsersConfigDeciderSpec extends FunSpec with Matchers with GeneratorDrivenPropertyChecks { describe("A basic implementation of WhitelistedUsersConfigDecider") { - val decider = new EnabledUsersConfigDecider[Map[String, Seq[String]]]() { - override protected def enabledUsers(config: Map[String, Seq[String]], featureIdentifier: String): util.Collection[String] = + val configReader: ConfigReader[Map[String, Seq[String]]] = new ConfigReader[Map[String, Seq[String]]] { + override def enabledUsers(config: Map[String, Seq[String]], featureIdentifier: String): java.util.Collection[String] = config.get(featureIdentifier).map(_.asJava).getOrElse(java.util.Collections.emptyList()) + + override def featureEnabled(config: Map[String, Seq[String]], featureIdentifier: String): Optional[java.lang.Boolean] = ??? + override def enabledProportion(config: Map[String, Seq[String]], featureIdentifier: String): Optional[java.lang.Double] = ??? + override def featureGroup(config: Map[String, Seq[String]], featureIdentifier: String): Optional[String] = Optional.empty() } + val decider = ConfigDeciders.enabledUsers[Map[String, Seq[String]]](configReader) + val config = Map( "feature1" -> Seq("a", "b"), "feature2" -> Seq("c") diff --git a/moirai-core/src/test/scala/com/nike/moirai/config/ProportionOfUsersConfigDeciderSpec.scala b/moirai-core/src/test/scala/com/nike/moirai/config/ProportionOfUsersConfigDeciderSpec.scala new file mode 100644 index 0000000..c0dbed4 --- /dev/null +++ b/moirai-core/src/test/scala/com/nike/moirai/config/ProportionOfUsersConfigDeciderSpec.scala @@ -0,0 +1,132 @@ +package com.nike.moirai.config + +import java.util.Optional +import com.nike.moirai.FeatureCheckInput +import org.scalatest.prop.GeneratorDrivenPropertyChecks +import org.scalatest.{FunSpec, Matchers} + +import scala.collection.mutable +import scala.compat.java8.OptionConverters._ + +class ProportionOfUsersConfigDeciderSpec extends FunSpec with Matchers with GeneratorDrivenPropertyChecks { + describe("A basic implementation of ProportionOfUsersConfigDecider") { + val configReader: ConfigReader[Map[String, Double]] = new ConfigReader[Map[String, Double]] { + override def enabledProportion(config: Map[String, Double], featureIdentifier: String): Optional[java.lang.Double] = + config.get(featureIdentifier).map(Double.box).asJava + override def featureGroup(config: Map[String, Double], featureIdentifier: String): Optional[String] = Optional.empty() + override def enabledUsers(config: Map[String, Double], featureIdentifier: String): java.util.Collection[String] = ??? + override def featureEnabled(config: Map[String, Double], featureIdentifier: String): Optional[java.lang.Boolean] = ??? + } + + val decider = ConfigDeciders.proportionOfUsers(configReader) + + val config = Map( + "feature1" -> 0.0, + "feature2" -> 1.0, + "feature3" -> 0.7 + ) + + describe("a feature that returns 0.0") { + val feature = "feature1" + + it("should return false for any user") { + forAll { (userId: String) => + decider.test(new ConfigDecisionInput(config, feature, FeatureCheckInput.forUser(userId))) shouldBe false + } + } + } + + describe("a feature that returns 1.0") { + val feature = "feature2" + + it("should return true for any user") { + forAll { (userId: String) => + decider.test(new ConfigDecisionInput(config, feature, FeatureCheckInput.forUser(userId))) shouldBe true + } + } + } + + describe("a feature that returns 0.7") { + val feature = "feature3" + + it("should return true for approximately 70% of users") { + val results = mutable.Buffer.empty[Boolean] + + implicit val generatorDrivenConfig: PropertyCheckConfiguration = PropertyCheckConfiguration(minSuccessful = 10000) + + forAll { (userId: String) => + results += decider.test(new ConfigDecisionInput(config, feature, FeatureCheckInput.forUser(userId))) + } + + results.count(identity) / results.size.toDouble should be (0.7 +- 0.05) + } + } + + describe("a feature without a configured proportion") { + val feature = "feature4" + + it("should return false for any user") { + forAll { (userId: String) => + decider.test(new ConfigDecisionInput(config, feature, FeatureCheckInput.forUser(userId))) shouldBe false + } + } + } + + describe("featureGroup") { + val configReader: ConfigReader[Map[String, Double]] = new ConfigReader[Map[String, Double]] { + override def featureGroup(config: Map[String, Double], featureIdentifier: String): Optional[String] = featureIdentifier match { + case "feature1" => Optional.of("group1") + case "feature2" => Optional.of("group1") + case "feature3" => Optional.of("group2") + case "feature4" => Optional.of("group2") + case _ => Optional.empty() + } + override def enabledProportion(config: Map[String, Double], featureIdentifier: String): Optional[java.lang.Double] = + config.get(featureIdentifier).map(Double.box).asJava + + override def enabledUsers(config: Map[String, Double], featureIdentifier: String): java.util.Collection[String] = ??? + override def featureEnabled(config: Map[String, Double], featureIdentifier: String): Optional[java.lang.Boolean] = ??? + } + + val decider = ConfigDeciders.proportionOfUsers(configReader) + + val config = Map( + "feature1" -> 0.5, + "feature2" -> 0.5, + "feature3" -> 0.5, + "feature4" -> 0.5, + "feature5" -> 0.5 + ) + + it("should decide features are enabled for the same users if the features are in the same feature group and have the same proportion enabled") { + val feature1EnabledUsers = mutable.Buffer.empty[String] + val feature2EnabledUsers = mutable.Buffer.empty[String] + val feature3EnabledUsers = mutable.Buffer.empty[String] + val feature4EnabledUsers = mutable.Buffer.empty[String] + val feature5EnabledUsers = mutable.Buffer.empty[String] + + implicit val generatorDrivenConfig: PropertyCheckConfiguration = PropertyCheckConfiguration(minSuccessful = 1000) + + forAll { (userId: String) => + List( + ("feature1", feature1EnabledUsers), + ("feature2", feature2EnabledUsers), + ("feature3", feature3EnabledUsers), + ("feature4", feature4EnabledUsers), + ("feature5", feature5EnabledUsers)).foreach { + case (feature, buffer) if decider.test(new ConfigDecisionInput(config, feature, FeatureCheckInput.forUser(userId))) => buffer += userId + case _ => + } + } + + feature1EnabledUsers should contain theSameElementsAs feature2EnabledUsers + feature3EnabledUsers should contain theSameElementsAs feature4EnabledUsers + + feature1EnabledUsers should not (contain theSameElementsAs feature3EnabledUsers) + feature1EnabledUsers should not (contain theSameElementsAs feature5EnabledUsers) + + feature3EnabledUsers should not (contain theSameElementsAs feature5EnabledUsers) + } + } + } +} diff --git a/moirai-core/src/test/scala/com/nike/moirai/server/config/ProportionOfUsersConfigDeciderSpec.scala b/moirai-core/src/test/scala/com/nike/moirai/server/config/ProportionOfUsersConfigDeciderSpec.scala deleted file mode 100644 index b91401a..0000000 --- a/moirai-core/src/test/scala/com/nike/moirai/server/config/ProportionOfUsersConfigDeciderSpec.scala +++ /dev/null @@ -1,72 +0,0 @@ -package com.nike.moirai.config - -import java.lang -import java.util.Optional - -import com.nike.moirai.FeatureCheckInput -import org.scalatest.prop.GeneratorDrivenPropertyChecks -import org.scalatest.{FunSpec, Matchers} - -import scala.collection.mutable -import scala.compat.java8.OptionConverters._ - -class ProportionOfUsersConfigDeciderSpec extends FunSpec with Matchers with GeneratorDrivenPropertyChecks { - describe("A basic implementation of ProportionOfUsersConfigDecider") { - val decider = new ProportionOfUsersConfigDecider[Map[String, Double]]() { - override protected def enabledProportion(config: Map[String, Double], featureIdentifier: String): Optional[lang.Double] = - config.get(featureIdentifier).map(Double.box).asJava - } - - val config = Map( - "feature1" -> 0.0, - "feature2" -> 1.0, - "feature3" -> 0.7 - ) - - describe("a feature that returns 0.0") { - val feature = "feature1" - - it("should return false for any user") { - forAll { (userId: String) => - decider.test(new ConfigDecisionInput(config, feature, FeatureCheckInput.forUser(userId))) shouldBe false - } - } - } - - describe("a feature that returns 1.0") { - val feature = "feature2" - - it("should return true for any user") { - forAll { (userId: String) => - decider.test(new ConfigDecisionInput(config, feature, FeatureCheckInput.forUser(userId))) shouldBe true - } - } - } - - describe("a feature that returns 0.7") { - val feature = "feature3" - - it("should return true for approximately 70% of users") { - val results = mutable.Buffer.empty[Boolean] - - implicit val generatorDrivenConfig = PropertyCheckConfiguration(minSuccessful = 10000) - - forAll { (userId: String) => - results += decider.test(new ConfigDecisionInput(config, feature, FeatureCheckInput.forUser(userId))) - } - - results.count(identity) / results.size.toDouble should be (0.7 +- 0.05) - } - } - - describe("a feature without a configured proportion") { - val feature = "feature4" - - it("should return false for any user") { - forAll { (userId: String) => - decider.test(new ConfigDecisionInput(config, feature, FeatureCheckInput.forUser(userId))) shouldBe false - } - } - } - } -} diff --git a/moirai-riposte-example/src/main/java/com/nike/moirairiposteexample/Main.java b/moirai-riposte-example/src/main/java/com/nike/moirairiposteexample/Main.java index 38afa98..a582faf 100644 --- a/moirai-riposte-example/src/main/java/com/nike/moirairiposteexample/Main.java +++ b/moirai-riposte-example/src/main/java/com/nike/moirairiposteexample/Main.java @@ -4,9 +4,8 @@ import com.nike.moirai.ConfigFeatureFlagChecker; import com.nike.moirai.Suppliers; import com.nike.moirai.config.ConfigDecisionInput; +import com.nike.moirai.typesafeconfig.TypesafeConfigLoader; import com.nike.moirairiposteexample.endpoints.GetShoeListEndpoint; -import com.nike.moirai.typesafeconfig.TypesafeConfigDecider; -import com.nike.moirai.typesafeconfig.TypesafeConfigReader; import com.nike.riposte.server.Server; import com.nike.riposte.server.config.ServerConfig; import com.nike.riposte.server.http.Endpoint; @@ -19,21 +18,25 @@ import java.util.function.Predicate; import java.util.function.Supplier; +import static com.nike.moirai.config.ConfigDeciders.enabledUsers; +import static com.nike.moirai.config.ConfigDeciders.proportionOfUsers; +import static com.nike.moirai.typesafeconfig.TypesafeConfigReader.TYPESAFE_CONFIG_READER; public class Main { public static class AppServerConfig implements ServerConfig { ConfigFeatureFlagChecker featureFlagChecker() { - Predicate> whiteListedUsersDecider = TypesafeConfigDecider.ENABLED_USERS - .or(TypesafeConfigDecider.PROPORTION_OF_USERS); - String conf; + Predicate> whiteListedUsersDecider = + enabledUsers(TYPESAFE_CONFIG_READER).or(proportionOfUsers(TYPESAFE_CONFIG_READER)); + + String conf; try { conf = Resources.toString(Resources.getResource("moirai.conf"), Charset.forName("UTF-8")); } catch(Exception e) { throw new RuntimeException(e); } - Supplier supp = Suppliers.supplierAndThen(() -> conf, TypesafeConfigReader.FROM_STRING); + Supplier supp = Suppliers.supplierAndThen(() -> conf, TypesafeConfigLoader.FROM_STRING); return ConfigFeatureFlagChecker.forConfigSupplier(supp, whiteListedUsersDecider); } private final Collection> endpoints = Collections.singleton(new GetShoeListEndpoint(featureFlagChecker())); diff --git a/moirai-riposte-example/src/test/scala/com/nike/moirairiposteexample/endpoints/GetShoeListEndpointTest.java b/moirai-riposte-example/src/test/scala/com/nike/moirairiposteexample/endpoints/GetShoeListEndpointTest.java index 704993d..bd97e18 100644 --- a/moirai-riposte-example/src/test/scala/com/nike/moirairiposteexample/endpoints/GetShoeListEndpointTest.java +++ b/moirai-riposte-example/src/test/scala/com/nike/moirairiposteexample/endpoints/GetShoeListEndpointTest.java @@ -5,8 +5,7 @@ import com.nike.moirai.ConfigFeatureFlagChecker; import com.nike.moirai.Suppliers; import com.nike.moirai.config.ConfigDecisionInput; -import com.nike.moirai.typesafeconfig.TypesafeConfigDecider; -import com.nike.moirai.typesafeconfig.TypesafeConfigReader; +import com.nike.moirai.typesafeconfig.TypesafeConfigLoader; import com.nike.moirairiposteexample.testutils.TestUtils; import com.nike.riposte.server.http.RequestInfo; import com.nike.riposte.server.http.ResponseInfo; @@ -25,6 +24,9 @@ import java.util.function.Predicate; import java.util.function.Supplier; +import static com.nike.moirai.config.ConfigDeciders.enabledUsers; +import static com.nike.moirai.config.ConfigDeciders.proportionOfUsers; +import static com.nike.moirai.typesafeconfig.TypesafeConfigReader.TYPESAFE_CONFIG_READER; import static com.nike.moirairiposteexample.error.ProjectApiError.MISSING_ID_HEADER; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowable; @@ -40,15 +42,15 @@ public class GetShoeListEndpointTest { @Before public void setup() { - Predicate> whiteListedUsersDecider = TypesafeConfigDecider.ENABLED_USERS - .or(TypesafeConfigDecider.PROPORTION_OF_USERS); + Predicate> whiteListedUsersDecider = + enabledUsers(TYPESAFE_CONFIG_READER).or(proportionOfUsers(TYPESAFE_CONFIG_READER)); String conf; try { conf = Resources.toString(Resources.getResource("moirai.conf"), Charset.forName("UTF-8")); } catch(Exception e) { throw new RuntimeException(e); } - Supplier supp = Suppliers.supplierAndThen(() -> conf, TypesafeConfigReader.FROM_STRING); + Supplier supp = Suppliers.supplierAndThen(() -> conf, TypesafeConfigLoader.FROM_STRING); featureFlagChecker = ConfigFeatureFlagChecker.forConfigSupplier(supp, whiteListedUsersDecider); underTest = new GetShoeListEndpoint(featureFlagChecker); diff --git a/moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigCustomDeciders.java b/moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigCustomDeciders.java new file mode 100644 index 0000000..bfd4148 --- /dev/null +++ b/moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigCustomDeciders.java @@ -0,0 +1,59 @@ +package com.nike.moirai.typesafeconfig; + +import com.nike.moirai.config.ConfigDecisionInput; +import com.nike.moirai.config.EnabledCustomDimensionConfigDecider; +import com.typesafe.config.Config; + +import java.util.Collection; +import java.util.function.Function; +import java.util.function.Predicate; + +import static com.nike.moirai.typesafeconfig.TypesafeConfigReader.TYPESAFE_CONFIG_READER; + +/** + * Predicate implementations that read from a Typesafe {@link Config}. + */ +public class TypesafeConfigCustomDeciders { + + /** + * Reads the enabled values using {@link TypesafeConfigReader#enabledValues(Config, String, String, Function)}. + * + * Your custom dimension values must match the provided type. If they do not, then the predicate will return false and log a warning message. + * + * @param dimensionKey the key used for the dimension; this should match how you construct your FeatureCheckInput + * @param configKey the key used for the enabled values for the dimension + * @param conversion a function to convert the values in the config from strings to the data-type used in your custom dimension + * @param the data-type of your custom dimension values + * @return a Predicate that will return true if the FeatureCheckInput has a value for your custom dimension that matches the values read from the Config + */ + public static Predicate> enabledCustomDimension(String dimensionKey, String configKey, Function conversion) { + return new EnabledCustomDimensionConfigDecider() { + @Override + protected String dimensionKey() { + return dimensionKey; + } + + @Override + protected Collection enabledValues(Config config, String featureIdentifier) { + return TYPESAFE_CONFIG_READER.enabledValues(config,featureIdentifier, configKey, conversion); + } + }; + } + + /** + * Reads the enabled values using {@link TypesafeConfigReader#enabledValues(Config, String, String, Function)}. + * + * Your custom dimension values must be strings. If the values are not strings, then the predicate will return false and log a warning message. + * + * @param dimensionKey the key used for the dimension; this should match how you construct your FeatureCheckInput + * @param configKey the key used for the configuration value to be read + * @return a Predicate that will return true if the FeatureCheckInput has a value for your custom dimension that matches the values read from the Config + */ + public static Predicate> enabledCustomStringDimension(String dimensionKey, String configKey) { + return enabledCustomDimension(dimensionKey, configKey, Function.identity()); + } + + private TypesafeConfigCustomDeciders() { + // prevent instantiation + } +} diff --git a/moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigDecider.java b/moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigDecider.java deleted file mode 100644 index c77016e..0000000 --- a/moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigDecider.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.nike.moirai.typesafeconfig; - -import com.nike.moirai.config.ConfigDecisionInput; -import com.nike.moirai.config.EnabledUsersConfigDecider; -import com.nike.moirai.config.ProportionOfUsersConfigDecider; -import com.nike.moirai.config.EnabledCustomDimensionConfigDecider; -import com.nike.moirai.config.FeatureEnabledConfigDecider; -import com.typesafe.config.Config; - -import java.util.Collection; -import java.util.Optional; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.stream.Collectors; - -/** - * Predicate implementations that read from a Typesafe {@link Config}. - */ -public class TypesafeConfigDecider { - - /** - * Reads the enabled list from the config at a path of "moirai.[featureIdentifier].enabledUserIds". For instance, for a - * feature identifier of "foo.service.myfeature", the config value "moirai.foo.service.myfeature.enabledUserIds" will be read. - * If that config path does not exist, an empty list of users will be provided. - * - * @see EnabledUsersConfigDecider - */ - public static final Predicate> ENABLED_USERS = new EnabledUsersConfigDecider() { - @Override - protected Collection enabledUsers(Config config, String featureIdentifier) { - String path = String.format("moirai.%s.enabledUserIds", featureIdentifier); - return TypesafeConfigExtractor.extractCollection(config, path, Config::getStringList); - } - }; - - /** - * Reads the enabled proportion of users from the config at a path of "moirai.[featureIdentifier].enabledProportion". For instance, for a - * feature identifier of "foo.service.myfeature", the config value "moirai.foo.service.myfeature.enabledProportion" will be read. If that config - * path does not exist, {@link Optional#empty()} will be provided. - * - * @see ProportionOfUsersConfigDecider - */ - public static final Predicate> PROPORTION_OF_USERS = new ProportionOfUsersConfigDecider() { - @Override - protected Optional enabledProportion(Config config, String featureIdentifier) { - String path = String.format("moirai.%s.enabledProportion", featureIdentifier); - return TypesafeConfigExtractor.extractOptional(config, path, Config::getDouble); - } - }; - - /** - * Reads the boolean value from the config at a path of "moirai.[featureIdentifier].featureEnabled". For instance, for a - * feature identifier of "foo.service.myfeature", the config value "moirai.foo.service.myfeature.featureEnabled" will be read. If that config - * path does not exist, {@link Optional#empty()} will be provided. - * - * @see FeatureEnabledConfigDecider - */ - public static final Predicate> FEATURE_ENABLED = new FeatureEnabledConfigDecider() { - @Override - protected Optional featureEnabled(Config config, String featureIdentifier) { - String path = String.format("moirai.%s.featureEnabled", featureIdentifier); - return TypesafeConfigExtractor.extractOptional(config, path, Config::getBoolean); - } - }; - - /** - * Reads the enabled values from the config at a path of of "moirai.[featureIdentifier].[configKey]". For instance, for a - * feature identifier of "foo.service.myfeature" and a configKey of "enabledCountries", the config value "moirai.foo.service.myfeature.enabledCountries" - * will be read. If that config path does not exist, an empty list of users will be provided. - * - * Your custom dimension values must match the provided type. If they do not, then the predicate will return false and log a warning message. - * - * @param dimensionKey the key used for the dimension; this should match how you construct your FeatureCheckInput - * @param configKey the key used for the enabled values for the dimension - * @param conversion a function to convert the values in the config from strings to the data-type used in your custom dimension - * @param the data-type of your custom dimension values - * @return a Predicate that will return true if the FeatureCheckInput has a value for your custom dimension that matches the values read from the Config - */ - public static Predicate> enabledCustomDimension(String dimensionKey, String configKey, Function conversion) { - return new EnabledCustomDimensionConfigDecider() { - @Override - protected String dimensionKey() { - return dimensionKey; - } - - @Override - protected Collection enabledValues(Config config, String featureIdentifier) { - String path = String.format("moirai.%s.%s", featureIdentifier, configKey); - - return TypesafeConfigExtractor.extractCollection(config, path, Config::getStringList) - .stream().map(conversion).collect(Collectors.toList()); - } - }; - } - - /** - * Reads the enabled values from the config at a path of of "moirai.[featureIdentifier].[configKey]". For instance, for a - * feature identifier of "foo.service.myfeature" and a configKey of "enabledCountries", the config value "moirai.foo.service.myfeature.enabledCountries" - * will be read. If that config path does not exist, an empty list of users will be provided. - * - * Your custom dimension values must be strings. If the values are not strings, then the predicate will return false and log a warning message. - * - * @param dimensionKey the key used for the dimension; this should match how you construct your FeatureCheckInput - * @param configKey the key used for the configuration value to be read - * @return a Predicate that will return true if the FeatureCheckInput has a value for your custom dimension that matches the values read from the Config - */ - public static Predicate> enabledCustomStringDimension(String dimensionKey, String configKey) { - return enabledCustomDimension(dimensionKey, configKey, Function.identity()); - } - - private TypesafeConfigDecider() { - // prevent instantiation - } -} diff --git a/moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigLoader.java b/moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigLoader.java new file mode 100644 index 0000000..a743636 --- /dev/null +++ b/moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigLoader.java @@ -0,0 +1,17 @@ +package com.nike.moirai.typesafeconfig; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +import java.util.function.Function; + +public class TypesafeConfigLoader { + public static final Function FROM_STRING = s -> { + Config config = ConfigFactory.parseString(s); + return config.resolve(); + }; + + private TypesafeConfigLoader() { + // Prevent instantiation + } +} diff --git a/moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigReader.java b/moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigReader.java index 6701477..6d912b3 100644 --- a/moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigReader.java +++ b/moirai-typesafeconfig/src/main/java/com/nike/moirai/typesafeconfig/TypesafeConfigReader.java @@ -1,17 +1,87 @@ package com.nike.moirai.typesafeconfig; +import com.nike.moirai.config.ConfigReader; import com.typesafe.config.Config; -import com.typesafe.config.ConfigFactory; +import java.util.Collection; +import java.util.Optional; import java.util.function.Function; +import java.util.stream.Collectors; -public class TypesafeConfigReader { - public static final Function FROM_STRING = s -> { - Config config = ConfigFactory.parseString(s); - return config.resolve(); - }; +public class TypesafeConfigReader implements ConfigReader { + public static TypesafeConfigReader TYPESAFE_CONFIG_READER = new TypesafeConfigReader(); private TypesafeConfigReader() { - // Prevent instantiation + } + + /** + * Reads the enabled list from the config at a path of "moirai.[featureIdentifier].enabledUserIds". For instance, for a + * feature identifier of "foo.service.myfeature", the config value "moirai.foo.service.myfeature.enabledUserIds" will be read. + * If that config path does not exist, an empty list of users will be provided. + * + * @see ConfigReader#enabledUsers(Object, String) + */ + @Override + public Collection enabledUsers(Config config, String featureIdentifier) { + String path = String.format("moirai.%s.enabledUserIds", featureIdentifier); + return TypesafeConfigExtractor.extractCollection(config, path, Config::getStringList); + } + + /** + * Reads the boolean value from the config at a path of "moirai.[featureIdentifier].featureEnabled". For instance, for a + * feature identifier of "foo.service.myfeature", the config value "moirai.foo.service.myfeature.featureEnabled" will be read. If that config + * path does not exist, {@link Optional#empty()} will be provided. + * + * @see ConfigReader#featureEnabled(Object, String) + */ + @Override + public Optional featureEnabled(Config config, String featureIdentifier) { + String path = String.format("moirai.%s.featureEnabled", featureIdentifier); + return TypesafeConfigExtractor.extractOptional(config, path, Config::getBoolean); + } + + /** + * Reads the enabled proportion of users from the config at a path of "moirai.[featureIdentifier].enabledProportion". For instance, for a + * feature identifier of "foo.service.myfeature", the config value "moirai.foo.service.myfeature.enabledProportion" will be read. If that config + * path does not exist, {@link Optional#empty()} will be provided. + * + * @see ConfigReader#enabledProportion(Object, String) + */ + @Override + public Optional enabledProportion(Config config, String featureIdentifier) { + String path = String.format("moirai.%s.enabledProportion", featureIdentifier); + return TypesafeConfigExtractor.extractOptional(config, path, Config::getDouble); + } + + /** + * Reads the feature group from the config at a path of "moirai.[featureIdentifier].featureGroup". For instance, for a feature identifier + * of "foo.service.myfeature", the config value "moirai.foo.service.myfeature.featureGroup" will be read. If that config path does not exist, + * {@link Optional#empty()} will be provided. + * + * @param config the config source + * @param featureIdentifier the feature + * @see ConfigReader#featureGroup(Object, String) + */ + @Override + public Optional featureGroup(Config config, String featureIdentifier) { + String path = String.format("moirai.%s.featureGroup", featureIdentifier); + return TypesafeConfigExtractor.extractOptional(config, path, Config::getString); + } + + /** + * Reads the enabled values from the config at a path of of "moirai.[featureIdentifier].[configKey]". For instance, for a + * feature identifier of "foo.service.myfeature" and a configKey of "enabledCountries", the config value "moirai.foo.service.myfeature.enabledCountries" + * will be read. If that config path does not exist, an empty list of users will be provided. + * + * @param configKey the key used for the enabled values for the dimension + * @param conversion a function to convert the values in the config from strings to the data-type used in your custom dimension + * @param the data-type of your custom dimension values + * @return the collection of enabled values + */ + public Collection enabledValues(Config config, String featureIdentifier, String configKey, Function conversion) { + String path = String.format("moirai.%s.%s", featureIdentifier, configKey); + + return TypesafeConfigExtractor.extractCollection(config, path, Config::getStringList) + .stream().map(conversion).collect(Collectors.toList()); } } diff --git a/moirai-typesafeconfig/src/test/resources/moirai-feature-group.conf b/moirai-typesafeconfig/src/test/resources/moirai-feature-group.conf new file mode 100644 index 0000000..c40a0d8 --- /dev/null +++ b/moirai-typesafeconfig/src/test/resources/moirai-feature-group.conf @@ -0,0 +1,25 @@ +moirai { + feature1 { + enabledProportion = 0.5 + featureGroup = "group1" + } + + feature2 { + enabledProportion = 0.5 + featureGroup = "group1" + } + + feature3 { + enabledProportion = 0.5 + featureGroup = "group2" + } + + feature4 { + enabledProportion = 0.5 + featureGroup = "group2" + } + + feature5 { + enabledProportion = 0.5 + } +} diff --git a/moirai-typesafeconfig/src/test/scala/com/nike/moirai/typesafeconfig/TypesafeConfigUserDecidersSpec.scala b/moirai-typesafeconfig/src/test/scala/com/nike/moirai/typesafeconfig/TypesafeConfigUserDecidersSpec.scala index c3825cd..02cdd76 100644 --- a/moirai-typesafeconfig/src/test/scala/com/nike/moirai/typesafeconfig/TypesafeConfigUserDecidersSpec.scala +++ b/moirai-typesafeconfig/src/test/scala/com/nike/moirai/typesafeconfig/TypesafeConfigUserDecidersSpec.scala @@ -1,19 +1,25 @@ package com.nike.moirai.typesafeconfig +import com.nike.moirai.config.ConfigDeciders._ +import com.nike.moirai.config.ConfigDecisionInput import com.nike.moirai.resource.FileResourceLoaders +import com.nike.moirai.typesafeconfig.TypesafeConfigReader.TYPESAFE_CONFIG_READER import com.nike.moirai.{ConfigFeatureFlagChecker, FeatureCheckInput, Suppliers} import com.typesafe.config.Config +import org.scalatest.prop.GeneratorDrivenPropertyChecks import org.scalatest.{FunSpec, Matchers} +import scala.collection.mutable + //noinspection TypeAnnotation -class TypesafeConfigUserDecidersSpec extends FunSpec with Matchers { +class TypesafeConfigUserDecidersSpec extends FunSpec with Matchers with GeneratorDrivenPropertyChecks { describe("A combined enabled-user and proportion-of-users config decider") { val resourceLoader = FileResourceLoaders.forClasspathResource("moirai.conf") val featureFlagChecker = ConfigFeatureFlagChecker.forConfigSupplier[Config]( - Suppliers.supplierAndThen(resourceLoader, TypesafeConfigReader.FROM_STRING), - TypesafeConfigDecider.ENABLED_USERS.or(TypesafeConfigDecider.PROPORTION_OF_USERS) + Suppliers.supplierAndThen(resourceLoader, TypesafeConfigLoader.FROM_STRING), + enabledUsers(TYPESAFE_CONFIG_READER).or(proportionOfUsers(TYPESAFE_CONFIG_READER)) ) describe("a feature specifying both enabledUserIds and an enabledProportion of 0.0") { @@ -69,12 +75,51 @@ class TypesafeConfigUserDecidersSpec extends FunSpec with Matchers { } } + describe("A proportionOfUsers config decider with featureGroup") { + val resourceLoader = FileResourceLoaders.forClasspathResource("moirai-feature-group.conf") + + val featureFlagChecker = ConfigFeatureFlagChecker.forConfigSupplier[Config]( + Suppliers.supplierAndThen(resourceLoader, TypesafeConfigLoader.FROM_STRING), + proportionOfUsers(TYPESAFE_CONFIG_READER) + ) + + it("should decide features are enabled for the same users if the features are in the same feature group and have the same proportion enabled") { + val feature1EnabledUsers = mutable.Buffer.empty[String] + val feature2EnabledUsers = mutable.Buffer.empty[String] + val feature3EnabledUsers = mutable.Buffer.empty[String] + val feature4EnabledUsers = mutable.Buffer.empty[String] + val feature5EnabledUsers = mutable.Buffer.empty[String] + + implicit val generatorDrivenConfig: PropertyCheckConfiguration = PropertyCheckConfiguration(minSuccessful = 1000) + + forAll { (userId: String) => + List( + ("feature1", feature1EnabledUsers), + ("feature2", feature2EnabledUsers), + ("feature3", feature3EnabledUsers), + ("feature4", feature4EnabledUsers), + ("feature5", feature5EnabledUsers)).foreach { + case (feature, buffer) if featureFlagChecker.isFeatureEnabled(feature, FeatureCheckInput.forUser(userId)) => buffer += userId + case _ => + } + } + + feature1EnabledUsers should contain theSameElementsAs feature2EnabledUsers + feature3EnabledUsers should contain theSameElementsAs feature4EnabledUsers + + feature1EnabledUsers should not (contain theSameElementsAs feature3EnabledUsers) + feature1EnabledUsers should not (contain theSameElementsAs feature5EnabledUsers) + + feature3EnabledUsers should not (contain theSameElementsAs feature5EnabledUsers) + } + } + describe("A featureEnabled config decider") { val resourceLoader = FileResourceLoaders.forClasspathResource("moirai.conf") val featureFlagChecker = ConfigFeatureFlagChecker.forConfigSupplier[Config]( - Suppliers.supplierAndThen(resourceLoader, TypesafeConfigReader.FROM_STRING), - TypesafeConfigDecider.FEATURE_ENABLED + Suppliers.supplierAndThen(resourceLoader, TypesafeConfigLoader.FROM_STRING), + featureEnabled(TYPESAFE_CONFIG_READER) ) it("should be enabled if the configuration say so") { @@ -97,8 +142,8 @@ class TypesafeConfigUserDecidersSpec extends FunSpec with Matchers { val resourceLoader = FileResourceLoaders.forClasspathResource("moirai.conf") val featureFlagChecker = ConfigFeatureFlagChecker.forConfigSupplier[Config]( - Suppliers.supplierAndThen(resourceLoader, TypesafeConfigReader.FROM_STRING), - TypesafeConfigDecider.enabledCustomStringDimension("country", "enabledCountries") + Suppliers.supplierAndThen(resourceLoader, TypesafeConfigLoader.FROM_STRING), + TypesafeConfigCustomDeciders.enabledCustomStringDimension("country", "enabledCountries") ) it("should be enabled for a user in an enabled country") {