diff --git a/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/actuate/web/mappings/DispatcherHandlersMappingDescriptionProvider.java b/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/actuate/web/mappings/DispatcherHandlersMappingDescriptionProvider.java index ceb5a9c3188..d3c69d02ac2 100644 --- a/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/actuate/web/mappings/DispatcherHandlersMappingDescriptionProvider.java +++ b/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/actuate/web/mappings/DispatcherHandlersMappingDescriptionProvider.java @@ -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 descriptions = new ArrayList<>(); + private Deque 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 diff --git a/module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/actuate/web/mappings/DispatcherHandlersMappingDescriptionProviderTests.java b/module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/actuate/web/mappings/DispatcherHandlersMappingDescriptionProviderTests.java index 381124f08e6..a84aa3f5b5c 100644 --- a/module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/actuate/web/mappings/DispatcherHandlersMappingDescriptionProviderTests.java +++ b/module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/actuate/web/mappings/DispatcherHandlersMappingDescriptionProviderTests.java @@ -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> describedMappings = new DispatcherHandlersMappingDescriptionProvider() + .describeMappings(context); + assertThat(describedMappings).hasSize(1).containsOnlyKeys("webHandler"); + List 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> describedMappings = new DispatcherHandlersMappingDescriptionProvider() + .describeMappings(context); + assertThat(describedMappings).hasSize(1).containsOnlyKeys("webHandler"); + List 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 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(); + } + + } + } diff --git a/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/actuate/web/mappings/DispatcherServletsMappingDescriptionProvider.java b/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/actuate/web/mappings/DispatcherServletsMappingDescriptionProvider.java index 2958d00980c..fd2d1f9d70b 100644 --- a/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/actuate/web/mappings/DispatcherServletsMappingDescriptionProvider.java +++ b/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/actuate/web/mappings/DispatcherServletsMappingDescriptionProvider.java @@ -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 descriptions = new ArrayList<>(); + private Deque 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 diff --git a/module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/actuate/web/mappings/DispatcherServletsMappingDescriptionProviderTests.java b/module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/actuate/web/mappings/DispatcherServletsMappingDescriptionProviderTests.java index 1cdfd227301..3debcb1ee0f 100644 --- a/module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/actuate/web/mappings/DispatcherServletsMappingDescriptionProviderTests.java +++ b/module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/actuate/web/mappings/DispatcherServletsMappingDescriptionProviderTests.java @@ -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 contextSupplier = prepareContextSupplier(); + new WebApplicationContextRunner(contextSupplier).withUserConfiguration(ControllerWebConfiguration.class) + .run((context) -> { + + Map> describedMappings = new DispatcherServletsMappingDescriptionProvider() + .describeMappings(context); + assertThat(describedMappings).hasSize(1).containsOnlyKeys("dispatcherServlet"); + List 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 contextSupplier = prepareContextSupplier(); + new WebApplicationContextRunner(contextSupplier).withUserConfiguration(RouterConfiguration.class) + .run((context) -> { + + Map> describedMappings = new DispatcherServletsMappingDescriptionProvider() + .describeMappings(context); + assertThat(describedMappings).hasSize(1).containsOnlyKeys("dispatcherServlet"); + List 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 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) servletContext.getFilterRegistrations()) + .willReturn(Collections.singletonMap("testFilter", filterRegistration)); + ServletRegistration servletRegistration = mock(ServletRegistration.class); + given((Map) 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 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(); + } + + } + }