Skip to content

Commit 96f02d5

Browse files
authored
feat: add HttpLogLevel.fromEnv with a caller-supplied key and source (#95)
PR: #95
1 parent 752c05b commit 96f02d5

3 files changed

Lines changed: 190 additions & 0 deletions

File tree

sdk-core/api/sdk-core.api

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,13 +835,22 @@ public final class org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationO
835835

836836
public final class org/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel : java/lang/Enum {
837837
public static final field BODY_AND_HEADERS Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;
838+
public static final field Companion Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel$Companion;
838839
public static final field HEADERS Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;
839840
public static final field NONE Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;
841+
public static final fun fromConfiguration (Ljava/lang/String;Lorg/dexpace/sdk/core/config/Configuration;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;
842+
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;
840843
public static fun getEntries ()Lkotlin/enums/EnumEntries;
841844
public static fun valueOf (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;
842845
public static fun values ()[Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;
843846
}
844847

848+
public final class org/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel$Companion {
849+
public final fun fromConfiguration (Ljava/lang/String;Lorg/dexpace/sdk/core/config/Configuration;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;
850+
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;
851+
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;
852+
}
853+
845854
public final class org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectCondition {
846855
public fun <init> (Lorg/dexpace/sdk/core/http/response/Response;ILjava/util/Set;)V
847856
public final fun component1 ()Lorg/dexpace/sdk/core/http/response/Response;

sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
package org.dexpace.sdk.core.http.pipeline.steps
99

10+
import org.dexpace.sdk.core.config.Configuration
11+
import java.util.Locale
12+
1013
/**
1114
* Granularity of HTTP logging emitted by [InstrumentationStep].
1215
*
@@ -32,4 +35,41 @@ public enum class HttpLogLevel {
3235
* See [HttpInstrumentationOptions] for the streaming and async-completion-thread caveats.
3336
*/
3437
BODY_AND_HEADERS,
38+
;
39+
40+
public companion object {
41+
/**
42+
* Resolves a log level by looking [key] up in [source].
43+
*
44+
* [source] is consulted via [Configuration.get], so the value goes through the full
45+
* layering — explicit override -> environment variable -> normalized system property ->
46+
* default. The value may come from any of those layers: the key `MY_PRODUCT_LOG_LEVEL`
47+
* also matches the `my.product.log.level` system property, and an explicit override wins
48+
* over both.
49+
*
50+
* The SDK is a toolkit, not a product, so it deliberately bakes in **no** default key —
51+
* the caller (e.g. a generated client) supplies its own product's variable name, and
52+
* [source] supplies the lookup. Pass a [Configuration] built with
53+
* [org.dexpace.sdk.core.config.ConfigurationBuilder.envSource] to inject a hermetic env
54+
* in tests; in production a [Configuration] backed by `System.getenv` is the natural fit.
55+
*
56+
* Parsing is tolerant: the resolved value is matched against the enum names
57+
* case-insensitively and after trimming surrounding whitespace (so `headers`,
58+
* `HEADERS`, and ` Headers ` all resolve to [HEADERS]). When the key is unset (or set
59+
* to an empty string, which [Configuration] treats as absent) or holds an unrecognized
60+
* value, [default] is returned. [default] itself defaults to [NONE] — logging stays off
61+
* unless explicitly opted into.
62+
*/
63+
@JvmStatic
64+
@JvmOverloads
65+
public fun fromConfiguration(
66+
key: String,
67+
source: Configuration,
68+
default: HttpLogLevel = NONE,
69+
): HttpLogLevel {
70+
val raw = source.get(key) ?: return default
71+
val name = raw.trim().uppercase(Locale.US)
72+
return entries.firstOrNull { it.name == name } ?: default
73+
}
74+
}
3575
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* Copyright (c) 2026 dexpace and Omar Aljarrah
3+
*
4+
* Licensed under the MIT License. See LICENSE in the project root.
5+
* SPDX-License-Identifier: MIT
6+
*/
7+
8+
package org.dexpace.sdk.core.http.pipeline.steps
9+
10+
import org.dexpace.sdk.core.config.ConfigurationBuilder
11+
import kotlin.test.Test
12+
import kotlin.test.assertEquals
13+
14+
class HttpLogLevelTest {
15+
/** Builds a [org.dexpace.sdk.core.config.Configuration] whose env layer returns [entries] and nothing else. */
16+
private fun configWithEnv(vararg entries: Pair<String, String>) =
17+
ConfigurationBuilder()
18+
.envSource { name -> entries.firstOrNull { it.first == name }?.second }
19+
.propsSource { null }
20+
.build()
21+
22+
// ----- Known value resolves (case-insensitive) -----
23+
24+
@Test
25+
fun `known key resolves to the matching level`() {
26+
val cfg = configWithEnv("MY_PRODUCT_LOG_LEVEL" to "HEADERS")
27+
assertEquals(
28+
HttpLogLevel.HEADERS,
29+
HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.NONE),
30+
)
31+
}
32+
33+
@Test
34+
fun `value resolves case-insensitively`() {
35+
val cfg = configWithEnv("MY_PRODUCT_LOG_LEVEL" to "body_and_headers")
36+
assertEquals(
37+
HttpLogLevel.BODY_AND_HEADERS,
38+
HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.NONE),
39+
)
40+
}
41+
42+
@Test
43+
fun `value resolves with surrounding whitespace trimmed`() {
44+
val cfg = configWithEnv("MY_PRODUCT_LOG_LEVEL" to " Headers ")
45+
assertEquals(
46+
HttpLogLevel.HEADERS,
47+
HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.NONE),
48+
)
49+
}
50+
51+
@Test
52+
fun `each level name round-trips`() {
53+
HttpLogLevel.entries.forEach { level ->
54+
val cfg = configWithEnv("LL" to level.name)
55+
assertEquals(level, HttpLogLevel.fromConfiguration("LL", cfg, HttpLogLevel.NONE))
56+
}
57+
}
58+
59+
// ----- Resolution honors the full Configuration layering, not just env -----
60+
61+
@Test
62+
fun `value resolves from the system-property layer when the env is unset`() {
63+
// Env unset; the property is looked up under the normalized name (MY_PRODUCT_LOG_LEVEL
64+
// -> my.product.log.level). The resolved value must still feed the level parsing.
65+
val cfg =
66+
ConfigurationBuilder()
67+
.envSource { null }
68+
.propsSource { name -> if (name == "my.product.log.level") "HEADERS" else null }
69+
.build()
70+
assertEquals(
71+
HttpLogLevel.HEADERS,
72+
HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.NONE),
73+
)
74+
}
75+
76+
@Test
77+
fun `explicit override wins over a conflicting env value`() {
78+
// Override is the top layer; it must take precedence over the env entry for the same key.
79+
val cfg =
80+
ConfigurationBuilder()
81+
.put("MY_PRODUCT_LOG_LEVEL", "BODY_AND_HEADERS")
82+
.envSource { name -> if (name == "MY_PRODUCT_LOG_LEVEL") "HEADERS" else null }
83+
.propsSource { null }
84+
.build()
85+
assertEquals(
86+
HttpLogLevel.BODY_AND_HEADERS,
87+
HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.NONE),
88+
)
89+
}
90+
91+
// ----- Unset key falls back to the supplied default -----
92+
93+
@Test
94+
fun `unset key returns the supplied default`() {
95+
val cfg = configWithEnv() // nothing set
96+
assertEquals(
97+
HttpLogLevel.HEADERS,
98+
HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.HEADERS),
99+
)
100+
}
101+
102+
@Test
103+
fun `empty env value returns the supplied default`() {
104+
// An empty env var (EXAMPLE=) is treated as absent by Configuration, so the default applies.
105+
val cfg = configWithEnv("MY_PRODUCT_LOG_LEVEL" to "")
106+
assertEquals(
107+
HttpLogLevel.BODY_AND_HEADERS,
108+
HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.BODY_AND_HEADERS),
109+
)
110+
}
111+
112+
@Test
113+
fun `whitespace-only value returns the supplied default`() {
114+
// Configuration only treats an exactly-empty env string as absent, so a whitespace-only
115+
// value is returned as-is; fromConfiguration's own trim collapses it to "" and falls to the default.
116+
val cfg = configWithEnv("MY_PRODUCT_LOG_LEVEL" to " ")
117+
assertEquals(
118+
HttpLogLevel.HEADERS,
119+
HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.HEADERS),
120+
)
121+
}
122+
123+
// ----- Unrecognized value falls back to the supplied default -----
124+
125+
@Test
126+
fun `unrecognized value returns the supplied default`() {
127+
val cfg = configWithEnv("MY_PRODUCT_LOG_LEVEL" to "VERBOSE")
128+
assertEquals(
129+
HttpLogLevel.NONE,
130+
HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg, HttpLogLevel.NONE),
131+
)
132+
}
133+
134+
// ----- Default of the default-defaulted overload -----
135+
136+
@Test
137+
fun `default parameter is NONE when omitted`() {
138+
val cfg = configWithEnv() // nothing set
139+
assertEquals(HttpLogLevel.NONE, HttpLogLevel.fromConfiguration("MY_PRODUCT_LOG_LEVEL", cfg))
140+
}
141+
}

0 commit comments

Comments
 (0)