feat(Tracer Bullet)

This commit is contained in:
2025-08-11 23:47:05 +02:00
parent 582678e226
commit a50b1b3822
43 changed files with 1665 additions and 292 deletions
+3
View File
@@ -2,6 +2,9 @@ import java.util.Locale
plugins { plugins {
alias(libs.plugins.kotlin.jvm) apply false 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.boot) apply false
alias(libs.plugins.spring.dependencyManagement) apply false alias(libs.plugins.spring.dependencyManagement) apply false
} }
-1
View File
@@ -57,7 +57,6 @@ kotlin {
val commonTest by getting { val commonTest by getting {
dependencies { dependencies {
implementation(libs.kotlin.test) 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.common.components.masterdata.StammdatenListe
import at.mocode.client.web.screens.CreatePersonScreen import at.mocode.client.web.screens.CreatePersonScreen
import at.mocode.client.web.screens.PersonListScreen 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.DatenQuelleE
import at.mocode.core.domain.model.PferdeGeschlechtE import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.events.domain.model.Veranstaltung import at.mocode.events.domain.model.Veranstaltung
import at.mocode.horses.domain.model.DomPferd import at.mocode.horses.domain.model.DomPferd
import at.mocode.masterdata.domain.model.LandDefinition 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.datetime.LocalDate
import kotlinx.serialization.json.Json
/** /**
* Main application composable for the desktop application. * Main application composable for the desktop application.
@@ -103,6 +110,10 @@ data class TabItem(
*/ */
@Composable @Composable
fun DashboardScreen() { fun DashboardScreen() {
val coroutineScope = rememberCoroutineScope()
var pingResult by remember { mutableStateOf<String?>(null) }
var pingLoading by remember { mutableStateOf(false) }
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@@ -120,6 +131,15 @@ fun DashboardScreen() {
modifier = Modifier.padding(bottom = 32.dp) 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 // Quick access buttons
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -144,6 +164,47 @@ fun DashboardScreen() {
Text("Suche") 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")
}
}
} }
} }
} }
+6
View File
@@ -23,6 +23,12 @@ kotlin {
// Stellt die Web-spezifischen (HTML) Teile von Jetpack Compose bereit. // Stellt die Web-spezifischen (HTML) Teile von Jetpack Compose bereit.
implementation(compose.html.core) 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 { val jsTest by getting {
@@ -1,36 +1,173 @@
package at.mocode.client.web package at.mocode.client.web
import androidx.compose.foundation.layout.Column import androidx.compose.runtime.*
import androidx.compose.foundation.layout.fillMaxSize import org.jetbrains.compose.web.dom.*
import androidx.compose.foundation.layout.padding import org.jetbrains.compose.web.css.*
import androidx.compose.foundation.layout.Arrangement import kotlinx.coroutines.launch
import androidx.compose.material3.ExperimentalMaterial3Api import io.ktor.client.*
import androidx.compose.material3.Scaffold import io.ktor.client.request.*
import androidx.compose.material3.Text import io.ktor.client.statement.*
import androidx.compose.material3.TopAppBar import io.ktor.client.plugins.contentnegotiation.*
import androidx.compose.runtime.Composable import io.ktor.serialization.kotlinx.json.*
import androidx.compose.ui.Modifier import kotlinx.serialization.Serializable
import androidx.compose.ui.unit.dp import kotlinx.serialization.json.Json
import at.mocode.client.common.BaseApp
@Serializable
data class PingResponse(val status: String)
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun App() { fun App() {
BaseApp { var responseStatus by remember { mutableStateOf<String?>(null) }
Scaffold( var isLoading by remember { mutableStateOf(false) }
topBar = { var errorMessage by remember { mutableStateOf<String?>(null) }
TopAppBar( val scope = rememberCoroutineScope()
title = { Text("Meldestelle - Reitersport Management") }
) val httpClient = remember {
HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
} }
) { paddingValues -> }
Column( }
modifier = Modifier.padding(paddingValues).fillMaxSize(),
verticalArrangement = Arrangement.Center Div({
) { style {
// Placeholder content fontFamily("Arial, sans-serif")
Text("Welcome to Meldestelle - Reitersport Management") padding(20.px)
Text("This is a desktop application for managing equestrian events") 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 package at.mocode.client.web
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Window import org.jetbrains.compose.web.renderComposable
import androidx.compose.ui.window.application
fun main() = application { fun main() {
Window( renderComposable(rootElementId = "root") {
title = "Meldestelle - Reitersport Management",
onCloseRequest = ::exitApplication
) {
App() 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>
+42 -18
View File
@@ -1,27 +1,51 @@
// Dieses Modul definiert die Kern-Domänenobjekte des Shared Kernels. // Dieses Modul definiert die Kern-Domänenobjekte des Shared Kernels.
// Es enthält keine Implementierungsdetails, nur reine Datenklassen und Enums. // Es enthält keine Implementierungsdetails, nur reine Datenklassen und Enums.
plugins { plugins {
alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
} }
kotlin { kotlin {
compilerOptions { // Target platforms
freeCompilerArgs.add("-Xopt-in=kotlin.time.ExperimentalTime") jvm {
compilerOptions {
freeCompilerArgs.add("-Xopt-in=kotlin.time.ExperimentalTime")
}
}
js(IR) {
browser()
}
sourceSets {
val commonMain by getting {
dependencies {
// Kern-Abhängigkeiten für das Domänen-Modul (common for all platforms)
api(libs.uuid)
api(libs.kotlinx.serialization.json)
api(libs.kotlinx.datetime)
}
}
val jvmMain by getting {
dependencies {
// Stellt sicher, dass dieses Modul Zugriff auf die im zentralen Katalog
// definierten Bibliotheken hat (JVM-specific)
api(projects.platform.platformDependencies)
}
}
val commonTest by getting {
dependencies {
implementation(libs.kotlin.test)
}
}
val jvmTest by getting {
dependencies {
// Stellt die Test-Bibliotheken bereit (JVM-specific)
implementation(projects.platform.platformTesting)
implementation(libs.bundles.testing.jvm)
}
}
} }
} }
dependencies {
// Stellt sicher, dass dieses Modul Zugriff auf die im zentralen Katalog
// definierten Bibliotheken hat.
api(projects.platform.platformDependencies)
// Kern-Abhängigkeiten für das Domänen-Modul.
api(libs.uuid)
api(libs.kotlinx.serialization.json)
api(libs.kotlinx.datetime)
// Stellt die Test-Bibliotheken bereit.
testImplementation(projects.platform.platformTesting)
testImplementation(libs.bundles.testing.jvm)
}
@@ -1,5 +1,6 @@
package at.mocode.core.domain.event 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.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer import at.mocode.core.domain.serialization.UuidSerializer
import com.benasher44.uuid.Uuid import com.benasher44.uuid.Uuid
@@ -7,38 +8,38 @@ import com.benasher44.uuid.uuid4
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class)
/** /**
* Basis-Interface für alle Domänen-Events im System. * Basis-Interface für alle Domänen-Events im System.
* Ein Domänen-Event repräsentiert etwas fachlich Bedeutsames, das passiert ist. * Ein Domänen-Event repräsentiert etwas fachlich Bedeutsames, das passiert ist.
*/ */
interface DomainEvent { interface DomainEvent {
val eventId: Uuid val eventId: EventId
val aggregateId: Uuid val aggregateId: AggregateId
val eventType: String val eventType: EventType
val timestamp: Instant val timestamp: Instant
val version: Long val version: EventVersion
val correlationId: Uuid? val correlationId: CorrelationId?
val causationId: Uuid? val causationId: CausationId?
} }
/** /**
* Abstrakte Basisklasse für Domänen-Events, um Boilerplate-Code zu reduzieren. * Abstrakte Basisklasse für Domänen-Events, um Boilerplate-Code zu reduzieren.
*/ */
@Serializable @Serializable
@OptIn(ExperimentalTime::class)
abstract class BaseDomainEvent( abstract class BaseDomainEvent(
@Serializable(with = UuidSerializer::class) override val aggregateId: AggregateId,
override val aggregateId: Uuid, override val eventType: EventType,
override val eventType: String, override val version: EventVersion,
override val version: Long, override val eventId: EventId = EventId(uuid4()),
@Serializable(with = UuidSerializer::class)
override val eventId: Uuid = uuid4(),
@Serializable(with = KotlinInstantSerializer::class) @Serializable(with = KotlinInstantSerializer::class)
override val timestamp: Instant = Clock.System.now(), override val timestamp: Instant = Clock.System.now(),
@Serializable(with = UuidSerializer::class) override val correlationId: CorrelationId? = null,
override val correlationId: Uuid? = null, override val causationId: CausationId? = null
@Serializable(with = UuidSerializer::class)
override val causationId: Uuid? = null
) : DomainEvent ) : DomainEvent
/** /**
@@ -5,6 +5,7 @@ import at.mocode.core.domain.serialization.UuidSerializer
import com.benasher44.uuid.Uuid import com.benasher44.uuid.Uuid
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import kotlin.time.ExperimentalTime
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@@ -16,9 +17,9 @@ interface BaseDto
* Base DTO for domain entities that have unique ID and audit timestamps. * Base DTO for domain entities that have unique ID and audit timestamps.
*/ */
@Serializable @Serializable
@OptIn(ExperimentalTime::class)
abstract class EntityDto : BaseDto { abstract class EntityDto : BaseDto {
@Serializable(with = UuidSerializer::class) abstract val id: EntityId
abstract val id: Uuid
@Serializable(with = KotlinInstantSerializer::class) @Serializable(with = KotlinInstantSerializer::class)
abstract val createdAt: Instant abstract val createdAt: Instant
@@ -41,6 +42,7 @@ data class ErrorDto(
* A standardized and consistent wrapper for all API responses. * A standardized and consistent wrapper for all API responses.
*/ */
@Serializable @Serializable
@OptIn(ExperimentalTime::class)
data class ApiResponse<T>( data class ApiResponse<T>(
val data: T?, val data: T?,
val success: Boolean, val success: Boolean,
@@ -49,10 +51,12 @@ data class ApiResponse<T>(
val timestamp: Instant = Clock.System.now() val timestamp: Instant = Clock.System.now()
) { ) {
companion object { companion object {
@OptIn(ExperimentalTime::class)
fun <T> success(data: T): ApiResponse<T> { fun <T> success(data: T): ApiResponse<T> {
return ApiResponse(data = data, success = true) return ApiResponse(data = data, success = true)
} }
@OptIn(ExperimentalTime::class)
fun <T> error( fun <T> error(
code: String, code: String,
message: String, message: String,
@@ -65,6 +69,7 @@ data class ApiResponse<T>(
) )
} }
@OptIn(ExperimentalTime::class)
fun <T> error(errors: List<ErrorDto>): ApiResponse<T> { fun <T> error(errors: List<ErrorDto>): ApiResponse<T> {
return ApiResponse(data = null, success = false, errors = errors) return ApiResponse(data = null, success = false, errors = errors)
} }
@@ -0,0 +1,134 @@
package at.mocode.core.domain.model
import at.mocode.core.domain.serialization.UuidSerializer
import com.benasher44.uuid.Uuid
import kotlin.jvm.JvmInline
import kotlinx.serialization.Serializable
/**
* Value classes for strongly typed IDs and domain values.
* These provide compile-time type safety without runtime overhead.
*/
// === ID Value Classes ===
/**
* A strongly typed wrapper for entity IDs.
*/
@Serializable
@JvmInline
value class EntityId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for event IDs.
*/
@Serializable
@JvmInline
value class EventId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for aggregate IDs.
*/
@Serializable
@JvmInline
value class AggregateId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for correlation IDs used in event tracing.
*/
@Serializable
@JvmInline
value class CorrelationId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for causation IDs used in event tracing.
*/
@Serializable
@JvmInline
value class CausationId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
}
// === Domain Value Classes ===
/**
* A strongly typed wrapper for event types.
*/
@Serializable
@JvmInline
value class EventType(val value: String) {
init {
require(value.isNotBlank()) { "Event type cannot be blank" }
require(value.matches(Regex("^[A-Za-z][A-Za-z0-9]*$"))) {
"Event type must start with a letter and contain only alphanumeric characters"
}
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for event version numbers.
*/
@Serializable
@JvmInline
value class EventVersion(val value: Long) : Comparable<EventVersion> {
init {
require(value >= 0) { "Event version must be non-negative" }
}
override fun toString(): String = value.toString()
override fun compareTo(other: EventVersion): Int = value.compareTo(other.value)
}
/**
* A strongly typed wrapper for error codes.
*/
@Serializable
@JvmInline
value class ErrorCode(val value: String) {
init {
require(value.isNotBlank()) { "Error code cannot be blank" }
require(value.matches(Regex("^[A-Z][A-Z0-9_]*$"))) {
"Error code must be uppercase and contain only letters, numbers, and underscores"
}
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for page numbers in pagination.
*/
@Serializable
@JvmInline
value class PageNumber(val value: Int) {
init {
require(value >= 0) { "Page number must be non-negative" }
}
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for page sizes in pagination.
*/
@Serializable
@JvmInline
value class PageSize(val value: Int) {
init {
require(value > 0) { "Page size must be positive" }
require(value <= 1000) { "Page size cannot exceed 1000" }
}
override fun toString(): String = value.toString()
}
@@ -3,6 +3,7 @@ package at.mocode.core.domain.serialization
import com.benasher44.uuid.Uuid import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuidFrom import com.benasher44.uuid.uuidFrom
import kotlin.time.Instant // KORRIGIERT: Finaler Wechsel zu kotlin.time import kotlin.time.Instant // KORRIGIERT: Finaler Wechsel zu kotlin.time
import kotlin.time.ExperimentalTime
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
@@ -19,6 +20,7 @@ object UuidSerializer : KSerializer<Uuid> {
override fun deserialize(decoder: Decoder): Uuid = uuidFrom(decoder.decodeString()) override fun deserialize(decoder: Decoder): Uuid = uuidFrom(decoder.decodeString())
} }
@OptIn(ExperimentalTime::class)
object KotlinInstantSerializer : KSerializer<Instant> { object KotlinInstantSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING) override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString()) override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
@@ -1,6 +1,7 @@
package at.mocode.core.domain package at.mocode.core.domain
import at.mocode.core.domain.event.BaseDomainEvent import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.model.*
import com.benasher44.uuid.Uuid import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuid4 import com.benasher44.uuid.uuid4
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -22,21 +23,21 @@ class DomainEventTest {
@Serializable @Serializable
data class TestEvent( data class TestEvent(
@Transient @Transient
override val aggregateId: Uuid = uuid4(), override val aggregateId: AggregateId = AggregateId(uuid4()),
@Transient @Transient
override val version: Long = 1L, override val version: EventVersion = EventVersion(1L),
val testPayload: String = "Test" val testPayload: String = "Test"
) : BaseDomainEvent( ) : BaseDomainEvent(
aggregateId = aggregateId, aggregateId = aggregateId,
eventType = "TestEventOccurred", // Ein klar definierter Event-Typ eventType = EventType("TestEventOccurred"), // Ein klar definierter Event-Typ
version = version version = version
) )
@Test @Test
fun `BaseDomainEvent should auto-generate eventId and timestamp upon creation`() { fun `BaseDomainEvent should auto-generate eventId and timestamp upon creation`() {
// Arrange // Arrange
val aggregateId = uuid4() val aggregateId = AggregateId(uuid4())
val version = 1L val version = EventVersion(1L)
// Act // Act
val event = TestEvent(aggregateId, version) val event = TestEvent(aggregateId, version)
@@ -46,6 +47,6 @@ class DomainEventTest {
assertNotNull(event.timestamp, "timestamp should be automatically generated and not null") assertNotNull(event.timestamp, "timestamp should be automatically generated and not null")
assertEquals(aggregateId, event.aggregateId, "aggregateId should be set correctly") assertEquals(aggregateId, event.aggregateId, "aggregateId should be set correctly")
assertEquals(version, event.version, "version should be set correctly") assertEquals(version, event.version, "version should be set correctly")
assertEquals("TestEventOccurred", event.eventType, "eventType should be set correctly") assertEquals(EventType("TestEventOccurred"), event.eventType, "eventType should be set correctly")
} }
} }
+62 -36
View File
@@ -1,44 +1,70 @@
// Dieses Modul stellt gemeinsame technische Hilfsfunktionen bereit, // Dieses Modul stellt gemeinsame technische Hilfsfunktionen bereit,
// wie z.B. Konfigurations-Management, Datenbank-Verbindungen und Service Discovery. // wie z.B. Konfigurations-Management, Datenbank-Verbindungen und Service Discovery.
plugins { plugins {
alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.multiplatform)
} }
kotlin { kotlin {
compilerOptions { // Target platforms
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") 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 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( data class ServerConfig(
val port: Int, val port: Port,
val host: String, val host: Host,
val advertisedHost: String, val advertisedHost: Host,
val workers: Int, val workers: WorkerCount,
val cors: CorsConfig val cors: CorsConfig
) { ) {
data class CorsConfig(val enabled: Boolean, val allowedOrigins: List<String>) data class CorsConfig(val enabled: Boolean, val allowedOrigins: List<String>)
} }
data class DatabaseConfig( data class DatabaseConfig(
val host: String, val host: Host,
val port: Int, val port: Port,
val name: String, val name: DatabaseName,
val jdbcUrl: String, val jdbcUrl: JdbcUrl,
val username: String, val username: DatabaseUsername,
val password: String, val password: DatabasePassword,
val driverClassName: String, val driverClassName: String,
val maxPoolSize: Int, val maxPoolSize: PoolSize,
val minPoolSize: Int, val minPoolSize: PoolSize,
val autoMigrate: Boolean 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( data class JwtConfig(
val secret: String, val secret: JwtSecret,
val issuer: String, val issuer: JwtIssuer,
val audience: String, val audience: JwtAudience,
val realm: String, val realm: JwtRealm,
val expirationInMinutes: Long val expirationInMinutes: Long
) )
} }
data class LoggingConfig(val level: String, val logRequests: Boolean, val logResponses: Boolean) 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 // Die Konfigurations-Erstellungslogik ist hierher verschoben
private fun createAppInfoConfig(props: Properties) = AppInfoConfig( private fun createAppInfoConfig(props: Properties) = AppInfoConfig(
name = props.getProperty("app.name", "Meldestelle"), name = ApplicationName(props.getProperty("app.name", "Meldestelle")),
version = props.getProperty("app.version", "1.0.0"), version = ApplicationVersion(props.getProperty("app.version", "1.0.0")),
description = props.getProperty("app.description", "Pferdesport Meldestelle System") description = props.getProperty("app.description", "Pferdesport Meldestelle System")
) )
@@ -65,10 +65,10 @@ class ConfigLoader(private val configPath: String = "config") {
"127.0.0.1" "127.0.0.1"
} }
return ServerConfig( return ServerConfig(
port = props.getIntProperty("server.port", "API_PORT", 8081), port = Port(props.getIntProperty("server.port", "API_PORT", 8081)),
host = props.getStringProperty("server.host", "API_HOST", "0.0.0.0"), host = Host(props.getStringProperty("server.host", "API_HOST", "0.0.0.0")),
advertisedHost = props.getStringProperty("server.advertisedHost", "API_HOST_ADVERTISED", defaultHost), advertisedHost = Host(props.getStringProperty("server.advertisedHost", "API_HOST_ADVERTISED", defaultHost)),
workers = props.getIntProperty("server.workers", "API_WORKERS", Runtime.getRuntime().availableProcessors()), workers = WorkerCount(props.getIntProperty("server.workers", "API_WORKERS", Runtime.getRuntime().availableProcessors())),
cors = ServerConfig.CorsConfig( cors = ServerConfig.CorsConfig(
enabled = props.getBooleanProperty("server.cors.enabled", "API_CORS_ENABLED", true), enabled = props.getBooleanProperty("server.cors.enabled", "API_CORS_ENABLED", true),
allowedOrigins = props.getProperty("server.cors.allowedOrigins")?.split(",")?.map { it.trim() } 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 port = props.getIntProperty("database.port", "DB_PORT", 5432)
val name = props.getStringProperty("database.name", "DB_NAME", "meldestelle_db") val name = props.getStringProperty("database.name", "DB_NAME", "meldestelle_db")
return DatabaseConfig( return DatabaseConfig(
host = host, host = Host(host),
port = port, port = Port(port),
name = name, name = DatabaseName(name),
jdbcUrl = "jdbc:postgresql://$host:$port/$name", jdbcUrl = JdbcUrl("jdbc:postgresql://$host:$port/$name"),
username = props.getStringProperty("database.username", "DB_USER", "meldestelle_user"), username = DatabaseUsername(props.getStringProperty("database.username", "DB_USER", "meldestelle_user")),
password = props.getStringProperty("database.password", "DB_PASSWORD", "secure_password_change_me"), password = DatabasePassword(props.getStringProperty("database.password", "DB_PASSWORD", "secure_password_change_me")),
driverClassName = "org.postgresql.Driver", driverClassName = "org.postgresql.Driver",
maxPoolSize = props.getIntProperty("database.maxPoolSize", "DB_MAX_POOL_SIZE", 10), maxPoolSize = PoolSize(props.getIntProperty("database.maxPoolSize", "DB_MAX_POOL_SIZE", 10)),
minPoolSize = props.getIntProperty("database.minPoolSize", "DB_MIN_POOL_SIZE", 5), minPoolSize = PoolSize(props.getIntProperty("database.minPoolSize", "DB_MIN_POOL_SIZE", 5)),
autoMigrate = props.getBooleanProperty("database.autoMigrate", "DB_AUTO_MIGRATE", true) 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. // analog zu den 'fromProperties' Methoden aus der alten AppConfig.
private fun createServiceDiscoveryConfig(props: Properties) = ServiceDiscoveryConfig( private fun createServiceDiscoveryConfig(props: Properties) = ServiceDiscoveryConfig(
enabled = props.getBooleanProperty("service-discovery.enabled", "CONSUL_ENABLED", true), enabled = props.getBooleanProperty("service-discovery.enabled", "CONSUL_ENABLED", true),
consulHost = props.getStringProperty("service-discovery.consul.host", "CONSUL_HOST", "consul"), consulHost = Host(props.getStringProperty("service-discovery.consul.host", "CONSUL_HOST", "consul")),
consulPort = props.getIntProperty("service-discovery.consul.port", "CONSUL_PORT", 8500) consulPort = Port(props.getIntProperty("service-discovery.consul.port", "CONSUL_PORT", 8500))
) )
private fun createSecurityConfig(props: Properties) = SecurityConfig( private fun createSecurityConfig(props: Properties) = SecurityConfig(
jwt = SecurityConfig.JwtConfig( jwt = SecurityConfig.JwtConfig(
secret = props.getStringProperty( secret = JwtSecret(props.getStringProperty(
"security.jwt.secret", "security.jwt.secret",
"JWT_SECRET", "JWT_SECRET",
"default-secret-please-change-in-production" "default-secret-please-change-in-production"
), )),
issuer = props.getStringProperty("security.jwt.issuer", "JWT_ISSUER", "meldestelle-api"), issuer = JwtIssuer(props.getStringProperty("security.jwt.issuer", "JWT_ISSUER", "meldestelle-api")),
audience = props.getStringProperty("security.jwt.audience", "JWT_AUDIENCE", "meldestelle-clients"), audience = JwtAudience(props.getStringProperty("security.jwt.audience", "JWT_AUDIENCE", "meldestelle-clients")),
realm = props.getStringProperty("security.jwt.realm", "JWT_REALM", "meldestelle"), realm = JwtRealm(props.getStringProperty("security.jwt.realm", "JWT_REALM", "meldestelle")),
expirationInMinutes = props.getLongProperty( expirationInMinutes = props.getLongProperty(
"security.jwt.expirationInMinutes", "security.jwt.expirationInMinutes",
"JWT_EXPIRATION_MINUTES", "JWT_EXPIRATION_MINUTES",
60 * 24 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( 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( private fun createRateLimitConfig(props: Properties) = RateLimitConfig(
enabled = props.getBooleanProperty("ratelimit.enabled", "RATE_LIMIT_ENABLED", true), enabled = props.getBooleanProperty("ratelimit.enabled", "RATE_LIMIT_ENABLED", true),
globalLimit = props.getIntProperty("ratelimit.global.limit", "RATE_LIMIT_GLOBAL_LIMIT", 100), globalLimit = RateLimit(props.getIntProperty("ratelimit.global.limit", "RATE_LIMIT_GLOBAL_LIMIT", 100)),
globalPeriodMinutes = props.getIntProperty("ratelimit.global.periodMinutes", "RATE_LIMIT_GLOBAL_PERIOD", 1) 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 { private fun createHikariConfig(): HikariConfig {
return HikariConfig().apply { return HikariConfig().apply {
driverClassName = config.driverClassName driverClassName = config.driverClassName
jdbcUrl = config.jdbcUrl jdbcUrl = config.jdbcUrl.value
username = config.username username = config.username.value
password = config.password password = config.password.getValue() // Use getValue() for password to access actual value
maximumPoolSize = config.maxPoolSize maximumPoolSize = config.maxPoolSize.value
minimumIdle = config.minPoolSize minimumIdle = config.minPoolSize.value
isAutoCommit = false isAutoCommit = false
transactionIsolation = "TRANSACTION_READ_COMMITTED" transactionIsolation = "TRANSACTION_READ_COMMITTED"
validationTimeout = 5000 validationTimeout = 5000
@@ -31,8 +31,8 @@ class ConfigLoaderTest {
val config = configLoader.load(AppEnvironment.DEVELOPMENT) val config = configLoader.load(AppEnvironment.DEVELOPMENT)
// Assert // Assert
assertEquals("Meldestelle", config.appInfo.name) assertEquals("Meldestelle", config.appInfo.name.value)
assertEquals(8081, config.server.port) // Standard-Port assertEquals(8081, config.server.port.value) // Standard-Port
} }
@Test @Test
@@ -53,8 +53,8 @@ class ConfigLoaderTest {
val config = configLoader.load(AppEnvironment.DEVELOPMENT) val config = configLoader.load(AppEnvironment.DEVELOPMENT)
// Assert // Assert
assertEquals("TestApp", config.appInfo.name) assertEquals("TestApp", config.appInfo.name.value)
assertEquals(9999, config.server.port) assertEquals(9999, config.server.port.value)
} }
@Test @Test
@@ -83,8 +83,8 @@ class ConfigLoaderTest {
// Assert // Assert
assertEquals(AppEnvironment.TEST, config.environment, "Environment should be TEST") assertEquals(AppEnvironment.TEST, config.environment, "Environment should be TEST")
assertEquals("TestEnvApp", config.appInfo.name, "app.name should be overridden") assertEquals("TestEnvApp", config.appInfo.name.value, "app.name should be overridden")
assertEquals(9000, config.server.port, "server.port should be overridden") assertEquals(9000, config.server.port.value, "server.port should be overridden")
assertEquals("base-db-host", config.database.host, "database.host should come from the base file") assertEquals("base-db-host", config.database.host.value, "database.host should come from the base file")
} }
} }
@@ -1,6 +1,6 @@
package at.mocode.core.utils.database package at.mocode.core.utils.database
import at.mocode.core.utils.config.DatabaseConfig import at.mocode.core.utils.config.*
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.Table
@@ -23,7 +23,7 @@ class DatabaseFactoryTest {
companion object { companion object {
@Container @Container
val postgresContainer = PostgreSQLContainer<Nothing>("postgres:16-alpine").apply { val postgresContainer = PostgreSQLContainer<Nothing>("postgres:16-alpine").apply {
withDatabaseName("test-db") withDatabaseName("testdb")
withUsername("test-user") withUsername("test-user")
withPassword("test-password") withPassword("test-password")
} }
@@ -37,15 +37,15 @@ class DatabaseFactoryTest {
fun setup() { fun setup() {
// Erstelle eine DB-Konfiguration mit den dynamischen Daten des gestarteten Containers // Erstelle eine DB-Konfiguration mit den dynamischen Daten des gestarteten Containers
dbConfig = DatabaseConfig( dbConfig = DatabaseConfig(
host = postgresContainer.host, host = Host(postgresContainer.host),
port = postgresContainer.firstMappedPort, port = Port(postgresContainer.firstMappedPort),
name = postgresContainer.databaseName, name = DatabaseName(postgresContainer.databaseName),
jdbcUrl = postgresContainer.jdbcUrl, jdbcUrl = JdbcUrl(postgresContainer.jdbcUrl),
username = postgresContainer.username, username = DatabaseUsername(postgresContainer.username),
password = postgresContainer.password, password = DatabasePassword(postgresContainer.password),
driverClassName = "org.postgresql.Driver", driverClassName = "org.postgresql.Driver",
maxPoolSize = 2, maxPoolSize = PoolSize(2),
minPoolSize = 1, minPoolSize = PoolSize(1),
autoMigrate = false // Wir steuern Migrationen im Test manuell autoMigrate = false // Wir steuern Migrationen im Test manuell
) )
// Erstelle eine neue Factory-Instanz und verbinde sie mit der Test-DB // Erstelle eine neue Factory-Instanz und verbinde sie mit der Test-DB
+60
View File
@@ -114,6 +114,66 @@ services:
retries: 3 retries: 3
start_period: 10s 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 # Optional monitoring services
prometheus: prometheus:
image: prom/prometheus:latest image: prom/prometheus:latest
+162
View File
@@ -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.
@@ -1,6 +1,8 @@
package at.mocode.infrastructure.eventstore.redis package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.DomainEvent 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.ConcurrencyException
import at.mocode.infrastructure.eventstore.api.EventSerializer import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore import at.mocode.infrastructure.eventstore.api.EventStore
@@ -26,7 +28,7 @@ class RedisEventStore(
val aggregateId = events.first().aggregateId val aggregateId = events.first().aggregateId
require(events.all { it.aggregateId == aggregateId }) { "All events must belong to the same aggregate" } 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) var currentVersion = getStreamVersion(streamId)
@@ -59,7 +61,7 @@ class RedisEventStore(
private fun appendToStreamInternal(event: DomainEvent, streamId: Uuid, currentVersion: Long): Long { private fun appendToStreamInternal(event: DomainEvent, streamId: Uuid, currentVersion: Long): Long {
val newVersion = currentVersion + 1 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 streamKey = getStreamKey(streamId)
val allEventsStreamKey = getAllEventsStreamKey() val allEventsStreamKey = getAllEventsStreamKey()
@@ -102,7 +104,7 @@ class RedisEventStore(
} }
} ?: emptyList() } ?: 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 { override fun getStreamVersion(streamId: Uuid): Long {
@@ -2,6 +2,7 @@ package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent 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.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore import at.mocode.infrastructure.eventstore.api.EventStore
import com.benasher44.uuid.Uuid import com.benasher44.uuid.Uuid
@@ -85,8 +86,8 @@ class RedisEventStoreIntegrationTest {
@Test @Test
fun `event publishing and consuming with consumer groups should work`() { fun `event publishing and consuming with consumer groups should work`() {
val aggregateId = uuid4() val aggregateId = uuid4()
val event1 = TestCreatedEvent(aggregateId = aggregateId, version = 1L, name = "Test Entity") val event1 = TestCreatedEvent(aggregateId = AggregateId(aggregateId), version = EventVersion(1L), name = "Test Entity")
val event2 = TestUpdatedEvent(aggregateId = aggregateId, version = 2L, name = "Updated Test Entity") val event2 = TestUpdatedEvent(aggregateId = AggregateId(aggregateId), version = EventVersion(2L), name = "Updated Test Entity")
val latch = CountDownLatch(2) val latch = CountDownLatch(2)
val receivedEvents = mutableListOf<DomainEvent>() val receivedEvents = mutableListOf<DomainEvent>()
@@ -112,34 +113,34 @@ class RedisEventStoreIntegrationTest {
assertEquals(2, receivedEvents.size) assertEquals(2, receivedEvents.size)
val receivedEvent1 = receivedEvents.find { it.version == 1L } as TestCreatedEvent val receivedEvent1 = receivedEvents.find { it.version == EventVersion(1L) } as TestCreatedEvent
assertEquals(aggregateId, receivedEvent1.aggregateId) assertEquals(AggregateId(aggregateId), receivedEvent1.aggregateId)
assertEquals("Test Entity", receivedEvent1.name) assertEquals("Test Entity", receivedEvent1.name)
val receivedEvent2 = receivedEvents.find { it.version == 2L } as TestUpdatedEvent val receivedEvent2 = receivedEvents.find { it.version == EventVersion(2L) } as TestUpdatedEvent
assertEquals(aggregateId, receivedEvent2.aggregateId) assertEquals(AggregateId(aggregateId), receivedEvent2.aggregateId)
assertEquals("Updated Test Entity", receivedEvent2.name) assertEquals("Updated Test Entity", receivedEvent2.name)
} }
data class TestCreatedEvent( data class TestCreatedEvent(
override val aggregateId: Uuid, override val aggregateId: AggregateId,
override val version: Long, override val version: EventVersion,
val name: String, val name: String,
override val eventType: String = "TestCreated", override val eventType: EventType = EventType("TestCreated"),
override val eventId: Uuid = uuid4(), override val eventId: EventId = EventId(uuid4()),
override val timestamp: Instant = Clock.System.now(), override val timestamp: Instant = Clock.System.now(),
override val correlationId: Uuid? = null, override val correlationId: CorrelationId? = null,
override val causationId: Uuid? = null override val causationId: CausationId? = null
) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId) ) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId)
data class TestUpdatedEvent( data class TestUpdatedEvent(
override val aggregateId: Uuid, override val aggregateId: AggregateId,
override val version: Long, override val version: EventVersion,
val name: String, val name: String,
override val eventType: String = "TestUpdated", override val eventType: EventType = EventType("TestUpdated"),
override val eventId: Uuid = uuid4(), override val eventId: EventId = EventId(uuid4()),
override val timestamp: Instant = Clock.System.now(), override val timestamp: Instant = Clock.System.now(),
override val correlationId: Uuid? = null, override val correlationId: CorrelationId? = null,
override val causationId: Uuid? = null override val causationId: CausationId? = null
) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId) ) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId)
} }
@@ -1,6 +1,7 @@
package at.mocode.infrastructure.eventstore.redis package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent 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.ConcurrencyException
import at.mocode.infrastructure.eventstore.api.EventSerializer import at.mocode.infrastructure.eventstore.api.EventSerializer
import com.benasher44.uuid.Uuid import com.benasher44.uuid.Uuid
@@ -68,8 +69,8 @@ class RedisEventStoreTest {
@Test @Test
fun `append and read events should work correctly for new stream`() { fun `append and read events should work correctly for new stream`() {
val aggregateId = uuid4() val aggregateId = uuid4()
val event1 = TestCreatedEvent(aggregateId, 1L, "Test Entity") val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
val event2 = TestUpdatedEvent(aggregateId, 2L, "Updated Test Entity") val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity")
eventStore.appendToStream(listOf(event1, event2), aggregateId, 0) eventStore.appendToStream(listOf(event1, event2), aggregateId, 0)
@@ -77,21 +78,21 @@ class RedisEventStoreTest {
assertEquals(2, events.size) assertEquals(2, events.size)
val firstEvent = events[0] as TestCreatedEvent val firstEvent = events[0] as TestCreatedEvent
assertEquals(1L, firstEvent.version) assertEquals(EventVersion(1L), firstEvent.version)
assertEquals("Test Entity", firstEvent.name) assertEquals("Test Entity", firstEvent.name)
val secondEvent = events[1] as TestUpdatedEvent val secondEvent = events[1] as TestUpdatedEvent
assertEquals(2L, secondEvent.version) assertEquals(EventVersion(2L), secondEvent.version)
assertEquals("Updated Test Entity", secondEvent.name) assertEquals("Updated Test Entity", secondEvent.name)
} }
@Test @Test
fun `appending with wrong expected version should throw ConcurrencyException`() { fun `appending with wrong expected version should throw ConcurrencyException`() {
val aggregateId = uuid4() 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 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> { assertThrows<ConcurrencyException> {
eventStore.appendToStream(listOf(event2), aggregateId, 0) eventStore.appendToStream(listOf(event2), aggregateId, 0)
} }
@@ -99,15 +100,15 @@ class RedisEventStoreTest {
@Serializable @Serializable
data class TestCreatedEvent( data class TestCreatedEvent(
@Transient override val aggregateId: Uuid = uuid4(), @Transient override val aggregateId: AggregateId = AggregateId(uuid4()),
@Transient override val version: Long = 0, @Transient override val version: EventVersion = EventVersion(0),
val name: String val name: String
) : BaseDomainEvent(aggregateId, "TestCreated", version) ) : BaseDomainEvent(aggregateId, EventType("TestCreated"), version)
@Serializable @Serializable
data class TestUpdatedEvent( data class TestUpdatedEvent(
@Transient override val aggregateId: Uuid = uuid4(), @Transient override val aggregateId: AggregateId = AggregateId(uuid4()),
@Transient override val version: Long = 0, @Transient override val version: EventVersion = EventVersion(0),
val name: String val name: String
) : BaseDomainEvent(aggregateId, "TestUpdated", version) ) : BaseDomainEvent(aggregateId, EventType("TestUpdated"), version)
} }
@@ -2,6 +2,7 @@ package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent 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.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore import at.mocode.infrastructure.eventstore.api.EventStore
import com.benasher44.uuid.Uuid import com.benasher44.uuid.Uuid
@@ -78,8 +79,8 @@ class RedisIntegrationTest {
@Test @Test
fun `event publishing and consuming should be fast and reliable`() { fun `event publishing and consuming should be fast and reliable`() {
val aggregateId = uuid4() val aggregateId = uuid4()
val event1 = TestCreatedEvent(aggregateId, 1L, "Test Entity") val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
val event2 = TestUpdatedEvent(aggregateId, 2L, "Updated Test Entity") val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity")
val receivedEvents = mutableListOf<DomainEvent>() val receivedEvents = mutableListOf<DomainEvent>()
eventConsumer.registerEventHandler("TestCreated") { receivedEvents.add(it) } eventConsumer.registerEventHandler("TestCreated") { receivedEvents.add(it) }
@@ -91,26 +92,26 @@ class RedisIntegrationTest {
assertEquals(2, receivedEvents.size) assertEquals(2, receivedEvents.size)
val receivedEvent1 = receivedEvents.find { it.version == 1L } as TestCreatedEvent val receivedEvent1 = receivedEvents.find { it.version == EventVersion(1L) } as TestCreatedEvent
assertEquals(aggregateId, receivedEvent1.aggregateId) assertEquals(AggregateId(aggregateId), receivedEvent1.aggregateId)
assertEquals("Test Entity", receivedEvent1.name) assertEquals("Test Entity", receivedEvent1.name)
val receivedEvent2 = receivedEvents.find { it.version == 2L } as TestUpdatedEvent val receivedEvent2 = receivedEvents.find { it.version == EventVersion(2L) } as TestUpdatedEvent
assertEquals(aggregateId, receivedEvent2.aggregateId) assertEquals(AggregateId(aggregateId), receivedEvent2.aggregateId)
assertEquals("Updated Test Entity", receivedEvent2.name) assertEquals("Updated Test Entity", receivedEvent2.name)
} }
@Serializable @Serializable
data class TestCreatedEvent( data class TestCreatedEvent(
@Transient override val aggregateId: Uuid = uuid4(), @Transient override val aggregateId: AggregateId = AggregateId(uuid4()),
@Transient override val version: Long = 0, @Transient override val version: EventVersion = EventVersion(0),
val name: String val name: String
) : BaseDomainEvent(aggregateId, "TestCreated", version) ) : BaseDomainEvent(aggregateId, EventType("TestCreated"), version)
@Serializable @Serializable
data class TestUpdatedEvent( data class TestUpdatedEvent(
@Transient override val aggregateId: Uuid = uuid4(), @Transient override val aggregateId: AggregateId = AggregateId(uuid4()),
@Transient override val version: Long = 0, @Transient override val version: EventVersion = EventVersion(0),
val name: String val name: String
) : BaseDomainEvent(aggregateId, "TestUpdated", version) ) : BaseDomainEvent(aggregateId, EventType("TestUpdated"), version)
} }
+17
View File
@@ -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: spring:
application: application:
name: api-gateway name: api-gateway
security:
user:
name: admin
password: admin
cloud: cloud:
consul:
host: localhost
port: 8500
discovery:
register: true
health-check-path: /actuator/health
health-check-interval: 10s
gateway: gateway:
# HTTP Client-Timeouts für stabile Upstream-Verbindungen # HTTP Client-Timeouts für stabile Upstream-Verbindungen
httpclient: httpclient:
@@ -22,9 +33,17 @@ spring:
# Antwort-Header bereinigen (verhindert doppelte CORS-Header) # Antwort-Header bereinigen (verhindert doppelte CORS-Header)
default-filters: default-filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
# Aktiviert die automatische Routen-Erstellung basierend auf Consul # Route definitions with service discovery
discovery: routes:
locator: - id: ping-service-route
enabled: true uri: lb://ping-service
# Macht Routen-Namen klein (z.B. /members-service/** statt /MEMBERS-SERVICE/**) predicates:
lower-case-service-id: true - Path=/api/ping/**
filters:
- StripPrefix=1
management:
endpoints:
web:
exposure:
include: health,info
@@ -71,7 +71,9 @@ class GatewayApplicationTests {
class TestRoutes { class TestRoutes {
@Bean @Bean
fun routeLocator(builder: RouteLocatorBuilder): RouteLocator = builder.routes() 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() .build()
} }
@@ -1,5 +1,6 @@
package at.mocode.infrastructure.messaging.client package at.mocode.infrastructure.messaging.client
import at.mocode.infrastructure.messaging.config.KafkaConfig
import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.consumer.ConsumerConfig
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.kafka.support.serializer.JsonDeserializer import org.springframework.kafka.support.serializer.JsonDeserializer
@@ -7,42 +8,109 @@ import org.springframework.stereotype.Component
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.kafka.receiver.KafkaReceiver import reactor.kafka.receiver.KafkaReceiver
import reactor.kafka.receiver.ReceiverOptions import reactor.kafka.receiver.ReceiverOptions
import reactor.util.retry.Retry
import java.time.Duration
import java.util.Collections 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 @Component
class KafkaEventConsumer( class KafkaEventConsumer(
// Wir injizieren die Basis-Konfigurationseigenschaften aus messaging-config private val kafkaConfig: KafkaConfig
private val consumerConfig: Map<String, Any>
) : EventConsumer { ) : EventConsumer {
private val logger = LoggerFactory.getLogger(KafkaEventConsumer::class.java) private val logger = LoggerFactory.getLogger(KafkaEventConsumer::class.java)
override fun <T : Any> receiveEvents(topic: String, eventType: Class<T>): Flux<T> { // Connection pool to reuse KafkaReceiver instances per topic-eventType combination
// Für jeden Aufruf wird eine neue, spezifische Konfiguration für diesen Topic erstellt. private val receiverCache = ConcurrentHashMap<String, KafkaReceiver<String, Any>>()
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)
}
return KafkaReceiver.create(receiverOptions) override fun <T : Any> receiveEvents(topic: String, eventType: Class<T>): Flux<T> {
.receive() 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 -> .doOnNext { record ->
logger.debug( logger.debug(
"Received message from topic-partition {}-{} with offset {}", "Received message from topic-partition {}-{} with offset {} for event type '{}'",
record.topic(), record.partition(), record.offset() record.topic(), record.partition(), record.offset(), eventType.simpleName
) )
} }
.map { it.value() } // Extrahiere nur die deserialisierte Nachricht .map { record ->
.doOnError { exception -> // Manual commit acknowledgment for better control
logger.error("Error receiving events from topic '{}'", topic, exception) 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)
} }
} }
@@ -5,42 +5,121 @@ import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono 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 @Component
class KafkaEventPublisher( class KafkaEventPublisher(
// KORREKTUR: Verwendung des reaktiven Templates
private val reactiveKafkaTemplate: ReactiveKafkaProducerTemplate<String, Any> private val reactiveKafkaTemplate: ReactiveKafkaProducerTemplate<String, Any>
) : EventPublisher { ) : EventPublisher {
private val logger = LoggerFactory.getLogger(KafkaEventPublisher::class.java) 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> { 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) return reactiveKafkaTemplate.send(topic, key, event)
.doOnSuccess { result -> .doOnSuccess { result ->
val record = result.recordMetadata() val record = result.recordMetadata()
logger.info( logger.debug(
"Successfully published event to topic-partition {}-{} with offset {}", "Successfully published event to topic-partition {}-{} with offset {} (key: '{}')",
record.topic(), record.partition(), record.offset() record.topic(), record.partition(), record.offset(), key
) )
} }
.doOnError { exception -> .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> { override fun publishEvents(topic: String, events: List<Pair<String?, Any>>): Flux<Void> {
logger.debug("Publishing {} events to topic '{}'", events.size, topic) if (events.isEmpty()) {
// Verwendet Flux.fromIterable, um eine Sequenz von Sende-Operationen zu erstellen 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) return Flux.fromIterable(events)
// .flatMap stellt sicher, dass die Sende-Operationen parallelisiert, .index() // Add index for progress tracking
// aber dennoch reaktiv (nicht-blockierend) ausgeführt werden. .flatMap({ indexedEventPair ->
.flatMap { (key, event) -> val index = indexedEventPair.t1
val eventPair = indexedEventPair.t2
val (key, event) = eventPair
publishEvent(topic, key, event) 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
}
}
} }
@@ -1,22 +1,57 @@
package at.mocode.infrastructure.messaging.client 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.DefaultKafkaProducerFactory
import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate
import reactor.kafka.sender.SenderOptions 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( @Bean
producerFactory: DefaultKafkaProducerFactory<String, Any> fun reactiveKafkaProducerTemplate(): ReactiveKafkaProducerTemplate<String, Any> {
): ReactiveKafkaProducerTemplate<String, Any> { logger.info("Creating optimized ReactiveKafkaProducerTemplate with enhanced configuration")
val producerFactory = kafkaConfig.producerFactory()
val props: Map<String, Any> = producerFactory.configurationProperties 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)
}
} }
} }
@@ -38,8 +38,8 @@ class KafkaIntegrationTest {
} }
producerFactory = kafkaConfig.producerFactory() producerFactory = kafkaConfig.producerFactory()
val reactiveKafkaConfig = ReactiveKafkaConfig() val reactiveKafkaConfig = ReactiveKafkaConfig(kafkaConfig)
val reactiveTemplate = reactiveKafkaConfig.reactiveKafkaProducerTemplate(producerFactory) val reactiveTemplate = reactiveKafkaConfig.reactiveKafkaProducerTemplate()
kafkaEventPublisher = KafkaEventPublisher(reactiveTemplate) kafkaEventPublisher = KafkaEventPublisher(reactiveTemplate)
} }
@@ -54,19 +54,18 @@ class KafkaIntegrationTest {
val testKey = "test-key" val testKey = "test-key"
val testEvent = TestEvent("Test Message") val testEvent = TestEvent("Test Message")
val consumerProps = mapOf( // Use the same KafkaConfig for consistent and secure configuration
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to kafkaContainer.bootstrapServers, val testKafkaConfig = KafkaConfig().apply {
ConsumerConfig.GROUP_ID_CONFIG to "test-group-${UUID.randomUUID()}", bootstrapServers = kafkaContainer.bootstrapServers
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, // For tests, we need to trust the test package
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to JsonDeserializer::class.java, trustedPackages = "at.mocode.*"
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest", }
JsonDeserializer.TRUSTED_PACKAGES to "*",
JsonDeserializer.USE_TYPE_INFO_HEADERS to false, val consumerProps = testKafkaConfig.consumerConfigs("test-group-${UUID.randomUUID()}")
JsonDeserializer.VALUE_DEFAULT_TYPE to TestEvent::class.java.name
)
val jsonValueDeserializer = JsonDeserializer(TestEvent::class.java).apply { val jsonValueDeserializer = JsonDeserializer(TestEvent::class.java).apply {
addTrustedPackages("*") addTrustedPackages(testKafkaConfig.trustedPackages)
setUseTypeHeaders(false)
} }
val receiverOptions = ReceiverOptions.create<String, TestEvent>(consumerProps) val receiverOptions = ReceiverOptions.create<String, TestEvent>(consumerProps)
.withKeyDeserializer(StringDeserializer()) .withKeyDeserializer(StringDeserializer())
@@ -1,13 +1,16 @@
package at.mocode.infrastructure.messaging.config package at.mocode.infrastructure.messaging.config
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.clients.producer.ProducerConfig
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.kafka.common.serialization.StringSerializer import org.apache.kafka.common.serialization.StringSerializer
import org.springframework.kafka.core.DefaultKafkaProducerFactory import org.springframework.kafka.core.DefaultKafkaProducerFactory
import org.springframework.kafka.core.ProducerFactory import org.springframework.kafka.core.ProducerFactory
import org.springframework.kafka.support.serializer.JsonDeserializer
import org.springframework.kafka.support.serializer.JsonSerializer 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 * This class can be instantiated programmatically (as done in tests) or
* registered as a Spring @Configuration with @Bean methods in an application context. * registered as a Spring @Configuration with @Bean methods in an application context.
@@ -20,14 +23,73 @@ class KafkaConfig {
var bootstrapServers: String = "localhost:9092" 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( fun producerConfigs(): Map<String, Any> = mapOf(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java,
// Avoid adding type info headers; keeps payloads simple and interoperable. // 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
) )
/** /**
+8
View File
@@ -47,6 +47,14 @@ include(":infrastructure:event-store:redis-event-store")
include(":infrastructure:monitoring:monitoring-client") include(":infrastructure:monitoring:monitoring-client")
include(":infrastructure:monitoring:monitoring-server") 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 // Temporär deaktivierte Fach-Module
// Members modules // Members modules
+51
View File
@@ -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
```
+17
View File
@@ -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"]
+33
View File
@@ -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")
}
}
@@ -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"))
}
}