Merge branch '3.5.x' into 4.0.x

Closes gh-49302
This commit is contained in:
Brian Clozel
2026-02-23 18:34:48 +01:00
4 changed files with 249 additions and 4 deletions
@@ -16,9 +16,11 @@
package org.springframework.boot.webflux.actuate.web.mappings;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -173,20 +175,28 @@ public class DispatcherHandlersMappingDescriptionProvider implements MappingDesc
private final List<DispatcherHandlerMappingDescription> descriptions = new ArrayList<>();
private Deque<RequestPredicate> predicates = new ArrayDeque<>();
@Override
public void startNested(RequestPredicate predicate) {
this.predicates.addLast(predicate);
}
@Override
public void endNested(RequestPredicate predicate) {
this.predicates.removeLast();
}
@Override
public void route(RequestPredicate predicate, HandlerFunction<?> handlerFunction) {
DispatcherHandlerMappingDetails details = new DispatcherHandlerMappingDetails();
details.setHandlerFunction(new HandlerFunctionDescription(handlerFunction));
this.descriptions.add(
new DispatcherHandlerMappingDescription(predicate.toString(), handlerFunction.toString(), details));
RequestPredicate reduced = this.predicates.stream()
.reduce(RequestPredicate::and)
.map((all) -> all.and(predicate))
.orElse(predicate);
this.descriptions
.add(new DispatcherHandlerMappingDescription(reduced.toString(), handlerFunction.toString(), details));
}
@Override
@@ -16,19 +16,38 @@
package org.springframework.boot.webflux.actuate.web.mappings;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
import org.springframework.boot.webflux.actuate.web.mappings.DispatcherHandlersMappingDescriptionProvider.DispatcherHandlersMappingDescriptionProviderRuntimeHints;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.reactive.config.EnableWebFlux;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
import static org.springframework.web.reactive.function.server.RequestPredicates.path;
/**
* Tests for {@link DispatcherHandlersMappingDescriptionProvider}.
*
* @author Moritz Halbritter
* @author Brian Clozel
*/
class DispatcherHandlersMappingDescriptionProviderTests {
@@ -42,4 +61,74 @@ class DispatcherHandlersMappingDescriptionProviderTests {
.withMemberCategories(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)).accepts(runtimeHints);
}
@Test
void shouldDescribeAnnotatedControllers() {
new ReactiveWebApplicationContextRunner().withUserConfiguration(ControllerWebConfiguration.class)
.run((context) -> {
Map<String, List<DispatcherHandlerMappingDescription>> describedMappings = new DispatcherHandlersMappingDescriptionProvider()
.describeMappings(context);
assertThat(describedMappings).hasSize(1).containsOnlyKeys("webHandler");
List<DispatcherHandlerMappingDescription> descriptions = describedMappings.get("webHandler");
assertThat(descriptions).hasSize(2)
.extracting("predicate")
.containsExactlyInAnyOrder("{POST /api/projects, consumes [application/json]}",
"{GET /api/projects/{id}, produces [application/json]}");
});
}
@Test
void shouldDescribeRouterFunctions() {
new ReactiveWebApplicationContextRunner().withUserConfiguration(RouterConfiguration.class).run((context) -> {
Map<String, List<DispatcherHandlerMappingDescription>> describedMappings = new DispatcherHandlersMappingDescriptionProvider()
.describeMappings(context);
assertThat(describedMappings).hasSize(1).containsOnlyKeys("webHandler");
List<DispatcherHandlerMappingDescription> descriptions = describedMappings.get("webHandler");
assertThat(descriptions).hasSize(2)
.extracting("predicate")
.containsExactlyInAnyOrder("(/api && (POST && (/projects/ && Content-Type: application/json)))",
"(/api && (GET && (/projects//{id} && Accept: application/json)))");
});
}
@SuppressWarnings("unchecked")
@Configuration(proxyBeanMethods = false)
@EnableWebFlux
static class ControllerWebConfiguration {
@Controller
@RequestMapping("/api")
static class SampleController {
@PostMapping(path = "/projects", consumes = MediaType.APPLICATION_JSON_VALUE)
void createProject() {
}
@GetMapping(path = "/projects/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
void findProject() {
}
}
}
@Configuration(proxyBeanMethods = false)
@EnableWebFlux
static class RouterConfiguration {
@Bean
RouterFunction<ServerResponse> routerFunctions() {
return RouterFunctions.route()
.nest(path("/api"),
(builder) -> builder
.POST(path("/projects/").and(contentType(MediaType.APPLICATION_JSON)),
(request) -> ServerResponse.ok().build())
.GET(path("/projects//{id}").and(accept(MediaType.APPLICATION_JSON)),
(request) -> ServerResponse.ok().build()))
.build();
}
}
}
@@ -16,8 +16,10 @@
package org.springframework.boot.webmvc.actuate.web.mappings;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
@@ -236,20 +238,28 @@ public class DispatcherServletsMappingDescriptionProvider implements MappingDesc
private final List<DispatcherServletMappingDescription> descriptions = new ArrayList<>();
private Deque<RequestPredicate> predicates = new ArrayDeque<>();
@Override
public void startNested(RequestPredicate predicate) {
this.predicates.addLast(predicate);
}
@Override
public void endNested(RequestPredicate predicate) {
this.predicates.removeLast();
}
@Override
public void route(RequestPredicate predicate, HandlerFunction<?> handlerFunction) {
DispatcherServletMappingDetails details = new DispatcherServletMappingDetails();
details.setHandlerFunction(new HandlerFunctionDescription(handlerFunction));
this.descriptions.add(
new DispatcherServletMappingDescription(predicate.toString(), handlerFunction.toString(), details));
RequestPredicate reduced = this.predicates.stream()
.reduce(RequestPredicate::and)
.map((all) -> all.and(predicate))
.orElse(predicate);
this.descriptions
.add(new DispatcherServletMappingDescription(reduced.toString(), handlerFunction.toString(), details));
}
@Override
@@ -16,19 +16,51 @@
package org.springframework.boot.webmvc.actuate.web.mappings;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import jakarta.servlet.FilterRegistration;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRegistration;
import org.junit.jupiter.api.Test;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.boot.web.context.servlet.AnnotationConfigServletWebApplicationContext;
import org.springframework.boot.webmvc.actuate.web.mappings.DispatcherServletsMappingDescriptionProvider.DispatcherServletsMappingDescriptionProviderRuntimeHints;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockServletConfig;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.ConfigurableWebApplicationContext;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.springframework.web.servlet.function.RequestPredicates.accept;
import static org.springframework.web.servlet.function.RequestPredicates.contentType;
import static org.springframework.web.servlet.function.RequestPredicates.path;
/**
* Tests for {@link DispatcherServletsMappingDescriptionProvider}.
*
* @author Moritz Halbritter
* @author Brian Clozel
*/
class DispatcherServletsMappingDescriptionProviderTests {
@@ -42,4 +74,108 @@ class DispatcherServletsMappingDescriptionProviderTests {
.withMemberCategories(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)).accepts(runtimeHints);
}
@Test
void shouldDescribeAnnotatedControllers() {
Supplier<ConfigurableWebApplicationContext> contextSupplier = prepareContextSupplier();
new WebApplicationContextRunner(contextSupplier).withUserConfiguration(ControllerWebConfiguration.class)
.run((context) -> {
Map<String, List<DispatcherServletMappingDescription>> describedMappings = new DispatcherServletsMappingDescriptionProvider()
.describeMappings(context);
assertThat(describedMappings).hasSize(1).containsOnlyKeys("dispatcherServlet");
List<DispatcherServletMappingDescription> descriptions = describedMappings.get("dispatcherServlet");
assertThat(descriptions).hasSize(2)
.extracting("predicate")
.containsExactlyInAnyOrder("{POST [/api/projects], consumes [application/json]}",
"{GET [/api/projects/{id}], produces [application/json]}");
});
}
@Test
void shouldDescribeRouterFunctions() {
Supplier<ConfigurableWebApplicationContext> contextSupplier = prepareContextSupplier();
new WebApplicationContextRunner(contextSupplier).withUserConfiguration(RouterConfiguration.class)
.run((context) -> {
Map<String, List<DispatcherServletMappingDescription>> describedMappings = new DispatcherServletsMappingDescriptionProvider()
.describeMappings(context);
assertThat(describedMappings).hasSize(1).containsOnlyKeys("dispatcherServlet");
List<DispatcherServletMappingDescription> descriptions = describedMappings.get("dispatcherServlet");
assertThat(descriptions).hasSize(2)
.extracting("predicate")
.containsExactlyInAnyOrder("(/api && (POST && (/projects/ && Content-Type: application/json)))",
"(/api && (GET && (/projects//{id} && Accept: application/json)))");
});
}
@SuppressWarnings("unchecked")
private Supplier<ConfigurableWebApplicationContext> prepareContextSupplier() {
ServletContext servletContext = mock(ServletContext.class);
given(servletContext.getInitParameterNames()).willReturn(Collections.emptyEnumeration());
given(servletContext.getAttributeNames()).willReturn(Collections.emptyEnumeration());
FilterRegistration filterRegistration = mock(FilterRegistration.class);
given((Map<String, FilterRegistration>) servletContext.getFilterRegistrations())
.willReturn(Collections.singletonMap("testFilter", filterRegistration));
ServletRegistration servletRegistration = mock(ServletRegistration.class);
given((Map<String, ServletRegistration>) servletContext.getServletRegistrations())
.willReturn(Collections.singletonMap("testServlet", servletRegistration));
return () -> {
AnnotationConfigServletWebApplicationContext context = new AnnotationConfigServletWebApplicationContext();
context.setServletContext(servletContext);
return context;
};
}
@Configuration(proxyBeanMethods = false)
@EnableWebMvc
static class ControllerWebConfiguration {
@Bean
DispatcherServlet dispatcherServlet(WebApplicationContext context) throws ServletException {
DispatcherServlet dispatcherServlet = new DispatcherServlet(context);
dispatcherServlet.init(new MockServletConfig());
return dispatcherServlet;
}
@Controller
@RequestMapping("/api")
static class SampleController {
@PostMapping(path = "/projects", consumes = MediaType.APPLICATION_JSON_VALUE)
void createProject() {
}
@GetMapping(path = "/projects/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
void findProject() {
}
}
}
@Configuration(proxyBeanMethods = false)
@EnableWebMvc
static class RouterConfiguration {
@Bean
DispatcherServlet dispatcherServlet(WebApplicationContext context) throws ServletException {
DispatcherServlet dispatcherServlet = new DispatcherServlet(context);
dispatcherServlet.init(new MockServletConfig());
return dispatcherServlet;
}
@Bean
RouterFunction<ServerResponse> routerFunctions() {
return RouterFunctions.route()
.nest(path("/api"),
(builder) -> builder
.POST(path("/projects/").and(contentType(MediaType.APPLICATION_JSON)),
(request) -> ServerResponse.ok().build())
.GET(path("/projects//{id}").and(accept(MediaType.APPLICATION_JSON)),
(request) -> ServerResponse.ok().build()))
.build();
}
}
}