diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 7ebcc312..4e99cb31 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -37,6 +37,8 @@ public final class org/dexpace/sdk/core/config/Configuration { public final fun getInt (Ljava/lang/String;I)I public final fun getProperty (Ljava/lang/String;)Ljava/lang/String; public static final fun setGlobalConfiguration (Lorg/dexpace/sdk/core/config/Configuration;)V + public final fun toBuilder ()Lorg/dexpace/sdk/core/config/ConfigurationBuilder; + public final fun withOptions (Ljava/util/function/Consumer;)Lorg/dexpace/sdk/core/config/Configuration; } public final class org/dexpace/sdk/core/config/Configuration$Companion { @@ -44,9 +46,11 @@ public final class org/dexpace/sdk/core/config/Configuration$Companion { public final fun setGlobalConfiguration (Lorg/dexpace/sdk/core/config/Configuration;)V } -public final class org/dexpace/sdk/core/config/ConfigurationBuilder { +public final class org/dexpace/sdk/core/config/ConfigurationBuilder : org/dexpace/sdk/core/generics/Builder { public fun ()V - public final fun build ()Lorg/dexpace/sdk/core/config/Configuration; + public fun (Lorg/dexpace/sdk/core/config/Configuration;)V + public synthetic fun build ()Ljava/lang/Object; + public fun build ()Lorg/dexpace/sdk/core/config/Configuration; public final fun envSource (Ljava/util/function/Function;)Lorg/dexpace/sdk/core/config/ConfigurationBuilder; public final fun propsSource (Ljava/util/function/Function;)Lorg/dexpace/sdk/core/config/ConfigurationBuilder; public final fun put (Ljava/lang/String;Ljava/lang/String;)Lorg/dexpace/sdk/core/config/ConfigurationBuilder; diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Configuration.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Configuration.kt index 321de182..c5dfaa5d 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Configuration.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Configuration.kt @@ -9,6 +9,7 @@ package org.dexpace.sdk.core.config import java.time.Duration import java.util.Locale +import java.util.function.Consumer import java.util.function.Function /** @@ -23,6 +24,23 @@ import java.util.function.Function * * Constructed via [ConfigurationBuilder]. * + * ## Deriving a reconfigured copy (copy-on-write) + * [withOptions] returns a **new** immutable [Configuration] with a mutator applied on top of this + * one, leaving the receiver untouched: + * + * ```java + * Configuration base = new ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build(); + * Configuration derived = base.withOptions(b -> b.put("LOG_LEVEL", "DEBUG")); + * // base is unchanged; derived carries both overrides. + * ``` + * + * The derivation is copy-on-write in the value sense: the override map is copied so the original and + * the derived instance never alias the same mutable state, while the [envSource]/[propsSource] + * lookup functions are shared by reference (they are pure read seams and are never mutated). A + * mutator that touches no override and replaces no source produces an independent instance equal in + * behaviour to the original. [toBuilder] exposes the same prefilled builder for callers that prefer + * to thread it through other builder-folding code before [ConfigurationBuilder.build]. + * * ## Thread-safety * Instances are immutable once built (the override map is copied) and safe to share across threads. * The process-wide global slot is published via `@Volatile`; readers observe the most-recently-set @@ -33,6 +51,39 @@ public class Configuration internal constructor( private val envSource: Function = Function { name -> System.getenv(name) }, private val propsSource: Function = Function { name -> System.getProperty(name) }, ) { + /** A defensive copy of the explicit overrides, for prefilling a derived [ConfigurationBuilder]. */ + internal fun overridesSnapshot(): Map = overrides.toMap() + + /** The environment-variable lookup seam, shared by reference into derived builders. */ + internal fun envSource(): Function = envSource + + /** The system-property lookup seam, shared by reference into derived builders. */ + internal fun propsSource(): Function = propsSource + + /** + * Returns a fresh [ConfigurationBuilder] preloaded with this instance's overrides and lookup + * sources. Mutating the returned builder never affects this [Configuration]; the override map is + * copied up front. Use this when you want to thread the builder through other configuration code + * before calling [ConfigurationBuilder.build]; prefer [withOptions] for the common + * derive-in-one-call case. + */ + public fun toBuilder(): ConfigurationBuilder = ConfigurationBuilder(this) + + /** + * Derive a new immutable [Configuration] by applying [mutator] to a builder prefilled from this + * instance, then building it. This [Configuration] is left unchanged (copy-on-write): the + * override map is copied before [mutator] runs, so overrides added or replaced inside [mutator] + * never leak back into the receiver. The env/property lookup seams are inherited by reference. + * + * Kotlin's compiler-generated non-null parameter check raises `NullPointerException` when a Java + * caller passes `null` for [mutator], so no explicit guard is needed here. + */ + public fun withOptions(mutator: Consumer): Configuration { + val builder = toBuilder() + mutator.accept(builder) + return builder.build() + } + /** * Look up a configuration value by [name]. * diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/ConfigurationBuilder.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/ConfigurationBuilder.kt index 7050ba18..eea414e9 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/ConfigurationBuilder.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/ConfigurationBuilder.kt @@ -7,6 +7,7 @@ package org.dexpace.sdk.core.config +import org.dexpace.sdk.core.generics.Builder import java.util.function.Function /** @@ -14,15 +15,33 @@ import java.util.function.Function * system properties), and [envSource] / [propsSource] as test seams to substitute the env / property * lookups with hermetic functions. * + * Implements the generic [Builder] contract so it can be folded by builder-style configuration code. + * Construct empty for a configuration built from scratch, or via [Configuration.toBuilder] / + * [Configuration.withOptions] to derive a reconfigured copy of an existing [Configuration]. + * * ## Thread-safety * Builders are *not* thread-safe — construct, configure, and [build] from a single thread. The * resulting [Configuration] is immutable and safe to share. */ -public class ConfigurationBuilder { +public class ConfigurationBuilder : Builder { private val overrides = mutableMapOf() private var envSource: Function = Function { name -> System.getenv(name) } private var propsSource: Function = Function { name -> System.getProperty(name) } + /** Creates an empty builder with the default env / system-property lookup sources. */ + public constructor() + + /** + * Creates a builder preloaded with [source]'s overrides and lookup sources. The override map is + * copied, so mutating this builder never affects [source]. Used by [Configuration.toBuilder] and + * [Configuration.withOptions]. + */ + public constructor(source: Configuration) { + overrides.putAll(source.overridesSnapshot()) + envSource = source.envSource() + propsSource = source.propsSource() + } + /** * Register an explicit override. Overrides win over every other layer. Kotlin's * compiler-generated non-null parameter check raises `NullPointerException` when a Java @@ -46,5 +65,5 @@ public class ConfigurationBuilder { * Materialize the immutable [Configuration]. The current override map is defensively copied so * subsequent [put] calls on this builder do not mutate the returned configuration. */ - public fun build(): Configuration = Configuration(overrides.toMap(), envSource, propsSource) + override fun build(): Configuration = Configuration(overrides.toMap(), envSource, propsSource) } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/ConfigurationTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/ConfigurationTest.kt index a86efd5f..5bc5b187 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/ConfigurationTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/ConfigurationTest.kt @@ -529,4 +529,85 @@ class ConfigurationTest { method.invoke(null, custom) assertEquals("hit", Configuration.getGlobalConfiguration().get("BRIDGE")) } + + // ----- withOptions / toBuilder (copy-on-write derivation) ----- + + @Test + fun `withOptions adds a new override on the derived configuration`() { + val base = ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build() + val derived = base.withOptions { it.put("LOG_LEVEL", "DEBUG") } + assertEquals("3", derived.get("MAX_RETRY_ATTEMPTS")) + assertEquals("DEBUG", derived.get("LOG_LEVEL")) + } + + @Test + fun `withOptions leaves the original configuration unchanged`() { + val base = ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build() + val derived = base.withOptions { it.put("LOG_LEVEL", "DEBUG") } + // The override added to the derived copy must not leak back into the receiver. + assertNull(base.get("LOG_LEVEL")) + assertEquals("3", base.get("MAX_RETRY_ATTEMPTS")) + // The two instances are distinct objects. + assertFalse(base === derived) + } + + @Test + fun `withOptions can override an existing key without mutating the original`() { + val base = ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build() + val derived = base.withOptions { it.put("MAX_RETRY_ATTEMPTS", "9") } + assertEquals("9", derived.get("MAX_RETRY_ATTEMPTS")) + assertEquals("3", base.get("MAX_RETRY_ATTEMPTS")) + } + + @Test + fun `withOptions inherits the env and property lookup seams`() { + val base = + ConfigurationBuilder() + .envSource { name -> if (name == "MAX_RETRY_ATTEMPTS") "5" else null } + .propsSource { name -> if (name == "log.level") "INFO" else null } + .build() + val derived = base.withOptions { it.put("LOG_LEVEL", "DEBUG") } + // Inherited env seam still resolves on the derived copy. + assertEquals("5", derived.get("MAX_RETRY_ATTEMPTS")) + // Explicit override on the derived copy wins over the inherited property seam. + assertEquals("DEBUG", derived.get("LOG_LEVEL")) + // The base, queried for the same key, still falls through to the property seam. + assertEquals("INFO", base.get("LOG_LEVEL")) + } + + @Test + fun `withOptions with an empty mutator yields an equivalent independent configuration`() { + val base = ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build() + val derived = base.withOptions { /* no-op */ } + assertFalse(base === derived) + assertEquals("3", derived.get("MAX_RETRY_ATTEMPTS")) + } + + @Test + fun `toBuilder prefills overrides and sources and is independent of the source`() { + val base = ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build() + val builder = base.toBuilder() + builder.put("LOG_LEVEL", "DEBUG") + val derived = builder.build() + assertEquals("3", derived.get("MAX_RETRY_ATTEMPTS")) + assertEquals("DEBUG", derived.get("LOG_LEVEL")) + // Mutating the builder after the fact never affects the already-derived configuration + // nor the original. + builder.put("EXTRA", "x") + assertNull(derived.get("EXTRA")) + assertNull(base.get("EXTRA")) + assertNull(base.get("LOG_LEVEL")) + } + + @Test + fun `prefilled builder constructor copies the override map defensively`() { + val base = ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build() + val first = base.toBuilder().put("A", "1").build() + val second = base.toBuilder().put("B", "2").build() + // Two independent derivations from the same base do not see each other's overrides. + assertEquals("1", first.get("A")) + assertNull(first.get("B")) + assertEquals("2", second.get("B")) + assertNull(second.get("A")) + } }