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
+42 -18
View File
@@ -1,27 +1,51 @@
// Dieses Modul definiert die Kern-Domänenobjekte des Shared Kernels.
// Es enthält keine Implementierungsdetails, nur reine Datenklassen und Enums.
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xopt-in=kotlin.time.ExperimentalTime")
// Target platforms
jvm {
compilerOptions {
freeCompilerArgs.add("-Xopt-in=kotlin.time.ExperimentalTime")
}
}
js(IR) {
browser()
}
sourceSets {
val commonMain by getting {
dependencies {
// Kern-Abhängigkeiten für das Domänen-Modul (common for all platforms)
api(libs.uuid)
api(libs.kotlinx.serialization.json)
api(libs.kotlinx.datetime)
}
}
val jvmMain by getting {
dependencies {
// Stellt sicher, dass dieses Modul Zugriff auf die im zentralen Katalog
// definierten Bibliotheken hat (JVM-specific)
api(projects.platform.platformDependencies)
}
}
val commonTest by getting {
dependencies {
implementation(libs.kotlin.test)
}
}
val jvmTest by getting {
dependencies {
// Stellt die Test-Bibliotheken bereit (JVM-specific)
implementation(projects.platform.platformTesting)
implementation(libs.bundles.testing.jvm)
}
}
}
}
dependencies {
// Stellt sicher, dass dieses Modul Zugriff auf die im zentralen Katalog
// definierten Bibliotheken hat.
api(projects.platform.platformDependencies)
// Kern-Abhängigkeiten für das Domänen-Modul.
api(libs.uuid)
api(libs.kotlinx.serialization.json)
api(libs.kotlinx.datetime)
// Stellt die Test-Bibliotheken bereit.
testImplementation(projects.platform.platformTesting)
testImplementation(libs.bundles.testing.jvm)
}
@@ -1,5 +1,6 @@
package at.mocode.core.domain.event
import at.mocode.core.domain.model.*
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import com.benasher44.uuid.Uuid
@@ -7,38 +8,38 @@ import com.benasher44.uuid.uuid4
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class)
/**
* Basis-Interface für alle Domänen-Events im System.
* Ein Domänen-Event repräsentiert etwas fachlich Bedeutsames, das passiert ist.
*/
interface DomainEvent {
val eventId: Uuid
val aggregateId: Uuid
val eventType: String
val eventId: EventId
val aggregateId: AggregateId
val eventType: EventType
val timestamp: Instant
val version: Long
val correlationId: Uuid?
val causationId: Uuid?
val version: EventVersion
val correlationId: CorrelationId?
val causationId: CausationId?
}
/**
* Abstrakte Basisklasse für Domänen-Events, um Boilerplate-Code zu reduzieren.
*/
@Serializable
@OptIn(ExperimentalTime::class)
abstract class BaseDomainEvent(
@Serializable(with = UuidSerializer::class)
override val aggregateId: Uuid,
override val eventType: String,
override val version: Long,
@Serializable(with = UuidSerializer::class)
override val eventId: Uuid = uuid4(),
override val aggregateId: AggregateId,
override val eventType: EventType,
override val version: EventVersion,
override val eventId: EventId = EventId(uuid4()),
@Serializable(with = KotlinInstantSerializer::class)
override val timestamp: Instant = Clock.System.now(),
@Serializable(with = UuidSerializer::class)
override val correlationId: Uuid? = null,
@Serializable(with = UuidSerializer::class)
override val causationId: Uuid? = null
override val correlationId: CorrelationId? = null,
override val causationId: CausationId? = null
) : DomainEvent
/**
@@ -5,6 +5,7 @@ import at.mocode.core.domain.serialization.UuidSerializer
import com.benasher44.uuid.Uuid
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.time.ExperimentalTime
import kotlinx.serialization.Serializable
/**
@@ -16,9 +17,9 @@ interface BaseDto
* Base DTO for domain entities that have unique ID and audit timestamps.
*/
@Serializable
@OptIn(ExperimentalTime::class)
abstract class EntityDto : BaseDto {
@Serializable(with = UuidSerializer::class)
abstract val id: Uuid
abstract val id: EntityId
@Serializable(with = KotlinInstantSerializer::class)
abstract val createdAt: Instant
@@ -41,6 +42,7 @@ data class ErrorDto(
* A standardized and consistent wrapper for all API responses.
*/
@Serializable
@OptIn(ExperimentalTime::class)
data class ApiResponse<T>(
val data: T?,
val success: Boolean,
@@ -49,10 +51,12 @@ data class ApiResponse<T>(
val timestamp: Instant = Clock.System.now()
) {
companion object {
@OptIn(ExperimentalTime::class)
fun <T> success(data: T): ApiResponse<T> {
return ApiResponse(data = data, success = true)
}
@OptIn(ExperimentalTime::class)
fun <T> error(
code: String,
message: String,
@@ -65,6 +69,7 @@ data class ApiResponse<T>(
)
}
@OptIn(ExperimentalTime::class)
fun <T> error(errors: List<ErrorDto>): ApiResponse<T> {
return ApiResponse(data = null, success = false, errors = errors)
}
@@ -0,0 +1,134 @@
package at.mocode.core.domain.model
import at.mocode.core.domain.serialization.UuidSerializer
import com.benasher44.uuid.Uuid
import kotlin.jvm.JvmInline
import kotlinx.serialization.Serializable
/**
* Value classes for strongly typed IDs and domain values.
* These provide compile-time type safety without runtime overhead.
*/
// === ID Value Classes ===
/**
* A strongly typed wrapper for entity IDs.
*/
@Serializable
@JvmInline
value class EntityId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for event IDs.
*/
@Serializable
@JvmInline
value class EventId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for aggregate IDs.
*/
@Serializable
@JvmInline
value class AggregateId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for correlation IDs used in event tracing.
*/
@Serializable
@JvmInline
value class CorrelationId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for causation IDs used in event tracing.
*/
@Serializable
@JvmInline
value class CausationId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
}
// === Domain Value Classes ===
/**
* A strongly typed wrapper for event types.
*/
@Serializable
@JvmInline
value class EventType(val value: String) {
init {
require(value.isNotBlank()) { "Event type cannot be blank" }
require(value.matches(Regex("^[A-Za-z][A-Za-z0-9]*$"))) {
"Event type must start with a letter and contain only alphanumeric characters"
}
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for event version numbers.
*/
@Serializable
@JvmInline
value class EventVersion(val value: Long) : Comparable<EventVersion> {
init {
require(value >= 0) { "Event version must be non-negative" }
}
override fun toString(): String = value.toString()
override fun compareTo(other: EventVersion): Int = value.compareTo(other.value)
}
/**
* A strongly typed wrapper for error codes.
*/
@Serializable
@JvmInline
value class ErrorCode(val value: String) {
init {
require(value.isNotBlank()) { "Error code cannot be blank" }
require(value.matches(Regex("^[A-Z][A-Z0-9_]*$"))) {
"Error code must be uppercase and contain only letters, numbers, and underscores"
}
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for page numbers in pagination.
*/
@Serializable
@JvmInline
value class PageNumber(val value: Int) {
init {
require(value >= 0) { "Page number must be non-negative" }
}
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for page sizes in pagination.
*/
@Serializable
@JvmInline
value class PageSize(val value: Int) {
init {
require(value > 0) { "Page size must be positive" }
require(value <= 1000) { "Page size cannot exceed 1000" }
}
override fun toString(): String = value.toString()
}
@@ -3,6 +3,7 @@ package at.mocode.core.domain.serialization
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuidFrom
import kotlin.time.Instant // KORRIGIERT: Finaler Wechsel zu kotlin.time
import kotlin.time.ExperimentalTime
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -19,6 +20,7 @@ object UuidSerializer : KSerializer<Uuid> {
override fun deserialize(decoder: Decoder): Uuid = uuidFrom(decoder.decodeString())
}
@OptIn(ExperimentalTime::class)
object KotlinInstantSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
@@ -1,6 +1,7 @@
package at.mocode.core.domain
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.model.*
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuid4
import kotlinx.serialization.Serializable
@@ -22,21 +23,21 @@ class DomainEventTest {
@Serializable
data class TestEvent(
@Transient
override val aggregateId: Uuid = uuid4(),
override val aggregateId: AggregateId = AggregateId(uuid4()),
@Transient
override val version: Long = 1L,
override val version: EventVersion = EventVersion(1L),
val testPayload: String = "Test"
) : BaseDomainEvent(
aggregateId = aggregateId,
eventType = "TestEventOccurred", // Ein klar definierter Event-Typ
eventType = EventType("TestEventOccurred"), // Ein klar definierter Event-Typ
version = version
)
@Test
fun `BaseDomainEvent should auto-generate eventId and timestamp upon creation`() {
// Arrange
val aggregateId = uuid4()
val version = 1L
val aggregateId = AggregateId(uuid4())
val version = EventVersion(1L)
// Act
val event = TestEvent(aggregateId, version)
@@ -46,6 +47,6 @@ class DomainEventTest {
assertNotNull(event.timestamp, "timestamp should be automatically generated and not null")
assertEquals(aggregateId, event.aggregateId, "aggregateId should be set correctly")
assertEquals(version, event.version, "version should be set correctly")
assertEquals("TestEventOccurred", event.eventType, "eventType should be set correctly")
assertEquals(EventType("TestEventOccurred"), event.eventType, "eventType should be set correctly")
}
}
+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