From 825c4d46637675bd2dc9e207cec6aacde1f89f60 Mon Sep 17 00:00:00 2001
From: Kevin Cianfarini <kevin.cianfarini@octoenergy.com>
Date: Wed, 6 Mar 2024 08:34:42 -0500
Subject: [PATCH] Add POSIX environment variable integration (#48)

---
 .../environment-variable/build.gradle.kts     |  26 ++++
 .../environment-variable/gradle.properties    |   3 +
 ...EnvironmentVariableFeatureFlagDataStore.kt |  63 +++++++++
 .../monarch/environment/environment.kt        |   3 +
 ...ronmentVariableFeatureFlagDataStoreTest.kt | 131 ++++++++++++++++++
 .../monarch/environment/environment.jvm.kt    |   5 +
 .../monarch/environment/environment.native.kt |  10 ++
 settings.gradle.kts                           |   1 +
 8 files changed, 242 insertions(+)
 create mode 100644 integrations/environment-variable/build.gradle.kts
 create mode 100644 integrations/environment-variable/gradle.properties
 create mode 100644 integrations/environment-variable/src/commonMain/kotlin/io/github/kevincianfarini/monarch/environment/EnvironmentVariableFeatureFlagDataStore.kt
 create mode 100644 integrations/environment-variable/src/commonMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.kt
 create mode 100644 integrations/environment-variable/src/commonTest/kotlin/io/github/kevincianfarini/monarch/environment/EnvironmentVariableFeatureFlagDataStoreTest.kt
 create mode 100644 integrations/environment-variable/src/jvmMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.jvm.kt
 create mode 100644 integrations/environment-variable/src/nativeMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.native.kt

diff --git a/integrations/environment-variable/build.gradle.kts b/integrations/environment-variable/build.gradle.kts
new file mode 100644
index 0000000..0f9b629
--- /dev/null
+++ b/integrations/environment-variable/build.gradle.kts
@@ -0,0 +1,26 @@
+plugins {
+    alias(libs.plugins.dokka)
+    alias(libs.plugins.kotlin.multiplatform)
+    alias(libs.plugins.publish)
+}
+
+kotlin {
+
+    explicitApi()
+
+    jvm()
+    linuxArm64()
+    linuxX64()
+    macosArm64()
+    macosX64()
+    mingwX64()
+
+    sourceSets {
+        commonMain.dependencies {
+            api(project(":core"))
+        }
+        commonTest.dependencies {
+            implementation(libs.kotlin.test)
+        }
+    }
+}
\ No newline at end of file
diff --git a/integrations/environment-variable/gradle.properties b/integrations/environment-variable/gradle.properties
new file mode 100644
index 0000000..f54102e
--- /dev/null
+++ b/integrations/environment-variable/gradle.properties
@@ -0,0 +1,3 @@
+POM_ARTIFACT_ID=environment-variable-integration
+POM_NAME=Monarch System Environment Variable Integration
+POM_DESCRIPTION=Multiplatform integration with environment variables
\ No newline at end of file
diff --git a/integrations/environment-variable/src/commonMain/kotlin/io/github/kevincianfarini/monarch/environment/EnvironmentVariableFeatureFlagDataStore.kt b/integrations/environment-variable/src/commonMain/kotlin/io/github/kevincianfarini/monarch/environment/EnvironmentVariableFeatureFlagDataStore.kt
new file mode 100644
index 0000000..225181b
--- /dev/null
+++ b/integrations/environment-variable/src/commonMain/kotlin/io/github/kevincianfarini/monarch/environment/EnvironmentVariableFeatureFlagDataStore.kt
@@ -0,0 +1,63 @@
+package io.github.kevincianfarini.monarch.environment
+
+import io.github.kevincianfarini.monarch.FeatureFlagDataStore
+
+/**
+ * A [FeatureFlagDataStore] implementation that provides values from environment variables.
+ */
+public class EnvironmentVariableFeatureFlagDataStore internal constructor(
+    private val strictlyTyped: Boolean = true,
+    private val getEnvironmentVariable: (String) -> String?,
+) : FeatureFlagDataStore {
+
+    /**
+     * Create an [EnvironmentVariableFeatureFlagDataStore] which reads from the environment.
+     * If [strictlyTyped] is true, this store will throw exceptions when the raw string value
+     * of the environment variable cannot be coerced to a specific type. Otherwise, this store
+     * will return the default value.
+     */
+    public constructor(strictlyTyped: Boolean = true) : this(strictlyTyped, ::getSystemEnvVar)
+
+    override fun getBoolean(key: String, default: Boolean): Boolean {
+        val env = getEnvironmentVariable(key)
+        val boolean = if (strictlyTyped) env?.toBooleanStrict() else env?.toBooleanStrictOrNull()
+        return boolean ?: default
+    }
+
+    override fun getString(key: String, default: String): String {
+        return getEnvironmentVariable(key) ?: default
+    }
+
+    override fun getDouble(key: String, default: Double): Double {
+        val env = getEnvironmentVariable(key)
+        val double = if (strictlyTyped) env?.toDouble() else env?.toDoubleOrNull()
+        return double ?: default
+    }
+
+    override fun getLong(key: String, default: Long): Long {
+        val env = getEnvironmentVariable(key)
+        val long = if (strictlyTyped) env?.toLong() else env?.toLongOrNull()
+        return long ?: default
+    }
+
+    override fun getByteArray(key: String, default: ByteArray): ByteArray {
+        val env = getEnvironmentVariable(key)
+        val bytes = if (strictlyTyped) env?.decodeHexToByteArray() else env?.decodeHexToByteArrayOrNull()
+        return bytes ?: default
+    }
+}
+
+private fun String.decodeHexToByteArrayOrNull(): ByteArray? = takeIf { it.length % 2 == 0 }?.let { string ->
+    ByteArray(string.length / 2) { index ->
+        val startIndex = index * 2
+        val endIndex = startIndex + 1
+        val byteString = string.substring(startIndex, endIndex + 1)
+        byteString.toByte(16)
+    }
+}
+
+private fun String.decodeHexToByteArray(): ByteArray {
+    return requireNotNull(decodeHexToByteArrayOrNull()) {
+        "The input string $this is not a valid hex string."
+    }
+}
\ No newline at end of file
diff --git a/integrations/environment-variable/src/commonMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.kt b/integrations/environment-variable/src/commonMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.kt
new file mode 100644
index 0000000..f681d14
--- /dev/null
+++ b/integrations/environment-variable/src/commonMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.kt
@@ -0,0 +1,3 @@
+package io.github.kevincianfarini.monarch.environment
+
+internal expect fun getSystemEnvVar(key: String): String?
\ No newline at end of file
diff --git a/integrations/environment-variable/src/commonTest/kotlin/io/github/kevincianfarini/monarch/environment/EnvironmentVariableFeatureFlagDataStoreTest.kt b/integrations/environment-variable/src/commonTest/kotlin/io/github/kevincianfarini/monarch/environment/EnvironmentVariableFeatureFlagDataStoreTest.kt
new file mode 100644
index 0000000..98eeb78
--- /dev/null
+++ b/integrations/environment-variable/src/commonTest/kotlin/io/github/kevincianfarini/monarch/environment/EnvironmentVariableFeatureFlagDataStoreTest.kt
@@ -0,0 +1,131 @@
+package io.github.kevincianfarini.monarch.environment
+
+import kotlin.test.*
+
+class EnvironmentVariableFeatureFlagDataStoreTest {
+
+    @Test
+    fun strictly_typed_boolean_returns_value() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "true" }
+        assertTrue(store.getBoolean(key = "key", default = false))
+    }
+
+    @Test
+    fun strictly_typed_boolean_fails() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "tRuE" }
+        assertFailsWith<IllegalArgumentException> {
+            store.getBoolean(key = "key", default = false)
+        }
+    }
+
+    @Test
+    fun loosely_typed_boolean_returns_default_on_failure() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = false) { "tRuE" }
+        assertFalse(store.getBoolean(key = "key", default = false))
+    }
+
+    @Test
+    fun no_underlying_value_boolean_returns_default() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = false) { null }
+        assertFalse(store.getBoolean(key = "key", default = false))
+    }
+
+    @Test
+    fun string_returns_environment_variable() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "some string" }
+        assertEquals("some string", store.getString(key = "key", default = "default"))
+    }
+
+    @Test
+    fun string_returns_default_when_no_environment_variable() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { null }
+        assertEquals("default", store.getString(key = "key", default = "default"))
+    }
+
+    @Test
+    fun strictly_typed_double_returns_value() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "1.2" }
+        assertEquals(1.2, store.getDouble(key = "key", default = 0.0))
+    }
+
+    @Test
+    fun strictly_typed_double_fails() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "Not a number" }
+        assertFailsWith<NumberFormatException> {
+            store.getDouble(key = "key", default = 0.0)
+        }
+    }
+
+    @Test
+    fun loosely_typed_double_returns_default_on_failure() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = false) { "Not a number" }
+        assertEquals(0.0, store.getDouble(key = "key", default = 0.0))
+    }
+
+    @Test
+    fun no_underlying_value_double_returns_default() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { null }
+        assertEquals(0.0, store.getDouble(key = "key", default = 0.0))
+    }
+
+    @Test
+    fun strictly_typed_long_returns_value() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "1000" }
+        assertEquals(1000, store.getLong(key = "key", default = 0))
+    }
+
+    @Test
+    fun strictly_typed_long_fails() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "Not a number" }
+        assertFailsWith<NumberFormatException> {
+            store.getLong(key = "key", default = 0)
+        }
+    }
+
+    @Test
+    fun loosely_typed_long_returns_default_on_failure() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = false) { "Not a number" }
+        assertEquals(0, store.getLong(key = "key", default = 0))
+    }
+
+    @Test
+    fun no_underlying_value_long_returns_default() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { null }
+        assertEquals(0, store.getLong(key = "key", default = 0))
+    }
+
+    @Test
+    fun strictly_typed_byte_array_returns_value() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "0f00" }
+        assertContentEquals(
+            byteArrayOf(0x0f, 0x00),
+            store.getByteArray(key = "key", default = byteArrayOf())
+        )
+    }
+
+    @Test
+    fun strictly_typed_byte_array_fails() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "0f0" }
+        assertFailsWith<IllegalArgumentException> {
+            store.getByteArray(key = "key", default = byteArrayOf())
+        }
+    }
+
+    @Test
+    fun loosely_typed_byte_array_returns_default_on_failure() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = false) { "0f0" }
+        assertContentEquals(
+            byteArrayOf(),
+            store.getByteArray(key = "key", default = byteArrayOf())
+        )
+    }
+
+    @Test
+    fun no_underlying_value_byte_array_returns_default() {
+        val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { null }
+        assertContentEquals(
+            byteArrayOf(),
+            store.getByteArray(key = "key", default = byteArrayOf())
+        )
+    }
+}
\ No newline at end of file
diff --git a/integrations/environment-variable/src/jvmMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.jvm.kt b/integrations/environment-variable/src/jvmMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.jvm.kt
new file mode 100644
index 0000000..1f720cf
--- /dev/null
+++ b/integrations/environment-variable/src/jvmMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.jvm.kt
@@ -0,0 +1,5 @@
+package io.github.kevincianfarini.monarch.environment
+
+internal actual fun getSystemEnvVar(key: String): String? {
+    return System.getenv(key)
+}
\ No newline at end of file
diff --git a/integrations/environment-variable/src/nativeMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.native.kt b/integrations/environment-variable/src/nativeMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.native.kt
new file mode 100644
index 0000000..1f6a125
--- /dev/null
+++ b/integrations/environment-variable/src/nativeMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.native.kt
@@ -0,0 +1,10 @@
+package io.github.kevincianfarini.monarch.environment
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.toKString
+import platform.posix.getenv
+
+@OptIn(ExperimentalForeignApi::class)
+internal actual fun getSystemEnvVar(key: String): String? {
+    return getenv(key)?.toKString()
+}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 1c9be91..b355e88 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -11,6 +11,7 @@ rootProject.name = "monarch"
 include(":compose")
 include(":core")
 include(":integrations")
+include(":integrations:environment-variable")
 include(":integrations:launch-darkly")
 include(":mixins")
 include(":mixins:kotlinx-serialization-json")