fixing(gradle)

This commit is contained in:
2025-08-24 21:31:31 +02:00
parent 8d01fa0e9a
commit 89ef9698af
77 changed files with 2060 additions and 1656 deletions
+20 -14
View File
@@ -11,6 +11,7 @@ kotlin {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
}
}
js(IR) {
browser()
}
@@ -18,44 +19,45 @@ kotlin {
sourceSets {
val commonMain by getting {
dependencies {
// Abhängigkeit zum core-domain-Modul, um dessen Typen zu verwenden
// Dependency on core-domain module to use its types
api(projects.core.coreDomain)
// Asynchronität (available for all platforms) - explicit version to avoid BOM issues
// Async support (available for all platforms)
api(libs.kotlinx.coroutines.core)
// Utilities (multiplatform compatible)
api(libs.bignum)
}
}
val commonTest by getting {
dependencies {
implementation(libs.kotlin.test)
}
}
val jvmMain by getting {
dependencies {
// Abhängigkeit zum platform-Modul für zentrale Versionsverwaltung
// JVM-specific dependencies - access to central catalog
api(projects.platform.platformDependencies)
// Datenbank-Management (JVM-specific)
// OPTIMIERUNG: Verwendung von Bundles für Exposed und Flyway
// Database Management (JVM-specific)
api(libs.bundles.exposed)
api(libs.bundles.flyway)
api(libs.hikari.cp)
// Service Discovery (JVM-specific)
// api(libs.consul.client) wird getauscht mir spring-cloud-starter-consul-discovery
api(libs.spring.cloud.starter.consul.discovery)
// Logging (JVM-specific)
api(libs.kotlin.logging.jvm)
// JVM-specific utilities
implementation(libs.room.common.jvm) // Für BigDecimal Serialisierung
}
}
// Jakarta Annotation API
api(libs.jakarta.annotation.api)
val commonTest by getting {
dependencies {
implementation(libs.kotlin.test)
// JSON Processing
api(libs.jackson.module.kotlin)
api(libs.jackson.datatype.jsr310)
}
}
@@ -69,3 +71,7 @@ kotlin {
}
}
}
tasks.named<Test>("jvmTest") {
useJUnitPlatform()
}
@@ -0,0 +1,123 @@
package at.mocode.core.utils
import at.mocode.core.domain.model.*
import com.benasher44.uuid.uuid4
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
import kotlin.time.Clock
/**
* Extension-Funktionen für häufig verwendete Operationen im gesamten System.
*/
// === UUID Generation Extensions ===
/**
* Erstellt eine neue EntityId mit einer zufälligen UUID.
*/
fun EntityId.Companion.random(): EntityId = EntityId(uuid4())
/**
* Erstellt eine neue EventId mit einer zufälligen UUID.
*/
fun EventId.Companion.random(): EventId = EventId(uuid4())
/**
* Erstellt eine neue AggregateId mit einer zufälligen UUID.
*/
fun AggregateId.Companion.random(): AggregateId = AggregateId(uuid4())
/**
* Erstellt eine neue CorrelationId mit einer zufälligen UUID.
*/
fun CorrelationId.Companion.random(): CorrelationId = CorrelationId(uuid4())
/**
* Erstellt eine neue CausationId mit einer zufälligen UUID.
*/
fun CausationId.Companion.random(): CausationId = CausationId(uuid4())
// === String Extensions ===
/**
* Konvertiert einen String zu einem EventType mit Validierung.
*/
fun String.toEventType(): EventType = EventType(this)
/**
* Konvertiert einen String zu einem ErrorCode mit Validierung.
*/
fun String.toErrorCode(): ErrorCode = ErrorCode(this)
/**
* Prüft ob der String ein gültiger EventType-Name ist.
*/
fun String.isValidEventType(): Boolean {
return isNotBlank() && matches(Regex("^[A-Za-z][A-Za-z0-9]*$"))
}
/**
* Prüft ob der String ein gültiger ErrorCode ist.
*/
fun String.isValidErrorCode(): Boolean {
return isNotBlank() && matches(Regex("^[A-Z][A-Z0-9_]*$"))
}
// === Collection Extensions ===
/**
* Erstellt eine PagedResponse aus einer Liste mit Standard-Paginierung.
*/
fun <T> List<T>.toPagedResponse(
page: Int = 0,
size: Int = 20
): PagedResponse<T> {
val startIndex = page * size
val endIndex = minOf(startIndex + size, this.size)
val content = if (startIndex < this.size) this.subList(startIndex, endIndex) else emptyList()
return PagedResponse.create(
content = content,
page = page,
size = size,
totalElements = this.size.toLong(),
totalPages = (this.size + size - 1) / size,
hasNext = endIndex < this.size,
hasPrevious = page > 0
)
}
// === Validation Extensions ===
/**
* Erstellt eine Liste von ValidationError aus einer Map von Fehlern.
*/
fun Map<String, String>.toValidationErrors(): List<ValidationError> {
return this.map { (field, message) -> ValidationError(field, message, "VALIDATION_ERROR") }
}
/**
* Prüft ob eine Liste von ValidationError leer ist.
*/
fun List<ValidationError>.hasErrors(): Boolean = this.isNotEmpty()
/**
* Konvertiert eine Liste von ValidationError zu ErrorDto.
*/
fun List<ValidationError>.toErrorDtos(): List<ErrorDto> {
return this.map { ErrorDto(ErrorCode(it.code), it.message, it.field) }
}
// === Time Extensions ===
/**
* Prüft ob ein Zeitstempel in der Vergangenheit liegt.
*/
@OptIn(ExperimentalTime::class)
fun Instant.isPast(): Boolean = this < Clock.System.now()
/**
* Prüft ob ein Zeitstempel in der Zukunft liegt.
*/
@OptIn(ExperimentalTime::class)
fun Instant.isFuture(): Boolean = this > Clock.System.now()
@@ -0,0 +1,338 @@
package at.mocode.core.utils
import at.mocode.core.domain.model.ErrorDto
import at.mocode.core.domain.model.ValidationError
import kotlin.jvm.JvmName
/**
* Typsichere Result-Klasse für Fehlermanagement im gesamten System.
* Bietet einen funktionalen Ansatz zur Fehlerbehandlung ohne Exceptions.
*/
sealed class Result<out T> {
/**
* Represents a successful operation with a value.
*/
data class Success<T>(val value: T) : Result<T>()
/**
* Represents a failed operation with error messages.
*/
data class Failure(val errors: List<ErrorDto>) : Result<Nothing>()
/**
* Checks if the Result is a success.
*/
val isSuccess: Boolean get() = this is Success
/**
* Checks if the Result is a failure.
*/
val isFailure: Boolean get() = this is Failure
/**
* Gets the value if it's a success, otherwise null.
*
* @return the value if this is a Success, or null if this is a Failure
*/
fun getOrNull(): T? = when (this) {
is Success -> value
is Failure -> null
}
/**
* Gets the value if it's a success, otherwise the default value.
*
* @param defaultValue the value to return if this is a Failure
* @return the value if this is a Success, or the default value if this is a Failure
*/
fun getOrDefault(defaultValue: @UnsafeVariance T): T = when (this) {
is Success -> value
is Failure -> defaultValue
}
/**
* Gets the errors if it's a failure, otherwise an empty list.
*
* @return the list of errors if this is a Failure, or an empty list if this is a Success
*/
@JvmName("retrieveErrors")
fun getErrors(): List<ErrorDto> = when (this) {
is Success -> emptyList()
is Failure -> errors
}
/**
* Transforms the value if it's a success.
*
* @param transform function to apply to the success value
* @return a new Success with the transformed value if this is a Success, or this unchanged Failure
*/
inline fun <R> map(transform: (T) -> R): Result<R> = when (this) {
is Success -> Success(transform(value))
is Failure -> this
}
/**
* Transforms the Result flatly (for nested Results).
* Unlike map, which wraps the transformed value in a new Success, flatMap uses the Result returned by the transform function.
*
* @param transform function that returns a Result
* @return the Result returned by the transform function if this is a Success, or this unchanged Failure
*/
inline fun <R> flatMap(transform: (T) -> Result<R>): Result<R> = when (this) {
is Success -> transform(value)
is Failure -> this
}
/**
* Executes an action if it's a success.
*
* @param action the function to execute with the success value
* @return this Result, unchanged, to allow for chaining
*/
inline fun onSuccess(action: (T) -> Unit): Result<T> {
if (this is Success) action(value)
return this
}
/**
* Executes an action if it's a failure.
*
* @param action the function to execute with the list of errors
* @return this Result, unchanged, to allow for chaining
*/
inline fun onFailure(action: (List<ErrorDto>) -> Unit): Result<T> {
if (this is Failure) action(errors)
return this
}
/**
* Transforms the Result by applying one of two functions depending on whether it's a success or failure.
*
* @param onSuccess function to apply if this is a success
* @param onFailure function to apply if this is a failure
* @return the result of applying the appropriate function
*/
inline fun <R> fold(
onSuccess: (T) -> R,
onFailure: (List<ErrorDto>) -> R
): R = when (this) {
is Success -> onSuccess(value)
is Failure -> onFailure(errors)
}
/**
* Attempts to recover from a failure by applying the specified function to the error list.
* If this is already a success, it is returned unchanged.
*
* @param transform function to apply to the error list to recover
* @return a new Success if recovery was successful, or this unchanged Result if already a success
*/
inline fun recover(transform: (List<ErrorDto>) -> @UnsafeVariance T): Result<T> = when (this) {
is Success -> this
is Failure -> Success(transform(errors))
}
/**
* Attempts to recover from a failure by applying the specified function to the error list.
* If this is already a success, it is returned unchanged.
* If an exception occurs during recovery, it is converted to a new failure.
*
* @param transform function to apply to the error list to recover
* @return a new Success if recovery was successful, a new Failure if recovery threw an exception,
* or this unchanged Result if already a success
*/
inline fun recoverCatching(transform: (List<ErrorDto>) -> @UnsafeVariance T): Result<T> = when (this) {
is Success -> this
is Failure -> try {
Success(transform(errors))
} catch (e: Exception) {
Failure(listOf(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("RECOVERY_FAILED"),
message = e.message ?: "Recovery failed with an unknown error"
)))
}
}
/**
* Combines this Result with another Result, creating a pair of their values if both are successful.
* If either Result is a failure, the combined Result will be a failure containing all errors.
*
* @param other the Result to combine with this one
* @return a Result containing a Pair of values if both are successful, or a Failure with all errors
*/
fun <R> zip(other: Result<R>): Result<Pair<T, R>> = when {
this is Success && other is Success -> Success(Pair(this.value, other.value))
this is Success && other is Failure -> Failure(other.errors)
this is Failure && other is Success -> Failure(this.errors)
this is Failure && other is Failure -> {
val allErrors = this.errors + other.errors
Failure(allErrors)
}
// This branch should never be reached due to sealed class, but included for completeness
else -> throw IllegalStateException("Unreachable code - Result should be either Success or Failure")
}
/**
* Safely attempts to get the value, throwing a custom exception if this is a failure.
*
* @param errorHandler function that converts the list of errors to an exception
* @return the value if this is a Success
* @throws E if this is a Failure, as created by the errorHandler
*/
inline fun <E : Throwable> getOrThrow(errorHandler: (List<ErrorDto>) -> E): T = when (this) {
is Success -> value
is Failure -> throw errorHandler(errors)
}
/**
* Gets the value if it's a success, or throws an IllegalStateException with a message constructed from the errors.
*
* @return the value if this is a Success
* @throws IllegalStateException if this is a Failure, with a message containing the error details
*/
fun getOrThrow(): T = getOrThrow { errors ->
IllegalStateException("Result is a Failure with errors: ${errors.joinToString { it.message }}")
}
companion object {
/**
* Creates a successful Result.
*
* @param value the value to wrap in a Success
* @return a new Success containing the provided value
*/
fun <T> success(value: T): Result<T> = Success(value)
/**
* Creates a failure Result with a single error.
*
* @param error the error to include in the Failure
* @return a new Failure containing the provided error
*/
fun <T> failure(error: ErrorDto): Result<T> = Failure(listOf(error))
/**
* Creates a failure Result with multiple errors.
*
* @param errors the list of errors to include in the Failure
* @return a new Failure containing the provided errors
*/
fun <T> failure(errors: List<ErrorDto>): Result<T> = Failure(errors)
/**
* Creates a failure Result from ValidationErrors.
* Converts the ValidationErrors to ErrorDtos internally.
*
* @param validationErrors the list of validation errors to convert and include in the Failure
* @return a new Failure containing ErrorDtos converted from the provided ValidationErrors
*/
@JvmName("failureFromValidationErrors")
fun <T> failure(validationErrors: List<ValidationError>): Result<T> =
Failure(validationErrors.toErrorDtos())
/**
* Executes an operation that returns a Result and catches exceptions.
* Provides more specific error codes based on the type of exception caught.
*
* @param operation the operation to execute
* @return a Success with the operation result, or a Failure with error details if an exception occurred
*/
inline fun <T> runCatching(operation: () -> T): Result<T> = try {
success(operation())
} catch (e: IllegalArgumentException) {
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("INVALID_ARGUMENT"),
message = e.message ?: "Invalid argument provided"
))
} catch (e: IllegalStateException) {
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("INVALID_STATE"),
message = e.message ?: "Operation called in invalid state"
))
} catch (e: UnsupportedOperationException) {
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("UNSUPPORTED_OPERATION"),
message = e.message ?: "Operation not supported"
))
} catch (e: IndexOutOfBoundsException) {
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("INDEX_OUT_OF_BOUNDS"),
message = e.message ?: "Index out of bounds"
))
} catch (e: NullPointerException) {
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("NULL_REFERENCE"),
message = e.message ?: "Unexpected null reference"
))
} catch (e: ClassCastException) {
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("TYPE_MISMATCH"),
message = e.message ?: "Type mismatch occurred"
))
} catch (e: Exception) {
// Fallback for any other exception type
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("OPERATION_FAILED"),
message = e.message ?: "Unknown error occurred"
))
}
/**
* Combines multiple Results into a single Result with a list.
* Optimized for performance with large collections.
*
* @param results a list of Results to combine
* @return a Success containing a list of all success values if all Results are successful,
* or a Failure containing all error messages if any Results are failures
*/
fun <T> combine(results: List<Result<T>>): Result<List<T>> {
// Fast path for empty list
if (results.isEmpty()) {
return success(emptyList())
}
// Fast path for single result
if (results.size == 1) {
return results.first().map { listOf(it) }
}
// Check if there are any failures
val anyFailure = results.any { it.isFailure }
// If no failures, we can optimize by directly mapping to values
if (!anyFailure) {
return success(results.map { (it as Success).value })
}
// If there are failures, collect all errors
val errors = results
.filterIsInstance<Failure>()
.flatMap { it.errors }
// If empty results list contained no failures, return empty success
if (errors.isEmpty()) {
return success(emptyList())
}
return failure(errors)
}
}
}
/**
* Extension function to convert nullable values to Results.
* This is useful for handling nullable values in a functional way.
*
* @param errorMessage custom error message to use when the value is null
* @return a Success containing the non-null value, or a Failure if the value is null
*/
fun <T> T?.toResult(errorMessage: String = "Value is null"): Result<T> =
if (this != null) {
Result.success(this)
} else {
Result.failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("NULL_VALUE"),
message = errorMessage
))
}
@@ -0,0 +1,224 @@
package at.mocode.core.utils
import at.mocode.core.domain.model.ValidationError
/**
* Umfassende Validierungs-Utilities für das gesamte System.
* Stellt typsichere und wiederverwendbare Validierungslogik bereit.
*/
/**
* Builder-Klasse für die Erstellung von Validierungsregeln.
*/
class ValidationBuilder {
private val errors = mutableListOf<ValidationError>()
/**
* Validiert ein Feld gegen mehrere Regeln.
*/
fun <T> field(name: String, value: T, vararg rules: ValidationRule<T>): ValidationBuilder {
rules.forEach { rule ->
rule.validate(name, value)?.let { error ->
errors.add(error)
}
}
return this
}
/**
* Fügt einen benutzerdefinierten Validierungsfehler hinzu.
*/
fun addError(field: String, message: String, code: String = "VALIDATION_ERROR"): ValidationBuilder {
errors.add(ValidationError(field, message, code))
return this
}
/**
* Führt eine benutzerdefinierten Validierung aus.
*/
fun custom(validation: () -> ValidationError?): ValidationBuilder {
validation()?.let { error ->
errors.add(error)
}
return this
}
/**
* Erstellt das finale Validierungsergebnis.
*/
fun build(): Result<Unit> {
return if (errors.isEmpty()) {
Result.success(Unit)
} else {
Result.failure(errors)
}
}
/**
* Gibt die gesammelten Fehler zurück.
*/
fun getErrors(): List<ValidationError> = errors.toList()
}
/**
* Interface für Validierungsregeln.
*/
fun interface ValidationRule<T> {
/**
* Validiert einen Wert und gibt einen Fehler zurück, wenn die Validierung fehlschlägt.
*/
fun validate(fieldName: String, value: T): ValidationError?
}
/**
* Vordefinierte Validierungsregeln.
*/
object ValidationRules {
// === String-Validierungen ===
/**
* Prüft ob ein String nicht leer ist.
*/
fun notBlank(): ValidationRule<String> = ValidationRule { fieldName, value ->
if (value.isBlank()) ValidationError.required(fieldName) else null
}
/**
* Prüft die Mindestlänge eines Strings.
*/
fun minLength(min: Int): ValidationRule<String> = ValidationRule { fieldName, value ->
if (value.length < min) {
ValidationError.invalidLength(fieldName, "$fieldName must be at least $min characters long")
} else null
}
/**
* Prüft die Maximallänge eines Strings.
*/
fun maxLength(max: Int): ValidationRule<String> = ValidationRule { fieldName, value ->
if (value.length > max) {
ValidationError.invalidLength(fieldName, "$fieldName must not exceed $max characters")
} else null
}
/**
* Prüft ob ein String einem RegEx-Pattern entspricht.
*/
fun matches(pattern: Regex, message: String): ValidationRule<String> = ValidationRule { fieldName, value ->
if (!value.matches(pattern)) {
ValidationError.invalidFormat(fieldName, message)
} else null
}
/**
* Prüft ob ein String eine gültige E-Mail-Adresse ist.
*/
fun email(): ValidationRule<String> = ValidationRule { fieldName, value ->
val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
if (!value.matches(emailRegex)) {
ValidationError.invalidFormat(fieldName, "$fieldName must be a valid email address")
} else null
}
// === Numerische Validierungen ===
/**
* Prüft den Mindestwert einer Zahl.
*/
fun <T : Comparable<T>> min(minValue: T): ValidationRule<T> = ValidationRule { fieldName, value ->
if (value < minValue) {
ValidationError.invalidRange(fieldName, "$fieldName must be at least $minValue")
} else null
}
/**
* Prüft den Maximalwert einer Zahl.
*/
fun <T : Comparable<T>> max(maxValue: T): ValidationRule<T> = ValidationRule { fieldName, value ->
if (value > maxValue) {
ValidationError.invalidRange(fieldName, "$fieldName must not exceed $maxValue")
} else null
}
/**
* Prüft ob eine Zahl positiv ist.
*/
fun positive(): ValidationRule<Number> = ValidationRule { fieldName, value ->
if (value.toDouble() <= 0) {
ValidationError.invalidRange(fieldName, "$fieldName must be positive")
} else null
}
/**
* Prüft ob eine Zahl nicht negativ ist.
*/
fun nonNegative(): ValidationRule<Number> = ValidationRule { fieldName, value ->
if (value.toDouble() < 0) {
ValidationError.invalidRange(fieldName, "$fieldName must not be negative")
} else null
}
// === Collection-Validierungen ===
/**
* Prüft ob eine Collection nicht leer ist.
*/
fun <T> notEmpty(): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
if (value.isEmpty()) {
ValidationError.required(fieldName)
} else null
}
/**
* Prüft die Mindestgröße einer Collection.
*/
fun <T> minSize(min: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
if (value.size < min) {
ValidationError.invalidLength(fieldName, "$fieldName must contain at least $min items")
} else null
}
/**
* Prüft die Maximalgröße einer Collection.
*/
fun <T> maxSize(max: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
if (value.size > max) {
ValidationError.invalidLength(fieldName, "$fieldName must not contain more than $max items")
} else null
}
// === Null-Validierungen ===
/**
* Prüft ob ein Wert nicht null ist.
*/
fun <T> notNull(): ValidationRule<T?> = ValidationRule { fieldName, value ->
if (value == null) ValidationError.required(fieldName) else null
}
}
/**
* DSL-Funktion für die Erstellung von Validierungen.
*/
inline fun validate(builder: ValidationBuilder.() -> Unit): Result<Unit> {
return ValidationBuilder().apply(builder).build()
}
/**
* Extension-Funktion für einfache String-Validierung.
*/
fun String?.validateNotBlank(fieldName: String): ValidationError? {
return if (this.isNullOrBlank()) ValidationError.required(fieldName) else null
}
/**
* Extension-Funktion für einfache E-Mail-Validierung.
*/
fun String?.validateEmail(fieldName: String): ValidationError? {
if (this.isNullOrBlank()) return ValidationError.required(fieldName)
val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
return if (!this.matches(emailRegex)) {
ValidationError.invalidFormat(fieldName, "$fieldName must be a valid email address")
} else null
}
@@ -0,0 +1,36 @@
package at.mocode.core.utils
import at.mocode.core.domain.model.PageNumber
import at.mocode.core.domain.model.PageSize
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ExtensionsPagedResponseTest {
@Test
fun `toPagedResponse basic pagination`() {
val list = (1..50).toList()
val page0 = list.toPagedResponse(page = 0, size = 10)
assertEquals(10, page0.content.size)
assertEquals(PageNumber(0), page0.page)
assertEquals(PageSize(10), page0.size)
assertEquals(50L, page0.totalElements)
assertEquals(5, page0.totalPages)
assertTrue(page0.hasNext)
assertFalse(page0.hasPrevious)
val page4 = list.toPagedResponse(page = 4, size = 10)
assertEquals((41..50).toList(), page4.content)
assertFalse(page4.hasNext)
assertTrue(page4.hasPrevious)
val emptyPage = list.toPagedResponse(page = 6, size = 10)
assertTrue(emptyPage.content.isEmpty())
assertEquals(5, emptyPage.totalPages)
assertFalse(emptyPage.hasNext)
assertTrue(emptyPage.hasPrevious)
}
}
@@ -0,0 +1,107 @@
package at.mocode.core.utils
import at.mocode.core.domain.model.ErrorCode
import at.mocode.core.domain.model.ErrorDto
import at.mocode.core.domain.model.ValidationError
import kotlin.test.*
class ResultTest {
@Test
fun `success and failure flags`() {
val s = Result.success(1)
assertTrue(s.isSuccess)
assertFalse(s.isFailure)
val f: Result<Int> = Result.failure(ErrorDto(ErrorCode("E"), "m"))
assertTrue(f.isFailure)
assertFalse(f.isSuccess)
}
@Test
fun `map flatMap fold`() {
val s = Result.success(2).map { it * 2 }
assertEquals(4, (s as Result.Success).value)
val f: Result<Int> = Result.failure(ErrorDto(ErrorCode("E"), "m"))
assertTrue(f.map { it + 1 } is Result.Failure)
val flat = Result.success(2).flatMap { Result.success(it.toString()) }
assertEquals("2", (flat as Result.Success).value)
val folded = flat.fold({ it.length }, { -1 })
assertEquals(1, folded)
}
@Test
fun `zip and combine`() {
val a = Result.success(1)
val b = Result.success("x")
val zipped = a.zip(b)
assertTrue(zipped is Result.Success)
assertEquals(Pair(1, "x"), (zipped as Result.Success).value)
val f1: Result<Int> = Result.failure(ErrorDto(ErrorCode("E1"), ""))
val f2: Result<String> = Result.failure(ErrorDto(ErrorCode("E2"), ""))
val z2 = f1.zip(b)
assertTrue(z2 is Result.Failure)
val combined = Result.combine(listOf(Result.success(1), Result.success(2)))
assertTrue(combined is Result.Success)
assertEquals(listOf(1, 2), (combined as Result.Success).value)
val combinedFail = Result.combine(listOf(f1 as Result<Int>, Result.success(3), Result.failure(ErrorDto(ErrorCode("E3"), ""))))
assertTrue(combinedFail is Result.Failure)
assertEquals(2, (combinedFail as Result.Failure).errors.size)
}
@Test
fun `runCatching failure conversion failureFromValidation and recovery`() {
val ok = Result.runCatching { "ok" }
assertTrue(ok is Result.Success)
val iae = Result.runCatching<String> { throw IllegalArgumentException("bad") }
assertTrue(iae is Result.Failure)
assertEquals("INVALID_ARGUMENT", (iae as Result.Failure).errors.first().code.value)
val generic = Result.runCatching<String> { throw Exception("x") }
assertTrue(generic is Result.Failure)
val verrs = listOf(ValidationError.required("name"), ValidationError.invalidFormat("email"))
val fromVal: Result<Unit> = Result.failure(verrs)
assertTrue(fromVal is Result.Failure)
assertEquals("REQUIRED", (fromVal as Result.Failure).errors.first().code.value)
val rec = Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recover { _ -> "fallback" }
assertTrue(rec is Result.Success)
val recFail = Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recoverCatching { _ -> throw IllegalStateException("boom") }
assertTrue(recFail is Result.Failure)
assertEquals("RECOVERY_FAILED", (recFail as Result.Failure).errors.first().code.value)
}
@Test
fun `getOrNull default throw and toResult`() {
val s = Result.success(5)
assertEquals(5, s.getOrNull())
val f: Result<Int> = Result.failure(ErrorDto(ErrorCode("E"), ""))
assertNull(f.getOrNull())
assertEquals(7, f.getOrDefault(7))
assertEquals(5, s.getOrThrow())
try {
f.getOrThrow()
fail("should throw")
} catch (e: IllegalStateException) {
// ok
}
val nullable: Int? = null
val r = nullable.toResult("ist leer")
assertTrue(r is Result.Failure)
val r2 = 3.toResult()
assertTrue(r2 is Result.Success)
}
}
@@ -0,0 +1,234 @@
package at.mocode.core.utils
import at.mocode.core.domain.model.ErrorCode
import at.mocode.core.domain.model.ErrorDto
import at.mocode.core.domain.model.PagedResponse
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.statements.BatchInsertStatement
import org.jetbrains.exposed.sql.transactions.transaction
import java.sql.SQLException
/**
* JVM-specific database utilities for the Core module.
* Provides common database operations and configurations.
*/
/**
* Executes a database operation in a transaction and returns a Result.
* Provides specific error handling for different database-related exceptions.
*
* @param database Optional database to use (uses default if null)
* @param block The transaction block to execute
* @return A Result containing either the operation result or error information
*/
inline fun <T> transactionResult(
database: Database? = null,
crossinline block: Transaction.() -> T
): Result<T> {
return try {
val result = transaction(database) { block() }
Result.success(result)
} catch (e: SQLException) {
// Handle specific SQL exceptions
val errorCode = when {
e.message?.contains("constraint", ignoreCase = true) == true -> "CONSTRAINT_VIOLATION"
e.message?.contains("duplicate", ignoreCase = true) == true -> "DUPLICATE_ENTRY"
e.message?.contains("timeout", ignoreCase = true) == true -> "DATABASE_TIMEOUT"
else -> "DATABASE_ERROR"
}
Result.failure(
ErrorDto(
code = ErrorCode(errorCode),
message = "Database operation failed: ${e.message}"
)
)
} catch (e: Exception) {
Result.failure(
ErrorDto(
code = ErrorCode("TRANSACTION_ERROR"),
message = "Transaction failed: ${e.message ?: "Unknown error"}"
)
)
}
}
/**
* Executes a write database operation.
*/
inline fun <T> writeTransaction(
database: Database? = null,
crossinline block: Transaction.() -> T
): Result<T> = transactionResult(database, block)
/**
* Executes a read database operation.
*/
inline fun <T> readTransaction(
database: Database? = null,
crossinline block: Transaction.() -> T
): Result<T> = transactionResult(database, block)
/**
* Extension function for Query-Builder to add pagination.
*/
fun Query.paginate(page: Int, size: Int): Query {
require(page >= 0) { "Page number must be non-negative" }
require(size > 0) { "Page size must be positive" }
return this.limit(size, offset = (page * size).toLong())
}
/**
* Creates a PagedResponse from a Query.
* Handles pagination efficiently and manages edge cases properly.
*
* @param page The requested page number (0-based)
* @param size The requested page size
* @param transform Function to transform each ResultRow to the target type
* @return A PagedResponse containing the paginated and transformed data
*/
fun <T> Query.toPagedResponse(
page: Int,
size: Int,
transform: (ResultRow) -> T
): PagedResponse<T> {
// Validate input parameters
require(page >= 0) { "Page number must be non-negative" }
require(size > 0) { "Page size must be positive" }
// Calculate the total count first (executes a COUNT query)
val totalCount = this.count()
// If there are no results, return an empty page
if (totalCount == 0L) {
return PagedResponse.create(
content = emptyList(),
page = page,
size = size,
totalElements = 0,
totalPages = 0,
hasNext = false,
hasPrevious = page > 0
)
}
// Calculate total pages - use ceil division to ensure we round up
val totalPages = ((totalCount + size - 1) / size).toInt()
// Ensure the requested page exists (if page is beyond available pages, return the last page)
val adjustedPage = if (page >= totalPages) (totalPages - 1).coerceAtLeast(0) else page
// Then apply pagination and transform results
val content = this.paginate(adjustedPage, size).map(transform)
return PagedResponse.create(
content = content,
page = adjustedPage,
size = size,
totalElements = totalCount,
totalPages = totalPages,
hasNext = adjustedPage < totalPages - 1,
hasPrevious = adjustedPage > 0
)
}
/**
* Utility class for common database operations.
*/
object DatabaseUtils {
/**
* Checks if a table exists.
* Uses a safe query approach to verify table existence.
*/
fun tableExists(tableName: String, database: Database? = null): Boolean {
return try {
transaction(database) {
// Execute a safer SQL statement to check if table exists
val result = exec("SELECT 1 FROM information_schema.tables WHERE table_name = '$tableName' LIMIT 1")
// If the query returns a result, the table exists
result != null
}
} catch (e: Exception) {
false
}
}
/**
* Creates an index if it doesn't exist.
*/
fun createIndexIfNotExists(
tableName: String,
indexName: String,
columns: Array<String>,
unique: Boolean = false,
database: Database? = null
): Result<Unit> {
return transactionResult(database) {
val uniqueStr = if (unique) "UNIQUE" else ""
val columnsStr = columns.joinToString(", ")
val sql = "CREATE $uniqueStr INDEX IF NOT EXISTS $indexName ON $tableName ($columnsStr)"
exec(sql)
}
}
/**
* Executes a raw SQL query and returns the number of affected rows.
*/
fun executeRawSql(sql: String, database: Database? = null): Result<Int> {
return transactionResult(database) {
(exec(sql) ?: 0) as Int
}
}
/**
* Helper function for batch inserts.
*/
inline fun <T> batchInsert(
table: Table,
data: Iterable<T>,
crossinline body: BatchInsertStatement.(T) -> Unit
): Result<List<ResultRow>> {
return transactionResult {
table.batchInsert(data) { item ->
body(item)
}
}
}
}
/**
* Extension functions for ResultRow.
*/
/**
* Safely gets a value from a ResultRow.
*/
fun <T> ResultRow.getOrNull(column: Column<T>): T? {
return try {
this[column]
} catch (e: Exception) {
null
}
}
/**
* Converts a ResultRow to a Map.
* Safely handles any exceptions during the conversion process.
*/
fun ResultRow.toMap(): Map<String, Any?> {
val result = mutableMapOf<String, Any?>()
this.fieldIndex.forEach { (expression, _) ->
try {
when (expression) {
is Column<*> -> result[expression.name] = this[expression]
else -> result[expression.toString()] = this[expression]
}
} catch (e: Exception) {
// Ignore columns that can't be read and log the error if needed
// You could add logging here in a production environment
}
}
return result
}
@@ -1,69 +0,0 @@
package at.mocode.core.utils.config
/**
* Eine reine, unveränderliche Datenhalte-Klasse für die gesamte Anwendungskonfiguration.
* Wird vom ConfigLoader instanziiert.
*/
data class AppConfig(
val environment: AppEnvironment,
val appInfo: AppInfoConfig,
val server: ServerConfig,
val database: DatabaseConfig,
val serviceDiscovery: ServiceDiscoveryConfig,
val security: SecurityConfig,
val logging: LoggingConfig,
val rateLimit: RateLimitConfig
)
data class AppInfoConfig(
val name: ApplicationName,
val version: ApplicationVersion,
val description: String
)
data class ServerConfig(
val port: Port,
val host: Host,
val advertisedHost: Host,
val workers: WorkerCount,
val cors: CorsConfig
) {
data class CorsConfig(val enabled: Boolean, val allowedOrigins: List<String>)
}
data class DatabaseConfig(
val host: Host,
val port: Port,
val name: DatabaseName,
val jdbcUrl: JdbcUrl,
val username: DatabaseUsername,
val password: DatabasePassword,
val driverClassName: String,
val maxPoolSize: PoolSize,
val minPoolSize: PoolSize,
val autoMigrate: Boolean
)
data class ServiceDiscoveryConfig(
val enabled: Boolean,
val consulHost: Host,
val consulPort: Port
)
data class SecurityConfig(val jwt: JwtConfig, val apiKey: ApiKey?) {
data class JwtConfig(
val secret: JwtSecret,
val issuer: JwtIssuer,
val audience: JwtAudience,
val realm: JwtRealm,
val expirationInMinutes: Long
)
}
data class LoggingConfig(val level: String, val logRequests: Boolean, val logResponses: Boolean)
data class RateLimitConfig(
val enabled: Boolean,
val globalLimit: RateLimit,
val globalPeriodMinutes: PeriodMinutes
)
@@ -1,26 +0,0 @@
package at.mocode.core.utils.config
import org.slf4j.LoggerFactory
enum class AppEnvironment {
DEVELOPMENT,
TEST,
STAGING,
PRODUCTION;
fun isProduction() = this == PRODUCTION
companion object {
private val logger = LoggerFactory.getLogger(AppEnvironment::class.java)
fun current(): AppEnvironment {
val envName = System.getenv("APP_ENV")?.uppercase() ?: "DEVELOPMENT"
return try {
valueOf(envName)
} catch (_: IllegalArgumentException) {
logger.warn("Unknown environment '{}', falling back to DEVELOPMENT.", envName)
DEVELOPMENT
}
}
}
}
@@ -1,136 +0,0 @@
package at.mocode.core.utils.config
import java.io.File
import java.net.InetAddress
import java.util.Properties
/**
* Verantwortlich für das Laden der Anwendungskonfiguration aus verschiedenen Quellen.
* Diese Klasse kapselt die "unreine" Logik des Datei- und Systemzugriffs.
*/
class ConfigLoader(private val configPath: String = "config") {
fun load(environment: AppEnvironment = AppEnvironment.current()): AppConfig {
//val environment = AppEnvironment.current()
val props = loadProperties(environment)
return AppConfig(
environment = environment,
appInfo = createAppInfoConfig(props),
server = createServerConfig(props),
database = createDatabaseConfig(props),
serviceDiscovery = createServiceDiscoveryConfig(props),
security = createSecurityConfig(props),
logging = createLoggingConfig(props, environment),
rateLimit = createRateLimitConfig(props)
)
}
private fun loadProperties(environment: AppEnvironment): Properties {
val props = Properties()
// Lade zuerst die Basis-Properties
loadPropertiesFile("application.properties", props)
// Überschreibe mit umgebungsspezifischen Properties, falls vorhanden
val envFile = "application-${environment.name.lowercase()}.properties"
loadPropertiesFile(envFile, props)
return props
}
private fun loadPropertiesFile(filename: String, props: Properties) {
// Versuche, aus den Ressourcen (im JAR) zu laden
val resourceStream = this::class.java.classLoader.getResourceAsStream(filename)
if (resourceStream != null) {
resourceStream.use { props.load(it) }
return
}
// Fallback für lokale Entwicklung: Lade aus einem 'config'-Ordner
// HIER WIRD DER PARAMETER VERWENDET
val file = File("$configPath/$filename")
if (file.exists()) {
file.inputStream().use { props.load(it) }
}
}
// Die Konfigurations-Erstellungslogik ist hierher verschoben
private fun createAppInfoConfig(props: Properties) = AppInfoConfig(
name = ApplicationName(props.getProperty("app.name", "Meldestelle")),
version = ApplicationVersion(props.getProperty("app.version", "1.0.0")),
description = props.getProperty("app.description", "Pferdesport Meldestelle System")
)
private fun createServerConfig(props: Properties): ServerConfig {
val defaultHost = try {
InetAddress.getLocalHost().hostAddress
} catch (_: Exception) {
"127.0.0.1"
}
return ServerConfig(
port = Port(props.getIntProperty("server.port", "API_PORT", 8081)),
host = Host(props.getStringProperty("server.host", "API_HOST", "0.0.0.0")),
advertisedHost = Host(props.getStringProperty("server.advertisedHost", "API_HOST_ADVERTISED", defaultHost)),
workers = WorkerCount(props.getIntProperty("server.workers", "API_WORKERS", Runtime.getRuntime().availableProcessors())),
cors = ServerConfig.CorsConfig(
enabled = props.getBooleanProperty("server.cors.enabled", "API_CORS_ENABLED", true),
allowedOrigins = props.getProperty("server.cors.allowedOrigins")?.split(",")?.map { it.trim() }
?: listOf("*")
)
)
}
private fun createDatabaseConfig(props: Properties): DatabaseConfig {
val host = props.getStringProperty("database.host", "DB_HOST", "localhost")
val port = props.getIntProperty("database.port", "DB_PORT", 5432)
val name = props.getStringProperty("database.name", "DB_NAME", "meldestelle_db")
return DatabaseConfig(
host = Host(host),
port = Port(port),
name = DatabaseName(name),
jdbcUrl = JdbcUrl("jdbc:postgresql://$host:$port/$name"),
username = DatabaseUsername(props.getStringProperty("database.username", "DB_USER", "meldestelle_user")),
password = DatabasePassword(props.getStringProperty("database.password", "DB_PASSWORD", "secure_password_change_me")),
driverClassName = "org.postgresql.Driver",
maxPoolSize = PoolSize(props.getIntProperty("database.maxPoolSize", "DB_MAX_POOL_SIZE", 10)),
minPoolSize = PoolSize(props.getIntProperty("database.minPoolSize", "DB_MIN_POOL_SIZE", 5)),
autoMigrate = props.getBooleanProperty("database.autoMigrate", "DB_AUTO_MIGRATE", true)
)
}
// ... Fügen Sie hier die verbleibenden 'create...Config' Methoden ein,
// analog zu den 'fromProperties' Methoden aus der alten AppConfig.
private fun createServiceDiscoveryConfig(props: Properties) = ServiceDiscoveryConfig(
enabled = props.getBooleanProperty("service-discovery.enabled", "CONSUL_ENABLED", true),
consulHost = Host(props.getStringProperty("service-discovery.consul.host", "CONSUL_HOST", "consul")),
consulPort = Port(props.getIntProperty("service-discovery.consul.port", "CONSUL_PORT", 8500))
)
private fun createSecurityConfig(props: Properties) = SecurityConfig(
jwt = SecurityConfig.JwtConfig(
secret = JwtSecret(props.getStringProperty(
"security.jwt.secret",
"JWT_SECRET",
"default-secret-please-change-in-production"
)),
issuer = JwtIssuer(props.getStringProperty("security.jwt.issuer", "JWT_ISSUER", "meldestelle-api")),
audience = JwtAudience(props.getStringProperty("security.jwt.audience", "JWT_AUDIENCE", "meldestelle-clients")),
realm = JwtRealm(props.getStringProperty("security.jwt.realm", "JWT_REALM", "meldestelle")),
expirationInMinutes = props.getLongProperty(
"security.jwt.expirationInMinutes",
"JWT_EXPIRATION_MINUTES",
60 * 24
)
),
apiKey = props.getStringProperty("security.apiKey", "API_KEY", "").ifEmpty { null }?.let { ApiKey(it) }
)
private fun createLoggingConfig(props: Properties, env: AppEnvironment) = LoggingConfig(
level = props.getStringProperty("logging.level", "LOG_LEVEL", if (env.isProduction()) "INFO" else "DEBUG"),
logRequests = props.getBooleanProperty("logging.requests", "LOG_REQUESTS", true),
logResponses = props.getBooleanProperty("logging.responses", "LOG_RESPONSES", !env.isProduction())
)
private fun createRateLimitConfig(props: Properties) = RateLimitConfig(
enabled = props.getBooleanProperty("ratelimit.enabled", "RATE_LIMIT_ENABLED", true),
globalLimit = RateLimit(props.getIntProperty("ratelimit.global.limit", "RATE_LIMIT_GLOBAL_LIMIT", 100)),
globalPeriodMinutes = PeriodMinutes(props.getIntProperty("ratelimit.global.periodMinutes", "RATE_LIMIT_GLOBAL_PERIOD", 1))
)
}
@@ -1,260 +0,0 @@
package at.mocode.core.utils.config
import kotlinx.serialization.Serializable
/**
* Value classes for strongly typed configuration parameters.
* These provide compile-time type safety for configuration values.
*/
// === Network Configuration Value Classes ===
/**
* A strongly typed wrapper for port numbers.
*/
@Serializable
@JvmInline
value class Port(val value: Int) {
init {
require(value in 1..65535) { "Port must be between 1 and 65535, got: $value" }
}
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for host names or IP addresses.
*/
@Serializable
@JvmInline
value class Host(val value: String) {
init {
require(value.isNotBlank()) { "Host cannot be blank" }
require(value.length <= 253) { "Host name cannot exceed 253 characters" }
}
override fun toString(): String = value
}
// === Database Configuration Value Classes ===
/**
* A strongly typed wrapper for database names.
*/
@Serializable
@JvmInline
value class DatabaseName(val value: String) {
init {
require(value.isNotBlank()) { "Database name cannot be blank" }
require(value.matches(Regex("^[a-zA-Z][a-zA-Z0-9_]*$"))) {
"Database name must start with a letter and contain only alphanumeric characters and underscores"
}
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for database usernames.
*/
@Serializable
@JvmInline
value class DatabaseUsername(val value: String) {
init {
require(value.isNotBlank()) { "Database username cannot be blank" }
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for database passwords.
*/
@Serializable
@JvmInline
value class DatabasePassword(val value: String) {
init {
require(value.isNotBlank()) { "Database password cannot be blank" }
}
override fun toString(): String = "***" // Never expose the actual password
fun getValue(): String = value
}
/**
* A strongly typed wrapper for JDBC URLs.
*/
@Serializable
@JvmInline
value class JdbcUrl(val value: String) {
init {
require(value.isNotBlank()) { "JDBC URL cannot be blank" }
require(value.startsWith("jdbc:")) { "JDBC URL must start with 'jdbc:'" }
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for connection pool sizes.
*/
@Serializable
@JvmInline
value class PoolSize(val value: Int) {
init {
require(value > 0) { "Pool size must be positive" }
require(value <= 1000) { "Pool size cannot exceed 1000" }
}
override fun toString(): String = value.toString()
}
// === Security Configuration Value Classes ===
/**
* A strongly typed wrapper for API keys.
*/
@Serializable
@JvmInline
value class ApiKey(val value: String) {
init {
require(value.isNotBlank()) { "API key cannot be blank" }
require(value.length >= 16) { "API key must be at least 16 characters long" }
}
override fun toString(): String = "***" // Never expose the actual key
fun getValue(): String = value
}
/**
* A strongly typed wrapper for JWT secrets.
*/
@Serializable
@JvmInline
value class JwtSecret(val value: String) {
init {
require(value.isNotBlank()) { "JWT secret cannot be blank" }
require(value.length >= 32) { "JWT secret must be at least 32 characters long" }
}
override fun toString(): String = "***" // Never expose the actual secret
fun getValue(): String = value
}
/**
* A strongly typed wrapper for JWT issuer.
*/
@Serializable
@JvmInline
value class JwtIssuer(val value: String) {
init {
require(value.isNotBlank()) { "JWT issuer cannot be blank" }
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for JWT audience.
*/
@Serializable
@JvmInline
value class JwtAudience(val value: String) {
init {
require(value.isNotBlank()) { "JWT audience cannot be blank" }
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for JWT realm.
*/
@Serializable
@JvmInline
value class JwtRealm(val value: String) {
init {
require(value.isNotBlank()) { "JWT realm cannot be blank" }
}
override fun toString(): String = value
}
// === Application Configuration Value Classes ===
/**
* A strongly typed wrapper for application names.
*/
@Serializable
@JvmInline
value class ApplicationName(val value: String) {
init {
require(value.isNotBlank()) { "Application name cannot be blank" }
require(value.matches(Regex("^[A-Za-z][A-Za-z0-9-_]*$"))) {
"Application name must start with a letter and contain only letters, numbers, hyphens, and underscores"
}
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for application versions.
*/
@Serializable
@JvmInline
value class ApplicationVersion(val value: String) {
init {
require(value.isNotBlank()) { "Application version cannot be blank" }
require(value.matches(Regex("^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9]+)?$"))) {
"Application version must follow semantic versioning (e.g., 1.0.0 or 1.0.0-beta)"
}
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for worker thread counts.
*/
@Serializable
@JvmInline
value class WorkerCount(val value: Int) {
init {
require(value > 0) { "Worker count must be positive" }
require(value <= Runtime.getRuntime().availableProcessors() * 4) {
"Worker count should not exceed 4 times the available processors"
}
}
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for rate limits.
*/
@Serializable
@JvmInline
value class RateLimit(val value: Int) {
init {
require(value > 0) { "Rate limit must be positive" }
}
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for time periods in minutes.
*/
@Serializable
@JvmInline
value class PeriodMinutes(val value: Int) {
init {
require(value > 0) { "Period must be positive" }
}
override fun toString(): String = value.toString()
}
@@ -1,39 +0,0 @@
package at.mocode.core.utils.config
import java.util.Properties
/**
* Liest eine String-Property, wobei eine Umgebungsvariable Vorrang hat.
*
* @param key Der Schlüssel in der '.properties-Datei'.
* @param envVar Der Name der Umgebungsvariable.
* @param default Der Standardwert, falls weder Property noch Env-Var existieren.
* @return Der geladene Konfigurationswert.
*/
fun Properties.getStringProperty(key: String, envVar: String, default: String): String {
return System.getenv(envVar) ?: this.getProperty(key, default)
}
/**
* Liest eine Integer-Property, wobei eine Umgebungsvariable Vorrang hat.
*/
fun Properties.getIntProperty(key: String, envVar: String, default: Int): Int {
val value = System.getenv(envVar) ?: this.getProperty(key)
return value?.toIntOrNull() ?: default
}
/**
* Liest eine Boolean-Property, wobei eine Umgebungsvariable Vorrang hat.
*/
fun Properties.getBooleanProperty(key: String, envVar: String, default: Boolean): Boolean {
val value = System.getenv(envVar) ?: this.getProperty(key)
return value?.toBoolean() ?: default
}
/**
* Liest eine Long-Property, wobei eine Umgebungsvariable Vorrang hat.
*/
fun Properties.getLongProperty(key: String, envVar: String, default: Long): Long {
val value = System.getenv(envVar) ?: this.getProperty(key)
return value?.toLongOrNull() ?: default
}
@@ -1,85 +0,0 @@
package at.mocode.core.utils.database
import at.mocode.core.utils.config.DatabaseConfig
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.slf4j.LoggerFactory
class DatabaseFactory(private val config: DatabaseConfig) {
private companion object {
private val logger = LoggerFactory.getLogger(DatabaseFactory::class.java)
}
private var dataSource: HikariDataSource? = null
private var database: Database? = null
fun connect() {
if (dataSource != null) {
logger.warn("Database already connected. Closing existing connection before creating a new one.")
close()
}
logger.info("Initializing database connection to ${config.jdbcUrl}")
val hikariConfig = createHikariConfig()
val ds = HikariDataSource(hikariConfig)
dataSource = ds
database = Database.connect(ds)
if (config.autoMigrate) {
runFlyway(ds)
}
}
fun close() {
dataSource?.close()
dataSource = null
database = null
logger.info("Database connection closed.")
}
suspend fun <T> dbQuery(block: suspend () -> T): T {
val db = database ?: throw IllegalStateException("Database has not been connected. Call connect() first.")
return newSuspendedTransaction(Dispatchers.IO, db = db) {
block()
}
}
private fun createHikariConfig(): HikariConfig {
return HikariConfig().apply {
driverClassName = config.driverClassName
jdbcUrl = config.jdbcUrl.value
username = config.username.value
password = config.password.getValue() // Use getValue() for password to access actual value
maximumPoolSize = config.maxPoolSize.value
minimumIdle = config.minPoolSize.value
isAutoCommit = false
transactionIsolation = "TRANSACTION_READ_COMMITTED"
validationTimeout = 5000
connectionTimeout = 30000
idleTimeout = 600000
maxLifetime = 1800000
leakDetectionThreshold = 60000
poolName = "MeldestelleDbPool"
}
}
private fun runFlyway(dataSource: HikariDataSource) {
logger.info("Starting Flyway migrations...")
try {
val count = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.load()
.migrate()
.migrationsExecuted
logger.info("Flyway migrations completed successfully. Applied $count migrations.")
} catch (e: Exception) {
logger.error("Flyway migration failed!", e)
throw IllegalStateException("Flyway migration failed", e)
}
}
}
@@ -1,54 +0,0 @@
package at.mocode.core.utils.serialization
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuidFrom
// KORREKTUR: Der Import wurde von java.math.BigDecimal auf die korrekte Bibliothek geändert.
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import kotlin.time.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlin.time.ExperimentalTime
object BigDecimalSerializer : KSerializer<BigDecimal> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BigDecimal", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: BigDecimal) = encoder.encodeString(value.toStringExpanded())
override fun deserialize(decoder: Decoder): BigDecimal = BigDecimal.parseString(decoder.decodeString())
}
object UuidSerializer : KSerializer<Uuid> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Uuid) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Uuid = uuidFrom(decoder.decodeString())
}
@OptIn(ExperimentalTime::class)
object KotlinInstantSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
}
object KotlinLocalDateSerializer : KSerializer<LocalDate> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDate) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): LocalDate = LocalDate.parse(decoder.decodeString())
}
object KotlinLocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDateTime) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): LocalDateTime = LocalDateTime.parse(decoder.decodeString())
}
object KotlinLocalTimeSerializer : KSerializer<LocalTime> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalTime) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): LocalTime = LocalTime.parse(decoder.decodeString())
}
@@ -1,66 +0,0 @@
package at.mocode.core.utils.validation
/**
* API-specific validation utilities for all modules.
*/
object ApiValidationUtils {
/**
* Validates query parameters with common validation rules
*/
fun validateQueryParameters(
limit: String? = null,
offset: String? = null,
): List<ValidationError> {
val errors = mutableListOf<ValidationError>()
// Validate limit parameter
limit?.let { limitStr ->
try {
val limitValue = limitStr.toInt()
if (limitValue < 1 || limitValue > 1000) {
errors.add(ValidationError("limit", "Limit must be between 1 and 1000", "INVALID_RANGE"))
}
} catch (_: NumberFormatException) {
errors.add(ValidationError("limit", "Limit must be a valid integer", "INVALID_FORMAT"))
}
}
// Validate offset parameter
offset?.let { offsetStr ->
try {
val offsetValue = offsetStr.toInt()
if (offsetValue < 0) {
errors.add(ValidationError("offset", "Offset must be non-negative", "INVALID_RANGE"))
}
} catch (_: NumberFormatException) {
errors.add(ValidationError("offset", "Offset must be a valid integer", "INVALID_FORMAT"))
}
}
return errors
}
/**
* Validates authentication request data
*/
fun validateLoginRequest(username: String?, password: String?): List<ValidationError> {
val errors = mutableListOf<ValidationError>()
ValidationUtils.validateNotBlank(username, "username")?.let { errors.add(it) }
ValidationUtils.validateNotBlank(password, "password")?.let { errors.add(it) }
username?.let {
ValidationUtils.validateLength(it, "username", 50, 3)?.let { error -> errors.add(error) }
if (it.contains("@")) {
ValidationUtils.validateEmail(it, "username")?.let { error -> errors.add(error) }
}
}
password?.let {
ValidationUtils.validateLength(it, "password", 128, 8)?.let { error -> errors.add(error) }
}
return errors
}
}
@@ -1,49 +0,0 @@
package at.mocode.core.utils.validation
import kotlinx.serialization.Serializable
/**
* Repräsentiert das Ergebnis einer Validierungsoperation als versiegelte Klasse.
* Stellt sicher, dass ein Ergebnis entweder 'Valid' oder 'Invalid' ist.
*/
@Serializable
sealed class ValidationResult {
/**
* Repräsentiert eine erfolgreiche Validierung.
*/
@Serializable
object Valid : ValidationResult()
/**
* Repräsentiert eine fehlgeschlagene Validierung mit einer Liste von spezifischen Fehlern.
*/
@Serializable
data class Invalid(val errors: List<ValidationError>) : ValidationResult()
fun isValid(): Boolean = this is Valid
fun isInvalid(): Boolean = this is Invalid
}
/**
* Repräsentiert einen einzelnen Validierungsfehler.
*
* @param field Das Feld, dessen Validierung fehlschlug.
* @param message Eine menschenlesbare Fehlermeldung.
* @param code Ein maschinenlesbarer Fehlercode für Clients.
*/
@Serializable
data class ValidationError(
val field: String,
val message: String,
val code: String? = null
)
/**
* Eine Exception, die eine fehlgeschlagene Validierung repräsentiert.
* Kann von zentralen Fehlerbehandlungs-Mechanismen abgefangen werden.
*/
class ValidationException(
val validationResult: ValidationResult.Invalid
) : IllegalArgumentException(
"Validation failed: ${validationResult.errors.joinToString { "${it.field}: ${it.message}" }}"
)
@@ -1,51 +0,0 @@
package at.mocode.core.utils.validation
/**
* Common validation utilities
*/
object ValidationUtils {
/**
* Validates that a string is not blank
*/
fun validateNotBlank(value: String?, fieldName: String): ValidationError? {
return if (value.isNullOrBlank()) {
ValidationError(fieldName, "$fieldName cannot be blank", "REQUIRED")
} else null
}
/**
* Validates string length
*/
fun validateLength(value: String?, fieldName: String, maxLength: Int, minLength: Int = 0): ValidationError? {
if (value == null) return null
return when {
value.length < minLength -> ValidationError(
fieldName,
"$fieldName must be at least $minLength characters long",
"MIN_LENGTH"
)
value.length > maxLength -> ValidationError(
fieldName,
"$fieldName cannot exceed $maxLength characters",
"MAX_LENGTH"
)
else -> null
}
}
/**
* Validates email format
*/
fun validateEmail(email: String?, fieldName: String = "email"): ValidationError? {
if (email.isNullOrBlank()) return null
val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\$".toRegex()
return if (!emailRegex.matches(email)) {
ValidationError(fieldName, "Invalid email format", "INVALID_FORMAT")
} else null
}
}
@@ -1,15 +0,0 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="STDOUT"/>
</root>
<logger name="at.mocode" level="debug" additivity="false">
<appender-ref ref="STDOUT"/>
</logger>
</configuration>
@@ -1,90 +0,0 @@
package at.mocode.core.utils.config
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.io.TempDir
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
class ConfigLoaderTest {
// JUnit 5 erstellt automatisch ein temporäres Verzeichnis für diesen Test
@TempDir
lateinit var tempDir: File
private lateinit var configDir: File
@BeforeEach
fun setup() {
// Wir erstellen unsere eigene 'config'-Verzeichnisstruktur im temporären Ordner
configDir = File(tempDir, "config")
configDir.mkdir()
}
@Test
fun `load should use default values when no properties file is present`() {
// Arrange
// HINWEIS: Der Loader braucht den Pfad zum übergeordneten Temp-Verzeichnis.
val configLoader = ConfigLoader(tempDir.absolutePath)
// Act
val config = configLoader.load(AppEnvironment.DEVELOPMENT)
// Assert
assertEquals("Meldestelle", config.appInfo.name.value)
assertEquals(8081, config.server.port.value) // Standard-Port
}
@Test
fun `load should read values from base application_properties`() {
// Arrange
// Erstelle eine Test-Konfigurationsdatei
File(tempDir, "application.properties").writeText(
"""
app.name=TestApp
server.port=9999
""".trimIndent()
)
// HINWEIS: Der Loader braucht den Pfad zum übergeordneten Temp-Verzeichnis.
val configLoader = ConfigLoader(tempDir.absolutePath)
// Act
val config = configLoader.load(AppEnvironment.DEVELOPMENT)
// Assert
assertEquals("TestApp", config.appInfo.name.value)
assertEquals(9999, config.server.port.value)
}
@Test
fun `load should override base properties with environment-specific properties`() {
// Arrange
File(tempDir, "application.properties").writeText(
"""
app.name=BaseApp
server.port=8000
database.host=base-db-host
""".trimIndent()
)
File(tempDir, "application-test.properties").writeText(
"""
app.name=TestEnvApp
server.port=9000
""".trimIndent()
)
// HINWEIS: Der Loader braucht den Pfad zum übergeordneten Temp-Verzeichnis.
val configLoader = ConfigLoader(tempDir.absolutePath)
// Act
val config = configLoader.load(AppEnvironment.TEST)
// Assert
assertEquals(AppEnvironment.TEST, config.environment, "Environment should be TEST")
assertEquals("TestEnvApp", config.appInfo.name.value, "app.name should be overridden")
assertEquals(9000, config.server.port.value, "server.port should be overridden")
assertEquals("base-db-host", config.database.host.value, "database.host should come from the base file")
}
}
@@ -1,89 +0,0 @@
package at.mocode.core.utils.database
import at.mocode.core.utils.config.*
import kotlinx.coroutines.runBlocking
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
// 1. Aktiviert die Testcontainers-Unterstützung für diese Klasse
@Testcontainers
class DatabaseFactoryTest {
// 2. Definiert einen PostgreSQL-Container, der vor den Tests gestartet wird
companion object {
@Container
val postgresContainer = PostgreSQLContainer<Nothing>("postgres:16-alpine").apply {
withDatabaseName("testdb")
withUsername("test-user")
withPassword("test-password")
}
}
private lateinit var databaseFactory: DatabaseFactory
private lateinit var dbConfig: DatabaseConfig
// 3. Diese Methode wird VOR jedem Test ausgeführt
@BeforeEach
fun setup() {
// Erstelle eine DB-Konfiguration mit den dynamischen Daten des gestarteten Containers
dbConfig = DatabaseConfig(
host = Host(postgresContainer.host),
port = Port(postgresContainer.firstMappedPort),
name = DatabaseName(postgresContainer.databaseName),
jdbcUrl = JdbcUrl(postgresContainer.jdbcUrl),
username = DatabaseUsername(postgresContainer.username),
password = DatabasePassword(postgresContainer.password),
driverClassName = "org.postgresql.Driver",
maxPoolSize = PoolSize(2),
minPoolSize = PoolSize(1),
autoMigrate = false // Wir steuern Migrationen im Test manuell
)
// Erstelle eine neue Factory-Instanz und verbinde sie mit der Test-DB
databaseFactory = DatabaseFactory(dbConfig)
databaseFactory.connect()
}
// 4. Diese Methode wird NACH jedem Test ausgeführt
@AfterEach
fun tearDown() {
databaseFactory.close()
}
// Ein einfaches Test-Tabellen-Objekt für Exposed
private object Users : Table("test_users") {
val id = integer("id").autoIncrement()
val name = varchar("name", 50)
override val primaryKey = PrimaryKey(id)
}
@Test
fun `dbQuery should connect and execute a transaction against a real PostgreSQL container`() {
// Act & Assert
// runBlocking wird verwendet, da dbQuery eine suspend-Funktion ist
runBlocking {
val resultName = databaseFactory.dbQuery {
// Führe Operationen in einer Transaktion aus
SchemaUtils.create(Users)
Users.insert {
it[name] = "Stefan"
}
// Lese den gerade eingefügten Wert
Users.selectAll().first()[Users.name]
}
// Überprüfe das Ergebnis
assertNotNull(resultName)
assertEquals("Stefan", resultName)
}
}
}
@@ -1,46 +0,0 @@
package at.mocode.core.utils.validation
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ApiValidationUtilsTest {
@Test
fun `validateQueryParameters should validate limit and offset`() {
// Test valid parameters
var errors = ApiValidationUtils.validateQueryParameters(limit = "50", offset = "10")
assertTrue(errors.isEmpty(), "Valid limit and offset should produce no errors")
// Test invalid limit
errors = ApiValidationUtils.validateQueryParameters(limit = "invalid")
assertEquals(1, errors.size)
assertEquals("limit", errors.first().field)
// Test out of range limit
errors = ApiValidationUtils.validateQueryParameters(limit = "0")
assertEquals(1, errors.size)
assertEquals("limit", errors.first().field)
// Test invalid offset
errors = ApiValidationUtils.validateQueryParameters(offset = "-1")
assertEquals(1, errors.size)
assertEquals("offset", errors.first().field)
}
@Test
fun `validateLoginRequest should validate username and password`() {
// Test valid request
var errors = ApiValidationUtils.validateLoginRequest("user@example.com", "password123")
assertTrue(errors.isEmpty())
// Test missing username
errors = ApiValidationUtils.validateLoginRequest(null, "password123")
assertTrue(errors.any { it.field == "username" })
// Test password too short
errors = ApiValidationUtils.validateLoginRequest("user@example.com", "pass")
assertTrue(errors.any { it.field == "password" })
}
}
@@ -1,35 +0,0 @@
package at.mocode.core.utils.validation
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class ValidationUtilsTest {
@Test
fun `validateNotBlank should return error for blank strings`() {
assertNotNull(ValidationUtils.validateNotBlank(null, "testField"))
assertNotNull(ValidationUtils.validateNotBlank("", "testField"))
assertNotNull(ValidationUtils.validateNotBlank(" ", "testField"))
}
@Test
fun `validateNotBlank should return null for non-blank strings`() {
assertNull(ValidationUtils.validateNotBlank("value", "testField"))
}
@Test
fun `validateLength should check min and max length`() {
assertNotNull(ValidationUtils.validateLength("a", "testField", 5, 2), "Should fail for being too short")
assertNotNull(ValidationUtils.validateLength("abcdef", "testField", 5, 2), "Should fail for being too long")
assertNull(ValidationUtils.validateLength("abc", "testField", 5, 2), "Should pass with valid length")
}
@Test
fun `validateEmail should validate email format`() {
assertNull(ValidationUtils.validateEmail("test@example.com", "email"))
assertNotNull(ValidationUtils.validateEmail("test@", "email"))
assertNotNull(ValidationUtils.validateEmail("test@example", "email"))
assertNotNull(ValidationUtils.validateEmail("test.example.com", "email"))
}
}