diff --git a/backend/services/entries/entries-service/Dockerfile b/backend/services/entries/Dockerfile similarity index 95% rename from backend/services/entries/entries-service/Dockerfile rename to backend/services/entries/Dockerfile index 3b4e91b0..57f6c8b5 100644 --- a/backend/services/entries/entries-service/Dockerfile +++ b/backend/services/entries/Dockerfile @@ -38,9 +38,10 @@ COPY frontend/ frontend/ COPY core/ core/ COPY backend/ backend/ COPY docs/ docs/ -COPY build.gradle.kts ./ +COPY entries-service/build.gradle.kts ./ # Copy entries modules +COPY backend/services/entries/entries-api/ backend/services/entries/entries-api/ COPY backend/services/entries/entries-service/ backend/services/entries/entries-service/ RUN --mount=type=cache,target=/home/gradle/.gradle/caches \ @@ -127,5 +128,6 @@ ENTRYPOINT ["tini", "--", "sh", "-c", "\ echo 'DEBUG mode enabled'; \ exec java ${JAVA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar; \ else \ + echo 'Starting Entries Service in production mode'; \ exec java ${JAVA_OPTS} -jar app.jar; \ fi"] diff --git a/backend/services/entries/entries-api/build.gradle.kts b/backend/services/entries/entries-api/build.gradle.kts new file mode 100644 index 00000000..a75f1fde --- /dev/null +++ b/backend/services/entries/entries-api/build.gradle.kts @@ -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 the toolchain with a 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) + } + } + } +} diff --git a/backend/services/entries/entries-api/src/commonMain/kotlin/at/mocode/entries/api/EntriesApi.kt b/backend/services/entries/entries-api/src/commonMain/kotlin/at/mocode/entries/api/EntriesApi.kt new file mode 100644 index 00000000..f8df7a82 --- /dev/null +++ b/backend/services/entries/entries-api/src/commonMain/kotlin/at/mocode/entries/api/EntriesApi.kt @@ -0,0 +1,5 @@ +package at.mocode.entries.api + +interface EntriesApi { + suspend fun healthCheck(): HealthResponse +} diff --git a/backend/services/entries/entries-api/src/commonMain/kotlin/at/mocode/entries/api/EntriesData.kt b/backend/services/entries/entries-api/src/commonMain/kotlin/at/mocode/entries/api/EntriesData.kt new file mode 100644 index 00000000..1011a40e --- /dev/null +++ b/backend/services/entries/entries-api/src/commonMain/kotlin/at/mocode/entries/api/EntriesData.kt @@ -0,0 +1,23 @@ +package at.mocode.entries.api + +import kotlinx.serialization.Serializable + +@Serializable +data class EntriesResponse(val status: String, val timestamp: String, val service: String) + +@Serializable +data class EnhancedEntriesResponse( + 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 +) diff --git a/backend/services/entries/entries-service/build.gradle.kts b/backend/services/entries/entries-service/build.gradle.kts index b13592b2..3587b2f2 100644 --- a/backend/services/entries/entries-service/build.gradle.kts +++ b/backend/services/entries/entries-service/build.gradle.kts @@ -13,6 +13,7 @@ springBoot { dependencies { implementation(platform(projects.platform.platformBom)) implementation(projects.platform.platformDependencies) + implementation(projects.backend.services.entries.entriesApi) implementation(projects.backend.infrastructure.monitoring.monitoringClient) implementation(libs.bundles.spring.boot.service.complete) diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesController.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesController.kt new file mode 100644 index 00000000..5549b6c9 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesController.kt @@ -0,0 +1,27 @@ +package at.mocode.entries.service + +import at.mocode.entries.api.EnhancedEntriesResponse +import at.mocode.entries.api.EntriesApi +import at.mocode.entries.api.HealthResponse +import org.springframework.web.bind.annotation.* + +@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 EntriesController( + private val entriesService: EntriesServiceCircuitBreaker +) : EntriesApi { + + // Contract endpoints + @GetMapping("/entries/enhanced") + suspend fun enhancedEntries( + @RequestParam(required = false, defaultValue = "false") simulate: Boolean + ): EnhancedEntriesResponse = entriesService.entries(simulate) + + @GetMapping("/entries/health") + override suspend fun healthCheck(): HealthResponse = entriesService.healthCheck() +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesServiceApplication.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesServiceApplication.kt index b075e20c..e7011658 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesServiceApplication.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesServiceApplication.kt @@ -2,26 +2,34 @@ package at.mocode.entries.service import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RestController -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity - -@SpringBootApplication -class EntriesServiceApplication +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 fun main(args: Array) { runApplication(*args) } -@RestController -class EntriesController { - @GetMapping("/") - fun health(): String = "Entries Service is running" +@SpringBootApplication +@EnableAspectJAutoProxy +class EntriesServiceApplication { - @PostMapping("/entries/conflict-demo") - fun conflictDemo(): ResponseEntity { - return ResponseEntity.status(HttpStatus.CONFLICT).body("Conflict detected (Demo)") + @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) + } } + } } diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesServiceCircuitBreaker.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesServiceCircuitBreaker.kt new file mode 100644 index 00000000..333fa18d --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/EntriesServiceCircuitBreaker.kt @@ -0,0 +1,110 @@ +package at.mocode.entries.service + +import at.mocode.entries.api.EnhancedEntriesResponse +import at.mocode.entries.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.ZoneOffset +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 EntriesServiceCircuitBreaker { + + private val logger = LoggerFactory.getLogger(EntriesServiceCircuitBreaker::class.java) + + companion object { + const val ENTRIES_CIRCUIT_BREAKER = "entriesCircuitBreaker" + private val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME //.ofPattern("yyyy-MM-dd HH:mm:ss") + } + + /** + * Primary entries method with circuit breaker protection returning DTO directly + * + * @param simulateFailure - if true, randomly throws exceptions to test circuit breaker + */ + @CircuitBreaker(name = ENTRIES_CIRCUIT_BREAKER, fallbackMethod = "fallbackEntries") + fun entries(simulateFailure: Boolean = false): EnhancedEntriesResponse { + val start = System.nanoTime() + logger.info("Executing entries 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(ZoneOffset.UTC).format(formatter) + val elapsedMs = (System.nanoTime() - start) / 1_000_000 + logger.info("Entries service call successful") + + return EnhancedEntriesResponse( + status = "entries", + timestamp = currentTime, + service = "entries-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 fallbackEntries(simulateFailure: Boolean = false, exception: Exception): EnhancedEntriesResponse { + 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(ZoneOffset.UTC).format(formatter) + val elapsedMs = (System.nanoTime() - start) / 1_000_000 + + return EnhancedEntriesResponse( + status = "fallback", + timestamp = currentTime, + service = "entries-service-fallback", + circuitBreakerState = "OPEN", + responseTime = elapsedMs + ) + } + + /** + * Health check method with circuit breaker protection returning DTO directly + */ + @CircuitBreaker(name = ENTRIES_CIRCUIT_BREAKER, fallbackMethod = "fallbackHealth") + fun healthCheck(): HealthResponse { + logger.info("Executing health check...") + + val currentTime = LocalDateTime.now().atOffset(ZoneOffset.UTC).format(formatter) + return HealthResponse( + status = "entries", + timestamp = currentTime, + service = "entries-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(ZoneOffset.UTC).format(formatter) + return HealthResponse( + status = "down", + timestamp = currentTime, + service = "entries-service", + healthy = false + ) + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/SecurityConfiguration.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/SecurityConfiguration.kt new file mode 100644 index 00000000..0ac7866f --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/SecurityConfiguration.kt @@ -0,0 +1,36 @@ +package at.mocode.entries.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 Entries 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("/entries/**").permitAll() + // All other endpoints require authentication (handled by method-level security) + .anyRequest().authenticated() + } + .build() + } +} diff --git a/backend/services/entries/entries-service/src/main/resources/application.yaml b/backend/services/entries/entries-service/src/main/resources/application.yaml new file mode 100644 index 00000000..f5666402 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/resources/application.yaml @@ -0,0 +1,70 @@ +spring: + application: + name: entries-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:${ENTRIES_SERVICE_PORT:8083}} + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,circuitbreakers + endpoint: + health: + show-details: always + probes: + enabled: true + tracing: + enabled: ${TRACING_ENABLED:false} + sampling: + probability: ${TRACING_SAMPLING_PROBABILITY:0.1} + zipkin: + tracing: + endpoint: ${ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans} + 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: + entriesCircuitBreaker: + # 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 + diff --git a/backend/services/entries/entries-service/src/main/resources/logback-spring.xml b/backend/services/entries/entries-service/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..9a8764a2 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/resources/logback-spring.xml @@ -0,0 +1,19 @@ + + + + + + + ${LOG_PATTERN} + + + + + + + + + + + + diff --git a/backend/services/ping/ping-service/src/main/resources/application.yml b/backend/services/ping/ping-service/src/main/resources/application.yaml similarity index 100% rename from backend/services/ping/ping-service/src/main/resources/application.yml rename to backend/services/ping/ping-service/src/main/resources/application.yaml diff --git a/backend/services/ping/ping-service/src/test/resources/application-test.yml b/backend/services/ping/ping-service/src/test/resources/application-test.yaml similarity index 100% rename from backend/services/ping/ping-service/src/test/resources/application-test.yml rename to backend/services/ping/ping-service/src/test/resources/application-test.yaml diff --git a/docker-compose.yaml b/docker-compose.yaml index 03a1af15..7ec4cf33 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -355,7 +355,7 @@ services: entries-service: build: context: . - dockerfile: backend/services/entries/entries-service/Dockerfile + dockerfile: backend/services/entries/Dockerfile args: GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.1.0}" JAVA_VERSION: "${DOCKER_JAVA_VERSION:-21}" diff --git a/docs/c4/Kernentitäten.puml b/docs/c4/Kernentitäten.puml new file mode 100644 index 00000000..f3cc0f63 --- /dev/null +++ b/docs/c4/Kernentitäten.puml @@ -0,0 +1,80 @@ +@startuml +!define table(x) entity x << (T, white) >> +!define primary_key(x) <&key> x +!define foreign_key(x) <&key> x + +skinparam linetype ortho + +table(Turnier) { + primary_key(turnier_id) + -- + name: varchar + start_datum: date + end_datum: date + ort: varchar + kategorie_oeto: varchar +} + +table(Bewerb) { + primary_key(bewerb_id) + -- + foreign_key(turnier_id) + nummer: varchar + disziplin: varchar + klasse: varchar + nenngeld: decimal +} + +table(Person) { + primary_key(person_id) + -- + vorname: varchar + nachname: varchar + lizenznummer: varchar +} + +table(Pferd) { + primary_key(pferd_id) + -- + foreign_key(besitzer_person_id) + name: varchar + lebensnummer: varchar +} + +table(Nennung) { + primary_key(nennung_id) + -- + foreign_key(bewerb_id) + foreign_key(person_id) + foreign_key(pferd_id) + status: varchar + nennungs_datum: timestamp +} + +table(Startplatz) { + primary_key(startplatz_id) + -- + foreign_key(nennung_id) + startnummer: int + startzeit: time + status: varchar +} + +table(Ergebnis) { + primary_key(ergebnis_id) + -- + foreign_key(startplatz_id) + wertnote: decimal + fehler: int + zeit: time + platzierung: int +} + +Turnier ||--o{ Bewerb +Bewerb }o--o{ Nennung +Person }o--o{ Nennung +Pferd }o--o{ Nennung +Person }o..o{ Pferd +Nennung ||--|{ Startplatz +Startplatz ||--|{ Ergebnis +@enduml diff --git a/settings.gradle.kts b/settings.gradle.kts index c6476627..7deee9bd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -65,6 +65,7 @@ include(":backend:infrastructure:monitoring:monitoring-server") // include(":backend:services:events:events-service") // --- ENTRIES (Nennungen) --- +include(":backend:services:entries:entries-api") include(":backend:services:entries:entries-service") // --- RESULTS (Ergebnisse) ---