Skip to content

Commit 44ee380

Browse files
Change Configuration.java to object instance & correct README
1 parent 9b1bf3e commit 44ee380

4 files changed

Lines changed: 181 additions & 28 deletions

File tree

README.md

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,10 @@ The seven permitted subtypes — `AuthenticationError`, `BadRequestError`,
107107
The repo uses **Gradle (Kotlin DSL)** with a version catalog at
108108
[`gradle/libs.versions.toml`](gradle/libs.versions.toml).
109109

110-
The Gradle wrapper jar is **not** committed. Bootstrap once with a
111-
locally installed Gradle (≥ 8.10):
112-
113-
```bash
114-
gradle wrapper --gradle-version 8.12
115-
```
116-
117-
After that, use the wrapper for everything:
110+
The Gradle wrapper is committed (`gradlew`, `gradlew.bat`,
111+
`gradle/wrapper/gradle-wrapper.jar`, `gradle/wrapper/gradle-wrapper.properties`),
112+
so any JDK 17+ environment can build the project without a separate Gradle
113+
install — the wrapper downloads the right Gradle version on first run.
118114

119115
```bash
120116
./gradlew build # compile + unit tests + spotless + jacoco

src/main/java/com/marketdata/sdk/MarketDataClient.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,14 @@ public final class MarketDataClient implements AutoCloseable {
4949
private final boolean validateOnStartup;
5050

5151
private MarketDataClient(Builder builder) {
52-
this.token = Configuration.resolve(builder.apiKey, EnvVars.TOKEN);
52+
Configuration config = Configuration.loadFromProcess();
53+
this.token = config.resolve(builder.apiKey, EnvVars.TOKEN);
5354
this.baseUrl =
5455
trimTrailingSlash(
55-
Configuration.resolveOrDefault(
56+
config.resolveOrDefault(
5657
builder.baseUrl, EnvVars.BASE_URL, Configuration.DEFAULT_BASE_URL));
5758
this.apiVersion =
58-
Configuration.resolveOrDefault(
59+
config.resolveOrDefault(
5960
builder.apiVersion, EnvVars.API_VERSION, Configuration.DEFAULT_API_VERSION);
6061
this.demoMode = this.token == null;
6162
this.validateOnStartup = builder.validateOnStartup;

src/main/java/com/marketdata/sdk/internal/Configuration.java

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,39 +12,49 @@
1212
* Resolves SDK configuration values per the cascade in SDK requirements §4: {@code explicit value →
1313
* MARKETDATA_* env var → .env file in CWD → built-in default}.
1414
*
15-
* <p>The {@code .env} file is read lazily from the current working directory when an env-backed
16-
* value is requested. Lines starting with {@code #} are treated as comments; surrounding single or
17-
* double quotes on values are stripped.
15+
* <p>The only public construction path is {@link #loadFromProcess()}, which snapshots the live
16+
* environment and the {@code .env} file once. The constructor is strictly private — there is no
17+
* production-callable backdoor for injecting arbitrary maps. Tests reach the private constructor
18+
* via reflection (see {@code ConfigurationTest}); this is by design so a developer can't
19+
* accidentally take a shortcut around the canonical load path.
1820
*/
1921
public final class Configuration {
2022

2123
public static final String DEFAULT_BASE_URL = "https://api.marketdata.app";
2224
public static final String DEFAULT_API_VERSION = "v1";
25+
private static final Path DEFAULT_DOTENV_PATH = Paths.get(".env");
2326

24-
private Configuration() {}
27+
private final Map<String, String> systemEnv;
28+
private final Map<String, String> dotEnv;
29+
30+
private Configuration(Map<String, String> systemEnv, Map<String, String> dotEnv) {
31+
this.systemEnv = Map.copyOf(systemEnv);
32+
this.dotEnv = Map.copyOf(dotEnv);
33+
}
2534

2635
/**
27-
* Returns the first non-blank value among {@code explicit}, the named environment variable, and
28-
* the {@code .env} file entry — or {@code null} if none is set.
36+
* Production factory: snapshots {@code System.getenv()} and reads {@code ./.env} once. Call
37+
* during client construction.
2938
*/
30-
public static @Nullable String resolve(@Nullable String explicit, String envKey) {
39+
public static Configuration loadFromProcess() {
40+
return new Configuration(System.getenv(), readDotEnvFile(DEFAULT_DOTENV_PATH));
41+
}
42+
43+
/** Cascade: explicit → system env → .env → {@code null}. */
44+
public @Nullable String resolve(@Nullable String explicit, String envKey) {
3145
if (isPresent(explicit)) {
3246
return explicit;
3347
}
34-
String fromSystem = System.getenv(envKey);
48+
String fromSystem = systemEnv.get(envKey);
3549
if (isPresent(fromSystem)) {
3650
return fromSystem;
3751
}
38-
String fromDotEnv = readDotEnv().get(envKey);
52+
String fromDotEnv = dotEnv.get(envKey);
3953
return isPresent(fromDotEnv) ? fromDotEnv : null;
4054
}
4155

42-
/**
43-
* Same as {@link #resolve(String, String)} but falls back to the supplied default when the
44-
* cascade yields nothing.
45-
*/
46-
public static String resolveOrDefault(
47-
@Nullable String explicit, String envKey, String defaultValue) {
56+
/** Same as {@link #resolve} but returns {@code defaultValue} when the cascade yields nothing. */
57+
public String resolveOrDefault(@Nullable String explicit, String envKey, String defaultValue) {
4858
String resolved = resolve(explicit, envKey);
4959
return resolved != null ? resolved : defaultValue;
5060
}
@@ -53,8 +63,12 @@ private static boolean isPresent(@Nullable String value) {
5363
return value != null && !value.isBlank();
5464
}
5565

56-
private static Map<String, String> readDotEnv() {
57-
Path path = Paths.get(".env");
66+
/**
67+
* Reads a {@code .env}-style file: lines like {@code KEY=value}, {@code #} for comments,
68+
* surrounding single or double quotes stripped. Package-private so tests can target an arbitrary
69+
* {@link Path} (e.g. inside a JUnit {@code @TempDir}) instead of CWD.
70+
*/
71+
static Map<String, String> readDotEnvFile(Path path) {
5872
if (!Files.isRegularFile(path)) {
5973
return Map.of();
6074
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package com.marketdata.sdk.internal;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.io.IOException;
6+
import java.lang.reflect.Constructor;
7+
import java.nio.file.Files;
8+
import java.nio.file.Path;
9+
import java.util.Map;
10+
import org.junit.jupiter.api.Test;
11+
import org.junit.jupiter.api.io.TempDir;
12+
13+
class ConfigurationTest {
14+
15+
/**
16+
* Reflection bridge to {@code Configuration}'s private constructor. Tests need to inject custom
17+
* environment maps; production code cannot — that's the entire point of keeping the constructor
18+
* private. Encapsulating the reflection here keeps individual tests clean.
19+
*/
20+
private static Configuration newConfig(
21+
Map<String, String> systemEnv, Map<String, String> dotEnv) {
22+
try {
23+
Constructor<Configuration> ctor =
24+
Configuration.class.getDeclaredConstructor(Map.class, Map.class);
25+
ctor.setAccessible(true);
26+
return ctor.newInstance(systemEnv, dotEnv);
27+
} catch (ReflectiveOperationException e) {
28+
throw new IllegalStateException(
29+
"Could not construct Configuration via reflection — has the private ctor signature"
30+
+ " changed?",
31+
e);
32+
}
33+
}
34+
35+
@Test
36+
void explicitWinsOverEverything() {
37+
Configuration config =
38+
newConfig(
39+
Map.of("MARKETDATA_TOKEN", "from-env"), Map.of("MARKETDATA_TOKEN", "from-dotenv"));
40+
41+
assertThat(config.resolve("explicit-value", "MARKETDATA_TOKEN")).isEqualTo("explicit-value");
42+
}
43+
44+
@Test
45+
void envVarWinsOverDotEnv() {
46+
Configuration config =
47+
newConfig(
48+
Map.of("MARKETDATA_TOKEN", "from-env"), Map.of("MARKETDATA_TOKEN", "from-dotenv"));
49+
50+
assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isEqualTo("from-env");
51+
}
52+
53+
@Test
54+
void fallsBackToDotEnvWhenEnvVarMissing() {
55+
Configuration config = newConfig(Map.of(), Map.of("MARKETDATA_TOKEN", "from-dotenv"));
56+
57+
assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isEqualTo("from-dotenv");
58+
}
59+
60+
@Test
61+
void blankExplicitDoesNotCount() {
62+
Configuration config = newConfig(Map.of("MARKETDATA_TOKEN", "from-env"), Map.of());
63+
64+
assertThat(config.resolve(" ", "MARKETDATA_TOKEN")).isEqualTo("from-env");
65+
}
66+
67+
@Test
68+
void blankEnvVarFallsThroughToDotEnv() {
69+
Configuration config =
70+
newConfig(Map.of("MARKETDATA_TOKEN", " "), Map.of("MARKETDATA_TOKEN", "from-dotenv"));
71+
72+
assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isEqualTo("from-dotenv");
73+
}
74+
75+
@Test
76+
void resolveReturnsNullWhenAllSourcesEmpty() {
77+
Configuration config = newConfig(Map.of(), Map.of());
78+
79+
assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isNull();
80+
}
81+
82+
@Test
83+
void resolveOrDefaultReturnsDefaultWhenAllEmpty() {
84+
Configuration config = newConfig(Map.of(), Map.of());
85+
86+
assertThat(config.resolveOrDefault(null, "MARKETDATA_BASE_URL", "https://default"))
87+
.isEqualTo("https://default");
88+
}
89+
90+
@Test
91+
void resolveOrDefaultPrefersResolvedValue() {
92+
Configuration config = newConfig(Map.of("MARKETDATA_BASE_URL", "https://explicit"), Map.of());
93+
94+
assertThat(config.resolveOrDefault(null, "MARKETDATA_BASE_URL", "https://default"))
95+
.isEqualTo("https://explicit");
96+
}
97+
98+
// ---------- .env file parsing ----------
99+
100+
@Test
101+
void readsAndParsesDotEnvFile(@TempDir Path tmp) throws IOException {
102+
Path dotenv = tmp.resolve(".env");
103+
Files.writeString(
104+
dotenv,
105+
"""
106+
# comment line — should be ignored
107+
MARKETDATA_TOKEN=plain-token
108+
MARKETDATA_BASE_URL="https://staging.example.com"
109+
QUOTED_SINGLE='single-quoted'
110+
EMPTY_VALUE=
111+
112+
# blank line above
113+
BAD_LINE_NO_EQUALS
114+
=BAD_LINE_NO_KEY
115+
""");
116+
117+
Map<String, String> parsed = Configuration.readDotEnvFile(dotenv);
118+
119+
assertThat(parsed)
120+
.containsEntry("MARKETDATA_TOKEN", "plain-token")
121+
.containsEntry("MARKETDATA_BASE_URL", "https://staging.example.com")
122+
.containsEntry("QUOTED_SINGLE", "single-quoted")
123+
.containsEntry("EMPTY_VALUE", "")
124+
.doesNotContainKey("# comment line — should be ignored")
125+
.doesNotContainKey("BAD_LINE_NO_EQUALS");
126+
}
127+
128+
@Test
129+
void missingDotEnvReturnsEmpty(@TempDir Path tmp) {
130+
assertThat(Configuration.readDotEnvFile(tmp.resolve(".env"))).isEmpty();
131+
}
132+
133+
@Test
134+
void dotEnvParsingIntegratesWithCascade(@TempDir Path tmp) throws IOException {
135+
Path dotenv = tmp.resolve(".env");
136+
Files.writeString(dotenv, "MARKETDATA_TOKEN=from-real-dotenv\n");
137+
138+
Configuration config = newConfig(Map.of(), Configuration.readDotEnvFile(dotenv));
139+
140+
assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isEqualTo("from-real-dotenv");
141+
}
142+
}

0 commit comments

Comments
 (0)