Compare commits

...

2 Commits

Author SHA1 Message Date
189ebc6565 chore: entferne XML-Deklaration aus .idea/misc.xml
Some checks failed
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
2026-04-19 21:50:42 +02:00
83adb4ae07 chore: erweitere Resilience4j-Bundle um Kotlin-Support, aktualisiere PingController um Fallback-Logik, füge Fehlerhandler hinzu, verbessere PingControllerTest, synchronisiere .env und dc-infra.yaml 2026-04-19 21:50:33 +02:00
10 changed files with 338 additions and 24 deletions

246
.env Normal file
View File

@ -0,0 +1,246 @@
# ==========================================
# Meldestelle Docker Compose Environment
# Single Source of Truth (SSoT)
# ==========================================
# WARNING: This file contains secrets (passwords).
# Do NOT commit this file to version control if it contains production secrets.
# --- PROJECT ---
PROJECT_NAME=meldestelle
# --- BACKUP ---
BACKUP_DIR=/home/stefan/backups/meldestelle
BACKUP_RETENTION_DAYS=7
# Docker build versions (optional overrides)
DOCKER_VERSION=1.0.0-SNAPSHOT
DOCKER_REGISTRY=git.mo-code.at/mocode-software/meldestelle
DOCKER_BUILD_DATE=2026-03-16T12:00:00Z
DOCKER_GRADLE_VERSION=9.3.1
DOCKER_JAVA_VERSION=25
DOCKER_NODE_VERSION=24.12.0
DOCKER_NGINX_VERSION=1.28.0-alpine
# JVM Power Flags (Lokal leer lassen, da Intel/AMD Architektur)
JVM_OPTS_ARM64=
# --- POSTGRES ---
POSTGRES_IMAGE=postgres:16-alpine
POSTGRES_SHARED_BUFFERS=256MB
POSTGRES_EFFECTIVE_CACHE_SIZE=768MB
POSTGRES_USER=pg-user
POSTGRES_PASSWORD=pg-password
POSTGRES_DB=pg-meldestelle-db
POSTGRES_PORT=5432:5432
POSTGRES_DB_URL=jdbc:postgresql://postgres:5432/pg-meldestelle-db
# --- VALKEY (formerly Redis) ---
VALKEY_IMAGE=valkey/valkey:9-alpine
VALKEY_PASSWORD=valkey-password
VALKEY_PORT=6379:6379
VALKEY_SERVER_HOSTNAME=valkey
VALKEY_SERVER_PORT=6379
VALKEY_SERVER_CONNECT_TIMEOUT=5s
VALKEY_POLICY=allkeys-lru
VALKEY_MAX_MEMORY=256MB
SPRING_DATA_VALKEY_HOST=localhost
SPRING_DATA_VALKEY_PORT=6379
SPRING_DATA_VALKEY_PASSWORD=valkey-password
# --- KEYCLOAK ---
KEYCLOAK_IMAGE_TAG=latest
KC_HEAP_MIN=512M
KC_HEAP_MAX=1024M
# Lokale Entwicklung: start-dev (kein Pre-Build nötig, kein --optimized)
# Server/Produktion: start --optimized --import-realm (nutzt das pre-built Registry-Image)
KC_COMMAND=start-dev --import-realm
# System-Admin (Master Console)
KC_BOOTSTRAP_ADMIN_USERNAME=kc-admin
KC_BOOTSTRAP_ADMIN_PASSWORD=kc-password
# Fach-Admin User Passwort (wird im Realm Import genutzt)
# Hinweis: Wenn du das hier änderst, müsstest du auch die JSON anpassen
# oder dort eine Variable nutzen.
KC_DB=postgres
KC_DB_SCHEMA=keycloak
KC_DB_URL=jdbc:postgresql://postgres:5432/pg-meldestelle-db
KC_DB_USERNAME=pg-user
KC_DB_PASSWORD=meldestelle
# Lokal: localhost | Server: echte IP oder Domain (z.B. 10.0.0.50 oder auth.meldestelle.at)
# WICHTIG: Nur den Hostnamen angeben, OHNE Port (Keycloak 26.x hostname v2)
KC_HOSTNAME=localhost
# false = Zugriff über beliebige Hostnamen erlaubt (nötig ohne TLS / für HTTP-Betrieb)
KC_HOSTNAME_STRICT=false
KC_HOSTNAME_STRICT_HTTPS=false
KC_PORT=8180:8080
KC_MANAGEMENT_PORT=9000:9000
KC_HTTP_ENABLE=true
KC_API_GATEWAY_CLIENT_SECRET=K5RqonwVOaxPKaXVH4mbthSRbjRh5tOK
# KC_POSTMAN_CLIENT_SECRET=postman-secret-123
# KC_BOOTSTRAP_ADMIN_PASSWORD=Admin#1234
KC_FRONTEND_URL=http://localhost:8180
KC_PROXY_HEADERS=xforwarded
# --- KEYCLOAK TOKEN VALIDATION ---
# Public Issuer URI (must match the token issuer from browser/postman)
# Lokal: http://localhost:8180 | Produktion: http://10.0.0.50:8180
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI=http://localhost:8180/realms/meldestelle
# Internal JWK Set URI (for service-to-service communication within Docker)
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI=http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs
# --- CONSUL ---
CONSUL_IMAGE=hashicorp/consul:1.22.1
CONSUL_PORT=8500:8500
CONSUL_UDP_PORT=8600:8600/udp
CONSUL_HOST=consul
SPRING_CLOUD_CONSUL_HOST=consul
SPRING_CLOUD_CONSUL_PORT=8500
SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME=api-gateway
SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS=true
# --- Zipkin ---
ZIPKIN_IMAGE=openzipkin/zipkin:3
ZIPKIN_MIN_HEAP=256M
ZIPKIN_MAX_HEAP=512M
ZIPKIN_PORT=9411:9411
ZIPKIN_ENDPOINT=http://zipkin:9411/api/v2/spans
ZIPKIN_SAMPLING_PROBABILITY=1.0
# --- Mailpit ---
MAILPIT_IMAGE=axllent/mailpit:v1.29
MAILPIT_WEB_PORT=8025:8025
MAILPIT_SMTP_PORT=1025:1025
# --- PGADMIN ---
PGADMIN_IMAGE=dpage/pgadmin4:8
PGADMIN_EMAIL=meldestelle@mo-code.at
PGADMIN_PASSWORD=pgadmin
PGADMIN_PORT=8888:80
# --- POSTGRES-EXPORTER ---
POSTGRES_EXPORTER_IMAGE=prometheuscommunity/postgres-exporter:v0.18.0
# --- ALERTMANAGER ---
ALERTMANAGER_IMAGE=prom/alertmanager:v0.29.0
ALERTMANAGER_PORT=9093:9093
# --- PROMETHEUS ---
PROMETHEUS_IMAGE=prom/prometheus:v3.7.3
PROMETHEUS_PORT=9090:9090
# --- GRAFANA ---
GF_IMAGE=grafana/grafana:12.3
GF_ADMIN_USER=gf-admin
GF_ADMIN_PASSWORD=gf-password
GF_PORT=3000:3000
# --- API-GATEWAY ---
GATEWAY_PORT=8081:8081
GATEWAY_DEBUG_PORT=5005:5005
GATEWAY_SERVER_PORT=8081
GATEWAY_SPRING_PROFILES_ACTIVE=docker
GATEWAY_DEBUG=true
GATEWAY_SERVICE_NAME=api-gateway
GATEWAY_CONSUL_PREFER_IP=true
# --- PING-SERVICE ---
PING_SPRING_PROFILES_ACTIVE=docker
PING_PORT=8082:8082
PING_DEBUG_PORT=5006:5006
PING_SERVER_PORT=8082
PING_DEBUG=true
PING_SERVICE_NAME=ping-service
PING_CONSUL_PREFER_IP=true
# --- MAIL-SERVICE ---
MAIL_PORT=8083:8083
MAIL_DEBUG_PORT=5014:5014
MAIL_SERVER_PORT=8083
MAIL_SPRING_PROFILES_ACTIVE=docker
MAIL_DEBUG=true
MAIL_SERVICE_NAME=mail-service
MAIL_CONSUL_PREFER_IP=true
MAIL_SMTP_HOST=smtp.world4you.com
MAIL_SMTP_PORT=587
MAIL_SMTP_USER=online-nennen@mo-code.at
MAIL_SMTP_PASSWORD=secret
MAIL_SMTP_AUTH=true
MAIL_SMTP_STARTTLS=true
# --- MASTERDATA-SERVICE ---
MASTERDATA_PORT=8086:8086
MASTERDATA_DEBUG_PORT=5007:5007
MASTERDATA_SERVER_PORT=8086
MASTERDATA_SPRING_PROFILES_ACTIVE=docker
MASTERDATA_DEBUG=true
MASTERDATA_SERVICE_NAME=masterdata-service
MASTERDATA_CONSUL_PREFER_IP=true
# --- EVENTS-SERVICE ---
EVENTS_PORT=8085:8085
EVENTS_DEBUG_PORT=5008:5008
EVENTS_SERVER_PORT=8085
EVENTS_SPRING_PROFILES_ACTIVE=docker
EVENTS_DEBUG=true
EVENTS_SERVICE_NAME=events-service
EVENTS_CONSUL_PREFER_IP=true
# --- ZNS-IMPORT-SERVICE ---
ZNS_IMPORT_PORT=8095:8095
ZNS_IMPORT_DEBUG_PORT=5009:5009
ZNS_IMPORT_SERVER_PORT=8095
ZNS_IMPORT_SPRING_PROFILES_ACTIVE=docker
ZNS_IMPORT_DEBUG=true
ZNS_IMPORT_SERVICE_NAME=zns-import-service
ZNS_IMPORT_CONSUL_PREFER_IP=true
# --- RESULTS-SERVICE ---
RESULTS_PORT=8088:8088
RESULTS_DEBUG_PORT=5010:5010
RESULTS_SERVER_PORT=8088
RESULTS_SPRING_PROFILES_ACTIVE=docker
RESULTS_DEBUG=true
RESULTS_SERVICE_NAME=results-service
RESULTS_CONSUL_PREFER_IP=true
# --- BILLING-SERVICE ---
BILLING_PORT=8087:8087
BILLING_DEBUG_PORT=5012:5012
BILLING_SERVER_PORT=8087
BILLING_SPRING_PROFILES_ACTIVE=docker
BILLING_DEBUG=true
BILLING_SERVICE_NAME=billing-service
BILLING_CONSUL_PREFER_IP=true
# --- SCHEDULING-SERVICE ---
SCHEDULING_PORT=8084:8084
SCHEDULING_DEBUG_PORT=5013:5013
SCHEDULING_SERVER_PORT=8084
SCHEDULING_SPRING_PROFILES_ACTIVE=docker
SCHEDULING_DEBUG=true
SCHEDULING_SERVICE_NAME=scheduling-service
SCHEDULING_CONSUL_PREFER_IP=true
# --- SERIES-SERVICE ---
SERIES_PORT=8089:8089
SERIES_DEBUG_PORT=5011:5011
SERIES_SERVER_PORT=8089
SERIES_SPRING_PROFILES_ACTIVE=docker
SERIES_DEBUG=true
SERIES_SERVICE_NAME=series-service
SERIES_CONSUL_PREFER_IP=true
# --- WEB-APP ---
CADDY_VERSION=2.11-alpine
WEB_APP_PORT=4000:4000
WEB_BUILD_PROFILE=dev
# Lokal: http://localhost:8081 | Produktion: http://10.0.0.50:8081
WEB_APP_API_URL=http://localhost:8081
WEB_APP_KEYCLOAK_URL=http://auth.mo-code.at
# --- DESKTOP-APP ---
DESKTOP_APP_VNC_PORT=5901:5901
DESKTOP_APP_NOVNC_PORT=6080:6080

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/.idea/

