From 9c509cf5b3e734b0306cd371346879ff9125e3e8 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Mon, 23 Feb 2026 18:11:54 +0100 Subject: [PATCH] Fix nesting support in RouterFunctions descriptions Prior to this commit, descriptions for `RouterFunctions` web routes would not include `nest` predicates and would only consider the last predicate in the chain. This commit ensures that nested predicates are properly chained adn considered in the resulting description that is displayed in Actuator endpoints. Fixes gh-49289 --- ...herHandlersMappingDescriptionProvider.java | 14 +- ...herServletsMappingDescriptionProvider.java | 14 +- ...ndlersMappingDescriptionProviderTests.java | 89 ++++++++++++ ...rvletsMappingDescriptionProviderTests.java | 136 ++++++++++++++++++ 4 files changed, 249 insertions(+), 4 deletions(-) diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/web/mappings/reactive/DispatcherHandlersMappingDescriptionProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/web/mappings/reactive/DispatcherHandlersMappingDescriptionProvider.java index 24ef4f2a382..02be752baa3 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/web/mappings/reactive/DispatcherHandlersMappingDescriptionProvider.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/web/mappings/reactive/DispatcherHandlersMappingDescriptionProvider.java @@ -16,8 +16,10 @@ package org.springframework.boot.actuate.web.mappings.reactive; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; +import java.util.Deque; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -167,20 +169,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/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/web/mappings/servlet/DispatcherServletsMappingDescriptionProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/web/mappings/servlet/DispatcherServletsMappingDescriptionProvider.java index cc96b8e0f71..e9613bd70ea 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/web/mappings/servlet/DispatcherServletsMappingDescriptionProvider.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/web/mappings/servlet/DispatcherServletsMappingDescriptionProvider.java @@ -16,8 +16,10 @@ package org.springframework.boot.actuate.web.mappings.servlet; +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; @@ -235,20 +237,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/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/web/mappings/reactive/DispatcherHandlersMappingDescriptionProviderTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/web/mappings/reactive/DispatcherHandlersMappingDescriptionProviderTests.java index 45fd3fecd41..5e626ba4cd5 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/web/mappings/reactive/DispatcherHandlersMappingDescriptionProviderTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/web/mappings/reactive/DispatcherHandlersMappingDescriptionProviderTests.java @@ -16,19 +16,38 @@ package org.springframework.boot.actuate.web.mappings.reactive; +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.actuate.web.mappings.reactive.DispatcherHandlersMappingDescriptionProvider.DispatcherHandlersMappingDescriptionProviderRuntimeHints; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +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 { @@ -43,4 +62,74 @@ class DispatcherHandlersMappingDescriptionProviderTests { .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/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/web/mappings/servlet/DispatcherServletsMappingDescriptionProviderTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/web/mappings/servlet/DispatcherServletsMappingDescriptionProviderTests.java index 6270080a43c..3b794548276 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/web/mappings/servlet/DispatcherServletsMappingDescriptionProviderTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/web/mappings/servlet/DispatcherServletsMappingDescriptionProviderTests.java @@ -16,19 +16,51 @@ package org.springframework.boot.actuate.web.mappings.servlet; +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.actuate.web.mappings.servlet.DispatcherServletsMappingDescriptionProvider.DispatcherServletsMappingDescriptionProviderRuntimeHints; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebApplicationContext; +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 { @@ -43,4 +75,108 @@ class DispatcherServletsMappingDescriptionProviderTests { .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(); + } + + } + }