chore(cleanup): remove unused FallbackController and outdated GatewayDependencies.txt

- Deleted `FallbackController` as it is no longer required, with alternatives already in place.
- Removed `GatewayDependencies.txt` to clean up outdated and redundant dependency tracking files.
This commit is contained in:
2026-01-04 22:47:11 +01:00
parent a2faf2166a
commit 6e58af1b5b
26 changed files with 495 additions and 1078 deletions
@@ -25,7 +25,7 @@ dependencies {
// === GATEWAY-SPEZIFISCHE ABHÄNGIGKEITEN ===
// Die WebFlux-Abhängigkeit wird jetzt korrekt durch das BOM bereitgestellt.
// implementation(libs.spring.boot.starter.webflux)
implementation(libs.spring.boot.starter.webflux)
// Kern-Gateway inkl. Security, Actuator, CircuitBreaker, Discovery
implementation(libs.bundles.gateway.core)
@@ -34,6 +34,12 @@ dependencies {
// Redis-Unterstützung für verteiltes Rate Limiting (RequestRateLimiter)
implementation(libs.bundles.gateway.redis)
// === Tracing Dependencies (Micrometer Tracing) ===
// Ermöglicht verteiltes Tracing über Thread-Grenzen hinweg (ersetzt manuellen MDC-Filter)
implementation(libs.micrometer.tracing.bridge.brave)
// Optional: Zipkin Reporter, falls du Traces an Zipkin senden willst (bereits im monitoringClient enthalten, aber hier explizit schadet nicht)
// implementation(libs.zipkin.reporter.brave)
// === Test Dependencies ===
testImplementation(projects.platform.platformTesting)
testImplementation(libs.bundles.testing.jvm)
@@ -1,6 +1,7 @@
package at.mocode.infrastructure.gateway
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.getBean
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.core.env.Environment
@@ -11,7 +12,7 @@ class GatewayApplication
fun main(args: Array<String>) {
val context = runApplication<GatewayApplication>(*args)
val logger = LoggerFactory.getLogger(GatewayApplication::class.java)
val env = context.getBean(Environment::class.java)
val env = context.getBean<Environment>()
val port = env.getProperty("server.port") ?: "8081"
logger.info("""
@@ -1,5 +1,6 @@
package at.mocode.infrastructure.gateway.config
import io.micrometer.tracing.Tracer
import org.slf4j.LoggerFactory
import org.springframework.cloud.gateway.filter.GatewayFilterChain
import org.springframework.cloud.gateway.filter.GlobalFilter
@@ -7,17 +8,21 @@ import org.springframework.core.Ordered
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
import java.util.*
/**
* Gateway-Konfiguration für erweiterte Funktionalitäten wie Logging, Rate Limiting und Security.
*/
/**
* Global Filter für Correlations-IDs zur Request-Verfolgung.
* Globaler Filter, der sicherstellt, dass die Trace-ID (von Micrometer Tracing)
* auch als "X-Correlation-ID" im Response-Header zurückgegeben wird.
*
* Hinweis: Micrometer Tracing kümmert sich bereits automatisch um die Propagation
* der Trace-ID (b3 oder w3c) an nachgelagerte Services. Dieser Filter dient nur
* der Bequemlichkeit für Clients (z. B. Frontend), um die ID einfach auslesen zu können.
*/
@Component
class CorrelationIdFilter : GlobalFilter, Ordered {
class CorrelationIdFilter(private val tracer: Tracer) : GlobalFilter, Ordered {
private val logger = LoggerFactory.getLogger(CorrelationIdFilter::class.java)
@@ -26,26 +31,21 @@ class CorrelationIdFilter : GlobalFilter, Ordered {
}
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
val request = exchange.request
val correlationId = request.headers.getFirst(CORRELATION_ID_HEADER)
?: UUID.randomUUID().toString()
// Die aktuelle Trace-ID aus dem Micrometer Tracer holen
val currentSpan = tracer.currentSpan()
val traceId = currentSpan?.context()?.traceId()
val mutatedRequest = request.mutate()
.header(CORRELATION_ID_HEADER, correlationId)
.build()
if (traceId != null) {
// Trace-ID als Response-Header hinzufügen
exchange.response.headers.add(CORRELATION_ID_HEADER, traceId)
}
val mutatedExchange = exchange.mutate()
.request(mutatedRequest)
.build()
// Response-Header nach der Verarbeitung hinzufügen
mutatedExchange.response.headers.add(CORRELATION_ID_HEADER, correlationId)
return chain.filter(mutatedExchange)
return chain.filter(exchange)
.doOnError { ex ->
logger.error("Error in CorrelationIdFilter for request {}: {}", request.uri, ex.message)
logger.error("Error processing request {}: {}", exchange.request.uri, ex.message)
}
}
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE
// Niedrige Priorität, damit Tracing-Kontext bereits initialisiert ist
override fun getOrder(): Int = Ordered.LOWEST_PRECEDENCE
}
@@ -0,0 +1,51 @@
package at.mocode.infrastructure.gateway.config
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import reactor.core.publisher.Mono
import java.security.Principal
@Configuration
class RateLimitConfig {
/**
* Standard KeyResolver: IP-basiert.
* Nutzt X-Forwarded-For (für Proxies/LoadBalancer), wenn vorhanden, sonst die Remote-Adresse.
* Wird verwendet, wenn kein anderer KeyResolver explizit angefordert wird oder aktiv ist.
*/
@Bean
@Primary
fun ipAddressKeyResolver(): KeyResolver = KeyResolver { exchange ->
val forwardedFor = exchange.request.headers.getFirst("X-Forwarded-For")
?.split(',')?.firstOrNull()?.trim()
val ip = forwardedFor
?: exchange.request.remoteAddress?.address?.hostAddress
?: "unknown"
Mono.just(ip)
}
/**
* Erweiterter KeyResolver: Principal-basiert (User-ID).
* Versucht, den authentifizierten User (Principal) zu nutzen.
* Fallback auf IP-Adresse, falls der User nicht eingeloggt ist.
*
* Aktivierung über Property: gateway.ratelimit.principal-key-resolver.enabled=true
*/
@Bean
@ConditionalOnProperty(prefix = "gateway.ratelimit.principal-key-resolver", name = ["enabled"], havingValue = "true", matchIfMissing = false)
fun principalNameKeyResolver(): KeyResolver = KeyResolver { exchange ->
exchange.getPrincipal<Principal>()
.map { it.name }
.switchIfEmpty(
Mono.just(
exchange.request.headers.getFirst("X-Forwarded-For")?.split(",")?.first()?.trim()
?: exchange.request.headers.getFirst("X-Real-IP")
?: exchange.request.remoteAddress?.address?.hostAddress
?: "unknown"
)
)
}
}
@@ -1,31 +0,0 @@
package at.mocode.infrastructure.gateway.config
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import reactor.core.publisher.Mono
import java.security.Principal
@Configuration
class RateLimiterConfig {
/**
* KeyResolver basierend auf authentifiziertem Principal; Fallback auf Client-IP.
* Funktioniert out-of-the-box mit Keycloak (Resource Server), sofern Security aktiv ist.
*/
@Bean
@ConditionalOnProperty(prefix = "gateway.ratelimit.principal-key-resolver", name = ["enabled"], havingValue = "true", matchIfMissing = false)
fun principalNameKeyResolver(): KeyResolver = KeyResolver { exchange ->
exchange.getPrincipal<Principal>()
.map { it.name }
.switchIfEmpty(
Mono.just(
exchange.request.headers.getFirst("X-Forwarded-For")?.split(",")?.first()?.trim()
?: exchange.request.headers.getFirst("X-Real-IP")
?: exchange.request.remoteAddress?.address?.hostAddress
?: "unknown"
)
)
}
}
@@ -1,26 +0,0 @@
package at.mocode.infrastructure.gateway.config
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver
import org.springframework.context.annotation.Primary
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import reactor.core.publisher.Mono
@Configuration
class RateLimitingConfig {
/**
* Einfache IP-basierte KeyResolver-Implementierung für das RequestRateLimiter-Filter.
* Nutzt X-Forwarded-For, wenn vorhanden, sonst die Remote-Adresse.
*/
@Bean
@Primary
fun ipAddressKeyResolver(): KeyResolver = KeyResolver { exchange ->
val forwardedFor = exchange.request.headers.getFirst("X-Forwarded-For")
?.split(',')?.firstOrNull()?.trim()
val ip = forwardedFor
?: exchange.request.remoteAddress?.address?.hostAddress
?: "unknown"
Mono.just(ip)
}
}
@@ -1,27 +0,0 @@
package at.mocode.infrastructure.gateway.fallback
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.time.Instant
/**
* Alternative FallbackController (deaktiviert per Default), nur aktivierbar über
* property `gateway.customFallback.enabled=true`. Standardmäßig existiert bereits
* ein FallbackController unter `...gateway.controller.FallbackController`.
*/
@RestController
@ConditionalOnProperty(prefix = "gateway.customFallback", name = ["enabled"], havingValue = "true", matchIfMissing = false)
class FallbackController {
@RequestMapping("/fallback/ping")
fun pingFallback(): ResponseEntity<Map<String, Any>> =
ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(
mapOf(
"message" to "Ping service unavailable",
"timestamp" to Instant.now().toString()
)
)
}
@@ -1,7 +1,8 @@
package at.mocode.infrastructure.gateway.health
import org.springframework.boot.actuate.health.Health
import org.springframework.boot.actuate.health.ReactiveHealthIndicator
import org.springframework.boot.health.contributor.Health
import org.springframework.boot.health.contributor.ReactiveHealthIndicator
import org.springframework.cloud.client.ServiceInstance
import org.springframework.cloud.client.discovery.DiscoveryClient
import org.springframework.core.env.Environment
import org.springframework.stereotype.Component
@@ -9,7 +10,9 @@ import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.WebClientResponseException
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import java.time.Duration
import java.util.concurrent.TimeoutException
/**
* Gateway Health Indicator zur Überwachung der Downstream Services.
@@ -40,46 +43,47 @@ class GatewayHealthIndicator(
)
private val HEALTH_CHECK_TIMEOUT = Duration.ofSeconds(5)
private const val PARALLELISM = 8
}
// KORREKTUR für Spring Boot 4: Die `health()`-Methode und ihr Rückgabetyp `Mono<Health>`
// erlauben keine Null-Werte mehr. Die Fragezeichen (?) wurden entfernt.
override fun health(): Mono<Health> {
val builder = Health.up()
val details = mutableMapOf<String, Any>()
return Mono.fromCallable { discoveryClient.services }
.flatMapMany { services ->
.subscribeOn(Schedulers.boundedElastic())
.flatMapMany { services: List<String> ->
details["totalServices"] = services.size
Flux.fromIterable(services)
}
.flatMap({ serviceName ->
val instances = discoveryClient.getInstances(serviceName)
val instanceDetails = mapOf(
"instanceCount" to instances.size,
"instances" to instances.map { "${it.host}:${it.port}" }
)
// Für Health-Check nur auf definierte Services gehen
val checkMono = when {
CRITICAL_SERVICES.contains(serviceName) || OPTIONAL_SERVICES.contains(serviceName) ->
checkServiceHealthReactive(serviceName)
else -> Mono.just("SKIPPED")
}
checkMono
.map { status -> Triple(serviceName, status, instanceDetails) }
}, 8) // begrenze Parallelität
.flatMap({ serviceName: String ->
Mono.fromCallable { discoveryClient.getInstances(serviceName) }
.subscribeOn(Schedulers.boundedElastic())
.flatMap { instances: List<ServiceInstance> ->
val instanceDetails = mapOf(
"instanceCount" to instances.size,
"instances" to instances.map { "${it.host}:${it.port}" }
)
val checkMono: Mono<String> = when {
CRITICAL_SERVICES.contains(serviceName) || OPTIONAL_SERVICES.contains(serviceName) ->
checkServiceHealthReactive(serviceName, instances)
else -> Mono.just("SKIPPED")
}
checkMono.map { status -> Triple(serviceName, status, instanceDetails) }
}
}, PARALLELISM)
.collectList()
.map { results ->
.flatMap { results: List<Triple<String, String, Map<String, Any>>> ->
val discoveredServices = mutableMapOf<String, Any>()
val criticalServiceStatus = mutableMapOf<String, String>()
val optionalServiceStatus = mutableMapOf<String, String>()
results.forEach { (serviceName, status, instanceDetails) ->
discoveredServices[serviceName] = instanceDetails
if (CRITICAL_SERVICES.contains(serviceName)) {
criticalServiceStatus[serviceName] = status
} else if (OPTIONAL_SERVICES.contains(serviceName)) {
optionalServiceStatus[serviceName] = status
when {
CRITICAL_SERVICES.contains(serviceName) -> criticalServiceStatus[serviceName] = status
OPTIONAL_SERVICES.contains(serviceName) -> optionalServiceStatus[serviceName] = status
}
}
@@ -99,48 +103,48 @@ class GatewayHealthIndicator(
details["status"] = "UP"
details["reason"] = when {
isTestEnvironment -> "Gesundheitsprüfung erfolgreich (Testumgebung)"
isDevEnvironment -> "Gesundheitsprüfung erfolgreich (Entwicklungsumgebung - nicht alle Services erforderlich)"
else -> "Alle kritischen Services sind verfügbar oder optional"
isDevEnvironment -> "Gesundheitsprüfung erfolgreich (Entwicklungsumgebung)"
else -> "Alle kritischen Services sind verfügbar"
}
}
builder.withDetails(details).build()
Mono.just(builder.withDetails(details).build())
}
.onErrorResume { ex ->
Mono.just(
Health.down(ex)
.withDetail("status", "DOWN")
.withDetail("reason", "Fehler beim Prüfen der nachgelagerten Services: ${ex.message}")
.withDetail("reason", "Fehler bei der Service-Prüfung: ${ex.message}")
.build()
)
}
}
private fun checkServiceHealthReactive(serviceName: String): Mono<String> {
return Mono.fromCallable { discoveryClient.getInstances(serviceName) }
.flatMap { instances ->
if (instances.isEmpty()) {
Mono.just("NO_INSTANCES")
} else {
val instance = instances.first()
val healthUrl = "http://${instance.host}:${instance.port}/actuator/health"
webClient.get()
.uri(healthUrl)
.retrieve()
.bodyToMono(Map::class.java)
.timeout(HEALTH_CHECK_TIMEOUT)
.map { it["status"]?.toString() ?: "UNKNOWN" }
.map { status -> if (status == "UP") "UP" else "DOWN" }
.onErrorResume { ex ->
when (ex) {
is WebClientResponseException -> when (ex.statusCode.value()) {
404 -> Mono.just("NO_HEALTH_ENDPOINT")
503 -> Mono.just("DOWN")
else -> Mono.just("ERROR")
}
else -> Mono.just("ERROR")
}
}
private fun checkServiceHealthReactive(serviceName: String, instances: List<ServiceInstance>): Mono<String> {
if (instances.isEmpty()) {
return Mono.just("NO_INSTANCES")
}
// Wir prüfen exemplarisch die erste Instanz
val instance = instances.first()
val healthUrl = "http://${instance.host}:${instance.port}/actuator/health"
return webClient.get()
.uri(healthUrl)
.retrieve()
.bodyToMono(Map::class.java)
.timeout(HEALTH_CHECK_TIMEOUT)
.map { it["status"]?.toString() ?: "UNKNOWN" }
.map { status -> if (status.equals("UP", ignoreCase = true)) "UP" else "DOWN" }
.onErrorResume { ex ->
when (ex) {
is WebClientResponseException -> when (ex.statusCode.value()) {
404 -> Mono.just("NO_HEALTH_ENDPOINT")
503 -> Mono.just("DOWN")
else -> Mono.just("ERROR")
}
is TimeoutException -> Mono.just("TIMEOUT")
else -> Mono.just("ERROR")
}
}
}
@@ -16,6 +16,7 @@ import org.springframework.security.web.server.util.matcher.ServerWebExchangeMat
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.reactive.CorsConfigurationSource
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource
import reactor.core.publisher.Mono
import java.time.Duration
@Configuration
@@ -69,35 +70,53 @@ class SecurityConfig(
* Erstellt einen ReactiveJwtDecoder für die JWT-Validierung.
*
* Verwendet die JWK Set URI aus der Konfiguration, um die öffentlichen Schlüssel
* von Keycloak zu laden. Falls die URI nicht konfiguriert ist oder Keycloak
* nicht erreichbar ist, wird trotzdem ein Bean erstellt, um Startfehler zu vermeiden.
* von Keycloak zu laden.
*
* Resilience-Optimierung:
* Anstatt beim Start zu failen oder einen statischen NoOp-Decoder zu nutzen,
* verwenden wir einen delegierenden Decoder. Dieser versucht bei jedem Request,
* den echten Decoder (lazy) zu initialisieren, falls er noch nicht bereit ist.
* So kann Keycloak auch NACH dem Gateway starten.
*/
@Bean
fun reactiveJwtDecoder(
@Value($$"${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:}") jwkSetUri: String
): ReactiveJwtDecoder {
return if (jwkSetUri.isNotBlank()) {
try {
NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build()
} catch (e: Exception) {
// Log warning and return a no-op decoder to allow startup
logger.warn("Failed to configure JWT decoder with JWK Set URI: {} - {}", jwkSetUri, e.message)
logger.warn("JWT authentication will not work until Keycloak is available")
createNoOpJwtDecoder()
}
} else {
logger.info("No JWK Set URI configured, using no-op JWT decoder")
createNoOpJwtDecoder()
}
return ResilienceReactiveJwtDecoder(jwkSetUri)
}
/**
* Erstellt einen No-Op JWT Decoder für Fälle, in denen Keycloak nicht verfügbar ist.
* Dieser Decoder lehnt alle Token ab, erlaubt aber den Anwendungsstart.
* Ein Wrapper um den NimbusReactiveJwtDecoder, der Initialisierungsfehler abfängt
* und erst zur Laufzeit (lazy) versucht, die JWKs zu laden.
*/
private fun createNoOpJwtDecoder(): ReactiveJwtDecoder {
return ReactiveJwtDecoder { token ->
throw IllegalStateException("JWT validation is not available - Keycloak may not be running")
class ResilienceReactiveJwtDecoder(private val jwkSetUri: String) : ReactiveJwtDecoder {
private val logger = LoggerFactory.getLogger(ResilienceReactiveJwtDecoder::class.java)
private var delegate: ReactiveJwtDecoder? = null
override fun decode(token: String): Mono<org.springframework.security.oauth2.jwt.Jwt> {
if (delegate == null) {
synchronized(this) {
if (delegate == null) {
try {
if (jwkSetUri.isBlank()) {
throw IllegalArgumentException("JWK Set URI is missing")
}
logger.info("Attempting to initialize JWT Decoder with URI: {}", jwkSetUri)
delegate = NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build()
logger.info("JWT Decoder successfully initialized.")
} catch (e: Exception) {
logger.warn("Could not initialize JWT Decoder (Keycloak might be down): {}", e.message)
return Mono.error(IllegalStateException("Identity Provider currently unavailable. Please try again later."))
}
}
}
}
return delegate!!.decode(token)
.onErrorResume { e ->
// Falls der Decoder zwar da ist, aber z.B. Netzwerkfehler auftreten, loggen wir das
logger.debug("JWT decoding failed: {}", e.message)
Mono.error(e)
}
}
}
@@ -27,6 +27,11 @@ management:
web:
exposure:
include: health,info,prometheus
tracing:
sampling:
probability: 1.0 # 100% der Requests tracen (für Dev/Test sinnvoll, in Prod reduzieren)
propagation:
type: w3c # Standard W3C Trace Context (kompatibel mit OpenTelemetry)
# Gateway-spezifische Einstellungen
gateway: