chore: erweitere Resilience4j-Bundle um Kotlin-Support, aktualisiere PingController um Fallback-Logik, füge Fehlerhandler hinzu, verbessere PingControllerTest, synchronisiere .env und dc-infra.yaml

This commit is contained in:
2026-04-19 21:50:27 +02:00
parent 54f91c7309
commit 83adb4ae07
9 changed files with 338 additions and 23 deletions
@@ -52,10 +52,12 @@ class GlobalSecurityConfig {
@Bean
fun jwtDecoder(): JwtDecoder {
// Wenn jwk-set-uri gesetzt ist, nutzen wir sie.
// Wir verzichten auf den Issuer-Check für maximale Flexibilität zwischen Docker/Host.
val jwkSetUri = System.getenv("SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI")
?: "http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs"
// 1. Suche in System-Properties (Spring injects these)
// 2. Suche in Environment Variables
// 3. Fallback auf localhost (IDE-Start) oder keycloak (Docker-Start)
val jwkSetUri = System.getProperty("spring.security.oauth2.resourceserver.jwt.jwk-set-uri")
?: System.getenv("SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI")
?: "http://localhost:8180/realms/meldestelle/protocol/openid-connect/certs"
val decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build()
val validator = DelegatingOAuth2TokenValidator<Jwt>(JwtTimestampValidator())
@@ -1,4 +1,4 @@
plugins {
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.kotlinJpa)
@@ -37,8 +37,7 @@ dependencies {
implementation(libs.bundles.database.complete)
// === Resilience ===
implementation(libs.resilience4j.spring.boot3)
implementation(libs.resilience4j.reactor)
implementation(libs.bundles.resilience)
implementation(libs.spring.boot.starter.aop)
// === Testing ===
@@ -2,6 +2,7 @@ package at.mocode.ping.infrastructure.web
import at.mocode.ping.api.*
import at.mocode.ping.application.PingUseCase
import at.mocode.ping.domain.Ping
import at.mocode.ping.infrastructure.PingProperties
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
import org.slf4j.LoggerFactory
@@ -20,7 +21,7 @@ import kotlin.uuid.ExperimentalUuidApi
*/
@RestController
@OptIn(ExperimentalUuidApi::class)
class PingController(
open class PingController(
private val pingUseCase: PingUseCase,
private val properties: PingProperties
) : PingApi {
@@ -43,10 +44,16 @@ class PingController(
override suspend fun enhancedPing(
@RequestParam(required = false, defaultValue = "false") simulate: Boolean
): EnhancedPingResponse {
logger.info("Enhanced ping requested, simulate: {}", simulate)
val start = System.nanoTime()
if (simulate && Random.nextDouble() < 0.6) {
throw RuntimeException("Simulated service failure")
if (simulate) {
if (Random.nextDouble() < 0.6) {
logger.info("Simulating service failure now...")
throw SimulatedException("Simulated service failure")
} else {
logger.info("Simulation mode ACTIVE, but this time lucky: Request passed!")
}
}
val domainPing = pingUseCase.executePing("Enhanced Ping")
@@ -61,6 +68,8 @@ class PingController(
)
}
class SimulatedException(message: String) : RuntimeException(message)
// Neue Endpunkte
@GetMapping("/ping/public")
@@ -70,7 +79,7 @@ class PingController(
}
@GetMapping("/ping/secure")
@PreAuthorize("hasRole('MELD_USER') or hasRole('MELD_ADMIN')") // Beispiel-Rollen
@PreAuthorize("hasRole('ROLE_MELD_USER') or hasRole('ROLE_MELD_ADMIN')") // Beispiel-Rollen
override suspend fun securePing(): PingResponse {
val domainPing = pingUseCase.executePing("Secure Ping")
return createResponse(domainPing, "secure-pong")
@@ -79,7 +88,7 @@ class PingController(
@GetMapping("/ping/sync")
override suspend fun syncPings(
// Changed the parameter name to 'since' to match SyncManager convention
@RequestParam(required = false, defaultValue = "0") since: Long
@RequestParam(name = "lastSyncTimestamp", required = false, defaultValue = "0") since: Long
): List<PingEvent> {
return pingUseCase.getPingsSince(since).map {
PingEvent(
@@ -91,7 +100,7 @@ class PingController(
}
// Helper
private fun createResponse(domainPing: at.mocode.ping.domain.Ping, status: String) = PingResponse(
private fun createResponse(domainPing: Ping, status: String) = PingResponse(
status = status,
timestamp = domainPing.timestamp.atOffset(ZoneOffset.UTC).format(formatter),
service = properties.serviceName
@@ -99,8 +108,8 @@ class PingController(
// Fallback
@Suppress("unused", "UNUSED_PARAMETER")
fun fallbackPing(simulate: Boolean, ex: Exception): EnhancedPingResponse {
logger.warn("Circuit breaker fallback triggered: {}", ex.message)
open fun fallbackPing(simulate: Boolean, ex: Throwable): EnhancedPingResponse {
logger.error("CIRCUIT BREAKER FALLBACK TRIGGERED! Reason: {}", ex.message, ex)
return EnhancedPingResponse(
status = "fallback",
timestamp = java.time.OffsetDateTime.now().format(formatter),
@@ -0,0 +1,32 @@
package at.mocode.ping.infrastructure.web
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.security.access.AccessDeniedException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class PingExceptionHandler {
private val log = LoggerFactory.getLogger(PingExceptionHandler::class.java)
@ExceptionHandler(AccessDeniedException::class)
fun handleAccessDenied(ex: AccessDeniedException): ProblemDetail {
log.warn("Zugriff verweigert: ${ex.message}")
return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, "Nicht berechtigt: ${ex.message}")
}
@ExceptionHandler(Exception::class)
fun handleAll(ex: Exception): ProblemDetail {
log.error("Unerwarteter Fehler: ", ex)
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.message ?: "Ein interner Fehler ist aufgetreten")
}
@ExceptionHandler(RuntimeException::class)
fun handleRuntime(ex: RuntimeException): ProblemDetail {
log.error("Interner Fehler: ", ex)
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.message ?: "Unbekannter Fehler")
}
}
@@ -40,12 +40,17 @@ import kotlin.uuid.ExperimentalUuidApi
controllers = [PingController::class],
properties = ["spring.aop.proxy-target-class=true"]
)
@Import(
PingControllerTest.PingControllerTestConfig::class,
io.github.resilience4j.springboot3.circuitbreaker.autoconfigure.CircuitBreakerAutoConfiguration::class,
io.github.resilience4j.springboot3.circuitbreaker.autoconfigure.CircuitBreakerMetricsAutoConfiguration::class,
org.springframework.boot.autoconfigure.aop.AopAutoConfiguration::class
)
@ContextConfiguration(classes = [TestPingServiceApplication::class])
@ActiveProfiles("test")
@Import(PingControllerTest.PingControllerTestConfig::class)
@AutoConfigureMockMvc(addFilters = false)
@OptIn(ExperimentalUuidApi::class)
class PingControllerTest {
open class PingControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@@ -125,11 +130,24 @@ class PingControllerTest {
// Then
val json = objectMapper.readTree(result.response.contentAsString)
assertThat(json["status"].asText()).isEqualTo("pong")
assertThat(json["service"].asText()).isEqualTo(properties.serviceName)
verify { pingUseCase.executePing("Enhanced Ping") }
}
@Test
fun `should return fallback when simulation failure occurs`() {
// Given
val controller = PingController(pingUseCase, properties)
// When
val response = controller.fallbackPing(simulate = true, ex = PingController.SimulatedException("test"))
// Then
assertThat(response.status).isEqualTo("fallback")
assertThat(response.service).isEqualTo(properties.serviceNameFallback)
assertThat(response.circuitBreakerState).isEqualTo("OPEN")
}
@Test
fun `should return health check response with status up`() {
// When
@@ -159,7 +177,7 @@ class PingControllerTest {
)
// When
val mvcResult: MvcResult = mockMvc.perform(get("/ping/sync").param("since", timestamp.toString()))
val mvcResult: MvcResult = mockMvc.perform(get("/ping/sync").param("lastSyncTimestamp", timestamp.toString()))
.andExpect(request().asyncStarted())
.andReturn()
@@ -183,7 +201,7 @@ class PingControllerTest {
every { pingUseCase.getPingsSince(timestamp) } returns emptyList()
// When
val mvcResult: MvcResult = mockMvc.perform(get("/ping/sync").param("since", timestamp.toString()))
val mvcResult: MvcResult = mockMvc.perform(get("/ping/sync").param("lastSyncTimestamp", timestamp.toString()))
.andExpect(request().asyncStarted())
.andReturn()