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:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
+11
@@ -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)
|
||||
}
|
||||
+256
@@ -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
|
||||
}
|
||||
+71
@@ -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
|
||||
)
|
||||
+142
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -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 }
|
||||
}
|
||||
}
|
||||
+143
@@ -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 <token></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>© 2024 Meldestelle API. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
+576
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+242
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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
|
||||
}
|
||||
}
|
||||
+194
@@ -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"
|
||||
}
|
||||
}
|
||||
+202
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
+269
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
+47
@@ -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" }
|
||||
}
|
||||
}
|
||||
+59
@@ -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)
|
||||
}
|
||||
+40
@@ -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()
|
||||
}
|
||||
+35
@@ -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)
|
||||
}
|
||||
+109
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+36
@@ -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>
|
||||
+246
@@ -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)
|
||||
}
|
||||
}
|
||||
+136
@@ -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) }
|
||||
}
|
||||
}
|
||||
+216
@@ -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
|
||||
Reference in New Issue
Block a user