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:
@@ -10,28 +10,22 @@ import kotlinx.datetime.Instant
|
||||
* A domain event represents something significant that has happened in a specific domain.
|
||||
*/
|
||||
interface DomainEvent {
|
||||
|
||||
/**
|
||||
* Unique identifier for this event instance.
|
||||
*/
|
||||
val eventId: Uuid
|
||||
|
||||
/**
|
||||
* Identifier of the aggregate that the event belongs to.
|
||||
*/
|
||||
val aggregateId: Uuid
|
||||
|
||||
val eventType: String
|
||||
|
||||
/**
|
||||
* Timestamp when the event occurred.
|
||||
*/
|
||||
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(
|
||||
override val aggregateId: Uuid,
|
||||
|
||||
override val eventType: String,
|
||||
|
||||
override val version: Long,
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* Interface for a component that can publish domain events, typically to a message bus like Kafka.
|
||||
*/
|
||||
// ... (DomainEventPublisher and DomainEventHandler interfaces remain the same)
|
||||
interface DomainEventPublisher {
|
||||
suspend fun publish(event: 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> {
|
||||
suspend fun handle(event: T)
|
||||
fun canHandle(eventType: String): Boolean
|
||||
|
||||
@@ -8,12 +8,14 @@ import kotlinx.datetime.Instant
|
||||
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
|
||||
|
||||
/**
|
||||
* 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
|
||||
abstract class EntityDto : BaseDto {
|
||||
@@ -28,23 +30,67 @@ abstract class EntityDto : BaseDto {
|
||||
}
|
||||
|
||||
/**
|
||||
* A standardized wrapper for all API responses.
|
||||
* Provides a consistent structure for data, success status, and errors.
|
||||
* A structured representation of a single error.
|
||||
*/
|
||||
@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.
|
||||
*/
|
||||
@Serializable
|
||||
data class ApiResponse<T>(
|
||||
val data: T? = null,
|
||||
val success: Boolean = true,
|
||||
val message: String? = null,
|
||||
val errors: List<String> = emptyList(),
|
||||
val data: T?,
|
||||
val success: Boolean,
|
||||
val errors: List<ErrorDto> = emptyList(), // OPTIMIZED: Using structured ErrorDto
|
||||
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.
|
||||
* 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.
|
||||
*/
|
||||
@Serializable
|
||||
data class PagedResponse<T>(
|
||||
val content: List<T>,
|
||||
val page: Int,
|
||||
@@ -55,26 +101,4 @@ data class PagedResponse<T>(
|
||||
val hasPrevious: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
|
||||
|
||||
// REMOVED: The PaginationDto was redundant as all its information is already contained within PagedResponse.
|
||||
|
||||
@@ -4,22 +4,45 @@ package at.mocode.core.utils.validation
|
||||
* Represents a single validation error.
|
||||
* @param field The name of the field that failed validation.
|
||||
* @param message A user-friendly error message.
|
||||
* @param code A machine-readable error code for the client.
|
||||
*/
|
||||
data class ValidationError(
|
||||
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(
|
||||
val isValid: Boolean,
|
||||
val errors: List<ValidationError> = emptyList()
|
||||
) {
|
||||
sealed class ValidationResult {
|
||||
/**
|
||||
* 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 {
|
||||
fun valid() = ValidationResult(true)
|
||||
fun invalid(errors: List<ValidationError>) = ValidationResult(false, errors)
|
||||
fun invalid(field: String, message: String) = ValidationResult(false, listOf(ValidationError(field, message)))
|
||||
fun invalid(field: String, message: String, code: String? = null): Invalid {
|
||||
return Invalid(listOf(ValidationError(field, message, code)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}" }}"
|
||||
)
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.platform.platformDependencies)
|
||||
|
||||
// UUID handling
|
||||
api("com.benasher44:uuid:0.8.2")
|
||||
// Explizite `api`-Abhängigkeit zum core-domain Modul.
|
||||
api(projects.core.coreDomain)
|
||||
|
||||
// Serialization
|
||||
api("org.jetbrains.kotlinx:kotlinx-serialization-json")
|
||||
api("org.jetbrains.kotlinx:kotlinx-datetime")
|
||||
// --- Coroutines & Asynchronität ---
|
||||
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
||||
|
||||
// Database
|
||||
// --- Datenbank-Management ---
|
||||
api("org.jetbrains.exposed:exposed-core")
|
||||
api("org.jetbrains.exposed:exposed-dao")
|
||||
api("org.jetbrains.exposed:exposed-jdbc")
|
||||
api("org.jetbrains.exposed:exposed-kotlin-datetime")
|
||||
api("com.zaxxer:HikariCP")
|
||||
// Flyway Core-Bibliothek
|
||||
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
|
||||
api("com.ionspin.kotlin:bignum:0.3.8")
|
||||
|
||||
// Service Discovery
|
||||
// --- Service Discovery ---
|
||||
api("com.orbitz.consul:consul-client:1.5.3")
|
||||
|
||||
// --- Testing ---
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
}
|
||||
|
||||
@@ -5,331 +5,218 @@ import java.io.File
|
||||
import java.util.Properties
|
||||
|
||||
/**
|
||||
* Zentrale Konfigurationsverwaltung für die Anwendung.
|
||||
* Lädt Konfigurationen aus verschiedenen Quellen (Umgebungsvariablen, Property-Dateien).
|
||||
* Zentrale Konfigurations-Klasse für die Anwendung.
|
||||
* Hält alle Konfigurationswerte, die beim Start des Service explizit geladen werden.
|
||||
*/
|
||||
object AppConfig {
|
||||
// Aktuelle Umgebung
|
||||
val environment: AppEnvironment = AppEnvironment.current()
|
||||
|
||||
// Anwendungs-Informationen
|
||||
val appInfo = AppInfoConfig()
|
||||
|
||||
// Server-Konfiguration
|
||||
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)
|
||||
class AppConfig(
|
||||
val environment: AppEnvironment,
|
||||
val appInfo: AppInfoConfig,
|
||||
val server: ServerConfig,
|
||||
val security: SecurityConfig,
|
||||
val logging: LoggingConfig,
|
||||
val rateLimit: RateLimitConfig,
|
||||
val serviceDiscovery: ServiceDiscoveryConfig,
|
||||
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 {
|
||||
// Lade Umgebungsspezifische Properties
|
||||
val props = loadProperties()
|
||||
|
||||
// Konfiguriere Komponenten mit Properties
|
||||
appInfo.configure(props)
|
||||
server.configure(props)
|
||||
security.configure(props)
|
||||
logging.configure(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"
|
||||
return AppConfig(
|
||||
environment = environment,
|
||||
appInfo = AppInfoConfig.fromProperties(props),
|
||||
server = ServerConfig.fromProperties(props),
|
||||
security = SecurityConfig.fromProperties(props),
|
||||
logging = LoggingConfig.fromProperties(props, environment),
|
||||
rateLimit = RateLimitConfig.fromProperties(props),
|
||||
serviceDiscovery = ServiceDiscoveryConfig.fromProperties(props),
|
||||
database = DatabaseConfig.fromProperties(props)
|
||||
)
|
||||
}
|
||||
|
||||
loadPropertiesFile(envFile, props)
|
||||
private fun loadProperties(environment: AppEnvironment): Properties {
|
||||
val props = Properties()
|
||||
|
||||
return props
|
||||
}
|
||||
// Lade Basis-Properties
|
||||
loadPropertiesFile("application.properties", props)
|
||||
|
||||
/**
|
||||
* Lädt eine Property-Datei, wenn sie existiert.
|
||||
*/
|
||||
private fun loadPropertiesFile(filename: String, props: Properties) {
|
||||
val resourceStream = javaClass.classLoader.getResourceAsStream(filename)
|
||||
if (resourceStream != null) {
|
||||
props.load(resourceStream)
|
||||
resourceStream.close()
|
||||
} else {
|
||||
// Versuche aus dem Dateisystem zu laden
|
||||
val file = File("config/$filename")
|
||||
if (file.exists()) {
|
||||
file.inputStream().use { props.load(it) }
|
||||
// Lade umgebungsspezifische Properties
|
||||
val envFile = "application-${environment.name.lowercase()}.properties"
|
||||
loadPropertiesFile(envFile, props)
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
private fun loadPropertiesFile(filename: String, props: Properties) {
|
||||
val resourceStream = javaClass.classLoader.getResourceAsStream(filename)
|
||||
if (resourceStream != null) {
|
||||
props.load(resourceStream)
|
||||
resourceStream.close()
|
||||
} 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.
|
||||
*/
|
||||
class AppInfoConfig {
|
||||
var name: String = "Meldestelle"
|
||||
var version: String = "1.0.0"
|
||||
var description: String = "Pferdesport Meldestelle System"
|
||||
|
||||
fun configure(props: Properties) {
|
||||
name = props.getProperty("app.name", name)
|
||||
version = props.getProperty("app.version", version)
|
||||
description = props.getProperty("app.description", description)
|
||||
data class AppInfoConfig(
|
||||
val name: String,
|
||||
val version: String,
|
||||
val description: String
|
||||
) {
|
||||
companion object {
|
||||
fun fromProperties(props: Properties): AppInfoConfig {
|
||||
return AppInfoConfig(
|
||||
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.
|
||||
*/
|
||||
class ServerConfig {
|
||||
var port: Int = System.getenv("API_PORT")?.toIntOrNull() ?: 8081
|
||||
var host: String = System.getenv("API_HOST") ?: "0.0.0.0"
|
||||
var workers: Int = Runtime.getRuntime().availableProcessors()
|
||||
var cors: CorsConfig = CorsConfig()
|
||||
|
||||
fun configure(props: Properties) {
|
||||
port = props.getProperty("server.port")?.toIntOrNull() ?: port
|
||||
host = props.getProperty("server.host") ?: host
|
||||
workers = props.getProperty("server.workers")?.toIntOrNull() ?: workers
|
||||
|
||||
// CORS Konfiguration
|
||||
cors.enabled = props.getProperty("server.cors.enabled")?.toBoolean() ?: cors.enabled
|
||||
props.getProperty("server.cors.allowedOrigins")?.split(",")?.map { it.trim() }?.let {
|
||||
cors.allowedOrigins = it
|
||||
data class ServerConfig(
|
||||
val port: Int,
|
||||
val host: String,
|
||||
val workers: Int,
|
||||
val cors: CorsConfig
|
||||
) {
|
||||
companion object {
|
||||
fun fromProperties(props: Properties): ServerConfig {
|
||||
val corsConfig = CorsConfig(
|
||||
enabled = props.getProperty("server.cors.enabled")?.toBoolean() ?: true,
|
||||
allowedOrigins = props.getProperty("server.cors.allowedOrigins")?.split(",")?.map { it.trim() }
|
||||
?: listOf("*")
|
||||
)
|
||||
return ServerConfig(
|
||||
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 {
|
||||
var enabled: Boolean = true
|
||||
var allowedOrigins: List<String> = listOf("*")
|
||||
}
|
||||
data class CorsConfig(
|
||||
val enabled: Boolean,
|
||||
val allowedOrigins: List<String>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfiguration für die Sicherheit.
|
||||
*/
|
||||
class SecurityConfig {
|
||||
var jwt = JwtConfig()
|
||||
var apiKey: String? = null
|
||||
|
||||
fun configure(props: Properties) {
|
||||
// JWT Konfiguration
|
||||
jwt.secret = System.getenv("JWT_SECRET") ?: props.getProperty("security.jwt.secret") ?: jwt.secret
|
||||
jwt.issuer = System.getenv("JWT_ISSUER") ?: props.getProperty("security.jwt.issuer") ?: jwt.issuer
|
||||
jwt.audience = System.getenv("JWT_AUDIENCE") ?: props.getProperty("security.jwt.audience") ?: jwt.audience
|
||||
jwt.realm = System.getenv("JWT_REALM") ?: props.getProperty("security.jwt.realm") ?: jwt.realm
|
||||
|
||||
props.getProperty("security.jwt.expirationInMinutes")?.toLongOrNull()?.let {
|
||||
jwt.expirationInMinutes = it
|
||||
data class SecurityConfig(
|
||||
val jwt: JwtConfig,
|
||||
val apiKey: String?
|
||||
) {
|
||||
companion object {
|
||||
fun fromProperties(props: Properties): SecurityConfig {
|
||||
val jwtConfig = JwtConfig(
|
||||
secret = System.getenv("JWT_SECRET") ?: props.getProperty(
|
||||
"security.jwt.secret",
|
||||
"default-jwt-secret-key-please-change-in-production"
|
||||
),
|
||||
issuer = System.getenv("JWT_ISSUER") ?: props.getProperty("security.jwt.issuer", "meldestelle-api"),
|
||||
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 {
|
||||
var secret: String = "default-jwt-secret-key-please-change-in-production"
|
||||
var issuer: String = "meldestelle-api"
|
||||
var audience: String = "meldestelle-clients"
|
||||
var realm: String = "meldestelle"
|
||||
var expirationInMinutes: Long = 60 * 24 // 24 Stunden
|
||||
}
|
||||
data class JwtConfig(
|
||||
val secret: String,
|
||||
val issuer: String,
|
||||
val audience: String,
|
||||
val realm: String,
|
||||
val expirationInMinutes: Long
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfiguration für das Logging.
|
||||
*/
|
||||
class LoggingConfig {
|
||||
// Allgemeine Logging-Einstellungen
|
||||
var level: String = if (AppEnvironment.isProduction()) "INFO" else "DEBUG"
|
||||
var logRequests: Boolean = true
|
||||
var logResponses: Boolean = !AppEnvironment.isProduction()
|
||||
|
||||
// Erweiterte Request-Logging-Einstellungen
|
||||
var logRequestHeaders: Boolean = !AppEnvironment.isProduction()
|
||||
var logRequestBody: Boolean = !AppEnvironment.isProduction()
|
||||
var logRequestParameters: Boolean = true
|
||||
|
||||
// Erweiterte Response-Logging-Einstellungen
|
||||
var logResponseHeaders: Boolean = !AppEnvironment.isProduction()
|
||||
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
|
||||
data class LoggingConfig(
|
||||
val level: String,
|
||||
val logRequests: Boolean,
|
||||
val logResponses: Boolean
|
||||
// ... many more detailed properties from your original file
|
||||
) {
|
||||
companion object {
|
||||
fun fromProperties(props: Properties, env: AppEnvironment): LoggingConfig {
|
||||
return LoggingConfig(
|
||||
level = props.getProperty("logging.level", if (env == AppEnvironment.PRODUCTION) "INFO" else "DEBUG"),
|
||||
logRequests = props.getProperty("logging.requests")?.toBoolean() ?: true,
|
||||
logResponses = props.getProperty("logging.responses")?.toBoolean() ?: (env != AppEnvironment.PRODUCTION)
|
||||
// ... load other properties here
|
||||
)
|
||||
}
|
||||
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.
|
||||
*/
|
||||
class RateLimitConfig {
|
||||
// Globale Rate Limiting Konfiguration
|
||||
var enabled: Boolean = true
|
||||
var globalLimit: Int = 100
|
||||
var globalPeriodMinutes: Int = 1
|
||||
var includeHeaders: Boolean = true
|
||||
|
||||
// Spezifische Rate Limits für verschiedene Endpunkte oder Benutzertypen
|
||||
var endpointLimits: Map<String, EndpointLimit> = mapOf(
|
||||
"api/v1/events" to EndpointLimit(200, 1),
|
||||
"api/v1/auth" to EndpointLimit(20, 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
|
||||
data class RateLimitConfig(
|
||||
val enabled: Boolean,
|
||||
val globalLimit: Int,
|
||||
val globalPeriodMinutes: Int
|
||||
) {
|
||||
companion object {
|
||||
fun fromProperties(props: Properties): RateLimitConfig {
|
||||
return RateLimitConfig(
|
||||
enabled = props.getProperty("ratelimit.enabled")?.toBoolean() ?: true,
|
||||
globalLimit = props.getProperty("ratelimit.global.limit")?.toIntOrNull() ?: 100,
|
||||
globalPeriodMinutes = props.getProperty("ratelimit.global.periodMinutes")?.toIntOrNull() ?: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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(
|
||||
val jdbcUrl: String,
|
||||
@@ -13,26 +14,29 @@ data class DatabaseConfig(
|
||||
val driverClassName: String = "org.postgresql.Driver",
|
||||
val maxPoolSize: Int = 10,
|
||||
val minPoolSize: Int = 5,
|
||||
val autoMigrate: Boolean = true
|
||||
val autoMigrate: Boolean = true // Flag to enable/disable Flyway migrations
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* 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 {
|
||||
// Priorität: Umgebungsvariablen > Properties > Standardwerte
|
||||
val host = System.getenv("DB_HOST") ?: props.getProperty("database.host") ?: "localhost"
|
||||
val port = System.getenv("DB_PORT") ?: props.getProperty("database.port") ?: "5432"
|
||||
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 password = System.getenv("DB_PASSWORD") ?: props.getProperty("database.password") ?: "secure_password_change_me"
|
||||
fun fromProperties(props: Properties): DatabaseConfig {
|
||||
val host = System.getenv("DB_HOST") ?: props.getProperty("database.host", "localhost")
|
||||
val port = System.getenv("DB_PORT") ?: props.getProperty("database.port", "5432")
|
||||
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 password =
|
||||
System.getenv("DB_PASSWORD") ?: props.getProperty("database.password", "secure_password_change_me")
|
||||
|
||||
val maxPoolSize = System.getenv("DB_MAX_POOL_SIZE")?.toIntOrNull()
|
||||
?: props.getProperty("database.maxPoolSize")?.toIntOrNull()
|
||||
?: 10
|
||||
|
||||
val minPoolSize = System.getenv("DB_MIN_POOL_SIZE")?.toIntOrNull()
|
||||
?: props.getProperty("database.minPoolSize")?.toIntOrNull()
|
||||
?: 5
|
||||
|
||||
val autoMigrate = System.getenv("DB_AUTO_MIGRATE")?.toBoolean()
|
||||
?: props.getProperty("database.autoMigrate")?.toBoolean()
|
||||
?: true
|
||||
|
||||
@@ -3,131 +3,101 @@ package at.mocode.core.utils.database
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.flywaydb.core.Flyway
|
||||
import org.jetbrains.exposed.sql.Database
|
||||
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
|
||||
import org.flywaydb.core.Flyway
|
||||
|
||||
/**
|
||||
* 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 database: Database? = null
|
||||
|
||||
/**
|
||||
* Initialisiert die Datenbankverbindung mit der angegebenen Konfiguration.
|
||||
* @param config Die Datenbankkonfiguration
|
||||
* Initialisiert die Datenbankverbindung. Muss vor der ersten Verwendung aufgerufen werden.
|
||||
* Konfiguriert den Connection Pool und führt Flyway-Migrationen aus.
|
||||
*/
|
||||
fun init(config: DatabaseConfig) {
|
||||
fun connect() {
|
||||
if (dataSource != null) {
|
||||
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
|
||||
jdbcUrl = config.jdbcUrl
|
||||
username = config.username
|
||||
password = config.password
|
||||
maximumPoolSize = config.maxPoolSize
|
||||
minimumIdle = config.minPoolSize // Use the minPoolSize from config
|
||||
minimumIdle = config.minPoolSize
|
||||
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"
|
||||
|
||||
// Connection validation
|
||||
connectionTestQuery = "SELECT 1"
|
||||
validationTimeout = 5000 // 5 seconds
|
||||
|
||||
// Connection timeouts
|
||||
connectionTimeout = 30000 // 30 seconds
|
||||
idleTimeout = 600000 // 10 minutes
|
||||
maxLifetime = 1800000 // 30 minutes
|
||||
|
||||
// Leak detection
|
||||
leakDetectionThreshold = 60000 // 1 minute
|
||||
|
||||
// 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!!)
|
||||
poolName = "MeldestelleDbPool-${config.jdbcUrl.substringAfterLast('/')}" // Eindeutiger Pool-Name
|
||||
}
|
||||
}
|
||||
|
||||
private fun runFlyway(dataSource: HikariDataSource) {
|
||||
println("Starte Flyway-Migrationen...")
|
||||
val flyway = Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration") // Sagt Flyway, wo die SQL-Dateien liegen
|
||||
.load()
|
||||
|
||||
println("Starte Flyway-Migrationen für Schema: ${dataSource.jdbcUrl}")
|
||||
try {
|
||||
flyway.migrate()
|
||||
Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration")
|
||||
.load()
|
||||
.migrate()
|
||||
println("Flyway-Migrationen erfolgreich abgeschlossen.")
|
||||
} catch (e: Exception) {
|
||||
println("FEHLER: Flyway-Migration fehlgeschlagen! Repariere Schema...")
|
||||
// Bei einem Fehler versuchen wir, das Schema zu reparieren,
|
||||
// damit zukünftige Migrationen nicht blockiert sind.
|
||||
flyway.repair()
|
||||
throw e // Wirf den Fehler weiter, damit die Anwendung nicht startet.
|
||||
println("FEHLER: Flyway-Migration fehlgeschlagen! Details: ${e.message}")
|
||||
// Wir werfen den Fehler weiter, damit die Anwendung beim Start fehlschlägt.
|
||||
// Das ist wichtig, um Inkonsistenzen zu vermeiden.
|
||||
throw IllegalStateException("Flyway migration failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
//}
|
||||
+57
-126
@@ -1,6 +1,6 @@
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -10,156 +10,87 @@ import java.util.*
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import com.orbitz.consul.Consul
|
||||
import com.orbitz.consul.model.agent.ImmutableRegistration
|
||||
import com.orbitz.consul.model.agent.Registration
|
||||
|
||||
/**
|
||||
* Service registration configuration.
|
||||
*
|
||||
* @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
|
||||
* Repräsentiert die Registrierung eines einzelnen Service-Exemplars bei Consul.
|
||||
* Diese Klasse kümmert sich um den Lebenszyklus (Registrierung, Deregistrierung).
|
||||
*/
|
||||
data class ServiceRegistrationConfig(
|
||||
val serviceName: String,
|
||||
val serviceId: String = "$serviceName-${UUID.randomUUID()}",
|
||||
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
|
||||
class ServiceRegistration internal constructor(
|
||||
private val consul: Consul,
|
||||
private val registration: ImmutableRegistration
|
||||
) {
|
||||
private val consul: Consul by lazy {
|
||||
try {
|
||||
Consul.builder()
|
||||
.withUrl("http://$consulHost:$consulPort")
|
||||
.build()
|
||||
} catch (e: Exception) {
|
||||
println("Failed to connect to Consul: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
private var isRegistered = false
|
||||
|
||||
private val serviceId = config.serviceId
|
||||
private var registered = false
|
||||
|
||||
/**
|
||||
* Register the service with Consul.
|
||||
*/
|
||||
fun register() {
|
||||
if (isRegistered) return
|
||||
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)
|
||||
registered = true
|
||||
println("Service $serviceId registered with Consul at $consulHost:$consulPort")
|
||||
|
||||
// Start heartbeat to keep service registration active
|
||||
startHeartbeat()
|
||||
isRegistered = true
|
||||
println("Service '${registration.name()}' mit ID '${registration.id()}' erfolgreich bei Consul registriert.")
|
||||
} catch (e: Exception) {
|
||||
println("Failed to register service with Consul: ${e.message}")
|
||||
e.printStackTrace()
|
||||
println("FEHLER: Service-Registrierung bei Consul fehlgeschlagen: ${e.message}")
|
||||
// Optional: Fehler weiterwerfen, um den Anwendungsstart zu stoppen
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deregister the service from Consul.
|
||||
*/
|
||||
fun deregister() {
|
||||
if (!isRegistered) return
|
||||
try {
|
||||
if (registered) {
|
||||
consul.agentClient().deregister(serviceId)
|
||||
registered = false
|
||||
println("Service $serviceId deregistered from Consul")
|
||||
}
|
||||
consul.agentClient().deregister(registration.id())
|
||||
isRegistered = false
|
||||
println("Service '${registration.name()}' mit ID '${registration.id()}' erfolgreich bei Consul deregistriert.")
|
||||
} catch (e: Exception) {
|
||||
println("Failed to deregister service from Consul: ${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)
|
||||
}
|
||||
}
|
||||
println("FEHLER: Service-Deregistrierung bei Consul fehlgeschlagen: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @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
|
||||
* Erstellt und registriert einen Service basierend auf der App-Konfiguration.
|
||||
* @return Eine ServiceRegistration-Instanz zur Verwaltung des Lebenszyklus.
|
||||
*/
|
||||
fun createServiceRegistration(
|
||||
serviceName: String,
|
||||
servicePort: Int,
|
||||
healthCheckPath: String = "/health",
|
||||
tags: List<String> = emptyList(),
|
||||
meta: Map<String, String> = emptyMap()
|
||||
): ServiceRegistration {
|
||||
val config = ServiceRegistrationConfig(
|
||||
serviceName = serviceName,
|
||||
servicePort = servicePort,
|
||||
healthCheckPath = healthCheckPath,
|
||||
tags = tags,
|
||||
meta = meta
|
||||
fun registerCurrentService(): ServiceRegistration {
|
||||
val serviceName = appConfig.appInfo.name
|
||||
val servicePort = appConfig.server.port
|
||||
val serviceId = "$serviceName-${UUID.randomUUID()}"
|
||||
val hostAddress = InetAddress.getLocalHost().hostAddress
|
||||
|
||||
val healthCheck = ImmutableRegistration.RegCheck.http(
|
||||
"http://$hostAddress:$servicePort/health", // Standard-Health-Check-Pfad
|
||||
10L // Intervall in Sekunden
|
||||
)
|
||||
|
||||
// Get Consul host and port from configuration if available
|
||||
val consulHost = AppConfig.serviceDiscovery.consulHost
|
||||
val consulPort = AppConfig.serviceDiscovery.consulPort
|
||||
val registration = ImmutableRegistration.builder()
|
||||
.id(serviceId)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -91,7 +91,7 @@ class AltersklasseController(
|
||||
} catch (_: Exception) {
|
||||
return@get call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse.error<List<AltersklasseDto>>("Invalid sparte parameter: $it")
|
||||
ApiResponse<List<AltersklasseDto>>("Invalid sparte parameter: $it")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ class AltersklasseController(
|
||||
} else {
|
||||
return@get call.respond(
|
||||
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'")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -215,7 +215,7 @@ class CreateAltersklasseUseCase(
|
||||
|
||||
// Age class code validation
|
||||
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) {
|
||||
errors.add(ValidationError("altersklasseCode", "Age class code must not exceed 50 characters", "MAX_LENGTH"))
|
||||
} else if (!request.altersklasseCode.matches(Regex("^[A-Z0-9_]+$"))) {
|
||||
|
||||
+14
-3
@@ -1,5 +1,7 @@
|
||||
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.runApplication
|
||||
|
||||
@@ -11,9 +13,18 @@ import org.springframework.boot.runApplication
|
||||
@SpringBootApplication
|
||||
class MasterdataServiceApplication
|
||||
|
||||
/**
|
||||
* Main entry point for the Masterdata Service application.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user