diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index de3a5d14..1c5f1c77 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -829,13 +829,22 @@ public final class org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationO public final class org/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel : java/lang/Enum { public static final field BODY_AND_HEADERS Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel; + public static final field Companion Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel$Companion; public static final field HEADERS Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel; public static final field NONE Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel; + public static final fun fromConfiguration (Ljava/lang/String;Lorg/dexpace/sdk/core/config/Configuration;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel; + public static final fun fromConfiguration (Ljava/lang/String;Lorg/dexpace/sdk/core/config/Configuration;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel; public static fun getEntries ()Lkotlin/enums/EnumEntries; public static fun valueOf (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel; public static fun values ()[Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel; } +public final class org/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel$Companion { + public final fun fromConfiguration (Ljava/lang/String;Lorg/dexpace/sdk/core/config/Configuration;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel; + public final fun fromConfiguration (Ljava/lang/String;Lorg/dexpace/sdk/core/config/Configuration;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel; + public static synthetic fun fromConfiguration$default (Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel$Companion;Ljava/lang/String;Lorg/dexpace/sdk/core/config/Configuration;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel; +} + public final class org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectCondition { public fun (Lorg/dexpace/sdk/core/http/response/Response;ILjava/util/Set;)V public final fun component1 ()Lorg/dexpace/sdk/core/http/response/Response; diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel.kt index 5e75cf26..7ae22378 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel.kt @@ -7,6 +7,9 @@ package org.dexpace.sdk.core.http.pipeline.steps +import org.dexpace.sdk.core.config.Configuration +import java.util.Locale + /** * Granularity of HTTP logging emitted by [InstrumentationStep]. * @@ -30,4 +33,41 @@ public enum class HttpLogLevel { * See [HttpInstrumentationOptions] for the streaming and async-completion-thread caveats. */ BODY_AND_HEADERS, + ; + + public companion object { + /** + * Resolves a log level by looking [key] up in [source]. + * + * [source] is consulted via [Configuration.get], so the value goes through the full + * layering — explicit override -> environment variable -> normalized system property -> + * default. The value may come from any of those layers: the key `MY_PRODUCT_LOG_LEVEL` + * also matches the `my.product.log.level` system property, and an explicit override wins + * over both. + * + * The SDK is a toolkit, not a product, so it deliberately bakes in **no** default key — + * the caller (e.g. a generated client) supplies its own product's variable name, and + * [source] supplies the lookup. Pass a [Configuration] built with + * [org.dexpace.sdk.core.config.ConfigurationBuilder.envSource] to inject a hermetic env + * in tests; in production a [Configuration] backed by `System.getenv` is the natural fit. + * + * Parsing is tolerant: the resolved value is matched against the enum names + * case-insensitively and after trimming surrounding whitespace (so `headers`, + * `HEADERS`, and ` Headers ` all resolve to [HEADERS]). When the key is unset (or set + * to an empty string, which [Configuration] treats as absent) or holds an unrecognized + * value, [default] is returned. [default] itself defaults to [NONE] — logging stays off + * unless explicitly opted into. + */ + @JvmStatic + @JvmOverloads + public fun fromConfiguration( + key: String, + source: Configuration, + default: HttpLogLevel = NONE, + ): HttpLogLevel { + val raw = source.get(key) ?: return default + val name = raw.trim().uppercase(Locale.US) + return entries.firstOrNull { it.name == name } ?: default + } + } } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpLogLevelTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpLogLevelTest.kt new file mode 100644 index 00000000..00b6874b --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpLogLevelTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.pipeline.steps + +import org.dexpace.sdk.core.config.ConfigurationBuilder +import kotlin.test.Test +import kotlin.test.assertEquals + +class HttpLogLevelTest { + /** Builds a [org.dexpace.sdk.core.config.Configuration] whose env layer returns [entries] and nothing else. */ + private fun configWithEnv(vararg entries: Pair) = + ConfigurationBuilder() + .envSource { name -> entries.firstOrNull { it.first == name }?.second } + .propsSource { null } + .build() + + // ----- Known value resolves (case-insensitive) ----- + + @Test + fun `known key resolves to the matching level`() { + val cfg = configWithEnv("MY_PRODUCT_LOG_LEVEL" to "HEADERS") + assertEquals( + HttpLogLevel.HEADERS, + HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.NONE), + ) + } + + @Test + fun `value resolves case-insensitively`() { + val cfg = configWithEnv("MY_PRODUCT_LOG_LEVEL" to "body_and_headers") + assertEquals( + HttpLogLevel.BODY_AND_HEADERS, + HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.NONE), + ) + } + + @Test + fun `value resolves with surrounding whitespace trimmed`() { + val cfg = configWithEnv("MY_PRODUCT_LOG_LEVEL" to " Headers ") + assertEquals( + HttpLogLevel.HEADERS, + HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.NONE), + ) + } + + @Test + fun `each level name round-trips`() { + HttpLogLevel.entries.forEach { level -> + val cfg = configWithEnv("LL" to level.name) + assertEquals(level, HttpLogLevel.fromConfiguration("LL", cfg, HttpLogLevel.NONE)) + } + } + + // ----- Resolution honors the full Configuration layering, not just env ----- + + @Test + fun `value resolves from the system-property layer when the env is unset`() { + // Env unset; the property is looked up under the normalized name (MY_PRODUCT_LOG_LEVEL + // -> my.product.log.level). The resolved value must still feed the level parsing. + val cfg = + ConfigurationBuilder() + .envSource { null } + .propsSource { name -> if (name == "my.product.log.level") "HEADERS" else null } + .build() + assertEquals( + HttpLogLevel.HEADERS, + HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.NONE), + ) + } + + @Test + fun `explicit override wins over a conflicting env value`() { + // Override is the top layer; it must take precedence over the env entry for the same key. + val cfg = + ConfigurationBuilder() + .put("MY_PRODUCT_LOG_LEVEL", "BODY_AND_HEADERS") + .envSource { name -> if (name == "MY_PRODUCT_LOG_LEVEL") "HEADERS" else null } + .propsSource { null } + .build() + assertEquals( + HttpLogLevel.BODY_AND_HEADERS, + HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.NONE), + ) + } + + // ----- Unset key falls back to the supplied default ----- + + @Test + fun `unset key returns the supplied default`() { + val cfg = configWithEnv() // nothing set + assertEquals( + HttpLogLevel.HEADERS, + HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.HEADERS), + ) + } + + @Test + fun `empty env value returns the supplied default`() { + // An empty env var (EXAMPLE=) is treated as absent by Configuration, so the default applies. + val cfg = configWithEnv("MY_PRODUCT_LOG_LEVEL" to "") + assertEquals( + HttpLogLevel.BODY_AND_HEADERS, + HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.BODY_AND_HEADERS), + ) + } + + @Test + fun `whitespace-only value returns the supplied default`() { + // Configuration only treats an exactly-empty env string as absent, so a whitespace-only + // value is returned as-is; fromConfiguration's own trim collapses it to "" and falls to the default. + val cfg = configWithEnv("MY_PRODUCT_LOG_LEVEL" to " ") + assertEquals( + HttpLogLevel.HEADERS, + HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.HEADERS), + ) + } + + // ----- Unrecognized value falls back to the supplied default ----- + + @Test + fun `unrecognized value returns the supplied default`() { + val cfg = configWithEnv("MY_PRODUCT_LOG_LEVEL" to "VERBOSE") + assertEquals( + HttpLogLevel.NONE, + HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.NONE), + ) + } + + // ----- Default of the default-defaulted overload ----- + + @Test + fun `default parameter is NONE when omitted`() { + val cfg = configWithEnv() // nothing set + assertEquals(HttpLogLevel.NONE, HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg)) + } +}