From 518b3bba22b37d188493a3b8b16506d712767533 Mon Sep 17 00:00:00 2001 From: Ralf Ueberfuhr Date: Tue, 2 Sep 2025 13:59:13 +0200 Subject: [PATCH] BAEL-9364: Add samples for new features in Spring Boot 4 and Spring Framework 7 - API Versioning - `@HttpServiceClient` - Resilient Methods - Multiple Task Decorators --- spring-boot-modules/spring-boot-4/pom.xml | 58 +++++++---------- .../SampleApplication.java | 6 +- .../com/baeldung/spring/mvc/ApiConfig.java | 24 +++++++ .../spring/mvc/ChristmasJoyClient.java | 16 +++++ .../spring/mvc/HelloWorldController.java | 22 +++++++ .../baeldung/spring/mvc/HelloWorldEvent.java | 5 ++ .../spring/mvc/HelloWorldEventLogger.java | 19 ++++++ .../spring/mvc/HelloWorldV3Controller.java | 17 +++++ .../spring/mvc/HelloWorldV4Controller.java | 26 ++++++++ .../baeldung/spring/mvc/HttpClientConfig.java | 36 +++++++++++ .../mvc/TaskDecoratorConfiguration.java | 41 ++++++++++++ .../src/main/resources/application.properties | 1 + .../mvc/HelloWorldApiIntegrationTest.java | 62 +++++++++++++++++++ .../mvc/HelloWorldApiV4IntegrationTest.java | 45 ++++++++++++++ 14 files changed, 342 insertions(+), 36 deletions(-) rename spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/{beanregistrar => }/SampleApplication.java (59%) create mode 100644 spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/ApiConfig.java create mode 100644 spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/ChristmasJoyClient.java create mode 100644 spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldController.java create mode 100644 spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldEvent.java create mode 100644 spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldEventLogger.java create mode 100644 spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldV3Controller.java create mode 100644 spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldV4Controller.java create mode 100644 spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HttpClientConfig.java create mode 100644 spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/TaskDecoratorConfiguration.java create mode 100644 spring-boot-modules/spring-boot-4/src/main/resources/application.properties create mode 100644 spring-boot-modules/spring-boot-4/src/test/java/com/baeldung/spring/mvc/HelloWorldApiIntegrationTest.java create mode 100644 spring-boot-modules/spring-boot-4/src/test/java/com/baeldung/spring/mvc/HelloWorldApiV4IntegrationTest.java diff --git a/spring-boot-modules/spring-boot-4/pom.xml b/spring-boot-modules/spring-boot-4/pom.xml index 1463a385000f..1fa59f2fb97e 100644 --- a/spring-boot-modules/spring-boot-4/pom.xml +++ b/spring-boot-modules/spring-boot-4/pom.xml @@ -14,7 +14,7 @@ 1.0.0-SNAPSHOT - + org.springframework.boot spring-boot-devtools @@ -26,6 +26,18 @@ spring-boot-configuration-processor true + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + org.projectlombok lombok @@ -37,9 +49,18 @@ ${mapstruct.version} true + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + org.springframework.boot spring-boot-starter-test + test @@ -128,42 +149,9 @@ - - - repository.spring.milestone - Spring Snapshot Repository - https://repo.spring.io/milestone - - - repository.spring.snapshot - Spring Snapshot Repository - https://repo.spring.io/snapshot - - true - daily - - - - - - repository.spring.milestone.plugins - Spring Snapshot Repository - https://repo.spring.io/milestone - - - repository.spring.snapshot.plugins - Spring Snapshot Repository - https://repo.spring.io/snapshot - - true - daily - - - - 1.6.3 - 4.0.0-SNAPSHOT + 4.0.0-M2 1.5.18 0.2.0 diff --git a/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/beanregistrar/SampleApplication.java b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/SampleApplication.java similarity index 59% rename from spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/beanregistrar/SampleApplication.java rename to spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/SampleApplication.java index 07c2684e3e22..44e5bb9be9f9 100644 --- a/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/beanregistrar/SampleApplication.java +++ b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/SampleApplication.java @@ -1,9 +1,13 @@ -package com.baeldung.spring.beanregistrar; +package com.baeldung.spring; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.resilience.annotation.EnableResilientMethods; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication +@EnableResilientMethods +@EnableAsync public class SampleApplication { public static void main(String[] args) { diff --git a/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/ApiConfig.java b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/ApiConfig.java new file mode 100644 index 000000000000..50a3ab95109b --- /dev/null +++ b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/ApiConfig.java @@ -0,0 +1,24 @@ +package com.baeldung.spring.mvc; + +import org.jspecify.annotations.NonNull; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerTypePredicate; +import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class ApiConfig implements WebMvcConfigurer { + + @Override + public void configureApiVersioning(@NonNull ApiVersionConfigurer configurer) { + configurer.usePathSegment(1); + } + + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + configurer.addPathPrefix("/api/v{version}", HandlerTypePredicate.forAnnotation(RestController.class)); + } + +} diff --git a/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/ChristmasJoyClient.java b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/ChristmasJoyClient.java new file mode 100644 index 000000000000..0e8cab8a6d5a --- /dev/null +++ b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/ChristmasJoyClient.java @@ -0,0 +1,16 @@ +package com.baeldung.spring.mvc; + +import org.springframework.resilience.annotation.ConcurrencyLimit; +import org.springframework.resilience.annotation.Retryable; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.registry.HttpServiceClient; + +@HttpServiceClient("christmasJoy") +public interface ChristmasJoyClient { + + @GetExchange("/greetings?random") + @Retryable(maxAttempts = 3, delay = 100, multiplier = 2, maxDelay = 1000) + @ConcurrencyLimit(3) + String getRandomGreeting(); + +} diff --git a/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldController.java b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldController.java new file mode 100644 index 000000000000..28b0c1b2d482 --- /dev/null +++ b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldController.java @@ -0,0 +1,22 @@ +package com.baeldung.spring.mvc; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/hello") +public class HelloWorldController { + + @GetMapping(version = "1", produces = MediaType.TEXT_PLAIN_VALUE) + public String sayHelloV1() { + return "Hello World"; + } + + @GetMapping(version = "2", produces = MediaType.TEXT_PLAIN_VALUE) + public String sayHelloV2() { + return "Hi World"; + } + +} diff --git a/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldEvent.java b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldEvent.java new file mode 100644 index 000000000000..3f723af959ff --- /dev/null +++ b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldEvent.java @@ -0,0 +1,5 @@ +package com.baeldung.spring.mvc; + +public record HelloWorldEvent(String message) { + +} diff --git a/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldEventLogger.java b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldEventLogger.java new file mode 100644 index 000000000000..aac9c9601cc5 --- /dev/null +++ b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldEventLogger.java @@ -0,0 +1,19 @@ +package com.baeldung.spring.mvc; + +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class HelloWorldEventLogger { + + @Async + @EventListener + void logHelloWorldEvent(HelloWorldEvent event) { + log.info("Hello World Event: {}", event.message()); + } + +} diff --git a/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldV3Controller.java b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldV3Controller.java new file mode 100644 index 000000000000..7f175fae55bb --- /dev/null +++ b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldV3Controller.java @@ -0,0 +1,17 @@ +package com.baeldung.spring.mvc; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = "/hello", version = "3") +public class HelloWorldV3Controller { + + @GetMapping(produces = MediaType.TEXT_PLAIN_VALUE) + public String sayHello() { + return "Hey World"; + } + +} diff --git a/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldV4Controller.java b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldV4Controller.java new file mode 100644 index 000000000000..2d492527d0bb --- /dev/null +++ b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HelloWorldV4Controller.java @@ -0,0 +1,26 @@ +package com.baeldung.spring.mvc; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping(path = "/hello", version = "4") +@RequiredArgsConstructor +public class HelloWorldV4Controller { + + private final ChristmasJoyClient christmasJoy; + private final ApplicationEventPublisher applicationEventPublisher; + + @GetMapping(produces = MediaType.TEXT_PLAIN_VALUE) + public String sayHello() { + final var result = this.christmasJoy.getRandomGreeting(); + applicationEventPublisher.publishEvent(new HelloWorldEvent(result)); + return result; + } + +} diff --git a/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HttpClientConfig.java b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HttpClientConfig.java new file mode 100644 index 000000000000..4f70285e9152 --- /dev/null +++ b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/HttpClientConfig.java @@ -0,0 +1,36 @@ +package com.baeldung.spring.mvc; + +import java.util.List; + +import org.jspecify.annotations.NonNull; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; +import org.springframework.web.service.registry.AbstractClientHttpServiceRegistrar; + +@Configuration +@Import(HttpClientConfig.HelloWorldClientHttpServiceRegistrar.class) +public class HttpClientConfig { + + static class HelloWorldClientHttpServiceRegistrar extends AbstractClientHttpServiceRegistrar { + + @Override + protected void registerHttpServices(@NonNull GroupRegistry registry, @NonNull AnnotationMetadata metadata) { + findAndRegisterHttpServiceClients(registry, List.of("com.baeldung.spring.mvc")); + } + } + + @Bean + RestClientHttpServiceGroupConfigurer christmasJoyServiceGroupConfigurer(@Value("${application.rest.services.christmasJoy.baseUrl}") String baseUrl) { + return groups -> { + groups.filterByName("christmasJoy") + .forEachClient((group, clientBuilder) -> { + clientBuilder.baseUrl(baseUrl); + }); + }; + } + +} diff --git a/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/TaskDecoratorConfiguration.java b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/TaskDecoratorConfiguration.java new file mode 100644 index 000000000000..7ee3346a6d27 --- /dev/null +++ b/spring-boot-modules/spring-boot-4/src/main/java/com/baeldung/spring/mvc/TaskDecoratorConfiguration.java @@ -0,0 +1,41 @@ +package com.baeldung.spring.mvc; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.core.task.TaskDecorator; + +import lombok.extern.slf4j.Slf4j; + +@Configuration +@Slf4j +public class TaskDecoratorConfiguration { + + @Bean + @Order(2) + TaskDecorator loggingTaskConfigurator() { + return runnable -> () -> { + log.info("Running Task: {}", runnable); + try { + runnable.run(); + } finally { + log.info("Finished Task: {}", runnable); + } + }; + } + + @Bean + @Order(1) + TaskDecorator measuringTaskConfigurator() { + return runnable -> () -> { + final var ts1 = System.currentTimeMillis(); + try { + runnable.run(); + } finally { + final var ts2 = System.currentTimeMillis(); + log.info("Finished within {}ms (Task: {})", ts2 - ts1, runnable); + } + }; + } + +} diff --git a/spring-boot-modules/spring-boot-4/src/main/resources/application.properties b/spring-boot-modules/spring-boot-4/src/main/resources/application.properties new file mode 100644 index 000000000000..007c1e5d5fcf --- /dev/null +++ b/spring-boot-modules/spring-boot-4/src/main/resources/application.properties @@ -0,0 +1 @@ +application.rest.services.christmasJoy.baseUrl=https://christmasjoy.dev/api \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-4/src/test/java/com/baeldung/spring/mvc/HelloWorldApiIntegrationTest.java b/spring-boot-modules/spring-boot-4/src/test/java/com/baeldung/spring/mvc/HelloWorldApiIntegrationTest.java new file mode 100644 index 000000000000..ffbf4cfceb1e --- /dev/null +++ b/spring-boot-modules/spring-boot-4/src/test/java/com/baeldung/spring/mvc/HelloWorldApiIntegrationTest.java @@ -0,0 +1,62 @@ +package com.baeldung.spring.mvc; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class HelloWorldApiIntegrationTest { + + RestTestClient client; + + @BeforeEach + void setUp(WebApplicationContext context) { + client = RestTestClient.bindToApplicationContext(context) + .build(); + } + + @Test + void shouldFetchHelloV1() { + client.get() + .uri("/api/v1/hello") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_PLAIN) + .expectBody(String.class) + .consumeWith(message -> assertThat(message.getResponseBody()).containsIgnoringCase("hello")); + } + + @Test + void shouldFetchHelloV2() { + client.get() + .uri("/api/v2/hello") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_PLAIN) + .expectBody(String.class) + .consumeWith(message -> assertThat(message.getResponseBody()).containsIgnoringCase("hi")); + } + + @Test + void shouldFetchHelloV3() { + client.get() + .uri("/api/v3/hello") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_PLAIN) + .expectBody(String.class) + .consumeWith(message -> assertThat(message.getResponseBody()).containsIgnoringCase("hey")); + } + +} diff --git a/spring-boot-modules/spring-boot-4/src/test/java/com/baeldung/spring/mvc/HelloWorldApiV4IntegrationTest.java b/spring-boot-modules/spring-boot-4/src/test/java/com/baeldung/spring/mvc/HelloWorldApiV4IntegrationTest.java new file mode 100644 index 000000000000..412884fdbda6 --- /dev/null +++ b/spring-boot-modules/spring-boot-4/src/test/java/com/baeldung/spring/mvc/HelloWorldApiV4IntegrationTest.java @@ -0,0 +1,45 @@ +package com.baeldung.spring.mvc; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@MockitoBean(types = ChristmasJoyClient.class) +class HelloWorldApiV4IntegrationTest { + + RestTestClient client; + + @Autowired + ChristmasJoyClient christmasJoy; + + @BeforeEach + void setUp(WebApplicationContext context) { + client = RestTestClient.bindToApplicationContext(context) + .build(); + } + + @Test + void shouldFetchHello() { + Mockito.when(christmasJoy.getRandomGreeting()) + .thenReturn("Joy to the World"); + client.get() + .uri("/api/v4/hello") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_PLAIN) + .expectBody(String.class) + .consumeWith(message -> assertThat(message.getResponseBody()).isEqualTo("Joy to the World")); + } + +}