From d04d7b2e57096b4c4acde3ba12e978a3867b0772 Mon Sep 17 00:00:00 2001 From: Arjen Poutsma Date: Tue, 4 Jul 2023 15:01:32 +0200 Subject: [PATCH] Add request binding to functional endpoints This commit introduces form binding to ServerRequest in both WebMVC.fn and WebFlux.fn's, in the form of a bind(Class) method. Closes gh-25943 --- .../function/server/MockServerRequest.java | 18 +- .../function/server/DefaultServerRequest.java | 34 +++ .../function/server/RequestPredicates.java | 14 +- .../function/server/ServerRequest.java | 29 ++- .../server/support/ServerRequestWrapper.java | 14 +- .../BindingFunctionIntegrationTests.java | 236 ++++++++++++++++++ .../function/DefaultServerRequest.java | 38 ++- .../function/DefaultServerRequestBuilder.java | 34 +++ .../servlet/function/RequestPredicates.java | 15 +- .../web/servlet/function/ServerRequest.java | 28 ++- .../function/DefaultServerRequestTests.java | 145 +++++++++++ 11 files changed, 598 insertions(+), 7 deletions(-) create mode 100644 spring-webflux/src/test/java/org/springframework/web/reactive/function/server/BindingFunctionIntegrationTests.java diff --git a/spring-test/src/main/java/org/springframework/mock/web/reactive/function/server/MockServerRequest.java b/spring-test/src/main/java/org/springframework/mock/web/reactive/function/server/MockServerRequest.java index 5e5ee6ac4f..7e1cf1b8ea 100644 --- a/spring-test/src/main/java/org/springframework/mock/web/reactive/function/server/MockServerRequest.java +++ b/spring-test/src/main/java/org/springframework/mock/web/reactive/function/server/MockServerRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2023 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. @@ -29,6 +29,7 @@ import java.util.Map; import java.util.Optional; import java.util.OptionalLong; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -48,6 +49,7 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.WebDataBinder; import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.reactive.function.server.HandlerStrategies; import org.springframework.web.reactive.function.server.ServerRequest; @@ -219,6 +221,20 @@ public final class MockServerRequest implements ServerRequest { return (Flux) this.body; } + @Override + @SuppressWarnings("unchecked") + public Mono bind(Class bindType) { + Assert.state(this.body != null, "No body"); + return (Mono) this.body; + } + + @Override + @SuppressWarnings("unchecked") + public Mono bind(Class bindType, Consumer dataBinderCustomizer) { + Assert.state(this.body != null, "No body"); + return (Mono) this.body; + } + @Override public Map attributes() { return this.attributes; diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequest.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequest.java index 7da63d855d..f115fc95d8 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequest.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequest.java @@ -27,12 +27,14 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.OptionalLong; +import java.util.function.Consumer; import java.util.function.Function; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; import org.springframework.core.codec.DecodingException; import org.springframework.core.codec.Hints; import org.springframework.core.io.buffer.DataBuffer; @@ -49,7 +51,12 @@ import org.springframework.http.server.RequestPath; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.support.WebExchangeDataBinder; import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.UnsupportedMediaTypeException; @@ -220,6 +227,33 @@ class DefaultServerRequest implements ServerRequest { .onErrorMap(DecodingException.class, DECODING_MAPPER); } + @Override + @SuppressWarnings("unchecked") + public Mono bind(Class bindType, Consumer dataBinderCustomizer) { + Assert.notNull(bindType, "BindType must not be null"); + Assert.notNull(dataBinderCustomizer, "DataBinderCustomizer must not be null"); + + return Mono.defer(() -> { + WebExchangeDataBinder dataBinder = new WebExchangeDataBinder(null); + dataBinder.setTargetType(ResolvableType.forClass(bindType)); + dataBinderCustomizer.accept(dataBinder); + + ServerWebExchange exchange = exchange(); + return dataBinder.construct(exchange) + .then(dataBinder.bind(exchange)) + .then(Mono.defer(() -> { + BindingResult bindingResult = dataBinder.getBindingResult(); + if (bindingResult.hasErrors()) { + return Mono.error(new BindException(bindingResult)); + } + else { + T result = (T) bindingResult.getTarget(); + return Mono.justOrEmpty(result); + } + })); + }); + } + @Override public Map attributes() { return this.exchange.getAttributes(); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java index 003c1571eb..4072b46241 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -29,6 +29,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; @@ -52,6 +53,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.MimeTypeUtils; import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.WebDataBinder; import org.springframework.web.cors.reactive.CorsUtils; import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.server.ServerWebExchange; @@ -1044,6 +1046,16 @@ public abstract class RequestPredicates { return this.request.bodyToFlux(typeReference); } + @Override + public Mono bind(Class bindType) { + return this.request.bind(bindType); + } + + @Override + public Mono bind(Class bindType, Consumer dataBinderCustomizer) { + return this.request.bind(bindType, dataBinderCustomizer); + } + @Override public Map attributes() { return this.attributes; diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java index 6f0b268a16..d4b36cc01d 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -48,6 +48,8 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; +import org.springframework.validation.BindException; +import org.springframework.web.bind.WebDataBinder; import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebSession; @@ -197,6 +199,31 @@ public interface ServerRequest { */ Flux bodyToFlux(ParameterizedTypeReference typeReference); + /** + * Bind to this request and return an instance of the given type. + * @param bindType the type of class to bind this request to + * @param the type to bind to + * @return a mono containing either a constructed and bound instance of + * {@code bindType}, or a {@link BindException} in case of binding errors + * @since 6.1 + */ + default Mono bind(Class bindType) { + return bind(bindType, dataBinder -> {}); + } + + /** + * Bind to this request and return an instance of the given type. + * @param bindType the type of class to bind this request to + * @param dataBinderCustomizer used to customize the data binder, e.g. set + * (dis)allowed fields + * @param the type to bind to + * @return a mono containing either a constructed and bound instance of + * {@code bindType}, or a {@link BindException} in case of binding errors + * @since 6.1 + */ + Mono bind(Class bindType, Consumer dataBinderCustomizer); + + /** * Get the request attribute value if present. * @param name the attribute name diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/ServerRequestWrapper.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/ServerRequestWrapper.java index ac7faf5743..09f8feacb9 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/ServerRequestWrapper.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/ServerRequestWrapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2023 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. @@ -25,6 +25,7 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.OptionalLong; +import java.util.function.Consumer; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -42,6 +43,7 @@ import org.springframework.http.server.RequestPath; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.WebDataBinder; import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.server.ServerWebExchange; @@ -171,6 +173,16 @@ public class ServerRequestWrapper implements ServerRequest { return this.delegate.bodyToFlux(typeReference); } + @Override + public Mono bind(Class bindType) { + return this.delegate.bind(bindType); + } + + @Override + public Mono bind(Class bindType, Consumer dataBinderCustomizer) { + return this.delegate.bind(bindType, dataBinderCustomizer); + } + @Override public Optional attribute(String name) { return this.delegate.attribute(name); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/BindingFunctionIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/BindingFunctionIntegrationTests.java new file mode 100644 index 0000000000..a8c0986b8a --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/BindingFunctionIntegrationTests.java @@ -0,0 +1,236 @@ +/* + * Copyright 2002-2023 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.web.reactive.function.server; + +import java.time.Duration; +import java.util.Objects; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.lang.Nullable; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.testfixture.http.server.reactive.bootstrap.HttpServer; + +import static org.springframework.web.reactive.function.server.RouterFunctions.route; + +/** + * @author Arjen Poutsma + */ +class BindingFunctionIntegrationTests extends AbstractRouterFunctionIntegrationTests { + + private WebClient webClient; + + + @Override + protected void startServer(HttpServer httpServer) throws Exception { + super.startServer(httpServer); + this.webClient = WebClient.create("http://localhost:" + this.port); + } + + @Override + protected RouterFunction routerFunction() { + return route() + .GET("/constructor", request -> ServerResponse.ok().body( + request.bind(ConstructorInjection.class).map(Objects::toString), String.class)) + .GET("/property", request -> ServerResponse.ok().body( + request.bind(PropertyInjection.class).map(Objects::toString), String.class)) + .GET("/mixed", request -> ServerResponse.ok().body( + request.bind(MixedInjection.class).map(Objects::toString), String.class)) + .GET("/customize", request -> ServerResponse.ok().body( + request.bind(PropertyInjection.class, dataBinder -> dataBinder.setAllowedFields("foo")).map(Objects::toString), String.class)) + .GET("/error", request -> ServerResponse.ok().body( + request.bind(ErrorInjection.class).map(Objects::toString), String.class)) + .build(); + } + + @ParameterizedHttpServerTest + void bindToConstructor(HttpServer httpServer) throws Exception { + + startServer(httpServer); + + Mono result = this.webClient.get() + .uri("/constructor?foo=FOO&bar=BAR") + .retrieve() + .bodyToMono(String.class); + + StepVerifier.create(result) + .expectNext("FOO:BAR") + .verifyComplete(); + } + + @ParameterizedHttpServerTest + void bindToProperties(HttpServer httpServer) throws Exception { + + startServer(httpServer); + + Mono result = this.webClient.get() + .uri("/property?foo=FOO&bar=BAR") + .retrieve() + .bodyToMono(String.class); + + StepVerifier.create(result) + .expectNext("FOO:BAR") + .verifyComplete(); + } + + @ParameterizedHttpServerTest + void bindToMixed(HttpServer httpServer) throws Exception { + + startServer(httpServer); + + Mono result = this.webClient.get() + .uri("/mixed?foo=FOO&bar=BAR") + .retrieve() + .bodyToMono(String.class); + + StepVerifier.create(result) + .expectNext("FOO:BAR") + .verifyComplete(); + } + + @ParameterizedHttpServerTest + void bindCustomizer(HttpServer httpServer) throws Exception { + + startServer(httpServer); + + Mono result = this.webClient.get() + .uri("/customize?foo=FOO&bar=BAR") + .retrieve() + .bodyToMono(String.class); + + StepVerifier.create(result) + .expectNext("FOO:null") + .verifyComplete(); + } + + @ParameterizedHttpServerTest + void bindError(HttpServer httpServer) throws Exception { + + startServer(httpServer); + + Mono result = this.webClient.get() + .uri("/error?foo=FOO") + .retrieve() + .bodyToMono(String.class); + + StepVerifier.create(result) + .expectError(WebClientResponseException.InternalServerError.class) + .verify(Duration.ofSeconds(5L)); + } + + + private static final class ConstructorInjection { + private final String foo; + + private final String bar; + + public ConstructorInjection(String foo, String bar) { + this.foo = foo; + this.bar = bar; + } + + public String getFoo() { + return this.foo; + } + + public String getBar() { + return this.bar; + } + + @Override + public String toString() { + return this.foo + ":" + this.bar; + } + } + + private static final class PropertyInjection { + @Nullable + private String foo; + + @Nullable + private String bar; + + @Nullable + public String getFoo() { + return this.foo; + } + + public void setFoo(String foo) { + this.foo = foo; + } + + @Nullable + public String getBar() { + return this.bar; + } + + public void setBar(String bar) { + this.bar = bar; + } + + @Override + public String toString() { + return String.valueOf(this.foo) + ":" + String.valueOf(this.bar); + } + } + + private static final class MixedInjection { + private final String foo; + + @Nullable + private String bar; + + public MixedInjection(String foo) { + this.foo = foo; + } + + public String getFoo() { + return this.foo; + } + + @Nullable + public String getBar() { + return this.bar; + } + + public void setBar(String bar) { + this.bar = bar; + } + + public String toString() { + return this.foo + ":" + String.valueOf(this.bar); + } + + } + + private static final class ErrorInjection { + + private int foo; + + public int getFoo() { + return this.foo; + } + + public void setFoo(int foo) { + this.foo = foo; + } + } + + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java index a2223c29ea..daaef35a07 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -36,6 +36,7 @@ import java.util.Map; import java.util.Optional; import java.util.OptionalLong; import java.util.Set; +import java.util.function.Consumer; import java.util.stream.Collectors; import jakarta.servlet.ServletException; @@ -47,6 +48,7 @@ import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.Part; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpRange; @@ -56,12 +58,17 @@ import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.server.RequestPath; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MimeTypeUtils; import org.springframework.util.MultiValueMap; import org.springframework.util.ObjectUtils; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.bind.ServletRequestDataBinder; +import org.springframework.web.bind.WebDataBinder; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -218,6 +225,35 @@ class DefaultServerRequest implements ServerRequest { return result; } + @Override + @SuppressWarnings("unchecked") + public T bind(Class bindType, Consumer dataBinderCustomizer) throws BindException { + Assert.notNull(bindType, "BindType must not be null"); + Assert.notNull(dataBinderCustomizer, "DataBinderCustomizer must not be null"); + + ServletRequestDataBinder dataBinder = new ServletRequestDataBinder(null); + dataBinder.setTargetType(ResolvableType.forClass(bindType)); + dataBinderCustomizer.accept(dataBinder); + + HttpServletRequest servletRequest = servletRequest(); + dataBinder.construct(servletRequest); + dataBinder.bind(servletRequest); + + BindingResult bindingResult = dataBinder.getBindingResult(); + if (bindingResult.hasErrors()) { + throw new BindException(bindingResult); + } + else { + T result = (T) bindingResult.getTarget(); + if (result != null) { + return result; + } + else { + throw new IllegalStateException("Binding result has neither target nor errors"); + } + } + } + @Override public Optional attribute(String name) { return Optional.ofNullable(servletRequest().getAttribute(name)); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequestBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequestBuilder.java index ed2e5cf3fd..2705b712bc 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequestBuilder.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequestBuilder.java @@ -42,6 +42,7 @@ import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.Part; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpMethod; @@ -52,7 +53,11 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.bind.ServletRequestDataBinder; +import org.springframework.web.bind.WebDataBinder; import org.springframework.web.util.UriBuilder; import org.springframework.web.util.UriComponentsBuilder; @@ -323,6 +328,35 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder { throw new HttpMediaTypeNotSupportedException(contentType, Collections.emptyList(), method()); } + @Override + @SuppressWarnings("unchecked") + public T bind(Class bindType, Consumer dataBinderCustomizer) throws BindException { + Assert.notNull(bindType, "BindType must not be null"); + Assert.notNull(dataBinderCustomizer, "DataBinderCustomizer must not be null"); + + ServletRequestDataBinder dataBinder = new ServletRequestDataBinder(null); + dataBinder.setTargetType(ResolvableType.forClass(bindType)); + dataBinderCustomizer.accept(dataBinder); + + HttpServletRequest servletRequest = servletRequest(); + dataBinder.construct(servletRequest); + dataBinder.bind(servletRequest); + + BindingResult bindingResult = dataBinder.getBindingResult(); + if (bindingResult.hasErrors()) { + throw new BindException(bindingResult); + } + else { + T result = (T) bindingResult.getTarget(); + if (result != null) { + return result; + } + else { + throw new IllegalStateException("Binding result has neither target nor errors"); + } + } + } + @Override public Map attributes() { return this.attributes; diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java index 08606f5c01..49141fa848 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -31,6 +31,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; @@ -54,6 +55,8 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.MimeTypeUtils; import org.springframework.util.MultiValueMap; +import org.springframework.validation.BindException; +import org.springframework.web.bind.WebDataBinder; import org.springframework.web.cors.CorsUtils; import org.springframework.web.util.UriBuilder; import org.springframework.web.util.UriUtils; @@ -1018,6 +1021,16 @@ public abstract class RequestPredicates { return this.request.body(bodyType); } + @Override + public T bind(Class bindType) throws BindException { + return this.request.bind(bindType); + } + + @Override + public T bind(Class bindType, Consumer dataBinderCustomizer) throws BindException { + return this.request.bind(bindType, dataBinderCustomizer); + } + @Override public Optional attribute(String name) { return this.request.attribute(name); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java index 1ffe98fadf..ced5bb0da3 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2023 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. @@ -48,6 +48,8 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; +import org.springframework.validation.BindException; +import org.springframework.web.bind.WebDataBinder; import org.springframework.web.util.ServletRequestPathUtils; import org.springframework.web.util.UriBuilder; @@ -148,6 +150,30 @@ public interface ServerRequest { */ T body(ParameterizedTypeReference bodyType) throws ServletException, IOException; + /** + * Bind to this request and return an instance of the given type. + * @param bindType the type of class to bind this request to + * @param the type to bind to + * @return a constructed and bound instance of {@code bindType} + * @throws BindException in case of binding errors + * @since 6.1 + */ + default T bind(Class bindType) throws BindException { + return bind(bindType, dataBinder -> {}); + } + + /** + * Bind to this request and return an instance of the given type. + * @param bindType the type of class to bind this request to + * @param dataBinderCustomizer used to customize the data binder, e.g. set + * (dis)allowed fields + * @param the type to bind to + * @return a constructed and bound instance of {@code bindType} + * @throws BindException in case of binding errors + * @since 6.1 + */ + T bind(Class bindType, Consumer dataBinderCustomizer) throws BindException; + /** * Get the request attribute value if present. * @param name the attribute name diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultServerRequestTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultServerRequestTests.java index 30707f1a8e..1b166b7a40 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultServerRequestTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultServerRequestTests.java @@ -49,6 +49,7 @@ import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.validation.BindException; import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.servlet.handler.PathPatternsTestUtils; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; @@ -304,6 +305,71 @@ class DefaultServerRequestTests { assertThat(request.principal()).contains(principal); } + @Test + void bindToConstructor() throws BindException { + MockHttpServletRequest servletRequest = PathPatternsTestUtils.initRequest("GET", "/", true); + servletRequest.addParameter("foo", "FOO"); + servletRequest.addParameter("bar", "BAR"); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + ConstructorInjection result = request.bind(ConstructorInjection.class); + assertThat(result.getFoo()).isEqualTo("FOO"); + assertThat(result.getBar()).isEqualTo("BAR"); + } + + @Test + void bindToProperties() throws BindException { + MockHttpServletRequest servletRequest = PathPatternsTestUtils.initRequest("GET", "/", true); + servletRequest.addParameter("foo", "FOO"); + servletRequest.addParameter("bar", "BAR"); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + PropertyInjection result = request.bind(PropertyInjection.class); + assertThat(result.getFoo()).isEqualTo("FOO"); + assertThat(result.getBar()).isEqualTo("BAR"); + } + + @Test + void bindToMixed() throws BindException { + MockHttpServletRequest servletRequest = PathPatternsTestUtils.initRequest("GET", "/", true); + servletRequest.addParameter("foo", "FOO"); + servletRequest.addParameter("bar", "BAR"); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + MixedInjection result = request.bind(MixedInjection.class); + assertThat(result.getFoo()).isEqualTo("FOO"); + assertThat(result.getBar()).isEqualTo("BAR"); + } + + @Test + void bindCustomizer() throws BindException { + MockHttpServletRequest servletRequest = PathPatternsTestUtils.initRequest("GET", "/", true); + servletRequest.addParameter("foo", "FOO"); + servletRequest.addParameter("bar", "BAR"); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + PropertyInjection result = request.bind(PropertyInjection.class, dataBinder -> dataBinder.setAllowedFields("foo")); + assertThat(result.getFoo()).isEqualTo("FOO"); + assertThat(result.getBar()).isNull(); + } + + @Test + void bindError() throws BindException { + MockHttpServletRequest servletRequest = PathPatternsTestUtils.initRequest("GET", "/", true); + servletRequest.addParameter("foo", "FOO"); + + DefaultServerRequest request = new DefaultServerRequest(servletRequest, this.messageConverters); + + assertThatExceptionOfType(BindException.class).isThrownBy(() -> + request.bind(ErrorInjection.class) + ); + } + + @ParameterizedHttpMethodTest void checkNotModifiedTimestamp(String method) throws Exception { MockHttpServletRequest servletRequest = PathPatternsTestUtils.initRequest(method, "/", true); @@ -487,4 +553,83 @@ class DefaultServerRequestTests { @interface ParameterizedHttpMethodTest { } + private static final class ConstructorInjection { + private final String foo; + + private final String bar; + + public ConstructorInjection(String foo, String bar) { + this.foo = foo; + this.bar = bar; + } + + public String getFoo() { + return this.foo; + } + + public String getBar() { + return this.bar; + } + } + + private static final class PropertyInjection { + private String foo; + + private String bar; + + public String getFoo() { + return this.foo; + } + + public void setFoo(String foo) { + this.foo = foo; + } + + public String getBar() { + return this.bar; + } + + public void setBar(String bar) { + this.bar = bar; + } + } + + private static final class MixedInjection { + private final String foo; + + private String bar; + + public MixedInjection(String foo) { + this.foo = foo; + } + + public String getFoo() { + return this.foo; + } + + public String getBar() { + return this.bar; + } + + public void setBar(String bar) { + this.bar = bar; + } + } + + private static final class ErrorInjection { + + private int foo; + + public int getFoo() { + return this.foo; + } + + public void setFoo(int foo) { + this.foo = foo; + } + } + + + + }