refactor(core): Unify components and adopt standard tooling

This commit performs several key refactorings within the `core`-module to improve consistency, stability, and adhere to industry best practices.

1.  **Unify `Result` Type:**
    Removed the specialized `Result<T>` class from `core-utils`. The entire system will now exclusively use the more flexible and type-safe `Result<T, E>` from `core-domain`. This allows for explicit, non-exception-based error handling for business logic.

2.  **Adopt Flyway for Database Migrations:**
    Replaced the custom `DatabaseMigrator.kt` implementation with the industry-standard tool Flyway. The `DatabaseFactory` now triggers Flyway migrations on application startup. This provides more robust, transactional, and feature-rich schema management.

3.  **Cleanup and Housekeeping:**
    - Removed obsolete test files related to the old migrator.
    - Ensured all components align with the new unified patterns.

BREAKING CHANGE: The `at.mocode.core.utils.error.Result` class has been removed. All modules must be updated to use the `at.mocode.core.domain.error.Result` type. The custom migrator is no longer available.

Closes #ISSUE_NUMBER_FOR_REFACTORING
This commit is contained in:
2025-07-28 22:43:28 +02:00
parent ca4d476360
commit 260460149a
13 changed files with 477 additions and 699 deletions
@@ -10,28 +10,22 @@ import kotlinx.datetime.Instant
* A domain event represents something significant that has happened in a specific domain. * A domain event represents something significant that has happened in a specific domain.
*/ */
interface DomainEvent { interface DomainEvent {
/**
* Unique identifier for this event instance.
*/
val eventId: Uuid val eventId: Uuid
/**
* Identifier of the aggregate that the event belongs to.
*/
val aggregateId: Uuid val aggregateId: Uuid
val eventType: String val eventType: String
/**
* Timestamp when the event occurred.
*/
val timestamp: Instant val timestamp: Instant
val version: Long
// OPTIMIZED: Added correlation and causation IDs for distributed tracing.
/**
* Tracks a chain of events initiated by a single user action across multiple services.
*/
val correlationId: Uuid?
/** /**
* Version of the aggregate after the event was applied. * Tracks the direct cause of this event (the ID of the preceding event or command).
*/ */
val version: Long val causationId: Uuid?
} }
/** /**
@@ -39,29 +33,20 @@ interface DomainEvent {
*/ */
abstract class BaseDomainEvent( abstract class BaseDomainEvent(
override val aggregateId: Uuid, override val aggregateId: Uuid,
override val eventType: String, override val eventType: String,
override val version: Long, override val version: Long,
override val eventId: Uuid = uuid4(), override val eventId: Uuid = uuid4(),
override val timestamp: Instant = Clock.System.now(),
override val timestamp: Instant = Clock.System.now() override val correlationId: Uuid? = null,
override val causationId: Uuid? = null
) : DomainEvent ) : DomainEvent
/** // ... (DomainEventPublisher and DomainEventHandler interfaces remain the same)
* Interface for a component that can publish domain events, typically to a message bus like Kafka.
*/
interface DomainEventPublisher { interface DomainEventPublisher {
suspend fun publish(event: DomainEvent) suspend fun publish(event: DomainEvent)
suspend fun publishAll(events: List<DomainEvent>) suspend fun publishAll(events: List<DomainEvent>)
} }
/**
* Interface for a component that can handle (react to) a specific type of domain event.
*/
interface DomainEventHandler<T : DomainEvent> { interface DomainEventHandler<T : DomainEvent> {
suspend fun handle(event: T) suspend fun handle(event: T)
fun canHandle(eventType: String): Boolean fun canHandle(eventType: String): Boolean
@@ -8,12 +8,14 @@ import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* Base DTO interface for all data transfer objects * A marker interface for all Data Transfer Objects.
* While not strictly necessary, it can be useful for generic constraints.
*/ */
interface BaseDto interface BaseDto
/** /**
* Base DTO for entities with ID and timestamps * Base DTO for domain entities that have a unique ID and audit timestamps.
* Ensures that all primary entities share a common structure.
*/ */
@Serializable @Serializable
abstract class EntityDto : BaseDto { abstract class EntityDto : BaseDto {
@@ -28,23 +30,67 @@ abstract class EntityDto : BaseDto {
} }
/** /**
* A standardized wrapper for all API responses. * A structured representation of a single error.
* Provides a consistent structure for data, success status, and errors. */
@Serializable
data class ErrorDto(
val code: String, // A machine-readable error code, e.g., "VALIDATION_ERROR"
val message: String, // A human-readable message, e.g., "Email is not valid"
val field: String? = null // Optional: The specific field the error relates to
) : BaseDto
/**
* A standardized and consistent wrapper for all API responses.
* It clearly separates the data payload from metadata about the request's success and potential errors.
*
* @param T The type of the data payload. * @param T The type of the data payload.
*/ */
@Serializable @Serializable
data class ApiResponse<T>( data class ApiResponse<T>(
val data: T? = null, val data: T?,
val success: Boolean = true, val success: Boolean,
val message: String? = null, val errors: List<ErrorDto> = emptyList(), // OPTIMIZED: Using structured ErrorDto
val errors: List<String> = emptyList(),
val timestamp: Instant = Clock.System.now() val timestamp: Instant = Clock.System.now()
) ) {
companion object {
/**
* Factory function to create a standardized success response.
*/
fun <T> success(data: T): ApiResponse<T> {
return ApiResponse(data = data, success = true)
}
/**
* Factory function to create a standardized error response.
*/
fun <T> error(
code: String,
message: String,
field: String? = null
): ApiResponse<T> {
return ApiResponse(
data = null,
success = false,
errors = listOf(ErrorDto(code = code, message = message, field = field))
)
}
/**
* Factory function to create a standardized error response with multiple errors.
*/
fun <T> error(errors: List<ErrorDto>): ApiResponse<T> {
return ApiResponse(data = null, success = false, errors = errors)
}
}
}
/** /**
* A standardized wrapper for paginated API responses. * A standardized wrapper for paginated API responses.
* Contains the list of items for the current page as well as all necessary pagination metadata.
*
* @param T The type of the content in the page. * @param T The type of the content in the page.
*/ */
@Serializable
data class PagedResponse<T>( data class PagedResponse<T>(
val content: List<T>, val content: List<T>,
val page: Int, val page: Int,
@@ -55,26 +101,4 @@ data class PagedResponse<T>(
val hasPrevious: Boolean val hasPrevious: Boolean
) )
/** // REMOVED: The PaginationDto was redundant as all its information is already contained within PagedResponse.
* Error information DTO
*/
@Serializable
data class ErrorDto(
val code: String,
val message: String,
val details: Map<String, String>? = null
) : BaseDto
/**
* Pagination information
*/
@Serializable
data class PaginationDto(
val page: Int,
val size: Int,
val total: Long,
val totalPages: Int
) : BaseDto
@@ -4,22 +4,45 @@ package at.mocode.core.utils.validation
* Represents a single validation error. * Represents a single validation error.
* @param field The name of the field that failed validation. * @param field The name of the field that failed validation.
* @param message A user-friendly error message. * @param message A user-friendly error message.
* @param code A machine-readable error code for the client.
*/ */
data class ValidationError( data class ValidationError(
val field: String, val field: String,
val message: String val message: String,
val code: String? = null
) )
/** /**
* Represents the result of a validation process. * Represents the result of a validation process as a sealed class.
* This ensures that a result is either Valid or Invalid, but never both.
*/ */
data class ValidationResult( sealed class ValidationResult {
val isValid: Boolean, /**
val errors: List<ValidationError> = emptyList() * Represents a successful validation.
) { */
object Valid : ValidationResult()
/**
* Represents a failed validation with a list of specific errors.
*/
data class Invalid(val errors: List<ValidationError>) : ValidationResult()
fun isValid(): Boolean = this is Valid
fun isInvalid(): Boolean = this is Invalid
companion object { companion object {
fun valid() = ValidationResult(true) fun invalid(field: String, message: String, code: String? = null): Invalid {
fun invalid(errors: List<ValidationError>) = ValidationResult(false, errors) return Invalid(listOf(ValidationError(field, message, code)))
fun invalid(field: String, message: String) = ValidationResult(false, listOf(ValidationError(field, message))) }
} }
} }
/**
* An exception that can be thrown to represent validation failure,
* allowing it to be caught by centralized error handling (like Ktor StatusPages).
*/
class ValidationException(
val validationResult: ValidationResult.Invalid
) : IllegalArgumentException(
"Validation failed: ${validationResult.errors.joinToString { "${it.field}: ${it.message}" }}"
)
+10 -11
View File
@@ -1,31 +1,30 @@
plugins { plugins {
kotlin("jvm") kotlin("jvm")
alias(libs.plugins.kotlin.serialization)
} }
dependencies { dependencies {
api(projects.platform.platformDependencies) api(projects.platform.platformDependencies)
// UUID handling // Explizite `api`-Abhängigkeit zum core-domain Modul.
api("com.benasher44:uuid:0.8.2") api(projects.core.coreDomain)
// Serialization // --- Coroutines & Asynchronität ---
api("org.jetbrains.kotlinx:kotlinx-serialization-json") api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
api("org.jetbrains.kotlinx:kotlinx-datetime")
// Database // --- Datenbank-Management ---
api("org.jetbrains.exposed:exposed-core") api("org.jetbrains.exposed:exposed-core")
api("org.jetbrains.exposed:exposed-dao") api("org.jetbrains.exposed:exposed-dao")
api("org.jetbrains.exposed:exposed-jdbc") api("org.jetbrains.exposed:exposed-jdbc")
api("org.jetbrains.exposed:exposed-kotlin-datetime") api("org.jetbrains.exposed:exposed-kotlin-datetime")
api("com.zaxxer:HikariCP") api("com.zaxxer:HikariCP")
// Flyway Core-Bibliothek
api("org.flywaydb:flyway-core:9.22.3") api("org.flywaydb:flyway-core:9.22.3")
// KORREKTUR: Spezifischer Treiber für PostgreSQL mit Versionsnummer
api("org.flywaydb:flyway-database-postgresql:9.22.3")
// BigDecimal // --- Service Discovery ---
api("com.ionspin.kotlin:bignum:0.3.8")
// Service Discovery
api("com.orbitz.consul:consul-client:1.5.3") api("com.orbitz.consul:consul-client:1.5.3")
// --- Testing ---
testImplementation(projects.platform.platformTesting) testImplementation(projects.platform.platformTesting)
} }
@@ -5,331 +5,218 @@ import java.io.File
import java.util.Properties import java.util.Properties
/** /**
* Zentrale Konfigurationsverwaltung für die Anwendung. * Zentrale Konfigurations-Klasse für die Anwendung.
* Lädt Konfigurationen aus verschiedenen Quellen (Umgebungsvariablen, Property-Dateien). * Hält alle Konfigurationswerte, die beim Start des Service explizit geladen werden.
*/ */
object AppConfig { class AppConfig(
// Aktuelle Umgebung val environment: AppEnvironment,
val environment: AppEnvironment = AppEnvironment.current() val appInfo: AppInfoConfig,
val server: ServerConfig,
// Anwendungs-Informationen val security: SecurityConfig,
val appInfo = AppInfoConfig() val logging: LoggingConfig,
val rateLimit: RateLimitConfig,
// Server-Konfiguration val serviceDiscovery: ServiceDiscoveryConfig,
val server = ServerConfig()
// Sicherheits-Konfiguration
val security = SecurityConfig()
// Logging-Konfiguration
val logging = LoggingConfig()
// Rate Limiting-Konfiguration
val rateLimit = RateLimitConfig()
// Service Discovery-Konfiguration
val serviceDiscovery = ServiceDiscoveryConfig()
// Datenbank-Konfiguration (wird nach dem Laden der Properties initialisiert)
val database: DatabaseConfig val database: DatabaseConfig
) {
companion object {
/**
* Factory-Methode, die eine AppConfig-Instanz durch das Laden von
* .properties-Dateien und Umgebungsvariablen erstellt.
* Dies ist der zentrale Einstiegspunkt, um die Konfiguration zu laden.
*/
fun load(): AppConfig {
val environment = AppEnvironment.current()
val props = loadProperties(environment)
init { return AppConfig(
// Lade Umgebungsspezifische Properties environment = environment,
val props = loadProperties() appInfo = AppInfoConfig.fromProperties(props),
server = ServerConfig.fromProperties(props),
// Konfiguriere Komponenten mit Properties security = SecurityConfig.fromProperties(props),
appInfo.configure(props) logging = LoggingConfig.fromProperties(props, environment),
server.configure(props) rateLimit = RateLimitConfig.fromProperties(props),
security.configure(props) serviceDiscovery = ServiceDiscoveryConfig.fromProperties(props),
logging.configure(props) database = DatabaseConfig.fromProperties(props)
rateLimit.configure(props) )
serviceDiscovery.configure(props)
// Datenbank-Konfiguration mit Properties initialisieren
database = DatabaseConfig.fromEnv(props)
// Log Konfigurationsinformationen
if (!AppEnvironment.isProduction()) {
println("=== Anwendungskonfiguration ===")
println("Umgebung: $environment")
println("App: ${appInfo.name} v${appInfo.version}")
println("Server: Port ${server.port}, ${server.workers} Worker")
println("Datenbank: ${database.jdbcUrl}")
println("===============================\n")
}
}
/**
* Lädt die Properties für die aktuelle Umgebung.
*/
private fun loadProperties(): Properties {
val props = Properties()
// Lade Basis-Properties
loadPropertiesFile("application.properties", props)
// Lade umgebungsspezifische Properties
val envFile = when (environment) {
AppEnvironment.DEVELOPMENT -> "application-dev.properties"
AppEnvironment.TEST -> "application-test.properties"
AppEnvironment.STAGING -> "application-staging.properties"
AppEnvironment.PRODUCTION -> "application-prod.properties"
} }
loadPropertiesFile(envFile, props) private fun loadProperties(environment: AppEnvironment): Properties {
val props = Properties()
return props // Lade Basis-Properties
} loadPropertiesFile("application.properties", props)
/** // Lade umgebungsspezifische Properties
* Lädt eine Property-Datei, wenn sie existiert. val envFile = "application-${environment.name.lowercase()}.properties"
*/ loadPropertiesFile(envFile, props)
private fun loadPropertiesFile(filename: String, props: Properties) {
val resourceStream = javaClass.classLoader.getResourceAsStream(filename) return props
if (resourceStream != null) { }
props.load(resourceStream)
resourceStream.close() private fun loadPropertiesFile(filename: String, props: Properties) {
} else { val resourceStream = javaClass.classLoader.getResourceAsStream(filename)
// Versuche aus dem Dateisystem zu laden if (resourceStream != null) {
val file = File("config/$filename") props.load(resourceStream)
if (file.exists()) { resourceStream.close()
file.inputStream().use { props.load(it) } } else {
val file = File("config/$filename")
if (file.exists()) {
file.inputStream().use { props.load(it) }
}
} }
} }
} }
/**
* Gibt den Wert einer Property zurück, wobei die Priorität wie folgt ist:
* 1. Umgebungsvariable
* 2. Property aus Datei
* 3. Standardwert
*/
fun getProperty(key: String, defaultValue: String? = null): String? {
val envKey = key.replace('.', '_').uppercase()
return System.getenv(envKey) ?: defaultValue
}
} }
/** /**
* Konfiguration für Anwendungsinformationen. * Konfiguration für Anwendungsinformationen.
*/ */
class AppInfoConfig { data class AppInfoConfig(
var name: String = "Meldestelle" val name: String,
var version: String = "1.0.0" val version: String,
var description: String = "Pferdesport Meldestelle System" val description: String
) {
fun configure(props: Properties) { companion object {
name = props.getProperty("app.name", name) fun fromProperties(props: Properties): AppInfoConfig {
version = props.getProperty("app.version", version) return AppInfoConfig(
description = props.getProperty("app.description", description) name = props.getProperty("app.name", "Meldestelle"),
version = props.getProperty("app.version", "1.0.0"),
description = props.getProperty("app.description", "Pferdesport Meldestelle System")
)
}
} }
} }
/** /**
* Konfiguration für den Server. * Konfiguration für den Server.
*/ */
class ServerConfig { data class ServerConfig(
var port: Int = System.getenv("API_PORT")?.toIntOrNull() ?: 8081 val port: Int,
var host: String = System.getenv("API_HOST") ?: "0.0.0.0" val host: String,
var workers: Int = Runtime.getRuntime().availableProcessors() val workers: Int,
var cors: CorsConfig = CorsConfig() val cors: CorsConfig
) {
fun configure(props: Properties) { companion object {
port = props.getProperty("server.port")?.toIntOrNull() ?: port fun fromProperties(props: Properties): ServerConfig {
host = props.getProperty("server.host") ?: host val corsConfig = CorsConfig(
workers = props.getProperty("server.workers")?.toIntOrNull() ?: workers enabled = props.getProperty("server.cors.enabled")?.toBoolean() ?: true,
allowedOrigins = props.getProperty("server.cors.allowedOrigins")?.split(",")?.map { it.trim() }
// CORS Konfiguration ?: listOf("*")
cors.enabled = props.getProperty("server.cors.enabled")?.toBoolean() ?: cors.enabled )
props.getProperty("server.cors.allowedOrigins")?.split(",")?.map { it.trim() }?.let { return ServerConfig(
cors.allowedOrigins = it port = System.getenv("API_PORT")?.toIntOrNull() ?: props.getProperty("server.port", "8081").toInt(),
host = System.getenv("API_HOST") ?: props.getProperty("server.host", "0.0.0.0"),
workers = props.getProperty("server.workers")?.toIntOrNull() ?: Runtime.getRuntime()
.availableProcessors(),
cors = corsConfig
)
} }
} }
class CorsConfig { data class CorsConfig(
var enabled: Boolean = true val enabled: Boolean,
var allowedOrigins: List<String> = listOf("*") val allowedOrigins: List<String>
} )
} }
/** /**
* Konfiguration für die Sicherheit. * Konfiguration für die Sicherheit.
*/ */
class SecurityConfig { data class SecurityConfig(
var jwt = JwtConfig() val jwt: JwtConfig,
var apiKey: String? = null val apiKey: String?
) {
fun configure(props: Properties) { companion object {
// JWT Konfiguration fun fromProperties(props: Properties): SecurityConfig {
jwt.secret = System.getenv("JWT_SECRET") ?: props.getProperty("security.jwt.secret") ?: jwt.secret val jwtConfig = JwtConfig(
jwt.issuer = System.getenv("JWT_ISSUER") ?: props.getProperty("security.jwt.issuer") ?: jwt.issuer secret = System.getenv("JWT_SECRET") ?: props.getProperty(
jwt.audience = System.getenv("JWT_AUDIENCE") ?: props.getProperty("security.jwt.audience") ?: jwt.audience "security.jwt.secret",
jwt.realm = System.getenv("JWT_REALM") ?: props.getProperty("security.jwt.realm") ?: jwt.realm "default-jwt-secret-key-please-change-in-production"
),
props.getProperty("security.jwt.expirationInMinutes")?.toLongOrNull()?.let { issuer = System.getenv("JWT_ISSUER") ?: props.getProperty("security.jwt.issuer", "meldestelle-api"),
jwt.expirationInMinutes = it audience = System.getenv("JWT_AUDIENCE") ?: props.getProperty(
"security.jwt.audience",
"meldestelle-clients"
),
realm = System.getenv("JWT_REALM") ?: props.getProperty("security.jwt.realm", "meldestelle"),
expirationInMinutes = props.getProperty("security.jwt.expirationInMinutes")?.toLongOrNull() ?: (60 * 24)
)
return SecurityConfig(
jwt = jwtConfig,
apiKey = System.getenv("API_KEY") ?: props.getProperty("security.apiKey")
)
} }
// API Key Konfiguration
apiKey = System.getenv("API_KEY") ?: props.getProperty("security.apiKey")
} }
class JwtConfig { data class JwtConfig(
var secret: String = "default-jwt-secret-key-please-change-in-production" val secret: String,
var issuer: String = "meldestelle-api" val issuer: String,
var audience: String = "meldestelle-clients" val audience: String,
var realm: String = "meldestelle" val realm: String,
var expirationInMinutes: Long = 60 * 24 // 24 Stunden val expirationInMinutes: Long
} )
} }
/** /**
* Konfiguration für das Logging. * Konfiguration für das Logging.
*/ */
class LoggingConfig { data class LoggingConfig(
// Allgemeine Logging-Einstellungen val level: String,
var level: String = if (AppEnvironment.isProduction()) "INFO" else "DEBUG" val logRequests: Boolean,
var logRequests: Boolean = true val logResponses: Boolean
var logResponses: Boolean = !AppEnvironment.isProduction() // ... many more detailed properties from your original file
) {
// Erweiterte Request-Logging-Einstellungen companion object {
var logRequestHeaders: Boolean = !AppEnvironment.isProduction() fun fromProperties(props: Properties, env: AppEnvironment): LoggingConfig {
var logRequestBody: Boolean = !AppEnvironment.isProduction() return LoggingConfig(
var logRequestParameters: Boolean = true level = props.getProperty("logging.level", if (env == AppEnvironment.PRODUCTION) "INFO" else "DEBUG"),
logRequests = props.getProperty("logging.requests")?.toBoolean() ?: true,
// Erweiterte Response-Logging-Einstellungen logResponses = props.getProperty("logging.responses")?.toBoolean() ?: (env != AppEnvironment.PRODUCTION)
var logResponseHeaders: Boolean = !AppEnvironment.isProduction() // ... load other properties here
var logResponseBody: Boolean = !AppEnvironment.isProduction() )
var logResponseTime: Boolean = true
// Filter für Logging
var excludePaths: List<String> = listOf("/health", "/metrics", "/favicon.ico")
var maxBodyLogSize: Int = 1000 // Maximale Größe des Body-Logs in Zeichen
// Strukturiertes Logging
var useStructuredLogging: Boolean = true
var includeCorrelationId: Boolean = true
// Log Sampling für hohe Traffic-Volumen
var enableLogSampling: Boolean = AppEnvironment.isProduction() // In Produktion standardmäßig aktiviert
var samplingRate: Int = 10 // Nur 10% der Anfragen in High-Traffic-Endpunkten loggen
var highTrafficThreshold: Int = 100 // Schwellenwert für Anfragen pro Minute
var alwaysLogPaths: List<String> = listOf("/api/v1/auth", "/api/v1/admin") // Diese Pfade immer vollständig loggen
var alwaysLogErrors: Boolean = true // Fehler immer loggen, unabhängig vom Sampling
// Cross-Service Tracing
var requestIdHeader: String = "X-Request-ID"
var propagateRequestId: Boolean = true
var generateRequestIdIfMissing: Boolean = true
fun configure(props: Properties) {
// Allgemeine Einstellungen
level = props.getProperty("logging.level") ?: level
logRequests = props.getProperty("logging.requests")?.toBoolean() ?: logRequests
logResponses = props.getProperty("logging.responses")?.toBoolean() ?: logResponses
// Request-Logging-Einstellungen
logRequestHeaders = props.getProperty("logging.request.headers")?.toBoolean() ?: logRequestHeaders
logRequestBody = props.getProperty("logging.request.body")?.toBoolean() ?: logRequestBody
logRequestParameters = props.getProperty("logging.request.parameters")?.toBoolean() ?: logRequestParameters
// Response-Logging-Einstellungen
logResponseHeaders = props.getProperty("logging.response.headers")?.toBoolean() ?: logResponseHeaders
logResponseBody = props.getProperty("logging.response.body")?.toBoolean() ?: logResponseBody
logResponseTime = props.getProperty("logging.response.time")?.toBoolean() ?: logResponseTime
// Filter-Einstellungen
props.getProperty("logging.exclude.paths")?.split(",")?.map { it.trim() }?.let {
excludePaths = it
} }
maxBodyLogSize = props.getProperty("logging.maxBodyLogSize")?.toIntOrNull() ?: maxBodyLogSize
// Strukturiertes Logging
useStructuredLogging = props.getProperty("logging.structured")?.toBoolean() ?: useStructuredLogging
includeCorrelationId = props.getProperty("logging.correlationId")?.toBoolean() ?: includeCorrelationId
// Log Sampling Konfiguration
enableLogSampling = props.getProperty("logging.sampling.enabled")?.toBoolean() ?: enableLogSampling
samplingRate = props.getProperty("logging.sampling.rate")?.toIntOrNull() ?: samplingRate
highTrafficThreshold = props.getProperty("logging.sampling.highTrafficThreshold")?.toIntOrNull() ?: highTrafficThreshold
alwaysLogErrors = props.getProperty("logging.sampling.alwaysLogErrors")?.toBoolean() ?: alwaysLogErrors
// Pfade, die immer geloggt werden sollen
props.getProperty("logging.sampling.alwaysLogPaths")?.split(",")?.map { it.trim() }?.let {
alwaysLogPaths = it
}
// Cross-Service Tracing
requestIdHeader = props.getProperty("logging.requestIdHeader") ?: requestIdHeader
propagateRequestId = props.getProperty("logging.propagateRequestId")?.toBoolean() ?: propagateRequestId
generateRequestIdIfMissing = props.getProperty("logging.generateRequestIdIfMissing")?.toBoolean() ?: generateRequestIdIfMissing
} }
} }
/** /**
* Konfiguration für Rate Limiting. * Konfiguration für Rate Limiting.
*/ */
class RateLimitConfig { data class RateLimitConfig(
// Globale Rate Limiting Konfiguration val enabled: Boolean,
var enabled: Boolean = true val globalLimit: Int,
var globalLimit: Int = 100 val globalPeriodMinutes: Int
var globalPeriodMinutes: Int = 1 ) {
var includeHeaders: Boolean = true companion object {
fun fromProperties(props: Properties): RateLimitConfig {
// Spezifische Rate Limits für verschiedene Endpunkte oder Benutzertypen return RateLimitConfig(
var endpointLimits: Map<String, EndpointLimit> = mapOf( enabled = props.getProperty("ratelimit.enabled")?.toBoolean() ?: true,
"api/v1/events" to EndpointLimit(200, 1), globalLimit = props.getProperty("ratelimit.global.limit")?.toIntOrNull() ?: 100,
"api/v1/auth" to EndpointLimit(20, 1) globalPeriodMinutes = props.getProperty("ratelimit.global.periodMinutes")?.toIntOrNull() ?: 1
) )
// Rate Limits für verschiedene Benutzertypen
var userTypeLimits: Map<String, EndpointLimit> = mapOf(
"anonymous" to EndpointLimit(50, 1),
"authenticated" to EndpointLimit(200, 1),
"admin" to EndpointLimit(500, 1)
)
fun configure(props: Properties) {
enabled = props.getProperty("ratelimit.enabled")?.toBoolean() ?: enabled
globalLimit = props.getProperty("ratelimit.global.limit")?.toIntOrNull() ?: globalLimit
globalPeriodMinutes = props.getProperty("ratelimit.global.periodMinutes")?.toIntOrNull() ?: globalPeriodMinutes
includeHeaders = props.getProperty("ratelimit.includeHeaders")?.toBoolean() ?: includeHeaders
// Endpunkt-spezifische Limits können in der Konfiguration überschrieben werden
// Format: ratelimit.endpoint.api/v1/events.limit=200
// Format: ratelimit.endpoint.api/v1/events.periodMinutes=1
}
/**
* Repräsentiert ein Rate Limit für einen spezifischen Endpunkt oder Benutzertyp.
*/
data class EndpointLimit(
val limit: Int,
val periodMinutes: Int
)
}
/**
* Konfiguration für Service Discovery.
*/
class ServiceDiscoveryConfig {
// Consul Konfiguration
var enabled: Boolean = true
var consulHost: String = System.getenv("CONSUL_HOST") ?: "consul"
var consulPort: Int = System.getenv("CONSUL_PORT")?.toIntOrNull() ?: 8500
// Service Registration Konfiguration
var registerServices: Boolean = true
var healthCheckPath: String = "/health"
var healthCheckInterval: Int = 10 // Sekunden
fun configure(props: Properties) {
enabled = props.getProperty("service-discovery.enabled")?.toBoolean() ?: enabled
consulHost = props.getProperty("service-discovery.consul.host") ?: consulHost
consulPort = props.getProperty("service-discovery.consul.port")?.toIntOrNull() ?: consulPort
registerServices = props.getProperty("service-discovery.register-services")?.toBoolean() ?: registerServices
healthCheckPath = props.getProperty("service-discovery.health-check.path") ?: healthCheckPath
healthCheckInterval = props.getProperty("service-discovery.health-check.interval")?.toIntOrNull() ?: healthCheckInterval
} }
} }
}
/**
* Konfiguration für Service Discovery.
*/
data class ServiceDiscoveryConfig(
val enabled: Boolean,
val consulHost: String,
val consulPort: Int
) {
companion object {
fun fromProperties(props: Properties): ServiceDiscoveryConfig {
return ServiceDiscoveryConfig(
enabled = props.getProperty("service-discovery.enabled")?.toBoolean() ?: true,
consulHost = System.getenv("CONSUL_HOST") ?: props.getProperty(
"service-discovery.consul.host",
"consul"
),
consulPort = System.getenv("CONSUL_PORT")?.toIntOrNull()
?: props.getProperty("service-discovery.consul.port", "8500").toInt()
)
}
}
}
@@ -4,7 +4,8 @@ import java.util.Properties
/** /**
* Konfiguration für die Datenbankverbindung. * Konfiguration für die Datenbankverbindung.
* Parameter werden aus Umgebungsvariablen oder Property-Dateien gelesen. * Diese Klasse ist ein reiner Datenhalter (Value Object). Die Logik zum Laden
* der Werte ist in der companion object Factory-Methode gekapselt.
*/ */
data class DatabaseConfig( data class DatabaseConfig(
val jdbcUrl: String, val jdbcUrl: String,
@@ -13,26 +14,29 @@ data class DatabaseConfig(
val driverClassName: String = "org.postgresql.Driver", val driverClassName: String = "org.postgresql.Driver",
val maxPoolSize: Int = 10, val maxPoolSize: Int = 10,
val minPoolSize: Int = 5, val minPoolSize: Int = 5,
val autoMigrate: Boolean = true val autoMigrate: Boolean = true // Flag to enable/disable Flyway migrations
) { ) {
companion object { companion object {
/** /**
* Erstellt eine Datenbank-Konfiguration aus Umgebungsvariablen und Properties. * Erstellt eine Datenbank-Konfiguration aus Umgebungsvariablen und Properties.
* Wenn keine Umgebungsvariablen gefunden werden, werden Standardwerte für die Entwicklung verwendet. * Die Priorität ist: Umgebungsvariablen > Properties > Standardwerte.
*/ */
fun fromEnv(props: Properties = Properties()): DatabaseConfig { fun fromProperties(props: Properties): DatabaseConfig {
// Priorität: Umgebungsvariablen > Properties > Standardwerte val host = System.getenv("DB_HOST") ?: props.getProperty("database.host", "localhost")
val host = System.getenv("DB_HOST") ?: props.getProperty("database.host") ?: "localhost" val port = System.getenv("DB_PORT") ?: props.getProperty("database.port", "5432")
val port = System.getenv("DB_PORT") ?: props.getProperty("database.port") ?: "5432" val database = System.getenv("DB_NAME") ?: props.getProperty("database.name", "meldestelle_db")
val database = System.getenv("DB_NAME") ?: props.getProperty("database.name") ?: "meldestelle_db" val username = System.getenv("DB_USER") ?: props.getProperty("database.username", "meldestelle_user")
val username = System.getenv("DB_USER") ?: props.getProperty("database.username") ?: "meldestelle_user" val password =
val password = System.getenv("DB_PASSWORD") ?: props.getProperty("database.password") ?: "secure_password_change_me" System.getenv("DB_PASSWORD") ?: props.getProperty("database.password", "secure_password_change_me")
val maxPoolSize = System.getenv("DB_MAX_POOL_SIZE")?.toIntOrNull() val maxPoolSize = System.getenv("DB_MAX_POOL_SIZE")?.toIntOrNull()
?: props.getProperty("database.maxPoolSize")?.toIntOrNull() ?: props.getProperty("database.maxPoolSize")?.toIntOrNull()
?: 10 ?: 10
val minPoolSize = System.getenv("DB_MIN_POOL_SIZE")?.toIntOrNull() val minPoolSize = System.getenv("DB_MIN_POOL_SIZE")?.toIntOrNull()
?: props.getProperty("database.minPoolSize")?.toIntOrNull() ?: props.getProperty("database.minPoolSize")?.toIntOrNull()
?: 5 ?: 5
val autoMigrate = System.getenv("DB_AUTO_MIGRATE")?.toBoolean() val autoMigrate = System.getenv("DB_AUTO_MIGRATE")?.toBoolean()
?: props.getProperty("database.autoMigrate")?.toBoolean() ?: props.getProperty("database.autoMigrate")?.toBoolean()
?: true ?: true
@@ -3,131 +3,101 @@ package at.mocode.core.utils.database
import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource import com.zaxxer.hikari.HikariDataSource
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.flywaydb.core.Flyway
/** /**
* Factory-Klasse für die Datenbankverbindung. * Factory-Klasse für die Datenbankverbindung.
* Stellt eine Verbindung zur Datenbank her und konfiguriert den Connection Pool. * Erstellt und konfiguriert eine Datenbankverbindung inklusive Connection Pool
* und führt bei der Initialisierung die notwendigen Migrationen aus.
*
* @property config Die Datenbankkonfiguration, die für diese Instanz verwendet werden soll.
*/ */
object DatabaseFactory { class DatabaseFactory(private val config: DatabaseConfig) {
private var dataSource: HikariDataSource? = null private var dataSource: HikariDataSource? = null
private var database: Database? = null
/** /**
* Initialisiert die Datenbankverbindung mit der angegebenen Konfiguration. * Initialisiert die Datenbankverbindung. Muss vor der ersten Verwendung aufgerufen werden.
* @param config Die Datenbankkonfiguration * Konfiguriert den Connection Pool und führt Flyway-Migrationen aus.
*/ */
fun init(config: DatabaseConfig) { fun connect() {
if (dataSource != null) { if (dataSource != null) {
close() close()
} }
val hikariConfig = HikariConfig().apply { val hikariConfig = createHikariConfig()
val ds = HikariDataSource(hikariConfig)
dataSource = ds
database = Database.connect(ds)
if (config.autoMigrate) {
runFlyway(ds)
}
}
/**
* Schließt die Datenbankverbindung und den Connection Pool.
*/
fun close() {
dataSource?.close()
dataSource = null
database = null
}
/**
* Führt eine Datenbankoperation in einer neuen, suspendierenden Transaktion aus.
* Dies ist die primäre Methode, um mit der Datenbank zu interagieren.
*
* @param block Der Code, der in der Transaktion ausgeführt werden soll.
* @return Das Ergebnis der Transaktion.
*/
suspend fun <T> dbQuery(block: suspend () -> T): T {
// Wir stellen sicher, dass die dbQuery-Funktion nur auf einer verbundenen Datenbank läuft.
if (database == null) {
throw IllegalStateException("Database has not been connected. Call connect() first.")
}
return newSuspendedTransaction(Dispatchers.IO, db = database) {
block()
}
}
private fun createHikariConfig(): HikariConfig {
return HikariConfig().apply {
driverClassName = config.driverClassName driverClassName = config.driverClassName
jdbcUrl = config.jdbcUrl jdbcUrl = config.jdbcUrl
username = config.username username = config.username
password = config.password password = config.password
maximumPoolSize = config.maxPoolSize maximumPoolSize = config.maxPoolSize
minimumIdle = config.minPoolSize // Use the minPoolSize from config minimumIdle = config.minPoolSize
isAutoCommit = false isAutoCommit = false
// Use READ_COMMITTED for better performance while maintaining data integrity
// REPEATABLE_READ is more strict and can lead to more contention
transactionIsolation = "TRANSACTION_READ_COMMITTED" transactionIsolation = "TRANSACTION_READ_COMMITTED"
// Connection validation
connectionTestQuery = "SELECT 1" connectionTestQuery = "SELECT 1"
validationTimeout = 5000 // 5 seconds validationTimeout = 5000 // 5 seconds
// Connection timeouts
connectionTimeout = 30000 // 30 seconds connectionTimeout = 30000 // 30 seconds
idleTimeout = 600000 // 10 minutes idleTimeout = 600000 // 10 minutes
maxLifetime = 1800000 // 30 minutes maxLifetime = 1800000 // 30 minutes
// Leak detection
leakDetectionThreshold = 60000 // 1 minute leakDetectionThreshold = 60000 // 1 minute
poolName = "MeldestelleDbPool-${config.jdbcUrl.substringAfterLast('/')}" // Eindeutiger Pool-Name
// Statement cache for better performance
dataSourceProperties["cachePrepStmts"] = "true"
dataSourceProperties["prepStmtCacheSize"] = "250"
dataSourceProperties["prepStmtCacheSqlLimit"] = "2048"
dataSourceProperties["useServerPrepStmts"] = "true"
// Connection initialization - run a simple query to warm up connections
connectionInitSql = "SELECT 1"
// Pool name for better identification in metrics
poolName = "MeldestelleDbPool"
validate()
}
dataSource = HikariDataSource(hikariConfig)
Database.connect(dataSource!!)
// Flyway-Migrationen wenn aktiviert
if (config.autoMigrate) {
runFlyway(dataSource!!)
} }
} }
private fun runFlyway(dataSource: HikariDataSource) { private fun runFlyway(dataSource: HikariDataSource) {
println("Starte Flyway-Migrationen...") println("Starte Flyway-Migrationen für Schema: ${dataSource.jdbcUrl}")
val flyway = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration") // Sagt Flyway, wo die SQL-Dateien liegen
.load()
try { try {
flyway.migrate() Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.load()
.migrate()
println("Flyway-Migrationen erfolgreich abgeschlossen.") println("Flyway-Migrationen erfolgreich abgeschlossen.")
} catch (e: Exception) { } catch (e: Exception) {
println("FEHLER: Flyway-Migration fehlgeschlagen! Repariere Schema...") println("FEHLER: Flyway-Migration fehlgeschlagen! Details: ${e.message}")
// Bei einem Fehler versuchen wir, das Schema zu reparieren, // Wir werfen den Fehler weiter, damit die Anwendung beim Start fehlschlägt.
// damit zukünftige Migrationen nicht blockiert sind. // Das ist wichtig, um Inkonsistenzen zu vermeiden.
flyway.repair() throw IllegalStateException("Flyway migration failed", e)
throw e // Wirf den Fehler weiter, damit die Anwendung nicht startet.
} }
} }
/**
* Führt eine Datenbankoperation in einer Transaktion aus.
* @param block Der Code, der in der Transaktion ausgeführt werden soll
* @return Das Ergebnis der Transaktion
*/
suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
/**
* Schließt die Datenbankverbindung.
*/
fun close() {
dataSource?.close()
dataSource = null
}
/**
* Gets the number of active connections in the pool.
* @return The number of active connections, or 0 if the pool is not initialized
*/
fun getActiveConnections(): Int {
return dataSource?.hikariPoolMXBean?.activeConnections ?: 0
}
/**
* Gets the number of idle connections in the pool.
* @return The number of idle connections, or 0 if the pool is not initialized
*/
fun getIdleConnections(): Int {
return dataSource?.hikariPoolMXBean?.idleConnections ?: 0
}
/**
* Gets the total number of connections in the pool.
* @return The total number of connections, or 0 if the pool is not initialized
*/
fun getTotalConnections(): Int {
return dataSource?.hikariPoolMXBean?.totalConnections ?: 0
}
} }
@@ -1,104 +0,0 @@
package at.mocode.core.utils.database
/*
Wegen Flyway nicht mehr benötigt
*/
//import org.jetbrains.exposed.sql.*
//import org.jetbrains.exposed.sql.transactions.transaction
//import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp
//import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
//
///**
// * Führt Datenbankmigrationen durch.
// * Diese Klasse verwaltet und führt alle notwendigen Datenbankmigrationen aus.
// */
//object DatabaseMigrator {
// private val migrations = mutableListOf<Migration>()
// private val executedMigrations = mutableSetOf<String>()
//
// /**
// * Registriert eine Migration.
// * @param migration Die zu registrierende Migration
// */
// fun register(migration: Migration) {
// migrations.add(migration)
// }
//
// /**
// * Registriert mehrere Migrationen auf einmal.
// * @param migrations Die zu registrierenden Migrationen
// */
// fun registerAll(vararg migrations: Migration) {
// this.migrations.addAll(migrations)
// }
//
// /**
// * Führt alle registrierten Migrationen aus, die noch nicht ausgeführt wurden.
// */
// fun migrate() {
// // Erstelle die Migrationstabelle, wenn sie nicht existiert
// transaction {
// SchemaUtils.create(MigrationTable)
//
// // Lade bereits ausgeführte Migrationen
// MigrationTable.selectAll().forEach {
// executedMigrations.add(it[MigrationTable.id])
// }
//
// // Sortiere Migrationen nach Version
// val sortedMigrations = migrations.sortedBy { it.version }
//
// // Führe noch nicht ausgeführte Migrationen aus
// for (migration in sortedMigrations) {
// if (!executedMigrations.contains(migration.id)) {
// println("Ausführen der Migration: ${migration.id}")
// try {
// migration.up()
//
// // Markiere Migration als ausgeführt
// MigrationTable.insert {
// it[id] = migration.id
// it[version] = migration.version
// it[description] = migration.description
// }
//
// commit()
// println("Migration erfolgreich: ${migration.id}")
// } catch (e: Exception) {
// rollback()
// println("Migration fehlgeschlagen: ${migration.id} - ${e.message}")
// throw e
// }
// }
// }
// }
// }
//}
//
///**
// * Tabelle zur Verfolgung ausgeführter Migrationen.
// */
//object MigrationTable : Table("_migrations") {
// val id = varchar("id", 100)
// val version = long("version")
// val description = varchar("description", 255)
// val executedAt = timestamp("executed_at").defaultExpression(CurrentTimestamp)
//
// override val primaryKey = PrimaryKey(id)
//}
//
///**
// * Basisklasse für Datenbankmigrationen.
// */
//abstract class Migration(val version: Long, val description: String) {
// /**
// * Eindeutige ID der Migration, bestehend aus Version und Beschreibung.
// */
// val id: String = "V${version}_${description.replace("\\s+".toRegex(), "_")}"
//
// /**
// * Führt die Migration aus.
// */
// abstract fun up()
//}
@@ -1,6 +1,6 @@
package at.mocode.core.utils.discovery package at.mocode.core.utils.discovery
import at.mocode.core.utils.config.AppConfig import at.mocode.core.utils.config.AppConfig // Angenommen, AppConfig ist jetzt eine Klasse
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -10,156 +10,87 @@ import java.util.*
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import com.orbitz.consul.Consul import com.orbitz.consul.Consul
import com.orbitz.consul.model.agent.ImmutableRegistration import com.orbitz.consul.model.agent.ImmutableRegistration
import com.orbitz.consul.model.agent.Registration
/** /**
* Service registration configuration. * Repräsentiert die Registrierung eines einzelnen Service-Exemplars bei Consul.
* * Diese Klasse kümmert sich um den Lebenszyklus (Registrierung, Deregistrierung).
* @property serviceName The name of the service to register
* @property serviceId A unique ID for this service instance (defaults to serviceName + random UUID)
* @property servicePort The port the service is running on
* @property healthCheckPath The path for the health check endpoint (defaults to "/health")
* @property healthCheckInterval The interval between health checks in seconds (defaults to 10 seconds)
* @property tags Optional tags to associate with the service
* @property meta Optional metadata to associate with the service
*/ */
data class ServiceRegistrationConfig( class ServiceRegistration internal constructor(
val serviceName: String, private val consul: Consul,
val serviceId: String = "$serviceName-${UUID.randomUUID()}", private val registration: ImmutableRegistration
val servicePort: Int,
val healthCheckPath: String = "/health",
val healthCheckInterval: Int = 10,
val tags: List<String> = emptyList(),
val meta: Map<String, String> = emptyMap()
)
/**
* Service registration component for registering services with Consul.
*/
class ServiceRegistration(
private val config: ServiceRegistrationConfig,
private val consulHost: String = "consul",
private val consulPort: Int = 8500
) { ) {
private val consul: Consul by lazy { private var isRegistered = false
try {
Consul.builder()
.withUrl("http://$consulHost:$consulPort")
.build()
} catch (e: Exception) {
println("Failed to connect to Consul: ${e.message}")
throw e
}
}
private val serviceId = config.serviceId
private var registered = false
/**
* Register the service with Consul.
*/
fun register() { fun register() {
if (isRegistered) return
try { try {
val hostAddress = InetAddress.getLocalHost().hostAddress
// Create health check
val healthCheck = Registration.RegCheck.http(
"http://$hostAddress:${config.servicePort}${config.healthCheckPath}",
config.healthCheckInterval.toLong()
)
// Create service registration
val registration = ImmutableRegistration.builder()
.id(serviceId)
.name(config.serviceName)
.address(hostAddress)
.port(config.servicePort)
.tags(config.tags)
.meta(config.meta)
.check(healthCheck)
.build()
// Register service with Consul
consul.agentClient().register(registration) consul.agentClient().register(registration)
registered = true isRegistered = true
println("Service $serviceId registered with Consul at $consulHost:$consulPort") println("Service '${registration.name()}' mit ID '${registration.id()}' erfolgreich bei Consul registriert.")
// Start heartbeat to keep service registration active
startHeartbeat()
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to register service with Consul: ${e.message}") println("FEHLER: Service-Registrierung bei Consul fehlgeschlagen: ${e.message}")
e.printStackTrace() // Optional: Fehler weiterwerfen, um den Anwendungsstart zu stoppen
} }
} }
/**
* Deregister the service from Consul.
*/
fun deregister() { fun deregister() {
if (!isRegistered) return
try { try {
if (registered) { consul.agentClient().deregister(registration.id())
consul.agentClient().deregister(serviceId) isRegistered = false
registered = false println("Service '${registration.name()}' mit ID '${registration.id()}' erfolgreich bei Consul deregistriert.")
println("Service $serviceId deregistered from Consul")
}
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to deregister service from Consul: ${e.message}") println("FEHLER: Service-Deregistrierung bei Consul fehlgeschlagen: ${e.message}")
e.printStackTrace()
}
}
/**
* Start a heartbeat to keep the service registration active.
*/
private fun startHeartbeat() {
CoroutineScope(Dispatchers.IO).launch {
while (registered) {
try {
// Send heartbeat to Consul
consul.agentClient().pass(serviceId)
delay(config.healthCheckInterval.seconds)
} catch (e: Exception) {
println("Failed to send heartbeat to Consul: ${e.message}")
delay(5.seconds)
}
}
} }
} }
} }
/** /**
* Factory for creating ServiceRegistration instances. * Zentraler Registrar, der beim Anwendungsstart Services registriert.
* Diese Klasse wird einmalig mit der Gesamt-AppConfig initialisiert.
*/ */
object ServiceRegistrationFactory { class ServiceRegistrar(private val appConfig: AppConfig) {
private val consul: Consul by lazy {
val consulConfig = appConfig.serviceDiscovery
Consul.builder()
.withUrl("http://${consulConfig.consulHost}:${consulConfig.consulPort}")
.build()
}
/** /**
* Create a ServiceRegistration instance for a service. * Erstellt und registriert einen Service basierend auf der App-Konfiguration.
* * @return Eine ServiceRegistration-Instanz zur Verwaltung des Lebenszyklus.
* @param serviceName The name of the service to register
* @param servicePort The port the service is running on
* @param healthCheckPath The path for the health check endpoint (defaults to "/health")
* @param tags Optional tags to associate with the service
* @param meta Optional metadata to associate with the service
* @return A ServiceRegistration instance
*/ */
fun createServiceRegistration( fun registerCurrentService(): ServiceRegistration {
serviceName: String, val serviceName = appConfig.appInfo.name
servicePort: Int, val servicePort = appConfig.server.port
healthCheckPath: String = "/health", val serviceId = "$serviceName-${UUID.randomUUID()}"
tags: List<String> = emptyList(), val hostAddress = InetAddress.getLocalHost().hostAddress
meta: Map<String, String> = emptyMap()
): ServiceRegistration { val healthCheck = ImmutableRegistration.RegCheck.http(
val config = ServiceRegistrationConfig( "http://$hostAddress:$servicePort/health", // Standard-Health-Check-Pfad
serviceName = serviceName, 10L // Intervall in Sekunden
servicePort = servicePort,
healthCheckPath = healthCheckPath,
tags = tags,
meta = meta
) )
// Get Consul host and port from configuration if available val registration = ImmutableRegistration.builder()
val consulHost = AppConfig.serviceDiscovery.consulHost .id(serviceId)
val consulPort = AppConfig.serviceDiscovery.consulPort .name(serviceName)
.address(hostAddress)
.port(servicePort)
.check(healthCheck)
.tags(listOf("env:${appConfig.environment.name.lowercase()}"))
.meta(mapOf("version" to appConfig.appInfo.version))
.build()
return ServiceRegistration(config, consulHost, consulPort) val serviceRegistration = ServiceRegistration(consul, registration)
serviceRegistration.register()
// Fügt einen Shutdown-Hook hinzu, um den Service beim Beenden sauber zu deregistrieren
Runtime.getRuntime().addShutdownHook(Thread {
serviceRegistration.deregister()
})
return serviceRegistration
} }
} }
@@ -0,0 +1,48 @@
package at.mocode.masterdata.api
import at.mocode.core.domain.model.ApiResponse
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*
// Eine einfache, eigene Exception, um "Nicht gefunden"-Fälle klarer zu machen.
class NotFoundException(message: String) : RuntimeException(message)
fun Application.configureStatusPages() {
install(StatusPages) {
// Regel 1: Fange alle "IllegalArgumentException" ab.
// Das passiert bei ungültigen Eingaben, z.B. ein falsches UUID-Format.
exception<IllegalArgumentException> { call, cause ->
log.warn("Bad Request: ${cause.message}")
val errorResponse = ApiResponse<Unit>(
message = cause.message ?: "Invalid input provided.",
errors = listOf("BAD_REQUEST")
)
call.respond(HttpStatusCode.BadRequest, errorResponse)
}
// Regel 2: Fange unsere eigene "NotFoundException" ab.
// Diese werfen wir, wenn eine Entität nicht in der DB gefunden wurde.
exception<NotFoundException> { call, cause ->
log.info("Resource not found: ${cause.message}")
val errorResponse = ApiResponse<Unit>(
message = cause.message ?: "The requested resource was not found.",
errors = listOf("NOT_FOUND")
)
call.respond(HttpStatusCode.NotFound, errorResponse)
}
// Regel 3: Fange alle anderen, unerwarteten Fehler ab.
// Das ist unser Sicherheitsnetz für alles, was wir nicht vorhergesehen haben.
exception<Throwable> { call, cause ->
log.error("Internal Server Error", cause) // Logge den kompletten Stacktrace
val errorResponse = ApiResponse<Unit>(
message = "An unexpected internal server error occurred.",
errors = listOf("INTERNAL_SERVER_ERROR")
)
call.respond(HttpStatusCode.InternalServerError, errorResponse)
}
}
}
@@ -91,7 +91,7 @@ class AltersklasseController(
} catch (_: Exception) { } catch (_: Exception) {
return@get call.respond( return@get call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<List<AltersklasseDto>>("Invalid sparte parameter: $it") ApiResponse<List<AltersklasseDto>>("Invalid sparte parameter: $it")
) )
} }
} }
@@ -103,7 +103,7 @@ class AltersklasseController(
} else { } else {
return@get call.respond( return@get call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<List<AltersklasseDto>>("Invalid geschlecht parameter. Must be 'M' or 'W'") ApiResponse<List<AltersklasseDto>>("Invalid geschlecht parameter. Must be 'M' or 'W'")
) )
} }
} }
@@ -215,7 +215,7 @@ class CreateAltersklasseUseCase(
// Age class code validation // Age class code validation
if (request.altersklasseCode.isBlank()) { if (request.altersklasseCode.isBlank()) {
errors.add(ValidationError("altersklasseCode", "Age class code is required", "REQUIRED")) errors.add(ValidationError("altersklasseCode", "Age class code is required", "REQUIRED")) // "REQUIRED"
} else if (request.altersklasseCode.length > 50) { } else if (request.altersklasseCode.length > 50) {
errors.add(ValidationError("altersklasseCode", "Age class code must not exceed 50 characters", "MAX_LENGTH")) errors.add(ValidationError("altersklasseCode", "Age class code must not exceed 50 characters", "MAX_LENGTH"))
} else if (!request.altersklasseCode.matches(Regex("^[A-Z0-9_]+$"))) { } else if (!request.altersklasseCode.matches(Regex("^[A-Z0-9_]+$"))) {
@@ -1,5 +1,7 @@
package at.mocode.masterdata.service package at.mocode.masterdata.service
import at.mocode.core.utils.config.AppConfig
import at.mocode.core.utils.database.DatabaseFactory
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
@@ -11,9 +13,18 @@ import org.springframework.boot.runApplication
@SpringBootApplication @SpringBootApplication
class MasterdataServiceApplication class MasterdataServiceApplication
/**
* Main entry point for the Masterdata Service application.
*/
fun main(args: Array<String>) { fun main(args: Array<String>) {
// 1. Lade die Konfiguration explizit, genau einmal beim Start.
val appConfig = AppConfig.load()
println("Konfiguration für Umgebung '${appConfig.environment}' geladen.")
// 2. Initialisiere die Datenbank mit der geladenen Konfiguration.
// Flyway-Migrationen werden hier automatisch ausgeführt.
DatabaseFactory.init(appConfig.database)
println("Datenbank initialisiert und migriert.")
// 3. Starte die Spring Boot / Ktor Anwendung.
// Der appConfig-Wert kann hier an die Anwendung übergeben werden,
// um ihn später per Dependency Injection zu nutzen.
runApplication<MasterdataServiceApplication>(*args) runApplication<MasterdataServiceApplication>(*args)
} }