chore(MP-23): network DI client, frontend architecture guards, detekt & ktlint setup, docs, ping DI factory (#21)

* chore(MP-21): snapshot pre-refactor state (Epic 1)

* chore(MP-22): scaffold new repo structure, relocate Docker Compose, move frontend/backend modules, update Makefile; add docs mapping and env template

* MP-22 Epic 2: Erfolgreich umgesetzt und verifiziert

* MP-23 Epic 3: Gradle/Build Governance zentralisieren
This commit is contained in:
StefanMo
2025-11-30 23:14:00 +01:00
committed by GitHub
parent 89bbd42245
commit 034892e890
101 changed files with 857 additions and 407 deletions
+7
View File
@@ -0,0 +1,7 @@
# Backend
Domänenspezifische Services und Gateway.
- gateway: API Gateway/Auth/Routing
- discovery: Service Registry/Discovery (optional)
- services: Microservices, pro Domäne ein Service
+76
View File
@@ -0,0 +1,76 @@
// Dieses Modul ist das API-Gateway und der einzige öffentliche Einstiegspunkt
// für alle externen Anfragen an das Meldestelle-System.
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.kotlinJpa)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
}
// Konfiguriert die Hauptklasse für das ausführbare JAR
springBoot {
mainClass.set("at.mocode.infrastructure.gateway.GatewayApplicationKt")
}
dependencies {
implementation(platform(projects.platform.platformBom))
// === Core Dependencies ===
implementation(projects.core.coreUtils)
implementation(projects.platform.platformDependencies)
implementation(projects.infrastructure.monitoring.monitoringClient)
// === GATEWAY-SPEZIFISCHE ABHÄNGIGKEITEN ===
implementation(libs.bundles.spring.cloud.gateway)
implementation(libs.bundles.spring.boot.security)
implementation(libs.bundles.resilience)
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")
implementation(libs.spring.boot.starter.actuator) // Wichtig für Health & Metrics
implementation(libs.bundles.logging)
implementation(libs.bundles.jackson.kotlin)
// WICHTIG: PostgreSQL Treiber hinzufügen!
implementation(libs.postgresql.driver)
// === Test Dependencies ===
testImplementation(projects.platform.platformTesting)
testImplementation(libs.bundles.testing.jvm)
}
tasks.test {
useJUnitPlatform()
}
// Konfiguration für Integration Tests
sourceSets {
val integrationTest by creating {
compileClasspath += sourceSets.main.get().output
runtimeClasspath += sourceSets.main.get().output
}
}
val integrationTestImplementation by configurations.getting {
extendsFrom(configurations.testImplementation.get())
}
tasks.register<Test>("integrationTest") {
description = "Führt die Integration Tests aus"
group = "verification"
testClassesDirs = sourceSets["integrationTest"].output.classesDirs
classpath = sourceSets["integrationTest"].runtimeClasspath
useJUnitPlatform()
shouldRunAfter("test")
testLogging {
events("passed", "skipped", "failed")
showStandardStreams = false
showExceptions = true
showCauses = true
showStackTraces = true
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
+379
View File
@@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Meldestelle API Documentation</title>
<meta name="description" content="Self-Contained Systems API Gateway for Austrian Equestrian Federation - Modern API documentation and interactive tools.">
<!-- Modern CSS Framework -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
:root {
--primary-color: #0d6efd;
--secondary-color: #6c757d;
--success-color: #198754;
--info-color: #0dcaf0;
--warning-color: #ffc107;
--danger-color: #dc3545;
--dark-color: #212529;
--light-color: #f8f9fa;
}
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.hero-section {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
margin: 2rem 0;
padding: 3rem;
}
.feature-card {
background: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
border: none;
height: 100%;
}
.feature-card:hover {
transform: translateY(-10px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}
.feature-icon {
width: 80px;
height: 80px;
background: linear-gradient(135deg, var(--primary-color), var(--info-color));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.5rem;
color: white;
font-size: 2rem;
}
.btn-primary-custom {
background: linear-gradient(135deg, var(--primary-color), var(--info-color));
border: none;
border-radius: 50px;
padding: 12px 30px;
font-weight: 600;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}
.btn-primary-custom:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(13, 110, 253, 0.3);
background: linear-gradient(135deg, var(--info-color), var(--primary-color));
}
.stats-section {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: 2rem;
margin: 2rem 0;
}
.stat-item {
text-align: center;
padding: 1rem;
}
.stat-number {
font-size: 2.5rem;
font-weight: bold;
color: var(--primary-color);
display: block;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(25, 135, 84, 0.1);
color: var(--success-color);
border-radius: 50px;
font-weight: 600;
border: 2px solid rgba(25, 135, 84, 0.2);
}
.status-indicator {
width: 10px;
height: 10px;
background: var(--success-color);
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.footer-section {
background: rgba(33, 37, 41, 0.9);
backdrop-filter: blur(10px);
border-radius: 15px;
margin-top: 3rem;
}
.system-info {
background: rgba(248, 249, 250, 0.9);
border-radius: 10px;
padding: 1rem;
margin: 1rem 0;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
border-left: 4px solid var(--primary-color);
}
</style>
</head>
<body>
<div class="container-fluid px-4">
<!-- Hero Section -->
<div class="hero-section text-center">
<div class="mb-4">
<div class="status-badge mb-3">
<div class="status-indicator"></div>
System Online
</div>
</div>
<h1 class="display-3 fw-bold text-primary mb-4">
<i class="bi bi-diagram-3"></i>
Meldestelle API Gateway
</h1>
<p class="lead mb-4 text-muted">
Self-Contained Systems API Gateway for Austrian Equestrian Federation
</p>
<p class="fs-5 mb-5">
Modern, scalable API architecture providing unified access to all bounded contexts
including member management, horse registry, event management, and master data services.
</p>
<div class="d-flex flex-wrap gap-3 justify-content-center">
<a href="/swagger" class="btn btn-primary-custom btn-lg" target="_blank">
<i class="bi bi-code-square me-2"></i>
Interactive API Docs
</a>
<a href="../static/docs/index.html" class="btn btn-outline-primary btn-lg">
<i class="bi bi-book me-2"></i>
Documentation Hub
</a>
<a href="/actuator/health" class="btn btn-outline-success btn-lg" target="_blank">
<i class="bi bi-heart-pulse me-2"></i>
Health Status
</a>
</div>
</div>
<!-- Statistics Section -->
<div class="stats-section">
<div class="row text-center">
<div class="col-md-3 stat-item">
<span class="stat-number">6+</span>
<small class="text-muted">Microservices</small>
</div>
<div class="col-md-3 stat-item">
<span class="stat-number">50+</span>
<small class="text-muted">API Endpoints</small>
</div>
<div class="col-md-3 stat-item">
<span class="stat-number">99.9%</span>
<small class="text-muted">Uptime</small>
</div>
<div class="col-md-3 stat-item">
<span class="stat-number">OpenAPI 3.0</span>
<small class="text-muted">Specification</small>
</div>
</div>
</div>
<!-- Features Section -->
<div class="row g-4 my-5">
<div class="col-lg-4 col-md-6">
<div class="card feature-card p-4 text-center">
<div class="feature-icon">
<i class="bi bi-lightning-charge"></i>
</div>
<h4 class="h5 fw-bold">Interactive Documentation</h4>
<p class="text-muted mb-4">
Modern Swagger UI with real-time API testing, request/response examples,
and comprehensive endpoint documentation.
</p>
<a href="/swagger" target="_blank" class="btn btn-outline-primary btn-sm">
Explore API <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
<div class="col-lg-4 col-md-6">
<div class="card feature-card p-4 text-center">
<div class="feature-icon">
<i class="bi bi-shield-check"></i>
</div>
<h4 class="h5 fw-bold">Health Monitoring</h4>
<p class="text-muted mb-4">
Real-time health checks for all downstream services with circuit breaker
patterns and comprehensive monitoring dashboards.
</p>
<a href="/actuator/health" target="_blank" class="btn btn-outline-primary btn-sm">
View Status <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
<div class="col-lg-4 col-md-6">
<div class="card feature-card p-4 text-center">
<div class="feature-icon">
<i class="bi bi-diagram-3-fill"></i>
</div>
<h4 class="h5 fw-bold">Microservices Architecture</h4>
<p class="text-muted mb-4">
Clean architecture with bounded contexts: Members, Horses, Events,
Master Data, Authentication, and more.
</p>
<a href="../static/docs/index.html" class="btn btn-outline-primary btn-sm">
Learn More <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
<div class="col-lg-4 col-md-6">
<div class="card feature-card p-4 text-center">
<div class="feature-icon">
<i class="bi bi-file-earmark-code"></i>
</div>
<h4 class="h5 fw-bold">OpenAPI Specification</h4>
<p class="text-muted mb-4">
Complete OpenAPI 3.0.3 specification with detailed schemas,
examples, and code generation support.
</p>
<a href="/openapi" target="_blank" class="btn btn-outline-primary btn-sm">
View Spec <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
<div class="col-lg-4 col-md-6">
<div class="card feature-card p-4 text-center">
<div class="feature-icon">
<i class="bi bi-graph-up"></i>
</div>
<h4 class="h5 fw-bold">Metrics & Analytics</h4>
<p class="text-muted mb-4">
Prometheus metrics, distributed tracing, and performance monitoring
with detailed request/response analytics.
</p>
<a href="/actuator/metrics" target="_blank" class="btn btn-outline-primary btn-sm">
View Metrics <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
<div class="col-lg-4 col-md-6">
<div class="card feature-card p-4 text-center">
<div class="feature-icon">
<i class="bi bi-collection"></i>
</div>
<h4 class="h5 fw-bold">Development Tools</h4>
<p class="text-muted mb-4">
Postman collections, code examples in multiple languages,
and comprehensive developer resources.
</p>
<a href="../static/docs/index.html#resources" class="btn btn-outline-primary btn-sm">
Get Tools <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
</div>
<!-- System Information -->
<div class="system-info">
<h5 class="fw-bold mb-3"><i class="bi bi-info-circle me-2"></i>System Information</h5>
<div class="row">
<div class="col-md-6">
<strong>Gateway Version:</strong> 1.0.0<br>
<strong>Spring Boot:</strong> 3.x<br>
<strong>Spring Cloud Gateway:</strong> Latest
</div>
<div class="col-md-6">
<strong>Documentation:</strong> OpenAPI 3.0.3<br>
<strong>Service Discovery:</strong> Consul<br>
<strong>Circuit Breaker:</strong> Resilience4j
</div>
</div>
</div>
<!-- Footer -->
<footer class="footer-section text-white text-center py-4">
<div class="container">
<p class="mb-2">
<strong>Meldestelle API Gateway</strong> - Austrian Equestrian Federation
</p>
<p class="mb-0 text-muted">
Modern Self-Contained Systems Architecture | Built with Spring Cloud Gateway
</p>
<div class="mt-3">
<a href="/actuator/info" class="text-white-50 text-decoration-none me-3" target="_blank">
<i class="bi bi-info-circle me-1"></i>System Info
</a>
<a href="../static/docs/index.html" class="text-white-50 text-decoration-none me-3">
<i class="bi bi-book me-1"></i>Documentation
</a>
<a href="/swagger" class="text-white-50 text-decoration-none" target="_blank">
<i class="bi bi-code-square me-1"></i>API Explorer
</a>
</div>
</div>
</footer>
</div>
<!-- Modern JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<!-- Optional: Health Status Check -->
<script>
// Optional: Real-time health status check
async function checkHealthStatus() {
try {
const response = await fetch('/actuator/health');
const health = await response.json();
const statusBadge = document.querySelector('.status-badge');
const statusIndicator = document.querySelector('.status-indicator');
if (health.status === 'UP') {
statusBadge.innerHTML = '<div class="status-indicator"></div>System Online';
statusBadge.className = 'status-badge';
} else {
statusBadge.innerHTML = '<div class="status-indicator"></div>System Issues Detected';
statusBadge.className = 'status-badge text-warning';
statusIndicator.style.background = '#ffc107';
}
} catch (error) {
console.log('Health check unavailable in current environment');
}
}
// Check health status on load (only if available)
document.addEventListener('DOMContentLoaded', checkHealthStatus);
</script>
</body>
</html>
+733
View File
@@ -0,0 +1,733 @@
# Gateway Configuration Documentation
## Überblick
Dieses Dokument beschreibt alle zentralen Konfigurationseigenschaften für das API Gateway. Die Konfiguration erfolgt über die `application.yml` Datei und kann durch Umgebungsvariablen überschrieben werden.
## Table of Contents
- [Server Configuration](#server-configuration)
- [Spring Application](#spring-application)
- [Consul Service Discovery](#consul-service-discovery)
- [Spring Cloud Gateway](#spring-cloud-gateway)
- [Circuit Breaker (Resilience4j)](#circuit-breaker-resilience4j)
- [Management & Monitoring](#management--monitoring)
- [Security](#security)
- [Logging](#logging)
---
## Server Configuration
### server.port
- **Typ**: Integer
- **Default**: 8081
- **Environment Variable**: `GATEWAY_PORT`
- **Beschreibung**: Port, auf dem das Gateway läuft
### server.netty.connection-timeout
- **Typ**: Duration
- **Default**: 5s
- **Beschreibung**: Timeout für initiale TCP-Verbindungen
### server.netty.idle-timeout
- **Typ**: Duration
- **Default**: 15s
- **Beschreibung**: Timeout für inaktive Verbindungen
**Beispiel:**
```yaml
server:
port: 8081
netty:
connection-timeout: 5s
idle-timeout: 15s
```
---
## Spring Application
### spring.application.name
- **Typ**: String
- **Default**: api-gateway
- **Beschreibung**: Name der Anwendung, wird in Consul und Logs verwendet
### spring.profiles.active
- **Typ**: String
- **Default**: dev
- **Environment Variable**: `SPRING_PROFILES_ACTIVE`
- **Beschreibung**: Aktives Spring-Profil (dev, test, prod)
- **Mögliche Werte**: dev, test, staging, prod
### spring.security.user.name / password
- **Typ**: String
- **Default**: admin / admin
- **Environment Variables**: `GATEWAY_ADMIN_USER`, `GATEWAY_ADMIN_PASSWORD`
- **Beschreibung**: Basic Auth für administrative Endpunkte
- **⚠️ Wichtig**: In Produktion durch sichere Werte ersetzen!
**Beispiel:**
```yaml
spring:
application:
name: api-gateway
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
security:
user:
name: ${GATEWAY_ADMIN_USER:admin}
password: ${GATEWAY_ADMIN_PASSWORD:admin}
```
---
## Consul Service Discovery
### spring.cloud.consul.host
- **Typ**: String
- **Default**: localhost
- **Environment Variable**: `CONSUL_HOST`
- **Beschreibung**: Hostname des Consul-Servers
### spring.cloud.consul.port
- **Typ**: Integer
- **Default**: 8500
- **Environment Variable**: `CONSUL_PORT`
- **Beschreibung**: Port des Consul-Servers
### spring.cloud.consul.enabled
- **Typ**: Boolean
- **Default**: true
- **Environment Variable**: `CONSUL_ENABLED`
- **Beschreibung**: Aktiviert/Deaktiviert Consul Integration
### spring.cloud.consul.discovery.enabled
- **Typ**: Boolean
- **Default**: true
- **Environment Variable**: `CONSUL_ENABLED`
- **Beschreibung**: Aktiviert Service Discovery
### spring.cloud.consul.discovery.register
- **Typ**: Boolean
- **Default**: true
- **Environment Variable**: `CONSUL_ENABLED`
- **Beschreibung**: Registriert das Gateway in Consul
### spring.cloud.consul.discovery.health-check-path
- **Typ**: String
- **Default**: /actuator/health
- **Beschreibung**: Pfad für Consul Health Checks
### spring.cloud.consul.discovery.health-check-interval
- **Typ**: Duration
- **Default**: 10s
- **Beschreibung**: Intervall für Health Checks
### spring.cloud.consul.discovery.instance-id
- **Typ**: String
- **Default**: ${spring.application.name}-${server.port}-${random.uuid}
- **Beschreibung**: Eindeutige Instanz-ID für Service Discovery
**Beispiel:**
```yaml
spring:
cloud:
consul:
host: ${CONSUL_HOST:localhost}
port: ${CONSUL_PORT:8500}
enabled: ${CONSUL_ENABLED:true}
discovery:
enabled: ${CONSUL_ENABLED:true}
register: ${CONSUL_ENABLED:true}
health-check-path: /actuator/health
health-check-interval: 10s
instance-id: ${spring.application.name}-${server.port}-${random.uuid}
```
---
## Spring Cloud Gateway
### Verbindungskonfiguration
#### spring.cloud.gateway.server.webflux.httpclient.connect-timeout
- **Typ**: Integer (Millisekunden)
- **Default**: 5000
- **Beschreibung**: Timeout für Backend-Verbindungen
#### spring.cloud.gateway.server.webflux.httpclient.response-timeout
- **Typ**: Duration
- **Default**: 30s
- **Beschreibung**: Timeout für Backend-Responses
#### spring.cloud.gateway.server.webflux.httpclient.pool.max-idle-time
- **Typ**: Duration
- **Default**: 15s
- **Beschreibung**: Max. Idle-Zeit für Verbindungen im Pool
#### spring.cloud.gateway.server.webflux.httpclient.pool.max-life-time
- **Typ**: Duration
- **Default**: 60s
- **Beschreibung**: Max. Lebensdauer einer Verbindung
**Beispiel:**
```yaml
spring:
cloud:
gateway:
server:
webflux:
httpclient:
connect-timeout: 5000
response-timeout: 30s
pool:
max-idle-time: 15s
max-life-time: 60s
```
### Default Filters
Diese Filter werden auf **alle** Routen angewendet:
1. **DedupeResponseHeader**: Entfernt doppelte CORS-Header
2. **CircuitBreaker**: Default Circuit Breaker mit Fallback
3. **Retry**: Automatische Wiederholung bei Fehlern
4. **Security Headers**: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, etc.
5. **Cache-Control**: No-cache Header für alle Responses
**Beispiel:**
```yaml
spring:
cloud:
gateway:
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
- name: CircuitBreaker
args:
name: defaultCircuitBreaker
fallbackUri: forward:/fallback
- name: Retry
args:
retries: 3
statuses: BAD_GATEWAY,GATEWAY_TIMEOUT
methods: GET,POST,PUT,DELETE
backoff:
firstBackoff: 50ms
maxBackoff: 500ms
factor: 2
```
### Routes
Das Gateway definiert folgende Service-Routen:
#### 1. Members Service Route
- **Path**: `/api/members/**`
- **Service**: members-service (via Consul)
- **Circuit Breaker**: membersCircuitBreaker
- **Fallback**: /fallback/members
#### 2. Horses Service Route
- **Path**: `/api/horses/**`
- **Service**: horses-service (via Consul)
- **Circuit Breaker**: horsesCircuitBreaker
- **Fallback**: /fallback/horses
#### 3. Events Service Route
- **Path**: `/api/events/**`
- **Service**: events-service (via Consul)
- **Circuit Breaker**: eventsCircuitBreaker
- **Fallback**: /fallback/events
#### 4. Masterdata Service Route
- **Path**: `/api/masterdata/**`
- **Service**: masterdata-service (via Consul)
- **Circuit Breaker**: masterdataCircuitBreaker
- **Fallback**: /fallback/masterdata
#### 5. Auth Service Route
- **Path**: `/api/auth/**`
- **Service**: auth-service (via Consul)
- **Circuit Breaker**: authCircuitBreaker
- **Fallback**: /fallback/auth
#### 6. Ping Service Route
- **Path**: `/api/ping/**`
- **Service**: ping-service (via Consul)
- **No Circuit Breaker**: Optional service
**Beispiel einer Route:**
```yaml
spring:
cloud:
gateway:
routes:
- id: members-service-route
uri: lb://members-service # lb = Load Balanced via Consul
predicates:
- Path=/api/members/**
filters:
- StripPrefix=1 # Entfernt /api vom Pfad
- name: CircuitBreaker
args:
name: membersCircuitBreaker
fallbackUri: forward:/fallback/members
```
---
## Circuit Breaker (Resilience4j)
### Default Konfiguration
#### resilience4j.circuitbreaker.configs.default.registerHealthIndicator
- **Typ**: Boolean
- **Default**: true
- **Beschreibung**: Registriert Circuit Breaker im Health Endpoint
#### resilience4j.circuitbreaker.configs.default.slidingWindowSize
- **Typ**: Integer
- **Default**: 100
- **Beschreibung**: Größe des Sliding Window für Fehlerrate-Berechnung
#### resilience4j.circuitbreaker.configs.default.minimumNumberOfCalls
- **Typ**: Integer
- **Default**: 20
- **Beschreibung**: Mindestanzahl an Calls bevor Circuit Breaker aktiviert wird
#### resilience4j.circuitbreaker.configs.default.permittedNumberOfCallsInHalfOpenState
- **Typ**: Integer
- **Default**: 3
- **Beschreibung**: Anzahl Test-Calls im Half-Open State
#### resilience4j.circuitbreaker.configs.default.waitDurationInOpenState
- **Typ**: Duration
- **Default**: 5s
- **Beschreibung**: Wartezeit bevor von Open zu Half-Open gewechselt wird
#### resilience4j.circuitbreaker.configs.default.failureRateThreshold
- **Typ**: Integer (Prozent)
- **Default**: 50
- **Beschreibung**: Fehlerrate-Schwelle für Circuit Breaker Aktivierung
### Service-spezifische Circuit Breaker
Jeder Service hat einen eigenen Circuit Breaker mit angepasster Konfiguration:
| Service | Sliding Window | Failure Threshold | Besonderheit |
|---------|---------------|-------------------|--------------|
| members-service | 50 | 50% | Standard |
| horses-service | 50 | 50% | Standard |
| events-service | 75 | 50% | Größeres Window |
| masterdata-service | 30 | 50% | Kleineres Window |
| auth-service | 20 | 30% | Sensitiverer Threshold |
**Beispiel:**
```yaml
resilience4j:
circuitbreaker:
instances:
authCircuitBreaker:
baseConfig: default
slidingWindowSize: 20
failureRateThreshold: 30 # Auth ist kritisch -> niedrigerer Threshold
```
---
## Management & Monitoring
### Exposed Endpoints
#### management.endpoints.web.exposure.include
- **Typ**: Comma-separated String
- **Default**: health,info,metrics,prometheus,gateway,circuitbreakers
- **Beschreibung**: Öffentlich verfügbare Actuator Endpoints
**Verfügbare Endpoints:**
- `/actuator/health` - Service Health Status
- `/actuator/info` - Service Informationen
- `/actuator/metrics` - Micrometer Metriken
- `/actuator/prometheus` - Prometheus Scrape Endpoint
- `/actuator/gateway` - Gateway Routes & Filters
- `/actuator/circuitbreakers` - Circuit Breaker Status
### Health Endpoint
#### management.endpoint.health.show-details
- **Typ**: String
- **Default**: always
- **Mögliche Werte**: never, when-authorized, always
- **Beschreibung**: Zeigt detaillierte Health-Informationen
#### management.endpoint.health.show-components
- **Typ**: Boolean
- **Default**: always
- **Beschreibung**: Zeigt Health-Komponenten
#### management.endpoint.health.probes.enabled
- **Typ**: Boolean
- **Default**: true
- **Beschreibung**: Aktiviert Kubernetes Liveness/Readiness Probes
### Metrics
#### management.metrics.tags
- **Beschreibung**: Globale Tags für alle Metriken
- **Standard Tags**:
- application: ${spring.application.name}
- environment: ${spring.profiles.active}
- instance: ${spring.cloud.consul.discovery.instance-id}
- service: gateway
- component: infrastructure
- gateway: api-gateway
#### management.metrics.distribution.percentiles-histogram.http.server.requests
- **Typ**: Boolean
- **Default**: true
- **Beschreibung**: Aktiviert Histogram für Request-Zeiten
#### management.metrics.distribution.percentiles.http.server.requests
- **Typ**: Array[Double]
- **Default**: [0.5, 0.90, 0.95, 0.99]
- **Beschreibung**: Percentile-Werte für Request-Zeiten
### Tracing
#### management.tracing.enabled
- **Typ**: Boolean
- **Default**: false
- **Environment Variable**: `TRACING_ENABLED`
- **Beschreibung**: Aktiviert Distributed Tracing
#### management.tracing.sampling.probability
- **Typ**: Double (0.0 - 1.0)
- **Default**: 1.0
- **Environment Variable**: `TRACING_SAMPLING_PROBABILITY`
- **Beschreibung**: Sampling-Rate für Traces (1.0 = 100%)
#### management.zipkin.tracing.endpoint
- **Typ**: URL
- **Default**: <http://localhost:9411/api/v2/spans>
- **Environment Variable**: `ZIPKIN_TRACING_ENDPOINT`
- **Beschreibung**: Zipkin Server URL
**Beispiel:**
```yaml
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,gateway,circuitbreakers
endpoint:
health:
show-details: always
probes:
enabled: true
tracing:
enabled: ${TRACING_ENABLED:false}
sampling:
probability: ${TRACING_SAMPLING_PROBABILITY:1.0}
zipkin:
tracing:
endpoint: ${ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans}
```
---
## Security
Die Security-Konfiguration erfolgt über Custom Properties unter `gateway.security`:
### gateway.security.publicPaths
- **Typ**: Array[String]
- **Default**: ["/", "/fallback/**", "/actuator/**", "/webjars/**", "/v3/api-docs/**", "/api/auth/**"]
- **Beschreibung**: Pfade, die ohne Authentifizierung zugänglich sind
### gateway.security.cors.allowedOriginPatterns
- **Typ**: Array[String]
- **Default**: ["http://localhost:[*]", "https://*.meldestelle.at"]
- **Beschreibung**: Erlaubte Origin-Patterns für CORS
### gateway.security.cors.allowedMethods
- **Typ**: Array[String]
- **Default**: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"]
- **Beschreibung**: Erlaubte HTTP-Methoden
### gateway.security.cors.allowedHeaders
- **Typ**: Array[String]
- **Default**: ["*"]
- **Beschreibung**: Erlaubte Request-Headers
### gateway.security.cors.exposedHeaders
- **Typ**: Array[String]
- **Default**: ["X-Correlation-ID", "X-RateLimit-Limit", "X-RateLimit-Remaining"]
- **Beschreibung**: Headers die an Client exponiert werden
### gateway.security.cors.allowCredentials
- **Typ**: Boolean
- **Default**: true
- **Beschreibung**: Erlaubt Credentials (Cookies, Auth-Header)
### gateway.security.cors.maxAge
- **Typ**: Duration
- **Default**: 1h
- **Beschreibung**: Cache-Zeit für CORS Preflight-Requests
**Beispiel:**
```yaml
gateway:
security:
publicPaths:
- "/"
- "/actuator/**"
- "/api/auth/**"
cors:
allowedOriginPatterns:
- "http://localhost:[*]"
- "https://*.meldestelle.at"
allowedMethods:
- GET
- POST
- PUT
- DELETE
allowCredentials: true
maxAge: 1h
```
### JWT Configuration
#### spring.security.oauth2.resourceserver.jwt.jwk-set-uri
- **Typ**: URL
- **Environment Variable**: `KEYCLOAK_JWK_SET_URI`
- **Beschreibung**: Keycloak JWK Set URI für JWT-Validierung
- **Beispiel**: <http://localhost:8180/realms/meldestelle/protocol/openid-connect/certs>
---
## Logging
### logging.level
- **Beschreibung**: Log-Level für verschiedene Pakete
**Standard Log-Levels:**
- `org.springframework.cloud.gateway`: INFO
- `org.springframework.cloud.loadbalancer`: DEBUG
- `org.springframework.cloud.consul`: INFO
- `at.mocode.infrastructure.gateway`: DEBUG
- `io.github.resilience4j`: INFO
- `reactor.netty.http.client`: INFO
- `org.springframework.security`: WARN
- `org.springframework.web`: INFO
### logging.pattern.console
- **Beschreibung**: Console-Log-Pattern mit Farben und Correlation-ID
### logging.pattern.file
- **Beschreibung**: File-Log-Pattern ohne Farben
### logging.file.name
- **Typ**: String
- **Default**: infrastructure/gateway/logs/gateway.log
- **Beschreibung**: Log-Datei Pfad
### logging.logback.rollingpolicy
- **clean-history-on-start**: true
- **max-file-size**: 100MB
- **total-size-cap**: 1GB
- **max-history**: 30 (Tage)
**Beispiel:**
```yaml
logging:
level:
at.mocode.infrastructure.gateway: DEBUG
org.springframework.cloud.gateway: INFO
file:
name: infrastructure/gateway/logs/gateway.log
logback:
rollingpolicy:
max-file-size: 100MB
max-history: 30
```
---
## Umgebungsvariablen Übersicht
### Kritische Variablen für Produktion
| Variable | Beschreibung | Default |
|----------|--------------|---------|
| `GATEWAY_PORT` | Gateway Port | 8081 |
| `CONSUL_HOST` | Consul Server | localhost |
| `CONSUL_PORT` | Consul Port | 8500 |
| `CONSUL_ENABLED` | Consul Aktivieren | true |
| `GATEWAY_ADMIN_USER` | Admin Username | admin |
| `GATEWAY_ADMIN_PASSWORD` | Admin Password | admin |
| `KEYCLOAK_JWK_SET_URI` | Keycloak JWK URI | <http://localhost:8180/>... |
| `TRACING_ENABLED` | Tracing aktivieren | false |
| `ZIPKIN_TRACING_ENDPOINT` | Zipkin Server | <http://localhost:9411/>... |
| `SPRING_PROFILES_ACTIVE` | Spring Profil | dev |
---
## Profile-spezifische Konfiguration
Das Gateway unterstützt verschiedene Spring Profile:
### dev (Development)
- Detailliertes Logging
- Alle Monitoring-Endpunkte verfügbar
- Tracing optional
### test
- Reduziertes Logging
- Test-spezifische Timeouts
- In-Memory Services optional
### prod (Production)
- Production-ready Logging
- Sichere Credentials erforderlich
- Tracing empfohlen
- Rate Limiting aktiviert
**Beispiel für profile-spezifische Datei:**
```yaml
# application-prod.yml
spring:
security:
user:
name: ${GATEWAY_ADMIN_USER} # Muss gesetzt sein!
password: ${GATEWAY_ADMIN_PASSWORD} # Muss gesetzt sein!
management:
tracing:
enabled: true
sampling:
probability: 0.1 # 10% Sampling in Production
logging:
level:
at.mocode.infrastructure.gateway: INFO # Weniger Logs
```
---
## Best Practices
1. **Umgebungsvariablen verwenden**: Nie Credentials in application.yml hardcoden
2. **Profile nutzen**: Separate Konfigurationen für dev/test/prod
3. **Health Checks aktivieren**: Für Consul und Kubernetes
4. **Tracing in Production**: Mindestens 10% Sampling
5. **Monitoring exportieren**: Prometheus-Endpunkt für Grafana
6. **Circuit Breaker tunen**: An Service-Charakteristiken anpassen
7. **CORS restriktiv**: Nur benötigte Origins erlauben
8. **Log Rotation**: Verhindert volle Festplatten
---
## Troubleshooting
### Gateway startet nicht
- ✅ Prüfen: Consul erreichbar?
- ✅ Prüfen: Port 8081 frei?
- ✅ Prüfen: Keycloak erreichbar? (Optional)
### Service nicht erreichbar
- ✅ Prüfen: Service in Consul registriert?
- ✅ Prüfen: Circuit Breaker offen?
- ✅ Prüfen: Health Check erfolgreich?
### CORS-Fehler
- ✅ Prüfen: Origin in allowedOriginPatterns?
- ✅ Prüfen: Methode in allowedMethods?
- ✅ Prüfen: allowCredentials korrekt?
### Hohe Latenz
- ✅ Prüfen: response-timeout zu hoch?
- ✅ Prüfen: Backend-Services langsam?
- ✅ Prüfen: Connection Pool ausgeschöpft?
---
## Weitere Ressourcen
- [Gateway README](README-INFRA-GATEWAY.md)
- [Spring Cloud Gateway Dokumentation](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/)
- [Resilience4j Dokumentation](https://resilience4j.readme.io/)
- [Consul Dokumentation](https://www.consul.io/docs)
@@ -0,0 +1,442 @@
# Infrastructure/Gateway Module - Comprehensive Documentation
## Überblick
Das API-Gateway ist der zentrale und einzige öffentliche Einstiegspunkt für alle Anfragen von externen Clients (z.B. Web-Anwendung, Desktop-Anwendung, mobile Apps) an das Meldestelle-System. Es fungiert als "Pförtner" für die gesamte Microservice-Landschaft und wurde zu einem vollwertigen, produktionstauglichen API Gateway mit modernen Best Practices erweitert.
**Wichtiger Grundsatz**: Kein externer Client sollte jemals direkt mit einem internen Microservice kommunizieren. Alle Anfragen laufen über das Gateway.
## Architektur und Technologie
Das Gateway ist als eigenständiger Spring Boot Service implementiert und nutzt Spring Cloud Gateway als technologische Grundlage. Spring Cloud Gateway ist ein reaktives, nicht-blockierendes Framework, das sich nahtlos in das Spring-Ökosystem integriert.
### Technologie-Stack
- **Spring Boot 3.x** - Moderne Spring Boot Anwendung
- **Spring Cloud Gateway** - Reaktives Gateway Framework
- **Spring WebFlux** - Reaktive Web-Programmierung mit Netty
- **Resilience4j** - Circuit Breaker Pattern Implementation
- **Consul** - Service Discovery und Health Checks
- **Micrometer + Prometheus** - Umfassende Metriken und Monitoring
- **JWT** - Token-basierte Authentifizierung
- **Reactive Streams** - Non-blocking I/O für optimale Performance
## Hauptverantwortlichkeiten
Das Gateway handhabt alle Cross-Cutting Concerns (übergreifende Belange), die für mehrere oder alle Microservices gelten und entlastet damit die Fach-Services von technischen Aufgaben.
### 1. Dynamisches Routing
- **Service Discovery Integration**: Vollständige Consul Integration für automatische Service-Erkennung
- **Load Balancing**: Intelligente Lastverteilung zwischen Service-Instanzen
- **Health-basiertes Routing**: Weiterleitung nur an gesunde Service-Instanzen
**Verfügbare Routen**:
- `/api/members/**` → members-service
- `/api/horses/**` → horses-service
- `/api/events/**` → events-service
- `/api/masterdata/**` → masterdata-service
- `/api/auth/**` → auth-service
- `/api/ping/**` → ping-service
### 2. Sicherheit und Authentifizierung
- **JWT Security Enforcement**: Validierung von Bearer Tokens für alle geschützten Endpunkte
- **Public Path Exemptions**: Konfigurierbare öffentliche Pfade (`/`, `/health`, `/actuator/**`, `/api/auth/login`)
- **User Context Injection**: Automatische Weiterleitung von User-ID und Rolle an Backend Services
- **Standardisierte Fehlerbehandlung**: Strukturierte 401 Unauthorized Responses
### 3. Rate Limiting
- **Intelligentes Rate Limiting** basierend auf User-Typ:
- **Anonymous Users**: 50 Anfragen pro Minute
- **Authenticated Users**: 200 Anfragen pro Minute
- **Admin Users**: 500 Anfragen pro Minute
- **IP-basierte Limits**: Schutz vor DDoS-Attacken
- **Custom Headers**: X-RateLimit-* Header für Client-Information
### 4. Circuit Breaker und Resilienz
- **Service-spezifische Circuit Breaker**: Resilience4j Integration für jeden Backend Service
- **Fallback Mechanismen**: Benutzerfreundliche Fehlermeldungen bei Service-Ausfällen
- **Retry Logic**: Automatische Wiederholungen bei transienten Fehlern
- **Graceful Degradation**: Systembetrieb auch bei partiellen Service-Ausfällen
### 5. Monitoring und Observability
Das Gateway implementiert umfassende Observability durch eine vollständig integrierte Micrometer-basierte Metriken-Architektur.
#### Automatische Metriken-Erfassung (GatewayMetricsConfig)
- **Request Duration Tracking**: Automatische Messung aller Request-Response Zyklen
- Metric: `gateway_request_duration` (Timer)
- Tags: method, path, status, status_series
- Percentile-basierte Auswertung (P50, P90, P95, P99)
- **Error Rate Monitoring**: Detailliertes Error-Tracking für 4xx/5xx Responses
- Metric: `gateway_errors_total` (Counter)
- Tags: method, path, status, status_series, error_type
- Unterscheidung zwischen client_error und server_error
- **Request Volume Tracking**: Vollständige Request-Volumen Überwachung
- Metric: `gateway_requests_total` (Counter)
- Tags: method, path für detaillierte Analyse
- **Circuit Breaker Events**: Monitoring von Resilience-Pattern Events
- Metric: `gateway_circuit_breaker_events_total` (Counter)
- Integration mit Resilience4j Circuit Breaker Status
#### Intelligente Pfad-Normalisierung
- **Kardinalitäts-Kontrolle**: Automatische Normalisierung von dynamischen Pfaden
- `/api/horses/123``/api/horses/{id}`
- UUID-Pattern → `/{uuid}`
- Sehr lange Pfade werden gekürzt (100+ Zeichen)
#### Health Monitoring Integration
- **Downstream Service Health**: Umfassende Überwachung aller Backend Services
- Kritische Services: Members, Horses, Events, Masterdata, Auth
- Optionale Services: Ping Service
- Circuit Breaker Status Integration
- **Distributed Tracing**: Korrelations-ID basiertes Request-Tracking
- **Strukturierte Logs**: JSON-Format für maschinelle Auswertung
#### Prometheus Export
- **Automatischer Export**: Alle Metriken werden automatisch an Prometheus exportiert
- **Common Tags**: Alle Metriken erhalten automatisch Service- und Component-Tags
- **Filter-Optimierung**: Rauschen-reduzierende Metrik-Filter für interne Spring/Netty Metriken
### 6. CORS-Management
- **Produktionstaugliche CORS-Konfiguration**:
- Erlaubte Origins: `https://*.meldestelle.at`, `http://localhost:*`
- Alle HTTP-Methoden (GET, POST, PUT, DELETE, PATCH, OPTIONS)
- Credential-Support für authentifizierte Anfragen
## Implementierte Optimierungen
### Gateway-Konfiguration (application.yml)
**Vollständige Service-Routen**: Routing für alle Business Services
**Circuit Breaker Integration**: Service-spezifische Resilience4j Konfigurationen
**Connection Pooling**: Optimierte HTTP-Client-Konfiguration
**Security Headers**: Umfassende Sicherheits-Header (X-Content-Type-Options, X-Frame-Options, X-XSS-Protection)
**Enhanced Logging**: Strukturierte Logs mit Korrelations-IDs und Performance-Daten
### Health Monitoring (GatewayHealthIndicator.kt)
**Downstream Service Monitoring**: Überwachung aller kritischen Services
**Service Discovery Integration**: Consul-basierte Service-Erkennung
**Test-Environment Handling**: Graceful Degradation in Test-Umgebungen
**Detailliertes Error Reporting**: Umfassende Statusinformationen
### Build-Optimierungen (build.gradle.kts)
**SINGLE SOURCE OF TRUTH**: Alle Dependencies über libs.versions.toml
**Build Info Generation**: Automatische Build-Metadaten
**Modern Kotlin Compiler**: Optimierte Compiler-Einstellungen
**Dependency Optimization**: Bereinigung redundanter Dependencies
### Docker-Optimierungen (Dockerfile)
**Multi-Stage Build**: Spring Boot Layer-Extraktion für 90%+ besseres Caching
**Security Hardening**: Non-root User, Security Updates
**OCI Compliance**: Vollständige Container-Metadaten
**Production-Ready**: Optimierte JVM-Settings für Container-Umgebung
### Metriken-Integration (GatewayMetricsConfig.kt)
**Comprehensive Micrometer Integration**: Vollständige Metriken-Erfassung mit automatischem Prometheus Export
**Request/Response Time Tracking**: Detaillierte Performance-Metriken mit Percentile-Auswertung
**Error Rate Monitoring**: Intelligente Fehler-Klassifikation und -Tracking
**Path Normalization**: Kardinalitäts-kontrolle für dynamische API-Pfade
**Circuit Breaker Metrics**: Integration mit Resilience4j Event-Tracking
**Custom Business Metrics**: Erweiterbare Metrik-Architektur für fachliche KPIs
### Dokumentation
**OpenAPI 3.0.3 Spezifikation**: Vollständige API-Dokumentation mit Members Service
**Interactive Swagger UI**: Modern dokumentierte API-Endpunkte
**Static HTML Documentation**: Responsive, moderne Dokumentations-Website
**Health Monitoring Integration**: Real-time Status-Informationen
## Performance und Reliability
### Netty Server Optimierungen
- **Connection Timeouts**: 5 Sekunden für optimale Responsiveness
- **Idle Timeout**: 15 Sekunden für effiziente Resource-Nutzung
- **Elastic Connection Pool**: Automatische Skalierung basierend auf Load
### Circuit Breaker Konfiguration
- **Sliding Window**: 100 Anfragen für Default, service-spezifische Anpassungen
- **Failure Rate Threshold**: 50% für Standard-Services, 30% für Auth-Service
- **Half-Open State**: 3 Test-Anfragen für Service-Recovery
### JVM Optimierungen (Container)
```bash
JAVA_OPTS="-server -Xmx512m -Xms256m -XX:+UseG1GC
-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
```
## API Gateway Request Flow
Ein typischer Anfrage-Flow:
1. **Client Request**: `https://api.meldestelle.at/api/members/123`
2. **Gateway Empfang**: Anfrage wird vom Spring Cloud Gateway empfangen
3. **Filter-Pipeline**:
- **Security Filter**: JWT-Validierung
- **Rate Limiting Filter**: Anfrage-Limits prüfen
- **Correlation Filter**: Trace-ID generieren
- **Logging Filter**: Request-Details erfassen
4. **Service Discovery**: Consul-Abfrage für verfügbare `members-service` Instanzen
5. **Load Balancing**: Intelligente Auswahl einer gesunden Instanz
6. **Circuit Breaker**: Überwachung der Service-Verfügbarkeit
7. **Request Forwarding**: Weiterleitung an Backend Service
8. **Response Processing**: Antwort-Verarbeitung und Header-Enrichment
9. **Client Response**: Strukturierte Antwort an Client
## Monitoring und Health Checks
### Actuator Endpunkte
- `/actuator/health` - Umfassender Health Status aller Services
- `/actuator/metrics` - Prometheus-kompatible Metriken
- `/actuator/info` - Anwendungs- und Build-Informationen
- `/actuator/gateway` - Gateway-spezifische Routing-Informationen
- `/actuator/circuitbreakers` - Circuit Breaker Status
### Key Performance Indicators (KPIs)
#### Automatisch erfasste Metriken
- **Request Throughput**: `gateway_requests_total` - Anfragen pro Sekunde nach Method/Path
- **Response Times**: `gateway_request_duration` - P50, P90, P95, P99 Percentile nach Status
- **Error Rates**: `gateway_errors_total` - 4xx/5xx Response Codes mit Error-Type Klassifikation
- **Circuit Breaker Events**: `gateway_circuit_breaker_events_total` - Resilience Pattern Aktivierungen
- **Service Availability**: Upstream Service Health via Health Indicators
#### Verfügbare Metric Tags für detaillierte Analyse
- **method**: HTTP-Method (GET, POST, PUT, DELETE, etc.)
- **path**: Normalisierter API-Pfad (z.B. `/api/horses/{id}`)
- **status**: HTTP-Status-Code (200, 404, 500, etc.)
- **status_series**: Status-Gruppe (2xx, 3xx, 4xx, 5xx)
- **error_type**: Fehler-Klassifikation (client_error, server_error)
- **service**: Automatisches "gateway" Tag
- **component**: Automatisches "infrastructure" Tag
#### Prometheus Query Beispiele
```promql
# Request Rate pro Endpunkt
rate(gateway_requests_total[5m])
# 95. Percentile Response Time
histogram_quantile(0.95, rate(gateway_request_duration_bucket[5m]))
# Error Rate nach Service
rate(gateway_errors_total[5m]) / rate(gateway_requests_total[5m])
# Circuit Breaker Aktivierungen
increase(gateway_circuit_breaker_events_total[1h])
```
## Security Features
### JWT Authentication
- **Bearer Token Validation**: Automatische JWT-Verifikation
- **Role Extraction**: User-Rolle für Backend Services verfügbar
- **Token Refresh**: Unterstützung für Token-Erneuerung
- **Public Endpoints**: Konfigurierbare Ausnahmen für öffentliche APIs
### Security Headers
```yaml
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Cache-Control: no-cache, no-store, must-revalidate
```
## Development und Testing
### Local Development
**WICHTIG:** Alle Befehle müssen aus dem Projekt-Root-Verzeichnis (`/home/stefan/WsMeldestelle/Meldestelle`) ausgeführt werden.
```bash
# Sicherstellen, dass Sie im richtigen Verzeichnis sind
cd /home/stefan/WsMeldestelle/Meldestelle
# Gateway starten
./gradlew :infrastructure:gateway:bootRun
# Mit Docker
docker build -t meldestelle/gateway:latest -f infrastructure/gateway/Dockerfile .
docker run -p 8081:8081 meldestelle/gateway:latest
```
📖 **Detaillierte Startup-Anleitung:** Siehe `GATEWAY-STARTUP-GUIDE.md` im Projekt-Root für vollständige Befehle und Fehlerbehebung.
### Testing
```bash
# Unit Tests
./gradlew :infrastructure:gateway:test
# Integration Tests (mit Testcontainers)
./gradlew :infrastructure:gateway:integrationTest
```
## Konfiguration
### Environment Variables
```bash
SPRING_PROFILES_ACTIVE=prod
CONSUL_HOST=consul.meldestelle.at
CONSUL_PORT=8500
GATEWAY_ADMIN_USER=admin
GATEWAY_ADMIN_PASSWORD=secure-password
```
### Profile-spezifische Konfiguration
- **dev**: Entwicklungsumgebung mit Debug-Logging
- **test**: Test-Umgebung mit Mock Services
- **prod**: Produktionsumgebung mit allen Security Features
## Deployment
### Docker Deployment
```bash
# Multi-stage Build mit Layer Caching
docker build -t meldestelle/gateway:1.0.0 \
-f infrastructure/gateway/Dockerfile .
# Container starten
docker run -d \
--name gateway \
-p 8081:8081 \
-e SPRING_PROFILES_ACTIVE=prod \
-e CONSUL_HOST=consul \
meldestelle/gateway:1.0.0
```
### Kubernetes Deployment
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-gateway
spec:
replicas: 3
selector:
matchLabels:
app: api-gateway
template:
spec:
containers:
- name: gateway
image: meldestelle/gateway:1.0.0
ports:
- containerPort: 8081
livenessProbe:
httpGet:
path: /actuator/health
port: 8081
initialDelaySeconds: 90
periodSeconds: 30
```
## Troubleshooting
### Häufige Probleme
**Service Discovery Issues**
- Consul Connectivity prüfen
- Service Registration Status überprüfen
- DNS Resolution testen
**Circuit Breaker Activation**
- Service Health Status prüfen
- Failure Rate Threshold analysieren
- Manual Circuit Breaker Reset über Actuator
**Performance Issues**
- Connection Pool Metrics analysieren
- JVM Heap Usage monitoring
- Request Rate Limiting überprüfen
**Metriken und Monitoring Issues**
- Prometheus Scraping Endpunkt prüfen: `/actuator/prometheus`
- Metrics Registry Status überprüfen: `/actuator/metrics`
- GatewayMetricsWebFilter Aktivierung validieren
- Metric Tags auf Kardinalitäts-Explosion prüfen
- Path Normalization bei unerwarteten Metric-Namen
### Logging und Debugging
```bash
# Logs mit Korrelations-IDs
docker logs gateway | grep "correlationId"
# Circuit Breaker Status
curl http://localhost:8081/actuator/circuitbreakers
# Health Details
curl http://localhost:8081/actuator/health
```
## Zukünftige Erweiterungen
### Geplante Features
- **OAuth2/OIDC Integration**: Erweiterte Authentifizierung
- **GraphQL Gateway**: Unified GraphQL Interface
- **Caching Layer**: Redis-basiertes Response Caching
- **Request/Response Transformation**: Dynamic Content Modification
### Performance Optimierungen
- **HTTP/2 Support**: Moderne Protocol-Unterstützung
- **Connection Pooling Tuning**: Erweiterte Pool-Konfiguration
- **Reactive Streams Optimization**: Backpressure Handling
## Dokumentation und Ressourcen
### API Dokumentation
- **Swagger UI**: `/swagger` - Interactive API Documentation
- **OpenAPI Spec**: `/openapi` - Machine-readable API Specification
- **Static Documentation**: `/docs` - Comprehensive Documentation Hub
### Monitoring Dashboards
- **Health Status**: `/actuator/health` - Real-time Service Health
- **Metrics**: `/actuator/metrics` - Prometheus Metrics
- **Gateway Routes**: `/actuator/gateway/routes` - Active Route Information
---
**Letzte Aktualisierung**: 14. August 2025
**Version**: 1.1.0
**Maintainer**: Meldestelle Development Team
---
Diese Dokumentation wurde umfassend aktualisiert und um die neue Micrometer Metrics Integration (GatewayMetricsConfig.kt) erweitert. Sie dokumentiert alle implementierten Optimierungen einschließlich der vollständigen Observability-Architektur mit automatischer Request/Response Zeit Messung, Error Rate Tracking und Custom Business Metrics.
@@ -0,0 +1,11 @@
package at.mocode.infrastructure.gateway
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class GatewayApplication
fun main(args: Array<String>) {
runApplication<GatewayApplication>(*args)
}
@@ -0,0 +1,256 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.infrastructure.gateway.config
import org.slf4j.LoggerFactory
import org.springframework.cloud.gateway.filter.GatewayFilterChain
import org.springframework.cloud.gateway.filter.GlobalFilter
import org.springframework.core.Ordered
import org.springframework.http.HttpStatus
import org.springframework.http.server.reactive.ServerHttpRequest
import org.springframework.http.server.reactive.ServerHttpResponse
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
import java.util.concurrent.ConcurrentHashMap
import kotlin.uuid.Uuid
/**
* Gateway-Konfiguration für erweiterte Funktionalitäten wie Logging, Rate Limiting und Security.
*/
/**
* Global Filter für Correlations-IDs zur Request-Verfolgung.
*/
@Component
class CorrelationIdFilter : GlobalFilter, Ordered {
companion object {
const val CORRELATION_ID_HEADER = "X-Correlation-ID"
}
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
val request = exchange.request
val correlationId = request.headers.getFirst(CORRELATION_ID_HEADER)
?: Uuid.random().toString()
val mutatedRequest = request.mutate()
.header(CORRELATION_ID_HEADER, correlationId)
.build()
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)
}
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE
}
/**
* Enhanced Logging Filter für strukturiertes Logging mit Request/Response Details.
*/
@Component
class EnhancedLoggingFilter : GlobalFilter, Ordered {
private val logger = LoggerFactory.getLogger(EnhancedLoggingFilter::class.java)
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
val startTime = System.currentTimeMillis()
val request = exchange.request
val correlationId = request.headers.getFirst(CorrelationIdFilter.CORRELATION_ID_HEADER)
logRequest(request, correlationId)
return chain.filter(exchange)
.doOnSuccess {
val responseTime = System.currentTimeMillis() - startTime
logResponse(exchange.response, correlationId, responseTime)
}
.doOnError { error ->
val responseTime = System.currentTimeMillis() - startTime
logError(error, correlationId, responseTime)
}
}
private fun logRequest(request: ServerHttpRequest, correlationId: String?) {
logger.info("""
[REQUEST] [{}]
Method: {}
URI: {}
RemoteAddress: {}
UserAgent: {}
""".trimIndent(),
correlationId,
request.method,
request.uri,
request.remoteAddress,
request.headers.getFirst("User-Agent")
)
}
private fun logResponse(response: ServerHttpResponse, correlationId: String?, responseTime: Long) {
logger.info("""
[RESPONSE] [{}]
Status: {}
ResponseTime: {}ms
""".trimIndent(),
correlationId,
response.statusCode,
responseTime
)
}
private fun logError(error: Throwable, correlationId: String?, responseTime: Long) {
logger.error("""
[ERROR] [{}]
Error: {}
ResponseTime: {}ms
""".trimIndent(),
correlationId,
error.message,
responseTime,
error
)
}
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + 1
}
/**
* Rate Limiting Filter basierend auf IP-Adresse und User-Typ.
*
* Optimierungen:
* - Memory-Leak-Schutz durch regelmäßige Bereinigung alter Einträge
* - Sichere Rollenvalidierung basierend auf JWT-Authentifizierung
* - Bessere Verteilung der Rate-Limits basierend auf Benutzerrollen
*/
@Component
class RateLimitingFilter : GlobalFilter, Ordered {
private val requestCounts = ConcurrentHashMap<String, RequestCounter>()
private val logger = LoggerFactory.getLogger(RateLimitingFilter::class.java)
// Timestamp der letzten Bereinigung
@Volatile
private var lastCleanup = System.currentTimeMillis()
companion object {
const val RATE_LIMIT_ENABLED_HEADER = "X-RateLimit-Enabled"
const val RATE_LIMIT_LIMIT_HEADER = "X-RateLimit-Limit"
const val RATE_LIMIT_REMAINING_HEADER = "X-RateLimit-Remaining"
// Rate Limits pro Minute
const val ANONYMOUS_LIMIT = 50
const val AUTHENTICATED_LIMIT = 200
const val ADMIN_LIMIT = 500
const val AUTH_ENDPOINT_LIMIT = 20
const val DEFAULT_LIMIT = 100
// Bereinigungsintervall: alle 5 Minuten
const val CLEANUP_INTERVAL_MS = 5 * 60 * 1000L
// Einträge, die älter als 10 Minuten sind, werden entfernt
const val ENTRY_MAX_AGE_MS = 10 * 60 * 1000L
}
data class RequestCounter(
var count: Int = 0,
var lastReset: Long = System.currentTimeMillis()
)
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
val request = exchange.request
val response = exchange.response
val clientIp = getClientIp(request)
val path = request.path.value()
// Periodische Bereinigung des Caches zur Vermeidung von memory Leaks
performPeriodicCleanup()
val limit = determineRateLimit(request, path)
val counter = requestCounts.computeIfAbsent(clientIp) { RequestCounter() }
// Zähler zurücksetzen, wenn mehr als eine Minute vergangen ist
val now = System.currentTimeMillis()
if (now - counter.lastReset > 60_000) {
counter.count = 0
counter.lastReset = now
}
counter.count++
// Rate-Limit-Header hinzufügen
response.headers.add(RATE_LIMIT_ENABLED_HEADER, "true")
response.headers.add(RATE_LIMIT_LIMIT_HEADER, limit.toString())
response.headers.add(RATE_LIMIT_REMAINING_HEADER, maxOf(0, limit - counter.count).toString())
return if (counter.count > limit) {
response.statusCode = HttpStatus.TOO_MANY_REQUESTS
response.setComplete()
} else {
chain.filter(exchange)
}
}
private fun getClientIp(request: ServerHttpRequest): String {
return request.headers.getFirst("X-Forwarded-For")?.split(",")?.first()?.trim()
?: request.headers.getFirst("X-Real-IP")
?: request.remoteAddress?.address?.hostAddress
?: "unknown"
}
private fun determineRateLimit(request: ServerHttpRequest, path: String): Int {
return when {
path.startsWith("/api/auth") -> AUTH_ENDPOINT_LIMIT
isAdminUser(request) -> ADMIN_LIMIT
isAuthenticatedUser(request) -> AUTHENTICATED_LIMIT
else -> ANONYMOUS_LIMIT
}
}
private fun isAuthenticatedUser(request: ServerHttpRequest): Boolean {
return request.headers.getFirst("Authorization") != null
}
private fun isAdminUser(request: ServerHttpRequest): Boolean {
// Sichere Rollenvalidierung basierend auf JWT-Authentifizierung
// die X-User-Role wird vom JwtAuthenticationFilter nach erfolgreicher JWT-Validierung gesetzt
val userRole = request.headers.getFirst("X-User-Role")
val userId = request.headers.getFirst("X-User-ID")
// Zusätzliche Sicherheitsprüfung: Beide Header müssen vorhanden sein.
// Dies reduziert die Wahrscheinlichkeit von Header-Spoofing
return userRole == "ADMIN" && userId != null
}
/**
* Bereinigt alte Einträge aus dem requestCounts Cache zur Vermeidung von memory Leaks.
* Wird nur alle CLEANUP_INTERVAL_MS ausgeführt für bessere Performance.
*/
private fun performPeriodicCleanup() {
val now = System.currentTimeMillis()
if (now - lastCleanup > CLEANUP_INTERVAL_MS) {
val sizeBefore = requestCounts.size
val cutoffTime = now - ENTRY_MAX_AGE_MS
// Entferne alle Einträge, die älter als ENTRY_MAX_AGE_MS sind
requestCounts.entries.removeIf { (_, counter) ->
counter.lastReset < cutoffTime
}
lastCleanup = now
val sizeAfter = requestCounts.size
if (sizeBefore > sizeAfter) {
logger.debug("Rate limit cache cleanup: removed {} old entries, {} entries remaining",
sizeBefore - sizeAfter, sizeAfter)
}
}
}
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + 2
}
@@ -0,0 +1,71 @@
package at.mocode.infrastructure.gateway.controller
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestMethod
import org.springframework.web.bind.annotation.RestController
import java.time.LocalDateTime
/**
* Fallback Controller für Circuit Breaker Szenarien.
* Bietet standardisierte Fehlermeldungen, wenn Backend-Services nicht verfügbar sind.
*/
@RestController
@RequestMapping("/fallback")
class FallbackController {
@RequestMapping(value = ["/members"], method = [RequestMethod.GET, RequestMethod.POST])
fun membersFallback(): ResponseEntity<ErrorResponse> {
return createFallbackResponse("members-service", "Member operations are temporarily unavailable")
}
@RequestMapping(value = ["/horses"], method = [RequestMethod.GET, RequestMethod.POST])
fun horsesFallback(): ResponseEntity<ErrorResponse> {
return createFallbackResponse("horses-service", "Horse registry operations are temporarily unavailable")
}
@RequestMapping(value = ["/events"], method = [RequestMethod.GET, RequestMethod.POST])
fun eventsFallback(): ResponseEntity<ErrorResponse> {
return createFallbackResponse("events-service", "Event management operations are temporarily unavailable")
}
@RequestMapping(value = ["/masterdata"], method = [RequestMethod.GET, RequestMethod.POST])
fun masterdataFallback(): ResponseEntity<ErrorResponse> {
return createFallbackResponse("masterdata-service", "Master data operations are temporarily unavailable")
}
@RequestMapping(value = ["/auth"], method = [RequestMethod.GET, RequestMethod.POST])
fun authFallback(): ResponseEntity<ErrorResponse> {
return createFallbackResponse("auth-service", "Authentication operations are temporarily unavailable")
}
@RequestMapping(value = [""], method = [RequestMethod.GET, RequestMethod.POST])
fun defaultFallback(): ResponseEntity<ErrorResponse> {
return createFallbackResponse("unknown-service", "Service is temporarily unavailable")
}
private fun createFallbackResponse(service: String, message: String): ResponseEntity<ErrorResponse> {
val errorResponse = ErrorResponse(
error = "SERVICE_UNAVAILABLE",
message = message,
service = service,
timestamp = LocalDateTime.now(),
status = HttpStatus.SERVICE_UNAVAILABLE.value(),
suggestion = "Please try again in a few moments. If the problem persists, contact support."
)
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse)
}
}
/**
* Standardisierte Fehlerantwort für Circuit Breaker Fallbacks.
*/
data class ErrorResponse(
val error: String,
val message: String,
val service: String,
val timestamp: LocalDateTime,
val status: Int,
val suggestion: String
)
@@ -0,0 +1,142 @@
package at.mocode.infrastructure.gateway.health
import org.springframework.boot.actuate.health.Health
import org.springframework.boot.actuate.health.HealthIndicator
import org.springframework.cloud.client.discovery.DiscoveryClient
import org.springframework.core.env.Environment
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.WebClientResponseException
import java.time.Duration
/**
* Gateway Health Indicator zur Überwachung der Downstream Services.
*
* Prüft die Verfügbarkeit aller registrierten Services über Consul Discovery
* und führt Health-Checks für kritische Services durch.
*/
@Component
class GatewayHealthIndicator(
private val discoveryClient: DiscoveryClient,
private val webClient: WebClient.Builder,
private val environment: Environment
) : HealthIndicator {
companion object {
private val CRITICAL_SERVICES = setOf(
"ping-service"
)
private val OPTIONAL_SERVICES = setOf(
"members-service",
"horses-service",
"events-service",
"masterdata-service",
"auth-service"
)
private val HEALTH_CHECK_TIMEOUT = Duration.ofSeconds(5)
}
override fun health(): Health {
val builder = Health.up()
val details = mutableMapOf<String, Any>()
try {
// Prüfe alle registrierten Services in Consul
val allServices = discoveryClient.services
val discoveredServices = mutableMapOf<String, Any>()
allServices.forEach { serviceName ->
val instances = discoveryClient.getInstances(serviceName)
discoveredServices[serviceName] = mapOf(
"instanceCount" to instances.size,
"instances" to instances.map { "${it.host}:${it.port}" }
)
}
details["discoveredServices"] = discoveredServices
details["totalServices"] = allServices.size
// Prüfe kritische Services
val criticalServiceStatus = mutableMapOf<String, String>()
var hasCriticalFailure = false
CRITICAL_SERVICES.forEach { serviceName ->
val status = checkServiceHealth(serviceName)
criticalServiceStatus[serviceName] = status
if (status != "UP") {
hasCriticalFailure = true
}
}
// Prüfe optionale Services
val optionalServiceStatus = mutableMapOf<String, String>()
OPTIONAL_SERVICES.forEach { serviceName ->
optionalServiceStatus[serviceName] = checkServiceHealth(serviceName)
}
details["criticalServices"] = criticalServiceStatus
details["optionalServices"] = optionalServiceStatus
// Gateway Status basierend auf kritischen Services
val isTestEnvironment = environment.activeProfiles.contains("test")
val isDevEnvironment = environment.activeProfiles.contains("dev")
if (hasCriticalFailure && !isTestEnvironment && !isDevEnvironment) {
builder.down()
details["status"] = "DOWN"
details["reason"] = "Ein oder mehrere kritische Services sind nicht verfügbar"
} else {
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"
}
}
} catch (exception: Exception) {
builder.down()
.withException(exception)
details["status"] = "DOWN"
details["reason"] = "Fehler beim Prüfen der nachgelagerten Services: ${exception.message}"
}
return builder.withDetails(details).build()
}
private fun checkServiceHealth(serviceName: String): String {
return try {
val instances = discoveryClient.getInstances(serviceName)
if (instances.isEmpty()) {
"NO_INSTANCES"
} else {
// Versuche Health-Check für die erste verfügbare Instanz
val instance = instances.first()
val healthUrl = "http://${instance.host}:${instance.port}/actuator/health"
val client = webClient.build()
val response = client.get()
.uri(healthUrl)
.retrieve()
.bodyToMono(Map::class.java)
.timeout(HEALTH_CHECK_TIMEOUT)
.onErrorReturn(mapOf("status" to "DOWN"))
.block()
val status = response?.get("status")?.toString() ?: "UNKNOWN"
if (status == "UP") "UP" else "DOWN"
}
} catch (exception: WebClientResponseException) {
when (exception.statusCode.value()) {
404 -> "NO_HEALTH_ENDPOINT"
503 -> "DOWN"
else -> "ERROR"
}
} catch (_: Exception) {
"ERROR"
}
}
}
@@ -0,0 +1,184 @@
package at.mocode.infrastructure.gateway.metrics
import io.micrometer.core.instrument.Counter
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Timer
import io.micrometer.core.instrument.config.MeterFilter
import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilter
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono
import java.time.Duration
/**
* Konfiguration für Gateway-spezifische Metriken mit Micrometer.
*
* Diese Konfiguration stellt folgende Metriken bereit:
* - Request/Response Zeit Metriken (Timer)
* - Fehlerrate Tracking (Counter)
* - Custom Business Metrics
*
* Alle Metriken werden automatisch an Prometheus exportiert durch die
* bestehende monitoring-client Integration.
*/
@Configuration
class GatewayMetricsConfig {
companion object {
// Metric Namen als Konstanten für bessere Wartbarkeit
const val GATEWAY_REQUEST_TIMER = "gateway_custom_request_duration"
const val GATEWAY_ERROR_COUNTER = "gateway_errors_total"
const val GATEWAY_REQUESTS_COUNTER = "gateway_requests_total"
const val GATEWAY_CIRCUIT_BREAKER_COUNTER = "gateway_circuit_breaker_events_total"
const val GATEWAY_DOWNSTREAM_HEALTH_GAUGE = "gateway_downstream_health_status"
}
/**
* Konfiguriert globale Meter-Registry Einstellungen für das Gateway.
*/
@Bean
fun gatewayMeterRegistryCustomizer(): MeterRegistryCustomizer<MeterRegistry> {
return MeterRegistryCustomizer { registry ->
// Gemeinsame Tags für alle Gateway-Metriken
registry.config()
.commonTags("service", "gateway", "component", "infrastructure")
// Filterung von zu detaillierten Metriken
.meterFilter(MeterFilter.deny { id ->
val name = id.name
// Ausschluss von internen Spring/Netty Metriken, die zu viel Rauschen erzeugen
name.startsWith("reactor.netty.connection.provider") ||
name.startsWith("reactor.netty.bytebuf.allocator") ||
name.startsWith("jvm.gc.overhead")
})
// Histogram-Buckets für Request Duration optimieren
.meterFilter(MeterFilter.accept())
}
}
/**
* WebFilter für automatische Request/Response Zeit und Error Rate Tracking.
*
* Dieser Filter misst:
* - Gesamte Request-Verarbeitungszeit
* - Anzahl der Requests nach Status-Code kategorisiert
* - Error-Rate basierend auf HTTP Status Codes
*/
@Bean
fun gatewayMetricsWebFilter(meterRegistry: MeterRegistry): WebFilter {
return GatewayMetricsWebFilter(meterRegistry)
}
/**
* Bean für Request Duration Timer - entfernt um Konflikte mit dem WebFilter zu vermeiden.
* Die Request-Zeiten werden automatisch im GatewayMetricsWebFilter erfasst.
*/
// @Bean - entfernt, um Prometheus Meter-Konflikte zu vermeiden,
// fun requestTimer(meterRegistry: MeterRegistry): Timer { ... }
/**
* Bean für Error Counter - ermöglicht manuelles Error Tracking.
*/
@Bean
fun errorCounter(meterRegistry: MeterRegistry): Counter {
return Counter.builder(GATEWAY_ERROR_COUNTER)
.description("Gesamtanzahl der Gateway-Fehler")
.register(meterRegistry)
}
/**
* Bean für Request Counter - ermöglicht Request-Volumen Tracking.
* Hinweis: Dieser Counter wird nur als Fallback registriert.
* Die tatsächlichen Requests werden mit dynamischen Tags im WebFilter erfasst.
*/
@Bean
fun requestCounter(meterRegistry: MeterRegistry): Counter {
return Counter.builder("${GATEWAY_REQUESTS_COUNTER}_fallback")
.description("Gateway-Requests Fallback Counter")
.register(meterRegistry)
}
/**
* Bean für Circuit Breaker Events Counter.
*/
@Bean
fun circuitBreakerCounter(meterRegistry: MeterRegistry): Counter {
return Counter.builder(GATEWAY_CIRCUIT_BREAKER_COUNTER)
.description("Circuit Breaker Events im Gateway")
.register(meterRegistry)
}
}
/**
* WebFilter Implementation für automatische Metrics-Erfassung.
*/
class GatewayMetricsWebFilter(private val meterRegistry: MeterRegistry) : WebFilter {
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
val startTime = System.nanoTime()
val request = exchange.request
val path = request.path.value()
val method = request.method.toString()
// Request Counter incrementer
Counter.builder(GatewayMetricsConfig.GATEWAY_REQUESTS_COUNTER)
.tag("method", method)
.tag("path", normalizePath(path))
.description("Gateway-Requests gesamt")
.register(meterRegistry)
.increment()
return chain.filter(exchange)
.doFinally { _ ->
val duration = Duration.ofNanos(System.nanoTime() - startTime)
val response = exchange.response
val statusCode = response.statusCode?.value()?.toString() ?: "unknown"
val statusSeries = when {
statusCode.startsWith("2") -> "2xx"
statusCode.startsWith("3") -> "3xx"
statusCode.startsWith("4") -> "4xx"
statusCode.startsWith("5") -> "5xx"
else -> "unknown"
}
// Request Duration Timer
Timer.builder(GatewayMetricsConfig.GATEWAY_REQUEST_TIMER)
.tag("method", method)
.tag("path", normalizePath(path))
.tag("status", statusCode)
.tag("status_series", statusSeries)
.description("Gateway Request-Verarbeitungszeit")
.register(meterRegistry)
.record(duration)
// Error Counter für 4xx und 5xx Responses
if (statusCode.startsWith("4") || statusCode.startsWith("5")) {
Counter.builder(GatewayMetricsConfig.GATEWAY_ERROR_COUNTER)
.tag("method", method)
.tag("path", normalizePath(path))
.tag("status", statusCode)
.tag("status_series", statusSeries)
.tag("error_type", if (statusCode.startsWith("4")) "client_error" else "server_error")
.description("Gateway-Fehleranzahl")
.register(meterRegistry)
.increment()
}
}
}
/**
* Normalisiert Pfade für Metriken, um Kardinalität-Explosion zu vermeiden.
* Beispiel: /api/horses/123 → /api/horses/{id}
*/
private fun normalizePath(path: String): String {
return path
// UUID pattern ersetzen
.replace(Regex("/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"), "/{uuid}")
// Numerische IDs ersetzen
.replace(Regex("/\\d+"), "/{id}")
// Sehr lange Pfade kürzen
.let { if (it.length > 100) "${it.substring(0, 97)}..." else it }
}
}
@@ -0,0 +1,143 @@
package at.mocode.infrastructure.gateway.security
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.config.web.server.invoke
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder
import org.springframework.security.web.server.SecurityWebFilterChain
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.reactive.CorsConfigurationSource
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource
import java.time.Duration
@Configuration
@EnableWebFluxSecurity
@EnableConfigurationProperties(GatewaySecurityProperties::class)
class SecurityConfig(
private val securityProperties: GatewaySecurityProperties
) {
/**
* Konfiguriert die zentrale Security-Filter-Kette für das Gateway.
*
* Diese Konfiguration nutzt den Standard-OAuth2-Resource-Server von Spring Security,
* um JWTs (z.B. von Keycloak) automatisch zu validieren.
*/
@Bean
fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
return http { // Start der modernen Kotlin-DSL
// 1. CORS-Konfiguration anwenden
cors { }
// 2. CSRF deaktivieren (für zustandslose APIs)
csrf { disable() }
// 3. Routen-Berechtigungen definieren
authorizeExchange {
// Öffentlich zugängliche Pfade aus der .yml-Datei laden
authorize(
pathMatchers(*securityProperties.publicPaths.toTypedArray()),
permitAll
)
// Alle anderen Pfade erfordern eine Authentifizierung
authorize(anyExchange, authenticated)
}
// 4. JWT-Validierung via Keycloak aktivieren
oauth2ResourceServer {
jwt { }
}
}
}
/**
* 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.
*/
@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
println("WARN: Failed to configure JWT decoder with JWK Set URI: $jwkSetUri - ${e.message}")
println("WARN: JWT authentication will not work until Keycloak is available")
createNoOpJwtDecoder()
}
} else {
println("INFO: No JWK Set URI configured, using no-op JWT decoder")
createNoOpJwtDecoder()
}
}
/**
* 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.
*/
private fun createNoOpJwtDecoder(): ReactiveJwtDecoder {
return ReactiveJwtDecoder { token ->
throw IllegalStateException("JWT validation is not available - Keycloak may not be running")
}
}
/**
* Definiert die zentrale und einzige CORS-Konfiguration für das Gateway.
*/
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val configuration = CorsConfiguration().apply {
allowedOriginPatterns = securityProperties.cors.allowedOriginPatterns.toList()
allowedMethods = securityProperties.cors.allowedMethods.toList()
allowedHeaders = securityProperties.cors.allowedHeaders.toList()
exposedHeaders = securityProperties.cors.exposedHeaders.toList()
allowCredentials = securityProperties.cors.allowCredentials
maxAge = securityProperties.cors.maxAge.seconds
}
return UrlBasedCorsConfigurationSource().apply {
registerCorsConfiguration("/**", configuration)
}
}
}
/**
* Configurations-Properties für alle sicherheitsrelevanten Einstellungen des Gateways.
*/
@ConfigurationProperties(prefix = "gateway.security")
data class GatewaySecurityProperties(
val cors: CorsProperties = CorsProperties(),
val publicPaths: List<String> = listOf(
"/",
"/fallback/**",
"/actuator/**",
"/webjars/**",
"/v3/api-docs/**",
"/api/auth/**", // Alle Auth-Endpunkte
"/api/ping/**"
)
)
/**
* DTO für CORS-Properties mit sinnvollen Standardwerten.
*/
data class CorsProperties(
val allowedOriginPatterns: Set<String> = setOf("http://localhost:[*]", "https://*.meldestelle.at"),
val allowedMethods: Set<String> = setOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"),
val allowedHeaders: Set<String> = setOf("*"),
val exposedHeaders: Set<String> = setOf("X-Correlation-ID", "X-RateLimit-Limit", "X-RateLimit-Remaining"),
val allowCredentials: Boolean = true,
val maxAge: Duration = Duration.ofHours(1)
)
@@ -0,0 +1,28 @@
# ===================================================================
# Keycloak Profile Configuration
# ===================================================================
# This profile configures OAuth2/JWT authentication with Keycloak.
# Uses Spring Security's oauth2ResourceServer for secure JWT validation.
# ===================================================================
spring:
security:
oauth2:
resourceserver:
jwt:
# Issuer URI for JWT validation - Docker internal: keycloak:8080, External: localhost:8180
issuer-uri: ${KEYCLOAK_ISSUER_URI:http://keycloak:8180/realms/meldestelle}
# JWK Set URI for fetching public keys to validate JWT signatures
jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://keycloak:8180/realms/meldestelle/protocol/openid-connect/certs}
# Keycloak-spezifische Konfiguration
keycloak:
# Internal Docker service name, external via port 8180
server-url: ${KEYCLOAK_SERVER_URL:http://keycloak:8180}
issuer-uri: ${KEYCLOAK_ISSUER_URI:http://keycloak:8180/realms/meldestelle}
jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://keycloak:8180/realms/meldestelle/protocol/openid-connect/certs}
realm: ${KEYCLOAK_REALM:meldestelle}
resource: ${KEYCLOAK_CLIENT_ID:api-gateway}
client-id: ${KEYCLOAK_CLIENT_ID:api-gateway}
public-client: false
bearer-only: true
@@ -0,0 +1,3 @@
# Placeholder HOCON configuration for compatibility with legacy test scripts
# The actual configuration is provided in application.yml.
# This file ensures scripts that check for application.conf do not fail.
@@ -0,0 +1,299 @@
# Port, auf dem das Gateway läuft
server:
port: ${SERVER_PORT:8081}
# Optimierte Netty-Konfiguration für reaktive Anwendungen
netty:
connection-timeout: 5s
idle-timeout: 15s
# Der Name, unter dem sich das Gateway in Consul registriert
spring:
application:
name: api-gateway
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
security:
user:
name: ${GATEWAY_ADMIN_USER:admin}
password: ${GATEWAY_ADMIN_PASSWORD:admin}
cloud:
consul:
host: ${CONSUL_HOST:localhost}
port: ${CONSUL_PORT:8500}
enabled: ${CONSUL_ENABLED:true}
discovery:
enabled: ${CONSUL_ENABLED:true}
register: ${CONSUL_ENABLED:true}
health-check-path: /actuator/health
health-check-interval: 10s
instance-id: ${spring.application.name}-${server.port}-${random.uuid}
gateway:
server:
webflux:
httpclient:
connect-timeout: 5000
response-timeout: 30s
pool:
max-idle-time: 15s
max-life-time: 60s
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
- name: CircuitBreaker
args:
name: defaultCircuitBreaker
fallbackUri: forward:/fallback
- name: Retry
args:
retries: 3
statuses: BAD_GATEWAY,GATEWAY_TIMEOUT
methods: GET,POST,PUT,DELETE
backoff:
firstBackoff: 50ms
maxBackoff: 500ms
factor: 2
basedOnPreviousValue: false
- name: AddResponseHeader
args:
name: X-Content-Type-Options
value: nosniff
- name: AddResponseHeader
args:
name: X-Frame-Options
value: DENY
- name: AddResponseHeader
args:
name: X-XSS-Protection
value: 1; mode=block
- name: AddResponseHeader
args:
name: Referrer-Policy
value: strict-origin-when-cross-origin
- name: AddResponseHeader
args:
name: Cache-Control
value: no-cache, no-store, must-revalidate
routes:
# ==============================================================
# --- Gateway-Info-Route (optional) ---
# ==============================================================
- id: gateway-info-route
uri: http://localhost:${server.port}
predicates:
- Method=GET
- Path=/gateway-info
filters:
- name: SetStatus
args:
status: 200
- name: SetResponseHeader
args:
name: Content-Type
value: application/json
# ==============================================================
# --- Ping-Service-Integration (optional) ---
# ==============================================================
- id: ping-service-route
uri: lb://ping-service
predicates:
- Path=/api/ping/**
filters:
- StripPrefix=1
# ==============================================================
# --- Members-Service-Integration (optional) ---
# ==============================================================
# - id: members-service-route
# uri: lb://members-service
# predicates:
# - Path=/api/members/**
# filters:
# - StripPrefix=1
# - name: CircuitBreaker
# args:
# name: membersCircuitBreaker
# fallbackUri: forward:/fallback/members
# ==============================================================
# --- Horses-Service-Integration (optional) ---
# ==============================================================
# - id: horses-service-route
# uri: lb://horses-service
# predicates:
# - Path=/api/horses/**
# filters:
# - StripPrefix=1
# - name: CircuitBreaker
# args:
# name: horsesCircuitBreaker
# fallbackUri: forward:/fallback/horses
# ==============================================================
# --- Events-Service-Integration (optional) ---
# ==============================================================
# - id: events-service-route
# uri: lb://events-service
# predicates:
# - Path=/api/events/**
# filters:
# - StripPrefix=1
# - name: CircuitBreaker
# args:
# name: eventsCircuitBreaker
# fallbackUri: forward:/fallback/events
# ==============================================================
# --- Masterdata-Service-Integration (optional) ---
# ==============================================================
# - id: masterdata-service-route
# uri: lb://masterdata-service
# predicates:
# - Path=/api/masterdata/**
# filters:
# - StripPrefix=1
# - name: CircuitBreaker
# args:
# name: masterdataCircuitBreaker
# fallbackUri: forward:/fallback/masterdata
# ==============================================================
# --- Auth-Service-Integration (optional) ---
# ==============================================================
# - id: auth-service-route
# uri: lb://auth-service
# predicates:
# - Path=/api/auth/**
# filters:
# - StripPrefix=1
# - name: CircuitBreaker
# args:
# name: authCircuitBreaker
# fallbackUri: forward:/fallback/auth
# Circuit Breaker Konfiguration
resilience4j:
circuitbreaker:
configs:
default:
registerHealthIndicator: true
slidingWindowSize: 100
minimumNumberOfCalls: 20
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
waitDurationInOpenState: 5s
failureRateThreshold: 50
eventConsumerBufferSize: 10
recordExceptions:
- org.springframework.web.client.HttpServerErrorException
- java.util.concurrent.TimeoutException
- java.io.IOException
instances:
defaultCircuitBreaker:
baseConfig: default
membersCircuitBreaker:
baseConfig: default
slidingWindowSize: 50
horsesCircuitBreaker:
baseConfig: default
slidingWindowSize: 50
eventsCircuitBreaker:
baseConfig: default
slidingWindowSize: 75
masterdataCircuitBreaker:
baseConfig: default
slidingWindowSize: 30
authCircuitBreaker:
baseConfig: default
slidingWindowSize: 20
failureRateThreshold: 30
# Management und Monitoring
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,gateway,circuitbreakers
base-path: /actuator
cors:
allowed-origins:
- "https://*.meldestelle.at"
- "http://localhost:*"
allowed-methods: GET,POST
allowed-headers: "*"
allow-credentials: true
endpoint:
health:
show-details: always
show-components: always
probes:
enabled: true
metrics:
access: unrestricted
info:
access: unrestricted
prometheus:
access: unrestricted
gateway:
access: unrestricted
circuitbreakers:
enabled: true
metrics:
distribution:
percentiles-histogram:
http.server.requests: true
percentiles:
http.server.requests: 0.5,0.90,0.95,0.99
minimum-expected-value:
http.server.requests: 1ms
maximum-expected-value:
http.server.requests: 30s
tags:
application: ${spring.application.name}
environment: ${spring.profiles.active}
instance: ${spring.cloud.consul.discovery.instance-id}
service: gateway
component: infrastructure
gateway: api-gateway
info:
env:
enabled: true
git:
mode: full
build:
enabled: true
java:
enabled: true
# Tracing-Konfiguration - Aktiviert (Micrometer Tracing + Zipkin)
tracing:
enabled: ${TRACING_ENABLED:false}
sampling:
probability: ${TRACING_SAMPLING_PROBABILITY:1.0}
zipkin:
tracing:
endpoint: ${ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans}
# Reduziert Verbindungsfehler, wenn Zipkin nicht verfügbar ist
connect-timeout: 1s
read-timeout: 10s
# Erweiterte Logging-Konfiguration
logging:
level:
org.springframework.cloud.gateway: INFO
org.springframework.cloud.loadbalancer: DEBUG
org.springframework.cloud.consul: INFO
at.mocode.infrastructure.gateway: DEBUG
io.github.resilience4j: INFO
reactor.netty.http.client: INFO
org.springframework.security: WARN
org.springframework.web: INFO
pattern:
console: "%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr([%X{correlationId:-}]){yellow} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{correlationId:-}] %logger{36} - %msg%n"
file:
name: infrastructure/gateway/logs/gateway.log
logback:
rolling policy:
clean-history-on-start: true
max-file-size: 100MB
total-size-cap: 1GB
max-history: 30
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_PATTERN" value="%d{ISO8601} %-5level [%X{traceId:-}:%X{spanId:-}] %logger{36} - %msg%n"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<logger name="org.springframework" level="INFO"/>
<logger name="org.springframework.web" level="INFO"/>
<logger name="org.springframework.boot.actuate" level="INFO"/>
<logger name="reactor.netty" level="WARN"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true">
<property name="LOG_FILE" value="logs/gateway.log"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{correlationId}] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_FILE}</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/gateway.log.%d{yyyy-MM-dd}.%i.gz</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<maxHistory>30</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{correlationId}] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<logger name="org.springframework.cloud.gateway" level="INFO"/>
<logger name="org.springframework.cloud.loadbalancer" level="DEBUG"/>
<logger name="org.springframework.cloud.consul" level="INFO"/>
<logger name="io.github.resilience4j" level="INFO"/>
<logger name="reactor.netty.http.client" level="INFO"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,432 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meldestelle API Documentation</title>
<style>
:root {
--primary-color: #3498db;
--secondary-color: #2c3e50;
--accent-color: #e74c3c;
--light-bg: #f5f5f5;
--dark-bg: #2c3e50;
--text-color: #333;
--light-text: #f5f5f5;
--border-color: #ddd;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: var(--light-bg);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background-color: var(--dark-bg);
color: var(--light-text);
padding: 20px 0;
margin-bottom: 30px;
}
header .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 24px;
font-weight: bold;
}
nav ul {
display: flex;
list-style: none;
}
nav ul li {
margin-left: 20px;
}
nav ul li a {
color: var(--light-text);
text-decoration: none;
transition: color 0.3s;
}
nav ul li a:hover {
color: var(--primary-color);
}
.hero {
background-color: var(--primary-color);
color: var(--light-text);
padding: 50px 0;
margin-bottom: 30px;
text-align: center;
}
.hero h1 {
font-size: 36px;
margin-bottom: 20px;
}
.hero p {
font-size: 18px;
max-width: 800px;
margin: 0 auto 30px;
}
.btn {
display: inline-block;
background-color: var(--secondary-color);
color: var(--light-text);
padding: 10px 20px;
border-radius: 5px;
text-decoration: none;
transition: background-color 0.3s;
margin: 0 10px;
}
.btn:hover {
background-color: var(--accent-color);
}
.section {
margin-bottom: 40px;
}
.section h2 {
font-size: 24px;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid var(--primary-color);
}
.card {
background-color: white;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 20px;
}
.card h3 {
font-size: 20px;
margin-bottom: 15px;
color: var(--primary-color);
}
.card p {
margin-bottom: 15px;
}
.card .endpoints {
margin-top: 15px;
}
.card .endpoints h4 {
font-size: 16px;
margin-bottom: 10px;
}
.card .endpoints ul {
list-style: none;
margin-left: 20px;
}
.card .endpoints ul li {
margin-bottom: 5px;
}
.card .endpoints .method {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
font-weight: bold;
margin-right: 10px;
}
.card .endpoints .get {
background-color: #61affe;
color: white;
}
.card .endpoints .post {
background-color: #49cc90;
color: white;
}
.card .endpoints .put {
background-color: #fca130;
color: white;
}
.card .endpoints .delete {
background-color: #f93e3e;
color: white;
}
.resources {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-top: 20px;
}
.resource-card {
flex: 1;
min-width: 250px;
background-color: white;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.resource-card h3 {
font-size: 18px;
margin-bottom: 10px;
color: var(--primary-color);
}
.resource-card p {
margin-bottom: 15px;
}
footer {
background-color: var(--dark-bg);
color: var(--light-text);
padding: 20px 0;
text-align: center;
margin-top: 50px;
}
</style>
</head>
<body>
<header>
<div class="container">
<div class="logo">Meldestelle API</div>
<nav>
<ul>
<li><a href="#overview">Overview</a></li>
<li><a href="#contexts">API Contexts</a></li>
<li><a href="#resources">Resources</a></li>
<li><a href="/swagger" target="_blank">Swagger UI</a></li>
<li><a href="/openapi" target="_blank">OpenAPI Spec</a></li>
</ul>
</nav>
</div>
</header>
<section class="hero">
<div class="container">
<h1>Meldestelle Self-Contained Systems API</h1>
<p>Unified API Gateway for all bounded contexts of the Austrian Equestrian Federation's Meldestelle system.</p>
<div>
<a href="/swagger" class="btn" target="_blank">Interactive API Documentation</a>
<a href="/openapi" class="btn" target="_blank">OpenAPI Specification</a>
</div>
</div>
</section>
<div class="container">
<section id="overview" class="section">
<h2>Overview</h2>
<div class="card">
<p>The Meldestelle API provides a unified interface to various bounded contexts while maintaining the independence of each context. This API Gateway aggregates all bounded context APIs and provides a single entry point for clients.</p>
<p>The API follows REST principles and uses JSON for data exchange. All responses are wrapped in a consistent format using the <code>BaseDto</code> wrapper.</p>
<p>Authentication is handled using JWT (JSON Web Token) based authentication. Most endpoints require authentication, which can be obtained by registering and logging in through the Authentication Context.</p>
</div>
</section>
<section id="contexts" class="section">
<h2>API Contexts</h2>
<div class="card">
<h3>Authentication Context</h3>
<p>User authentication, registration, and profile management</p>
<p><strong>Base Path:</strong> /auth</p>
<div class="endpoints">
<h4>Key Endpoints:</h4>
<ul>
<li><span class="method post">POST</span> /auth/register - User registration</li>
<li><span class="method post">POST</span> /auth/login - User authentication</li>
<li><span class="method get">GET</span> /auth/profile - Get user profile</li>
<li><span class="method put">PUT</span> /auth/profile - Update user profile</li>
<li><span class="method post">POST</span> /auth/change-password - Change password</li>
</ul>
</div>
</div>
<div class="card">
<h3>Members Context</h3>
<p>Member registration, profile management, and membership administration</p>
<p><strong>Base Path:</strong> /api/members</p>
<div class="endpoints">
<h4>Key Endpoints:</h4>
<ul>
<li><span class="method get">GET</span> /api/members - Get all members with pagination</li>
<li><span class="method get">GET</span> /api/members/search - Search members by criteria</li>
<li><span class="method get">GET</span> /api/members/{id} - Get member by ID</li>
<li><span class="method post">POST</span> /api/members - Create new member</li>
<li><span class="method put">PUT</span> /api/members/{id} - Update member information</li>
<li><span class="method delete">DELETE</span> /api/members/{id} - Delete member (soft delete)</li>
</ul>
</div>
</div>
<div class="card">
<h3>Master Data Context</h3>
<p>Reference data management (countries, states, age classes, venues)</p>
<p><strong>Base Path:</strong> /api/masterdata</p>
<div class="endpoints">
<h4>Key Endpoints:</h4>
<ul>
<li><span class="method get">GET</span> /api/masterdata/countries - Get all countries</li>
<li><span class="method get">GET</span> /api/masterdata/countries/active - Get active countries</li>
<li><span class="method get">GET</span> /api/masterdata/countries/{id} - Get country by ID</li>
<li><span class="method post">POST</span> /api/masterdata/countries - Create country</li>
<li><span class="method put">PUT</span> /api/masterdata/countries/{id} - Update country</li>
<li><span class="method delete">DELETE</span> /api/masterdata/countries/{id} - Delete country</li>
</ul>
</div>
</div>
<div class="card">
<h3>Horse Registry Context</h3>
<p>Horse registration, ownership, and pedigree management</p>
<p><strong>Base Path:</strong> /api/horses</p>
<div class="endpoints">
<h4>Key Endpoints:</h4>
<ul>
<li><span class="method get">GET</span> /api/horses - Get all horses</li>
<li><span class="method get">GET</span> /api/horses/active - Get active horses</li>
<li><span class="method get">GET</span> /api/horses/{id} - Get horse by ID</li>
<li><span class="method get">GET</span> /api/horses/search - Search horses by name</li>
<li><span class="method post">POST</span> /api/horses - Create horse</li>
<li><span class="method put">PUT</span> /api/horses/{id} - Update horse</li>
<li><span class="method delete">DELETE</span> /api/horses/{id} - Delete horse</li>
</ul>
</div>
</div>
<div class="card">
<h3>Event Management Context</h3>
<p>Event creation, management, and participant registration</p>
<p><strong>Base Path:</strong> /api/events</p>
<div class="endpoints">
<h4>Key Endpoints:</h4>
<ul>
<li><span class="method get">GET</span> /api/events - Get all events</li>
<li><span class="method get">GET</span> /api/events/stats - Get event statistics</li>
<li><span class="method post">POST</span> /api/events - Create event</li>
<li><span class="method get">GET</span> /api/events/{id} - Get event by ID</li>
<li><span class="method put">PUT</span> /api/events/{id} - Update event</li>
<li><span class="method delete">DELETE</span> /api/events/{id} - Delete event</li>
<li><span class="method get">GET</span> /api/events/search - Search events</li>
</ul>
</div>
</div>
</section>
<section id="resources" class="section">
<h2>Documentation Resources</h2>
<div class="resources">
<div class="resource-card">
<h3>Swagger UI</h3>
<p>Interactive documentation for exploring and testing the API endpoints.</p>
<a href="/swagger" class="btn" target="_blank" aria-label="Open Swagger UI in new tab">Open Swagger UI</a>
</div>
<div class="resource-card">
<h3>OpenAPI Specification</h3>
<p>Raw OpenAPI 3.0.3 specification in YAML format for code generation or import into other tools.</p>
<a href="/openapi" class="btn" target="_blank" aria-label="View OpenAPI specification in new tab">View OpenAPI Spec</a>
</div>
<div class="resource-card">
<h3>Postman Collection</h3>
<p>Comprehensive API collection covering all endpoints with pre-configured request examples.</p>
<a href="/docs/postman/Meldestelle_API_Collection.json" class="btn" target="_blank" aria-label="Download Postman collection">Download Collection</a>
</div>
<div class="resource-card">
<h3>Health Monitoring</h3>
<p>Real-time health status and monitoring information for all downstream services.</p>
<a href="/actuator/health" class="btn" target="_blank" aria-label="View health monitoring in new tab">View Health Status</a>
</div>
</div>
</section>
<section id="monitoring" class="section">
<h2>System Monitoring & Health</h2>
<div class="card">
<h3>Health Check Endpoints</h3>
<p>The API Gateway provides comprehensive health monitoring for all downstream services:</p>
<div class="endpoints">
<h4>Monitoring Endpoints:</h4>
<ul>
<li><span class="method get">GET</span> /actuator/health - Comprehensive health status of all services</li>
<li><span class="method get">GET</span> /actuator/metrics - System metrics and performance data</li>
<li><span class="method get">GET</span> /actuator/info - Application information and build details</li>
<li><span class="method get">GET</span> /actuator/prometheus - Prometheus-compatible metrics export</li>
</ul>
</div>
<p><strong>Health Indicator Features:</strong></p>
<ul>
<li>Monitors critical services: Members, Horses, Events, Masterdata, Auth</li>
<li>Optional service monitoring: Ping service</li>
<li>Circuit breaker status integration</li>
<li>Service discovery status from Consul</li>
<li>Detailed error reporting and status codes</li>
</ul>
</div>
</section>
<section class="section">
<h2>Getting Started</h2>
<div class="card">
<h3>Authentication</h3>
<p>The API uses JWT (JSON Web Token) based authentication:</p>
<ol>
<li>Register a new user via <code>POST /auth/register</code></li>
<li>Login with credentials via <code>POST /auth/login</code></li>
<li>Extract the JWT token from the login response</li>
<li>Include the token in the <code>Authorization</code> header: <code>Bearer &lt;token&gt;</code></li>
</ol>
</div>
<div class="card">
<h3>Response Format</h3>
<p>All API responses follow a consistent format using the <code>BaseDto</code> wrapper:</p>
<pre><code>{
"success": true,
"data": {
"example": "Actual response data goes here"
},
"message": "Operation completed successfully",
"timestamp": "2024-01-15T10:30:00Z"
}</code></pre>
</div>
</section>
</div>
<footer>
<div class="container">
<p>&copy; 2024 Meldestelle API. All rights reserved.</p>
</div>
</footer>
</body>
</html>
@@ -0,0 +1,576 @@
{
"info": {
"name": "Meldestelle Self-Contained Systems API",
"description": "Comprehensive API collection for the Austrian Equestrian Federation Meldestelle system. This collection covers all bounded contexts including Authentication, Master Data, and Horse Registry.",
"version": "1.0.0",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"key": "baseUrl",
"value": "http://localhost:8080",
"type": "string"
},
{
"key": "authToken",
"value": "",
"type": "string"
}
],
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{authToken}}",
"type": "string"
}
]
},
"item": [
{
"name": "System Information",
"item": [
{
"name": "API Gateway Info",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/",
"host": ["{{baseUrl}}"],
"path": [""]
}
},
"response": []
},
{
"name": "Health Check",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/health",
"host": ["{{baseUrl}}"],
"path": ["health"]
}
},
"response": []
},
{
"name": "API Documentation",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api",
"host": ["{{baseUrl}}"],
"path": ["api"]
}
},
"response": []
},
{
"name": "Swagger UI",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/swagger",
"host": ["{{baseUrl}}"],
"path": ["swagger"]
}
},
"response": []
}
]
},
{
"name": "Authentication Context",
"item": [
{
"name": "User Registration",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"test@example.com\",\n \"password\": \"SecurePassword123!\",\n \"firstName\": \"Test\",\n \"lastName\": \"User\",\n \"phoneNumber\": \"+43123456789\"\n}"
},
"url": {
"raw": "{{baseUrl}}/auth/register",
"host": ["{{baseUrl}}"],
"path": ["auth", "register"]
}
},
"response": []
},
{
"name": "User Login",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"test@example.com\",\n \"password\": \"SecurePassword123!\"\n}"
},
"url": {
"raw": "{{baseUrl}}/auth/login",
"host": ["{{baseUrl}}"],
"path": ["auth", "login"]
}
},
"response": [],
"event": [
{
"listen": "test",
"script": {
"exec": [
"if (pm.response.code === 200) {",
" const response = pm.response.json();",
" if (response.success && response.data && response.data.token) {",
" pm.collectionVariables.set('authToken', response.data.token);",
" console.log('Auth token saved:', response.data.token);",
" }",
"}"
],
"type": "text/javascript"
}
}
]
},
{
"name": "Get User Profile",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"url": {
"raw": "{{baseUrl}}/auth/profile",
"host": ["{{baseUrl}}"],
"path": ["auth", "profile"]
}
},
"response": []
},
{
"name": "Update User Profile",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"firstName\": \"Updated\",\n \"lastName\": \"User\",\n \"phoneNumber\": \"+43987654321\"\n}"
},
"url": {
"raw": "{{baseUrl}}/auth/profile",
"host": ["{{baseUrl}}"],
"path": ["auth", "profile"]
}
},
"response": []
},
{
"name": "Change Password",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"currentPassword\": \"SecurePassword123!\",\n \"newPassword\": \"NewSecurePassword456!\"\n}"
},
"url": {
"raw": "{{baseUrl}}/auth/change-password",
"host": ["{{baseUrl}}"],
"path": ["auth", "change-password"]
}
},
"response": []
}
]
},
{
"name": "Master Data Context",
"item": [
{
"name": "Countries",
"item": [
{
"name": "Get All Countries",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/masterdata/countries",
"host": ["{{baseUrl}}"],
"path": ["api", "masterdata", "countries"]
}
},
"response": []
},
{
"name": "Get Active Countries",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/masterdata/countries/active",
"host": ["{{baseUrl}}"],
"path": ["api", "masterdata", "countries", "active"]
}
},
"response": []
},
{
"name": "Get Country by ID",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/masterdata/countries/{{countryId}}",
"host": ["{{baseUrl}}"],
"path": ["api", "masterdata", "countries", "{{countryId}}"]
}
},
"response": []
},
{
"name": "Get Country by ISO Code",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/masterdata/countries/iso/AT",
"host": ["{{baseUrl}}"],
"path": ["api", "masterdata", "countries", "iso", "AT"]
}
},
"response": []
},
{
"name": "Create Country",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"isoAlpha2Code\": \"TS\",\n \"isoAlpha3Code\": \"TST\",\n \"isoNumerischerCode\": \"999\",\n \"nameDeutsch\": \"Testland\",\n \"nameEnglisch\": \"Testland\",\n \"istEuMitglied\": false,\n \"istEwrMitglied\": false,\n \"istAktiv\": true,\n \"sortierReihenfolge\": 999\n}"
},
"url": {
"raw": "{{baseUrl}}/api/masterdata/countries",
"host": ["{{baseUrl}}"],
"path": ["api", "masterdata", "countries"]
}
},
"response": []
},
{
"name": "Update Country",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"isoAlpha2Code\": \"TS\",\n \"isoAlpha3Code\": \"TST\",\n \"isoNumerischerCode\": \"999\",\n \"nameDeutsch\": \"Updated Testland\",\n \"nameEnglisch\": \"Updated Testland\",\n \"istEuMitglied\": false,\n \"istEwrMitglied\": false,\n \"istAktiv\": true,\n \"sortierReihenfolge\": 999\n}"
},
"url": {
"raw": "{{baseUrl}}/api/masterdata/countries/{{countryId}}",
"host": ["{{baseUrl}}"],
"path": ["api", "masterdata", "countries", "{{countryId}}"]
}
},
"response": []
},
{
"name": "Delete Country",
"request": {
"method": "DELETE",
"header": [
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/masterdata/countries/{{countryId}}",
"host": ["{{baseUrl}}"],
"path": ["api", "masterdata", "countries", "{{countryId}}"]
}
},
"response": []
}
]
}
]
},
{
"name": "Horse Registry Context",
"item": [
{
"name": "Get All Horses",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/horses",
"host": ["{{baseUrl}}"],
"path": ["api", "horses"]
}
},
"response": []
},
{
"name": "Get Active Horses",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/horses/active",
"host": ["{{baseUrl}}"],
"path": ["api", "horses", "active"]
}
},
"response": []
},
{
"name": "Get Horse by ID",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/horses/{{horseId}}",
"host": ["{{baseUrl}}"],
"path": ["api", "horses", "{{horseId}}"]
}
},
"response": []
},
{
"name": "Search Horses by Name",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/horses/search?name=Test&limit=10",
"host": ["{{baseUrl}}"],
"path": ["api", "horses", "search"],
"query": [
{
"key": "name",
"value": "Test"
},
{
"key": "limit",
"value": "10"
}
]
}
},
"response": []
},
{
"name": "Get Horses by Owner",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/horses/owner/{{ownerId}}",
"host": ["{{baseUrl}}"],
"path": ["api", "horses", "owner", "{{ownerId}}"]
}
},
"response": []
},
{
"name": "Create Horse",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"pferdeName\": \"Test Horse\",\n \"geschlecht\": \"WALLACH\",\n \"geburtsdatum\": \"2020-05-15\",\n \"rasse\": \"Warmblut\",\n \"farbe\": \"Braun\",\n \"zuechterName\": \"Test Breeder\",\n \"stockmass\": 165,\n \"istAktiv\": true,\n \"bemerkungen\": \"Test horse for API demonstration\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/horses",
"host": ["{{baseUrl}}"],
"path": ["api", "horses"]
}
},
"response": []
},
{
"name": "Update Horse",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"pferdeName\": \"Updated Test Horse\",\n \"geschlecht\": \"WALLACH\",\n \"geburtsdatum\": \"2020-05-15\",\n \"rasse\": \"Warmblut\",\n \"farbe\": \"Dunkelbraun\",\n \"zuechterName\": \"Updated Test Breeder\",\n \"stockmass\": 167,\n \"istAktiv\": true,\n \"bemerkungen\": \"Updated test horse for API demonstration\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/horses/{{horseId}}",
"host": ["{{baseUrl}}"],
"path": ["api", "horses", "{{horseId}}"]
}
},
"response": []
},
{
"name": "Delete Horse",
"request": {
"method": "DELETE",
"header": [
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/horses/{{horseId}}",
"host": ["{{baseUrl}}"],
"path": ["api", "horses", "{{horseId}}"]
}
},
"response": []
},
{
"name": "Batch Delete Horses",
"request": {
"method": "DELETE",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"horseIds\": [\"{{horseId1}}\", \"{{horseId2}}\"],\n \"forceDelete\": false\n}"
},
"url": {
"raw": "{{baseUrl}}/api/horses/batch",
"host": ["{{baseUrl}}"],
"path": ["api", "horses", "batch"]
}
},
"response": []
},
{
"name": "Get Horse Statistics",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/horses/stats",
"host": ["{{baseUrl}}"],
"path": ["api", "horses", "stats"]
}
},
"response": []
}
]
}
]
}
@@ -0,0 +1,242 @@
package at.mocode.infrastructure.gateway
import at.mocode.infrastructure.gateway.config.TestSecurityConfig
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.reactive.server.WebTestClient
/**
* Tests für den Fallback Controller, der Circuit Breaker Szenarien behandelt.
* Testet alle Fallback-Endpunkte für verschiedene Services.
*/
@SpringBootTest(
classes = [GatewayApplication::class],
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
// Externe Abhängigkeiten für Fallback-Tests deaktivieren
"spring.cloud.discovery.enabled=false",
"spring.cloud.consul.enabled=false",
"spring.cloud.consul.config.enabled=false",
"spring.cloud.consul.discovery.register=false",
"spring.cloud.loadbalancer.enabled=false",
// Circuit Breaker Health Indicator deaktivieren um Interferenzen zu vermeiden
"resilience4j.circuitbreaker.configs.default.registerHealthIndicator=false",
"management.health.circuitbreakers.enabled=false",
// Custom Filter für reine Fallback-Tests deaktivieren
"gateway.security.jwt.enabled=false",
// Reaktiven Web-Anwendungstyp verwenden
"spring.main.web-application-type=reactive",
// Gateway Discovery deaktivieren
"spring.cloud.gateway.server.webflux.discovery.locator.enabled=false",
// Actuator Security deaktivieren
"management.security.enabled=false",
// Zufälligen Port setzen
"server.port=0"
]
)
@ActiveProfiles("test")
@Import(TestSecurityConfig::class)
class FallbackControllerTests {
@Autowired
lateinit var webTestClient: WebTestClient
@Test
fun `sollte Members Service Fallback Response zurueckgeben`() {
webTestClient.get()
.uri("/fallback/members")
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectHeader().valueEquals("Content-Type", "application/json")
.expectBody()
.jsonPath("$.error").isEqualTo("SERVICE_UNAVAILABLE")
.jsonPath("$.message").isEqualTo("Member operations are temporarily unavailable")
.jsonPath("$.service").isEqualTo("members-service")
.jsonPath("$.status").isEqualTo(503)
.jsonPath("$.suggestion")
.isEqualTo("Please try again in a few moments. If the problem persists, contact support.")
.jsonPath("$.timestamp").exists()
}
@Test
fun `sollte Horses Service Fallback Response zurueckgeben`() {
webTestClient.get()
.uri("/fallback/horses")
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectHeader().valueEquals("Content-Type", "application/json")
.expectBody()
.jsonPath("$.error").isEqualTo("SERVICE_UNAVAILABLE")
.jsonPath("$.message").isEqualTo("Horse registry operations are temporarily unavailable")
.jsonPath("$.service").isEqualTo("horses-service")
.jsonPath("$.status").isEqualTo(503)
.jsonPath("$.suggestion").exists()
}
@Test
fun `sollte Events Service Fallback Response zurueckgeben`() {
webTestClient.get()
.uri("/fallback/events")
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectBody()
.jsonPath("$.error").isEqualTo("SERVICE_UNAVAILABLE")
.jsonPath("$.message").isEqualTo("Event management operations are temporarily unavailable")
.jsonPath("$.service").isEqualTo("events-service")
.jsonPath("$.status").isEqualTo(503)
}
@Test
fun `should return masterdata service fallback response`() {
webTestClient.get()
.uri("/fallback/masterdata")
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectBody()
.jsonPath("$.error").isEqualTo("SERVICE_UNAVAILABLE")
.jsonPath("$.message").isEqualTo("Master data operations are temporarily unavailable")
.jsonPath("$.service").isEqualTo("masterdata-service")
.jsonPath("$.status").isEqualTo(503)
}
@Test
fun `should return auth service fallback response`() {
webTestClient.get()
.uri("/fallback/auth")
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectBody()
.jsonPath("$.error").isEqualTo("SERVICE_UNAVAILABLE")
.jsonPath("$.message").isEqualTo("Authentication operations are temporarily unavailable")
.jsonPath("$.service").isEqualTo("auth-service")
.jsonPath("$.status").isEqualTo(503)
}
@Test
fun `should return default fallback response for unknown service`() {
webTestClient.get()
.uri("/fallback")
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectBody()
.jsonPath("$.error").isEqualTo("SERVICE_UNAVAILABLE")
.jsonPath("$.message").isEqualTo("Service is temporarily unavailable")
.jsonPath("$.service").isEqualTo("unknown-service")
.jsonPath("$.status").isEqualTo(503)
}
@Test
fun `should handle POST requests to members fallback`() {
webTestClient.post()
.uri("/fallback/members")
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectBody()
.jsonPath("$.error").isEqualTo("SERVICE_UNAVAILABLE")
.jsonPath("$.service").isEqualTo("members-service")
}
@Test
fun `should handle POST requests to horses fallback`() {
webTestClient.post()
.uri("/fallback/horses")
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectBody()
.jsonPath("$.error").isEqualTo("SERVICE_UNAVAILABLE")
.jsonPath("$.service").isEqualTo("horses-service")
}
@Test
fun `should handle POST requests to events fallback`() {
webTestClient.post()
.uri("/fallback/events")
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectBody()
.jsonPath("$.error").isEqualTo("SERVICE_UNAVAILABLE")
.jsonPath("$.service").isEqualTo("events-service")
}
@Test
fun `should handle POST requests to masterdata fallback`() {
webTestClient.post()
.uri("/fallback/masterdata")
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectBody()
.jsonPath("$.error").isEqualTo("SERVICE_UNAVAILABLE")
.jsonPath("$.service").isEqualTo("masterdata-service")
}
@Test
fun `should handle POST requests to auth fallback`() {
webTestClient.post()
.uri("/fallback/auth")
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectBody()
.jsonPath("$.error").isEqualTo("SERVICE_UNAVAILABLE")
.jsonPath("$.service").isEqualTo("auth-service")
}
@Test
fun `should handle POST requests to default fallback`() {
webTestClient.post()
.uri("/fallback")
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectBody()
.jsonPath("$.error").isEqualTo("SERVICE_UNAVAILABLE")
.jsonPath("$.service").isEqualTo("unknown-service")
}
@Test
fun `should return valid JSON structure for all fallback responses`() {
val fallbackPaths = listOf(
"/fallback/members",
"/fallback/horses",
"/fallback/events",
"/fallback/masterdata",
"/fallback/auth",
"/fallback"
)
fallbackPaths.forEach { path ->
webTestClient.get()
.uri(path)
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectHeader().valueEquals("Content-Type", "application/json")
.expectBody()
.jsonPath("$.error").isNotEmpty
.jsonPath("$.message").isNotEmpty
.jsonPath("$.service").isNotEmpty
.jsonPath("$.timestamp").isNotEmpty
.jsonPath("$.status").isNumber
.jsonPath("$.suggestion").isNotEmpty
}
}
@Test
fun `should have consistent error response structure`() {
webTestClient.get()
.uri("/fallback/members")
.exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
.expectBody()
.consumeWith { result ->
val body = String(result.responseBody ?: byteArrayOf())
assert(body.contains("error"))
assert(body.contains("message"))
assert(body.contains("service"))
assert(body.contains("timestamp"))
assert(body.contains("status"))
assert(body.contains("suggestion"))
}
}
}
@@ -0,0 +1,47 @@
package at.mocode.infrastructure.gateway
import at.mocode.infrastructure.gateway.config.TestSecurityConfig
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
/**
* Basis-Test zur Überprüfung, dass der Gateway-Anwendungskontext erfolgreich lädt.
* Verwendet Test-Profil um Produktions-Filter und externe Abhängigkeiten zu deaktivieren.
*/
@SpringBootTest(
classes = [GatewayApplication::class],
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
// Alle externen Abhängigkeiten für Context-Loading-Test deaktivieren
"spring.cloud.discovery.enabled=false",
"spring.cloud.consul.enabled=false",
"spring.cloud.consul.config.enabled=false",
"spring.cloud.consul.discovery.register=false",
"spring.cloud.loadbalancer.enabled=false",
// Circuit Breaker für Tests deaktivieren
"resilience4j.circuitbreaker.configs.default.registerHealthIndicator=false",
"management.health.circuitbreakers.enabled=false",
// Custom Security und Filter deaktivieren
"gateway.security.jwt.enabled=false",
// Reaktiven Web-Anwendungstyp verwenden
"spring.main.web-application-type=reactive",
// Gateway Discovery deaktivieren
"spring.cloud.gateway.server.webflux.discovery.locator.enabled=false",
// Actuator Security deaktivieren
"management.security.enabled=false",
// Zufälligen Port setzen
"server.port=0"
]
)
@ActiveProfiles("test")
@Import(TestSecurityConfig::class)
class GatewayApplicationTests {
@Test
fun contextLoads() {
// Dieser Test ist erfolgreich, wenn der Spring-Anwendungskontext erfolgreich lädt
// ohne Konfigurationsfehler oder fehlende Bean-Abhängigkeiten
}
}
@@ -0,0 +1,194 @@
package at.mocode.infrastructure.gateway
import at.mocode.infrastructure.gateway.config.TestSecurityConfig
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.cloud.gateway.route.RouteLocator
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
/**
* Tests for Gateway custom filters: CorrelationId, Enhanced Logging, and Rate Limiting.
* Tests filter behavior without disabling them (unlike other test classes).
*/
@SpringBootTest(
classes = [GatewayApplication::class],
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
// Disable external dependencies
"spring.cloud.discovery.enabled=false",
"spring.cloud.consul.enabled=false",
"spring.cloud.consul.config.enabled=false",
"spring.cloud.consul.discovery.register=false",
"spring.cloud.loadbalancer.enabled=false",
// Disable circuit breaker for filter tests
"resilience4j.circuitbreaker.configs.default.registerHealthIndicator=false",
"management.health.circuitbreakers.enabled=false",
// Keep custom filters enabled for testing
"gateway.security.jwt.enabled=false", // Disable JWT but keep other filters
// Use reactive web application type
"spring.main.web-application-type=reactive",
// Disable gateway discovery - use explicit routes
"spring.cloud.gateway.server.webflux.discovery.locator.enabled=false",
// Disable actuator security
"management.security.enabled=false",
// Set random port
"server.port=0"
]
)
@ActiveProfiles("dev") // Use dev profile to enable filters
@AutoConfigureWebTestClient
@Import(TestSecurityConfig::class, GatewayFiltersTests.TestFilterConfig::class)
class GatewayFiltersTests {
@Autowired
lateinit var webTestClient: WebTestClient
@Test
fun `should add correlation ID header when not present`() {
webTestClient.get()
.uri("/test/correlation")
.exchange()
.expectStatus().isOk
.expectHeader().exists("X-Correlation-ID")
.expectBody(String::class.java)
.isEqualTo("correlation-test")
}
@Test
fun `should preserve existing correlation ID header`() {
val existingCorrelationId = "test-correlation-123"
webTestClient.get()
.uri("/test/correlation")
.header("X-Correlation-ID", existingCorrelationId)
.exchange()
.expectStatus().isOk
.expectHeader().valueEquals("X-Correlation-ID", existingCorrelationId)
.expectBody(String::class.java)
.isEqualTo("correlation-test")
}
@Test
fun `should add rate limiting headers`() {
webTestClient.get()
.uri("/test/ratelimit")
.exchange()
.expectStatus().isOk
.expectHeader().exists("X-RateLimit-Enabled")
.expectHeader().exists("X-RateLimit-Limit")
.expectHeader().exists("X-RateLimit-Remaining")
.expectHeader().valueEquals("X-RateLimit-Enabled", "true")
}
@Test
fun `should apply different rate limits for auth endpoints`() {
// This test validates rate-limit headers only; endpoint body/status may vary based on route mapping
webTestClient.get()
.uri("/api/auth/test")
.exchange()
.expectHeader().valueEquals("X-RateLimit-Limit", "20") // AUTH_ENDPOINT_LIMIT
}
@Test
fun `should apply higher rate limit for authenticated users`() {
webTestClient.get()
.uri("/test/ratelimit")
.header("Authorization", "Bearer test-token")
.exchange()
.expectStatus().isOk
.expectHeader().valueEquals("X-RateLimit-Limit", "200") // AUTHENTICATED_LIMIT
}
@Test
fun `should apply admin rate limit for admin users`() {
webTestClient.get()
.uri("/test/ratelimit")
.header("Authorization", "Bearer test-token")
.header("X-User-Role", "ADMIN")
.header("X-User-ID", "admin-test-user") // Required for admin detection security
.exchange()
.expectStatus().isOk
.expectHeader().valueEquals("X-RateLimit-Limit", "500") // ADMIN_LIMIT
}
@Test
fun `should enforce rate limiting after exceeding limit`() {
// This test would need multiple requests to test actual rate limiting
// For simplicity, we just verify the headers are present
val responses = (1..5).map {
webTestClient.get()
.uri("/test/ratelimit")
.exchange()
.expectStatus().isOk
.expectHeader().exists("X-RateLimit-Remaining")
.returnResult(String::class.java)
}
// Verify that remaining count decreases
assert(responses.isNotEmpty())
}
@Test
fun `should handle requests with X-Forwarded-For header`() {
webTestClient.get()
.uri("/test/ratelimit")
.header("X-Forwarded-For", "192.168.1.100, 10.0.0.1")
.exchange()
.expectStatus().isOk
.expectHeader().exists("X-RateLimit-Enabled")
}
/**
* Test configuration that provides routes for filter testing.
*/
@Configuration
class TestFilterConfig {
@Bean
fun filterTestRoutes(builder: RouteLocatorBuilder): RouteLocator = builder.routes()
.route("test-correlation") { r ->
r.path("/test/correlation")
.uri("forward:/mock/correlation-test")
}
.route("test-ratelimit") { r ->
r.path("/test/ratelimit")
.uri("forward:/mock/ratelimit-test")
}
.route("test-auth-endpoint") { r ->
r.path("/api/auth/**")
.filters { f -> f.stripPrefix(1) }
.uri("forward:/mock/auth-test")
}
.build()
@Bean
fun filterTestController(): FilterTestController = FilterTestController()
}
/**
* Mock controller for filter testing.
*/
@RestController
@RequestMapping("/mock")
class FilterTestController {
@GetMapping("/correlation-test")
fun correlationTest(): String = "correlation-test"
@GetMapping("/ratelimit-test")
fun rateLimitTest(): String = "ratelimit-test"
@GetMapping("/auth-test")
fun authEndpointTest(): String = "auth-endpoint-test"
}
}
@@ -0,0 +1,202 @@
package at.mocode.infrastructure.gateway
import at.mocode.infrastructure.gateway.config.TestSecurityConfig
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.cloud.gateway.route.RouteLocator
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
/**
* Tests for Gateway routing functionality.
* Uses mock backend services to test route forwarding.
*/
@SpringBootTest(
classes = [GatewayApplication::class],
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
// Disable external dependencies
"spring.cloud.discovery.enabled=false",
"spring.cloud.consul.enabled=false",
"spring.cloud.consul.config.enabled=false",
"spring.cloud.consul.discovery.register=false",
"spring.cloud.loadbalancer.enabled=false",
// Disable circuit breaker for routing tests
"resilience4j.circuitbreaker.configs.default.registerHealthIndicator=false",
"management.health.circuitbreakers.enabled=false",
// Disable custom filters for pure routing tests
"gateway.security.jwt.enabled=false",
// Use reactive web application type
"spring.main.web-application-type=reactive",
// Disable gateway discovery - use explicit routes
"spring.cloud.gateway.server.webflux.discovery.locator.enabled=false",
// Disable actuator security
"management.security.enabled=false",
// Set random port
"server.port=0"
]
)
@ActiveProfiles("test")
@AutoConfigureWebTestClient
@Import(TestSecurityConfig::class, GatewayRoutingTests.TestRoutesConfig::class)
class GatewayRoutingTests {
@Autowired
lateinit var webTestClient: WebTestClient
@Test
fun `should route members service requests`() {
webTestClient.get()
.uri("/api/members/test")
.exchange()
.expectStatus().isOk
.expectBody(String::class.java)
.isEqualTo("members-service-mock")
}
@Test
fun `should route horses service requests`() {
webTestClient.get()
.uri("/api/horses/test")
.exchange()
.expectStatus().isOk
.expectBody(String::class.java)
.isEqualTo("horses-service-mock")
}
@Test
fun `should route events service requests`() {
webTestClient.get()
.uri("/api/events/test")
.exchange()
.expectStatus().isOk
.expectBody(String::class.java)
.isEqualTo("events-service-mock")
}
@Test
fun `should route masterdata service requests`() {
webTestClient.get()
.uri("/api/masterdata/test")
.exchange()
.expectStatus().isOk
.expectBody(String::class.java)
.isEqualTo("masterdata-service-mock")
}
@Test
fun `auth route is not configured anymore`() {
webTestClient.post()
.uri("/api/auth/login")
.exchange()
.expectStatus().isNotFound
}
@Test
fun `should route ping service requests`() {
webTestClient.get()
.uri("/api/ping/health")
.exchange()
.expectStatus().isOk
.expectBody(String::class.java)
.isEqualTo("ping-service-mock")
}
@Test
fun `should handle gateway info path request`() {
webTestClient.get()
.uri("/gateway-info")
.exchange()
.expectStatus().isOk
}
/**
* Test configuration that provides mock backend services and custom routes.
*/
@Configuration
class TestRoutesConfig {
@Bean
fun testRouteLocator(builder: RouteLocatorBuilder): RouteLocator = builder.routes()
.route("test-members") { r ->
r.path("/api/members/**")
.filters { f -> f.setPath("/mock/members") }
.uri("forward:/")
}
.route("test-horses") { r ->
r.path("/api/horses/**")
.filters { f -> f.setPath("/mock/horses") }
.uri("forward:/")
}
.route("test-events") { r ->
r.path("/api/events/**")
.filters { f -> f.setPath("/mock/events") }
.uri("forward:/")
}
.route("test-masterdata") { r ->
r.path("/api/masterdata/**")
.filters { f -> f.setPath("/mock/masterdata") }
.uri("forward:/")
}
// no dedicated auth route anymore clients should talk to Keycloak directly
.route("test-ping") { r ->
r.path("/api/ping/**")
.filters { f -> f.setPath("/mock/ping") }
.uri("forward:/")
}
.route("test-root") { r ->
r.path("/gateway-info")
.uri("forward:/mock/gateway-info")
}
.build()
@Bean
fun mockBackendController(): MockBackendController = MockBackendController()
}
/**
* Mock backend controller that simulates the responses from actual microservices.
*/
@RestController
@RequestMapping("/mock")
class MockBackendController {
@GetMapping(value = ["/members", "/members/**"])
@PostMapping(value = ["/members", "/members/**"])
fun membersServiceMock(): String = "members-service-mock"
@GetMapping(value = ["/horses", "/horses/**"])
@PostMapping(value = ["/horses", "/horses/**"])
fun horsesServiceMock(): String = "horses-service-mock"
@GetMapping(value = ["/events", "/events/**"])
@PostMapping(value = ["/events", "/events/**"])
fun eventsServiceMock(): String = "events-service-mock"
@GetMapping(value = ["/masterdata", "/masterdata/**"])
@PostMapping(value = ["/masterdata", "/masterdata/**"])
fun masterdataServiceMock(): String = "masterdata-service-mock"
// removed auth mock endpoints not needed anymore
@GetMapping(value = ["/ping", "/ping/**"])
@PostMapping(value = ["/ping", "/ping/**"])
fun pingServiceMock(): String = "ping-service-mock"
@GetMapping("/gateway-info")
fun gatewayInfoMock(): Map<String, String> = mapOf(
"service" to "api-gateway",
"status" to "running"
)
}
}
@@ -0,0 +1,269 @@
package at.mocode.infrastructure.gateway
import at.mocode.infrastructure.gateway.config.TestSecurityConfig
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.cloud.gateway.route.RouteLocator
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.web.bind.annotation.*
/**
* Tests for Gateway security configuration including CORS settings.
* Tests the overall security setup and cross-origin request handling.
*/
@SpringBootTest(
classes = [GatewayApplication::class],
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
// Disable external dependencies
"spring.cloud.discovery.enabled=false",
"spring.cloud.consul.enabled=false",
"spring.cloud.consul.config.enabled=false",
"spring.cloud.consul.discovery.register=false",
"spring.cloud.loadbalancer.enabled=false",
// Disable circuit breaker for security tests
"resilience4j.circuitbreaker.configs.default.registerHealthIndicator=false",
"management.health.circuitbreakers.enabled=false",
// Disable JWT for CORS testing
"gateway.security.jwt.enabled=false",
// Use reactive web application type
"spring.main.web-application-type=reactive",
// Disable gateway discovery - use explicit routes
"spring.cloud.gateway.server.webflux.discovery.locator.enabled=false",
// Disable actuator security
"management.security.enabled=false",
// Set random port
"server.port=0"
]
)
@ActiveProfiles("test") // Use test profile to disable unrelated global filters; CORS config is present in application-test.yml
@AutoConfigureWebTestClient
@Import(TestSecurityConfig::class, GatewaySecurityTests.TestSecurityConfig::class)
class GatewaySecurityTests {
@Autowired
lateinit var webTestClient: WebTestClient
@LocalServerPort
private var port: Int = 0
@BeforeEach
fun setUpClient() {
// Ensure absolute base URL with a scheme to satisfy the CORS processor
webTestClient = webTestClient.mutate()
.baseUrl("http://localhost:$port")
.build()
}
@Test
fun `should handle CORS preflight requests`() {
webTestClient.options()
.uri("/api/members/test")
.header("Origin", "http://localhost:3000")
.header("Access-Control-Request-Method", "GET")
.header("Access-Control-Request-Headers", "Content-Type,Authorization")
.exchange()
.expectStatus().isOk
.expectHeader().exists("Access-Control-Allow-Origin")
.expectHeader().exists("Access-Control-Allow-Methods")
.expectHeader().exists("Access-Control-Allow-Headers")
}
@Test
fun `should allow requests from localhost origins`() {
webTestClient.get()
.uri("/test/cors")
.header("Origin", "http://localhost:3000")
.exchange()
.expectStatus().isOk
.expectHeader().exists("Access-Control-Allow-Origin")
}
@Test
fun `should allow requests from meldestelle domain`() {
webTestClient.get()
.uri("/test/cors")
.header("Origin", "https://app.meldestelle.at")
.exchange()
.expectStatus().isOk
.expectHeader().exists("Access-Control-Allow-Origin")
}
@Test
fun `should handle POST requests with CORS headers`() {
webTestClient.post()
.uri("/test/cors")
.header("Origin", "http://localhost:3000")
.header("Content-Type", "application/json")
.exchange()
.expectStatus().isOk
.expectHeader().exists("Access-Control-Allow-Origin")
}
@Test
fun `should handle PUT requests with CORS headers`() {
webTestClient.put()
.uri("/test/cors")
.header("Origin", "http://localhost:8080")
.header("Content-Type", "application/json")
.exchange()
.expectStatus().isOk
.expectHeader().exists("Access-Control-Allow-Origin")
}
@Test
fun `should handle DELETE requests with CORS headers`() {
webTestClient.delete()
.uri("/test/cors")
.header("Origin", "http://localhost:4200")
.exchange()
.expectStatus().isOk
.expectHeader().exists("Access-Control-Allow-Origin")
}
@Test
fun `should set max age for CORS requests`() {
webTestClient.options()
.uri("/test/cors")
.header("Origin", "http://localhost:3000")
.header("Access-Control-Request-Method", "GET")
.exchange()
.expectStatus().isOk
.expectHeader().exists("Access-Control-Max-Age")
}
@Test
fun `should allow credentials in CORS requests`() {
webTestClient.get()
.uri("/test/cors")
.header("Origin", "http://localhost:3000")
.exchange()
.expectStatus().isOk
.expectHeader().valueEquals("Access-Control-Allow-Credentials", "true")
}
@Test
fun `should handle complex CORS scenarios`() {
// Simulate a complex frontend request with custom headers
webTestClient.options()
.uri("/api/members/complex")
.header("Origin", "https://frontend.meldestelle.at")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "Authorization,Content-Type,X-Requested-With")
.exchange()
.expectStatus().isOk
.expectHeader().exists("Access-Control-Allow-Origin")
.expectHeader().exists("Access-Control-Allow-Methods")
.expectHeader().exists("Access-Control-Allow-Headers")
.expectHeader().valueEquals("Access-Control-Allow-Credentials", "true")
}
@Test
fun `should not duplicate CORS headers due to deduplication filter`() {
webTestClient.get()
.uri("/test/cors")
.header("Origin", "http://localhost:3000")
.exchange()
.expectStatus().isOk
.expectHeader().exists("Access-Control-Allow-Origin")
.expectHeader().exists("Access-Control-Allow-Credentials")
// Verify headers appear only once (DedupeResponseHeader filter should work)
}
@Test
fun `should handle different HTTP methods allowed in CORS`() {
val allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "PATCH")
allowedMethods.forEach { method ->
webTestClient.options()
.uri("/test/cors")
.header("Origin", "http://localhost:3000")
.header("Access-Control-Request-Method", method)
.exchange()
.expectStatus().isOk
.expectHeader().exists("Access-Control-Allow-Methods")
}
}
@Test
fun `should handle authorization headers in CORS requests`() {
webTestClient.get()
.uri("/test/cors")
.header("Origin", "http://localhost:3000")
.header("Authorization", "Bearer test-token")
.exchange()
.expectStatus().isOk
.expectHeader().exists("Access-Control-Allow-Origin")
}
@Test
fun `should maintain security headers in responses`() {
webTestClient.get()
.uri("/test/cors")
.exchange()
.expectStatus().isOk
.expectHeader().exists("Content-Type")
}
/**
* Test configuration for security and CORS testing.
*/
@Configuration
class TestSecurityConfig {
@Bean
fun securityTestRoutes(builder: RouteLocatorBuilder): RouteLocator = builder.routes()
.route("test-cors") { r ->
r.path("/test/cors")
.uri("forward:/mock/cors-test")
}
.route("test-members-complex") { r ->
r.path("/api/members/**")
.filters { f -> f.stripPrefix(1) }
.uri("forward:/mock/members-complex")
}
.build()
@Bean
fun securityTestController(): SecurityTestController = SecurityTestController()
}
/**
* Mock controller for security and CORS testing.
*/
@RestController
@RequestMapping("/mock")
class SecurityTestController {
@RequestMapping(
value = ["/cors-test"],
method = [
RequestMethod.GET,
RequestMethod.POST,
RequestMethod.PUT,
RequestMethod.DELETE
]
)
fun corsTest(): Map<String, String> = mapOf(
"message" to "CORS test successful",
"timestamp" to System.currentTimeMillis().toString()
)
@CrossOrigin
@GetMapping("/members-complex")
@PostMapping("/members-complex")
fun membersComplex(): Map<String, String> = mapOf(
"message" to "Complex CORS request handled",
"service" to "members"
)
}
}
@@ -0,0 +1,47 @@
package at.mocode.infrastructure.gateway
import at.mocode.infrastructure.gateway.config.TestSecurityConfig
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestPropertySource
/**
* Simplified integration test for Keycloak Gateway integration.
* This test verifies that the Spring context can initialize properly with Keycloak configuration
* without requiring actual Testcontainers, focusing on resolving the OAuth2 ResourceServer
* autoconfiguration timing issue.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("keycloak-integration-test")
@TestPropertySource(
properties = [
"gateway.security.keycloak.enabled=true",
"spring.cloud.discovery.enabled=false",
"spring.cloud.consul.enabled=false",
"spring.cloud.consul.config.enabled=false",
"spring.cloud.consul.discovery.register=false",
"spring.cloud.loadbalancer.enabled=false",
"management.security.enabled=false"
]
)
@Import(TestSecurityConfig::class)
class KeycloakGatewayIntegrationTest {
@Test
fun `should initialize Spring context with Keycloak configuration`() {
// This test verifies that the Spring context can start without the previous
// IllegalStateException related to OAuth2 ResourceServer auto-configuration.
//
// The key fix was excluding ReactiveOAuth2ResourceServerAutoConfiguration
// from auto-configuration in application-keycloak-integration-test.yml
// to prevent early issuer-uri validation before containers are ready.
println("✅ Spring context initialized successfully with Keycloak configuration")
println("✅ OAuth2 ResourceServer auto-configuration timing issue resolved")
// Test passes if context loads without IllegalStateException
assert(true) { "Spring context should initialize without errors" }
}
}
@@ -0,0 +1,59 @@
package at.mocode.infrastructure.gateway.config
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Primary
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.config.web.server.invoke
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder
import org.springframework.security.web.server.SecurityWebFilterChain
import reactor.core.publisher.Mono
import java.time.Instant
/**
* Test-Konfiguration für Security-Beans.
* Stellt einen Mock ReactiveJwtDecoder und eine Security-Konfiguration bereit,
* die alle Anfragen für Test-Zwecke erlaubt.
*/
@TestConfiguration
class TestSecurityConfig {
/**
* Mock ReactiveJwtDecoder für Tests.
* Validiert keine echten JWTs, sondern akzeptiert alle Token für Test-Zwecke.
*/
@Bean
@Primary
fun mockReactiveJwtDecoder(): ReactiveJwtDecoder {
return ReactiveJwtDecoder { token ->
// Erstelle ein Mock-JWT mit minimalen Claims
val jwt = Jwt.withTokenValue(token)
.header("alg", "none")
.header("typ", "JWT")
.claim("sub", "test-user")
.claim("scope", "read write")
.claim("preferred_username", "test-user")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(3600))
.build()
Mono.just(jwt)
}
}
/**
* Test Security Web Filter Chain, die alle Anfragen erlaubt.
* Dies ermöglicht Tests von Routing, CORS und Filtern ohne Authentifizierung.
*/
@Bean
@Primary
fun testSecurityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
return http {
csrf { disable() }
authorizeExchange {
authorize(anyExchange, permitAll)
}
}
}
}
@@ -0,0 +1,70 @@
server:
port: 0
spring:
application:
name: api-gateway-dev-test
main:
web-application-type: reactive
cloud:
discovery:
enabled: false
consul:
enabled: false
config:
enabled: false
discovery:
register: false
loadbalancer:
enabled: false
gateway:
server:
webflux:
httpclient:
connect-timeout: 1000
response-timeout: 5s
discovery:
locator:
enabled: false
routes:
[ ]
globalcors:
cors-configurations:
'[/**]':
allowedOriginPatterns:
- "http://localhost:*"
- "https://*.meldestelle.at"
allowedMethods:
- GET
- POST
- PUT
- DELETE
- PATCH
- OPTIONS
allowedHeaders:
- "*"
allowCredentials: true
maxAge: 3600
# Override production routes: keep empty in tests running with dev profile
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: always
health:
circuitbreakers:
enabled: false
logging:
level:
org.springframework.cloud.gateway: WARN
at.mocode.infrastructure.gateway: DEBUG
gateway:
security:
jwt:
enabled: false
@@ -0,0 +1,83 @@
server:
port: 0
spring:
application:
name: api-gateway-keycloak-integration-test
main:
web-application-type: reactive
# Exclude OAuth2 ResourceServer auto-configuration to prevent early issuer-uri validation
# The OAuth2 configuration will be set dynamically after Testcontainers start
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.security.oauth2.resource.reactive.ReactiveOAuth2ResourceServerAutoConfiguration
# OAuth2 configuration will be set by @DynamicPropertySource after containers start
# Do not set static issuer-uri here as it will fail validation before containers are ready
cloud:
discovery:
enabled: false
consul:
enabled: false
config:
enabled: false
discovery:
register: false
loadbalancer:
enabled: false
gateway:
# IMPORTANT: Do not load production lb:// routes in tests
server:
webflux:
discovery:
locator:
enabled: false
httpclient:
connect-timeout: 1000
response-timeout: 5s
routes:
[ ]
globalcors:
cors-configurations:
'[/**]':
allowedOriginPatterns:
- "http://localhost:*"
- "https://*.meldestelle.at"
allowedMethods:
- GET
- POST
- PUT
- DELETE
- PATCH
- OPTIONS
allowedHeaders:
- "*"
allowCredentials: true
maxAge: 3600
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: always
health:
circuit breakers:
enabled: false
security:
enabled: false
# Enable JWT authentication through OAuth2 Resource Server for integration testing
gateway:
security:
jwt:
enabled: false # Disable custom JWT filter
keycloak:
enabled: true # Enable Keycloak integration
logging:
level:
org.springframework.cloud.gateway: WARN
org.springframework.security: DEBUG
at.mocode.infrastructure.gateway: DEBUG
@@ -0,0 +1,70 @@
server:
port: 0
spring:
application:
name: api-gateway-test
main:
web-application-type: reactive
autoconfigure:
exclude:
# Disable OAuth2 ResourceServer autoconfiguration in tests
# use mock JwtAuthenticationFilter instead of real JWT validation
- org.springframework.boot.autoconfigure.security.oauth2.resource.reactive.ReactiveOAuth2ResourceServerAutoConfiguration
cloud:
discovery:
enabled: false
consul:
enabled: false
config:
enabled: false
discovery:
register: false
loadbalancer:
enabled: false
gateway:
# IMPORTANT: Do not load production lb:// routes in tests
server:
webflux:
discovery:
locator:
enabled: false
httpclient:
connect-timeout: 1000
response-timeout: 5s
routes:
[ ]
globalcors:
cors-configurations:
'[/**]':
allowedOriginPatterns:
- "http://localhost:*"
- "https://*.meldestelle.at"
allowedMethods:
- GET
- POST
- PUT
- DELETE
- PATCH
- OPTIONS
allowedHeaders:
- "*"
allowCredentials: true
maxAge: 3600
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: always
health:
circuit breakers:
enabled: false
logging:
level:
org.springframework.cloud.gateway: WARN
at.mocode.infrastructure.gateway: DEBUG
@@ -0,0 +1,17 @@
<configuration>
<!-- Minimale Konfiguration für stabilere Tests -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- Weniger verbose Logging für Tests -->
<root level="WARN">
<appender-ref ref="CONSOLE" />
</root>
<!-- Spezifische Logger für wichtige Test-Komponenten -->
<logger name="org.springframework.test" level="INFO" />
<logger name="at.mocode" level="DEBUG" />
</configuration>
@@ -0,0 +1,19 @@
-- Testcontainers init script for Keycloak schema
-- Creates the schema and basic privileges for the test DB user
CREATE SCHEMA IF NOT EXISTS keycloak;
GRANT USAGE ON SCHEMA keycloak TO meldestelle;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA keycloak TO meldestelle;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA keycloak TO meldestelle;
ALTER DEFAULT PRIVILEGES IN SCHEMA keycloak
GRANT ALL PRIVILEGES ON TABLES TO meldestelle;
ALTER DEFAULT PRIVILEGES IN SCHEMA keycloak
GRANT ALL PRIVILEGES ON SEQUENCES TO meldestelle;
DO $$
BEGIN
RAISE NOTICE 'Test Keycloak schema initialized';
END $$;
@@ -0,0 +1,45 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
group = "at.mocode"
version = "1.0.0"
kotlin {
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
// Align toolchain with project (see composeApp uses 21)
jvmToolchain(21)
// JVM target for backend usage
jvm()
// JS target for frontend usage (Compose/Browser)
js {
browser()
// no need for binaries.executable() in a library
}
// Optional Wasm target for browser clients
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
}
}
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlinx.serialization.json)
}
}
commonTest {
dependencies {
implementation(libs.kotlin.test)
}
}
}
}
@@ -0,0 +1,7 @@
package at.mocode.ping.api
interface PingApi {
suspend fun simplePing(): PingResponse
suspend fun enhancedPing(simulate: Boolean = false): EnhancedPingResponse
suspend fun healthCheck(): HealthResponse
}
@@ -0,0 +1,23 @@
package at.mocode.ping.api
import kotlinx.serialization.Serializable
@Serializable
data class PingResponse(val status: String, val timestamp: String, val service: String)
@Serializable
data class EnhancedPingResponse(
val status: String,
val timestamp: String,
val service: String,
val circuitBreakerState: String,
val responseTime: Long
)
@Serializable
data class HealthResponse(
val status: String,
val timestamp: String,
val service: String,
val healthy: Boolean
)
@@ -0,0 +1,60 @@
// Optimized Spring Boot ping service for testing microservice architecture
// This service demonstrates circuit breaker patterns, service discovery, and monitoring
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.kotlinJpa)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
}
// Configure the main class for the executable JAR
springBoot {
mainClass.set("at.mocode.ping.service.PingServiceApplicationKt")
}
dependencies {
// Platform BOM für zentrale Versionsverwaltung
implementation(platform(projects.platform.platformBom))
// Platform und Core Dependencies
implementation(projects.platform.platformDependencies)
implementation(project(":backend:services:ping:ping-api"))
implementation(projects.infrastructure.monitoring.monitoringClient)
// Spring Boot Service Complete Bundle
// Provides: web, validation, actuator, security, oauth2-client, oauth2-resource-server,
// data-jpa, data-redis, micrometer-prometheus, tracing, zipkin
implementation(libs.bundles.spring.boot.service.complete)
// Datenbank (PostgresQL) Driver
implementation(libs.postgresql.driver)
// Web-Server (Tomcat) explizit hinzufügen!
implementation(libs.spring.boot.starter.web)
// Jackson Kotlin Support Bundle
implementation(libs.bundles.jackson.kotlin)
// Kotlin Reflection (now from version catalog)
implementation(libs.kotlin.reflect)
// Service Discovery
implementation(libs.spring.cloud.starter.consul.discovery)
// Caching (Caffeine for Spring Cloud LoadBalancer)
implementation(libs.caffeine)
implementation(libs.spring.web) // Provides spring-context-support
// Resilience4j Bundle (Circuit Breaker, Reactor, AOP)
implementation(libs.bundles.resilience)
// OpenAPI Documentation
implementation(libs.springdoc.openapi.starter.webmvc.ui)
// Test Dependencies
testImplementation(projects.platform.platformTesting)
testImplementation(libs.bundles.testing.jvm)
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.spring.boot.starter.web)
}
@@ -0,0 +1,40 @@
package at.mocode.ping.service
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse
import org.springframework.web.bind.annotation.*
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
@RestController
@CrossOrigin(
origins = ["http://localhost:8080", "http://localhost:8083", "http://localhost:4000"],
methods = [RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS],
allowedHeaders = ["*"],
allowCredentials = "true"
)
class PingController(
private val pingService: PingServiceCircuitBreaker
) : PingApi {
// Contract endpoints
@GetMapping("/ping/simple")
override suspend fun simplePing(): PingResponse {
val now = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
return PingResponse(
status = "pong",
timestamp = now,
service = "ping-service"
)
}
@GetMapping("/ping/enhanced")
override suspend fun enhancedPing(
@RequestParam(required = false, defaultValue = "false") simulate: Boolean
): EnhancedPingResponse = pingService.ping(simulate)
@GetMapping("/ping/health")
override suspend fun healthCheck(): HealthResponse = pingService.healthCheck()
}
@@ -0,0 +1,35 @@
package at.mocode.ping.service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.EnableAspectJAutoProxy
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@SpringBootApplication
@EnableAspectJAutoProxy
class PingServiceApplication {
@Bean
fun corsConfigurer(): WebMvcConfigurer {
return object : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/**")
.allowedOriginPatterns("http://localhost:*")
.allowedOrigins("http://localhost:8080",
"http://localhost:8083",
"http://localhost:4000"
)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600)
}
}
}
}
fun main(args: Array<String>) {
runApplication<PingServiceApplication>(*args)
}
@@ -0,0 +1,109 @@
package at.mocode.ping.service
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.random.Random
/**
* Service demonstrating a Circuit Breaker pattern with Resilience
*
* This service simulates potential failures and uses circuit breaker
* to handle service degradation gracefully with fallback responses.
*/
@Service
class PingServiceCircuitBreaker {
private val logger = LoggerFactory.getLogger(PingServiceCircuitBreaker::class.java)
companion object {
const val PING_CIRCUIT_BREAKER = "pingCircuitBreaker"
private val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME //.ofPattern("yyyy-MM-dd HH:mm:ss")
}
/**
* Primary ping method with circuit breaker protection returning DTO directly
*
* @param simulateFailure - if true, randomly throws exceptions to test circuit breaker
*/
@CircuitBreaker(name = PING_CIRCUIT_BREAKER, fallbackMethod = "fallbackPing")
fun ping(simulateFailure: Boolean = false): EnhancedPingResponse {
val start = System.nanoTime()
logger.info("Executing ping service call...")
if (simulateFailure && Random.nextDouble() < 0.6) {
logger.warn("Simulating service failure for circuit breaker testing")
throw RuntimeException("Simulated service failure")
}
val currentTime = LocalDateTime.now().atOffset(java.time.ZoneOffset.UTC).format(formatter)
val elapsedMs = (System.nanoTime() - start) / 1_000_000
logger.info("Ping service call successful")
return EnhancedPingResponse(
status = "pong",
timestamp = currentTime,
service = "ping-service",
circuitBreakerState = "CLOSED",
responseTime = elapsedMs
)
}
/**
* Fallback method called when circuit breaker is OPEN
*
* @param simulateFailure - original parameter (ignored in fallback)
* @param exception - the exception that triggered the fallback
*/
fun fallbackPing(simulateFailure: Boolean = false, exception: Exception): EnhancedPingResponse {
val start = System.nanoTime()
// Die volle Exception nur loggen, nicht an den Client weitergeben.
logger.warn("Circuit breaker fallback triggered due to: {}", exception.toString())
val currentTime = LocalDateTime.now().atOffset(java.time.ZoneOffset.UTC).format(formatter)
val elapsedMs = (System.nanoTime() - start) / 1_000_000
return EnhancedPingResponse(
status = "fallback",
timestamp = currentTime,
service = "ping-service-fallback",
circuitBreakerState = "OPEN",
responseTime = elapsedMs
)
}
/**
* Health check method with circuit breaker protection returning DTO directly
*/
@CircuitBreaker(name = PING_CIRCUIT_BREAKER, fallbackMethod = "fallbackHealth")
fun healthCheck(): HealthResponse {
logger.info("Executing health check...")
val currentTime = LocalDateTime.now().atOffset(java.time.ZoneOffset.UTC).format(formatter)
return HealthResponse(
status = "pong",
timestamp = currentTime,
service = "ping-service",
healthy = true
)
}
/**
* Fallback for health check returning DTO
*/
fun fallbackHealth(exception: Exception): HealthResponse {
logger.warn("Health check fallback triggered: {}", exception.message)
val currentTime = LocalDateTime.now().atOffset(java.time.ZoneOffset.UTC).format(formatter)
return HealthResponse(
status = "down",
timestamp = currentTime,
service = "ping-service",
healthy = false
)
}
}
@@ -0,0 +1,36 @@
package at.mocode.ping.service.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain
/**
* Security configuration for the Ping Service.
* Enables method-level security for fine-grained authorization control.
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
class SecurityConfiguration {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth
// Allow health check endpoints
.requestMatchers("/actuator/**", "/health/**").permitAll()
// Allow ping endpoints for monitoring (these are typically public)
.requestMatchers("/ping/**").permitAll()
// All other endpoints require authentication (handled by method-level security)
.anyRequest().authenticated()
}
.build()
}
}
@@ -0,0 +1,73 @@
spring:
application:
name: ping-service
cloud:
consul:
host: ${CONSUL_HOST:localhost}
port: ${CONSUL_PORT:8500}
discovery:
enabled: true
register: true
health-check-path: /actuator/health
health-check-interval: 10s
server:
port: ${SERVER_PORT:${PING_SERVICE_PORT:8082}}
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,circuitbreakers
endpoint:
health:
show-details: always
probes:
enabled: true
tracing:
# Disable tracing by default to avoid Zipkin connection errors
enabled: ${TRACING_ENABLED:false}
sampling:
probability: ${TRACING_SAMPLING_PROBABILITY:0.1}
zipkin:
tracing:
# Only configure endpoint if tracing is explicitly enabled
endpoint: ${ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans}
# Configure timeout and connection settings to handle missing Zipkin gracefully
connect-timeout: 1s
read-timeout: 5s
# Resilience4j Circuit Breaker Configuration
resilience4j:
circuitbreaker:
configs:
default:
# Circuit breaker opens when the failure rate exceeds 50%
failure-rate-threshold: 50
# Minimum number of calls to calculate the failure rate
minimum-number-of-calls: 5
# Time to wait before transitioning from OPEN to HALF_OPEN
wait-duration-in-open-state: 10s
# Number of calls in HALF_OPEN state before deciding to close/open
permitted-number-of-calls-in-half-open-state: 3
# Sliding window size for calculating failure rate
sliding-window-size: 10
# Type of sliding window (COUNT_BASED or TIME_BASED)
sliding-window-type: COUNT_BASED
# Record exceptions that should be considered as failures
record-exceptions:
- java.lang.Exception
# Ignore certain exceptions (don't count as failures)
ignore-exceptions:
- java.lang.IllegalArgumentException
instances:
pingCircuitBreaker:
# Use default configuration
base-config: default
# Override specific settings for this instance if needed
failure-rate-threshold: 60
minimum-number-of-calls: 4
wait-duration-in-open-state: 5s
# Metrics configuration removed to avoid property resolution warnings
# Use micrometer and actuator endpoints for metrics instead
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_PATTERN" value="%d{ISO8601} %-5level [%X{traceId:-}:%X{spanId:-}] %logger{36} - %msg%n"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<logger name="org.springframework" level="INFO"/>
<logger name="org.springframework.web" level="INFO"/>
<logger name="org.springframework.boot.actuate" level="INFO"/>
<logger name="reactor.netty" level="WARN"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
@@ -0,0 +1,246 @@
package at.mocode.ping.service
import io.github.resilience4j.circuitbreaker.CircuitBreaker
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpStatus
import org.springframework.test.context.ActiveProfiles
/**
* Integration tests for PingController
* Tests REST endpoints with circuit breaker functionality using TestRestTemplate
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class PingControllerIntegrationTest {
@LocalServerPort
private var port: Int = 0
@Autowired
private lateinit var restTemplate: TestRestTemplate
@Autowired
private lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
private val logger = LoggerFactory.getLogger(PingControllerIntegrationTest::class.java)
private lateinit var circuitBreaker: CircuitBreaker
@BeforeEach
fun setUp() {
circuitBreaker = circuitBreakerRegistry.circuitBreaker(PingServiceCircuitBreaker.PING_CIRCUIT_BREAKER)
// Reset circuit breaker state before each test
circuitBreaker.reset()
}
private fun getUrl(endpoint: String) = "http://localhost:$port$endpoint"
@Test
fun `should return basic ping response from standard endpoint`() {
// When
val response = restTemplate.getForEntity(getUrl("/ping/simple"), Map::class.java)
// Then
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
assertThat(response.body!!["status"]).isEqualTo("pong")
logger.info("Standard ping endpoint response: {}", response.body)
}
@Test
fun `should return enhanced ping response when circuit breaker is closed`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When
val response = restTemplate.getForEntity(getUrl("/ping/enhanced"), Map::class.java)
// Then
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
val body = response.body!!
assertThat(body["status"]).isEqualTo("pong")
assertThat(body["service"]).isEqualTo("ping-service")
assertThat(body["circuitBreakerState"]).isEqualTo("CLOSED")
assertThat(body["timestamp"]).isNotNull()
logger.info("Enhanced ping response: {}", body)
}
@Test
fun `should return enhanced ping response without simulation`() {
// When
val response = restTemplate.getForEntity(getUrl("/ping/enhanced?simulate=false"), Map::class.java)
// Then
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
val body = response.body!!
assertThat(body["status"]).isEqualTo("pong")
assertThat(body["service"]).isEqualTo("ping-service")
assertThat(body["circuitBreakerState"]).isEqualTo("CLOSED")
logger.info("Enhanced ping without simulation: {}", body)
}
@Test
fun `should handle failure simulation in enhanced ping endpoint`() {
// Multiple calls to potentially trigger failures due to random simulation
repeat(3) { i ->
val response = restTemplate.getForEntity(getUrl("/ping/enhanced?simulate=true"), Map::class.java)
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
val body = response.body!!
logger.info("Attempt {}: Response status = {}, Circuit breaker state = {}",
i + 1, body["status"], circuitBreaker.state)
// Response should be either success or fallback
assertThat(body["status"]).isIn("pong", "fallback")
}
}
@Test
fun `should return health check response when circuit breaker is closed`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When
val response = restTemplate.getForEntity(getUrl("/ping/health"), Map::class.java)
// Then
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
val body = response.body!!
assertThat(body["status"]).isEqualTo("pong")
assertThat(body["service"]).isEqualTo("ping-service")
assertThat(body["healthy"]).isEqualTo(true)
assertThat(body["timestamp"]).isNotNull()
logger.info("Health check response: {}", body)
}
@Test
fun `should return fallback health check when circuit breaker is open`() {
// Given - manually open circuit breaker
circuitBreaker.transitionToOpenState()
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
// When
val response = restTemplate.getForEntity(getUrl("/ping/health"), Map::class.java)
// Then
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
val body = response.body!!
assertThat(body["status"]).isEqualTo("down")
assertThat(body["service"]).isEqualTo("ping-service")
assertThat(body["healthy"]).isEqualTo(false)
logger.info("Fallback health check response: {}", body)
}
@Test
fun `should handle multiple rapid requests correctly`() {
// Execute multiple rapid requests
val results = mutableListOf<Map<String, Any>>()
repeat(5) { i ->
val response = restTemplate.getForEntity(getUrl("/ping/enhanced"), Map::class.java)
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
@Suppress("UNCHECKED_CAST")
val body = response.body as Map<String, Any>
results.add(body)
logger.info("Rapid request {}: status = {}", i + 1, body["status"])
}
// All should be successful since we're not simulating failures
results.forEach { response ->
assertThat(response["status"]).isEqualTo("pong")
assertThat(response["service"]).isEqualTo("ping-service")
}
}
@Test
fun `should maintain circuit breaker state across requests`() {
// Given - manually open circuit breaker
circuitBreaker.transitionToOpenState()
// When - make multiple requests
repeat(3) { i ->
val response = restTemplate.getForEntity(getUrl("/ping/enhanced"), Map::class.java)
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
val body = response.body!!
// All should return fallback responses while circuit breaker is open
assertThat(body["status"]).isEqualTo("fallback")
assertThat(body["circuitBreakerState"]).isEqualTo("OPEN")
logger.info("Request {} with OPEN circuit breaker: {}", i + 1, body["status"])
}
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
}
@Test
fun `should test all existing endpoints return valid responses`() {
val endpoints = listOf(
"/ping/simple",
"/ping/enhanced",
"/ping/health"
)
endpoints.forEach { endpoint ->
val response = restTemplate.getForEntity(getUrl(endpoint), Map::class.java)
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull()
assertThat(response.body!!).isNotEmpty()
logger.info("Endpoint {} returned valid response: {}", endpoint, response.body)
}
}
@Test
fun `should track circuit breaker metrics after calls`() {
// Given
val initialMetrics = circuitBreaker.metrics
logger.info("Initial metrics - Calls: {}, Failures: {}",
initialMetrics.numberOfBufferedCalls, initialMetrics.numberOfFailedCalls)
// When - execute some calls
repeat(3) {
restTemplate.getForEntity(getUrl("/ping/enhanced"), Map::class.java)
}
// Then
val newMetrics = circuitBreaker.metrics
assertThat(newMetrics.numberOfBufferedCalls).isGreaterThanOrEqualTo(3)
logger.info("Updated metrics - Calls: {}, Failure rate: {}%, Successful: {}, Failed: {}",
newMetrics.numberOfBufferedCalls,
newMetrics.failureRate,
newMetrics.numberOfSuccessfulCalls,
newMetrics.numberOfFailedCalls)
}
}
@@ -0,0 +1,136 @@
package at.mocode.ping.service
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
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.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
/**
* Unit tests for PingController
* Tests REST endpoints with mocked dependencies
*/
@WebMvcTest(
controllers = [PingController::class],
excludeAutoConfiguration = [
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration::class,
org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration::class
]
)
@Import(PingControllerTest.TestConfig::class)
class PingControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var pingService: PingServiceCircuitBreaker
@TestConfiguration
class TestConfig {
@Bean
fun pingServiceCircuitBreaker(): PingServiceCircuitBreaker = mockk(relaxed = true)
}
@BeforeEach
fun setUp() {
// Reset mocks before each test
io.mockk.clearMocks(pingService)
}
@Test
fun `should return simple ping response`() {
// When & Then
mockMvc.perform(get("/ping/simple"))
.andExpect(status().isOk)
}
@Test
fun `should return enhanced ping response without simulation`() {
// Given
val expectedResponse = EnhancedPingResponse(
status = "pong",
timestamp = "2023-10-01T10:00:00Z",
service = "ping-service",
circuitBreakerState = "CLOSED",
responseTime = 10L
)
every { pingService.ping(false) } returns expectedResponse
// When & Then
mockMvc.perform(get("/ping/enhanced"))
.andExpect(status().isOk)
// Verify
verify { pingService.ping(false) }
}
@Test
fun `should return enhanced ping response with simulation enabled`() {
// Given
val expectedResponse = EnhancedPingResponse(
status = "fallback",
timestamp = "2023-10-01T10:00:00Z",
service = "ping-service-fallback",
circuitBreakerState = "OPEN",
responseTime = 5L
)
every { pingService.ping(true) } returns expectedResponse
// When & Then
mockMvc.perform(get("/ping/enhanced?simulate=true"))
.andExpect(status().isOk)
// Verify
verify { pingService.ping(true) }
}
@Test
fun `should return health check response`() {
// Given
val expectedResponse = HealthResponse(
status = "pong",
timestamp = "2023-10-01T10:00:00Z",
service = "ping-service",
healthy = true
)
every { pingService.healthCheck() } returns expectedResponse
// When & Then
mockMvc.perform(get("/ping/health"))
.andExpect(status().isOk)
// Verify
verify { pingService.healthCheck() }
}
@Test
fun `should handle missing simulate parameter with default false`() {
// Given
val expectedResponse = EnhancedPingResponse(
status = "pong",
timestamp = "2023-10-01T10:00:00Z",
service = "ping-service",
circuitBreakerState = "CLOSED",
responseTime = 8L
)
every { pingService.ping(false) } returns expectedResponse
// When & Then
mockMvc.perform(get("/ping/enhanced"))
.andExpect(status().isOk)
// Verify default parameter is used
verify { pingService.ping(false) }
}
}
@@ -0,0 +1,216 @@
package at.mocode.ping.service
import io.github.resilience4j.circuitbreaker.CircuitBreaker
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import kotlin.math.ceil
/**
* Comprehensive test suite for PingServiceCircuitBreaker
* Updated to assert DTOs instead of Maps.
*/
@SpringBootTest
class PingServiceCircuitBreakerTest {
@Autowired
private lateinit var pingServiceCircuitBreaker: PingServiceCircuitBreaker
@Autowired
private lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
private val logger = LoggerFactory.getLogger(PingServiceCircuitBreakerTest::class.java)
private lateinit var circuitBreaker: CircuitBreaker
@BeforeEach
fun setUp() {
circuitBreaker = circuitBreakerRegistry.circuitBreaker(PingServiceCircuitBreaker.PING_CIRCUIT_BREAKER)
// Reset circuit breaker state before each test
circuitBreaker.reset()
}
@Test
fun `should return successful ping response when circuit breaker is closed`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When
val result = pingServiceCircuitBreaker.ping(simulateFailure = false)
// Then
assertThat(result.status).isEqualTo("pong")
assertThat(result.service).isEqualTo("ping-service")
assertThat(result.circuitBreakerState).isEqualTo("CLOSED")
assertThat(result.timestamp).isNotBlank()
assertThat(result.responseTime).isGreaterThanOrEqualTo(0)
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
}
@Test
fun `should handle single failure without opening circuit breaker`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When - try until we hit a simulated failure (60% chance)
var result = pingServiceCircuitBreaker.ping(simulateFailure = true)
var attempts = 1
while (result.status == "pong" && attempts < 10) {
result = pingServiceCircuitBreaker.ping(simulateFailure = true)
attempts++
}
// Then - should get fallback response eventually
assertThat(result.status).isEqualTo("fallback")
assertThat(result.service).isEqualTo("ping-service-fallback")
assertThat(result.circuitBreakerState).isEqualTo("OPEN")
logger.info("Circuit breaker state after single failure (after {} attempts): {}", attempts, circuitBreaker.state)
}
@Test
fun `should open circuit breaker after multiple failures`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When - trigger multiple failures to reach minimum-number-of-calls (4) and failure threshold (60%)
var failureCount = 0
var totalCalls = 0
val maxAttempts = 20 // Prevent infinite loop
while (circuitBreaker.state == CircuitBreaker.State.CLOSED && totalCalls < maxAttempts) {
val result = pingServiceCircuitBreaker.ping(simulateFailure = true)
totalCalls++
if (result.status == "fallback") failureCount++
logger.info(
"Attempt {}: Circuit breaker state = {}, Response status = {}, Failures so far = {}",
totalCalls, circuitBreaker.state, result.status, failureCount
)
}
// Then - circuit breaker should be open after sufficient failures
logger.info(
"Final circuit breaker state: {} after {} total calls with {} failures",
circuitBreaker.state, totalCalls, failureCount
)
if (totalCalls >= 4 && failureCount >= ceil(totalCalls * 0.6)) {
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
}
}
@Test
fun `should return fallback response when circuit breaker is manually opened`() {
// Given
circuitBreaker.transitionToOpenState()
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
// When
val result = pingServiceCircuitBreaker.ping(simulateFailure = false)
// Then
assertThat(result.status).isEqualTo("fallback")
assertThat(result.service).isEqualTo("ping-service-fallback")
assertThat(result.circuitBreakerState).isEqualTo("OPEN")
}
@Test
fun `should return successful health check when circuit breaker is closed`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When
val result = pingServiceCircuitBreaker.healthCheck()
// Then
assertThat(result.healthy).isTrue()
assertThat(result.status).isEqualTo("pong")
assertThat(result.timestamp).isNotBlank()
}
@Test
fun `should return fallback health check when circuit breaker is open`() {
// Given
circuitBreaker.transitionToOpenState()
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
// When
val result = pingServiceCircuitBreaker.healthCheck()
// Then
assertThat(result.healthy).isFalse()
assertThat(result.status).isEqualTo("down")
}
@Test
fun `should test circuit breaker state transitions`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When - manually transition to open state
circuitBreaker.transitionToOpenState()
// Then
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
// When - manually transition to half-open state
circuitBreaker.transitionToHalfOpenState()
// Then
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.HALF_OPEN)
// When - successful call should close circuit breaker
val result = pingServiceCircuitBreaker.ping(simulateFailure = false)
// Then
assertThat(result.status).isEqualTo("pong")
logger.info("Circuit breaker state after successful call in HALF_OPEN: {}", circuitBreaker.state)
}
@Test
fun `should track circuit breaker metrics`() {
// Given
val metrics = circuitBreaker.metrics
val initialNumberOfCalls = metrics.numberOfBufferedCalls
// When - execute some successful calls
repeat(3) {
pingServiceCircuitBreaker.ping(simulateFailure = false)
}
// Then
val newMetrics = circuitBreaker.metrics
assertThat(newMetrics.numberOfBufferedCalls).isGreaterThan(initialNumberOfCalls)
logger.info(
"Circuit breaker metrics - Calls: {}, Failure rate: {}%, Successful calls: {}, Failed calls: {}",
newMetrics.numberOfBufferedCalls,
newMetrics.failureRate,
newMetrics.numberOfSuccessfulCalls,
newMetrics.numberOfFailedCalls
)
}
@Test
fun `should handle concurrent calls correctly`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When - execute concurrent calls
val threads = (1..10).map { index ->
Thread {
val result = pingServiceCircuitBreaker.ping(simulateFailure = false)
logger.info("Concurrent call {}: status = {}", index, result.status)
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
// Then - verify circuit breaker recorded calls
val metrics = circuitBreaker.metrics
assertThat(metrics.numberOfBufferedCalls).isGreaterThanOrEqualTo(10)
assertThat(metrics.numberOfSuccessfulCalls).isGreaterThanOrEqualTo(10)
}
}
@@ -0,0 +1,53 @@
spring:
application:
name: ping-service-test
cloud:
consul:
enabled: false
discovery:
enabled: false
register: false
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info,circuitbreakers
endpoint:
health:
show-details: always
# Resilience4j Circuit Breaker Configuration for tests
resilience4j:
circuitbreaker:
configs:
default:
failure-rate-threshold: 50
minimum-number-of-calls: 5
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
sliding-window-size: 10
sliding-window-type: COUNT_BASED
record-exceptions:
- java.lang.Exception
ignore-exceptions:
- java.lang.IllegalArgumentException
instances:
pingCircuitBreaker:
base-config: default
failure-rate-threshold: 60
minimum-number-of-calls: 4
wait-duration-in-open-state: 5s
metrics:
enabled: true
legacy:
enabled: true
logging:
level:
org.springframework.cloud.consul: ERROR
com.ecwid.consul: ERROR