Skip to content

Commit 8cc69d8

Browse files
Feature/13 implement config file (#22)
* Added basic YAML config-file. * Added class ConfigLoader with static classes for encapsulation * Added static metod loadOnce and step one of static method load * Added static method createMapperFor that checks for YAML or JSON-files before creating an ObjectMapper object. * implement ConfigLoader refs #13 * Added AppConfig.java record for config after coderabbit feedback * Updated ConfigLoader to use AppConfig record and jackson 3 * Added tests for ConfigLoader and reset cached method in ConfigLoader to ensure test isolation with static cache * Removed unused dependency. Minor readability tweaks in AppConfig. * Added check for illegal port numbers to withDefaultsApplied-method. * Added test for illegal port numbers.
1 parent 524f33c commit 8cc69d8

File tree

4 files changed

+271
-0
lines changed

4 files changed

+271
-0
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package org.example.config;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
6+
@JsonIgnoreProperties(ignoreUnknown = true)
7+
public record AppConfig(
8+
@JsonProperty("server") ServerConfig server,
9+
@JsonProperty("logging") LoggingConfig logging
10+
) {
11+
public static AppConfig defaults() {
12+
return new AppConfig(ServerConfig.defaults(), LoggingConfig.defaults());
13+
}
14+
15+
public AppConfig withDefaultsApplied() {
16+
ServerConfig serverConfig = (server == null ? ServerConfig.defaults() : server.withDefaultsApplied());
17+
LoggingConfig loggingConfig = (logging == null ? LoggingConfig.defaults() : logging.withDefaultsApplied());
18+
return new AppConfig(serverConfig, loggingConfig);
19+
}
20+
21+
@JsonIgnoreProperties(ignoreUnknown = true)
22+
public record ServerConfig(
23+
@JsonProperty("port") Integer port,
24+
@JsonProperty("rootDir") String rootDir
25+
) {
26+
public static ServerConfig defaults() {
27+
return new ServerConfig(8080, "./www");
28+
}
29+
30+
public ServerConfig withDefaultsApplied() {
31+
int p = (port == null ? 8080 : port);
32+
if (p < 1 || p > 65535) {
33+
throw new IllegalArgumentException("Invalid port number: " + p + ". Port must be between 1 and 65535");
34+
}
35+
String rd = (rootDir == null || rootDir.isBlank()) ? "./www" : rootDir;
36+
return new ServerConfig(p, rd);
37+
}
38+
}
39+
40+
@JsonIgnoreProperties(ignoreUnknown = true)
41+
public record LoggingConfig(
42+
@JsonProperty("level") String level
43+
) {
44+
public static LoggingConfig defaults() {
45+
return new LoggingConfig("INFO");
46+
}
47+
48+
public LoggingConfig withDefaultsApplied() {
49+
String lvl = (level == null || level.isBlank()) ? "INFO" : level;
50+
return new LoggingConfig(lvl);
51+
}
52+
}
53+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package org.example.config;
2+
3+
import tools.jackson.databind.ObjectMapper;
4+
import tools.jackson.databind.json.JsonMapper;
5+
import tools.jackson.dataformat.yaml.YAMLFactory;
6+
import tools.jackson.dataformat.yaml.YAMLMapper;
7+
8+
import java.io.InputStream;
9+
import java.nio.file.Files;
10+
import java.nio.file.Path;
11+
import java.util.Objects;
12+
13+
public final class ConfigLoader {
14+
15+
private static volatile AppConfig cached;
16+
17+
private ConfigLoader() {}
18+
19+
public static AppConfig loadOnce(Path configPath) {
20+
if (cached != null) return cached;
21+
22+
synchronized (ConfigLoader.class) {
23+
if (cached == null){
24+
cached = load(configPath).withDefaultsApplied();
25+
}
26+
return cached;
27+
}
28+
}
29+
30+
public static AppConfig get(){
31+
if (cached == null){
32+
throw new IllegalStateException("Config not loaded. call ConfigLoader.loadOnce(...) at startup.");
33+
}
34+
return cached;
35+
36+
}
37+
38+
public static AppConfig load(Path configPath) {
39+
Objects.requireNonNull(configPath, "configPath");
40+
41+
if (!Files.exists(configPath)) {
42+
return AppConfig.defaults();
43+
}
44+
45+
ObjectMapper objectMapper = createMapperFor(configPath);
46+
47+
try (InputStream stream = Files.newInputStream(configPath)){
48+
AppConfig config = objectMapper.readValue(stream, AppConfig.class);
49+
return config == null ? AppConfig.defaults() : config;
50+
} catch (Exception e){
51+
throw new IllegalStateException("failed to read config file " + configPath.toAbsolutePath(), e);
52+
}
53+
}
54+
55+
private static ObjectMapper createMapperFor(Path configPath) {
56+
String name = configPath.getFileName().toString().toLowerCase();
57+
58+
if (name.endsWith(".yml") || name.endsWith(".yaml")) {
59+
return YAMLMapper.builder(new YAMLFactory()).build();
60+
61+
} else if (name.endsWith(".json")) {
62+
return JsonMapper.builder().build();
63+
} else {
64+
return YAMLMapper.builder(new YAMLFactory()).build();
65+
}
66+
}
67+
68+
static void resetForTests() {
69+
cached = null;
70+
}
71+
}

src/main/resources/application.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
server:
2+
port: 8080
3+
rootDir: ./www
4+
5+
logging:
6+
level: INFO
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package org.example.config;
2+
3+
import org.junit.jupiter.api.BeforeEach;
4+
import org.junit.jupiter.api.DisplayName;
5+
import org.junit.jupiter.api.Test;
6+
import org.junit.jupiter.api.io.TempDir;
7+
8+
import java.nio.file.Files;
9+
import java.nio.file.Path;
10+
11+
import static org.assertj.core.api.Assertions.*;
12+
13+
class ConfigLoaderTest {
14+
15+
@TempDir
16+
Path tempDir;
17+
18+
@BeforeEach
19+
void reset() {
20+
ConfigLoader.resetForTests();
21+
}
22+
23+
@Test
24+
@DisplayName("Should return default configuration when config file is missing")
25+
void load_returns_defaults_when_file_missing() {
26+
Path missing = tempDir.resolve("missing.yml");
27+
28+
AppConfig appConfig = ConfigLoader.load(missing).withDefaultsApplied();
29+
30+
assertThat(appConfig.server().port()).isEqualTo(8080);
31+
assertThat(appConfig.server().rootDir()).isEqualTo("./www");
32+
assertThat(appConfig.logging().level()).isEqualTo("INFO");
33+
}
34+
35+
@Test
36+
@DisplayName("Should load values from YAML file when file exists")
37+
void loadOnce_reads_yaml_values() throws Exception {
38+
Path configFile = tempDir.resolve("application.yml");
39+
Files.writeString(configFile, """
40+
server:
41+
port: 9090
42+
rootDir: ./public
43+
logging:
44+
level: DEBUG
45+
""");
46+
47+
AppConfig appConfig = ConfigLoader.loadOnce(configFile);
48+
49+
assertThat(appConfig.server().port()).isEqualTo(9090);
50+
assertThat(appConfig.server().rootDir()).isEqualTo("./public");
51+
assertThat(appConfig.logging().level()).isEqualTo("DEBUG");
52+
}
53+
54+
@Test
55+
@DisplayName("Should apply default values when sections or fields are missing")
56+
void defaults_applied_when_sections_or_fields_missing() throws Exception {
57+
Path configFile = tempDir.resolve("application.yml");
58+
Files.writeString(configFile, """
59+
server:
60+
port: 1234
61+
""");
62+
63+
AppConfig cfg = ConfigLoader.loadOnce(configFile);
64+
65+
assertThat(cfg.server().port()).isEqualTo(1234);
66+
assertThat(cfg.server().rootDir()).isEqualTo("./www"); // default
67+
assertThat(cfg.logging().level()).isEqualTo("INFO"); // default
68+
}
69+
70+
@Test
71+
@DisplayName("Should ignore unknown fields in configuration file")
72+
void unknown_fields_are_ignored() throws Exception {
73+
Path configFile = tempDir.resolve("application.yml");
74+
Files.writeString(configFile, """
75+
server:
76+
port: 8081
77+
rootDir: ./www
78+
threads: 8
79+
logging:
80+
level: INFO
81+
json: true
82+
""");
83+
84+
AppConfig cfg = ConfigLoader.loadOnce(configFile);
85+
86+
assertThat(cfg.server().port()).isEqualTo(8081);
87+
assertThat(cfg.server().rootDir()).isEqualTo("./www");
88+
assertThat(cfg.logging().level()).isEqualTo("INFO");
89+
}
90+
91+
@Test
92+
@DisplayName("Should return same instance on repeated loadOnce calls")
93+
void loadOnce_caches_same_instance() throws Exception {
94+
Path configFile = tempDir.resolve("application.yml");
95+
Files.writeString(configFile, """
96+
server:
97+
port: 8080
98+
rootDir: ./www
99+
logging:
100+
level: INFO
101+
""");
102+
103+
AppConfig a = ConfigLoader.loadOnce(configFile);
104+
AppConfig b = ConfigLoader.loadOnce(configFile);
105+
106+
assertThat(a).isSameAs(b);
107+
}
108+
109+
@Test
110+
@DisplayName("Should throw exception when get is called before configuration is loaded")
111+
void get_throws_if_not_loaded() {
112+
assertThatThrownBy(ConfigLoader::get)
113+
.isInstanceOf(IllegalStateException.class)
114+
.hasMessageContaining("not loaded");
115+
}
116+
117+
@Test
118+
@DisplayName("Should fail when configuration file is invalid")
119+
void invalid_yaml_fails() throws Exception {
120+
Path configFile = tempDir.resolve("broken.yml");
121+
Files.writeString(configFile, "server:\n port 8080\n"); // saknar ':' efter port
122+
123+
assertThatThrownBy(() -> ConfigLoader.load(configFile))
124+
.isInstanceOf(IllegalStateException.class)
125+
.hasMessageContaining("failed to read config file");
126+
}
127+
128+
@Test
129+
@DisplayName("Should fail when port is out of range")
130+
void invalid_port_should_Throw_Exception () throws Exception {
131+
Path configFile = tempDir.resolve("application.yml");
132+
133+
Files.writeString(configFile, """
134+
server:
135+
port: 70000
136+
""");
137+
138+
assertThatThrownBy(() -> ConfigLoader.loadOnce(configFile))
139+
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid port number");
140+
}
141+
}

0 commit comments

Comments
 (0)