feat(Tracer Bullet)

This commit is contained in:
2025-08-11 23:47:05 +02:00
parent 582678e226
commit a50b1b3822
43 changed files with 1665 additions and 292 deletions
+62 -36
View File
@@ -1,44 +1,70 @@
// Dieses Modul stellt gemeinsame technische Hilfsfunktionen bereit,
// wie z.B. Konfigurations-Management, Datenbank-Verbindungen und Service Discovery.
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
// Target platforms
jvm {
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
}
}
js(IR) {
browser()
}
sourceSets {
val commonMain by getting {
dependencies {
// Abhängigkeit zum core-domain-Modul, um dessen Typen zu verwenden
api(projects.core.coreDomain)
// Asynchronität (available for all platforms) - explicit version to avoid BOM issues
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
// Utilities (multiplatform compatible)
api(libs.bignum)
}
}
val jvmMain by getting {
dependencies {
// Abhängigkeit zum platform-Modul für zentrale Versionsverwaltung
api(projects.platform.platformDependencies)
// Datenbank-Management (JVM-specific)
// OPTIMIERUNG: Verwendung von Bundles für Exposed und Flyway
api(libs.bundles.exposed)
api(libs.bundles.flyway)
api(libs.hikari.cp)
// Service Discovery (JVM-specific)
// api(libs.consul.client) wird getauscht mir spring-cloud-starter-consul-discovery
api(libs.spring.cloud.starter.consul.discovery)
// Logging (JVM-specific)
api(libs.kotlin.logging.jvm)
// JVM-specific utilities
implementation(libs.room.common.jvm) // Für BigDecimal Serialisierung
}
}
val commonTest by getting {
dependencies {
implementation(libs.kotlin.test)
}
}
val jvmTest by getting {
dependencies {
// Testing (JVM-specific)
implementation(projects.platform.platformTesting)
implementation(libs.bundles.testing.jvm)
runtimeOnly(libs.postgresql.driver)
}
}
}
}
dependencies {
// Abhängigkeit zum platform-Modul für zentrale Versionsverwaltung
api(projects.platform.platformDependencies)
// Abhängigkeit zum core-domain-Modul, um dessen Typen zu verwenden
api(projects.core.coreDomain)
// Asynchronität
api(libs.kotlinx.coroutines.core)
// Datenbank-Management
// OPTIMIERUNG: Verwendung von Bundles für Exposed und Flyway
api(libs.bundles.exposed)
api(libs.bundles.flyway)
api(libs.hikari.cp)
// Service Discovery
// api(libs.consul.client) wird getauscht mir spring-cloud-starter-consul-discovery
api(libs.spring.cloud.starter.consul.discovery)
// Logging
api(libs.kotlin.logging.jvm)
// Utilities
api(libs.bignum)
implementation(libs.room.common.jvm) // Für BigDecimal Serialisierung
// Testing
testImplementation(projects.platform.platformTesting)
testImplementation(libs.bundles.testing.jvm)
testImplementation(libs.kotlin.test)
testRuntimeOnly(libs.postgresql.driver)
}
@@ -15,43 +15,55 @@ data class AppConfig(
val rateLimit: RateLimitConfig
)
data class AppInfoConfig(val name: String, val version: String, val description: String)
data class AppInfoConfig(
val name: ApplicationName,
val version: ApplicationVersion,
val description: String
)
data class ServerConfig(
val port: Int,
val host: String,
val advertisedHost: String,
val workers: Int,
val port: Port,
val host: Host,
val advertisedHost: Host,
val workers: WorkerCount,
val cors: CorsConfig
) {
data class CorsConfig(val enabled: Boolean, val allowedOrigins: List<String>)
}
data class DatabaseConfig(
val host: String,
val port: Int,
val name: String,
val jdbcUrl: String,
val username: String,
val password: String,
val host: Host,
val port: Port,
val name: DatabaseName,
val jdbcUrl: JdbcUrl,
val username: DatabaseUsername,
val password: DatabasePassword,
val driverClassName: String,
val maxPoolSize: Int,
val minPoolSize: Int,
val maxPoolSize: PoolSize,
val minPoolSize: PoolSize,
val autoMigrate: Boolean
)
data class ServiceDiscoveryConfig(val enabled: Boolean, val consulHost: String, val consulPort: Int)
data class ServiceDiscoveryConfig(
val enabled: Boolean,
val consulHost: Host,
val consulPort: Port
)
data class SecurityConfig(val jwt: JwtConfig, val apiKey: String?) {
data class SecurityConfig(val jwt: JwtConfig, val apiKey: ApiKey?) {
data class JwtConfig(
val secret: String,
val issuer: String,
val audience: String,
val realm: String,
val secret: JwtSecret,
val issuer: JwtIssuer,
val audience: JwtAudience,
val realm: JwtRealm,
val expirationInMinutes: Long
)
}
data class LoggingConfig(val level: String, val logRequests: Boolean, val logResponses: Boolean)
data class RateLimitConfig(val enabled: Boolean, val globalLimit: Int, val globalPeriodMinutes: Int)
data class RateLimitConfig(
val enabled: Boolean,
val globalLimit: RateLimit,
val globalPeriodMinutes: PeriodMinutes
)
@@ -53,8 +53,8 @@ class ConfigLoader(private val configPath: String = "config") {
// Die Konfigurations-Erstellungslogik ist hierher verschoben
private fun createAppInfoConfig(props: Properties) = AppInfoConfig(
name = props.getProperty("app.name", "Meldestelle"),
version = props.getProperty("app.version", "1.0.0"),
name = ApplicationName(props.getProperty("app.name", "Meldestelle")),
version = ApplicationVersion(props.getProperty("app.version", "1.0.0")),
description = props.getProperty("app.description", "Pferdesport Meldestelle System")
)
@@ -65,10 +65,10 @@ class ConfigLoader(private val configPath: String = "config") {
"127.0.0.1"
}
return ServerConfig(
port = props.getIntProperty("server.port", "API_PORT", 8081),
host = props.getStringProperty("server.host", "API_HOST", "0.0.0.0"),
advertisedHost = props.getStringProperty("server.advertisedHost", "API_HOST_ADVERTISED", defaultHost),
workers = props.getIntProperty("server.workers", "API_WORKERS", Runtime.getRuntime().availableProcessors()),
port = Port(props.getIntProperty("server.port", "API_PORT", 8081)),
host = Host(props.getStringProperty("server.host", "API_HOST", "0.0.0.0")),
advertisedHost = Host(props.getStringProperty("server.advertisedHost", "API_HOST_ADVERTISED", defaultHost)),
workers = WorkerCount(props.getIntProperty("server.workers", "API_WORKERS", Runtime.getRuntime().availableProcessors())),
cors = ServerConfig.CorsConfig(
enabled = props.getBooleanProperty("server.cors.enabled", "API_CORS_ENABLED", true),
allowedOrigins = props.getProperty("server.cors.allowedOrigins")?.split(",")?.map { it.trim() }
@@ -82,15 +82,15 @@ class ConfigLoader(private val configPath: String = "config") {
val port = props.getIntProperty("database.port", "DB_PORT", 5432)
val name = props.getStringProperty("database.name", "DB_NAME", "meldestelle_db")
return DatabaseConfig(
host = host,
port = port,
name = name,
jdbcUrl = "jdbc:postgresql://$host:$port/$name",
username = props.getStringProperty("database.username", "DB_USER", "meldestelle_user"),
password = props.getStringProperty("database.password", "DB_PASSWORD", "secure_password_change_me"),
host = Host(host),
port = Port(port),
name = DatabaseName(name),
jdbcUrl = JdbcUrl("jdbc:postgresql://$host:$port/$name"),
username = DatabaseUsername(props.getStringProperty("database.username", "DB_USER", "meldestelle_user")),
password = DatabasePassword(props.getStringProperty("database.password", "DB_PASSWORD", "secure_password_change_me")),
driverClassName = "org.postgresql.Driver",
maxPoolSize = props.getIntProperty("database.maxPoolSize", "DB_MAX_POOL_SIZE", 10),
minPoolSize = props.getIntProperty("database.minPoolSize", "DB_MIN_POOL_SIZE", 5),
maxPoolSize = PoolSize(props.getIntProperty("database.maxPoolSize", "DB_MAX_POOL_SIZE", 10)),
minPoolSize = PoolSize(props.getIntProperty("database.minPoolSize", "DB_MIN_POOL_SIZE", 5)),
autoMigrate = props.getBooleanProperty("database.autoMigrate", "DB_AUTO_MIGRATE", true)
)
}
@@ -99,27 +99,27 @@ class ConfigLoader(private val configPath: String = "config") {
// analog zu den 'fromProperties' Methoden aus der alten AppConfig.
private fun createServiceDiscoveryConfig(props: Properties) = ServiceDiscoveryConfig(
enabled = props.getBooleanProperty("service-discovery.enabled", "CONSUL_ENABLED", true),
consulHost = props.getStringProperty("service-discovery.consul.host", "CONSUL_HOST", "consul"),
consulPort = props.getIntProperty("service-discovery.consul.port", "CONSUL_PORT", 8500)
consulHost = Host(props.getStringProperty("service-discovery.consul.host", "CONSUL_HOST", "consul")),
consulPort = Port(props.getIntProperty("service-discovery.consul.port", "CONSUL_PORT", 8500))
)
private fun createSecurityConfig(props: Properties) = SecurityConfig(
jwt = SecurityConfig.JwtConfig(
secret = props.getStringProperty(
secret = JwtSecret(props.getStringProperty(
"security.jwt.secret",
"JWT_SECRET",
"default-secret-please-change-in-production"
),
issuer = props.getStringProperty("security.jwt.issuer", "JWT_ISSUER", "meldestelle-api"),
audience = props.getStringProperty("security.jwt.audience", "JWT_AUDIENCE", "meldestelle-clients"),
realm = props.getStringProperty("security.jwt.realm", "JWT_REALM", "meldestelle"),
)),
issuer = JwtIssuer(props.getStringProperty("security.jwt.issuer", "JWT_ISSUER", "meldestelle-api")),
audience = JwtAudience(props.getStringProperty("security.jwt.audience", "JWT_AUDIENCE", "meldestelle-clients")),
realm = JwtRealm(props.getStringProperty("security.jwt.realm", "JWT_REALM", "meldestelle")),
expirationInMinutes = props.getLongProperty(
"security.jwt.expirationInMinutes",
"JWT_EXPIRATION_MINUTES",
60 * 24
)
),
apiKey = props.getStringProperty("security.apiKey", "API_KEY", "").ifEmpty { null }
apiKey = props.getStringProperty("security.apiKey", "API_KEY", "").ifEmpty { null }?.let { ApiKey(it) }
)
private fun createLoggingConfig(props: Properties, env: AppEnvironment) = LoggingConfig(
@@ -130,7 +130,7 @@ class ConfigLoader(private val configPath: String = "config") {
private fun createRateLimitConfig(props: Properties) = RateLimitConfig(
enabled = props.getBooleanProperty("ratelimit.enabled", "RATE_LIMIT_ENABLED", true),
globalLimit = props.getIntProperty("ratelimit.global.limit", "RATE_LIMIT_GLOBAL_LIMIT", 100),
globalPeriodMinutes = props.getIntProperty("ratelimit.global.periodMinutes", "RATE_LIMIT_GLOBAL_PERIOD", 1)
globalLimit = RateLimit(props.getIntProperty("ratelimit.global.limit", "RATE_LIMIT_GLOBAL_LIMIT", 100)),
globalPeriodMinutes = PeriodMinutes(props.getIntProperty("ratelimit.global.periodMinutes", "RATE_LIMIT_GLOBAL_PERIOD", 1))
)
}
@@ -0,0 +1,260 @@
package at.mocode.core.utils.config
import kotlinx.serialization.Serializable
/**
* Value classes for strongly typed configuration parameters.
* These provide compile-time type safety for configuration values.
*/
// === Network Configuration Value Classes ===
/**
* A strongly typed wrapper for port numbers.
*/
@Serializable
@JvmInline
value class Port(val value: Int) {
init {
require(value in 1..65535) { "Port must be between 1 and 65535, got: $value" }
}
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for host names or IP addresses.
*/
@Serializable
@JvmInline
value class Host(val value: String) {
init {
require(value.isNotBlank()) { "Host cannot be blank" }
require(value.length <= 253) { "Host name cannot exceed 253 characters" }
}
override fun toString(): String = value
}
// === Database Configuration Value Classes ===
/**
* A strongly typed wrapper for database names.
*/
@Serializable
@JvmInline
value class DatabaseName(val value: String) {
init {
require(value.isNotBlank()) { "Database name cannot be blank" }
require(value.matches(Regex("^[a-zA-Z][a-zA-Z0-9_]*$"))) {
"Database name must start with a letter and contain only alphanumeric characters and underscores"
}
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for database usernames.
*/
@Serializable
@JvmInline
value class DatabaseUsername(val value: String) {
init {
require(value.isNotBlank()) { "Database username cannot be blank" }
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for database passwords.
*/
@Serializable
@JvmInline
value class DatabasePassword(val value: String) {
init {
require(value.isNotBlank()) { "Database password cannot be blank" }
}
override fun toString(): String = "***" // Never expose the actual password
fun getValue(): String = value
}
/**
* A strongly typed wrapper for JDBC URLs.
*/
@Serializable
@JvmInline
value class JdbcUrl(val value: String) {
init {
require(value.isNotBlank()) { "JDBC URL cannot be blank" }
require(value.startsWith("jdbc:")) { "JDBC URL must start with 'jdbc:'" }
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for connection pool sizes.
*/
@Serializable
@JvmInline
value class PoolSize(val value: Int) {
init {
require(value > 0) { "Pool size must be positive" }
require(value <= 1000) { "Pool size cannot exceed 1000" }
}
override fun toString(): String = value.toString()
}
// === Security Configuration Value Classes ===
/**
* A strongly typed wrapper for API keys.
*/
@Serializable
@JvmInline
value class ApiKey(val value: String) {
init {
require(value.isNotBlank()) { "API key cannot be blank" }
require(value.length >= 16) { "API key must be at least 16 characters long" }
}
override fun toString(): String = "***" // Never expose the actual key
fun getValue(): String = value
}
/**
* A strongly typed wrapper for JWT secrets.
*/
@Serializable
@JvmInline
value class JwtSecret(val value: String) {
init {
require(value.isNotBlank()) { "JWT secret cannot be blank" }
require(value.length >= 32) { "JWT secret must be at least 32 characters long" }
}
override fun toString(): String = "***" // Never expose the actual secret
fun getValue(): String = value
}
/**
* A strongly typed wrapper for JWT issuer.
*/
@Serializable
@JvmInline
value class JwtIssuer(val value: String) {
init {
require(value.isNotBlank()) { "JWT issuer cannot be blank" }
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for JWT audience.
*/
@Serializable
@JvmInline
value class JwtAudience(val value: String) {
init {
require(value.isNotBlank()) { "JWT audience cannot be blank" }
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for JWT realm.
*/
@Serializable
@JvmInline
value class JwtRealm(val value: String) {
init {
require(value.isNotBlank()) { "JWT realm cannot be blank" }
}
override fun toString(): String = value
}
// === Application Configuration Value Classes ===
/**
* A strongly typed wrapper for application names.
*/
@Serializable
@JvmInline
value class ApplicationName(val value: String) {
init {
require(value.isNotBlank()) { "Application name cannot be blank" }
require(value.matches(Regex("^[A-Za-z][A-Za-z0-9-_]*$"))) {
"Application name must start with a letter and contain only letters, numbers, hyphens, and underscores"
}
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for application versions.
*/
@Serializable
@JvmInline
value class ApplicationVersion(val value: String) {
init {
require(value.isNotBlank()) { "Application version cannot be blank" }
require(value.matches(Regex("^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9]+)?$"))) {
"Application version must follow semantic versioning (e.g., 1.0.0 or 1.0.0-beta)"
}
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for worker thread counts.
*/
@Serializable
@JvmInline
value class WorkerCount(val value: Int) {
init {
require(value > 0) { "Worker count must be positive" }
require(value <= Runtime.getRuntime().availableProcessors() * 4) {
"Worker count should not exceed 4 times the available processors"
}
}
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for rate limits.
*/
@Serializable
@JvmInline
value class RateLimit(val value: Int) {
init {
require(value > 0) { "Rate limit must be positive" }
}
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for time periods in minutes.
*/
@Serializable
@JvmInline
value class PeriodMinutes(val value: Int) {
init {
require(value > 0) { "Period must be positive" }
}
override fun toString(): String = value.toString()
}
@@ -51,11 +51,11 @@ class DatabaseFactory(private val config: DatabaseConfig) {
private fun createHikariConfig(): HikariConfig {
return HikariConfig().apply {
driverClassName = config.driverClassName
jdbcUrl = config.jdbcUrl
username = config.username
password = config.password
maximumPoolSize = config.maxPoolSize
minimumIdle = config.minPoolSize
jdbcUrl = config.jdbcUrl.value
username = config.username.value
password = config.password.getValue() // Use getValue() for password to access actual value
maximumPoolSize = config.maxPoolSize.value
minimumIdle = config.minPoolSize.value
isAutoCommit = false
transactionIsolation = "TRANSACTION_READ_COMMITTED"
validationTimeout = 5000
@@ -31,8 +31,8 @@ class ConfigLoaderTest {
val config = configLoader.load(AppEnvironment.DEVELOPMENT)
// Assert
assertEquals("Meldestelle", config.appInfo.name)
assertEquals(8081, config.server.port) // Standard-Port
assertEquals("Meldestelle", config.appInfo.name.value)
assertEquals(8081, config.server.port.value) // Standard-Port
}
@Test
@@ -53,8 +53,8 @@ class ConfigLoaderTest {
val config = configLoader.load(AppEnvironment.DEVELOPMENT)
// Assert
assertEquals("TestApp", config.appInfo.name)
assertEquals(9999, config.server.port)
assertEquals("TestApp", config.appInfo.name.value)
assertEquals(9999, config.server.port.value)
}
@Test
@@ -83,8 +83,8 @@ class ConfigLoaderTest {
// Assert
assertEquals(AppEnvironment.TEST, config.environment, "Environment should be TEST")
assertEquals("TestEnvApp", config.appInfo.name, "app.name should be overridden")
assertEquals(9000, config.server.port, "server.port should be overridden")
assertEquals("base-db-host", config.database.host, "database.host should come from the base file")
assertEquals("TestEnvApp", config.appInfo.name.value, "app.name should be overridden")
assertEquals(9000, config.server.port.value, "server.port should be overridden")
assertEquals("base-db-host", config.database.host.value, "database.host should come from the base file")
}
}
@@ -1,6 +1,6 @@
package at.mocode.core.utils.database
import at.mocode.core.utils.config.DatabaseConfig
import at.mocode.core.utils.config.*
import kotlinx.coroutines.runBlocking
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.Table
@@ -23,7 +23,7 @@ class DatabaseFactoryTest {
companion object {
@Container
val postgresContainer = PostgreSQLContainer<Nothing>("postgres:16-alpine").apply {
withDatabaseName("test-db")
withDatabaseName("testdb")
withUsername("test-user")
withPassword("test-password")
}
@@ -37,15 +37,15 @@ class DatabaseFactoryTest {
fun setup() {
// Erstelle eine DB-Konfiguration mit den dynamischen Daten des gestarteten Containers
dbConfig = DatabaseConfig(
host = postgresContainer.host,
port = postgresContainer.firstMappedPort,
name = postgresContainer.databaseName,
jdbcUrl = postgresContainer.jdbcUrl,
username = postgresContainer.username,
password = postgresContainer.password,
host = Host(postgresContainer.host),
port = Port(postgresContainer.firstMappedPort),
name = DatabaseName(postgresContainer.databaseName),
jdbcUrl = JdbcUrl(postgresContainer.jdbcUrl),
username = DatabaseUsername(postgresContainer.username),
password = DatabasePassword(postgresContainer.password),
driverClassName = "org.postgresql.Driver",
maxPoolSize = 2,
minPoolSize = 1,
maxPoolSize = PoolSize(2),
minPoolSize = PoolSize(1),
autoMigrate = false // Wir steuern Migrationen im Test manuell
)
// Erstelle eine neue Factory-Instanz und verbinde sie mit der Test-DB