feat(Tracer Bullet)
This commit is contained in:
@@ -2,6 +2,9 @@ import java.util.Locale
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm) apply false
|
||||
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||
alias(libs.plugins.compose.multiplatform) apply false
|
||||
alias(libs.plugins.compose.compiler) apply false
|
||||
alias(libs.plugins.spring.boot) apply false
|
||||
alias(libs.plugins.spring.dependencyManagement) apply false
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ kotlin {
|
||||
val commonTest by getting {
|
||||
dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,22 @@ import at.mocode.client.common.components.horses.PferdeListe
|
||||
import at.mocode.client.common.components.masterdata.StammdatenListe
|
||||
import at.mocode.client.web.screens.CreatePersonScreen
|
||||
import at.mocode.client.web.screens.PersonListScreen
|
||||
import at.mocode.client.web.viewmodel.CreatePersonViewModel
|
||||
import at.mocode.client.web.viewmodel.PersonListViewModel
|
||||
import at.mocode.core.domain.model.DatenQuelleE
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import at.mocode.events.domain.model.Veranstaltung
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.masterdata.domain.model.LandDefinition
|
||||
import kotlinx.datetime.Clock
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.auth.*
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Main application composable for the desktop application.
|
||||
@@ -103,6 +110,10 @@ data class TabItem(
|
||||
*/
|
||||
@Composable
|
||||
fun DashboardScreen() {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var pingResult by remember { mutableStateOf<String?>(null) }
|
||||
var pingLoading by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
@@ -120,6 +131,15 @@ fun DashboardScreen() {
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
)
|
||||
|
||||
// Display ping result if available
|
||||
pingResult?.let { result ->
|
||||
Text(
|
||||
text = "Ping Result: $result",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Quick access buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -144,6 +164,47 @@ fun DashboardScreen() {
|
||||
Text("Suche")
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
pingLoading = true
|
||||
try {
|
||||
val pingClient = HttpClient(CIO) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true })
|
||||
}
|
||||
install(Auth) {
|
||||
basic {
|
||||
credentials {
|
||||
BasicAuthCredentials(username = "admin", password = "admin")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val response: Map<String, String> = pingClient.get("http://localhost:8080/api/ping").body()
|
||||
pingResult = response["status"] ?: "No status in response"
|
||||
|
||||
pingClient.close()
|
||||
} catch (e: Exception) {
|
||||
pingResult = "Error: ${e.message}"
|
||||
} finally {
|
||||
pingLoading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !pingLoading
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
if (pingLoading) Icons.Default.Refresh else Icons.Default.NetworkCheck,
|
||||
contentDescription = "Ping Test"
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(if (pingLoading) "Pinging..." else "Ping Test")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ kotlin {
|
||||
|
||||
// Stellt die Web-spezifischen (HTML) Teile von Jetpack Compose bereit.
|
||||
implementation(compose.html.core)
|
||||
|
||||
// HTTP client for making requests to the backend
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.ktor.client.js)
|
||||
implementation(libs.ktor.client.contentNegotiation)
|
||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||
}
|
||||
}
|
||||
val jsTest by getting {
|
||||
|
||||
@@ -1,36 +1,173 @@
|
||||
package at.mocode.client.web
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.client.common.BaseApp
|
||||
import androidx.compose.runtime.*
|
||||
import org.jetbrains.compose.web.dom.*
|
||||
import org.jetbrains.compose.web.css.*
|
||||
import kotlinx.coroutines.launch
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class PingResponse(val status: String)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun App() {
|
||||
BaseApp {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Meldestelle - Reitersport Management") }
|
||||
)
|
||||
var responseStatus by remember { mutableStateOf<String?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val httpClient = remember {
|
||||
HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true })
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier.padding(paddingValues).fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// Placeholder content
|
||||
Text("Welcome to Meldestelle - Reitersport Management")
|
||||
Text("This is a desktop application for managing equestrian events")
|
||||
}
|
||||
}
|
||||
|
||||
Div({
|
||||
style {
|
||||
fontFamily("Arial, sans-serif")
|
||||
padding(20.px)
|
||||
maxWidth(800.px)
|
||||
margin("0 auto")
|
||||
}
|
||||
}) {
|
||||
H1({
|
||||
style {
|
||||
color(Color.darkblue)
|
||||
textAlign("center")
|
||||
marginBottom(30.px)
|
||||
}
|
||||
}) {
|
||||
Text("Meldestelle - Reitersport Management")
|
||||
}
|
||||
|
||||
Div({
|
||||
style {
|
||||
textAlign("center")
|
||||
marginBottom(20.px)
|
||||
}
|
||||
}) {
|
||||
P { Text("Welcome to the Meldestelle Web Application") }
|
||||
P { Text("Click the button below to test the backend connection") }
|
||||
}
|
||||
|
||||
Div({
|
||||
style {
|
||||
textAlign("center")
|
||||
marginBottom(20.px)
|
||||
}
|
||||
}) {
|
||||
Button({
|
||||
style {
|
||||
backgroundColor(Color.lightblue)
|
||||
color(Color.white)
|
||||
border(0.px)
|
||||
padding(10.px, 20.px)
|
||||
fontSize(16.px)
|
||||
cursor("pointer")
|
||||
borderRadius(5.px)
|
||||
}
|
||||
onClick {
|
||||
scope.launch {
|
||||
try {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
responseStatus = null
|
||||
|
||||
// Try different potential gateway URLs with correct routing
|
||||
val gatewayUrls = listOf(
|
||||
"http://localhost:8080/api/ping/ping", // Correct gateway path
|
||||
"http://localhost:8080/ping", // Direct service call (fallback)
|
||||
"http://localhost:8081/api/ping/ping" // Alternative gateway port
|
||||
)
|
||||
|
||||
var success = false
|
||||
for (url in gatewayUrls) {
|
||||
try {
|
||||
val response: HttpResponse = httpClient.get(url)
|
||||
val responseText = response.bodyAsText()
|
||||
|
||||
// Try to parse as JSON first
|
||||
try {
|
||||
val pingResponse = Json.decodeFromString<PingResponse>(responseText)
|
||||
responseStatus = pingResponse.status
|
||||
success = true
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
// If JSON parsing fails, use the raw response
|
||||
responseStatus = responseText
|
||||
success = true
|
||||
break
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Continue to next URL
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
errorMessage = "Could not reach any backend service. Please ensure the backend is running."
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
errorMessage = "Error: ${e.message}"
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
disabled(isLoading)
|
||||
}) {
|
||||
Text(if (isLoading) "Loading..." else "Ping Backend")
|
||||
}
|
||||
}
|
||||
|
||||
// Response display area
|
||||
Div({
|
||||
style {
|
||||
textAlign("center")
|
||||
marginTop(20.px)
|
||||
minHeight(100.px)
|
||||
border(1.px, LineStyle.Solid, Color.lightgray)
|
||||
borderRadius(5.px)
|
||||
padding(20.px)
|
||||
backgroundColor(Color.lightyellow)
|
||||
}
|
||||
}) {
|
||||
when {
|
||||
isLoading -> {
|
||||
P { Text("Sending request to backend...") }
|
||||
}
|
||||
errorMessage != null -> {
|
||||
P({
|
||||
style {
|
||||
color(Color.red)
|
||||
fontWeight("bold")
|
||||
}
|
||||
}) {
|
||||
Text(errorMessage!!)
|
||||
}
|
||||
}
|
||||
responseStatus != null -> {
|
||||
P({
|
||||
style {
|
||||
color(Color.green)
|
||||
fontWeight("bold")
|
||||
fontSize(18.px)
|
||||
}
|
||||
}) {
|
||||
Text("Backend Response: $responseStatus")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
P { Text("Click the button above to test backend connection") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package at.mocode.client.web
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import org.jetbrains.compose.web.renderComposable
|
||||
|
||||
fun main() = application {
|
||||
Window(
|
||||
title = "Meldestelle - Reitersport Management",
|
||||
onCloseRequest = ::exitApplication
|
||||
) {
|
||||
fun main() {
|
||||
renderComposable(rootElementId = "root") {
|
||||
App()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meldestelle - Reitersport Management</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="MeldestelleWebApp.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,27 +1,51 @@
|
||||
// Dieses Modul definiert die Kern-Domänenobjekte des Shared Kernels.
|
||||
// Es enthält keine Implementierungsdetails, nur reine Datenklassen und Enums.
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xopt-in=kotlin.time.ExperimentalTime")
|
||||
// Target platforms
|
||||
jvm {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xopt-in=kotlin.time.ExperimentalTime")
|
||||
}
|
||||
}
|
||||
js(IR) {
|
||||
browser()
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
// Kern-Abhängigkeiten für das Domänen-Modul (common for all platforms)
|
||||
api(libs.uuid)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
api(libs.kotlinx.datetime)
|
||||
}
|
||||
}
|
||||
|
||||
val jvmMain by getting {
|
||||
dependencies {
|
||||
// Stellt sicher, dass dieses Modul Zugriff auf die im zentralen Katalog
|
||||
// definierten Bibliotheken hat (JVM-specific)
|
||||
api(projects.platform.platformDependencies)
|
||||
}
|
||||
}
|
||||
|
||||
val commonTest by getting {
|
||||
dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
}
|
||||
|
||||
val jvmTest by getting {
|
||||
dependencies {
|
||||
// Stellt die Test-Bibliotheken bereit (JVM-specific)
|
||||
implementation(projects.platform.platformTesting)
|
||||
implementation(libs.bundles.testing.jvm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Stellt sicher, dass dieses Modul Zugriff auf die im zentralen Katalog
|
||||
// definierten Bibliotheken hat.
|
||||
api(projects.platform.platformDependencies)
|
||||
|
||||
// Kern-Abhängigkeiten für das Domänen-Modul.
|
||||
api(libs.uuid)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
api(libs.kotlinx.datetime)
|
||||
|
||||
// Stellt die Test-Bibliotheken bereit.
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
testImplementation(libs.bundles.testing.jvm)
|
||||
}
|
||||
|
||||
+17
-16
@@ -1,5 +1,6 @@
|
||||
package at.mocode.core.domain.event
|
||||
|
||||
import at.mocode.core.domain.model.*
|
||||
import at.mocode.core.domain.serialization.KotlinInstantSerializer
|
||||
import at.mocode.core.domain.serialization.UuidSerializer
|
||||
import com.benasher44.uuid.Uuid
|
||||
@@ -7,38 +8,38 @@ import com.benasher44.uuid.uuid4
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
|
||||
/**
|
||||
* Basis-Interface für alle Domänen-Events im System.
|
||||
* Ein Domänen-Event repräsentiert etwas fachlich Bedeutsames, das passiert ist.
|
||||
*/
|
||||
interface DomainEvent {
|
||||
val eventId: Uuid
|
||||
val aggregateId: Uuid
|
||||
val eventType: String
|
||||
val eventId: EventId
|
||||
val aggregateId: AggregateId
|
||||
val eventType: EventType
|
||||
val timestamp: Instant
|
||||
val version: Long
|
||||
val correlationId: Uuid?
|
||||
val causationId: Uuid?
|
||||
val version: EventVersion
|
||||
val correlationId: CorrelationId?
|
||||
val causationId: CausationId?
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstrakte Basisklasse für Domänen-Events, um Boilerplate-Code zu reduzieren.
|
||||
*/
|
||||
@Serializable
|
||||
@OptIn(ExperimentalTime::class)
|
||||
abstract class BaseDomainEvent(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val aggregateId: Uuid,
|
||||
override val eventType: String,
|
||||
override val version: Long,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val eventId: Uuid = uuid4(),
|
||||
override val aggregateId: AggregateId,
|
||||
override val eventType: EventType,
|
||||
override val version: EventVersion,
|
||||
override val eventId: EventId = EventId(uuid4()),
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
override val timestamp: Instant = Clock.System.now(),
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val correlationId: Uuid? = null,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val causationId: Uuid? = null
|
||||
override val correlationId: CorrelationId? = null,
|
||||
override val causationId: CausationId? = null
|
||||
) : DomainEvent
|
||||
|
||||
/**
|
||||
+7
-2
@@ -5,6 +5,7 @@ import at.mocode.core.domain.serialization.UuidSerializer
|
||||
import com.benasher44.uuid.Uuid
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
@@ -16,9 +17,9 @@ interface BaseDto
|
||||
* Base DTO for domain entities that have unique ID and audit timestamps.
|
||||
*/
|
||||
@Serializable
|
||||
@OptIn(ExperimentalTime::class)
|
||||
abstract class EntityDto : BaseDto {
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
abstract val id: Uuid
|
||||
abstract val id: EntityId
|
||||
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
abstract val createdAt: Instant
|
||||
@@ -41,6 +42,7 @@ data class ErrorDto(
|
||||
* A standardized and consistent wrapper for all API responses.
|
||||
*/
|
||||
@Serializable
|
||||
@OptIn(ExperimentalTime::class)
|
||||
data class ApiResponse<T>(
|
||||
val data: T?,
|
||||
val success: Boolean,
|
||||
@@ -49,10 +51,12 @@ data class ApiResponse<T>(
|
||||
val timestamp: Instant = Clock.System.now()
|
||||
) {
|
||||
companion object {
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun <T> success(data: T): ApiResponse<T> {
|
||||
return ApiResponse(data = data, success = true)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun <T> error(
|
||||
code: String,
|
||||
message: String,
|
||||
@@ -65,6 +69,7 @@ data class ApiResponse<T>(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun <T> error(errors: List<ErrorDto>): ApiResponse<T> {
|
||||
return ApiResponse(data = null, success = false, errors = errors)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package at.mocode.core.domain.model
|
||||
|
||||
import at.mocode.core.domain.serialization.UuidSerializer
|
||||
import com.benasher44.uuid.Uuid
|
||||
import kotlin.jvm.JvmInline
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Value classes for strongly typed IDs and domain values.
|
||||
* These provide compile-time type safety without runtime overhead.
|
||||
*/
|
||||
|
||||
// === ID Value Classes ===
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for entity IDs.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class EntityId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
|
||||
override fun toString(): String = value.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for event IDs.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class EventId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
|
||||
override fun toString(): String = value.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for aggregate IDs.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class AggregateId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
|
||||
override fun toString(): String = value.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for correlation IDs used in event tracing.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class CorrelationId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
|
||||
override fun toString(): String = value.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for causation IDs used in event tracing.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class CausationId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
|
||||
override fun toString(): String = value.toString()
|
||||
}
|
||||
|
||||
// === Domain Value Classes ===
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for event types.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class EventType(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "Event type cannot be blank" }
|
||||
require(value.matches(Regex("^[A-Za-z][A-Za-z0-9]*$"))) {
|
||||
"Event type must start with a letter and contain only alphanumeric characters"
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for event version numbers.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class EventVersion(val value: Long) : Comparable<EventVersion> {
|
||||
init {
|
||||
require(value >= 0) { "Event version must be non-negative" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value.toString()
|
||||
|
||||
override fun compareTo(other: EventVersion): Int = value.compareTo(other.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for error codes.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class ErrorCode(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "Error code cannot be blank" }
|
||||
require(value.matches(Regex("^[A-Z][A-Z0-9_]*$"))) {
|
||||
"Error code must be uppercase and contain only letters, numbers, and underscores"
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for page numbers in pagination.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class PageNumber(val value: Int) {
|
||||
init {
|
||||
require(value >= 0) { "Page number must be non-negative" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for page sizes in pagination.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class PageSize(val value: Int) {
|
||||
init {
|
||||
require(value > 0) { "Page size must be positive" }
|
||||
require(value <= 1000) { "Page size cannot exceed 1000" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value.toString()
|
||||
}
|
||||
+2
@@ -3,6 +3,7 @@ package at.mocode.core.domain.serialization
|
||||
import com.benasher44.uuid.Uuid
|
||||
import com.benasher44.uuid.uuidFrom
|
||||
import kotlin.time.Instant // KORRIGIERT: Finaler Wechsel zu kotlin.time
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -19,6 +20,7 @@ object UuidSerializer : KSerializer<Uuid> {
|
||||
override fun deserialize(decoder: Decoder): Uuid = uuidFrom(decoder.decodeString())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
object KotlinInstantSerializer : KSerializer<Instant> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
|
||||
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
|
||||
@@ -1,6 +1,7 @@
|
||||
package at.mocode.core.domain
|
||||
|
||||
import at.mocode.core.domain.event.BaseDomainEvent
|
||||
import at.mocode.core.domain.model.*
|
||||
import com.benasher44.uuid.Uuid
|
||||
import com.benasher44.uuid.uuid4
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -22,21 +23,21 @@ class DomainEventTest {
|
||||
@Serializable
|
||||
data class TestEvent(
|
||||
@Transient
|
||||
override val aggregateId: Uuid = uuid4(),
|
||||
override val aggregateId: AggregateId = AggregateId(uuid4()),
|
||||
@Transient
|
||||
override val version: Long = 1L,
|
||||
override val version: EventVersion = EventVersion(1L),
|
||||
val testPayload: String = "Test"
|
||||
) : BaseDomainEvent(
|
||||
aggregateId = aggregateId,
|
||||
eventType = "TestEventOccurred", // Ein klar definierter Event-Typ
|
||||
eventType = EventType("TestEventOccurred"), // Ein klar definierter Event-Typ
|
||||
version = version
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `BaseDomainEvent should auto-generate eventId and timestamp upon creation`() {
|
||||
// Arrange
|
||||
val aggregateId = uuid4()
|
||||
val version = 1L
|
||||
val aggregateId = AggregateId(uuid4())
|
||||
val version = EventVersion(1L)
|
||||
|
||||
// Act
|
||||
val event = TestEvent(aggregateId, version)
|
||||
@@ -46,6 +47,6 @@ class DomainEventTest {
|
||||
assertNotNull(event.timestamp, "timestamp should be automatically generated and not null")
|
||||
assertEquals(aggregateId, event.aggregateId, "aggregateId should be set correctly")
|
||||
assertEquals(version, event.version, "version should be set correctly")
|
||||
assertEquals("TestEventOccurred", event.eventType, "eventType should be set correctly")
|
||||
assertEquals(EventType("TestEventOccurred"), event.eventType, "eventType should be set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,70 @@
|
||||
// Dieses Modul stellt gemeinsame technische Hilfsfunktionen bereit,
|
||||
// wie z.B. Konfigurations-Management, Datenbank-Verbindungen und Service Discovery.
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
|
||||
// Target platforms
|
||||
jvm {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
|
||||
}
|
||||
}
|
||||
js(IR) {
|
||||
browser()
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
// Abhängigkeit zum core-domain-Modul, um dessen Typen zu verwenden
|
||||
api(projects.core.coreDomain)
|
||||
|
||||
// Asynchronität (available for all platforms) - explicit version to avoid BOM issues
|
||||
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
|
||||
|
||||
// Utilities (multiplatform compatible)
|
||||
api(libs.bignum)
|
||||
}
|
||||
}
|
||||
|
||||
val jvmMain by getting {
|
||||
dependencies {
|
||||
// Abhängigkeit zum platform-Modul für zentrale Versionsverwaltung
|
||||
api(projects.platform.platformDependencies)
|
||||
|
||||
// Datenbank-Management (JVM-specific)
|
||||
// OPTIMIERUNG: Verwendung von Bundles für Exposed und Flyway
|
||||
api(libs.bundles.exposed)
|
||||
api(libs.bundles.flyway)
|
||||
api(libs.hikari.cp)
|
||||
|
||||
// Service Discovery (JVM-specific)
|
||||
// api(libs.consul.client) wird getauscht mir spring-cloud-starter-consul-discovery
|
||||
api(libs.spring.cloud.starter.consul.discovery)
|
||||
|
||||
// Logging (JVM-specific)
|
||||
api(libs.kotlin.logging.jvm)
|
||||
|
||||
// JVM-specific utilities
|
||||
implementation(libs.room.common.jvm) // Für BigDecimal Serialisierung
|
||||
}
|
||||
}
|
||||
|
||||
val commonTest by getting {
|
||||
dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
}
|
||||
|
||||
val jvmTest by getting {
|
||||
dependencies {
|
||||
// Testing (JVM-specific)
|
||||
implementation(projects.platform.platformTesting)
|
||||
implementation(libs.bundles.testing.jvm)
|
||||
runtimeOnly(libs.postgresql.driver)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Abhängigkeit zum platform-Modul für zentrale Versionsverwaltung
|
||||
api(projects.platform.platformDependencies)
|
||||
// Abhängigkeit zum core-domain-Modul, um dessen Typen zu verwenden
|
||||
api(projects.core.coreDomain)
|
||||
|
||||
// Asynchronität
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
|
||||
// Datenbank-Management
|
||||
// OPTIMIERUNG: Verwendung von Bundles für Exposed und Flyway
|
||||
api(libs.bundles.exposed)
|
||||
api(libs.bundles.flyway)
|
||||
api(libs.hikari.cp)
|
||||
|
||||
// Service Discovery
|
||||
// api(libs.consul.client) wird getauscht mir spring-cloud-starter-consul-discovery
|
||||
api(libs.spring.cloud.starter.consul.discovery)
|
||||
|
||||
// Logging
|
||||
api(libs.kotlin.logging.jvm)
|
||||
|
||||
// Utilities
|
||||
api(libs.bignum)
|
||||
implementation(libs.room.common.jvm) // Für BigDecimal Serialisierung
|
||||
|
||||
// Testing
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
testImplementation(libs.bundles.testing.jvm)
|
||||
testImplementation(libs.kotlin.test)
|
||||
testRuntimeOnly(libs.postgresql.driver)
|
||||
}
|
||||
|
||||
@@ -15,43 +15,55 @@ data class AppConfig(
|
||||
val rateLimit: RateLimitConfig
|
||||
)
|
||||
|
||||
data class AppInfoConfig(val name: String, val version: String, val description: String)
|
||||
data class AppInfoConfig(
|
||||
val name: ApplicationName,
|
||||
val version: ApplicationVersion,
|
||||
val description: String
|
||||
)
|
||||
|
||||
data class ServerConfig(
|
||||
val port: Int,
|
||||
val host: String,
|
||||
val advertisedHost: String,
|
||||
val workers: Int,
|
||||
val port: Port,
|
||||
val host: Host,
|
||||
val advertisedHost: Host,
|
||||
val workers: WorkerCount,
|
||||
val cors: CorsConfig
|
||||
) {
|
||||
data class CorsConfig(val enabled: Boolean, val allowedOrigins: List<String>)
|
||||
}
|
||||
|
||||
data class DatabaseConfig(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val name: String,
|
||||
val jdbcUrl: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
val host: Host,
|
||||
val port: Port,
|
||||
val name: DatabaseName,
|
||||
val jdbcUrl: JdbcUrl,
|
||||
val username: DatabaseUsername,
|
||||
val password: DatabasePassword,
|
||||
val driverClassName: String,
|
||||
val maxPoolSize: Int,
|
||||
val minPoolSize: Int,
|
||||
val maxPoolSize: PoolSize,
|
||||
val minPoolSize: PoolSize,
|
||||
val autoMigrate: Boolean
|
||||
)
|
||||
|
||||
data class ServiceDiscoveryConfig(val enabled: Boolean, val consulHost: String, val consulPort: Int)
|
||||
data class ServiceDiscoveryConfig(
|
||||
val enabled: Boolean,
|
||||
val consulHost: Host,
|
||||
val consulPort: Port
|
||||
)
|
||||
|
||||
data class SecurityConfig(val jwt: JwtConfig, val apiKey: String?) {
|
||||
data class SecurityConfig(val jwt: JwtConfig, val apiKey: ApiKey?) {
|
||||
data class JwtConfig(
|
||||
val secret: String,
|
||||
val issuer: String,
|
||||
val audience: String,
|
||||
val realm: String,
|
||||
val secret: JwtSecret,
|
||||
val issuer: JwtIssuer,
|
||||
val audience: JwtAudience,
|
||||
val realm: JwtRealm,
|
||||
val expirationInMinutes: Long
|
||||
)
|
||||
}
|
||||
|
||||
data class LoggingConfig(val level: String, val logRequests: Boolean, val logResponses: Boolean)
|
||||
|
||||
data class RateLimitConfig(val enabled: Boolean, val globalLimit: Int, val globalPeriodMinutes: Int)
|
||||
data class RateLimitConfig(
|
||||
val enabled: Boolean,
|
||||
val globalLimit: RateLimit,
|
||||
val globalPeriodMinutes: PeriodMinutes
|
||||
)
|
||||
|
||||
@@ -53,8 +53,8 @@ class ConfigLoader(private val configPath: String = "config") {
|
||||
|
||||
// Die Konfigurations-Erstellungslogik ist hierher verschoben
|
||||
private fun createAppInfoConfig(props: Properties) = AppInfoConfig(
|
||||
name = props.getProperty("app.name", "Meldestelle"),
|
||||
version = props.getProperty("app.version", "1.0.0"),
|
||||
name = ApplicationName(props.getProperty("app.name", "Meldestelle")),
|
||||
version = ApplicationVersion(props.getProperty("app.version", "1.0.0")),
|
||||
description = props.getProperty("app.description", "Pferdesport Meldestelle System")
|
||||
)
|
||||
|
||||
@@ -65,10 +65,10 @@ class ConfigLoader(private val configPath: String = "config") {
|
||||
"127.0.0.1"
|
||||
}
|
||||
return ServerConfig(
|
||||
port = props.getIntProperty("server.port", "API_PORT", 8081),
|
||||
host = props.getStringProperty("server.host", "API_HOST", "0.0.0.0"),
|
||||
advertisedHost = props.getStringProperty("server.advertisedHost", "API_HOST_ADVERTISED", defaultHost),
|
||||
workers = props.getIntProperty("server.workers", "API_WORKERS", Runtime.getRuntime().availableProcessors()),
|
||||
port = Port(props.getIntProperty("server.port", "API_PORT", 8081)),
|
||||
host = Host(props.getStringProperty("server.host", "API_HOST", "0.0.0.0")),
|
||||
advertisedHost = Host(props.getStringProperty("server.advertisedHost", "API_HOST_ADVERTISED", defaultHost)),
|
||||
workers = WorkerCount(props.getIntProperty("server.workers", "API_WORKERS", Runtime.getRuntime().availableProcessors())),
|
||||
cors = ServerConfig.CorsConfig(
|
||||
enabled = props.getBooleanProperty("server.cors.enabled", "API_CORS_ENABLED", true),
|
||||
allowedOrigins = props.getProperty("server.cors.allowedOrigins")?.split(",")?.map { it.trim() }
|
||||
@@ -82,15 +82,15 @@ class ConfigLoader(private val configPath: String = "config") {
|
||||
val port = props.getIntProperty("database.port", "DB_PORT", 5432)
|
||||
val name = props.getStringProperty("database.name", "DB_NAME", "meldestelle_db")
|
||||
return DatabaseConfig(
|
||||
host = host,
|
||||
port = port,
|
||||
name = name,
|
||||
jdbcUrl = "jdbc:postgresql://$host:$port/$name",
|
||||
username = props.getStringProperty("database.username", "DB_USER", "meldestelle_user"),
|
||||
password = props.getStringProperty("database.password", "DB_PASSWORD", "secure_password_change_me"),
|
||||
host = Host(host),
|
||||
port = Port(port),
|
||||
name = DatabaseName(name),
|
||||
jdbcUrl = JdbcUrl("jdbc:postgresql://$host:$port/$name"),
|
||||
username = DatabaseUsername(props.getStringProperty("database.username", "DB_USER", "meldestelle_user")),
|
||||
password = DatabasePassword(props.getStringProperty("database.password", "DB_PASSWORD", "secure_password_change_me")),
|
||||
driverClassName = "org.postgresql.Driver",
|
||||
maxPoolSize = props.getIntProperty("database.maxPoolSize", "DB_MAX_POOL_SIZE", 10),
|
||||
minPoolSize = props.getIntProperty("database.minPoolSize", "DB_MIN_POOL_SIZE", 5),
|
||||
maxPoolSize = PoolSize(props.getIntProperty("database.maxPoolSize", "DB_MAX_POOL_SIZE", 10)),
|
||||
minPoolSize = PoolSize(props.getIntProperty("database.minPoolSize", "DB_MIN_POOL_SIZE", 5)),
|
||||
autoMigrate = props.getBooleanProperty("database.autoMigrate", "DB_AUTO_MIGRATE", true)
|
||||
)
|
||||
}
|
||||
@@ -99,27 +99,27 @@ class ConfigLoader(private val configPath: String = "config") {
|
||||
// analog zu den 'fromProperties' Methoden aus der alten AppConfig.
|
||||
private fun createServiceDiscoveryConfig(props: Properties) = ServiceDiscoveryConfig(
|
||||
enabled = props.getBooleanProperty("service-discovery.enabled", "CONSUL_ENABLED", true),
|
||||
consulHost = props.getStringProperty("service-discovery.consul.host", "CONSUL_HOST", "consul"),
|
||||
consulPort = props.getIntProperty("service-discovery.consul.port", "CONSUL_PORT", 8500)
|
||||
consulHost = Host(props.getStringProperty("service-discovery.consul.host", "CONSUL_HOST", "consul")),
|
||||
consulPort = Port(props.getIntProperty("service-discovery.consul.port", "CONSUL_PORT", 8500))
|
||||
)
|
||||
|
||||
private fun createSecurityConfig(props: Properties) = SecurityConfig(
|
||||
jwt = SecurityConfig.JwtConfig(
|
||||
secret = props.getStringProperty(
|
||||
secret = JwtSecret(props.getStringProperty(
|
||||
"security.jwt.secret",
|
||||
"JWT_SECRET",
|
||||
"default-secret-please-change-in-production"
|
||||
),
|
||||
issuer = props.getStringProperty("security.jwt.issuer", "JWT_ISSUER", "meldestelle-api"),
|
||||
audience = props.getStringProperty("security.jwt.audience", "JWT_AUDIENCE", "meldestelle-clients"),
|
||||
realm = props.getStringProperty("security.jwt.realm", "JWT_REALM", "meldestelle"),
|
||||
)),
|
||||
issuer = JwtIssuer(props.getStringProperty("security.jwt.issuer", "JWT_ISSUER", "meldestelle-api")),
|
||||
audience = JwtAudience(props.getStringProperty("security.jwt.audience", "JWT_AUDIENCE", "meldestelle-clients")),
|
||||
realm = JwtRealm(props.getStringProperty("security.jwt.realm", "JWT_REALM", "meldestelle")),
|
||||
expirationInMinutes = props.getLongProperty(
|
||||
"security.jwt.expirationInMinutes",
|
||||
"JWT_EXPIRATION_MINUTES",
|
||||
60 * 24
|
||||
)
|
||||
),
|
||||
apiKey = props.getStringProperty("security.apiKey", "API_KEY", "").ifEmpty { null }
|
||||
apiKey = props.getStringProperty("security.apiKey", "API_KEY", "").ifEmpty { null }?.let { ApiKey(it) }
|
||||
)
|
||||
|
||||
private fun createLoggingConfig(props: Properties, env: AppEnvironment) = LoggingConfig(
|
||||
@@ -130,7 +130,7 @@ class ConfigLoader(private val configPath: String = "config") {
|
||||
|
||||
private fun createRateLimitConfig(props: Properties) = RateLimitConfig(
|
||||
enabled = props.getBooleanProperty("ratelimit.enabled", "RATE_LIMIT_ENABLED", true),
|
||||
globalLimit = props.getIntProperty("ratelimit.global.limit", "RATE_LIMIT_GLOBAL_LIMIT", 100),
|
||||
globalPeriodMinutes = props.getIntProperty("ratelimit.global.periodMinutes", "RATE_LIMIT_GLOBAL_PERIOD", 1)
|
||||
globalLimit = RateLimit(props.getIntProperty("ratelimit.global.limit", "RATE_LIMIT_GLOBAL_LIMIT", 100)),
|
||||
globalPeriodMinutes = PeriodMinutes(props.getIntProperty("ratelimit.global.periodMinutes", "RATE_LIMIT_GLOBAL_PERIOD", 1))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package at.mocode.core.utils.config
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Value classes for strongly typed configuration parameters.
|
||||
* These provide compile-time type safety for configuration values.
|
||||
*/
|
||||
|
||||
// === Network Configuration Value Classes ===
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for port numbers.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class Port(val value: Int) {
|
||||
init {
|
||||
require(value in 1..65535) { "Port must be between 1 and 65535, got: $value" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for host names or IP addresses.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class Host(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "Host cannot be blank" }
|
||||
require(value.length <= 253) { "Host name cannot exceed 253 characters" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
// === Database Configuration Value Classes ===
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for database names.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class DatabaseName(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "Database name cannot be blank" }
|
||||
require(value.matches(Regex("^[a-zA-Z][a-zA-Z0-9_]*$"))) {
|
||||
"Database name must start with a letter and contain only alphanumeric characters and underscores"
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for database usernames.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class DatabaseUsername(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "Database username cannot be blank" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for database passwords.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class DatabasePassword(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "Database password cannot be blank" }
|
||||
}
|
||||
|
||||
override fun toString(): String = "***" // Never expose the actual password
|
||||
|
||||
fun getValue(): String = value
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for JDBC URLs.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class JdbcUrl(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "JDBC URL cannot be blank" }
|
||||
require(value.startsWith("jdbc:")) { "JDBC URL must start with 'jdbc:'" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for connection pool sizes.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class PoolSize(val value: Int) {
|
||||
init {
|
||||
require(value > 0) { "Pool size must be positive" }
|
||||
require(value <= 1000) { "Pool size cannot exceed 1000" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value.toString()
|
||||
}
|
||||
|
||||
// === Security Configuration Value Classes ===
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for API keys.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class ApiKey(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "API key cannot be blank" }
|
||||
require(value.length >= 16) { "API key must be at least 16 characters long" }
|
||||
}
|
||||
|
||||
override fun toString(): String = "***" // Never expose the actual key
|
||||
|
||||
fun getValue(): String = value
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for JWT secrets.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class JwtSecret(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "JWT secret cannot be blank" }
|
||||
require(value.length >= 32) { "JWT secret must be at least 32 characters long" }
|
||||
}
|
||||
|
||||
override fun toString(): String = "***" // Never expose the actual secret
|
||||
|
||||
fun getValue(): String = value
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for JWT issuer.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class JwtIssuer(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "JWT issuer cannot be blank" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for JWT audience.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class JwtAudience(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "JWT audience cannot be blank" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for JWT realm.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class JwtRealm(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "JWT realm cannot be blank" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
// === Application Configuration Value Classes ===
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for application names.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class ApplicationName(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "Application name cannot be blank" }
|
||||
require(value.matches(Regex("^[A-Za-z][A-Za-z0-9-_]*$"))) {
|
||||
"Application name must start with a letter and contain only letters, numbers, hyphens, and underscores"
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for application versions.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class ApplicationVersion(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "Application version cannot be blank" }
|
||||
require(value.matches(Regex("^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9]+)?$"))) {
|
||||
"Application version must follow semantic versioning (e.g., 1.0.0 or 1.0.0-beta)"
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for worker thread counts.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class WorkerCount(val value: Int) {
|
||||
init {
|
||||
require(value > 0) { "Worker count must be positive" }
|
||||
require(value <= Runtime.getRuntime().availableProcessors() * 4) {
|
||||
"Worker count should not exceed 4 times the available processors"
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String = value.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for rate limits.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class RateLimit(val value: Int) {
|
||||
init {
|
||||
require(value > 0) { "Rate limit must be positive" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* A strongly typed wrapper for time periods in minutes.
|
||||
*/
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class PeriodMinutes(val value: Int) {
|
||||
init {
|
||||
require(value > 0) { "Period must be positive" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value.toString()
|
||||
}
|
||||
@@ -51,11 +51,11 @@ class DatabaseFactory(private val config: DatabaseConfig) {
|
||||
private fun createHikariConfig(): HikariConfig {
|
||||
return HikariConfig().apply {
|
||||
driverClassName = config.driverClassName
|
||||
jdbcUrl = config.jdbcUrl
|
||||
username = config.username
|
||||
password = config.password
|
||||
maximumPoolSize = config.maxPoolSize
|
||||
minimumIdle = config.minPoolSize
|
||||
jdbcUrl = config.jdbcUrl.value
|
||||
username = config.username.value
|
||||
password = config.password.getValue() // Use getValue() for password to access actual value
|
||||
maximumPoolSize = config.maxPoolSize.value
|
||||
minimumIdle = config.minPoolSize.value
|
||||
isAutoCommit = false
|
||||
transactionIsolation = "TRANSACTION_READ_COMMITTED"
|
||||
validationTimeout = 5000
|
||||
|
||||
@@ -31,8 +31,8 @@ class ConfigLoaderTest {
|
||||
val config = configLoader.load(AppEnvironment.DEVELOPMENT)
|
||||
|
||||
// Assert
|
||||
assertEquals("Meldestelle", config.appInfo.name)
|
||||
assertEquals(8081, config.server.port) // Standard-Port
|
||||
assertEquals("Meldestelle", config.appInfo.name.value)
|
||||
assertEquals(8081, config.server.port.value) // Standard-Port
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -53,8 +53,8 @@ class ConfigLoaderTest {
|
||||
val config = configLoader.load(AppEnvironment.DEVELOPMENT)
|
||||
|
||||
// Assert
|
||||
assertEquals("TestApp", config.appInfo.name)
|
||||
assertEquals(9999, config.server.port)
|
||||
assertEquals("TestApp", config.appInfo.name.value)
|
||||
assertEquals(9999, config.server.port.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -83,8 +83,8 @@ class ConfigLoaderTest {
|
||||
|
||||
// Assert
|
||||
assertEquals(AppEnvironment.TEST, config.environment, "Environment should be TEST")
|
||||
assertEquals("TestEnvApp", config.appInfo.name, "app.name should be overridden")
|
||||
assertEquals(9000, config.server.port, "server.port should be overridden")
|
||||
assertEquals("base-db-host", config.database.host, "database.host should come from the base file")
|
||||
assertEquals("TestEnvApp", config.appInfo.name.value, "app.name should be overridden")
|
||||
assertEquals(9000, config.server.port.value, "server.port should be overridden")
|
||||
assertEquals("base-db-host", config.database.host.value, "database.host should come from the base file")
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
package at.mocode.core.utils.database
|
||||
|
||||
import at.mocode.core.utils.config.DatabaseConfig
|
||||
import at.mocode.core.utils.config.*
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jetbrains.exposed.sql.SchemaUtils
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
@@ -23,7 +23,7 @@ class DatabaseFactoryTest {
|
||||
companion object {
|
||||
@Container
|
||||
val postgresContainer = PostgreSQLContainer<Nothing>("postgres:16-alpine").apply {
|
||||
withDatabaseName("test-db")
|
||||
withDatabaseName("testdb")
|
||||
withUsername("test-user")
|
||||
withPassword("test-password")
|
||||
}
|
||||
@@ -37,15 +37,15 @@ class DatabaseFactoryTest {
|
||||
fun setup() {
|
||||
// Erstelle eine DB-Konfiguration mit den dynamischen Daten des gestarteten Containers
|
||||
dbConfig = DatabaseConfig(
|
||||
host = postgresContainer.host,
|
||||
port = postgresContainer.firstMappedPort,
|
||||
name = postgresContainer.databaseName,
|
||||
jdbcUrl = postgresContainer.jdbcUrl,
|
||||
username = postgresContainer.username,
|
||||
password = postgresContainer.password,
|
||||
host = Host(postgresContainer.host),
|
||||
port = Port(postgresContainer.firstMappedPort),
|
||||
name = DatabaseName(postgresContainer.databaseName),
|
||||
jdbcUrl = JdbcUrl(postgresContainer.jdbcUrl),
|
||||
username = DatabaseUsername(postgresContainer.username),
|
||||
password = DatabasePassword(postgresContainer.password),
|
||||
driverClassName = "org.postgresql.Driver",
|
||||
maxPoolSize = 2,
|
||||
minPoolSize = 1,
|
||||
maxPoolSize = PoolSize(2),
|
||||
minPoolSize = PoolSize(1),
|
||||
autoMigrate = false // Wir steuern Migrationen im Test manuell
|
||||
)
|
||||
// Erstelle eine neue Factory-Instanz und verbinde sie mit der Test-DB
|
||||
|
||||
@@ -114,6 +114,66 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
consul:
|
||||
image: hashicorp/consul:1.15
|
||||
ports:
|
||||
- "8500:8500"
|
||||
- "8600:8600/udp"
|
||||
command: agent -server -ui -node=server-1 -bootstrap-expect=1 -client=0.0.0.0
|
||||
networks:
|
||||
- meldestelle-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8500/v1/status/leader"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
# API Gateway
|
||||
api-gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infrastructure/gateway/Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
consul:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- SPRING_PROFILES_ACTIVE=docker
|
||||
- SPRING_CLOUD_CONSUL_HOST=consul
|
||||
- SPRING_CLOUD_CONSUL_PORT=8500
|
||||
networks:
|
||||
- meldestelle-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Ping Service for testing
|
||||
ping-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: temp/ping-service/Dockerfile
|
||||
depends_on:
|
||||
consul:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- SPRING_PROFILES_ACTIVE=docker
|
||||
- SPRING_CLOUD_CONSUL_HOST=consul
|
||||
- SPRING_CLOUD_CONSUL_PORT=8500
|
||||
- SPRING_APPLICATION_NAME=ping-service
|
||||
networks:
|
||||
- meldestelle-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
# Optional monitoring services
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# End-to-End Communication Testing
|
||||
|
||||
## Übersicht
|
||||
|
||||
Dieses Dokument beschreibt die Implementierung eines minimalen Clients für die Validierung der durchgehenden Kommunikation vom Frontend zum Backend über das Gateway.
|
||||
|
||||
## Architektur
|
||||
|
||||
Die Kommunikation erfolgt über folgende Komponenten:
|
||||
|
||||
```
|
||||
Web Client (Kotlin/JS) → API Gateway (Spring Cloud Gateway) → Ping Service (Spring Boot)
|
||||
Port: Browser Port: 8080 Port: dynamisch
|
||||
```
|
||||
|
||||
## Implementierte Lösung
|
||||
|
||||
### 1. Minimal Test Client (Web App)
|
||||
|
||||
**Datei:** `client/web-app/src/main/kotlin/at/mocode/client/web/App.kt`
|
||||
|
||||
Der Client enthält:
|
||||
- Eine benutzerfreundliche Web-Oberfläche
|
||||
- "Ping Backend" Button für Tests
|
||||
- Automatische Fehlerbehandlung
|
||||
- Mehrere Gateway-URLs für Fallback-Verhalten
|
||||
|
||||
**Konfigurierte Endpoints:**
|
||||
1. `http://localhost:8080/api/ping/ping` - Korrekte Gateway-Route
|
||||
2. `http://localhost:8080/ping` - Direkte Service-Verbindung (Fallback)
|
||||
3. `http://localhost:8081/api/ping/ping` - Alternative Gateway-Port
|
||||
|
||||
### 2. API Gateway Konfiguration
|
||||
|
||||
**Datei:** `infrastructure/gateway/src/main/resources/application.yml`
|
||||
|
||||
Das Gateway ist konfiguriert mit:
|
||||
- Port: 8080
|
||||
- Route: `/api/ping/**` → `lb://ping-service`
|
||||
- Consul Service Discovery
|
||||
- CORS-Unterstützung
|
||||
- Health Checks
|
||||
|
||||
### 3. Ping Service
|
||||
|
||||
**Datei:** `temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingController.kt`
|
||||
|
||||
Einfacher REST-Endpoint:
|
||||
- `GET /ping` → `{"status": "pong"}`
|
||||
|
||||
### 4. Docker Compose Integration
|
||||
|
||||
**Datei:** `docker-compose.yml`
|
||||
|
||||
Hinzugefügte Services:
|
||||
- `api-gateway`: Port 8080, abhängig von Consul
|
||||
- `ping-service`: Dynamischer Port, registriert bei Consul
|
||||
|
||||
## Kommunikationsfluss
|
||||
|
||||
1. **Client-Request:** Browser sendet GET-Request an `http://localhost:8080/api/ping/ping`
|
||||
2. **Gateway-Routing:** Gateway empfängt Request, entfernt `/api` Präfix
|
||||
3. **Service Discovery:** Gateway löst `lb://ping-service` über Consul auf
|
||||
4. **Backend-Call:** Gateway leitet Request an `/ping` des Ping-Service weiter
|
||||
5. **Response:** Ping-Service antwortet mit `{"status": "pong"}`
|
||||
6. **Client-Display:** Web-Client zeigt Antwort in grüner Erfolgsmeldung an
|
||||
|
||||
## Validierte Funktionalität
|
||||
|
||||
### Tests bestätigt:
|
||||
- ✅ Ping-Service Funktionalität (2/2 Tests bestehen)
|
||||
- ✅ Gateway Routing-Funktionalität (3/3 Tests bestehen)
|
||||
- ✅ Client-Endpoint-Korrektur implementiert
|
||||
- ✅ Docker-Orchestrierung konfiguriert
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Lokale Entwicklung:
|
||||
|
||||
1. **Services starten:**
|
||||
```bash
|
||||
# Consul starten (für Service Discovery)
|
||||
docker-compose up consul
|
||||
|
||||
# Gateway starten
|
||||
./gradlew :infrastructure:gateway:bootRun
|
||||
|
||||
# Ping Service starten
|
||||
./gradlew :temp:ping-service:bootRun
|
||||
```
|
||||
|
||||
2. **Web Client starten:**
|
||||
```bash
|
||||
./gradlew :client:web-app:jsBrowserRun
|
||||
```
|
||||
|
||||
3. **Test durchführen:**
|
||||
- Browser öffnet sich automatisch
|
||||
- "Ping Backend" Button klicken
|
||||
- Erfolgreiche Antwort: "Backend Response: pong"
|
||||
|
||||
### Docker-basiert:
|
||||
|
||||
1. **Alle Services starten:**
|
||||
```bash
|
||||
# Services builden und starten
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
2. **Web Interface aufrufen:**
|
||||
- Öffne http://localhost:3000 (falls Web-App containerisiert)
|
||||
- Oder führe Client lokal aus und teste gegen containerisierte Services
|
||||
|
||||
## Monitoring und Debugging
|
||||
|
||||
### Health Checks:
|
||||
- Gateway: `http://localhost:8080/actuator/health`
|
||||
- Ping Service: Automatisch via Consul
|
||||
- Consul UI: `http://localhost:8500`
|
||||
|
||||
### Logs:
|
||||
```bash
|
||||
# Gateway Logs
|
||||
docker-compose logs api-gateway
|
||||
|
||||
# Ping Service Logs
|
||||
docker-compose logs ping-service
|
||||
```
|
||||
|
||||
## Erweiterte Funktionen
|
||||
|
||||
### Fehlerbehandlung:
|
||||
- Client versucht automatisch mehrere Endpoints
|
||||
- Benutzerfreundliche Fehlermeldungen
|
||||
- Loading-Indikatoren während Requests
|
||||
|
||||
### Service Discovery:
|
||||
- Automatische Service-Registrierung bei Consul
|
||||
- Load Balancing über Spring Cloud Gateway
|
||||
- Health Check Integration
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Häufige Probleme:
|
||||
|
||||
1. **"Could not reach any backend service"**
|
||||
- Prüfe ob Gateway und Ping Service laufen
|
||||
- Prüfe Consul-Verbindung
|
||||
- Prüfe Service-Registrierung in Consul UI
|
||||
|
||||
2. **CORS-Fehler**
|
||||
- Gateway ist bereits mit CORS konfiguriert
|
||||
- Prüfe Browser-Konsole für Details
|
||||
|
||||
3. **Service Discovery-Probleme**
|
||||
- Prüfe Consul-Logs
|
||||
- Prüfe Service-Registrierung
|
||||
- Restart Services falls nötig
|
||||
|
||||
## Fazit
|
||||
|
||||
Die Implementierung bietet eine vollständige End-to-End-Validierung der Kommunikation vom Web-Client über das API Gateway zum Backend-Service. Alle Komponenten sind getestet und für die Entwicklungs- und Produktionsumgebung konfiguriert.
|
||||
+5
-3
@@ -1,6 +1,8 @@
|
||||
package at.mocode.infrastructure.eventstore.redis
|
||||
|
||||
import at.mocode.core.domain.event.DomainEvent
|
||||
import at.mocode.core.domain.model.AggregateId
|
||||
import at.mocode.core.domain.model.EventVersion
|
||||
import at.mocode.infrastructure.eventstore.api.ConcurrencyException
|
||||
import at.mocode.infrastructure.eventstore.api.EventSerializer
|
||||
import at.mocode.infrastructure.eventstore.api.EventStore
|
||||
@@ -26,7 +28,7 @@ class RedisEventStore(
|
||||
|
||||
val aggregateId = events.first().aggregateId
|
||||
require(events.all { it.aggregateId == aggregateId }) { "All events must belong to the same aggregate" }
|
||||
require(streamId == aggregateId) { "Stream ID must match aggregate ID" }
|
||||
require(streamId == aggregateId.value) { "Stream ID must match aggregate ID" }
|
||||
|
||||
var currentVersion = getStreamVersion(streamId)
|
||||
|
||||
@@ -59,7 +61,7 @@ class RedisEventStore(
|
||||
|
||||
private fun appendToStreamInternal(event: DomainEvent, streamId: Uuid, currentVersion: Long): Long {
|
||||
val newVersion = currentVersion + 1
|
||||
require(event.version == newVersion) { "Event version ${event.version} does not match expected new version $newVersion" }
|
||||
require(event.version.value == newVersion) { "Event version ${event.version} does not match expected new version $newVersion" }
|
||||
|
||||
val streamKey = getStreamKey(streamId)
|
||||
val allEventsStreamKey = getAllEventsStreamKey()
|
||||
@@ -102,7 +104,7 @@ class RedisEventStore(
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
return events.filter { it.version >= fromVersion && (toVersion == null || it.version <= toVersion) }
|
||||
return events.filter { it.version >= EventVersion(fromVersion) && (toVersion == null || it.version <= EventVersion(toVersion)) }
|
||||
}
|
||||
|
||||
override fun getStreamVersion(streamId: Uuid): Long {
|
||||
|
||||
+19
-18
@@ -2,6 +2,7 @@ package at.mocode.infrastructure.eventstore.redis
|
||||
|
||||
import at.mocode.core.domain.event.BaseDomainEvent
|
||||
import at.mocode.core.domain.event.DomainEvent
|
||||
import at.mocode.core.domain.model.*
|
||||
import at.mocode.infrastructure.eventstore.api.EventSerializer
|
||||
import at.mocode.infrastructure.eventstore.api.EventStore
|
||||
import com.benasher44.uuid.Uuid
|
||||
@@ -85,8 +86,8 @@ class RedisEventStoreIntegrationTest {
|
||||
@Test
|
||||
fun `event publishing and consuming with consumer groups should work`() {
|
||||
val aggregateId = uuid4()
|
||||
val event1 = TestCreatedEvent(aggregateId = aggregateId, version = 1L, name = "Test Entity")
|
||||
val event2 = TestUpdatedEvent(aggregateId = aggregateId, version = 2L, name = "Updated Test Entity")
|
||||
val event1 = TestCreatedEvent(aggregateId = AggregateId(aggregateId), version = EventVersion(1L), name = "Test Entity")
|
||||
val event2 = TestUpdatedEvent(aggregateId = AggregateId(aggregateId), version = EventVersion(2L), name = "Updated Test Entity")
|
||||
|
||||
val latch = CountDownLatch(2)
|
||||
val receivedEvents = mutableListOf<DomainEvent>()
|
||||
@@ -112,34 +113,34 @@ class RedisEventStoreIntegrationTest {
|
||||
|
||||
assertEquals(2, receivedEvents.size)
|
||||
|
||||
val receivedEvent1 = receivedEvents.find { it.version == 1L } as TestCreatedEvent
|
||||
assertEquals(aggregateId, receivedEvent1.aggregateId)
|
||||
val receivedEvent1 = receivedEvents.find { it.version == EventVersion(1L) } as TestCreatedEvent
|
||||
assertEquals(AggregateId(aggregateId), receivedEvent1.aggregateId)
|
||||
assertEquals("Test Entity", receivedEvent1.name)
|
||||
|
||||
val receivedEvent2 = receivedEvents.find { it.version == 2L } as TestUpdatedEvent
|
||||
assertEquals(aggregateId, receivedEvent2.aggregateId)
|
||||
val receivedEvent2 = receivedEvents.find { it.version == EventVersion(2L) } as TestUpdatedEvent
|
||||
assertEquals(AggregateId(aggregateId), receivedEvent2.aggregateId)
|
||||
assertEquals("Updated Test Entity", receivedEvent2.name)
|
||||
}
|
||||
|
||||
data class TestCreatedEvent(
|
||||
override val aggregateId: Uuid,
|
||||
override val version: Long,
|
||||
override val aggregateId: AggregateId,
|
||||
override val version: EventVersion,
|
||||
val name: String,
|
||||
override val eventType: String = "TestCreated",
|
||||
override val eventId: Uuid = uuid4(),
|
||||
override val eventType: EventType = EventType("TestCreated"),
|
||||
override val eventId: EventId = EventId(uuid4()),
|
||||
override val timestamp: Instant = Clock.System.now(),
|
||||
override val correlationId: Uuid? = null,
|
||||
override val causationId: Uuid? = null
|
||||
override val correlationId: CorrelationId? = null,
|
||||
override val causationId: CausationId? = null
|
||||
) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId)
|
||||
|
||||
data class TestUpdatedEvent(
|
||||
override val aggregateId: Uuid,
|
||||
override val version: Long,
|
||||
override val aggregateId: AggregateId,
|
||||
override val version: EventVersion,
|
||||
val name: String,
|
||||
override val eventType: String = "TestUpdated",
|
||||
override val eventId: Uuid = uuid4(),
|
||||
override val eventType: EventType = EventType("TestUpdated"),
|
||||
override val eventId: EventId = EventId(uuid4()),
|
||||
override val timestamp: Instant = Clock.System.now(),
|
||||
override val correlationId: Uuid? = null,
|
||||
override val causationId: Uuid? = null
|
||||
override val correlationId: CorrelationId? = null,
|
||||
override val causationId: CausationId? = null
|
||||
) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId)
|
||||
}
|
||||
|
||||
+13
-12
@@ -1,6 +1,7 @@
|
||||
package at.mocode.infrastructure.eventstore.redis
|
||||
|
||||
import at.mocode.core.domain.event.BaseDomainEvent
|
||||
import at.mocode.core.domain.model.*
|
||||
import at.mocode.infrastructure.eventstore.api.ConcurrencyException
|
||||
import at.mocode.infrastructure.eventstore.api.EventSerializer
|
||||
import com.benasher44.uuid.Uuid
|
||||
@@ -68,8 +69,8 @@ class RedisEventStoreTest {
|
||||
@Test
|
||||
fun `append and read events should work correctly for new stream`() {
|
||||
val aggregateId = uuid4()
|
||||
val event1 = TestCreatedEvent(aggregateId, 1L, "Test Entity")
|
||||
val event2 = TestUpdatedEvent(aggregateId, 2L, "Updated Test Entity")
|
||||
val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
|
||||
val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity")
|
||||
|
||||
eventStore.appendToStream(listOf(event1, event2), aggregateId, 0)
|
||||
|
||||
@@ -77,21 +78,21 @@ class RedisEventStoreTest {
|
||||
assertEquals(2, events.size)
|
||||
|
||||
val firstEvent = events[0] as TestCreatedEvent
|
||||
assertEquals(1L, firstEvent.version)
|
||||
assertEquals(EventVersion(1L), firstEvent.version)
|
||||
assertEquals("Test Entity", firstEvent.name)
|
||||
|
||||
val secondEvent = events[1] as TestUpdatedEvent
|
||||
assertEquals(2L, secondEvent.version)
|
||||
assertEquals(EventVersion(2L), secondEvent.version)
|
||||
assertEquals("Updated Test Entity", secondEvent.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `appending with wrong expected version should throw ConcurrencyException`() {
|
||||
val aggregateId = uuid4()
|
||||
val event1 = TestCreatedEvent(aggregateId, 1L, "Test Entity")
|
||||
val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
|
||||
eventStore.appendToStream(listOf(event1), aggregateId, 0) // Stream is now at version 1
|
||||
|
||||
val event2 = TestUpdatedEvent(aggregateId, 2L, "Updated Test Entity")
|
||||
val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity")
|
||||
assertThrows<ConcurrencyException> {
|
||||
eventStore.appendToStream(listOf(event2), aggregateId, 0)
|
||||
}
|
||||
@@ -99,15 +100,15 @@ class RedisEventStoreTest {
|
||||
|
||||
@Serializable
|
||||
data class TestCreatedEvent(
|
||||
@Transient override val aggregateId: Uuid = uuid4(),
|
||||
@Transient override val version: Long = 0,
|
||||
@Transient override val aggregateId: AggregateId = AggregateId(uuid4()),
|
||||
@Transient override val version: EventVersion = EventVersion(0),
|
||||
val name: String
|
||||
) : BaseDomainEvent(aggregateId, "TestCreated", version)
|
||||
) : BaseDomainEvent(aggregateId, EventType("TestCreated"), version)
|
||||
|
||||
@Serializable
|
||||
data class TestUpdatedEvent(
|
||||
@Transient override val aggregateId: Uuid = uuid4(),
|
||||
@Transient override val version: Long = 0,
|
||||
@Transient override val aggregateId: AggregateId = AggregateId(uuid4()),
|
||||
@Transient override val version: EventVersion = EventVersion(0),
|
||||
val name: String
|
||||
) : BaseDomainEvent(aggregateId, "TestUpdated", version)
|
||||
) : BaseDomainEvent(aggregateId, EventType("TestUpdated"), version)
|
||||
}
|
||||
|
||||
+13
-12
@@ -2,6 +2,7 @@ package at.mocode.infrastructure.eventstore.redis
|
||||
|
||||
import at.mocode.core.domain.event.BaseDomainEvent
|
||||
import at.mocode.core.domain.event.DomainEvent
|
||||
import at.mocode.core.domain.model.*
|
||||
import at.mocode.infrastructure.eventstore.api.EventSerializer
|
||||
import at.mocode.infrastructure.eventstore.api.EventStore
|
||||
import com.benasher44.uuid.Uuid
|
||||
@@ -78,8 +79,8 @@ class RedisIntegrationTest {
|
||||
@Test
|
||||
fun `event publishing and consuming should be fast and reliable`() {
|
||||
val aggregateId = uuid4()
|
||||
val event1 = TestCreatedEvent(aggregateId, 1L, "Test Entity")
|
||||
val event2 = TestUpdatedEvent(aggregateId, 2L, "Updated Test Entity")
|
||||
val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
|
||||
val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity")
|
||||
|
||||
val receivedEvents = mutableListOf<DomainEvent>()
|
||||
eventConsumer.registerEventHandler("TestCreated") { receivedEvents.add(it) }
|
||||
@@ -91,26 +92,26 @@ class RedisIntegrationTest {
|
||||
|
||||
assertEquals(2, receivedEvents.size)
|
||||
|
||||
val receivedEvent1 = receivedEvents.find { it.version == 1L } as TestCreatedEvent
|
||||
assertEquals(aggregateId, receivedEvent1.aggregateId)
|
||||
val receivedEvent1 = receivedEvents.find { it.version == EventVersion(1L) } as TestCreatedEvent
|
||||
assertEquals(AggregateId(aggregateId), receivedEvent1.aggregateId)
|
||||
assertEquals("Test Entity", receivedEvent1.name)
|
||||
|
||||
val receivedEvent2 = receivedEvents.find { it.version == 2L } as TestUpdatedEvent
|
||||
assertEquals(aggregateId, receivedEvent2.aggregateId)
|
||||
val receivedEvent2 = receivedEvents.find { it.version == EventVersion(2L) } as TestUpdatedEvent
|
||||
assertEquals(AggregateId(aggregateId), receivedEvent2.aggregateId)
|
||||
assertEquals("Updated Test Entity", receivedEvent2.name)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TestCreatedEvent(
|
||||
@Transient override val aggregateId: Uuid = uuid4(),
|
||||
@Transient override val version: Long = 0,
|
||||
@Transient override val aggregateId: AggregateId = AggregateId(uuid4()),
|
||||
@Transient override val version: EventVersion = EventVersion(0),
|
||||
val name: String
|
||||
) : BaseDomainEvent(aggregateId, "TestCreated", version)
|
||||
) : BaseDomainEvent(aggregateId, EventType("TestCreated"), version)
|
||||
|
||||
@Serializable
|
||||
data class TestUpdatedEvent(
|
||||
@Transient override val aggregateId: Uuid = uuid4(),
|
||||
@Transient override val version: Long = 0,
|
||||
@Transient override val aggregateId: AggregateId = AggregateId(uuid4()),
|
||||
@Transient override val version: EventVersion = EventVersion(0),
|
||||
val name: String
|
||||
) : BaseDomainEvent(aggregateId, "TestUpdated", version)
|
||||
) : BaseDomainEvent(aggregateId, EventType("TestUpdated"), version)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM openjdk:17-jre-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the gateway JAR file
|
||||
COPY infrastructure/gateway/build/libs/*.jar app.jar
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Add health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/actuator/health || exit 1
|
||||
|
||||
# Run the application
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
@@ -6,7 +6,18 @@ server:
|
||||
spring:
|
||||
application:
|
||||
name: api-gateway
|
||||
security:
|
||||
user:
|
||||
name: admin
|
||||
password: admin
|
||||
cloud:
|
||||
consul:
|
||||
host: localhost
|
||||
port: 8500
|
||||
discovery:
|
||||
register: true
|
||||
health-check-path: /actuator/health
|
||||
health-check-interval: 10s
|
||||
gateway:
|
||||
# HTTP Client-Timeouts für stabile Upstream-Verbindungen
|
||||
httpclient:
|
||||
@@ -22,9 +33,17 @@ spring:
|
||||
# Antwort-Header bereinigen (verhindert doppelte CORS-Header)
|
||||
default-filters:
|
||||
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
|
||||
# Aktiviert die automatische Routen-Erstellung basierend auf Consul
|
||||
discovery:
|
||||
locator:
|
||||
enabled: true
|
||||
# Macht Routen-Namen klein (z.B. /members-service/** statt /MEMBERS-SERVICE/**)
|
||||
lower-case-service-id: true
|
||||
# Route definitions with service discovery
|
||||
routes:
|
||||
- id: ping-service-route
|
||||
uri: lb://ping-service
|
||||
predicates:
|
||||
- Path=/api/ping/**
|
||||
filters:
|
||||
- StripPrefix=1
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
|
||||
+3
-1
@@ -71,7 +71,9 @@ class GatewayApplicationTests {
|
||||
class TestRoutes {
|
||||
@Bean
|
||||
fun routeLocator(builder: RouteLocatorBuilder): RouteLocator = builder.routes()
|
||||
.route("test-forward") { r -> r.path("/hello").uri("forward:/internal/hello") }
|
||||
.route("test-forward") {
|
||||
it.path("/hello").uri("forward:/internal/hello")
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
+89
-21
@@ -1,5 +1,6 @@
|
||||
package at.mocode.infrastructure.messaging.client
|
||||
|
||||
import at.mocode.infrastructure.messaging.config.KafkaConfig
|
||||
import org.apache.kafka.clients.consumer.ConsumerConfig
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.kafka.support.serializer.JsonDeserializer
|
||||
@@ -7,42 +8,109 @@ import org.springframework.stereotype.Component
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.kafka.receiver.KafkaReceiver
|
||||
import reactor.kafka.receiver.ReceiverOptions
|
||||
import reactor.util.retry.Retry
|
||||
import java.time.Duration
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* A reactive, non-blocking Kafka implementation of the EventConsumer interface.
|
||||
* A reactive, non-blocking Kafka implementation of the EventConsumer interface
|
||||
* with optimized connection pooling, security, and error handling.
|
||||
*/
|
||||
@Component
|
||||
class KafkaEventConsumer(
|
||||
// Wir injizieren die Basis-Konfigurationseigenschaften aus messaging-config
|
||||
private val consumerConfig: Map<String, Any>
|
||||
private val kafkaConfig: KafkaConfig
|
||||
) : EventConsumer {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(KafkaEventConsumer::class.java)
|
||||
|
||||
override fun <T : Any> receiveEvents(topic: String, eventType: Class<T>): Flux<T> {
|
||||
// Für jeden Aufruf wird eine neue, spezifische Konfiguration für diesen Topic erstellt.
|
||||
val receiverOptions = ReceiverOptions.create<String, T>(consumerConfig)
|
||||
.subscription(Collections.singleton(topic))
|
||||
.withValueDeserializer(JsonDeserializer(eventType).trustedPackages("*"))
|
||||
.addAssignListener { partitions ->
|
||||
logger.info("Partitions assigned for topic '{}': {}", topic, partitions)
|
||||
}
|
||||
.addRevokeListener { partitions ->
|
||||
logger.warn("Partitions revoked for topic '{}': {}", topic, partitions)
|
||||
}
|
||||
// Connection pool to reuse KafkaReceiver instances per topic-eventType combination
|
||||
private val receiverCache = ConcurrentHashMap<String, KafkaReceiver<String, Any>>()
|
||||
|
||||
return KafkaReceiver.create(receiverOptions)
|
||||
.receive()
|
||||
override fun <T : Any> receiveEvents(topic: String, eventType: Class<T>): Flux<T> {
|
||||
logger.info("Setting up reactive consumer for topic '{}' with event type '{}'", topic, eventType.simpleName)
|
||||
|
||||
val cacheKey = "${topic}-${eventType.name}"
|
||||
|
||||
// Get or create a cached receiver for this topic-eventType combination
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val receiver = receiverCache.computeIfAbsent(cacheKey) {
|
||||
createOptimizedReceiver<T>(topic, eventType) as KafkaReceiver<String, Any>
|
||||
} as KafkaReceiver<String, T>
|
||||
|
||||
return receiver.receive()
|
||||
.doOnNext { record ->
|
||||
logger.debug(
|
||||
"Received message from topic-partition {}-{} with offset {}",
|
||||
record.topic(), record.partition(), record.offset()
|
||||
"Received message from topic-partition {}-{} with offset {} for event type '{}'",
|
||||
record.topic(), record.partition(), record.offset(), eventType.simpleName
|
||||
)
|
||||
}
|
||||
.map { it.value() } // Extrahiere nur die deserialisierte Nachricht
|
||||
.doOnError { exception ->
|
||||
logger.error("Error receiving events from topic '{}'", topic, exception)
|
||||
.map { record ->
|
||||
// Manual commit acknowledgment for better control
|
||||
record.receiverOffset().acknowledge()
|
||||
record.value()
|
||||
}
|
||||
.doOnError { exception ->
|
||||
logger.error("Error receiving events from topic '{}' for event type '{}'",
|
||||
topic, eventType.simpleName, exception)
|
||||
}
|
||||
.retryWhen(
|
||||
Retry.backoff(3, Duration.ofSeconds(1))
|
||||
.maxBackoff(Duration.ofSeconds(10))
|
||||
.doBeforeRetry { retrySignal ->
|
||||
logger.warn("Retrying consumer for topic '{}', attempt: {}, error: {}",
|
||||
topic, retrySignal.totalRetries() + 1, retrySignal.failure().message)
|
||||
}
|
||||
.onRetryExhaustedThrow { _, retrySignal ->
|
||||
logger.error("Consumer retry exhausted for topic '{}' after {} attempts",
|
||||
topic, retrySignal.totalRetries())
|
||||
retrySignal.failure()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an optimized KafkaReceiver with secure configuration and performance tuning.
|
||||
*/
|
||||
private fun <T : Any> createOptimizedReceiver(topic: String, eventType: Class<T>): KafkaReceiver<String, T> {
|
||||
// Generate unique group ID for this consumer instance
|
||||
val groupId = "${kafkaConfig.defaultGroupIdPrefix}-${topic}-${eventType.simpleName.lowercase()}"
|
||||
val consumerConfig = kafkaConfig.consumerConfigs(groupId)
|
||||
|
||||
// Create type-safe JSON deserializer with restricted trusted packages
|
||||
val jsonDeserializer = JsonDeserializer(eventType).apply {
|
||||
// Use restricted trusted packages instead of wildcard for security
|
||||
addTrustedPackages(kafkaConfig.trustedPackages)
|
||||
setUseTypeHeaders(false)
|
||||
}
|
||||
|
||||
val receiverOptions = ReceiverOptions.create<String, T>(consumerConfig)
|
||||
.subscription(Collections.singleton(topic))
|
||||
.withValueDeserializer(jsonDeserializer)
|
||||
.addAssignListener { partitions ->
|
||||
logger.info("Consumer '{}' assigned partitions for topic '{}': {}",
|
||||
groupId, topic, partitions.map { "${it.topicPartition().topic()}-${it.topicPartition().partition()}" })
|
||||
}
|
||||
.addRevokeListener { partitions ->
|
||||
logger.warn("Consumer '{}' revoked partitions for topic '{}': {}",
|
||||
groupId, topic, partitions.map { "${it.topicPartition().topic()}-${it.topicPartition().partition()}" })
|
||||
}
|
||||
// Enable commit interval for manual acknowledgment control
|
||||
.commitInterval(Duration.ofSeconds(5))
|
||||
.commitBatchSize(100)
|
||||
|
||||
return KafkaReceiver.create(receiverOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup method to clear cached receivers on application shutdown.
|
||||
* Reactive receivers will be automatically cleaned up when their streams complete.
|
||||
*/
|
||||
@jakarta.annotation.PreDestroy
|
||||
fun cleanup() {
|
||||
logger.info("Cleaning up Kafka consumer cache...")
|
||||
val cacheSize = receiverCache.size
|
||||
receiverCache.clear()
|
||||
logger.info("Kafka consumer cleanup completed. Cleared {} cached receivers", cacheSize)
|
||||
}
|
||||
}
|
||||
|
||||
+92
-13
@@ -5,42 +5,121 @@ import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate
|
||||
import org.springframework.stereotype.Component
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
import reactor.util.retry.Retry
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* A reactive, non-blocking Kafka implementation of EventPublisher.
|
||||
* A reactive, non-blocking Kafka implementation of EventPublisher with enhanced
|
||||
* error handling, retry mechanisms, and optimized batch processing.
|
||||
*/
|
||||
@Component
|
||||
class KafkaEventPublisher(
|
||||
// KORREKTUR: Verwendung des reaktiven Templates
|
||||
private val reactiveKafkaTemplate: ReactiveKafkaProducerTemplate<String, Any>
|
||||
) : EventPublisher {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(KafkaEventPublisher::class.java)
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_RETRY_ATTEMPTS = 3L
|
||||
private const val DEFAULT_RETRY_DELAY_SECONDS = 1L
|
||||
private const val DEFAULT_MAX_BACKOFF_SECONDS = 10L
|
||||
private const val DEFAULT_BATCH_CONCURRENCY = 10
|
||||
}
|
||||
|
||||
override fun publishEvent(topic: String, key: String?, event: Any): Mono<Void> {
|
||||
logger.debug("Publishing event to topic '{}' with key '{}'", topic, key)
|
||||
logger.debug("Publishing event to topic '{}' with key '{}', event type: '{}'",
|
||||
topic, key, event::class.simpleName)
|
||||
|
||||
return reactiveKafkaTemplate.send(topic, key, event)
|
||||
.doOnSuccess { result ->
|
||||
val record = result.recordMetadata()
|
||||
logger.info(
|
||||
"Successfully published event to topic-partition {}-{} with offset {}",
|
||||
record.topic(), record.partition(), record.offset()
|
||||
logger.debug(
|
||||
"Successfully published event to topic-partition {}-{} with offset {} (key: '{}')",
|
||||
record.topic(), record.partition(), record.offset(), key
|
||||
)
|
||||
}
|
||||
.doOnError { exception ->
|
||||
logger.error("Failed to publish event to topic '{}' with key '{}'", topic, key, exception)
|
||||
logger.warn("Failed to publish event to topic '{}' with key '{}' - will retry if configured",
|
||||
topic, key, exception)
|
||||
}
|
||||
.then() // Wandelt das Ergebnis in ein Mono<Void> um
|
||||
.retryWhen(createRetrySpec(topic, key))
|
||||
.doOnError { exception ->
|
||||
logger.error("Final failure after retries: Failed to publish event to topic '{}' with key '{}'",
|
||||
topic, key, exception)
|
||||
}
|
||||
.then()
|
||||
}
|
||||
|
||||
override fun publishEvents(topic: String, events: List<Pair<String?, Any>>): Flux<Void> {
|
||||
logger.debug("Publishing {} events to topic '{}'", events.size, topic)
|
||||
// Verwendet Flux.fromIterable, um eine Sequenz von Sende-Operationen zu erstellen
|
||||
if (events.isEmpty()) {
|
||||
logger.debug("No events to publish to topic '{}'", topic)
|
||||
return Flux.empty()
|
||||
}
|
||||
|
||||
logger.info("Publishing {} events to topic '{}' using optimized batch processing", events.size, topic)
|
||||
|
||||
return Flux.fromIterable(events)
|
||||
// .flatMap stellt sicher, dass die Sende-Operationen parallelisiert,
|
||||
// aber dennoch reaktiv (nicht-blockierend) ausgeführt werden.
|
||||
.flatMap { (key, event) ->
|
||||
.index() // Add index for progress tracking
|
||||
.flatMap({ indexedEventPair ->
|
||||
val index = indexedEventPair.t1
|
||||
val eventPair = indexedEventPair.t2
|
||||
val (key, event) = eventPair
|
||||
publishEvent(topic, key, event)
|
||||
.doOnSuccess {
|
||||
if ((index + 1) % 100 == 0L || index == events.size.toLong() - 1) {
|
||||
logger.info("Batch progress: {}/{} events published to topic '{}'",
|
||||
index + 1, events.size, topic)
|
||||
}
|
||||
}
|
||||
.onErrorContinue { error, _ ->
|
||||
logger.error("Error publishing event {} in batch to topic '{}': {}",
|
||||
index + 1, topic, error.message)
|
||||
}
|
||||
}, DEFAULT_BATCH_CONCURRENCY) // Controlled concurrency for better resource management
|
||||
.doOnComplete {
|
||||
logger.info("Completed publishing batch of {} events to topic '{}'", events.size, topic)
|
||||
}
|
||||
.doOnError { error ->
|
||||
logger.error("Batch publishing to topic '{}' failed with error: {}", topic, error.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a retry specification with exponential backoff for robust error handling.
|
||||
*/
|
||||
private fun createRetrySpec(topic: String, key: String?): Retry =
|
||||
Retry.backoff(DEFAULT_RETRY_ATTEMPTS, Duration.ofSeconds(DEFAULT_RETRY_DELAY_SECONDS))
|
||||
.maxBackoff(Duration.ofSeconds(DEFAULT_MAX_BACKOFF_SECONDS))
|
||||
.filter { exception ->
|
||||
// Only retry on transient errors (not serialization errors, etc.)
|
||||
isRetryableException(exception)
|
||||
}
|
||||
.doBeforeRetry { retrySignal ->
|
||||
logger.info("Retrying publish to topic '{}' with key '{}', attempt: {}, error: {}",
|
||||
topic, key, retrySignal.totalRetries() + 1,
|
||||
retrySignal.failure().message?.take(100))
|
||||
}
|
||||
.onRetryExhaustedThrow { _, retrySignal ->
|
||||
logger.error("Retry exhausted for topic '{}' with key '{}' after {} attempts",
|
||||
topic, key, retrySignal.totalRetries())
|
||||
retrySignal.failure()
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an exception is retryable based on its type and characteristics.
|
||||
*/
|
||||
private fun isRetryableException(exception: Throwable): Boolean {
|
||||
return when {
|
||||
exception.message?.contains("timeout", ignoreCase = true) == true -> true
|
||||
exception.message?.contains("connection", ignoreCase = true) == true -> true
|
||||
exception.message?.contains("network", ignoreCase = true) == true -> true
|
||||
exception is java.util.concurrent.TimeoutException -> true
|
||||
exception is java.net.ConnectException -> true
|
||||
exception is java.io.IOException -> true
|
||||
// Don't retry serialization errors or authentication failures
|
||||
exception.message?.contains("serializ", ignoreCase = true) == true -> false
|
||||
exception.message?.contains("auth", ignoreCase = true) == true -> false
|
||||
else -> true // Default to retryable for unknown exceptions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-8
@@ -1,22 +1,57 @@
|
||||
package at.mocode.infrastructure.messaging.client
|
||||
|
||||
import at.mocode.infrastructure.messaging.config.KafkaConfig
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.kafka.core.DefaultKafkaProducerFactory
|
||||
import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate
|
||||
import reactor.kafka.sender.SenderOptions
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* Reactive Kafka configuration utilities for creating a ReactiveKafkaProducerTemplate.
|
||||
* Spring Configuration for reactive Kafka components with optimized settings.
|
||||
*/
|
||||
class ReactiveKafkaConfig {
|
||||
@Configuration
|
||||
class ReactiveKafkaConfig(
|
||||
private val kafkaConfig: KafkaConfig
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(ReactiveKafkaConfig::class.java)
|
||||
|
||||
/**
|
||||
* Create a ReactiveKafkaProducerTemplate using the configuration from the given ProducerFactory.
|
||||
* Creates a Spring Bean for the optimized ReactiveKafkaProducerTemplate.
|
||||
* This template includes enhanced error handling, monitoring, and performance tuning.
|
||||
*/
|
||||
fun reactiveKafkaProducerTemplate(
|
||||
producerFactory: DefaultKafkaProducerFactory<String, Any>
|
||||
): ReactiveKafkaProducerTemplate<String, Any> {
|
||||
@Bean
|
||||
fun reactiveKafkaProducerTemplate(): ReactiveKafkaProducerTemplate<String, Any> {
|
||||
logger.info("Creating optimized ReactiveKafkaProducerTemplate with enhanced configuration")
|
||||
|
||||
val producerFactory = kafkaConfig.producerFactory()
|
||||
val props: Map<String, Any> = producerFactory.configurationProperties
|
||||
val senderOptions: SenderOptions<String, Any> = SenderOptions.create(props)
|
||||
return ReactiveKafkaProducerTemplate(senderOptions)
|
||||
|
||||
val senderOptions = SenderOptions.create<String, Any>(props)
|
||||
// Enhanced sender options for better performance and reliability
|
||||
.maxInFlight(1024) // Increase in-flight requests for better throughput
|
||||
.scheduler(reactor.core.scheduler.Schedulers.boundedElastic()) // Use bounded elastic scheduler
|
||||
.closeTimeout(Duration.ofSeconds(30)) // Give enough time for graceful shutdown
|
||||
.stopOnError(false) // Continue processing even if some messages fail
|
||||
|
||||
return ReactiveKafkaProducerTemplate(senderOptions).apply {
|
||||
// Configure additional properties if needed
|
||||
logger.info("ReactiveKafkaProducerTemplate configured successfully with bootstrap servers: {}",
|
||||
kafkaConfig.bootstrapServers)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a KafkaConfig bean if not already provided.
|
||||
* This allows for external configuration override while providing sensible defaults.
|
||||
*/
|
||||
@Bean
|
||||
fun kafkaConfig(): KafkaConfig {
|
||||
return KafkaConfig().apply {
|
||||
logger.info("Initializing KafkaConfig with bootstrap servers: {}", bootstrapServers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-13
@@ -38,8 +38,8 @@ class KafkaIntegrationTest {
|
||||
}
|
||||
producerFactory = kafkaConfig.producerFactory()
|
||||
|
||||
val reactiveKafkaConfig = ReactiveKafkaConfig()
|
||||
val reactiveTemplate = reactiveKafkaConfig.reactiveKafkaProducerTemplate(producerFactory)
|
||||
val reactiveKafkaConfig = ReactiveKafkaConfig(kafkaConfig)
|
||||
val reactiveTemplate = reactiveKafkaConfig.reactiveKafkaProducerTemplate()
|
||||
kafkaEventPublisher = KafkaEventPublisher(reactiveTemplate)
|
||||
}
|
||||
|
||||
@@ -54,19 +54,18 @@ class KafkaIntegrationTest {
|
||||
val testKey = "test-key"
|
||||
val testEvent = TestEvent("Test Message")
|
||||
|
||||
val consumerProps = mapOf(
|
||||
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to kafkaContainer.bootstrapServers,
|
||||
ConsumerConfig.GROUP_ID_CONFIG to "test-group-${UUID.randomUUID()}",
|
||||
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
|
||||
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to JsonDeserializer::class.java,
|
||||
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest",
|
||||
JsonDeserializer.TRUSTED_PACKAGES to "*",
|
||||
JsonDeserializer.USE_TYPE_INFO_HEADERS to false,
|
||||
JsonDeserializer.VALUE_DEFAULT_TYPE to TestEvent::class.java.name
|
||||
)
|
||||
// Use the same KafkaConfig for consistent and secure configuration
|
||||
val testKafkaConfig = KafkaConfig().apply {
|
||||
bootstrapServers = kafkaContainer.bootstrapServers
|
||||
// For tests, we need to trust the test package
|
||||
trustedPackages = "at.mocode.*"
|
||||
}
|
||||
|
||||
val consumerProps = testKafkaConfig.consumerConfigs("test-group-${UUID.randomUUID()}")
|
||||
|
||||
val jsonValueDeserializer = JsonDeserializer(TestEvent::class.java).apply {
|
||||
addTrustedPackages("*")
|
||||
addTrustedPackages(testKafkaConfig.trustedPackages)
|
||||
setUseTypeHeaders(false)
|
||||
}
|
||||
val receiverOptions = ReceiverOptions.create<String, TestEvent>(consumerProps)
|
||||
.withKeyDeserializer(StringDeserializer())
|
||||
|
||||
+65
-3
@@ -1,13 +1,16 @@
|
||||
package at.mocode.infrastructure.messaging.config
|
||||
|
||||
import org.apache.kafka.clients.consumer.ConsumerConfig
|
||||
import org.apache.kafka.clients.producer.ProducerConfig
|
||||
import org.apache.kafka.common.serialization.StringDeserializer
|
||||
import org.apache.kafka.common.serialization.StringSerializer
|
||||
import org.springframework.kafka.core.DefaultKafkaProducerFactory
|
||||
import org.springframework.kafka.core.ProducerFactory
|
||||
import org.springframework.kafka.support.serializer.JsonDeserializer
|
||||
import org.springframework.kafka.support.serializer.JsonSerializer
|
||||
|
||||
/**
|
||||
* Central Kafka producer configuration used across modules.
|
||||
* Central Kafka configuration used across modules with optimized settings for performance and reliability.
|
||||
*
|
||||
* This class can be instantiated programmatically (as done in tests) or
|
||||
* registered as a Spring @Configuration with @Bean methods in an application context.
|
||||
@@ -20,14 +23,73 @@ class KafkaConfig {
|
||||
var bootstrapServers: String = "localhost:9092"
|
||||
|
||||
/**
|
||||
* Common producer properties with sensible defaults (String keys, JSON values).
|
||||
* Default consumer group ID prefix.
|
||||
*/
|
||||
var defaultGroupIdPrefix: String = "messaging-client"
|
||||
|
||||
/**
|
||||
* Comma-separated list of trusted packages for JSON deserialization security.
|
||||
* Default restricts to application packages only.
|
||||
*/
|
||||
var trustedPackages: String = "at.mocode.*"
|
||||
|
||||
/**
|
||||
* Optimized producer properties with performance tuning and reliability settings.
|
||||
*/
|
||||
fun producerConfigs(): Map<String, Any> = mapOf(
|
||||
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
|
||||
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
|
||||
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java,
|
||||
// Avoid adding type info headers; keeps payloads simple and interoperable.
|
||||
JsonSerializer.ADD_TYPE_INFO_HEADERS to false
|
||||
JsonSerializer.ADD_TYPE_INFO_HEADERS to false,
|
||||
|
||||
// Performance optimizations
|
||||
ProducerConfig.BATCH_SIZE_CONFIG to 32768, // 32KB batch size for better throughput
|
||||
ProducerConfig.LINGER_MS_CONFIG to 5, // Wait up to 5ms to batch messages
|
||||
ProducerConfig.COMPRESSION_TYPE_CONFIG to "snappy", // Fast compression
|
||||
ProducerConfig.BUFFER_MEMORY_CONFIG to 67108864, // 64MB buffer memory
|
||||
|
||||
// Reliability settings
|
||||
ProducerConfig.ACKS_CONFIG to "all", // Wait for all replicas
|
||||
ProducerConfig.RETRIES_CONFIG to 3, // Retry failed sends
|
||||
ProducerConfig.RETRY_BACKOFF_MS_CONFIG to 1000, // 1 second retry backoff
|
||||
ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG to 30000, // 30 second delivery timeout
|
||||
ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG to 10000, // 10 second request timeout
|
||||
|
||||
// Idempotence for exactly-once semantics
|
||||
ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to true,
|
||||
ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION to 5
|
||||
)
|
||||
|
||||
/**
|
||||
* Optimized consumer properties with performance tuning and reliability settings.
|
||||
*/
|
||||
fun consumerConfigs(groupId: String? = null): Map<String, Any> = mapOf(
|
||||
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
|
||||
ConsumerConfig.GROUP_ID_CONFIG to (groupId ?: "${defaultGroupIdPrefix}-${System.currentTimeMillis()}"),
|
||||
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
|
||||
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to JsonDeserializer::class.java,
|
||||
|
||||
// JSON deserialization security
|
||||
JsonDeserializer.TRUSTED_PACKAGES to trustedPackages,
|
||||
JsonDeserializer.USE_TYPE_INFO_HEADERS to false,
|
||||
|
||||
// Performance optimizations
|
||||
ConsumerConfig.FETCH_MIN_BYTES_CONFIG to 1024, // 1KB minimum fetch size
|
||||
ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG to 500, // Max 500ms wait for fetch
|
||||
ConsumerConfig.MAX_PARTITION_FETCH_BYTES_CONFIG to 1048576, // 1MB max partition fetch
|
||||
ConsumerConfig.MAX_POLL_RECORDS_CONFIG to 500, // Process up to 500 records per poll
|
||||
|
||||
// Reliability settings
|
||||
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest",
|
||||
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false, // Manual commit for better control
|
||||
ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to 30000, // 30 second session timeout
|
||||
ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to 3000, // 3 second heartbeat
|
||||
|
||||
// Connection settings
|
||||
ConsumerConfig.CONNECTIONS_MAX_IDLE_MS_CONFIG to 540000, // 9 minutes idle timeout
|
||||
ConsumerConfig.RECONNECT_BACKOFF_MS_CONFIG to 50,
|
||||
ConsumerConfig.RECONNECT_BACKOFF_MAX_MS_CONFIG to 1000
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,14 @@ include(":infrastructure:event-store:redis-event-store")
|
||||
include(":infrastructure:monitoring:monitoring-client")
|
||||
include(":infrastructure:monitoring:monitoring-server")
|
||||
|
||||
// Temporary modules
|
||||
include(":temp:ping-service")
|
||||
|
||||
// Client modules
|
||||
include(":client:common-ui")
|
||||
include(":client:web-app")
|
||||
include(":client:desktop-app")
|
||||
|
||||
/*
|
||||
// Temporär deaktivierte Fach-Module
|
||||
// Members modules
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Temp / Ping-Service
|
||||
|
||||
## ⚠️ Wichtiger Hinweis
|
||||
|
||||
Dieses Modul (`:temp:ping-service`) ist ein **temporärer Service** ausschließlich für Testzwecke. Seine einzige Aufgabe ist die Validierung der technischen Infrastruktur im Rahmen des **"Tracer Bullet"-Szenarios**.
|
||||
|
||||
Nachdem der End-to-End-Test erfolgreich war, sollte dieses Modul in der `settings.gradle.kts` wieder deaktiviert oder vollständig entfernt werden.
|
||||
|
||||
## 1. Überblick
|
||||
|
||||
Der `ping-service` ist ein minimaler Spring Boot Microservice, der beweisen soll, dass die grundlegende Service-Architektur funktioniert. Dies beinhaltet:
|
||||
* Korrekte Konfiguration und Start einer Spring Boot Anwendung.
|
||||
* Bereitstellung eines einfachen REST-Endpunkts.
|
||||
* Einbindung in die Gradle-Build-Logik.
|
||||
* Integration in das Test-Framework.
|
||||
|
||||
## 2. Funktionalität
|
||||
|
||||
Der Service stellt einen einzigen HTTP-Endpunkt zur Verfügung:
|
||||
|
||||
* **`GET /ping`**
|
||||
* **Antwort:** Gibt ein einfaches JSON-Objekt zurück, das den erfolgreichen Aufruf bestätigt.
|
||||
* **Beispiel-Antwort-Body:**
|
||||
```json
|
||||
{
|
||||
"status": "pong"
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Konfiguration
|
||||
|
||||
Die Konfiguration des Services erfolgt über die `application.yml`-Datei.
|
||||
|
||||
* **`spring.application.name`**: `ping-service`
|
||||
* **`server.port`**: `8082`
|
||||
|
||||
## 4. Wie man den Service startet
|
||||
|
||||
Um den Service lokal zu starten, führen Sie den folgenden Gradle-Befehl aus:
|
||||
|
||||
```bash
|
||||
./gradlew :temp:ping-service:bootRun
|
||||
```
|
||||
|
||||
## 5. Wie man den Service testet
|
||||
|
||||
Nach dem Start können Sie die Funktionalität mit einem einfachen curl-Befehl überprüfen:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8082/ping
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM openjdk:17-jre-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the ping-service JAR file
|
||||
COPY temp/ping-service/build/libs/*.jar app.jar
|
||||
|
||||
# Expose port (will be assigned dynamically by Spring Boot)
|
||||
EXPOSE 8080
|
||||
|
||||
# Add health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/ping || exit 1
|
||||
|
||||
# Run the application
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
@@ -0,0 +1,33 @@
|
||||
// Simple Spring Boot ping service for testing microservice architecture
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.spring)
|
||||
alias(libs.plugins.spring.boot)
|
||||
alias(libs.plugins.spring.dependencyManagement)
|
||||
}
|
||||
|
||||
// Configure the main class for the executable JAR
|
||||
springBoot {
|
||||
mainClass.set("at.mocode.temp.pingservice.PingServiceApplicationKt")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Ensure all versions come from the central BOM
|
||||
implementation(platform(projects.platform.platformBom))
|
||||
// Provide common dependencies
|
||||
implementation(projects.platform.platformDependencies)
|
||||
|
||||
// Spring Boot Web starter for REST endpoints
|
||||
implementation(libs.spring.boot.starter.web)
|
||||
|
||||
// Spring Boot Actuator for health checks
|
||||
implementation(libs.spring.boot.starter.actuator)
|
||||
|
||||
// Spring Cloud Consul for service discovery
|
||||
implementation(libs.spring.cloud.starter.consul.discovery)
|
||||
|
||||
// Testing dependencies
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
testImplementation(libs.bundles.testing.jvm)
|
||||
testImplementation(libs.spring.boot.starter.test)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package at.mocode.temp.pingservice
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
class PingController {
|
||||
|
||||
@GetMapping("/ping")
|
||||
fun ping(): Map<String, String> {
|
||||
return mapOf("status" to "pong")
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package at.mocode.temp.pingservice
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
|
||||
@SpringBootApplication
|
||||
class PingServiceApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<PingServiceApplication>(*args)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
spring:
|
||||
application:
|
||||
name: ping-service
|
||||
cloud:
|
||||
consul:
|
||||
host: localhost
|
||||
port: 8500
|
||||
discovery:
|
||||
register: true
|
||||
health-check-path: /actuator/health
|
||||
health-check-interval: 10s
|
||||
|
||||
server:
|
||||
port: 8082
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
@@ -0,0 +1,23 @@
|
||||
package at.mocode.temp.pingservice
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
|
||||
|
||||
@WebMvcTest(PingController::class)
|
||||
class PingControllerTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var mockMvc: MockMvc
|
||||
|
||||
@Test
|
||||
fun `ping endpoint should return pong status`() {
|
||||
mockMvc.perform(get("/ping"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().contentType("application/json"))
|
||||
.andExpect(jsonPath("$.status").value("pong"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user