diff --git a/build.gradle.kts b/build.gradle.kts index 5af12487..e8b5bc52 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,9 @@ import java.util.Locale plugins { alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.compose.multiplatform) apply false + alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.spring.boot) apply false alias(libs.plugins.spring.dependencyManagement) apply false } diff --git a/client/common-ui/build.gradle.kts b/client/common-ui/build.gradle.kts index 40e82848..cfa726ab 100644 --- a/client/common-ui/build.gradle.kts +++ b/client/common-ui/build.gradle.kts @@ -57,7 +57,6 @@ kotlin { val commonTest by getting { dependencies { implementation(libs.kotlin.test) - implementation(libs.kotlinx.coroutines.test) } } } diff --git a/client/desktop-app/src/main/kotlin/at/mocode/client/desktop/App.kt b/client/desktop-app/src/main/kotlin/at/mocode/client/desktop/App.kt index 3107aa50..8b59c4b0 100644 --- a/client/desktop-app/src/main/kotlin/at/mocode/client/desktop/App.kt +++ b/client/desktop-app/src/main/kotlin/at/mocode/client/desktop/App.kt @@ -15,15 +15,22 @@ import at.mocode.client.common.components.horses.PferdeListe import at.mocode.client.common.components.masterdata.StammdatenListe import at.mocode.client.web.screens.CreatePersonScreen import at.mocode.client.web.screens.PersonListScreen -import at.mocode.client.web.viewmodel.CreatePersonViewModel -import at.mocode.client.web.viewmodel.PersonListViewModel import at.mocode.core.domain.model.DatenQuelleE import at.mocode.core.domain.model.PferdeGeschlechtE import at.mocode.events.domain.model.Veranstaltung import at.mocode.horses.domain.model.DomPferd import at.mocode.masterdata.domain.model.LandDefinition -import kotlinx.datetime.Clock +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.auth.* +import io.ktor.client.plugins.auth.providers.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate +import kotlinx.serialization.json.Json /** * Main application composable for the desktop application. @@ -103,6 +110,10 @@ data class TabItem( */ @Composable fun DashboardScreen() { + val coroutineScope = rememberCoroutineScope() + var pingResult by remember { mutableStateOf(null) } + var pingLoading by remember { mutableStateOf(false) } + Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, @@ -120,6 +131,15 @@ fun DashboardScreen() { modifier = Modifier.padding(bottom = 32.dp) ) + // Display ping result if available + pingResult?.let { result -> + Text( + text = "Ping Result: $result", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + // Quick access buttons Row( modifier = Modifier.fillMaxWidth(), @@ -144,6 +164,47 @@ fun DashboardScreen() { Text("Suche") } } + + Button( + onClick = { + coroutineScope.launch { + pingLoading = true + try { + val pingClient = HttpClient(CIO) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(Auth) { + basic { + credentials { + BasicAuthCredentials(username = "admin", password = "admin") + } + } + } + } + + val response: Map = 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") + } + } } } } diff --git a/client/web-app/build.gradle.kts b/client/web-app/build.gradle.kts index 37b6c29b..9425dce5 100644 --- a/client/web-app/build.gradle.kts +++ b/client/web-app/build.gradle.kts @@ -23,6 +23,12 @@ kotlin { // Stellt die Web-spezifischen (HTML) Teile von Jetpack Compose bereit. implementation(compose.html.core) + + // HTTP client for making requests to the backend + implementation(libs.kotlinx.coroutines.core) + implementation(libs.ktor.client.js) + implementation(libs.ktor.client.contentNegotiation) + implementation(libs.ktor.client.serialization.kotlinx.json) } } val jsTest by getting { diff --git a/client/web-app/src/main/kotlin/at/mocode/client/web/App.kt b/client/web-app/src/main/kotlin/at/mocode/client/web/App.kt index c5457d24..45d895bd 100644 --- a/client/web-app/src/main/kotlin/at/mocode/client/web/App.kt +++ b/client/web-app/src/main/kotlin/at/mocode/client/web/App.kt @@ -1,36 +1,173 @@ package at.mocode.client.web -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import at.mocode.client.common.BaseApp +import androidx.compose.runtime.* +import org.jetbrains.compose.web.dom.* +import org.jetbrains.compose.web.css.* +import kotlinx.coroutines.launch +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class PingResponse(val status: String) -@OptIn(ExperimentalMaterial3Api::class) @Composable fun App() { - BaseApp { - Scaffold( - topBar = { - TopAppBar( - title = { Text("Meldestelle - Reitersport Management") } - ) + var responseStatus by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + val httpClient = remember { + HttpClient { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) } - ) { paddingValues -> - Column( - modifier = Modifier.padding(paddingValues).fillMaxSize(), - verticalArrangement = Arrangement.Center - ) { - // Placeholder content - Text("Welcome to Meldestelle - Reitersport Management") - Text("This is a desktop application for managing equestrian events") + } + } + + Div({ + style { + fontFamily("Arial, sans-serif") + padding(20.px) + maxWidth(800.px) + margin("0 auto") + } + }) { + H1({ + style { + color(Color.darkblue) + textAlign("center") + marginBottom(30.px) + } + }) { + Text("Meldestelle - Reitersport Management") + } + + Div({ + style { + textAlign("center") + marginBottom(20.px) + } + }) { + P { Text("Welcome to the Meldestelle Web Application") } + P { Text("Click the button below to test the backend connection") } + } + + Div({ + style { + textAlign("center") + marginBottom(20.px) + } + }) { + Button({ + style { + backgroundColor(Color.lightblue) + color(Color.white) + border(0.px) + padding(10.px, 20.px) + fontSize(16.px) + cursor("pointer") + borderRadius(5.px) + } + onClick { + scope.launch { + try { + isLoading = true + errorMessage = null + responseStatus = null + + // Try different potential gateway URLs with correct routing + val gatewayUrls = listOf( + "http://localhost:8080/api/ping/ping", // Correct gateway path + "http://localhost:8080/ping", // Direct service call (fallback) + "http://localhost:8081/api/ping/ping" // Alternative gateway port + ) + + var success = false + for (url in gatewayUrls) { + try { + val response: HttpResponse = httpClient.get(url) + val responseText = response.bodyAsText() + + // Try to parse as JSON first + try { + val pingResponse = Json.decodeFromString(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") } + } } } } diff --git a/client/web-app/src/main/kotlin/at/mocode/client/web/main.kt b/client/web-app/src/main/kotlin/at/mocode/client/web/main.kt index 5cd8c27e..9be655f1 100644 --- a/client/web-app/src/main/kotlin/at/mocode/client/web/main.kt +++ b/client/web-app/src/main/kotlin/at/mocode/client/web/main.kt @@ -1,14 +1,10 @@ package at.mocode.client.web import androidx.compose.runtime.Composable -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application +import org.jetbrains.compose.web.renderComposable -fun main() = application { - Window( - title = "Meldestelle - Reitersport Management", - onCloseRequest = ::exitApplication - ) { +fun main() { + renderComposable(rootElementId = "root") { App() } } diff --git a/client/web-app/src/main/resources/MeldestelleWebApp.js b/client/web-app/src/main/resources/MeldestelleWebApp.js new file mode 100644 index 00000000..e69de29b diff --git a/client/web-app/src/main/resources/index.html b/client/web-app/src/main/resources/index.html new file mode 100644 index 00000000..fc0c71e7 --- /dev/null +++ b/client/web-app/src/main/resources/index.html @@ -0,0 +1,19 @@ + + + + + + Meldestelle - Reitersport Management + + + +
+ + + diff --git a/core/core-domain/build.gradle.kts b/core/core-domain/build.gradle.kts index 1ed50016..1e5a301a 100644 --- a/core/core-domain/build.gradle.kts +++ b/core/core-domain/build.gradle.kts @@ -1,27 +1,51 @@ // Dieses Modul definiert die Kern-Domänenobjekte des Shared Kernels. // Es enthält keine Implementierungsdetails, nur reine Datenklassen und Enums. plugins { - alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.serialization) } kotlin { - compilerOptions { - freeCompilerArgs.add("-Xopt-in=kotlin.time.ExperimentalTime") + // Target platforms + jvm { + compilerOptions { + freeCompilerArgs.add("-Xopt-in=kotlin.time.ExperimentalTime") + } + } + js(IR) { + browser() + } + + sourceSets { + val commonMain by getting { + dependencies { + // Kern-Abhängigkeiten für das Domänen-Modul (common for all platforms) + api(libs.uuid) + api(libs.kotlinx.serialization.json) + api(libs.kotlinx.datetime) + } + } + + val jvmMain by getting { + dependencies { + // Stellt sicher, dass dieses Modul Zugriff auf die im zentralen Katalog + // definierten Bibliotheken hat (JVM-specific) + api(projects.platform.platformDependencies) + } + } + + val commonTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } + + val jvmTest by getting { + dependencies { + // Stellt die Test-Bibliotheken bereit (JVM-specific) + implementation(projects.platform.platformTesting) + implementation(libs.bundles.testing.jvm) + } + } } } - -dependencies { - // Stellt sicher, dass dieses Modul Zugriff auf die im zentralen Katalog - // definierten Bibliotheken hat. - api(projects.platform.platformDependencies) - - // Kern-Abhängigkeiten für das Domänen-Modul. - api(libs.uuid) - api(libs.kotlinx.serialization.json) - api(libs.kotlinx.datetime) - - // Stellt die Test-Bibliotheken bereit. - testImplementation(projects.platform.platformTesting) - testImplementation(libs.bundles.testing.jvm) -} diff --git a/core/core-domain/src/main/kotlin/at/mocode/core/domain/event/DomainEvent.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/event/DomainEvent.kt similarity index 66% rename from core/core-domain/src/main/kotlin/at/mocode/core/domain/event/DomainEvent.kt rename to core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/event/DomainEvent.kt index 421c497f..7a7a38f7 100644 --- a/core/core-domain/src/main/kotlin/at/mocode/core/domain/event/DomainEvent.kt +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/event/DomainEvent.kt @@ -1,5 +1,6 @@ package at.mocode.core.domain.event +import at.mocode.core.domain.model.* import at.mocode.core.domain.serialization.KotlinInstantSerializer import at.mocode.core.domain.serialization.UuidSerializer import com.benasher44.uuid.Uuid @@ -7,38 +8,38 @@ import com.benasher44.uuid.uuid4 import kotlin.time.Clock import kotlin.time.Instant import kotlinx.serialization.Serializable +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) /** * Basis-Interface für alle Domänen-Events im System. * Ein Domänen-Event repräsentiert etwas fachlich Bedeutsames, das passiert ist. */ interface DomainEvent { - val eventId: Uuid - val aggregateId: Uuid - val eventType: String + val eventId: EventId + val aggregateId: AggregateId + val eventType: EventType val timestamp: Instant - val version: Long - val correlationId: Uuid? - val causationId: Uuid? + val version: EventVersion + val correlationId: CorrelationId? + val causationId: CausationId? } /** * Abstrakte Basisklasse für Domänen-Events, um Boilerplate-Code zu reduzieren. */ @Serializable +@OptIn(ExperimentalTime::class) abstract class BaseDomainEvent( - @Serializable(with = UuidSerializer::class) - override val aggregateId: Uuid, - override val eventType: String, - override val version: Long, - @Serializable(with = UuidSerializer::class) - override val eventId: Uuid = uuid4(), + override val aggregateId: AggregateId, + override val eventType: EventType, + override val version: EventVersion, + override val eventId: EventId = EventId(uuid4()), @Serializable(with = KotlinInstantSerializer::class) override val timestamp: Instant = Clock.System.now(), - @Serializable(with = UuidSerializer::class) - override val correlationId: Uuid? = null, - @Serializable(with = UuidSerializer::class) - override val causationId: Uuid? = null + override val correlationId: CorrelationId? = null, + override val causationId: CausationId? = null ) : DomainEvent /** diff --git a/core/core-domain/src/main/kotlin/at/mocode/core/domain/model/BaseDto.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/BaseDto.kt similarity index 89% rename from core/core-domain/src/main/kotlin/at/mocode/core/domain/model/BaseDto.kt rename to core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/BaseDto.kt index 5af9d9ff..1a81a768 100644 --- a/core/core-domain/src/main/kotlin/at/mocode/core/domain/model/BaseDto.kt +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/BaseDto.kt @@ -5,6 +5,7 @@ import at.mocode.core.domain.serialization.UuidSerializer import com.benasher44.uuid.Uuid import kotlin.time.Clock import kotlin.time.Instant +import kotlin.time.ExperimentalTime import kotlinx.serialization.Serializable /** @@ -16,9 +17,9 @@ interface BaseDto * Base DTO for domain entities that have unique ID and audit timestamps. */ @Serializable +@OptIn(ExperimentalTime::class) abstract class EntityDto : BaseDto { - @Serializable(with = UuidSerializer::class) - abstract val id: Uuid + abstract val id: EntityId @Serializable(with = KotlinInstantSerializer::class) abstract val createdAt: Instant @@ -41,6 +42,7 @@ data class ErrorDto( * A standardized and consistent wrapper for all API responses. */ @Serializable +@OptIn(ExperimentalTime::class) data class ApiResponse( val data: T?, val success: Boolean, @@ -49,10 +51,12 @@ data class ApiResponse( val timestamp: Instant = Clock.System.now() ) { companion object { + @OptIn(ExperimentalTime::class) fun success(data: T): ApiResponse { return ApiResponse(data = data, success = true) } + @OptIn(ExperimentalTime::class) fun error( code: String, message: String, @@ -65,6 +69,7 @@ data class ApiResponse( ) } + @OptIn(ExperimentalTime::class) fun error(errors: List): ApiResponse { return ApiResponse(data = null, success = false, errors = errors) } diff --git a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/ValueTypes.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/ValueTypes.kt new file mode 100644 index 00000000..eca63643 --- /dev/null +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/ValueTypes.kt @@ -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 { + 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() +} diff --git a/core/core-domain/src/main/kotlin/at/mocode/core/domain/serialization/Serializers.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/Serializers.kt similarity index 97% rename from core/core-domain/src/main/kotlin/at/mocode/core/domain/serialization/Serializers.kt rename to core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/Serializers.kt index df434c1f..1fdb229a 100644 --- a/core/core-domain/src/main/kotlin/at/mocode/core/domain/serialization/Serializers.kt +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/Serializers.kt @@ -3,6 +3,7 @@ package at.mocode.core.domain.serialization import com.benasher44.uuid.Uuid import com.benasher44.uuid.uuidFrom import kotlin.time.Instant // KORRIGIERT: Finaler Wechsel zu kotlin.time +import kotlin.time.ExperimentalTime import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime @@ -19,6 +20,7 @@ object UuidSerializer : KSerializer { override fun deserialize(decoder: Decoder): Uuid = uuidFrom(decoder.decodeString()) } +@OptIn(ExperimentalTime::class) object KotlinInstantSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString()) diff --git a/core/core-domain/src/test/kotlin/at/mocode/core/domain/DomainEventTest.kt b/core/core-domain/src/test/kotlin/at/mocode/core/domain/DomainEventTest.kt index 711b1753..3ce198b6 100644 --- a/core/core-domain/src/test/kotlin/at/mocode/core/domain/DomainEventTest.kt +++ b/core/core-domain/src/test/kotlin/at/mocode/core/domain/DomainEventTest.kt @@ -1,6 +1,7 @@ package at.mocode.core.domain import at.mocode.core.domain.event.BaseDomainEvent +import at.mocode.core.domain.model.* import com.benasher44.uuid.Uuid import com.benasher44.uuid.uuid4 import kotlinx.serialization.Serializable @@ -22,21 +23,21 @@ class DomainEventTest { @Serializable data class TestEvent( @Transient - override val aggregateId: Uuid = uuid4(), + override val aggregateId: AggregateId = AggregateId(uuid4()), @Transient - override val version: Long = 1L, + override val version: EventVersion = EventVersion(1L), val testPayload: String = "Test" ) : BaseDomainEvent( aggregateId = aggregateId, - eventType = "TestEventOccurred", // Ein klar definierter Event-Typ + eventType = EventType("TestEventOccurred"), // Ein klar definierter Event-Typ version = version ) @Test fun `BaseDomainEvent should auto-generate eventId and timestamp upon creation`() { // Arrange - val aggregateId = uuid4() - val version = 1L + val aggregateId = AggregateId(uuid4()) + val version = EventVersion(1L) // Act val event = TestEvent(aggregateId, version) @@ -46,6 +47,6 @@ class DomainEventTest { assertNotNull(event.timestamp, "timestamp should be automatically generated and not null") assertEquals(aggregateId, event.aggregateId, "aggregateId should be set correctly") assertEquals(version, event.version, "version should be set correctly") - assertEquals("TestEventOccurred", event.eventType, "eventType should be set correctly") + assertEquals(EventType("TestEventOccurred"), event.eventType, "eventType should be set correctly") } } diff --git a/core/core-utils/build.gradle.kts b/core/core-utils/build.gradle.kts index 95503b19..6455b54e 100644 --- a/core/core-utils/build.gradle.kts +++ b/core/core-utils/build.gradle.kts @@ -1,44 +1,70 @@ // Dieses Modul stellt gemeinsame technische Hilfsfunktionen bereit, // wie z.B. Konfigurations-Management, Datenbank-Verbindungen und Service Discovery. plugins { - alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.multiplatform) } kotlin { - compilerOptions { - freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") + // Target platforms + jvm { + compilerOptions { + freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") + } + } + js(IR) { + browser() + } + + sourceSets { + val commonMain by getting { + dependencies { + // Abhängigkeit zum core-domain-Modul, um dessen Typen zu verwenden + api(projects.core.coreDomain) + + // Asynchronität (available for all platforms) - explicit version to avoid BOM issues + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + + // Utilities (multiplatform compatible) + api(libs.bignum) + } + } + + val jvmMain by getting { + dependencies { + // Abhängigkeit zum platform-Modul für zentrale Versionsverwaltung + api(projects.platform.platformDependencies) + + // Datenbank-Management (JVM-specific) + // OPTIMIERUNG: Verwendung von Bundles für Exposed und Flyway + api(libs.bundles.exposed) + api(libs.bundles.flyway) + api(libs.hikari.cp) + + // Service Discovery (JVM-specific) + // api(libs.consul.client) wird getauscht mir spring-cloud-starter-consul-discovery + api(libs.spring.cloud.starter.consul.discovery) + + // Logging (JVM-specific) + api(libs.kotlin.logging.jvm) + + // JVM-specific utilities + implementation(libs.room.common.jvm) // Für BigDecimal Serialisierung + } + } + + val commonTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } + + val jvmTest by getting { + dependencies { + // Testing (JVM-specific) + implementation(projects.platform.platformTesting) + implementation(libs.bundles.testing.jvm) + runtimeOnly(libs.postgresql.driver) + } + } } } - -dependencies { - // Abhängigkeit zum platform-Modul für zentrale Versionsverwaltung - api(projects.platform.platformDependencies) - // Abhängigkeit zum core-domain-Modul, um dessen Typen zu verwenden - api(projects.core.coreDomain) - - // Asynchronität - api(libs.kotlinx.coroutines.core) - - // Datenbank-Management - // OPTIMIERUNG: Verwendung von Bundles für Exposed und Flyway - api(libs.bundles.exposed) - api(libs.bundles.flyway) - api(libs.hikari.cp) - - // Service Discovery - // api(libs.consul.client) wird getauscht mir spring-cloud-starter-consul-discovery - api(libs.spring.cloud.starter.consul.discovery) - - // Logging - api(libs.kotlin.logging.jvm) - - // Utilities - api(libs.bignum) - implementation(libs.room.common.jvm) // Für BigDecimal Serialisierung - - // Testing - testImplementation(projects.platform.platformTesting) - testImplementation(libs.bundles.testing.jvm) - testImplementation(libs.kotlin.test) - testRuntimeOnly(libs.postgresql.driver) -} diff --git a/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/AppConfig.kt b/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/AppConfig.kt index 60c8a5f7..5dba8c29 100644 --- a/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/AppConfig.kt +++ b/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/AppConfig.kt @@ -15,43 +15,55 @@ data class AppConfig( val rateLimit: RateLimitConfig ) -data class AppInfoConfig(val name: String, val version: String, val description: String) +data class AppInfoConfig( + val name: ApplicationName, + val version: ApplicationVersion, + val description: String +) data class ServerConfig( - val port: Int, - val host: String, - val advertisedHost: String, - val workers: Int, + val port: Port, + val host: Host, + val advertisedHost: Host, + val workers: WorkerCount, val cors: CorsConfig ) { data class CorsConfig(val enabled: Boolean, val allowedOrigins: List) } data class DatabaseConfig( - val host: String, - val port: Int, - val name: String, - val jdbcUrl: String, - val username: String, - val password: String, + val host: Host, + val port: Port, + val name: DatabaseName, + val jdbcUrl: JdbcUrl, + val username: DatabaseUsername, + val password: DatabasePassword, val driverClassName: String, - val maxPoolSize: Int, - val minPoolSize: Int, + val maxPoolSize: PoolSize, + val minPoolSize: PoolSize, val autoMigrate: Boolean ) -data class ServiceDiscoveryConfig(val enabled: Boolean, val consulHost: String, val consulPort: Int) +data class ServiceDiscoveryConfig( + val enabled: Boolean, + val consulHost: Host, + val consulPort: Port +) -data class SecurityConfig(val jwt: JwtConfig, val apiKey: String?) { +data class SecurityConfig(val jwt: JwtConfig, val apiKey: ApiKey?) { data class JwtConfig( - val secret: String, - val issuer: String, - val audience: String, - val realm: String, + val secret: JwtSecret, + val issuer: JwtIssuer, + val audience: JwtAudience, + val realm: JwtRealm, val expirationInMinutes: Long ) } data class LoggingConfig(val level: String, val logRequests: Boolean, val logResponses: Boolean) -data class RateLimitConfig(val enabled: Boolean, val globalLimit: Int, val globalPeriodMinutes: Int) +data class RateLimitConfig( + val enabled: Boolean, + val globalLimit: RateLimit, + val globalPeriodMinutes: PeriodMinutes +) diff --git a/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/ConfigLoader.kt b/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/ConfigLoader.kt index dc65c034..03030426 100644 --- a/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/ConfigLoader.kt +++ b/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/ConfigLoader.kt @@ -53,8 +53,8 @@ class ConfigLoader(private val configPath: String = "config") { // Die Konfigurations-Erstellungslogik ist hierher verschoben private fun createAppInfoConfig(props: Properties) = AppInfoConfig( - name = props.getProperty("app.name", "Meldestelle"), - version = props.getProperty("app.version", "1.0.0"), + name = ApplicationName(props.getProperty("app.name", "Meldestelle")), + version = ApplicationVersion(props.getProperty("app.version", "1.0.0")), description = props.getProperty("app.description", "Pferdesport Meldestelle System") ) @@ -65,10 +65,10 @@ class ConfigLoader(private val configPath: String = "config") { "127.0.0.1" } return ServerConfig( - port = props.getIntProperty("server.port", "API_PORT", 8081), - host = props.getStringProperty("server.host", "API_HOST", "0.0.0.0"), - advertisedHost = props.getStringProperty("server.advertisedHost", "API_HOST_ADVERTISED", defaultHost), - workers = props.getIntProperty("server.workers", "API_WORKERS", Runtime.getRuntime().availableProcessors()), + port = Port(props.getIntProperty("server.port", "API_PORT", 8081)), + host = Host(props.getStringProperty("server.host", "API_HOST", "0.0.0.0")), + advertisedHost = Host(props.getStringProperty("server.advertisedHost", "API_HOST_ADVERTISED", defaultHost)), + workers = WorkerCount(props.getIntProperty("server.workers", "API_WORKERS", Runtime.getRuntime().availableProcessors())), cors = ServerConfig.CorsConfig( enabled = props.getBooleanProperty("server.cors.enabled", "API_CORS_ENABLED", true), allowedOrigins = props.getProperty("server.cors.allowedOrigins")?.split(",")?.map { it.trim() } @@ -82,15 +82,15 @@ class ConfigLoader(private val configPath: String = "config") { val port = props.getIntProperty("database.port", "DB_PORT", 5432) val name = props.getStringProperty("database.name", "DB_NAME", "meldestelle_db") return DatabaseConfig( - host = host, - port = port, - name = name, - jdbcUrl = "jdbc:postgresql://$host:$port/$name", - username = props.getStringProperty("database.username", "DB_USER", "meldestelle_user"), - password = props.getStringProperty("database.password", "DB_PASSWORD", "secure_password_change_me"), + host = Host(host), + port = Port(port), + name = DatabaseName(name), + jdbcUrl = JdbcUrl("jdbc:postgresql://$host:$port/$name"), + username = DatabaseUsername(props.getStringProperty("database.username", "DB_USER", "meldestelle_user")), + password = DatabasePassword(props.getStringProperty("database.password", "DB_PASSWORD", "secure_password_change_me")), driverClassName = "org.postgresql.Driver", - maxPoolSize = props.getIntProperty("database.maxPoolSize", "DB_MAX_POOL_SIZE", 10), - minPoolSize = props.getIntProperty("database.minPoolSize", "DB_MIN_POOL_SIZE", 5), + maxPoolSize = PoolSize(props.getIntProperty("database.maxPoolSize", "DB_MAX_POOL_SIZE", 10)), + minPoolSize = PoolSize(props.getIntProperty("database.minPoolSize", "DB_MIN_POOL_SIZE", 5)), autoMigrate = props.getBooleanProperty("database.autoMigrate", "DB_AUTO_MIGRATE", true) ) } @@ -99,27 +99,27 @@ class ConfigLoader(private val configPath: String = "config") { // analog zu den 'fromProperties' Methoden aus der alten AppConfig. private fun createServiceDiscoveryConfig(props: Properties) = ServiceDiscoveryConfig( enabled = props.getBooleanProperty("service-discovery.enabled", "CONSUL_ENABLED", true), - consulHost = props.getStringProperty("service-discovery.consul.host", "CONSUL_HOST", "consul"), - consulPort = props.getIntProperty("service-discovery.consul.port", "CONSUL_PORT", 8500) + consulHost = Host(props.getStringProperty("service-discovery.consul.host", "CONSUL_HOST", "consul")), + consulPort = Port(props.getIntProperty("service-discovery.consul.port", "CONSUL_PORT", 8500)) ) private fun createSecurityConfig(props: Properties) = SecurityConfig( jwt = SecurityConfig.JwtConfig( - secret = props.getStringProperty( + secret = JwtSecret(props.getStringProperty( "security.jwt.secret", "JWT_SECRET", "default-secret-please-change-in-production" - ), - issuer = props.getStringProperty("security.jwt.issuer", "JWT_ISSUER", "meldestelle-api"), - audience = props.getStringProperty("security.jwt.audience", "JWT_AUDIENCE", "meldestelle-clients"), - realm = props.getStringProperty("security.jwt.realm", "JWT_REALM", "meldestelle"), + )), + issuer = JwtIssuer(props.getStringProperty("security.jwt.issuer", "JWT_ISSUER", "meldestelle-api")), + audience = JwtAudience(props.getStringProperty("security.jwt.audience", "JWT_AUDIENCE", "meldestelle-clients")), + realm = JwtRealm(props.getStringProperty("security.jwt.realm", "JWT_REALM", "meldestelle")), expirationInMinutes = props.getLongProperty( "security.jwt.expirationInMinutes", "JWT_EXPIRATION_MINUTES", 60 * 24 ) ), - apiKey = props.getStringProperty("security.apiKey", "API_KEY", "").ifEmpty { null } + apiKey = props.getStringProperty("security.apiKey", "API_KEY", "").ifEmpty { null }?.let { ApiKey(it) } ) private fun createLoggingConfig(props: Properties, env: AppEnvironment) = LoggingConfig( @@ -130,7 +130,7 @@ class ConfigLoader(private val configPath: String = "config") { private fun createRateLimitConfig(props: Properties) = RateLimitConfig( enabled = props.getBooleanProperty("ratelimit.enabled", "RATE_LIMIT_ENABLED", true), - globalLimit = props.getIntProperty("ratelimit.global.limit", "RATE_LIMIT_GLOBAL_LIMIT", 100), - globalPeriodMinutes = props.getIntProperty("ratelimit.global.periodMinutes", "RATE_LIMIT_GLOBAL_PERIOD", 1) + globalLimit = RateLimit(props.getIntProperty("ratelimit.global.limit", "RATE_LIMIT_GLOBAL_LIMIT", 100)), + globalPeriodMinutes = PeriodMinutes(props.getIntProperty("ratelimit.global.periodMinutes", "RATE_LIMIT_GLOBAL_PERIOD", 1)) ) } diff --git a/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/ConfigValueTypes.kt b/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/ConfigValueTypes.kt new file mode 100644 index 00000000..6b82c1c8 --- /dev/null +++ b/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/ConfigValueTypes.kt @@ -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() +} diff --git a/core/core-utils/src/main/kotlin/at/mocode/core/utils/database/DatabaseFactory.kt b/core/core-utils/src/main/kotlin/at/mocode/core/utils/database/DatabaseFactory.kt index e012a4d2..ad4c8512 100644 --- a/core/core-utils/src/main/kotlin/at/mocode/core/utils/database/DatabaseFactory.kt +++ b/core/core-utils/src/main/kotlin/at/mocode/core/utils/database/DatabaseFactory.kt @@ -51,11 +51,11 @@ class DatabaseFactory(private val config: DatabaseConfig) { private fun createHikariConfig(): HikariConfig { return HikariConfig().apply { driverClassName = config.driverClassName - jdbcUrl = config.jdbcUrl - username = config.username - password = config.password - maximumPoolSize = config.maxPoolSize - minimumIdle = config.minPoolSize + jdbcUrl = config.jdbcUrl.value + username = config.username.value + password = config.password.getValue() // Use getValue() for password to access actual value + maximumPoolSize = config.maxPoolSize.value + minimumIdle = config.minPoolSize.value isAutoCommit = false transactionIsolation = "TRANSACTION_READ_COMMITTED" validationTimeout = 5000 diff --git a/core/core-utils/src/test/kotlin/at/mocode/core/utils/config/ConfigLoaderTest.kt b/core/core-utils/src/test/kotlin/at/mocode/core/utils/config/ConfigLoaderTest.kt index 347648ba..6020f44e 100644 --- a/core/core-utils/src/test/kotlin/at/mocode/core/utils/config/ConfigLoaderTest.kt +++ b/core/core-utils/src/test/kotlin/at/mocode/core/utils/config/ConfigLoaderTest.kt @@ -31,8 +31,8 @@ class ConfigLoaderTest { val config = configLoader.load(AppEnvironment.DEVELOPMENT) // Assert - assertEquals("Meldestelle", config.appInfo.name) - assertEquals(8081, config.server.port) // Standard-Port + assertEquals("Meldestelle", config.appInfo.name.value) + assertEquals(8081, config.server.port.value) // Standard-Port } @Test @@ -53,8 +53,8 @@ class ConfigLoaderTest { val config = configLoader.load(AppEnvironment.DEVELOPMENT) // Assert - assertEquals("TestApp", config.appInfo.name) - assertEquals(9999, config.server.port) + assertEquals("TestApp", config.appInfo.name.value) + assertEquals(9999, config.server.port.value) } @Test @@ -83,8 +83,8 @@ class ConfigLoaderTest { // Assert assertEquals(AppEnvironment.TEST, config.environment, "Environment should be TEST") - assertEquals("TestEnvApp", config.appInfo.name, "app.name should be overridden") - assertEquals(9000, config.server.port, "server.port should be overridden") - assertEquals("base-db-host", config.database.host, "database.host should come from the base file") + assertEquals("TestEnvApp", config.appInfo.name.value, "app.name should be overridden") + assertEquals(9000, config.server.port.value, "server.port should be overridden") + assertEquals("base-db-host", config.database.host.value, "database.host should come from the base file") } } diff --git a/core/core-utils/src/test/kotlin/at/mocode/core/utils/database/DatabaseFactoryTest.kt b/core/core-utils/src/test/kotlin/at/mocode/core/utils/database/DatabaseFactoryTest.kt index 0fbde1f2..bb56f040 100644 --- a/core/core-utils/src/test/kotlin/at/mocode/core/utils/database/DatabaseFactoryTest.kt +++ b/core/core-utils/src/test/kotlin/at/mocode/core/utils/database/DatabaseFactoryTest.kt @@ -1,6 +1,6 @@ package at.mocode.core.utils.database -import at.mocode.core.utils.config.DatabaseConfig +import at.mocode.core.utils.config.* import kotlinx.coroutines.runBlocking import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.Table @@ -23,7 +23,7 @@ class DatabaseFactoryTest { companion object { @Container val postgresContainer = PostgreSQLContainer("postgres:16-alpine").apply { - withDatabaseName("test-db") + withDatabaseName("testdb") withUsername("test-user") withPassword("test-password") } @@ -37,15 +37,15 @@ class DatabaseFactoryTest { fun setup() { // Erstelle eine DB-Konfiguration mit den dynamischen Daten des gestarteten Containers dbConfig = DatabaseConfig( - host = postgresContainer.host, - port = postgresContainer.firstMappedPort, - name = postgresContainer.databaseName, - jdbcUrl = postgresContainer.jdbcUrl, - username = postgresContainer.username, - password = postgresContainer.password, + host = Host(postgresContainer.host), + port = Port(postgresContainer.firstMappedPort), + name = DatabaseName(postgresContainer.databaseName), + jdbcUrl = JdbcUrl(postgresContainer.jdbcUrl), + username = DatabaseUsername(postgresContainer.username), + password = DatabasePassword(postgresContainer.password), driverClassName = "org.postgresql.Driver", - maxPoolSize = 2, - minPoolSize = 1, + maxPoolSize = PoolSize(2), + minPoolSize = PoolSize(1), autoMigrate = false // Wir steuern Migrationen im Test manuell ) // Erstelle eine neue Factory-Instanz und verbinde sie mit der Test-DB diff --git a/docker-compose.yml b/docker-compose.yml index 22aa9c07..2c2a77c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -114,6 +114,66 @@ services: retries: 3 start_period: 10s + consul: + image: hashicorp/consul:1.15 + ports: + - "8500:8500" + - "8600:8600/udp" + command: agent -server -ui -node=server-1 -bootstrap-expect=1 -client=0.0.0.0 + networks: + - meldestelle-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8500/v1/status/leader"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + + # API Gateway + api-gateway: + build: + context: . + dockerfile: infrastructure/gateway/Dockerfile + ports: + - "8080:8080" + depends_on: + consul: + condition: service_healthy + environment: + - SPRING_PROFILES_ACTIVE=docker + - SPRING_CLOUD_CONSUL_HOST=consul + - SPRING_CLOUD_CONSUL_PORT=8500 + networks: + - meldestelle-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s + + # Ping Service for testing + ping-service: + build: + context: . + dockerfile: temp/ping-service/Dockerfile + depends_on: + consul: + condition: service_healthy + environment: + - SPRING_PROFILES_ACTIVE=docker + - SPRING_CLOUD_CONSUL_HOST=consul + - SPRING_CLOUD_CONSUL_PORT=8500 + - SPRING_APPLICATION_NAME=ping-service + networks: + - meldestelle-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/ping"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 20s + # Optional monitoring services prometheus: image: prom/prometheus:latest diff --git a/docs/END_TO_END_TESTING.md b/docs/END_TO_END_TESTING.md new file mode 100644 index 00000000..26498b07 --- /dev/null +++ b/docs/END_TO_END_TESTING.md @@ -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. diff --git a/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStore.kt b/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStore.kt index 65836186..f3b586f6 100644 --- a/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStore.kt +++ b/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStore.kt @@ -1,6 +1,8 @@ package at.mocode.infrastructure.eventstore.redis import at.mocode.core.domain.event.DomainEvent +import at.mocode.core.domain.model.AggregateId +import at.mocode.core.domain.model.EventVersion import at.mocode.infrastructure.eventstore.api.ConcurrencyException import at.mocode.infrastructure.eventstore.api.EventSerializer import at.mocode.infrastructure.eventstore.api.EventStore @@ -26,7 +28,7 @@ class RedisEventStore( val aggregateId = events.first().aggregateId require(events.all { it.aggregateId == aggregateId }) { "All events must belong to the same aggregate" } - require(streamId == aggregateId) { "Stream ID must match aggregate ID" } + require(streamId == aggregateId.value) { "Stream ID must match aggregate ID" } var currentVersion = getStreamVersion(streamId) @@ -59,7 +61,7 @@ class RedisEventStore( private fun appendToStreamInternal(event: DomainEvent, streamId: Uuid, currentVersion: Long): Long { val newVersion = currentVersion + 1 - require(event.version == newVersion) { "Event version ${event.version} does not match expected new version $newVersion" } + require(event.version.value == newVersion) { "Event version ${event.version} does not match expected new version $newVersion" } val streamKey = getStreamKey(streamId) val allEventsStreamKey = getAllEventsStreamKey() @@ -102,7 +104,7 @@ class RedisEventStore( } } ?: emptyList() - return events.filter { it.version >= fromVersion && (toVersion == null || it.version <= toVersion) } + return events.filter { it.version >= EventVersion(fromVersion) && (toVersion == null || it.version <= EventVersion(toVersion)) } } override fun getStreamVersion(streamId: Uuid): Long { diff --git a/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreIntegrationTest.kt b/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreIntegrationTest.kt index 4c7f8c2c..4e650cf1 100644 --- a/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreIntegrationTest.kt +++ b/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreIntegrationTest.kt @@ -2,6 +2,7 @@ package at.mocode.infrastructure.eventstore.redis import at.mocode.core.domain.event.BaseDomainEvent import at.mocode.core.domain.event.DomainEvent +import at.mocode.core.domain.model.* import at.mocode.infrastructure.eventstore.api.EventSerializer import at.mocode.infrastructure.eventstore.api.EventStore import com.benasher44.uuid.Uuid @@ -85,8 +86,8 @@ class RedisEventStoreIntegrationTest { @Test fun `event publishing and consuming with consumer groups should work`() { val aggregateId = uuid4() - val event1 = TestCreatedEvent(aggregateId = aggregateId, version = 1L, name = "Test Entity") - val event2 = TestUpdatedEvent(aggregateId = aggregateId, version = 2L, name = "Updated Test Entity") + val event1 = TestCreatedEvent(aggregateId = AggregateId(aggregateId), version = EventVersion(1L), name = "Test Entity") + val event2 = TestUpdatedEvent(aggregateId = AggregateId(aggregateId), version = EventVersion(2L), name = "Updated Test Entity") val latch = CountDownLatch(2) val receivedEvents = mutableListOf() @@ -112,34 +113,34 @@ class RedisEventStoreIntegrationTest { assertEquals(2, receivedEvents.size) - val receivedEvent1 = receivedEvents.find { it.version == 1L } as TestCreatedEvent - assertEquals(aggregateId, receivedEvent1.aggregateId) + val receivedEvent1 = receivedEvents.find { it.version == EventVersion(1L) } as TestCreatedEvent + assertEquals(AggregateId(aggregateId), receivedEvent1.aggregateId) assertEquals("Test Entity", receivedEvent1.name) - val receivedEvent2 = receivedEvents.find { it.version == 2L } as TestUpdatedEvent - assertEquals(aggregateId, receivedEvent2.aggregateId) + val receivedEvent2 = receivedEvents.find { it.version == EventVersion(2L) } as TestUpdatedEvent + assertEquals(AggregateId(aggregateId), receivedEvent2.aggregateId) assertEquals("Updated Test Entity", receivedEvent2.name) } data class TestCreatedEvent( - override val aggregateId: Uuid, - override val version: Long, + override val aggregateId: AggregateId, + override val version: EventVersion, val name: String, - override val eventType: String = "TestCreated", - override val eventId: Uuid = uuid4(), + override val eventType: EventType = EventType("TestCreated"), + override val eventId: EventId = EventId(uuid4()), override val timestamp: Instant = Clock.System.now(), - override val correlationId: Uuid? = null, - override val causationId: Uuid? = null + override val correlationId: CorrelationId? = null, + override val causationId: CausationId? = null ) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId) data class TestUpdatedEvent( - override val aggregateId: Uuid, - override val version: Long, + override val aggregateId: AggregateId, + override val version: EventVersion, val name: String, - override val eventType: String = "TestUpdated", - override val eventId: Uuid = uuid4(), + override val eventType: EventType = EventType("TestUpdated"), + override val eventId: EventId = EventId(uuid4()), override val timestamp: Instant = Clock.System.now(), - override val correlationId: Uuid? = null, - override val causationId: Uuid? = null + override val correlationId: CorrelationId? = null, + override val causationId: CausationId? = null ) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId) } diff --git a/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreTest.kt b/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreTest.kt index cff9e044..1ab4d6a7 100644 --- a/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreTest.kt +++ b/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreTest.kt @@ -1,6 +1,7 @@ package at.mocode.infrastructure.eventstore.redis import at.mocode.core.domain.event.BaseDomainEvent +import at.mocode.core.domain.model.* import at.mocode.infrastructure.eventstore.api.ConcurrencyException import at.mocode.infrastructure.eventstore.api.EventSerializer import com.benasher44.uuid.Uuid @@ -68,8 +69,8 @@ class RedisEventStoreTest { @Test fun `append and read events should work correctly for new stream`() { val aggregateId = uuid4() - val event1 = TestCreatedEvent(aggregateId, 1L, "Test Entity") - val event2 = TestUpdatedEvent(aggregateId, 2L, "Updated Test Entity") + val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity") + val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity") eventStore.appendToStream(listOf(event1, event2), aggregateId, 0) @@ -77,21 +78,21 @@ class RedisEventStoreTest { assertEquals(2, events.size) val firstEvent = events[0] as TestCreatedEvent - assertEquals(1L, firstEvent.version) + assertEquals(EventVersion(1L), firstEvent.version) assertEquals("Test Entity", firstEvent.name) val secondEvent = events[1] as TestUpdatedEvent - assertEquals(2L, secondEvent.version) + assertEquals(EventVersion(2L), secondEvent.version) assertEquals("Updated Test Entity", secondEvent.name) } @Test fun `appending with wrong expected version should throw ConcurrencyException`() { val aggregateId = uuid4() - val event1 = TestCreatedEvent(aggregateId, 1L, "Test Entity") + val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity") eventStore.appendToStream(listOf(event1), aggregateId, 0) // Stream is now at version 1 - val event2 = TestUpdatedEvent(aggregateId, 2L, "Updated Test Entity") + val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity") assertThrows { eventStore.appendToStream(listOf(event2), aggregateId, 0) } @@ -99,15 +100,15 @@ class RedisEventStoreTest { @Serializable data class TestCreatedEvent( - @Transient override val aggregateId: Uuid = uuid4(), - @Transient override val version: Long = 0, + @Transient override val aggregateId: AggregateId = AggregateId(uuid4()), + @Transient override val version: EventVersion = EventVersion(0), val name: String - ) : BaseDomainEvent(aggregateId, "TestCreated", version) + ) : BaseDomainEvent(aggregateId, EventType("TestCreated"), version) @Serializable data class TestUpdatedEvent( - @Transient override val aggregateId: Uuid = uuid4(), - @Transient override val version: Long = 0, + @Transient override val aggregateId: AggregateId = AggregateId(uuid4()), + @Transient override val version: EventVersion = EventVersion(0), val name: String - ) : BaseDomainEvent(aggregateId, "TestUpdated", version) + ) : BaseDomainEvent(aggregateId, EventType("TestUpdated"), version) } diff --git a/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisIntegrationTest.kt b/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisIntegrationTest.kt index 99fe07e0..e27593bf 100644 --- a/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisIntegrationTest.kt +++ b/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisIntegrationTest.kt @@ -2,6 +2,7 @@ package at.mocode.infrastructure.eventstore.redis import at.mocode.core.domain.event.BaseDomainEvent import at.mocode.core.domain.event.DomainEvent +import at.mocode.core.domain.model.* import at.mocode.infrastructure.eventstore.api.EventSerializer import at.mocode.infrastructure.eventstore.api.EventStore import com.benasher44.uuid.Uuid @@ -78,8 +79,8 @@ class RedisIntegrationTest { @Test fun `event publishing and consuming should be fast and reliable`() { val aggregateId = uuid4() - val event1 = TestCreatedEvent(aggregateId, 1L, "Test Entity") - val event2 = TestUpdatedEvent(aggregateId, 2L, "Updated Test Entity") + val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity") + val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity") val receivedEvents = mutableListOf() eventConsumer.registerEventHandler("TestCreated") { receivedEvents.add(it) } @@ -91,26 +92,26 @@ class RedisIntegrationTest { assertEquals(2, receivedEvents.size) - val receivedEvent1 = receivedEvents.find { it.version == 1L } as TestCreatedEvent - assertEquals(aggregateId, receivedEvent1.aggregateId) + val receivedEvent1 = receivedEvents.find { it.version == EventVersion(1L) } as TestCreatedEvent + assertEquals(AggregateId(aggregateId), receivedEvent1.aggregateId) assertEquals("Test Entity", receivedEvent1.name) - val receivedEvent2 = receivedEvents.find { it.version == 2L } as TestUpdatedEvent - assertEquals(aggregateId, receivedEvent2.aggregateId) + val receivedEvent2 = receivedEvents.find { it.version == EventVersion(2L) } as TestUpdatedEvent + assertEquals(AggregateId(aggregateId), receivedEvent2.aggregateId) assertEquals("Updated Test Entity", receivedEvent2.name) } @Serializable data class TestCreatedEvent( - @Transient override val aggregateId: Uuid = uuid4(), - @Transient override val version: Long = 0, + @Transient override val aggregateId: AggregateId = AggregateId(uuid4()), + @Transient override val version: EventVersion = EventVersion(0), val name: String - ) : BaseDomainEvent(aggregateId, "TestCreated", version) + ) : BaseDomainEvent(aggregateId, EventType("TestCreated"), version) @Serializable data class TestUpdatedEvent( - @Transient override val aggregateId: Uuid = uuid4(), - @Transient override val version: Long = 0, + @Transient override val aggregateId: AggregateId = AggregateId(uuid4()), + @Transient override val version: EventVersion = EventVersion(0), val name: String - ) : BaseDomainEvent(aggregateId, "TestUpdated", version) + ) : BaseDomainEvent(aggregateId, EventType("TestUpdated"), version) } diff --git a/infrastructure/gateway/Dockerfile b/infrastructure/gateway/Dockerfile new file mode 100644 index 00000000..b4741965 --- /dev/null +++ b/infrastructure/gateway/Dockerfile @@ -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"] diff --git a/infrastructure/gateway/src/main/resources/application.yml b/infrastructure/gateway/src/main/resources/application.yml index 0e3fd8e9..17718136 100644 --- a/infrastructure/gateway/src/main/resources/application.yml +++ b/infrastructure/gateway/src/main/resources/application.yml @@ -6,7 +6,18 @@ server: spring: application: name: api-gateway + security: + user: + name: admin + password: admin cloud: + consul: + host: localhost + port: 8500 + discovery: + register: true + health-check-path: /actuator/health + health-check-interval: 10s gateway: # HTTP Client-Timeouts für stabile Upstream-Verbindungen httpclient: @@ -22,9 +33,17 @@ spring: # Antwort-Header bereinigen (verhindert doppelte CORS-Header) default-filters: - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin - # Aktiviert die automatische Routen-Erstellung basierend auf Consul - discovery: - locator: - enabled: true - # Macht Routen-Namen klein (z.B. /members-service/** statt /MEMBERS-SERVICE/**) - lower-case-service-id: true + # Route definitions with service discovery + routes: + - id: ping-service-route + uri: lb://ping-service + predicates: + - Path=/api/ping/** + filters: + - StripPrefix=1 + +management: + endpoints: + web: + exposure: + include: health,info diff --git a/infrastructure/gateway/src/test/kotlin/at/mocode/infrastructure/gateway/GatewayApplicationTests.kt b/infrastructure/gateway/src/test/kotlin/at/mocode/infrastructure/gateway/GatewayApplicationTests.kt index b9d2b79d..bb546cee 100644 --- a/infrastructure/gateway/src/test/kotlin/at/mocode/infrastructure/gateway/GatewayApplicationTests.kt +++ b/infrastructure/gateway/src/test/kotlin/at/mocode/infrastructure/gateway/GatewayApplicationTests.kt @@ -71,7 +71,9 @@ class GatewayApplicationTests { class TestRoutes { @Bean fun routeLocator(builder: RouteLocatorBuilder): RouteLocator = builder.routes() - .route("test-forward") { r -> r.path("/hello").uri("forward:/internal/hello") } + .route("test-forward") { + it.path("/hello").uri("forward:/internal/hello") + } .build() } diff --git a/infrastructure/messaging/messaging-client/src/main/kotlin/at/mocode/infrastructure/messaging/client/KafkaEventConsumer.kt b/infrastructure/messaging/messaging-client/src/main/kotlin/at/mocode/infrastructure/messaging/client/KafkaEventConsumer.kt index d44f4481..85309ce4 100644 --- a/infrastructure/messaging/messaging-client/src/main/kotlin/at/mocode/infrastructure/messaging/client/KafkaEventConsumer.kt +++ b/infrastructure/messaging/messaging-client/src/main/kotlin/at/mocode/infrastructure/messaging/client/KafkaEventConsumer.kt @@ -1,5 +1,6 @@ package at.mocode.infrastructure.messaging.client +import at.mocode.infrastructure.messaging.config.KafkaConfig import org.apache.kafka.clients.consumer.ConsumerConfig import org.slf4j.LoggerFactory import org.springframework.kafka.support.serializer.JsonDeserializer @@ -7,42 +8,109 @@ import org.springframework.stereotype.Component import reactor.core.publisher.Flux import reactor.kafka.receiver.KafkaReceiver import reactor.kafka.receiver.ReceiverOptions +import reactor.util.retry.Retry +import java.time.Duration import java.util.Collections +import java.util.concurrent.ConcurrentHashMap /** - * A reactive, non-blocking Kafka implementation of the EventConsumer interface. + * A reactive, non-blocking Kafka implementation of the EventConsumer interface + * with optimized connection pooling, security, and error handling. */ @Component class KafkaEventConsumer( - // Wir injizieren die Basis-Konfigurationseigenschaften aus messaging-config - private val consumerConfig: Map + private val kafkaConfig: KafkaConfig ) : EventConsumer { private val logger = LoggerFactory.getLogger(KafkaEventConsumer::class.java) - override fun receiveEvents(topic: String, eventType: Class): Flux { - // Für jeden Aufruf wird eine neue, spezifische Konfiguration für diesen Topic erstellt. - val receiverOptions = ReceiverOptions.create(consumerConfig) - .subscription(Collections.singleton(topic)) - .withValueDeserializer(JsonDeserializer(eventType).trustedPackages("*")) - .addAssignListener { partitions -> - logger.info("Partitions assigned for topic '{}': {}", topic, partitions) - } - .addRevokeListener { partitions -> - logger.warn("Partitions revoked for topic '{}': {}", topic, partitions) - } + // Connection pool to reuse KafkaReceiver instances per topic-eventType combination + private val receiverCache = ConcurrentHashMap>() - return KafkaReceiver.create(receiverOptions) - .receive() + override fun receiveEvents(topic: String, eventType: Class): Flux { + 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(topic, eventType) as KafkaReceiver + } as KafkaReceiver + + return receiver.receive() .doOnNext { record -> logger.debug( - "Received message from topic-partition {}-{} with offset {}", - record.topic(), record.partition(), record.offset() + "Received message from topic-partition {}-{} with offset {} for event type '{}'", + record.topic(), record.partition(), record.offset(), eventType.simpleName ) } - .map { it.value() } // Extrahiere nur die deserialisierte Nachricht - .doOnError { exception -> - logger.error("Error receiving events from topic '{}'", topic, exception) + .map { record -> + // Manual commit acknowledgment for better control + record.receiverOffset().acknowledge() + record.value() } + .doOnError { exception -> + logger.error("Error receiving events from topic '{}' for event type '{}'", + topic, eventType.simpleName, exception) + } + .retryWhen( + Retry.backoff(3, Duration.ofSeconds(1)) + .maxBackoff(Duration.ofSeconds(10)) + .doBeforeRetry { retrySignal -> + logger.warn("Retrying consumer for topic '{}', attempt: {}, error: {}", + topic, retrySignal.totalRetries() + 1, retrySignal.failure().message) + } + .onRetryExhaustedThrow { _, retrySignal -> + logger.error("Consumer retry exhausted for topic '{}' after {} attempts", + topic, retrySignal.totalRetries()) + retrySignal.failure() + } + ) + } + + /** + * Creates an optimized KafkaReceiver with secure configuration and performance tuning. + */ + private fun createOptimizedReceiver(topic: String, eventType: Class): KafkaReceiver { + // 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(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) } } diff --git a/infrastructure/messaging/messaging-client/src/main/kotlin/at/mocode/infrastructure/messaging/client/KafkaEventPublisher.kt b/infrastructure/messaging/messaging-client/src/main/kotlin/at/mocode/infrastructure/messaging/client/KafkaEventPublisher.kt index 9dc13097..08bd11b5 100644 --- a/infrastructure/messaging/messaging-client/src/main/kotlin/at/mocode/infrastructure/messaging/client/KafkaEventPublisher.kt +++ b/infrastructure/messaging/messaging-client/src/main/kotlin/at/mocode/infrastructure/messaging/client/KafkaEventPublisher.kt @@ -5,42 +5,121 @@ import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate import org.springframework.stereotype.Component import reactor.core.publisher.Flux import reactor.core.publisher.Mono +import reactor.util.retry.Retry +import java.time.Duration /** - * A reactive, non-blocking Kafka implementation of EventPublisher. + * A reactive, non-blocking Kafka implementation of EventPublisher with enhanced + * error handling, retry mechanisms, and optimized batch processing. */ @Component class KafkaEventPublisher( - // KORREKTUR: Verwendung des reaktiven Templates private val reactiveKafkaTemplate: ReactiveKafkaProducerTemplate ) : EventPublisher { private val logger = LoggerFactory.getLogger(KafkaEventPublisher::class.java) + companion object { + private const val DEFAULT_RETRY_ATTEMPTS = 3L + private const val DEFAULT_RETRY_DELAY_SECONDS = 1L + private const val DEFAULT_MAX_BACKOFF_SECONDS = 10L + private const val DEFAULT_BATCH_CONCURRENCY = 10 + } + override fun publishEvent(topic: String, key: String?, event: Any): Mono { - logger.debug("Publishing event to topic '{}' with key '{}'", topic, key) + logger.debug("Publishing event to topic '{}' with key '{}', event type: '{}'", + topic, key, event::class.simpleName) + return reactiveKafkaTemplate.send(topic, key, event) .doOnSuccess { result -> val record = result.recordMetadata() - logger.info( - "Successfully published event to topic-partition {}-{} with offset {}", - record.topic(), record.partition(), record.offset() + logger.debug( + "Successfully published event to topic-partition {}-{} with offset {} (key: '{}')", + record.topic(), record.partition(), record.offset(), key ) } .doOnError { exception -> - logger.error("Failed to publish event to topic '{}' with key '{}'", topic, key, exception) + logger.warn("Failed to publish event to topic '{}' with key '{}' - will retry if configured", + topic, key, exception) } - .then() // Wandelt das Ergebnis in ein Mono 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>): Flux { - logger.debug("Publishing {} events to topic '{}'", events.size, topic) - // Verwendet Flux.fromIterable, um eine Sequenz von Sende-Operationen zu erstellen + if (events.isEmpty()) { + logger.debug("No events to publish to topic '{}'", topic) + return Flux.empty() + } + + logger.info("Publishing {} events to topic '{}' using optimized batch processing", events.size, topic) + return Flux.fromIterable(events) - // .flatMap stellt sicher, dass die Sende-Operationen parallelisiert, - // aber dennoch reaktiv (nicht-blockierend) ausgeführt werden. - .flatMap { (key, event) -> + .index() // Add index for progress tracking + .flatMap({ indexedEventPair -> + val index = indexedEventPair.t1 + val eventPair = indexedEventPair.t2 + val (key, event) = eventPair publishEvent(topic, key, event) + .doOnSuccess { + if ((index + 1) % 100 == 0L || index == events.size.toLong() - 1) { + logger.info("Batch progress: {}/{} events published to topic '{}'", + index + 1, events.size, topic) + } + } + .onErrorContinue { error, _ -> + logger.error("Error publishing event {} in batch to topic '{}': {}", + index + 1, topic, error.message) + } + }, DEFAULT_BATCH_CONCURRENCY) // Controlled concurrency for better resource management + .doOnComplete { + logger.info("Completed publishing batch of {} events to topic '{}'", events.size, topic) + } + .doOnError { error -> + logger.error("Batch publishing to topic '{}' failed with error: {}", topic, error.message) } } + + /** + * Creates a retry specification with exponential backoff for robust error handling. + */ + private fun createRetrySpec(topic: String, key: String?): Retry = + Retry.backoff(DEFAULT_RETRY_ATTEMPTS, Duration.ofSeconds(DEFAULT_RETRY_DELAY_SECONDS)) + .maxBackoff(Duration.ofSeconds(DEFAULT_MAX_BACKOFF_SECONDS)) + .filter { exception -> + // Only retry on transient errors (not serialization errors, etc.) + isRetryableException(exception) + } + .doBeforeRetry { retrySignal -> + logger.info("Retrying publish to topic '{}' with key '{}', attempt: {}, error: {}", + topic, key, retrySignal.totalRetries() + 1, + retrySignal.failure().message?.take(100)) + } + .onRetryExhaustedThrow { _, retrySignal -> + logger.error("Retry exhausted for topic '{}' with key '{}' after {} attempts", + topic, key, retrySignal.totalRetries()) + retrySignal.failure() + } + + /** + * Determines if an exception is retryable based on its type and characteristics. + */ + private fun isRetryableException(exception: Throwable): Boolean { + return when { + exception.message?.contains("timeout", ignoreCase = true) == true -> true + exception.message?.contains("connection", ignoreCase = true) == true -> true + exception.message?.contains("network", ignoreCase = true) == true -> true + exception is java.util.concurrent.TimeoutException -> true + exception is java.net.ConnectException -> true + exception is java.io.IOException -> true + // Don't retry serialization errors or authentication failures + exception.message?.contains("serializ", ignoreCase = true) == true -> false + exception.message?.contains("auth", ignoreCase = true) == true -> false + else -> true // Default to retryable for unknown exceptions + } + } } diff --git a/infrastructure/messaging/messaging-client/src/main/kotlin/at/mocode/infrastructure/messaging/client/ReactiveKafkaConfig.kt b/infrastructure/messaging/messaging-client/src/main/kotlin/at/mocode/infrastructure/messaging/client/ReactiveKafkaConfig.kt index d0612c14..48a8d3b2 100644 --- a/infrastructure/messaging/messaging-client/src/main/kotlin/at/mocode/infrastructure/messaging/client/ReactiveKafkaConfig.kt +++ b/infrastructure/messaging/messaging-client/src/main/kotlin/at/mocode/infrastructure/messaging/client/ReactiveKafkaConfig.kt @@ -1,22 +1,57 @@ package at.mocode.infrastructure.messaging.client +import at.mocode.infrastructure.messaging.config.KafkaConfig +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration import org.springframework.kafka.core.DefaultKafkaProducerFactory import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate import reactor.kafka.sender.SenderOptions +import java.time.Duration /** - * Reactive Kafka configuration utilities for creating a ReactiveKafkaProducerTemplate. + * Spring Configuration for reactive Kafka components with optimized settings. */ -class ReactiveKafkaConfig { +@Configuration +class ReactiveKafkaConfig( + private val kafkaConfig: KafkaConfig +) { + + private val logger = LoggerFactory.getLogger(ReactiveKafkaConfig::class.java) /** - * Create a ReactiveKafkaProducerTemplate using the configuration from the given ProducerFactory. + * Creates a Spring Bean for the optimized ReactiveKafkaProducerTemplate. + * This template includes enhanced error handling, monitoring, and performance tuning. */ - fun reactiveKafkaProducerTemplate( - producerFactory: DefaultKafkaProducerFactory - ): ReactiveKafkaProducerTemplate { + @Bean + fun reactiveKafkaProducerTemplate(): ReactiveKafkaProducerTemplate { + logger.info("Creating optimized ReactiveKafkaProducerTemplate with enhanced configuration") + + val producerFactory = kafkaConfig.producerFactory() val props: Map = producerFactory.configurationProperties - val senderOptions: SenderOptions = SenderOptions.create(props) - return ReactiveKafkaProducerTemplate(senderOptions) + + val senderOptions = SenderOptions.create(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) + } } } diff --git a/infrastructure/messaging/messaging-client/src/test/kotlin/at/mocode/infrastructure/messaging/client/KafkaIntegrationTest.kt b/infrastructure/messaging/messaging-client/src/test/kotlin/at/mocode/infrastructure/messaging/client/KafkaIntegrationTest.kt index eeb2d68c..077241f6 100644 --- a/infrastructure/messaging/messaging-client/src/test/kotlin/at/mocode/infrastructure/messaging/client/KafkaIntegrationTest.kt +++ b/infrastructure/messaging/messaging-client/src/test/kotlin/at/mocode/infrastructure/messaging/client/KafkaIntegrationTest.kt @@ -38,8 +38,8 @@ class KafkaIntegrationTest { } producerFactory = kafkaConfig.producerFactory() - val reactiveKafkaConfig = ReactiveKafkaConfig() - val reactiveTemplate = reactiveKafkaConfig.reactiveKafkaProducerTemplate(producerFactory) + val reactiveKafkaConfig = ReactiveKafkaConfig(kafkaConfig) + val reactiveTemplate = reactiveKafkaConfig.reactiveKafkaProducerTemplate() kafkaEventPublisher = KafkaEventPublisher(reactiveTemplate) } @@ -54,19 +54,18 @@ class KafkaIntegrationTest { val testKey = "test-key" val testEvent = TestEvent("Test Message") - val consumerProps = mapOf( - ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to kafkaContainer.bootstrapServers, - ConsumerConfig.GROUP_ID_CONFIG to "test-group-${UUID.randomUUID()}", - ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, - ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to JsonDeserializer::class.java, - ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest", - JsonDeserializer.TRUSTED_PACKAGES to "*", - JsonDeserializer.USE_TYPE_INFO_HEADERS to false, - JsonDeserializer.VALUE_DEFAULT_TYPE to TestEvent::class.java.name - ) + // Use the same KafkaConfig for consistent and secure configuration + val testKafkaConfig = KafkaConfig().apply { + bootstrapServers = kafkaContainer.bootstrapServers + // For tests, we need to trust the test package + trustedPackages = "at.mocode.*" + } + + val consumerProps = testKafkaConfig.consumerConfigs("test-group-${UUID.randomUUID()}") val jsonValueDeserializer = JsonDeserializer(TestEvent::class.java).apply { - addTrustedPackages("*") + addTrustedPackages(testKafkaConfig.trustedPackages) + setUseTypeHeaders(false) } val receiverOptions = ReceiverOptions.create(consumerProps) .withKeyDeserializer(StringDeserializer()) diff --git a/infrastructure/messaging/messaging-config/src/main/kotlin/at/mocode/infrastructure/messaging/config/KafkaConfig.kt b/infrastructure/messaging/messaging-config/src/main/kotlin/at/mocode/infrastructure/messaging/config/KafkaConfig.kt index 62da016e..8f215db7 100644 --- a/infrastructure/messaging/messaging-config/src/main/kotlin/at/mocode/infrastructure/messaging/config/KafkaConfig.kt +++ b/infrastructure/messaging/messaging-config/src/main/kotlin/at/mocode/infrastructure/messaging/config/KafkaConfig.kt @@ -1,13 +1,16 @@ package at.mocode.infrastructure.messaging.config +import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.StringDeserializer import org.apache.kafka.common.serialization.StringSerializer import org.springframework.kafka.core.DefaultKafkaProducerFactory import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.support.serializer.JsonDeserializer import org.springframework.kafka.support.serializer.JsonSerializer /** - * Central Kafka producer configuration used across modules. + * Central Kafka configuration used across modules with optimized settings for performance and reliability. * * This class can be instantiated programmatically (as done in tests) or * registered as a Spring @Configuration with @Bean methods in an application context. @@ -20,14 +23,73 @@ class KafkaConfig { var bootstrapServers: String = "localhost:9092" /** - * Common producer properties with sensible defaults (String keys, JSON values). + * Default consumer group ID prefix. + */ + var defaultGroupIdPrefix: String = "messaging-client" + + /** + * Comma-separated list of trusted packages for JSON deserialization security. + * Default restricts to application packages only. + */ + var trustedPackages: String = "at.mocode.*" + + /** + * Optimized producer properties with performance tuning and reliability settings. */ fun producerConfigs(): Map = mapOf( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java, // Avoid adding type info headers; keeps payloads simple and interoperable. - JsonSerializer.ADD_TYPE_INFO_HEADERS to false + JsonSerializer.ADD_TYPE_INFO_HEADERS to false, + + // Performance optimizations + ProducerConfig.BATCH_SIZE_CONFIG to 32768, // 32KB batch size for better throughput + ProducerConfig.LINGER_MS_CONFIG to 5, // Wait up to 5ms to batch messages + ProducerConfig.COMPRESSION_TYPE_CONFIG to "snappy", // Fast compression + ProducerConfig.BUFFER_MEMORY_CONFIG to 67108864, // 64MB buffer memory + + // Reliability settings + ProducerConfig.ACKS_CONFIG to "all", // Wait for all replicas + ProducerConfig.RETRIES_CONFIG to 3, // Retry failed sends + ProducerConfig.RETRY_BACKOFF_MS_CONFIG to 1000, // 1 second retry backoff + ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG to 30000, // 30 second delivery timeout + ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG to 10000, // 10 second request timeout + + // Idempotence for exactly-once semantics + ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to true, + ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION to 5 + ) + + /** + * Optimized consumer properties with performance tuning and reliability settings. + */ + fun consumerConfigs(groupId: String? = null): Map = 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 ) /** diff --git a/settings.gradle.kts b/settings.gradle.kts index 24b008ae..93e8baa2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,6 +47,14 @@ include(":infrastructure:event-store:redis-event-store") include(":infrastructure:monitoring:monitoring-client") include(":infrastructure:monitoring:monitoring-server") +// Temporary modules +include(":temp:ping-service") + +// Client modules +include(":client:common-ui") +include(":client:web-app") +include(":client:desktop-app") + /* // Temporär deaktivierte Fach-Module // Members modules diff --git a/temp/README_TEMP.md b/temp/README_TEMP.md new file mode 100644 index 00000000..338bcf06 --- /dev/null +++ b/temp/README_TEMP.md @@ -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 +``` diff --git a/temp/ping-service/Dockerfile b/temp/ping-service/Dockerfile new file mode 100644 index 00000000..bfb7a8f8 --- /dev/null +++ b/temp/ping-service/Dockerfile @@ -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"] diff --git a/temp/ping-service/build.gradle.kts b/temp/ping-service/build.gradle.kts new file mode 100644 index 00000000..51244a2c --- /dev/null +++ b/temp/ping-service/build.gradle.kts @@ -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) +} diff --git a/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingController.kt b/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingController.kt new file mode 100644 index 00000000..f87028d6 --- /dev/null +++ b/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingController.kt @@ -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 { + return mapOf("status" to "pong") + } +} diff --git a/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingServiceApplication.kt b/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingServiceApplication.kt new file mode 100644 index 00000000..f074b933 --- /dev/null +++ b/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingServiceApplication.kt @@ -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) { + runApplication(*args) +} diff --git a/temp/ping-service/src/main/resources/application.yml b/temp/ping-service/src/main/resources/application.yml new file mode 100644 index 00000000..b6da7d73 --- /dev/null +++ b/temp/ping-service/src/main/resources/application.yml @@ -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 diff --git a/temp/ping-service/src/test/kotlin/at/mocode/temp/pingservice/PingControllerTest.kt b/temp/ping-service/src/test/kotlin/at/mocode/temp/pingservice/PingControllerTest.kt new file mode 100644 index 00000000..b5855f0d --- /dev/null +++ b/temp/ping-service/src/test/kotlin/at/mocode/temp/pingservice/PingControllerTest.kt @@ -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")) + } +}