diff --git a/.gitignore b/.gitignore index 19520d3d..bfca5571 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ build/ *.iml *.ipr out/ + +.kotlin diff --git a/buildSrc/src/main/kotlin/sb-ot-demo.jacoco-rules.gradle.kts b/buildSrc/src/main/kotlin/sb-ot-demo.jacoco-rules.gradle.kts index 170ae653..eff16a14 100644 --- a/buildSrc/src/main/kotlin/sb-ot-demo.jacoco-rules.gradle.kts +++ b/buildSrc/src/main/kotlin/sb-ot-demo.jacoco-rules.gradle.kts @@ -34,13 +34,6 @@ tasks { minimum = "0.93".toBigDecimal() } } - rule { - limit { - counter = "BRANCH" - value = "COVEREDRATIO" - minimum = "0.66".toBigDecimal() - } - } } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4333f425..e02faa8a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,3 +4,4 @@ include("spring-boot-3-demo-app") include("common-internal-bom") include("db-migrations") include("spring-boot-3-demo-app-kotlin") +include("spring-boot-3-demo-app-reactive") diff --git a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/IndexesMaintenanceTest.kt b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/DatabaseStructureStaticAnalysisTest.kt similarity index 95% rename from spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/IndexesMaintenanceTest.kt rename to spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/DatabaseStructureStaticAnalysisTest.kt index d26e941a..75111f66 100644 --- a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/IndexesMaintenanceTest.kt +++ b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/DatabaseStructureStaticAnalysisTest.kt @@ -11,7 +11,8 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired -class IndexesMaintenanceTest : TestBase() { +internal class DatabaseStructureStaticAnalysisTest : TestBase() { + @Autowired private lateinit var checks: List> diff --git a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/TimeControllerTest.kt b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/TimeControllerTest.kt index b8da6b47..6f0797a6 100644 --- a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/TimeControllerTest.kt +++ b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/TimeControllerTest.kt @@ -4,15 +4,6 @@ import io.github.mfvanek.spring.boot3.kotlin.test.filters.TraceIdInResponseServl import io.github.mfvanek.spring.boot3.kotlin.test.service.dto.toParsedDateTime import io.github.mfvanek.spring.boot3.kotlin.test.support.KafkaInitializer import io.github.mfvanek.spring.boot3.kotlin.test.support.TestBase -import java.nio.charset.StandardCharsets -import java.time.Duration -import java.time.LocalDateTime -import java.time.temporal.ChronoUnit -import java.util.Locale -import java.util.UUID -import java.util.concurrent.BlockingQueue -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.TimeUnit import org.apache.kafka.clients.CommonClientConfigs import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.consumer.ConsumerRecord @@ -38,12 +29,20 @@ import org.springframework.kafka.listener.MessageListener import org.springframework.kafka.test.utils.ContainerTestUtils import org.springframework.kafka.test.utils.KafkaTestUtils import org.testcontainers.shaded.org.awaitility.Awaitility +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit +import java.util.* +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.BlockingQueue +import java.util.concurrent.TimeUnit @ExtendWith(OutputCaptureExtension::class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) class TimeControllerTest : TestBase() { private lateinit var container: KafkaMessageListenerContainer - private val consumerRecords = LinkedBlockingQueue>() + private val consumerRecords = ArrayBlockingQueue>(4) @Autowired private lateinit var kafkaProperties: KafkaProperties diff --git a/spring-boot-3-demo-app-reactive/build.gradle.kts b/spring-boot-3-demo-app-reactive/build.gradle.kts new file mode 100644 index 00000000..455de11a --- /dev/null +++ b/spring-boot-3-demo-app-reactive/build.gradle.kts @@ -0,0 +1,87 @@ +plugins { + id("sb-ot-demo.java-conventions") + id("sb-ot-demo.forbidden-apis") + id("sb-ot-demo.docker") + alias(libs.plugins.spring.boot.v3) + id("io.freefair.lombok") +} + +dependencies { + implementation(platform(project(":common-internal-bom"))) + implementation(platform(libs.springdoc.openapi)) + implementation(platform(libs.spring.boot.v3.dependencies)) + implementation(platform(libs.spring.cloud)) + + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("io.micrometer:micrometer-registry-prometheus") + implementation("io.projectreactor:reactor-tools") + implementation("org.springdoc:springdoc-openapi-starter-webflux-ui") + + implementation("org.springframework.kafka:spring-kafka") + + implementation("io.micrometer:micrometer-tracing-bridge-otel") + implementation("io.opentelemetry:opentelemetry-exporter-otlp") + + implementation("org.springframework.boot:spring-boot-starter-jdbc") + implementation("org.postgresql:postgresql") + implementation("com.zaxxer:HikariCP") + implementation(project(":db-migrations")) + implementation("org.liquibase:liquibase-core") + implementation("com.github.blagerweij:liquibase-sessionlock") + implementation(libs.datasource.micrometer) + implementation(libs.logstash) + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-webflux") + testImplementation("org.testcontainers:postgresql") + testImplementation("org.testcontainers:kafka") + testImplementation("org.springframework.kafka:spring-kafka-test") + testImplementation("org.awaitility:awaitility") + testImplementation("io.github.mfvanek:pg-index-health-test-starter") + testImplementation("org.springframework.cloud:spring-cloud-starter-contract-stub-runner") + testImplementation("io.projectreactor:reactor-test") + testImplementation("io.projectreactor.tools:blockhound:1.0.13.RELEASE") +} + +tasks { + withType().all { + jvmArgs("-XX:+AllowRedefinitionToAddDeleteMethods") + } + + jacocoTestCoverageVerification { + dependsOn(jacocoTestReport) + violationRules { + rule { + limit { + counter = "BRANCH" + value = "COVEREDRATIO" + minimum = "0.50".toBigDecimal() + } + } + } + } +} + +val coverageExcludeList = listOf("**/*Application.class") +listOf(JacocoCoverageVerification::class, JacocoReport::class).forEach { taskType -> + tasks.withType(taskType) { + afterEvaluate { + classDirectories.setFrom( + files( + classDirectories.files.map { file -> + fileTree(file).apply { + exclude(coverageExcludeList) + } + } + ) + ) + } + } +} + + +springBoot { + buildInfo() +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/Application.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/Application.java new file mode 100644 index 00000000..d4f0081e --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/Application.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import reactor.tools.agent.ReactorDebugAgent; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + ReactorDebugAgent.init(); + SpringApplication.run(Application.class, args); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/config/ClockConfig.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/config/ClockConfig.java new file mode 100644 index 00000000..e93868a8 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/config/ClockConfig.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration(proxyBeanMethods = false) +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/config/OpenTelemetryConfig.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/config/OpenTelemetryConfig.java new file mode 100644 index 00000000..26e6e024 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/config/OpenTelemetryConfig.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.config; + +import io.opentelemetry.sdk.common.export.RetryPolicy; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpHttpSpanExporterBuilderCustomizer; +import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpTracingAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@AutoConfigureBefore(OtlpTracingAutoConfiguration.class) +@Configuration(proxyBeanMethods = false) +class OpenTelemetryConfig { + + @Bean + OtlpHttpSpanExporterBuilderCustomizer otelJaegerHttpSpanExporterBuilderCustomizer( + @Value("${management.otlp.tracing.retry.max-attempts:2}") int maxAttempts + ) { + return builder -> builder.setRetryPolicy( + RetryPolicy.builder() + .setMaxAttempts(maxAttempts) + .build() + ); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/config/SecurityConfig.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/config/SecurityConfig.java new file mode 100644 index 00000000..f079abd9 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/config/SecurityConfig.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.config; + +import lombok.SneakyThrows; +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.web.server.SecurityWebFilterChain; + +@Configuration(proxyBeanMethods = false) +@EnableWebFluxSecurity +public class SecurityConfig { + + @Bean + @SneakyThrows + public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange(exchanges -> exchanges.anyExchange().permitAll()); + return http.build(); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/config/WebClientConfig.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/config/WebClientConfig.java new file mode 100644 index 00000000..9b236679 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/config/WebClientConfig.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration(proxyBeanMethods = false) +public class WebClientConfig { + + @Value("${app.external-base-url}") + private String external; + + @Bean + public WebClient webClient(WebClient.Builder builder) { + return builder + .baseUrl(external) + .build(); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/controllers/HomeController.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/controllers/HomeController.java new file mode 100644 index 00000000..c002b72b --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/controllers/HomeController.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.controllers; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +public class HomeController { + + @GetMapping("/") + public Mono home() { + return Mono.just("Hello!"); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/controllers/RedirectController.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/controllers/RedirectController.java new file mode 100644 index 00000000..30ca354f --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/controllers/RedirectController.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.controllers; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.net.URISyntaxException; + +@RestController +public class RedirectController { + + // http://localhost:8080/redirect + @GetMapping(path = "/redirect") + public Mono> redirectToGoogle() throws URISyntaxException { + final URI google = new URI("https://www.google.com"); + final HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setLocation(google); + return Mono.just(new ResponseEntity<>(httpHeaders, HttpStatus.SEE_OTHER)); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/controllers/TimeController.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/controllers/TimeController.java new file mode 100644 index 00000000..8217ac43 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/controllers/TimeController.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.controllers; + +import io.github.mfvanek.spring.boot3.reactive.service.KafkaSendingService; +import io.github.mfvanek.spring.boot3.reactive.service.PublicApiService; +import io.micrometer.tracing.Span; +import io.micrometer.tracing.TraceContext; +import io.micrometer.tracing.Tracer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Optional; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class TimeController { + + private final Tracer tracer; + private final Clock clock; + private final KafkaSendingService kafkaSendingService; + private final PublicApiService publicApiService; + + // http://localhost:8080/current-time + @GetMapping(path = "/current-time") + public Mono getNow() { + log.trace("tracer {}", tracer); + + final String traceId = Optional.ofNullable(tracer.currentSpan()) + .map(Span::context) + .map(TraceContext::traceId) + .orElse(null); + log.info("Called method getNow. TraceId = {}", traceId); + + return publicApiService.getZonedTime() + .defaultIfEmpty(LocalDateTime.now(clock)) + .flatMap(now -> kafkaSendingService.sendNotification("Current time = " + now) + .doOnSuccess(v -> log.info("Awaiting acknowledgement from Kafka")) + .thenReturn(now) + ); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/filters/TraceIdInResponseReactiveFilter.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/filters/TraceIdInResponseReactiveFilter.java new file mode 100644 index 00000000..07b50847 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/filters/TraceIdInResponseReactiveFilter.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.filters; + +import io.micrometer.tracing.handler.TracingObservationHandler.TracingContext; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.server.reactive.observation.ServerRequestObservationContext; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +public class TraceIdInResponseReactiveFilter implements WebFilter { + + private static final Logger LOGGER = LoggerFactory.getLogger(TraceIdInResponseReactiveFilter.class); + private static final String TRACE_ID_HEADER_NAME = "X-Trace-Id"; + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + exchange.getResponse().beforeCommit(() -> { + final ServerRequestObservationContext observationContext = exchange.getAttribute(ServerRequestObservationContext.CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE); + if (observationContext != null) { + final TracingContext traceContext = observationContext.get(TracingContext.class); + if (traceContext != null) { + final String traceId = traceContext.getSpan().context().traceId(); + exchange.getResponse().getHeaders().add(TRACE_ID_HEADER_NAME, traceId); + LOGGER.info("Added TraceId: {} to the response", traceId); + } + } + return Mono.empty(); + }); + return chain.filter(exchange); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/KafkaReadingService.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/KafkaReadingService.java new file mode 100644 index 00000000..2fddb203 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/KafkaReadingService.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.service; + +import io.micrometer.tracing.Span; +import io.micrometer.tracing.Tracer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Service; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KafkaReadingService { + + private final Tracer tracer; + private final Clock clock; + private final NamedParameterJdbcTemplate jdbcTemplate; + @Value("${app.tenant.name}") + private String tenantName; + + @KafkaListener(topics = "${spring.kafka.template.default-topic}") + public void listen(ConsumerRecord message, Acknowledgment ack) { + try (MDC.MDCCloseable ignored = MDC.putCloseable("tenant.name", tenantName)) { + final Span currentSpan = tracer.currentSpan(); + final String traceId = currentSpan != null ? currentSpan.context().traceId() : ""; + log.info("Received record: {} with traceId {}", message.value(), traceId); + jdbcTemplate.update("insert into otel_demo.storage(message, trace_id, created_at) values(:msg, :traceId, :createdAt);", + Map.ofEntries( + Map.entry("msg", message.value()), + Map.entry("traceId", traceId), + Map.entry("createdAt", LocalDateTime.now(clock)) + ) + ); + ack.acknowledge(); + } + } +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/KafkaSendingService.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/KafkaSendingService.java new file mode 100644 index 00000000..c254adb2 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/KafkaSendingService.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.util.UUID; +import javax.annotation.Nonnull; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KafkaSendingService { + + private final KafkaTemplate kafkaTemplate; + @Value("${app.tenant.name}") + private String tenantName; + + public Mono> sendNotification(@Nonnull final String message) { + return Mono.deferContextual(contextView -> { + try (MDC.MDCCloseable ignored = MDC.putCloseable("tenant.name", tenantName)) { + log.info("Sending message \"{}\" to Kafka", message); + return Mono.fromFuture(() -> kafkaTemplate.sendDefault(UUID.randomUUID(), message)) + .subscribeOn(Schedulers.boundedElastic()); + } + }); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/PublicApiService.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/PublicApiService.java new file mode 100644 index 00000000..ed23083e --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/PublicApiService.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.mfvanek.spring.boot3.reactive.service.dto.CurrentTime; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.retry.ExhaustedRetryException; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.TimeZone; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublicApiService { + + private final ObjectMapper mapper; + private final WebClient webClient; + @Value("${app.retries}") + private int retries; + + public Mono getZonedTime() { + return getZonedTimeFromWorldTimeApi() + .onErrorResume(ExhaustedRetryException.class, + it -> { + log.warn("Failed to get response", it); + return Mono.empty(); + }) + .map(it -> it.getDatetime().toLocalDateTime()); + } + + private Mono getZonedTimeFromWorldTimeApi() { + final String zoneName = TimeZone.getDefault().getID(); + return webClient.get() + .uri(zoneName) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(String.class) + .retryWhen(prepareRetry(zoneName)) + .flatMap(this::convert) + .onErrorComplete() + .flatMap(Mono::justOrEmpty); + } + + private Retry prepareRetry(final String zoneName) { + return Retry.fixedDelay(retries, Duration.ofSeconds(2)) + .doBeforeRetry(retrySignal -> { + try (MDC.MDCCloseable ignored = MDC.putCloseable("instance_timezone", zoneName)) { + log.info("Retrying request to '/{}', attempt {}/{} due to error:", + zoneName, retrySignal.totalRetries() + 1, retries, retrySignal.failure()); + } + }) + .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> { + log.error("Request to '/{}' failed after {} attempts.", zoneName, retrySignal.totalRetries() + 1); + return new ExhaustedRetryException("Retries exhausted", retrySignal.failure()); + }); + } + + @SneakyThrows + private Mono convert(String string) { + return Mono.just(mapper.readValue(string, CurrentTime.class)); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/dto/CurrentTime.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/dto/CurrentTime.java new file mode 100644 index 00000000..96610d34 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/dto/CurrentTime.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.service.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.extern.jackson.Jacksonized; + +@Jacksonized +@Builder +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@Getter +public class CurrentTime { + + private final ParsedDateTime datetime; +} diff --git a/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/dto/ParsedDateTime.java b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/dto/ParsedDateTime.java new file mode 100644 index 00000000..765fa7e5 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/java/io/github/mfvanek/spring/boot3/reactive/service/dto/ParsedDateTime.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.service.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; +import javax.annotation.Nonnull; +import javax.annotation.concurrent.Immutable; + +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@Getter +@Immutable +public class ParsedDateTime { + + private final int year; + private final int monthValue; + private final int dayOfMonth; + private final int hour; + private final int minute; + + @Nonnull + public static ParsedDateTime from(final LocalDateTime localDateTime) { + return new ParsedDateTime( + localDateTime.getYear(), + localDateTime.getMonthValue(), + localDateTime.getDayOfMonth(), + localDateTime.getHour(), + localDateTime.getMinute() + ); + } + + @Nonnull + public LocalDateTime toLocalDateTime() { + return LocalDateTime.of(year, monthValue, dayOfMonth, hour, minute); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/main/resources/application.yml b/spring-boot-3-demo-app-reactive/src/main/resources/application.yml new file mode 100644 index 00000000..138f52b1 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/resources/application.yml @@ -0,0 +1,137 @@ +app: + external-base-url: "http://worldtimeapi.org/api/timezone/" + retries: 3 + tenant.name: ru-a1-private + +server: + port: 8081 + # See also https://docs.spring.io/spring-boot/docs/2.7.9/reference/html/application-properties.html#appendix.application-properties.server + +demo: + kafka: + opentelemetry: + username: sb-ot-demo-user + password: pwdForSbOtDemoApp + +spring: + application.name: spring-boot-3-demo-app-reactive + datasource: + username: otel_demo_user + password: otel_demo_password + # socketTimeout should be greater than the longest sql query + url: jdbc:postgresql://localhost:6432/otel_demo_db?prepareThreshold=0&targetServerType=primary&hostRecheckSeconds=2&connectTimeout=1&socketTimeout=600 + liquibase: + change-log: classpath:/db/changelog/db.changelog-master.yaml + kafka: + template: + default-topic: open.telemetry.sb3.queue + observation-enabled: true # Important!!! + producer: + key-serializer: org.apache.kafka.common.serialization.UUIDSerializer + listener: + observation-enabled: true # Important!!! + ack-mode: manual_immediate + consumer: + auto-offset-reset: earliest + group-id: ${spring.kafka.template.default-topic}-group + client-id: open.telemetry.client + bootstrap-servers: localhost:9092 + security: + protocol: SASL_PLAINTEXT + properties: + sasl: + mechanism: PLAIN + jaas.config: org.apache.kafka.common.security.plain.PlainLoginModule required username="${demo.kafka.opentelemetry.username}" password="${demo.kafka.opentelemetry.password}"; + jdbc: + template: + query-timeout: 1s + reactor: + context-propagation: auto + +management: + server: + port: 8087 + endpoints: + web: + exposure.include: '*' + cors: + allowed-methods: '*' + allowed-origins: '*' + allowed-headers: '*' + access: + default: read_only + endpoint: + health: + probes.enabled: true + group: + readiness: + include: readinessState, db + additional-path: server:/readyz # In order to collect probes from application main port + access: read_only + prometheus: + access: read_only + liquibase: + access: read_only + info: + access: read_only + threaddump: + access: read_only + heapdump: + access: read_only + metrics: + distribution: + percentiles-histogram: + http.server.requests: true + minimum-expected-value: + http.server.requests: 10ms + maximum-expected-value: + http.server.requests: 10s + slo: + http.server.requests: 1s + health: + livenessState: + enabled: true + readinessState: + enabled: true + prometheus: + metrics: + export: + enabled: true + tracing: + enabled: true + propagation: + type: + - b3 + - w3c + sampling: + probability: 1.0 + observations: + enable: + spring: + security: false + otlp: + tracing: + endpoint: http://localhost:4317 + compression: none + timeout: 5s + +springdoc: + show-actuator: true + use-management-port: true + +jdbc: + datasource-proxy: + include-parameter-values: true + query: + enable-logging: true + log-level: INFO + includes: QUERY + +--- + +spring: + config.activate.on-profile: docker + kafka.bootstrap-servers: kafka1:29092 + datasource.url: jdbc:postgresql://postgres:5432/otel_demo_db?prepareThreshold=0&targetServerType=primary&hostRecheckSeconds=2&connectTimeout=1&socketTimeout=600 + +management.otlp.tracing.endpoint: http://jaeger:4317 diff --git a/spring-boot-3-demo-app-reactive/src/main/resources/logback-spring.xml b/spring-boot-3-demo-app-reactive/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..3a7509ad --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/main/resources/logback-spring.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + ${logging.pattern.console} + utf8 + + + + + + + + false + true + ${includeNonStructuredArguments} + + @timestamp + message + thread + logger + level + [ignore] + [ignore] + + + {"applicationName":"${applicationName}"} + + + + + + + ${CONSOLE_LOG_PATTERN} + utf8 + + + + + + + + + diff --git a/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/ActuatorEndpointTest.java b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/ActuatorEndpointTest.java new file mode 100644 index 00000000..734817ef --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/ActuatorEndpointTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive; + +import io.github.mfvanek.spring.boot3.reactive.support.TestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; + +import javax.annotation.Nonnull; + +import static org.assertj.core.api.Assertions.assertThat; + +class ActuatorEndpointTest extends TestBase { + + @LocalServerPort + private int port; + @LocalManagementPort + private int actuatorPort; + + private WebTestClient actuatorClient; + + @BeforeEach + void setUp() { + this.actuatorClient = WebTestClient.bindToServer() + .baseUrl("http://localhost:" + actuatorPort + "/actuator/") + .build(); + } + + @Test + void actuatorShouldBeRunOnSeparatePort() { + assertThat(actuatorPort) + .isNotEqualTo(port); + } + + @ParameterizedTest + @CsvSource(value = { + "prometheus|jvm_threads_live_threads|text/plain", + "health|{\"status\":\"UP\",\"groups\":[\"liveness\",\"readiness\"]}|application/json", + "health/liveness|{\"status\":\"UP\"}|application/json", + "health/readiness|{\"status\":\"UP\"}|application/json", + "info|\"version\":|application/json"}, delimiter = '|') + void actuatorEndpointShouldReturnOk(@Nonnull final String endpointName, + @Nonnull final String expectedSubstring, + @Nonnull final String mediaType) { + final String result = actuatorClient.get() + .uri(uriBuilder -> uriBuilder + .path(endpointName) + .build()) + .accept(MediaType.valueOf(mediaType)) + .exchange() + .expectStatus().isOk() + .expectBody(String.class) + .returnResult() + .getResponseBody(); + assertThat(result) + .contains(expectedSubstring); + } + + @Test + void swaggerUiEndpointShouldReturnFound() { + final byte[] result = actuatorClient.get() + .uri(uriBuilder -> uriBuilder + .pathSegment("swagger-ui") + .build()) + .accept(MediaType.TEXT_HTML) + .exchange() + .expectStatus().isFound() + .expectHeader().location("/actuator/swagger-ui/index.html") + .expectBody() + .returnResult() + .getResponseBody(); + assertThat(result).isNull(); + } + + @Test + void readinessProbeShouldBeCollectedFromApplicationMainPort() { + final String result = webTestClient.get() + .uri(uriBuilder -> uriBuilder + .pathSegment("readyz") + .build()) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody(String.class) + .returnResult() + .getResponseBody(); + assertThat(result) + .isEqualTo("{\"status\":\"UP\"}"); + + final String metricsResult = actuatorClient.get() + .uri(uriBuilder -> uriBuilder + .path("prometheus") + .build()) + .accept(MediaType.valueOf("text/plain")) + .exchange() + .expectStatus().isOk() + .expectBody(String.class) + .returnResult() + .getResponseBody(); + assertThat(metricsResult) + .contains("http_server_requests_seconds_bucket"); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/ApplicationTests.java b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/ApplicationTests.java new file mode 100644 index 00000000..f7c69df3 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/ApplicationTests.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive; + +import io.github.mfvanek.spring.boot3.reactive.support.JaegerInitializer; +import io.github.mfvanek.spring.boot3.reactive.support.TestBase; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.tracing.Span; +import io.micrometer.tracing.Tracer; +import io.micrometer.tracing.otel.bridge.OtelTracer; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.dao.QueryTimeoutException; + +import java.util.Locale; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ApplicationTests extends TestBase { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void contextLoads() { + assertThat(applicationContext.containsBean("otlpMeterRegistry")) + .isFalse(); + assertThat(applicationContext.getBean(ObservationRegistry.class)) + .isNotNull() + .isInstanceOf(ObservationRegistry.class); + assertThat(applicationContext.getBean(Tracer.class)) + .isNotNull() + .isInstanceOf(OtelTracer.class) + .satisfies(t -> assertThat(t.currentSpan()) + .isNotEqualTo(Span.NOOP)); + assertThat(applicationContext.getBean("otlpHttpSpanExporter")) + .isNotNull() + .isInstanceOf(OtlpHttpSpanExporter.class) + .satisfies(e -> assertThat(e.toString()) + .contains(String.format(Locale.ROOT, """ + OtlpHttpSpanExporter{exporterName=otlp, type=span, endpoint=http://localhost:%d, \ + timeoutNanos=5000000000, proxyOptions=null, compressorEncoding=null, connectTimeoutNanos=10000000000, \ + exportAsJson=false, headers=Headers{User-Agent=OBFUSCATED}, retryPolicy=RetryPolicy{maxAttempts=2, \ + initialBackoff=PT1S, maxBackoff=PT5S, backoffMultiplier=1.5, retryExceptionPredicate=null},""", + JaegerInitializer.getFirstMappedPort()))); + } + + @Test + void jdbcQueryTimeoutFromProperties() { + assertThat(jdbcTemplate.getQueryTimeout()) + .isEqualTo(1); + } + + @Test + @DisplayName("Throws exception when query exceeds timeout") + void exceptionWithLongQuery() { + assertThatThrownBy(() -> jdbcTemplate.execute("select pg_sleep(1.1);")) + .isInstanceOf(QueryTimeoutException.class) + .hasMessageContaining("ERROR: canceling statement due to user request"); + } + + @Test + @DisplayName("Does not throw exception when query does not exceed timeout") + void exceptionNotThrownWithNotLongQuery() { + assertThatNoException().isThrownBy(() -> jdbcTemplate.execute("select pg_sleep(0.9);")); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/DatabaseStructureStaticAnalysisTest.java b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/DatabaseStructureStaticAnalysisTest.java new file mode 100644 index 00000000..88012845 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/DatabaseStructureStaticAnalysisTest.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive; + +import io.github.mfvanek.pg.core.checks.common.DatabaseCheckOnHost; +import io.github.mfvanek.pg.core.checks.common.Diagnostic; +import io.github.mfvanek.pg.model.context.PgContext; +import io.github.mfvanek.pg.model.dbobject.DbObject; +import io.github.mfvanek.pg.model.predicates.SkipLiquibaseTablesPredicate; +import io.github.mfvanek.spring.boot3.reactive.support.TestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class DatabaseStructureStaticAnalysisTest extends TestBase { + + @Autowired + private List> checks; + + @Test + @DisplayName("Always check PostgreSQL version in your tests") + void checkPostgresVersion() { + final String pgVersion = jdbcTemplate.queryForObject("select version();", String.class); + assertThat(pgVersion) + .startsWith("PostgreSQL 17.4"); + } + + @Test + void databaseStructureCheckForPublicSchema() { + assertThat(checks) + .hasSameSizeAs(Diagnostic.values()); + + checks.stream() + .filter(DatabaseCheckOnHost::isStatic) + .forEach(check -> + assertThat(check.check(PgContext.ofDefault(), SkipLiquibaseTablesPredicate.ofDefault())) + .as(check.getDiagnostic().name()) + .isEmpty()); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/controllers/HomeControllerTest.java b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/controllers/HomeControllerTest.java new file mode 100644 index 00000000..207a1c13 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/controllers/HomeControllerTest.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.controllers; + +import io.github.mfvanek.spring.boot3.reactive.support.TestBase; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HomeControllerTest extends TestBase { + + @Test + void controllerResponseShouldHaveTraceIdHeader() { + final String result = webTestClient.get() + .uri("/") + .exchange() + .expectStatus().isEqualTo(200) + .expectHeader().exists(TRACE_ID_HEADER_NAME) + .expectBody(String.class) + .returnResult() + .getResponseBody(); + assertThat(result) + .isEqualTo("Hello!"); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/controllers/RedirectControllerTest.java b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/controllers/RedirectControllerTest.java new file mode 100644 index 00000000..5cd028b1 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/controllers/RedirectControllerTest.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.controllers; + +import io.github.mfvanek.spring.boot3.reactive.support.TestBase; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RedirectControllerTest extends TestBase { + + @Test + void redirectShouldWork() { + final Object result = webTestClient.get() + .uri("/redirect") + .exchange() + .expectStatus().isEqualTo(303) + .expectHeader().exists(TRACE_ID_HEADER_NAME) + .expectHeader().location("https://www.google.com") + .expectBody(Object.class) + .returnResult() + .getResponseBody(); + assertThat(result) + .isNull(); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/controllers/TimeControllerTest.java b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/controllers/TimeControllerTest.java new file mode 100644 index 00000000..b57913df --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/controllers/TimeControllerTest.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.controllers; + +import io.github.mfvanek.spring.boot3.reactive.service.dto.ParsedDateTime; +import io.github.mfvanek.spring.boot3.reactive.support.KafkaConsumerUtils; +import io.github.mfvanek.spring.boot3.reactive.support.TestBase; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Header; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.kafka.listener.KafkaMessageListenerContainer; +import org.springframework.test.web.reactive.server.EntityExchangeResult; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nonnull; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +@ExtendWith(OutputCaptureExtension.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TimeControllerTest extends TestBase { + + private final BlockingQueue> consumerRecords = new ArrayBlockingQueue<>(4); + private KafkaMessageListenerContainer container; + @Autowired + private KafkaProperties kafkaProperties; + + @BeforeAll + void setUpKafkaConsumer() { + container = KafkaConsumerUtils.setUpKafkaConsumer(kafkaProperties, consumerRecords); + } + + @AfterAll + void tearDownKafkaConsumer() { + if (container != null) { + container.stop(); + container = null; + } + } + + @BeforeEach + void cleanUpDatabase() { + jdbcTemplate.execute("truncate table otel_demo.storage"); + } + + @Order(1) + @Test + void spanShouldBeReportedInLogs(@Nonnull final CapturedOutput output) throws InterruptedException { + stubOkResponse(ParsedDateTime.from(LocalDateTime.now(clock).minusDays(1))); + + final EntityExchangeResult result = webTestClient.get() + .uri(uriBuilder -> uriBuilder.path("current-time") + .build()) + .exchange() + .expectStatus().isOk() + .expectHeader().exists(TRACE_ID_HEADER_NAME) + .expectBody(LocalDateTime.class) + .returnResult(); + final String traceId = result.getResponseHeaders().getFirst(TRACE_ID_HEADER_NAME); + assertThat(traceId).isNotBlank(); + assertThat(result.getResponseBody()).isCloseTo(LocalDateTime.now(clock).minusDays(1), within(1, ChronoUnit.MINUTES)); + assertThat(output.getAll()) + .contains("Called method getNow. TraceId = " + traceId) + .contains("Awaiting acknowledgement from Kafka"); + + final ConsumerRecord received = consumerRecords.poll(10, TimeUnit.SECONDS); + assertThat(received).isNotNull(); + assertThatTraceIdPresentInKafkaHeaders(received, traceId); + + awaitStoringIntoDatabase(); + + assertThat(output.getAll()) + .contains("Received record: " + received.value() + " with traceId " + traceId) + .contains("\"tenant.name\":\"ru-a1-private\""); + final String messageFromDb = namedParameterJdbcTemplate.queryForObject("select message from otel_demo.storage where trace_id = :traceId", + Map.of("traceId", traceId), String.class); + assertThat(messageFromDb) + .isEqualTo(received.value()); + } + + @Order(2) + @Test + void spanAndMdcShouldBeReportedWhenRetry(@Nonnull final CapturedOutput output) { + final String zoneName = stubErrorResponse(); + + final EntityExchangeResult result = webTestClient.get() + .uri(uriBuilder -> uriBuilder.path("current-time") + .build()) + .header("traceparent", "00-38c19768104ab8ae64fabbeed65bbbdf-4cac1747d4e1ee10-01") + .exchange() + .expectStatus().isOk() + .expectHeader().exists(TRACE_ID_HEADER_NAME) + .expectBody(LocalDateTime.class) + .returnResult(); + final String traceId = result.getResponseHeaders().getFirst(TRACE_ID_HEADER_NAME); + assertThat(traceId) + .isEqualTo("38c19768104ab8ae64fabbeed65bbbdf"); + assertThat(result.getResponseBody()).isCloseTo(LocalDateTime.now(clock), within(1, ChronoUnit.MINUTES)); + assertThat(output.getAll()) + .containsPattern(String.format(Locale.ROOT, + ".*\"message\":\"Retrying request to '/%1$s', attempt 1/1 due to error:\"," + + "\"logger\":\"io\\.github\\.mfvanek\\.spring\\.boot3\\.reactive\\.service\\.PublicApiService\"," + + "\"thread\":\"[^\"]+\",\"level\":\"INFO\",\"stack_trace\":\".+?\"," + + "\"traceId\":\"38c19768104ab8ae64fabbeed65bbbdf\",\"spanId\":\"[a-f0-9]+\",\"instance_timezone\":\"%1$s\",\"applicationName\":\"spring-boot-3-demo-app-reactive\"\\}%n", zoneName)) + .containsPattern(String.format(Locale.ROOT, + ".*\"message\":\"Request to '/%s' failed after 2 attempts.\",\"logger\":\"io\\.github\\.mfvanek\\.spring\\.boot3\\.reactive\\.service\\.PublicApiService\"," + + "\"thread\":\"[^\"]+\",\"level\":\"ERROR\"," + + "\"traceId\":\"38c19768104ab8ae64fabbeed65bbbdf\",\"spanId\":\"[a-f0-9]+\",\"applicationName\":\"spring-boot-3-demo-app-reactive\"}%n", + zoneName)); + } + + private long countRecordsInTable() { + final Long queryResult = jdbcTemplate.queryForObject("select count(*) from otel_demo.storage", Long.class); + return Objects.requireNonNullElse(queryResult, 0L); + } + + private void assertThatTraceIdPresentInKafkaHeaders(@Nonnull final ConsumerRecord received, + @Nonnull final String expectedTraceId) { + assertThat(received.value()).startsWith("Current time = "); + final Header[] headers = received.headers().toArray(); + final List headerNames = Arrays.stream(headers) + .map(Header::key) + .toList(); + assertThat(headerNames) + .hasSize(2) + .containsExactlyInAnyOrder("traceparent", "b3"); + final List headerValues = Arrays.stream(headers) + .map(Header::value) + .map(v -> new String(v, StandardCharsets.UTF_8)) + .toList(); + assertThat(headerValues) + .hasSameSizeAs(headerNames) + .allSatisfy(h -> assertThat(h).contains(expectedTraceId)); + } + + private void awaitStoringIntoDatabase() { + Awaitility + .await() + .atMost(10, TimeUnit.SECONDS) + .pollInterval(Duration.ofMillis(500L)) + .until(() -> countRecordsInTable() >= 1L); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/service/PublicApiServiceTest.java b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/service/PublicApiServiceTest.java new file mode 100644 index 00000000..51cb25c7 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/service/PublicApiServiceTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.service; + +import io.github.mfvanek.spring.boot3.reactive.service.dto.ParsedDateTime; +import io.github.mfvanek.spring.boot3.reactive.support.TestBase; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.tracing.Tracer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import reactor.test.StepVerifier; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Locale; +import java.util.Objects; +import javax.annotation.Nonnull; + +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(OutputCaptureExtension.class) +class PublicApiServiceTest extends TestBase { + + @Autowired + private PublicApiService publicApiService; + @Autowired + private Tracer tracer; + @Autowired + private ObservationRegistry observationRegistry; + + @Test + void printTimeZoneSuccessfully(@Nonnull final CapturedOutput output) { + final LocalDateTime localDateTimeNow = LocalDateTime.now(clock); + final String zoneName = stubOkResponse(ParsedDateTime.from(localDateTimeNow)); + + final LocalDateTime result = Objects.requireNonNull(publicApiService.getZonedTime()).block(); + verify(getRequestedFor(urlPathMatching("/" + zoneName))); + assertThat(result).isNotNull(); + assertThat(result.truncatedTo(ChronoUnit.MINUTES)) + .isEqualTo(localDateTimeNow.truncatedTo(ChronoUnit.MINUTES)); + } + + @Test + void printTimeZoneSuccessfullyWithStepVerifier() { + final LocalDateTime localDateTimeNow = LocalDateTime.now(clock); + stubOkResponse(ParsedDateTime.from(localDateTimeNow)); + + StepVerifier.create(publicApiService.getZonedTime()) + .expectNext(localDateTimeNow.truncatedTo(ChronoUnit.MINUTES)) + .verifyComplete(); + } + + @Test + void retriesOnceToGetZonedTime(@Nonnull final CapturedOutput output) { + final String zoneName = stubErrorResponse(); + + Observation.createNotStarted("test", observationRegistry).observe(() -> { + final String traceId = Objects.requireNonNull(tracer.currentSpan()).context().traceId(); + + final LocalDateTime result = publicApiService.getZonedTime().block(); + assertThat(result).isNull(); + + assertThat(output.getAll()) + .containsPattern(String.format(Locale.ROOT, + ".*\"message\":\"Retrying request to '[^']+?', attempt 1/1 due to error:\"," + + "\"logger\":\"io\\.github\\.mfvanek\\.spring\\.boot3\\.reactive\\.service\\.PublicApiService\"," + + "\"thread\":\"[^\"]+\",\"level\":\"INFO\",\"stack_trace\":\".+?\"," + + "\"traceId\":\"%s\",\"spanId\":\"[a-f0-9]+\",\"instance_timezone\":\"%s\",\"applicationName\":\"spring-boot-3-demo-app-reactive\"\\}%n", traceId, zoneName)) + .containsPattern(String.format(Locale.ROOT, + ".*\"message\":\"Request to '[^']+?' failed after 2 attempts.\"," + + "\"logger\":\"io\\.github\\.mfvanek\\.spring\\.boot3\\.reactive\\.service\\.PublicApiService\"," + + "\"thread\":\"[^\"]+\",\"level\":\"ERROR\",\"traceId\":\"%s\",\"spanId\":\"[a-f0-9]+\",\"applicationName\":\"spring-boot-3-demo-app-reactive\"}%n", traceId)) + .doesNotContain("Failed to convert response "); + }); + + verify(2, getRequestedFor(urlPathMatching("/" + zoneName))); + } + + @Test + void emptyResponseWhen500StatusWithStepVerifier() { + stubErrorResponse(); + + StepVerifier.create(publicApiService.getZonedTime()) + .expectNextCount(0) + .verifyComplete(); + } + + @Test + void emptyResponseWhen200StatusWithBadResposeWithStepVerifier(@Nonnull final CapturedOutput output) { + stubOkButNotCorrectResponse(); + + StepVerifier.create(publicApiService.getZonedTime()) + .expectNextCount(0) + .verifyComplete(); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/JaegerInitializer.java b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/JaegerInitializer.java new file mode 100644 index 00000000..380817f5 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/JaegerInitializer.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.support; + +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import javax.annotation.Nonnull; + +@SuppressWarnings("resource") +public class JaegerInitializer implements ApplicationContextInitializer { + + private static final DockerImageName IMAGE = DockerImageName.parse("jaegertracing/all-in-one:1.53"); + private static final GenericContainer JAEGER = new GenericContainer<>(IMAGE) + .withExposedPorts(4317); + + @Override + public void initialize(final ConfigurableApplicationContext context) { + JAEGER.start(); + + final String jaegerUrl = "http://localhost:" + JAEGER.getFirstMappedPort(); + TestPropertyValues.of( + "management.otlp.tracing.endpoint=" + jaegerUrl + ).applyTo(context.getEnvironment()); + } + + @Nonnull + public static Integer getFirstMappedPort() { + return JAEGER.getFirstMappedPort(); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/KafkaConsumerUtils.java b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/KafkaConsumerUtils.java new file mode 100644 index 00000000..0827934c --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/KafkaConsumerUtils.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.support; + +import lombok.experimental.UtilityClass; +import org.apache.kafka.clients.CommonClientConfigs; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.config.SaslConfigs; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.listener.KafkaMessageListenerContainer; +import org.springframework.kafka.listener.MessageListener; +import org.springframework.kafka.test.utils.ContainerTestUtils; +import org.springframework.kafka.test.utils.KafkaTestUtils; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import javax.annotation.Nonnull; + +@UtilityClass +public class KafkaConsumerUtils { + + public KafkaMessageListenerContainer setUpKafkaConsumer( + @Nonnull final KafkaProperties kafkaProperties, + @Nonnull final BlockingQueue> consumerRecords) { + final var containerProperties = new ContainerProperties(kafkaProperties.getTemplate().getDefaultTopic()); + final Map consumerProperties = KafkaTestUtils.consumerProps(KafkaInitializer.getBootstrapSevers(), "test-group", "false"); + consumerProperties.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "SASL_PLAINTEXT"); + consumerProperties.put(SaslConfigs.SASL_MECHANISM, "PLAIN"); + consumerProperties.put(SaslConfigs.SASL_JAAS_CONFIG, KafkaInitializer.plainJaas()); + consumerProperties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, org.apache.kafka.common.serialization.UUIDDeserializer.class); + final var consumer = new DefaultKafkaConsumerFactory(consumerProperties); + final var container = new KafkaMessageListenerContainer<>(consumer, containerProperties); + container.setupMessageListener((MessageListener) consumerRecords::add); + container.start(); + ContainerTestUtils.waitForAssignment(container, 1); + return container; + } +} diff --git a/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/KafkaInitializer.java b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/KafkaInitializer.java new file mode 100644 index 00000000..83175412 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/KafkaInitializer.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.support; + +import org.apache.kafka.common.security.plain.PlainLoginModule; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.testcontainers.containers.KafkaContainer; +import org.testcontainers.utility.DockerImageName; + +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; + +public class KafkaInitializer implements ApplicationContextInitializer { + + private static final String KAFKA_USER_NAME = "sb-ot-demo-user"; + private static final String KAFKA_USER_PASSWORD = "pwdForSbOtDemoApp"; + + private static final DockerImageName IMAGE_NAME = DockerImageName.parse("confluentinc/cp-kafka:7.7.1"); + + private static final KafkaContainer KAFKA_CONTAINER = new KafkaContainer(IMAGE_NAME) + .withEnv("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "PLAINTEXT:SASL_PLAINTEXT,BROKER:PLAINTEXT") + .withEnv("KAFKA_LISTENER_NAME_PLAINTEXT_SASL_ENABLED_MECHANISMS", "PLAIN") + .withEnv("KAFKA_SASL_JAAS_CONFIG", plainJaas()) + .withEnv("KAFKA_LISTENER_NAME_PLAINTEXT_PLAIN_SASL_JAAS_CONFIG", plainJaas(Map.of(KAFKA_USER_NAME, KAFKA_USER_PASSWORD))); + + @Nonnull + public static String plainJaas() { + return plainJaas(Map.of()); + } + + @Nonnull + private static String plainJaas(@Nonnull final Map additionalUsers) { + final String users = additionalUsers.entrySet() + .stream() + .map(e -> String.format(Locale.ROOT, "user_%s=\"%s\"", e.getKey(), e.getValue())) + .collect(Collectors.joining(" ")); + final StringBuilder builder = new StringBuilder() + .append(PlainLoginModule.class.getName()) + .append(String.format(Locale.ROOT, " required username=\"%s\" password=\"%s\"", KAFKA_USER_NAME, KAFKA_USER_PASSWORD)); + if (!users.isBlank()) { + builder.append(' ') + .append(users); + } + return builder.append(';') + .toString(); + } + + @Override + public void initialize(@Nonnull final ConfigurableApplicationContext applicationContext) { + KAFKA_CONTAINER.start(); + TestPropertyValues.of( + "spring.kafka.bootstrap-servers=" + KAFKA_CONTAINER.getBootstrapServers(), + "demo.kafka.opentelemetry.username=" + KAFKA_USER_NAME, + "demo.kafka.opentelemetry.password=" + KAFKA_USER_PASSWORD + ).applyTo(applicationContext.getEnvironment()); + } + + @Nonnull + public static String getBootstrapSevers() { + return KAFKA_CONTAINER.getBootstrapServers(); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/PostgresInitializer.java b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/PostgresInitializer.java new file mode 100644 index 00000000..9f73a84c --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/PostgresInitializer.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.support; + +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +public class PostgresInitializer implements ApplicationContextInitializer { + + private static final DockerImageName IMAGE = DockerImageName.parse("postgres:17.4"); + private static final Network NETWORK = Network.newNetwork(); + private static final PostgreSQLContainer CONTAINER = new PostgreSQLContainer<>(IMAGE); + + @Override + public void initialize(final ConfigurableApplicationContext context) { + CONTAINER + .withNetwork(NETWORK) + .withUsername("otel_demo_user") + .withPassword("otel_demo_password") + .withUrlParam("prepareThreshold", "0") + .waitingFor(Wait.forListeningPort()) + .start(); + + TestPropertyValues.of( + "spring.datasource.url=" + CONTAINER.getJdbcUrl(), + "spring.datasource.username=" + CONTAINER.getUsername(), + "spring.datasource.password=" + CONTAINER.getPassword() + ).applyTo(context.getEnvironment()); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/TestBase.java b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/TestBase.java new file mode 100644 index 00000000..cf04c2d3 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/test/java/io/github/mfvanek/spring/boot3/reactive/support/TestBase.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.reactive.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.client.WireMock; +import io.github.mfvanek.spring.boot3.reactive.service.dto.CurrentTime; +import io.github.mfvanek.spring.boot3.reactive.service.dto.ParsedDateTime; +import lombok.SneakyThrows; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.blockhound.BlockHound; + +import java.time.Clock; +import java.util.TimeZone; +import javax.annotation.Nonnull; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; + +@ActiveProfiles("test") +@AutoConfigureObservability +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ContextConfiguration(initializers = {KafkaInitializer.class, JaegerInitializer.class, PostgresInitializer.class}) +@AutoConfigureWireMock(port = 0) +public abstract class TestBase { + + protected static final String TRACE_ID_HEADER_NAME = "X-Trace-Id"; + @Autowired + protected WebTestClient webTestClient; + @Autowired + protected ObjectMapper objectMapper; + @Autowired + protected Clock clock; + @Autowired + protected JdbcTemplate jdbcTemplate; + @Autowired + protected NamedParameterJdbcTemplate namedParameterJdbcTemplate; + + @BeforeAll + static void initBlockHound() { + BlockHound.install(); + } + + @BeforeEach + void resetExternalMocks() { + WireMock.resetAllRequests(); + } + + @Nonnull + protected String stubOkResponse(@Nonnull final ParsedDateTime parsedDateTime) { + final String zoneName = TimeZone.getDefault().getID(); + stubOkResponse(zoneName, parsedDateTime); + return zoneName; + } + + @SneakyThrows + private void stubOkResponse(@Nonnull final String zoneName, @Nonnull final ParsedDateTime parsedDateTime) { + final CurrentTime currentTime = new CurrentTime(parsedDateTime); + stubFor(get(urlPathMatching("/" + zoneName)) + .willReturn(aResponse() + .withStatus(200) + .withBody(objectMapper.writeValueAsString(currentTime)) + )); + } + + @Nonnull + protected String stubErrorResponse() { + final String zoneName = TimeZone.getDefault().getID(); + final RuntimeException exception = new RuntimeException("Retries exhausted"); + stubErrorResponse(zoneName, exception); + return zoneName; + } + + @SneakyThrows + private void stubErrorResponse(@Nonnull final String zoneName, @Nonnull final RuntimeException errorForResponse) { + stubFor(get(urlPathMatching("/" + zoneName)) + .willReturn(aResponse() + .withStatus(500) + .withBody(objectMapper.writeValueAsString(errorForResponse)) + )); + } + + @Nonnull + protected String stubOkButNotCorrectResponse() { + final String zoneName = TimeZone.getDefault().getID(); + stubOkButNotCorrectResponse(zoneName); + return zoneName; + } + + @SneakyThrows + private void stubOkButNotCorrectResponse(@Nonnull final String zoneName) { + stubFor(get(urlPathMatching("/" + zoneName)) + .willReturn(aResponse() + .withStatus(200) + .withBody(objectMapper.writeValueAsString("bad response")) + )); + } +} diff --git a/spring-boot-3-demo-app-reactive/src/test/resources/application-test.yml b/spring-boot-3-demo-app-reactive/src/test/resources/application-test.yml new file mode 100644 index 00000000..8237d306 --- /dev/null +++ b/spring-boot-3-demo-app-reactive/src/test/resources/application-test.yml @@ -0,0 +1,14 @@ +app: + external-base-url: "http://localhost:${wiremock.server.port}/" + retries: 1 + +logging: +# appender: +# name: CONSOLE + level: + org.testcontainers: INFO # In order to troubleshoot issues with Testcontainers, increase the logging level to DEBUG + com.github.dockerjava: WARN + com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.wire: OFF +spring: + reactor: + context-propagation: auto diff --git a/spring-boot-3-demo-app/build.gradle.kts b/spring-boot-3-demo-app/build.gradle.kts index d0ca06b8..72809d00 100644 --- a/spring-boot-3-demo-app/build.gradle.kts +++ b/spring-boot-3-demo-app/build.gradle.kts @@ -43,6 +43,21 @@ dependencies { testImplementation("org.springframework.cloud:spring-cloud-starter-contract-stub-runner") } +tasks { + jacocoTestCoverageVerification { + dependsOn(jacocoTestReport) + violationRules { + rule { + limit { + counter = "BRANCH" + value = "COVEREDRATIO" + minimum = "0.66".toBigDecimal() + } + } + } + } +} + springBoot { buildInfo() } diff --git a/spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/IndexesMaintenanceTest.java b/spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/DatabaseStructureStaticAnalysisTest.java similarity index 96% rename from spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/IndexesMaintenanceTest.java rename to spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/DatabaseStructureStaticAnalysisTest.java index 27136766..1266eb84 100644 --- a/spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/IndexesMaintenanceTest.java +++ b/spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/DatabaseStructureStaticAnalysisTest.java @@ -21,7 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; -class IndexesMaintenanceTest extends TestBase { +class DatabaseStructureStaticAnalysisTest extends TestBase { @Autowired private List> checks; diff --git a/spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/controllers/TimeControllerTest.java b/spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/controllers/TimeControllerTest.java index 8336cd2d..374794d4 100644 --- a/spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/controllers/TimeControllerTest.java +++ b/spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/controllers/TimeControllerTest.java @@ -36,8 +36,8 @@ import java.util.Map; import java.util.Objects; import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import javax.annotation.Nonnull; @@ -49,7 +49,7 @@ class TimeControllerTest extends TestBase { private KafkaMessageListenerContainer container; - private final BlockingQueue> consumerRecords = new LinkedBlockingQueue<>(); + private final BlockingQueue> consumerRecords = new ArrayBlockingQueue<>(4); @Autowired private KafkaProperties kafkaProperties; diff --git a/spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/support/TestBase.java b/spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/support/TestBase.java index b3f46b2b..e5b257da 100644 --- a/spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/support/TestBase.java +++ b/spring-boot-3-demo-app/src/test/java/io/github/mfvanek/spring/boot3/test/support/TestBase.java @@ -16,7 +16,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.test.context.ActiveProfiles; @@ -24,6 +27,9 @@ import org.springframework.test.web.reactive.server.WebTestClient; import java.time.Clock; +import java.time.LocalDateTime; +import java.time.Month; +import java.time.ZoneOffset; import java.util.TimeZone; import javax.annotation.Nonnull; @@ -35,10 +41,16 @@ @ActiveProfiles("test") @AutoConfigureObservability @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@ContextConfiguration(initializers = {KafkaInitializer.class, JaegerInitializer.class, PostgresInitializer.class}) +@ContextConfiguration( + classes = TestBase.CustomClockConfiguration.class, + initializers = {KafkaInitializer.class, JaegerInitializer.class, PostgresInitializer.class} +) @AutoConfigureWireMock(port = 0) public abstract class TestBase { + private static final ZoneOffset FIXED_ZONE = ZoneOffset.ofHours(-1); + private static final LocalDateTime BEFORE_MILLENNIUM = LocalDateTime.of(1999, Month.DECEMBER, 31, 23, 59, 59); + @Autowired protected WebTestClient webTestClient; @Autowired @@ -88,4 +100,14 @@ private void stubErrorResponse(@Nonnull final String zoneName, @Nonnull final Ru .withBody(objectMapper.writeValueAsString(errorForResponse)) )); } + + @TestConfiguration + static class CustomClockConfiguration { + + @Bean + @Primary + public Clock fixedClock() { + return Clock.fixed(BEFORE_MILLENNIUM.toInstant(FIXED_ZONE), FIXED_ZONE); + } + } }