diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClient.java index 82ad8587097..9a206edc2f0 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,6 +35,7 @@ import org.springframework.test.web.servlet.ResultHandler; import org.springframework.test.web.servlet.ResultMatcher; import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; import org.springframework.test.web.servlet.setup.MockMvcConfigurer; +import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; import org.springframework.validation.Validator; import org.springframework.web.accept.ContentNegotiationManager; @@ -47,6 +48,7 @@ import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.LocaleResolver; import org.springframework.web.servlet.View; import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import org.springframework.web.util.pattern.PathPatternParser; @@ -81,13 +83,25 @@ public interface MockMvcWebTestClient { * Begin creating a {@link WebTestClient} by providing the {@code @Controller} * instance(s) to handle requests with. *

Internally this is delegated to and equivalent to using - * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#standaloneSetup(Object...)}. + * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#standaloneSetup(Object...)} * to initialize {@link MockMvc}. */ static ControllerSpec bindToController(Object... controllers) { return new StandaloneMockMvcSpec(controllers); } + /** + * Begin creating a {@link WebTestClient} by providing the {@link RouterFunction} + * instance(s) to handle requests with. + *

Internally this is delegated to and equivalent to using + * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#routerFunctions(RouterFunction[])} + * to initialize {@link MockMvc}. + * @since 6.2 + */ + static RouterFunctionSpec bindToRouterFunction(RouterFunction... routerFunctions) { + return new RouterFunctionMockMvcSpec(routerFunctions); + } + /** * Begin creating a {@link WebTestClient} by providing a * {@link WebApplicationContext} with Spring MVC infrastructure and @@ -381,4 +395,72 @@ public interface MockMvcWebTestClient { ControllerSpec customHandlerMapping(Supplier factory); } + + /** + * Specification for configuring {@link MockMvc} to test one or more + * {@linkplain RouterFunction router functions} + * directly, and a simple facade around {@link RouterFunctionMockMvcBuilder}. + * @since 6.2 + */ + interface RouterFunctionSpec extends MockMvcServerSpec { + + /** + * Set the message converters to use. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#setMessageConverters(HttpMessageConverter[])}. + */ + RouterFunctionSpec messageConverters(HttpMessageConverter... messageConverters); + + /** + * Add global interceptors. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#addInterceptors(HandlerInterceptor...)}. + */ + RouterFunctionSpec interceptors(HandlerInterceptor... interceptors); + + /** + * Add interceptors for specific patterns. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#addMappedInterceptors(String[], HandlerInterceptor...)}. + */ + RouterFunctionSpec mappedInterceptors( + @Nullable String[] pathPatterns, HandlerInterceptor... interceptors); + + /** + * Specify the timeout value for async execution. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#setAsyncRequestTimeout(long)}. + */ + RouterFunctionSpec asyncRequestTimeout(long timeout); + + /** + * Set the HandlerExceptionResolver types to use. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#setHandlerExceptionResolvers(HandlerExceptionResolver...)}. + */ + RouterFunctionSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers); + + /** + * Set up view resolution. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#setViewResolvers(ViewResolver...)}. + */ + RouterFunctionSpec viewResolvers(ViewResolver... resolvers); + + /** + * Set up a single {@link ViewResolver} with a fixed view. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#setSingleView(View)}. + */ + RouterFunctionSpec singleView(View view); + + /** + * Enable URL path matching with parsed + * {@link org.springframework.web.util.pattern.PathPattern PathPatterns}. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#setPatternParser(PathPatternParser)}. + */ + RouterFunctionSpec patternParser(PathPatternParser parser); + } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RouterFunctionMockMvcSpec.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RouterFunctionMockMvcSpec.java new file mode 100644 index 00000000000..5ff3e1dfc2b --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RouterFunctionMockMvcSpec.java @@ -0,0 +1,100 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.web.servlet.client; + +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * Simple wrapper around a {@link RouterFunctionMockMvcBuilder} that implements + * {@link MockMvcWebTestClient.RouterFunctionSpec}. + * + * @author Arjen Poutsma + * @since 6.2 + */ +class RouterFunctionMockMvcSpec extends AbstractMockMvcServerSpec + implements MockMvcWebTestClient.RouterFunctionSpec { + + private final RouterFunctionMockMvcBuilder mockMvcBuilder; + + + RouterFunctionMockMvcSpec(RouterFunction... routerFunctions) { + this.mockMvcBuilder = MockMvcBuilders.routerFunctions(routerFunctions); + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec messageConverters(HttpMessageConverter... messageConverters) { + this.mockMvcBuilder.setMessageConverters(messageConverters); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec interceptors(HandlerInterceptor... interceptors) { + mappedInterceptors(null, interceptors); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec mappedInterceptors(@Nullable String[] pathPatterns, HandlerInterceptor... interceptors) { + this.mockMvcBuilder.addMappedInterceptors(pathPatterns, interceptors); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec asyncRequestTimeout(long timeout) { + this.mockMvcBuilder.setAsyncRequestTimeout(timeout); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers) { + this.mockMvcBuilder.setHandlerExceptionResolvers(exceptionResolvers); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec viewResolvers(ViewResolver... resolvers) { + this.mockMvcBuilder.setViewResolvers(resolvers); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec singleView(View view) { + this.mockMvcBuilder.setSingleView(view); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec patternParser(PathPatternParser parser) { + this.mockMvcBuilder.setPatternParser(parser); + return this; + } + + @Override + protected ConfigurableMockMvcBuilder getMockMvcBuilder() { + return this.mockMvcBuilder; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcBuilders.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcBuilders.java index 71b523d7971..09520cee3ef 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcBuilders.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcBuilders.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ package org.springframework.test.web.servlet.setup; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvcBuilder; import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.function.RouterFunction; /** * The main class to import in order to access all available {@link MockMvcBuilder MockMvcBuilders}. @@ -76,4 +77,23 @@ public final class MockMvcBuilders { return new StandaloneMockMvcBuilder(controllers); } + /** + * Build a {@link MockMvc} instance by registering one or more + * {@link RouterFunction RouterFunction} instances and configuring Spring + * MVC infrastructure programmatically. + *

This allows full control over the instantiation and initialization of + * router functions and their dependencies, similar to plain unit tests while + * also making it possible to test one router function at a time. + *

When this builder is used, the minimum infrastructure required by the + * {@link org.springframework.web.servlet.DispatcherServlet DispatcherServlet} + * to serve requests with router functions is created automatically + * and can be customized, resulting in configuration that is equivalent to + * what MVC Java configuration provides except using builder-style methods. + * @param routerFunctions one or more {@code RouterFunction} instances to test + * @since 6.2 + */ + public static RouterFunctionMockMvcBuilder routerFunctions(RouterFunction... routerFunctions) { + return new RouterFunctionMockMvcBuilder(routerFunctions); + } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/RouterFunctionMockMvcBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/RouterFunctionMockMvcBuilder.java new file mode 100644 index 00000000000..6d9b507ce69 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/RouterFunctionMockMvcBuilder.java @@ -0,0 +1,321 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.web.servlet.setup; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +import jakarta.servlet.ServletContext; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.mock.web.MockServletContext; +import org.springframework.util.Assert; +import org.springframework.web.accept.ContentNegotiationManager; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.context.support.WebApplicationObjectSupport; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer; +import org.springframework.web.servlet.config.annotation.InterceptorRegistration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.support.HandlerFunctionAdapter; +import org.springframework.web.servlet.function.support.RouterFunctionMapping; +import org.springframework.web.servlet.handler.MappedInterceptor; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.servlet.resource.ResourceUrlProvider; +import org.springframework.web.servlet.view.InternalResourceViewResolver; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * A {@code MockMvcBuilder} that accepts {@link RouterFunction} registrations + * thus allowing full control over the instantiation and initialization of + * router functions and their dependencies similar to plain unit tests, and also + * making it possible to test one function at a time. + * + *

This builder creates the minimum infrastructure required by the + * {@link DispatcherServlet} to serve requests with router functions and + * also provides methods for customization. The resulting configuration and + * customization options are equivalent to using MVC Java config except + * using builder style methods. + * + *

To configure view resolution, either select a "fixed" view to use for every + * request performed (see {@link #setSingleView(View)}) or provide a list of + * {@code ViewResolver}s (see {@link #setViewResolvers(ViewResolver...)}). + * + * @author Arjen Poutsma + * @since 6.2 + */ +public class RouterFunctionMockMvcBuilder extends AbstractMockMvcBuilder { + + private final RouterFunction routerFunction; + + private List> messageConverters = new ArrayList<>(); + + private final List mappedInterceptors = new ArrayList<>(); + + @Nullable + private List handlerExceptionResolvers; + + @Nullable + private Long asyncRequestTimeout; + + @Nullable + private List viewResolvers; + + @Nullable + private PathPatternParser patternParser; + + + private Supplier handlerMappingFactory = RouterFunctionMapping::new; + + + protected RouterFunctionMockMvcBuilder(RouterFunction... routerFunctions) { + Assert.notEmpty(routerFunctions, "RouterFunctions must not be empty"); + + this.routerFunction = Arrays.stream(routerFunctions).reduce(RouterFunction::andOther).orElseThrow(); + } + + + /** + * Set the message converters to use in argument resolvers and in return value + * handlers, which support reading and/or writing to the body of the request + * and response. If no message converters are added to the list, a default + * list of converters is added instead. + */ + public RouterFunctionMockMvcBuilder setMessageConverters(HttpMessageConverter...messageConverters) { + this.messageConverters = Arrays.asList(messageConverters); + return this; + } + + /** + * Add interceptors mapped to all incoming requests. + */ + public RouterFunctionMockMvcBuilder addInterceptors(HandlerInterceptor... interceptors) { + addMappedInterceptors(null, interceptors); + return this; + } + + /** + * Add interceptors mapped to a set of path patterns. + */ + public RouterFunctionMockMvcBuilder addMappedInterceptors(@Nullable String[] pathPatterns, + HandlerInterceptor... interceptors) { + + for (HandlerInterceptor interceptor : interceptors) { + this.mappedInterceptors.add(new MappedInterceptor(pathPatterns, null, interceptor)); + } + return this; + } + + /** + * Set the HandlerExceptionResolver types to use as a list. + */ + public RouterFunctionMockMvcBuilder setHandlerExceptionResolvers(List exceptionResolvers) { + this.handlerExceptionResolvers = exceptionResolvers; + return this; + } + + /** + * Set the HandlerExceptionResolver types to use as an array. + */ + public RouterFunctionMockMvcBuilder setHandlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers) { + this.handlerExceptionResolvers = Arrays.asList(exceptionResolvers); + return this; + } + + /** + * Configure factory to create a custom {@link RequestMappingHandlerMapping}. + * @param factory the factory + * @since 5.0 + */ + public RouterFunctionMockMvcBuilder setCustomHandlerMapping(Supplier factory) { + this.handlerMappingFactory = factory; + return this; + } + + /** + * Set up view resolution with the given {@link ViewResolver ViewResolvers}. + * If not set, an {@link InternalResourceViewResolver} is used by default. + */ + public RouterFunctionMockMvcBuilder setViewResolvers(ViewResolver...resolvers) { + this.viewResolvers = Arrays.asList(resolvers); + return this; + } + + /** + * Sets up a single {@link ViewResolver} that always returns the provided + * view instance. This is a convenient shortcut if you need to use one + * View instance only -- e.g. rendering generated content (JSON, XML, Atom). + */ + public RouterFunctionMockMvcBuilder setSingleView(View view) { + this.viewResolvers = Collections.singletonList(new StaticViewResolver(view)); + return this; + } + + /** + * Specify the timeout value for async execution. In Spring MVC Test, this + * value is used to determine how to long to wait for async execution to + * complete so that a test can verify the results synchronously. + * @param timeout the timeout value in milliseconds + */ + public RouterFunctionMockMvcBuilder setAsyncRequestTimeout(long timeout) { + this.asyncRequestTimeout = timeout; + return this; + } + + /** + * Enable URL path matching with parsed + * {@link org.springframework.web.util.pattern.PathPattern PathPatterns} + * instead of String pattern matching with a {@link org.springframework.util.PathMatcher}. + * @param parser the parser to use + * @since 5.3 + */ + public RouterFunctionMockMvcBuilder setPatternParser(@Nullable PathPatternParser parser) { + this.patternParser = parser; + return this; + } + + + @Override + protected WebApplicationContext initWebAppContext() { + MockServletContext servletContext = new MockServletContext(); + StubWebApplicationContext wac = new StubWebApplicationContext(servletContext); + registerRouterFunction(wac); + servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, wac); + return wac; + } + + private void registerRouterFunction(StubWebApplicationContext wac) { + HandlerFunctionConfiguration config = new HandlerFunctionConfiguration(); + config.setApplicationContext(wac); + ServletContext sc = wac.getServletContext(); + + wac.addBean("routerFunction", this.routerFunction); + + FormattingConversionService mvcConversionService = config.mvcConversionService(); + wac.addBean("mvcConversionService", mvcConversionService); + ResourceUrlProvider resourceUrlProvider = config.mvcResourceUrlProvider(); + wac.addBean("mvcResourceUrlProvider", resourceUrlProvider); + ContentNegotiationManager mvcContentNegotiationManager = config.mvcContentNegotiationManager(); + wac.addBean("mvcContentNegotiationManager", mvcContentNegotiationManager); + + RouterFunctionMapping hm = config.routerFunctionMapping(mvcConversionService, resourceUrlProvider); + if (sc != null) { + hm.setServletContext(sc); + } + hm.setApplicationContext(wac); + hm.afterPropertiesSet(); + wac.addBean("routerFunctionMapping", hm); + + HandlerFunctionAdapter ha = config.handlerFunctionAdapter(); + wac.addBean("handlerFunctionAdapter", ha); + + wac.addBean("handlerExceptionResolver", config.handlerExceptionResolver(mvcContentNegotiationManager)); + + wac.addBeans(initViewResolvers(wac)); + } + + private List initViewResolvers(WebApplicationContext wac) { + this.viewResolvers = (this.viewResolvers != null ? this.viewResolvers : + Collections.singletonList(new InternalResourceViewResolver())); + for (Object viewResolver : this.viewResolvers) { + if (viewResolver instanceof WebApplicationObjectSupport support) { + support.setApplicationContext(wac); + } + } + return this.viewResolvers; + } + + + /** Using the MVC Java configuration as the starting point for the "standalone" setup. */ + private class HandlerFunctionConfiguration extends WebMvcConfigurationSupport { + + public RouterFunctionMapping getHandlerMapping( + FormattingConversionService mvcConversionService, + ResourceUrlProvider mvcResourceUrlProvider) { + + RouterFunctionMapping handlerMapping = handlerMappingFactory.get(); + handlerMapping.setOrder(0); + handlerMapping.setInterceptors(getInterceptors(mvcConversionService, mvcResourceUrlProvider)); + handlerMapping.setMessageConverters(getMessageConverters()); + if (patternParser != null) { + handlerMapping.setPatternParser(patternParser); + } + return handlerMapping; + } + + @Override + protected void configureMessageConverters(List> converters) { + converters.addAll(messageConverters); + } + + @Override + protected void addInterceptors(InterceptorRegistry registry) { + for (MappedInterceptor interceptor : mappedInterceptors) { + InterceptorRegistration registration = registry.addInterceptor(interceptor.getInterceptor()); + if (interceptor.getIncludePathPatterns() != null) { + registration.addPathPatterns(interceptor.getIncludePathPatterns()); + } + } + } + + @Override + public void configureAsyncSupport(AsyncSupportConfigurer configurer) { + if (asyncRequestTimeout != null) { + configurer.setDefaultTimeout(asyncRequestTimeout); + } + } + + @Override + protected void configureHandlerExceptionResolvers(List exceptionResolvers) { + if (handlerExceptionResolvers == null) { + return; + } + for (HandlerExceptionResolver resolver : handlerExceptionResolvers) { + if (resolver instanceof ApplicationContextAware applicationContextAware) { + ApplicationContext applicationContext = getApplicationContext(); + if (applicationContext != null) { + applicationContextAware.setApplicationContext(applicationContext); + } + } + if (resolver instanceof InitializingBean initializingBean) { + try { + initializingBean.afterPropertiesSet(); + } + catch (Exception ex) { + throw new IllegalStateException("Failure from afterPropertiesSet", ex); + } + } + exceptionResolvers.add(resolver); + } + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilder.java index b96b89cb1d8..992eb4c7fd0 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilder.java @@ -21,7 +21,6 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.function.Supplier; @@ -596,23 +595,4 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder body.jsonPath("$.name").isEqualTo("Lee") + .jsonPath("$.age").isEqualTo(42) + .jsonPath("$.age").value(equalTo(42)) + .jsonPath("$.age").value(Float.class, equalTo(42.0f))); + } + + @Test + public void queryParameter() { + execute("/search?name=George", body -> body.jsonPath("$.name").isEqualTo("George")); + } + + + @Nested + class AsyncTests { + + @Test + void completableFuture() { + execute("/async/completableFuture", body -> body.json("{\"name\":\"Joe\",\"age\":0}")); + } + + @Test + void publisher() { + execute("/async/publisher", body -> body.json("{\"name\":\"Joe\",\"age\":0}")); + } + + } + + + private void execute(String uri, Consumer assertions) { + RouterFunction testRoute = testRoute(); + assertions.accept(MockMvcWebTestClient.bindToRouterFunction(testRoute).build() + .get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody()); + } + + private static RouterFunction testRoute() { + return route() + .GET("/person/{name}", request -> { + Person person = new Person(request.pathVariable("name")); + person.setAge(42); + return ok().body(person); + }) + .GET("/search", request -> { + String name = request.param("name").orElseThrow(NullPointerException::new); + Person person = new Person(name); + return ok().body(person); + }) + .path("/async", b -> b + .GET("/completableFuture", request -> { + CompletableFuture future = new CompletableFuture<>(); + future.complete(new Person("Joe")); + return ok().body(future); + }) + .GET("/publisher", request -> { + Mono mono = Mono.just(new Person("Joe")); + return ok().body(mono); + }) + ) + .route(RequestPredicates.all(), request -> ServerResponse.notFound().build()) + .build(); + } + + @SuppressWarnings("unused") + private static class Person { + + private final String name; + + private int age; + + public Person(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + public int getAge() { + return this.age; + } + + public void setAge(int age) { + this.age = age; + } + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/RouterFunctionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/RouterFunctionTests.java new file mode 100644 index 00000000000..c653872c5a0 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/RouterFunctionTests.java @@ -0,0 +1,167 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.web.servlet.samples.standalone; + +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.servlet.function.RequestPredicates; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.routerFunctions; +import static org.springframework.web.servlet.function.RouterFunctions.route; +import static org.springframework.web.servlet.function.ServerResponse.ok; + +/** + * @author Arjen Poutsma + */ +public class RouterFunctionTests { + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + RouterFunction testRoute = testRoute(); + this.mockMvc = routerFunctions(testRoute).defaultResponseCharacterEncoding(UTF_8).build(); + } + + @Test + void json() throws Exception { + this.mockMvc + // We use a name containing an umlaut to test UTF-8 encoding for the request and the response. + .perform(get("/person/Jürgen").characterEncoding(UTF_8).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/json")) + .andExpect(content().encoding(UTF_8)) + .andExpect(content().string(containsString("Jürgen"))) + .andExpect(jsonPath("$.name").value("Jürgen")) + .andExpect(jsonPath("$.age").value(42)) + .andExpect(jsonPath("$.age").value(42.0f)) + .andExpect(jsonPath("$.age").value(equalTo(42))) + .andExpect(jsonPath("$.age").value(equalTo(42.0f), Float.class)) + .andExpect(jsonPath("$.age", equalTo(42))) + .andExpect(jsonPath("$.age", equalTo(42.0f), Float.class)); + } + + @Test + public void queryParameter() throws Exception { + this.mockMvc + .perform(get("/search?name=George").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/json")) + .andExpect(jsonPath("$.name").value("George")); + } + + @Nested + class AsyncTests { + + @Test + void completableFuture() throws Exception { + MvcResult mvcResult = mockMvc.perform(get("/async/completableFuture")) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string("{\"name\":\"Joe\",\"age\":0}")); + } + + @Test + void publisher() throws Exception { + MvcResult mvcResult = mockMvc.perform(get("/async/publisher")) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string("{\"name\":\"Joe\",\"age\":0}")); + + } + + } + + + private static RouterFunction testRoute() { + return route() + .GET("/person/{name}", request -> { + Person person = new Person(request.pathVariable("name")); + person.setAge(42); + return ok().body(person); + }) + .GET("/search", request -> { + String name = request.param("name").orElseThrow(NullPointerException::new); + Person person = new Person(name); + return ok().body(person); + }) + .path("/async", b -> b + .GET("/completableFuture", request -> { + CompletableFuture future = new CompletableFuture<>(); + future.complete(new Person("Joe")); + return ok().body(future); + }) + .GET("/publisher", request -> { + Mono mono = Mono.just(new Person("Joe")); + return ok().body(mono); + }) + ) + .route(RequestPredicates.all(), request -> ServerResponse.notFound().build()) + .build(); + } + + @SuppressWarnings("unused") + private static class Person { + + private final String name; + + private int age; + + public Person(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + public int getAge() { + return this.age; + } + + public void setAge(int age) { + this.age = age; + } + } +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java index 600d1db2918..751a2d4522a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java @@ -125,7 +125,7 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini @Override - public void afterPropertiesSet() throws Exception { + public void afterPropertiesSet() { if (this.routerFunction == null) { initRouterFunctions(); }