diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 06deafa..bad5479 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -24,7 +24,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: Run Check
- run: ./gradlew check detektAll detektReleaseAll
+ run: ./gradlew check detektAll
publish:
name: Publish
diff --git a/.gitignore b/.gitignore
index fd68970..990e6cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,10 @@
# Keep VCS config
!.idea/vcs.xml
+# Keep project dictionary
+!.idea/dictionaries
+!.idea/dictionaries/project.xml
+
## Gradle
# Ignore Gradle project-specific cache directory
.gradle
diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml
new file mode 100644
index 0000000..6f47080
--- /dev/null
+++ b/.idea/dictionaries/project.xml
@@ -0,0 +1,7 @@
+
+
+
+ konfeature
+
+
+
diff --git a/README.md b/README.md
index e422b3a..fed2998 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,15 @@
[![License](https://img.shields.io/github/license/RedMadRobot/Konfeature?style=flat-square)][license]
-Konfeature and no more spoilers.
+Working with remote configuration has become a standard part of the development process for almost any application. Depending on the complexity of the application, several requirements for such functionality may arise, including:
+- convenient syntax for declaring configuration elements
+- the ability to separate configuration into different files for different features
+- the ability to make the configuration local-only during active feature development
+- support for multiple data sources for remote config
+- the ability to view a list of all configurations and modify their values for debugging purposes
+- logging the value and its source when accessing the configuration, as well as logging non-critical errors
+
+We have made every effort to meet all these requirements in the development of Konfeature.
---
@@ -10,6 +18,13 @@ Konfeature and no more spoilers.
- [Installation](#installation)
- [Usage](#usage)
+ - [FeatureConfig](#featureconfig)
+ - [FeatureSource](#featuresource)
+ - [SourceSelectionStrategy](#sourceselectionstrategy)
+ - [Interceptor](#interceptor)
+ - [Logger](#logger)
+ - [Spec](#spec)
+ - [Ordering](#ordering)
- [Contributing](#contributing)
@@ -21,7 +36,6 @@ Add the dependency:
```groovy
repositories {
mavenCentral()
- google()
}
dependencies {
@@ -31,14 +45,309 @@ dependencies {
## Usage
+### FeatureConfig
+
+Defines a set of configuration elements, where each element is defined using a delegate.
+There are two types of delegates:
+- `by toggle(...)` - used for elements of type `Boolean`
+- `by value(...)` - used for elements of any other type
+
+```kotlin
+class ProfileFeatureConfig : FeatureConfig(
+ name = "profile_feature_config",
+ description = "Config of features for profile usage"
+) {
+ val isProfileFeatureEnabled: Boolean by toggle(
+ key = "profile_feature",
+ description = "show profile entry point for user",
+ defaultValue = false,
+ )
+
+ val profileFeatureTitle: String by value(
+ key = "profile_feature_title",
+ description = "title of profile entry point button",
+ defaultValue = "Feature number nine",
+ sourceSelectionStrategy = SourceSelectionStrategy.Any
+ )
+
+ val profileButtonAppearDuration: Long by value(
+ key = "profile_button_appear_duration",
+ description = "duration of profile button appearing in ms",
+ defaultValue = 200,
+ sourceSelectionStrategy = SourceSelectionStrategy.Any
+ )
+}
+```
+
+The configuration requires specifying:
+- `name` - the name of the configuration
+- `description` - a detailed description of the configuration
+
+Each configuration element requires specifying:
+- `key` - used to retrieve the value of the element from a `Source`
+- `description` - a detailed description of the element
+- `defaultValue` - used if the value cannot be found in a `Source`
+- `sourceSelectionStrategy` - the strategy for selecting a `Source` using [SourceSelectionStrategy](#sourceselectionstrategy)
+
+After that, you need to register the configuration in `Konfeature`:
+
+```kotlin
+val profileFeatureConfig: FeatureConfig = ProfileFeatureConfig()
+
+val konfeatureInstance = konfeature {
+ register(profileFeatureConfig)
+}
+```
+
+>Similarly, you can add multiple configurations, for example, for each module, when organizing multi-modularity by features.
+
+### FeatureSource
+
+An abstraction over the value source for configuration elements.
+
+```kotlin
+public interface FeatureSource {
+
+ public val name: String
+
+ public fun get(key: String): Any?
+}
+```
+- `name` - source name
+- `get(key: String)` - logic for getting values by `key`
+
+Example implementation based on `FirebaseRemoteConfig`:
+
+```kotlin
+class FirebaseFeatureSource(
+ private val remoteConfig: FirebaseRemoteConfig
+) : FeatureSource {
+
+ override val name: String = "FirebaseRemoteConfig"
+
+ override fun get(key: String): Any? {
+ return remoteConfig
+ .getValue(key)
+ .takeIf { source == FirebaseRemoteConfig.VALUE_SOURCE_REMOTE }
+ ?.let { value: FirebaseRemoteConfigValue ->
+ value.getOrNull { asBoolean() }
+ ?: value.getOrNull { asString() }
+ ?: value.getOrNull { asLong() }
+ ?: value.getOrNull { asDouble() }
+ }
+ }
+
+ private fun FirebaseRemoteConfigValue.getOrNull(
+ getter: FirebaseRemoteConfigValue.() -> Any?
+ ): Any? {
+ return try {
+ getter()
+ } catch (error: IllegalArgumentException) {
+ null
+ }
+ }
+}
+```
+After that, you need to add the `Source` in `Konfeature`:
+
+```kotlin
+val profileFeatureConfig: FeatureConfig = ProfileFeatureConfig()
+val source: FeatureSource = FirebaseFeatureSource(remoteConfig)
+
+val konfeatureInstance = konfeature {
+ addSource(source)
+ register(profileFeatureConfig)
+}
+```
+
+>Similarly, you can add multiple sources, for example, Huawei AppGallery, RuStore, or your own backend.
+
+### SourceSelectionStrategy
+
+You can configure the retrieval of an element's value from the source more flexibly by using the `sourceSelectionStrategy` parameter:
+
+```kotlin
+val profileFeatureTitle: String by value(
+ key = "profile_feature_title",
+ description = "title of profile entry point button",
+ defaultValue = "Feature number nine",
+ sourceSelectionStrategy = SourceSelectionStrategy.Any
+)
+```
+
+Where `sourceSelectionStrategy` filters the available data sources.
+
+```kotlin
+public fun interface SourceSelectionStrategy {
+
+ public fun select(names: Set): Set
+
+ public companion object {
+ public val None: SourceSelectionStrategy = SourceSelectionStrategy { emptySet() }
+ public val Any: SourceSelectionStrategy = SourceSelectionStrategy { it }
+
+ public fun anyOf(vararg sources: String): SourceSelectionStrategy = SourceSelectionStrategy { sources.toSet() }
+ }
+}
+```
+
+The `select(...)` method receives a list of available `Source` names and returns a list of sources from which the configuration element can retrieve a value.
+
+For most scenarios, predefined implementations will be sufficient:
+- `SourceSelectionStrategy.None` - prohibits taking values from any source, i.e., the value specified in `defaultValue` will always be used
+- `SourceSelectionStrategy.Any` - allows taking values from any source
+- `SourceSelectionStrategy.anyOf("Source 1", ... ,"Source N")` - allows taking values from the specified list of sources
+
+> [!IMPORTANT]
+> By default, `SourceSelectionStrategy.None` is used!
+
+### Interceptor
+
+Allows intercepting and overriding the value of the element.
+
+```kotlin
+public interface Interceptor {
+
+ public val name: String
+
+ public fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any?
+}
+```
+
+- `name` - the name of the interceptor
+- `intercept(valueSource: FeatureValueSource, key: String, value: Any): Any?` - called when accessing the element with `key` and `value` from `valueSource(Source(), Interceptor(), Default)`, and returns its new value or `null` if it doesn't change
+
+Example of implementation based on `DebugPanelInterceptor`:
+
+```kotlin
+class DebugPanelInterceptor : Interceptor {
+
+ private val values = mutableMapOf()
+
+ override val name: String = "DebugPanelInterceptor"
+
+ override fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? {
+ return values[key]
+ }
+
+ fun setFeatureValue(key: String, value: Any) {
+ values[key] = value
+ }
+
+ fun removeFeatureValue(key: String) {
+ values.remove(key)
+ }
+}
+```
+
+After that, you need to add the `Interceptor` in `Konfeature`:
+
+```kotlin
+val profileFeatureConfig: FeatureConfig = ProfileFeatureConfig()
+val source: FeatureSource = FirebaseFeatureSource(remoteConfig)
+val debugPanelInterceptor: Interceptor = DebugPanelInterceptor()
+
+val konfeatureInstance = konfeature {
+ addSource(source)
+ register(profileFeatureConfig)
+ addInterceptor(debugPanelInterceptor)
+}
+```
+
+>Similarly, you can add multiple interceptors.
+
+### Logger
+
+```kotlin
+public interface Logger {
+
+ public fun log(severity: Severity, message: String)
+
+ public enum class Severity {
+ WARNING, INFO
+ }
+}
+```
+
+The following events are logged:
+
+- key, value, and its source when requested
+>Get value 'true' by key 'profile_feature' from 'Source(name=FirebaseRemoteConfig)'
+- `Source` or `Interceptor` returns an unexpected type for `key`
+>Unexpected value type for 'profile_button_appear_duration': expected type is 'kotlin.Long', but value from 'Source(name=FirebaseRemoteConfig)' is 'true' with type 'kotlin.Boolean'
+
+Example of implementation based on `Timber`:
+
+```kotlin
+class TimberLogger: Logger {
+
+ override fun log(severity: Severity, message: String) {
+ if (severity == INFO) {
+ Timber.tag(TAG).i(message)
+ } else if (severity == WARNING) {
+ Timber.tag(TAG).w(message)
+ }
+ }
+
+ companion object {
+ private const val TAG = "Konfeature"
+ }
+}
+```
+
+After that, you need to add the `Logger` in `Konfeature`:
+
+```kotlin
+val profileFeatureConfig: FeatureConfig = ProfileFeatureConfig()
+val source: FeatureSource = FirebaseFeatureSource(remoteConfig)
+val debugPanelInterceptor: Interceptor = DebugPanelInterceptor()
+val logger: Logger = TimberLogger()
+
+val konfeatureInstance = konfeature {
+ addSource(source)
+ register(profileFeatureConfig)
+ addInterceptor(debugPanelInterceptor)
+ setLogger(logger)
+}
+```
+
+### Spec
+
+Konfeature contains information about all registered `FeatureConfig` in the form of `spec`:
+
+```kotlin
+public interface Konfeature {
+
+ public val spec: List
+
+ public fun getValue(spec: FeatureValueSpec): FeatureValue
+}
+```
+
+This allows you to obtain information about added configurations as well as the current value of each element:
+
+```kotlin
+val konfeatureInstance = konfeature {...}
+
+val featureConfigSpec = konfeatureInstance.spec[0]
+val featureSpec = featureConfigSpec.values[0]
+val featureValue = konfeatureInstance.getValue(featureSpec)
+```
+> This can be useful for use in the DebugPanel
+
+## Ordering
+The value of the configuration element is determined in the following order:
+
+- `defaultValue` and `Default` source are assigned.
+- Using `sourceSelectionStrategy`, a list of `Sources` from which a value can be requested is determined.
+- Search the list of `Sources` in the order they were added to `Konfeature`, **stopping at the first occurrence** of the element by `key`.
+ Upon successful search, the value from `Source` is assigned with `Source(name=SourceName)` source.
+- Search the list of `Interceptors` in the order they were added to `Konfeature`.
+ If `Interceptor` returns a value other than `null`, this value is assigned with `Interceptor(name=InterceptorName)` source.
+
## Contributing
Merge requests are welcome.
For major changes, please open an issue first to discuss what you would like to change.
-## Checklist after repository creation (remove after checked)
-
-- Update developers in [publishing plugin](buildSrc/src/main/kotlin/convention.publishing.gradle.kts)
-- remove `Cleanup` step from [main.yml](.github/workflows/main.yml)
-
[license]: ../LICENSE
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e337b1f..40805ad 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -6,9 +6,11 @@ android-gradle-plugin = "8.4.0"
detekt = "1.23.6"
gradle-android-cacheFix = "3.0.1"
gradle-infrastructure = "0.18.1"
-kotlin = "1.9.23"
+kotlin = "2.0.0"
versionsPlugin = "0.51.0"
publish-plugin = "0.28.0"
+poko = "0.16.0"
+kotest = "5.9.1"
[libraries]
android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" }
@@ -18,7 +20,9 @@ infrastructure-android = { module = "com.redmadrobot.build:infrastructure-androi
infrastructure-publish = { module = "com.redmadrobot.build:infrastructure-publish", version.ref = "gradle-infrastructure" }
publish-gradlePlugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "publish-plugin" }
kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
+kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
[plugins]
infrastructure-detekt = { id = "com.redmadrobot.detekt", version.ref = "gradle-infrastructure" }
versions = { id = "com.github.ben-manes.versions", version.ref = "versionsPlugin" }
+poko = { id = "dev.drewhamilton.poko", version.ref = "poko" }
diff --git a/konfeature/build.gradle.kts b/konfeature/build.gradle.kts
index a53ac47..5569b96 100644
--- a/konfeature/build.gradle.kts
+++ b/konfeature/build.gradle.kts
@@ -1,11 +1,21 @@
plugins {
- id("com.redmadrobot.kotlin-library")
+ kotlin("multiplatform")
+ alias(libs.plugins.poko)
convention.publishing
convention.detekt
}
-description = "konfeature"
+kotlin {
+ explicitApi()
+ jvm()
-dependencies {
- api(kotlin("stdlib"))
+ sourceSets {
+ commonMain.dependencies {
+ api(kotlin("stdlib"))
+ }
+ commonTest.dependencies {
+ implementation(kotlin("test"))
+ implementation(libs.kotest.assertions.core)
+ }
+ }
}
diff --git a/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureConfig.kt b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureConfig.kt
new file mode 100644
index 0000000..197a83e
--- /dev/null
+++ b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureConfig.kt
@@ -0,0 +1,92 @@
+package com.redmadrobot.konfeature
+
+import com.redmadrobot.konfeature.source.SourceSelectionStrategy
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+
+public abstract class FeatureConfig(
+ override val name: String,
+ override val description: String
+) : FeatureConfigSpec {
+ private var konfeature: Konfeature? = null
+ private val _values = mutableListOf>()
+
+ override val values: List>
+ get() = _values.toList()
+
+ internal fun bind(konfeature: Konfeature) {
+ this.konfeature = konfeature
+ }
+
+ @JvmName("ValueBoolean")
+ @Deprecated(
+ message = "Use toggle instead",
+ replaceWith = ReplaceWith("toggle(key, description, defaultValue, sourceSelectionStrategy)"),
+ level = DeprecationLevel.ERROR,
+ )
+ @Suppress("UNUSED_PARAMETER", "FINAL_UPPER_BOUND")
+ public fun value(
+ key: String,
+ description: String,
+ defaultValue: T,
+ sourceSelectionStrategy: SourceSelectionStrategy = SourceSelectionStrategy.None
+ ): ReadOnlyProperty {
+ error("Use toggle instead of boolean value")
+ }
+
+ public fun value(
+ key: String,
+ description: String,
+ defaultValue: T,
+ sourceSelectionStrategy: SourceSelectionStrategy = SourceSelectionStrategy.None
+ ): ReadOnlyProperty {
+ return createValue(
+ key = key,
+ description = description,
+ defaultValue = defaultValue,
+ sourceSelectionStrategy = sourceSelectionStrategy
+ )
+ }
+
+ public fun toggle(
+ key: String,
+ description: String,
+ defaultValue: Boolean,
+ sourceSelectionStrategy: SourceSelectionStrategy = SourceSelectionStrategy.None,
+ ): ReadOnlyProperty {
+ return createValue(
+ key = key,
+ description = description,
+ defaultValue = defaultValue,
+ sourceSelectionStrategy = sourceSelectionStrategy
+ )
+ }
+
+ private fun createValue(
+ key: String,
+ description: String,
+ defaultValue: T,
+ sourceSelectionStrategy: SourceSelectionStrategy = SourceSelectionStrategy.None
+ ): ReadOnlyProperty {
+ val spec = FeatureValueSpec(
+ key = key,
+ description = description,
+ defaultValue = defaultValue,
+ sourceSelectionStrategy = sourceSelectionStrategy
+ )
+ _values.add(spec)
+ return Value(spec)
+ }
+
+ private class Value(
+ private val spec: FeatureValueSpec,
+ ) : ReadOnlyProperty {
+ override fun getValue(thisRef: FeatureConfig?, property: KProperty<*>): T {
+ return checkBinding(thisRef?.konfeature).getValue(spec).value
+ }
+
+ private fun checkBinding(konFeature: Konfeature?): Konfeature {
+ return checkNotNull(konFeature) { "FeatureConfig is not bound to Konfeature" }
+ }
+ }
+}
diff --git a/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureConfigSpec.kt b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureConfigSpec.kt
new file mode 100644
index 0000000..f03557c
--- /dev/null
+++ b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureConfigSpec.kt
@@ -0,0 +1,7 @@
+package com.redmadrobot.konfeature
+
+public interface FeatureConfigSpec {
+ public val name: String
+ public val description: String
+ public val values: List>
+}
diff --git a/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureValue.kt b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureValue.kt
new file mode 100644
index 0000000..e94517b
--- /dev/null
+++ b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureValue.kt
@@ -0,0 +1,10 @@
+package com.redmadrobot.konfeature
+
+import com.redmadrobot.konfeature.source.FeatureValueSource
+import dev.drewhamilton.poko.Poko
+
+@Poko
+public class FeatureValue(
+ public val source: FeatureValueSource,
+ public val value: T,
+)
diff --git a/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureValueSpec.kt b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureValueSpec.kt
new file mode 100644
index 0000000..08aaea5
--- /dev/null
+++ b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/FeatureValueSpec.kt
@@ -0,0 +1,12 @@
+package com.redmadrobot.konfeature
+
+import com.redmadrobot.konfeature.source.SourceSelectionStrategy
+import dev.drewhamilton.poko.Poko
+
+@Poko
+public class FeatureValueSpec(
+ public val key: String,
+ public val description: String,
+ public val defaultValue: T,
+ public val sourceSelectionStrategy: SourceSelectionStrategy
+)
diff --git a/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/Konfeature.kt b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/Konfeature.kt
new file mode 100644
index 0000000..b8b0deb
--- /dev/null
+++ b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/Konfeature.kt
@@ -0,0 +1,8 @@
+package com.redmadrobot.konfeature
+
+public interface Konfeature {
+
+ public val spec: List
+
+ public fun getValue(spec: FeatureValueSpec): FeatureValue
+}
diff --git a/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/Logger.kt b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/Logger.kt
new file mode 100644
index 0000000..6337eea
--- /dev/null
+++ b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/Logger.kt
@@ -0,0 +1,18 @@
+package com.redmadrobot.konfeature
+
+public interface Logger {
+
+ public fun log(severity: Severity, message: String)
+
+ public enum class Severity {
+ WARNING, INFO
+ }
+}
+
+internal fun Logger.logWarn(message: String) {
+ log(Logger.Severity.WARNING, message)
+}
+
+internal fun Logger.logInfo(message: String) {
+ log(Logger.Severity.INFO, message)
+}
diff --git a/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/builder/KonfeatureBuilder.kt b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/builder/KonfeatureBuilder.kt
new file mode 100644
index 0000000..f2d6804
--- /dev/null
+++ b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/builder/KonfeatureBuilder.kt
@@ -0,0 +1,86 @@
+package com.redmadrobot.konfeature.builder
+
+import com.redmadrobot.konfeature.*
+import com.redmadrobot.konfeature.exception.ConfigNameAlreadyExistException
+import com.redmadrobot.konfeature.exception.KeyDuplicationException
+import com.redmadrobot.konfeature.exception.NoFeatureConfigException
+import com.redmadrobot.konfeature.exception.SourceNameAlreadyExistException
+import com.redmadrobot.konfeature.source.FeatureSource
+import com.redmadrobot.konfeature.source.Interceptor
+
+public class KonfeatureBuilder {
+ private val sources = mutableListOf()
+ private var interceptors = mutableListOf()
+ private var spec = mutableListOf()
+ private var logger: Logger? = null
+
+ public fun addInterceptor(interceptor: Interceptor): KonfeatureBuilder {
+ interceptors.add(interceptor)
+ return this
+ }
+
+ public fun addSource(source: FeatureSource): KonfeatureBuilder {
+ if (sources.any { it.name == source.name }) {
+ throw SourceNameAlreadyExistException(source.name)
+ }
+
+ sources.add(source)
+ return this
+ }
+
+ public fun register(featureConfig: FeatureConfig): KonfeatureBuilder {
+ if (spec.any { it.name == featureConfig.name }) {
+ throw ConfigNameAlreadyExistException(featureConfig.name)
+ }
+ spec.add(featureConfig)
+ return this
+ }
+
+ public fun setLogger(logger: Logger): KonfeatureBuilder {
+ this.logger = logger
+ return this
+ }
+
+ public fun build(): Konfeature {
+ if (spec.isEmpty()) throw NoFeatureConfigException()
+
+ spec.forEach(::validateConfigSpec)
+
+ return KonfeatureImpl(
+ sources = sources,
+ interceptors = interceptors,
+ logger = logger,
+ spec = spec
+ ).also { toggleEase ->
+ spec.forEach { values ->
+ values.bind(toggleEase)
+ }
+ }
+ }
+
+ private fun validateConfigSpec(config: FeatureConfigSpec) {
+ val counter = mutableMapOf().withDefault { 0 }
+ var hasDuplicates = false
+ config.values.forEach { valueSpec ->
+ val value = counter.getValue(valueSpec.key)
+ if (value > 0) {
+ hasDuplicates = true
+ }
+ counter[valueSpec.key] = value + 1
+ }
+
+ if (hasDuplicates) {
+ val values = counter.asSequence()
+ .filter { it.value > 1 }
+ .map { it.key }
+ .toList()
+ throw KeyDuplicationException(values, config.name)
+ } else if (counter.isEmpty()) {
+ logger?.logWarn("Config '${config.name}' is empty")
+ }
+ }
+}
+
+public fun konfeature(build: KonfeatureBuilder.() -> Unit): Konfeature {
+ return KonfeatureBuilder().apply(build).build()
+}
diff --git a/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/builder/KonfeatureImpl.kt b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/builder/KonfeatureImpl.kt
new file mode 100644
index 0000000..c46bcca
--- /dev/null
+++ b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/builder/KonfeatureImpl.kt
@@ -0,0 +1,97 @@
+@file:Suppress("NoWildcardImports", "WildcardImport")
+
+package com.redmadrobot.konfeature.builder
+
+import com.redmadrobot.konfeature.*
+import com.redmadrobot.konfeature.FeatureConfigSpec
+import com.redmadrobot.konfeature.FeatureValueSpec
+import com.redmadrobot.konfeature.source.FeatureSource
+import com.redmadrobot.konfeature.source.FeatureValueSource
+import com.redmadrobot.konfeature.source.Interceptor
+import kotlin.reflect.KClass
+
+internal class KonfeatureImpl(
+ private val sources: List,
+ private val interceptors: List,
+ private val logger: Logger?,
+ override val spec: List,
+) : Konfeature {
+
+ private val sourcesNames = sources.map { it.name }.toSet()
+
+ @Suppress("LoopWithTooManyJumpStatements")
+ override fun getValue(spec: FeatureValueSpec): FeatureValue {
+ val selectedSourcesNames = spec.sourceSelectionStrategy.select(sourcesNames)
+
+ val expectedClass = spec.defaultValue::class
+ var value: T = spec.defaultValue
+ var valueSource: FeatureValueSource = FeatureValueSource.Default
+
+ for (source in sources) {
+ if (source.name !in selectedSourcesNames) continue
+ val actualSourceValue = source.get(spec.key)
+ val sourceValue = expectedClass.tryCastOrNull(actualSourceValue)
+
+ if (actualSourceValue != null && sourceValue == null) {
+ logger?.logUnexpectedValueType(
+ key = spec.key,
+ source = FeatureValueSource.Source(source.name),
+ value = actualSourceValue,
+ actualClass = actualSourceValue::class.qualifiedName,
+ expectedClass = expectedClass.qualifiedName,
+ )
+ }
+
+ if (sourceValue != null) {
+ value = sourceValue
+ valueSource = FeatureValueSource.Source(source.name)
+ break
+ }
+ }
+
+ for (interceptor in interceptors) {
+ val actualInterceptorValue = interceptor.intercept(valueSource, spec.key, value)
+ val interceptorValue = expectedClass.tryCastOrNull(actualInterceptorValue)
+
+ if (actualInterceptorValue != null && interceptorValue == null) {
+ logger?.logUnexpectedValueType(
+ key = spec.key,
+ source = FeatureValueSource.Interceptor(interceptor.name),
+ value = actualInterceptorValue,
+ actualClass = actualInterceptorValue::class.qualifiedName,
+ expectedClass = expectedClass.qualifiedName,
+ )
+ }
+
+ if (interceptorValue != null) {
+ value = interceptorValue
+ valueSource = FeatureValueSource.Interceptor(interceptor.name)
+ }
+ }
+
+ logger?.logInfo("Get value '$value' by key '${spec.key}' from '$valueSource'")
+
+ return FeatureValue(valueSource, value)
+ }
+
+ private fun Logger.logUnexpectedValueType(
+ key: String,
+ source: FeatureValueSource,
+ value: Any,
+ actualClass: String?,
+ expectedClass: String?,
+ ) {
+ logWarn(
+ "Unexpected value type for '$key': " +
+ "expected type is '$expectedClass', but " +
+ "value from '$source' " +
+ "is '$value' " +
+ "with type '$actualClass'",
+ )
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun KClass.tryCastOrNull(value: Any?): T? {
+ return if (isInstance(value)) value as T else null
+ }
+}
diff --git a/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/exception/KonfeatureException.kt b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/exception/KonfeatureException.kt
new file mode 100644
index 0000000..90becf0
--- /dev/null
+++ b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/exception/KonfeatureException.kt
@@ -0,0 +1,21 @@
+package com.redmadrobot.konfeature.exception
+
+public sealed class KonfeatureException(messageProvider: () -> String) : Exception(messageProvider.invoke())
+
+public class ConfigNameAlreadyExistException(
+ name: String
+) : KonfeatureException({ "feature config with name '$name' already registered" })
+
+public class KeyDuplicationException(
+ values: List,
+ config: String
+) : KonfeatureException({
+ val duplicatedValues = values.joinToString(separator = ", ", transform = { "'$it'" })
+ "values with keys <$duplicatedValues> are duplicated in config '$config'"
+})
+
+public class NoFeatureConfigException : KonfeatureException({ "No feature config added" })
+
+public class SourceNameAlreadyExistException(
+ name: String
+) : KonfeatureException({ "source with name '$name' already registered" })
diff --git a/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/FeatureSource.kt b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/FeatureSource.kt
new file mode 100644
index 0000000..2d7ed51
--- /dev/null
+++ b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/FeatureSource.kt
@@ -0,0 +1,8 @@
+package com.redmadrobot.konfeature.source
+
+public interface FeatureSource {
+
+ public val name: String
+
+ public fun get(key: String): Any?
+}
diff --git a/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/FeatureValueSource.kt b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/FeatureValueSource.kt
new file mode 100644
index 0000000..6148383
--- /dev/null
+++ b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/FeatureValueSource.kt
@@ -0,0 +1,17 @@
+package com.redmadrobot.konfeature.source
+
+import dev.drewhamilton.poko.Poko
+
+public sealed class FeatureValueSource {
+
+ @Poko
+ public class Source(public val name: String) : FeatureValueSource()
+
+ @Poko
+ public class Interceptor(public val name: String) : FeatureValueSource()
+
+ @Suppress("ConvertObjectToDataObject")
+ public object Default : FeatureValueSource() {
+ override fun toString(): String = "Default"
+ }
+}
diff --git a/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/Interceptor.kt b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/Interceptor.kt
new file mode 100644
index 0000000..f05170d
--- /dev/null
+++ b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/Interceptor.kt
@@ -0,0 +1,8 @@
+package com.redmadrobot.konfeature.source
+
+public interface Interceptor {
+
+ public val name: String
+
+ public fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any?
+}
diff --git a/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/SourceSelectionStrategy.kt b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/SourceSelectionStrategy.kt
new file mode 100644
index 0000000..db127c3
--- /dev/null
+++ b/konfeature/src/commonMain/kotlin/com/redmadrobot/konfeature/source/SourceSelectionStrategy.kt
@@ -0,0 +1,13 @@
+package com.redmadrobot.konfeature.source
+
+public fun interface SourceSelectionStrategy {
+
+ public fun select(names: Set): Set
+
+ public companion object {
+ public val None: SourceSelectionStrategy = SourceSelectionStrategy { emptySet() }
+ public val Any: SourceSelectionStrategy = SourceSelectionStrategy { it }
+
+ public fun anyOf(vararg sources: String): SourceSelectionStrategy = SourceSelectionStrategy { sources.toSet() }
+ }
+}
diff --git a/konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/KonfeatureTest.kt b/konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/KonfeatureTest.kt
new file mode 100644
index 0000000..db9a6e7
--- /dev/null
+++ b/konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/KonfeatureTest.kt
@@ -0,0 +1,252 @@
+package com.redmadrobot.konfeature
+
+import com.redmadrobot.konfeature.builder.konfeature
+import com.redmadrobot.konfeature.helper.TestFeatureConfig
+import com.redmadrobot.konfeature.helper.createTestSource
+import com.redmadrobot.konfeature.source.FeatureValueSource
+import com.redmadrobot.konfeature.source.Interceptor
+import com.redmadrobot.konfeature.source.SourceSelectionStrategy
+import io.kotest.assertions.assertSoftly
+import io.kotest.matchers.shouldBe
+import kotlin.test.Test
+
+class KonfeatureTest {
+
+ @Test
+ fun `when correct config added - then code should pass`() {
+ val featureConfig = TestFeatureConfig()
+
+ konfeature {
+ register(featureConfig)
+ }
+ }
+
+ @Test
+ fun `when correct config added - should be correct spec`() {
+ // GIVEN
+ val sourceNames = listOf("Test Source 1", "Test Source 2", "Test Source 3")
+ val selectedSource = sourceNames[2]
+
+ val featureConfig = TestFeatureConfig(
+ cSourceSelectionStrategy = SourceSelectionStrategy.anyOf(selectedSource),
+ )
+
+ // WHEN
+ val toggleEase = konfeature {
+ register(featureConfig)
+ }
+
+ // THEN
+ toggleEase.spec.size shouldBe 1
+
+ val config = toggleEase.spec.first()
+
+ config.name shouldBe featureConfig.name
+ config.description shouldBe featureConfig.description
+ config.values.size shouldBe 3
+
+ assertSoftly(config) {
+ values[0].apply {
+ key shouldBe "a"
+ description shouldBe "feature a desc"
+ defaultValue shouldBe true
+ sourceSelectionStrategy.select(sourceNames.toSet()).size shouldBe sourceNames.size
+ }
+
+ values[1].apply {
+ key shouldBe "b"
+ description shouldBe "feature b desc"
+ defaultValue shouldBe true
+ sourceSelectionStrategy.select(sourceNames.toSet()).size shouldBe 0
+ }
+
+ values[2].apply {
+ key shouldBe "c"
+ description shouldBe "feature c desc"
+ defaultValue shouldBe "feature c"
+ sourceSelectionStrategy.select(sourceNames.toSet()).first() shouldBe selectedSource
+ }
+ }
+ }
+
+ @Test
+ fun `when source have value - config should return it`() {
+ // GIVEN
+ val source = createTestSource(
+ name = "Test source",
+ values = mapOf("a" to false),
+ )
+ val featureConfig = TestFeatureConfig()
+
+ konfeature {
+ addSource(source)
+ register(featureConfig)
+ }
+
+ // WHEN
+ val a = featureConfig.a
+
+ // THEN
+ a shouldBe false
+ }
+
+ @Test
+ fun `when source don't have value - config should return default value`() {
+ // GIVEN
+ val source = createTestSource(
+ name = "Test source",
+ values = mapOf("b" to false),
+ )
+ val featureConfig = TestFeatureConfig()
+
+ konfeature {
+ addSource(source)
+ register(featureConfig)
+ }
+
+ // WHEN
+ val a = featureConfig.a
+
+ // THEN
+ a shouldBe true
+ }
+
+ @Test
+ fun `when source have value with unexpected type - config should return default value`() {
+ // GIVEN
+ val source = createTestSource(
+ name = "Test source",
+ values = mapOf("a" to 5),
+ )
+ val featureConfig = TestFeatureConfig()
+
+ konfeature {
+ addSource(source)
+ register(featureConfig)
+ }
+
+ // WHEN
+ val a = featureConfig.a
+
+ // THEN
+ a shouldBe true
+ }
+
+ @Test
+ fun `when both sources contain same key - config should return value of first added source`() {
+ // GIVEN
+ val source1 = createTestSource(
+ name = "Test source 1",
+ values = mapOf("a" to false),
+ )
+ val source2 = createTestSource(
+ name = "Test source 2",
+ values = mapOf("a" to true),
+ )
+
+ val featureConfig = TestFeatureConfig()
+
+ // WHEN
+ konfeature {
+ addSource(source1)
+ addSource(source2)
+ register(featureConfig)
+ }
+
+ val a = featureConfig.a
+
+ // THEN
+ a shouldBe false
+ }
+
+ @Test
+ fun `when source specified by SourceSelectionStrategy - config should return value from it`() {
+ // GIVEN
+ val source1 = createTestSource(
+ name = "Test source 1",
+ values = mapOf("c" to "test_source_1_c"),
+ )
+ val source2 = createTestSource(
+ name = "Test source 2",
+ values = mapOf("c" to "test_source_2_c"),
+ )
+
+ val featureConfig = TestFeatureConfig(
+ cSourceSelectionStrategy = SourceSelectionStrategy.anyOf(source2.name),
+ )
+
+ // WHEN
+ konfeature {
+ addSource(source1)
+ addSource(source2)
+ register(featureConfig)
+ }
+
+ val c = featureConfig.c
+
+ // THEN
+ c shouldBe "test_source_2_c"
+ }
+
+ @Test
+ fun `when value changed by interceptor - config should return it`() {
+ // GIVEN
+ val source = createTestSource(
+ name = "Test source",
+ values = mapOf(
+ "a" to false,
+ "b" to true,
+ "c" to "test_source_1_c",
+ ),
+ )
+
+ val interceptedValue = "intercepted_value_c"
+
+ val interceptor = object : Interceptor {
+ override val name: String = "test interceptor"
+
+ override fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? {
+ return if (key == "c") interceptedValue else null
+ }
+ }
+
+ val featureConfig = TestFeatureConfig()
+
+ // WHEN
+ konfeature {
+ addSource(source)
+ addInterceptor(interceptor)
+ register(featureConfig)
+ }
+
+ // THEN
+ assertSoftly(featureConfig) {
+ a shouldBe false
+ b shouldBe true
+ c shouldBe interceptedValue
+ }
+ }
+
+ @Test
+ fun `when value changed by interceptor but has unexpected type - config should return default`() {
+ // GIVEN
+ val interceptor = object : Interceptor {
+ override val name: String = "test interceptor"
+
+ override fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? {
+ return if (key == "c") 100 else null
+ }
+ }
+
+ val featureConfig = TestFeatureConfig()
+
+ // WHEN
+ konfeature {
+ addInterceptor(interceptor)
+ register(featureConfig)
+ }
+
+ // THEN
+ featureConfig.c shouldBe "feature c"
+ }
+}
diff --git a/konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/builder/KonfeatureBuilderTest.kt b/konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/builder/KonfeatureBuilderTest.kt
new file mode 100644
index 0000000..b6fce2a
--- /dev/null
+++ b/konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/builder/KonfeatureBuilderTest.kt
@@ -0,0 +1,64 @@
+package com.redmadrobot.konfeature.builder
+
+import com.redmadrobot.konfeature.exception.ConfigNameAlreadyExistException
+import com.redmadrobot.konfeature.exception.KeyDuplicationException
+import com.redmadrobot.konfeature.exception.NoFeatureConfigException
+import com.redmadrobot.konfeature.exception.SourceNameAlreadyExistException
+import com.redmadrobot.konfeature.helper.TestFeatureConfig
+import com.redmadrobot.konfeature.helper.createEmptyFeatureConfig
+import com.redmadrobot.konfeature.helper.createTestSource
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.shouldBe
+import kotlin.test.Test
+
+class KonfeatureBuilderTest {
+
+ @Test
+ fun `when no any feature config registered - should throw exception`() {
+ shouldThrow {
+ KonfeatureBuilder().build()
+ }
+ }
+
+ @Test
+ fun `when config with duplicated keys added - should throw exception`() {
+ val featureConfig = TestFeatureConfig(withDuplicates = true)
+
+ val exception = shouldThrow {
+ KonfeatureBuilder().register(featureConfig).build()
+ }
+
+ exception.message shouldBe "values with keys <'a'> are duplicated in config '${featureConfig.name}'"
+ }
+
+ @Test
+ fun `when source with same name added twice - should throw exception`() {
+ val featureConfigName = "Test Feature Config"
+
+ val sourceName = "Test Source"
+
+ val exception = shouldThrow {
+ konfeature {
+ addSource(createTestSource(sourceName))
+ addSource(createTestSource(sourceName))
+ register(createEmptyFeatureConfig(featureConfigName))
+ }
+ }
+
+ exception.message shouldBe "source with name '$sourceName' already registered"
+ }
+
+ @Test
+ fun `when feature config with same name registered twice - should throw exception`() {
+ val featureConfigName = "Test Feature Config"
+
+ val exception = shouldThrow {
+ konfeature {
+ register(createEmptyFeatureConfig(featureConfigName))
+ register(createEmptyFeatureConfig(featureConfigName))
+ }
+ }
+
+ exception.message shouldBe "feature config with name '$featureConfigName' already registered"
+ }
+}
diff --git a/konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/helper/KonfeatureTestHelper.kt b/konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/helper/KonfeatureTestHelper.kt
new file mode 100644
index 0000000..1b446e4
--- /dev/null
+++ b/konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/helper/KonfeatureTestHelper.kt
@@ -0,0 +1,23 @@
+package com.redmadrobot.konfeature.helper
+
+import com.redmadrobot.konfeature.FeatureConfig
+import com.redmadrobot.konfeature.source.FeatureSource
+
+fun createTestSource(
+ name: String,
+ values: Map = emptyMap(),
+): FeatureSource {
+ return object : FeatureSource {
+
+ override val name: String = name
+
+ override fun get(key: String): Any? = values[key]
+ }
+}
+
+fun createEmptyFeatureConfig(
+ name: String,
+ description: String = "test description for $name",
+): FeatureConfig {
+ return object : FeatureConfig(name = name, description = description) {}
+}
diff --git a/konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/helper/TestFeatureConfig.kt b/konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/helper/TestFeatureConfig.kt
new file mode 100644
index 0000000..9fc51d8
--- /dev/null
+++ b/konfeature/src/commonTest/kotlin/com/redmadrobot/konfeature/helper/TestFeatureConfig.kt
@@ -0,0 +1,33 @@
+package com.redmadrobot.konfeature.helper
+
+import com.redmadrobot.konfeature.FeatureConfig
+import com.redmadrobot.konfeature.source.SourceSelectionStrategy
+
+class TestFeatureConfig(
+ withDuplicates: Boolean = false,
+ cSourceSelectionStrategy: SourceSelectionStrategy = SourceSelectionStrategy.Any,
+) : FeatureConfig(
+ name = "TestFeatureConfig",
+ description = "TestFeatureConfig description",
+) {
+ val a by toggle(
+ key = "a",
+ description = "feature a desc",
+ defaultValue = true,
+ sourceSelectionStrategy = SourceSelectionStrategy.Any,
+ )
+
+ val b by toggle(
+ key = if (withDuplicates) "a" else "b",
+ description = "feature b desc",
+ defaultValue = true,
+ sourceSelectionStrategy = SourceSelectionStrategy.None,
+ )
+
+ val c: String by value(
+ key = if (withDuplicates) "a" else "c",
+ description = "feature c desc",
+ defaultValue = "feature c",
+ sourceSelectionStrategy = cSourceSelectionStrategy,
+ )
+}
diff --git a/konfeature/src/main/kotlin/Konfeature.kt b/konfeature/src/main/kotlin/Konfeature.kt
deleted file mode 100644
index da970b3..0000000
--- a/konfeature/src/main/kotlin/Konfeature.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.redmadrobot.konfeature
-
-public interface Konfeature
diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts
new file mode 100644
index 0000000..f7b36d3
--- /dev/null
+++ b/sample/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+ kotlin("jvm")
+ application
+ convention.detekt
+}
+dependencies {
+ implementation(projects.konfeature)
+}
+
+application {
+ mainClass = "com.redmadrobot.konfeature.sample.AppKt"
+}
diff --git a/sample/src/main/kotlin/com/redmadrobot/konfeature/sample/App.kt b/sample/src/main/kotlin/com/redmadrobot/konfeature/sample/App.kt
new file mode 100644
index 0000000..5c1a944
--- /dev/null
+++ b/sample/src/main/kotlin/com/redmadrobot/konfeature/sample/App.kt
@@ -0,0 +1,47 @@
+package com.redmadrobot.konfeature.sample
+
+import com.redmadrobot.konfeature.Logger
+import com.redmadrobot.konfeature.builder.konfeature
+
+fun main() {
+ val featureConfig = SampleFeatureConfig()
+
+ val debugPanelInterceptor = FeatureToggleDebugPanelInterceptor()
+
+ val logger = object : Logger {
+ override fun log(severity: Logger.Severity, message: String) {
+ println("${severity.name}: $message")
+ }
+ }
+
+ val konfeature = konfeature {
+ addSource(RemoteFeatureSource())
+ addSource(FirebaseFeatureSource())
+ register(featureConfig)
+ addInterceptor(debugPanelInterceptor)
+ setLogger(logger)
+ }
+
+ konfeature.spec.forEach {
+ println("Spec: --name: '${it.name}', description: '${it.description}'")
+ it.values.forEach(::println)
+ }
+
+ println()
+ val spec = konfeature.spec.first().values.first()
+ println("getFeatureValue('${spec.key}') -> ${konfeature.getValue(spec)}")
+
+ println()
+ println("feature1: " + featureConfig.isFeature1Enabled)
+ println("feature2: " + featureConfig.isFeature2Enabled)
+ println("feature3: " + featureConfig.isFeature3Enabled)
+ println("velocity: " + featureConfig.velocity)
+ println("puhFeature: " + featureConfig.puhFeature)
+
+ debugPanelInterceptor.setFeatureValue("feature2", false)
+ println()
+ println("debugPanelInterceptor.setFeatureValue(\"feature2\", false)")
+ println("feature1: " + featureConfig.isFeature1Enabled)
+ println("feature2: " + featureConfig.isFeature2Enabled)
+ println("feature3: " + featureConfig.isFeature3Enabled)
+}
diff --git a/sample/src/main/kotlin/com/redmadrobot/konfeature/sample/FeatureToggleDebugPanelInterceptor.kt b/sample/src/main/kotlin/com/redmadrobot/konfeature/sample/FeatureToggleDebugPanelInterceptor.kt
new file mode 100644
index 0000000..6b3ca75
--- /dev/null
+++ b/sample/src/main/kotlin/com/redmadrobot/konfeature/sample/FeatureToggleDebugPanelInterceptor.kt
@@ -0,0 +1,19 @@
+package com.redmadrobot.konfeature.sample
+
+import com.redmadrobot.konfeature.source.FeatureValueSource
+import com.redmadrobot.konfeature.source.Interceptor
+
+class FeatureToggleDebugPanelInterceptor : Interceptor {
+
+ private val values = mutableMapOf()
+
+ override val name: String = "DebugPanelInterceptor"
+
+ override fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? {
+ return values[key]
+ }
+
+ fun setFeatureValue(key: String, value: Any) {
+ values[key] = value
+ }
+}
diff --git a/sample/src/main/kotlin/com/redmadrobot/konfeature/sample/SampleFeatureConfig.kt b/sample/src/main/kotlin/com/redmadrobot/konfeature/sample/SampleFeatureConfig.kt
new file mode 100644
index 0000000..9555209
--- /dev/null
+++ b/sample/src/main/kotlin/com/redmadrobot/konfeature/sample/SampleFeatureConfig.kt
@@ -0,0 +1,59 @@
+package com.redmadrobot.konfeature.sample
+
+import com.redmadrobot.konfeature.FeatureConfig
+import com.redmadrobot.konfeature.source.SourceSelectionStrategy
+
+class SampleFeatureConfig : FeatureConfig(
+ name = "Sample",
+ description = "simple sample set"
+) {
+
+ val isFeature1Enabled: Boolean by toggle(
+ key = "feature1",
+ description = "feature1 desc",
+ defaultValue = false,
+ )
+
+ val isFeature2Enabled: Boolean by toggle(
+ key = "feature2",
+ description = "feature2 desc",
+ defaultValue = true,
+ sourceSelectionStrategy = SourceSelectionStrategy.Any
+ )
+
+ val isFeature3Enabled: Boolean by toggle(
+ key = "feature3",
+ description = "feature3 desc",
+ defaultValue = false,
+ sourceSelectionStrategy = SourceSelectionStrategy.Any
+ )
+
+ val velocity: Long by value(
+ key = "velocity_value",
+ description = "velocity value",
+ defaultValue = 90,
+ sourceSelectionStrategy = SourceSelectionStrategy.Any
+ )
+
+ val isGroupFeatureEnable: Boolean
+ get() = isFeature1Enabled && isFeature3Enabled
+
+ val feature4: String by value(
+ key = "feature4",
+ description = "feature4 desc",
+ defaultValue = "true",
+ sourceSelectionStrategy = SourceSelectionStrategy.Any
+ )
+
+ enum class PUH { A, B, C }
+
+ private val _puhFeature by value(
+ key = "puhFeature",
+ description = "puhFeature desc",
+ defaultValue = PUH.B.name,
+ sourceSelectionStrategy = SourceSelectionStrategy.Any
+ )
+
+ val puhFeature: PUH
+ get() = _puhFeature.let { PUH.valueOf(it) }
+}
diff --git a/sample/src/main/kotlin/com/redmadrobot/konfeature/sample/SampleSources.kt b/sample/src/main/kotlin/com/redmadrobot/konfeature/sample/SampleSources.kt
new file mode 100644
index 0000000..2a5995a
--- /dev/null
+++ b/sample/src/main/kotlin/com/redmadrobot/konfeature/sample/SampleSources.kt
@@ -0,0 +1,32 @@
+package com.redmadrobot.konfeature.sample
+
+import com.redmadrobot.konfeature.source.FeatureSource
+
+class RemoteFeatureSource : FeatureSource {
+
+ private val store = mutableMapOf().apply {
+ put("feature1", false)
+ put("feature3", true)
+ put("velocity_value", true)
+ }
+
+ override val name: String = "RemoteFeatureToggleSource"
+
+ override fun get(key: String): Any? {
+ return store[key]
+ }
+}
+
+class FirebaseFeatureSource : FeatureSource {
+
+ private val store = mutableMapOf().apply {
+ put("feature2", true)
+ put("puhFeature", "C")
+ }
+
+ override val name: String = "FirebaseFeatureToggleSource"
+
+ override fun get(key: String): Any? {
+ return store[key]
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 25976a8..d1e5c47 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -23,5 +23,6 @@ dependencyResolutionManagement {
rootProject.name = "konfeature-root"
include(
- "konfeature",
+ ":sample",
+ ":konfeature",
)