View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">

View File

@ -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())

View File

@ -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 ===

View File

@ -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),

View File

@ -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")
}
}

View File

@ -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()

View File

@ -82,13 +82,18 @@ services:
restart: unless-stopped
profiles: [ "infra", "all" ]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: "${KC_ADMIN_USERNAME:-kc-admin}"
KC_BOOTSTRAP_ADMIN_PASSWORD: "${KC_ADMIN_PASSWORD:-kc-password}"
KC_BOOTSTRAP_ADMIN_USERNAME: "${KC_BOOTSTRAP_ADMIN_USERNAME:-kc-admin}"
KC_BOOTSTRAP_ADMIN_PASSWORD: "${KC_BOOTSTRAP_ADMIN_PASSWORD:-kc-password}"
KC_FRONTEND_URL: "${KC_FRONTEND_URL:-http://localhost:8180}"
KC_PROXY_HEADERS: "${KC_PROXY_HEADERS:-xforwarded}"
KC_DB: "${KC_DB:-postgres}"
KC_DB_SCHEMA: "${KC_DB_SCHEMA:-keycloak}"
KC_DB_URL: "jdbc:postgresql://postgres:5432/${POSTGRES_DB:-pg-meldestelle-db}"
KC_DB_USERNAME: "${POSTGRES_USER:-pg-user}"
KC_DB_PASSWORD: "${POSTGRES_PASSWORD:-pg-password}"
# Hostname-Konfiguration: Für lokale Entwicklung "localhost", auf dem Server die echte IP/Domain setzen
KC_HOSTNAME: "${KC_HOSTNAME:-localhost}"
# WICHTIG: false erlaubt Zugriff über beliebige Hostnamen (nötig für Server-Betrieb ohne TLS)
@ -98,6 +103,7 @@ services:
KC_HTTP_ENABLED: "true"
# Admin-Interface explizit auf allen Interfaces binden (0.0.0.0)
KC_HTTP_MANAGEMENT_PORT: "9000"
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
# Integration der Power-Flags

View File

@ -255,6 +255,7 @@ zipkin-server = { module = "io.zipkin:zipkin-server", version.ref = "zipkin" }
resilience4j-spring-boot3 = { module = "io.github.resilience4j:resilience4j-spring-boot3", version.ref = "resilience4j" }
resilience4j-reactor = { module = "io.github.resilience4j:resilience4j-reactor", version.ref = "resilience4j" }
resilience4j-kotlin = { module = "io.github.resilience4j:resilience4j-kotlin", version.ref = "resilience4j" }
auth0-java-jwt = { module = "com.auth0:java-jwt", version.ref = "auth0Jwt" }
keycloak-admin-client = { module = "org.keycloak:keycloak-admin-client", version.ref = "keycloakAdminClient" }
@ -392,7 +393,8 @@ jackson-kotlin = [
]
resilience = [
"resilience4j-spring-boot3",
"resilience4j-reactor"
"resilience4j-reactor",
"resilience4j-kotlin"
]
[plugins]