Browse Source

WebFlux constructs model attribute via DataBinder

See gh-26721
pull/30772/head
rstoyanchev 2 years ago
parent
commit
d37d6688d8
  1. 42
      spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeDataBinder.java
  2. 59
      spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java
  3. 174
      spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolver.java
  4. 15
      spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/InitBinderBindingContextTests.java

42
spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeDataBinder.java

@ -1,5 +1,5 @@ @@ -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.
@ -71,14 +71,30 @@ public class WebExchangeDataBinder extends WebDataBinder { @@ -71,14 +71,30 @@ public class WebExchangeDataBinder extends WebDataBinder {
}
/**
* Use a default or single data constructor to create the target by
* binding request parameters, multipart files, or parts to constructor args.
* <p>After the call, use {@link #getBindingResult()} to check for bind errors.
* If there are none, the target is set, and {@link #bind} can be called for
* further initialization via setters.
* @param exchange the request to bind
* @return a {@code Mono<Void>} that completes when the target is created
* @since 6.1
*/
public Mono<Void> construct(ServerWebExchange exchange) {
return getValuesToBind(exchange)
.doOnNext(map -> construct(new MapValueResolver(map)))
.then();
}
/**
* Bind query parameters, form data, or multipart form data to the binder target.
* @param exchange the current exchange
* @return a {@code Mono<Void>} when binding is complete
* @return a {@code Mono<Void>} that completes when binding is complete
*/
public Mono<Void> bind(ServerWebExchange exchange) {
return getValuesToBind(exchange)
.doOnNext(values -> doBind(new MutablePropertyValues(values)))
.doOnNext(map -> doBind(new MutablePropertyValues(map)))
.then();
}
@ -128,4 +144,22 @@ public class WebExchangeDataBinder extends WebDataBinder { @@ -128,4 +144,22 @@ public class WebExchangeDataBinder extends WebDataBinder {
}
}
}
/**
* Resolve values from a map.
*/
private static class MapValueResolver implements ValueResolver {
private final Map<String, Object> map;
private MapValueResolver(Map<String, Object> map) {
this.map = map;
}
@Override
public Object resolveValue(String name, Class<?> type) {
return this.map.get(name);
}
}
}

59
spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java

@ -24,6 +24,7 @@ import reactor.core.publisher.Mono; @@ -24,6 +24,7 @@ import reactor.core.publisher.Mono;
import org.springframework.core.MethodParameter;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.core.ResolvableType;
import org.springframework.lang.Nullable;
import org.springframework.ui.Model;
import org.springframework.validation.DataBinder;
@ -92,8 +93,7 @@ public class BindingContext { @@ -92,8 +93,7 @@ public class BindingContext {
/**
* Create a {@link WebExchangeDataBinder} to apply data binding and
* validation with on the target, command object.
* Create a binder with a target object.
* @param exchange the current exchange
* @param target the object to create a data binder for
* @param name the name of the target object
@ -101,51 +101,54 @@ public class BindingContext { @@ -101,51 +101,54 @@ public class BindingContext {
* @throws ServerErrorException if {@code @InitBinder} method invocation fails
*/
public WebExchangeDataBinder createDataBinder(ServerWebExchange exchange, @Nullable Object target, String name) {
WebExchangeDataBinder dataBinder = new ExtendedWebExchangeDataBinder(target, name);
if (this.initializer != null) {
this.initializer.initBinder(dataBinder);
}
return initDataBinder(dataBinder, exchange);
}
/**
* Initialize the data binder instance for the given exchange.
* @throws ServerErrorException if {@code @InitBinder} method invocation fails
*/
protected WebExchangeDataBinder initDataBinder(WebExchangeDataBinder binder, ServerWebExchange exchange) {
return binder;
return createDataBinder(exchange, target, name, null);
}
/**
* Create a {@link WebExchangeDataBinder} without a target object for type
* conversion of request values to simple types.
* Shortcut method to create a binder without a target object.
* @param exchange the current exchange
* @param name the name of the target object
* @return the created data binder
* @throws ServerErrorException if {@code @InitBinder} method invocation fails
*/
public WebExchangeDataBinder createDataBinder(ServerWebExchange exchange, String name) {
return createDataBinder(exchange, null, name);
return createDataBinder(exchange, null, name, null);
}
/**
* Variant of {@link #createDataBinder(ServerWebExchange, Object, String)}
* with a {@link MethodParameter} for which the {@code DataBinder} is created.
* That may provide more insight to initialize the {@link WebExchangeDataBinder}.
* <p>By default, if the parameter has {@code @Valid}, Bean Validation is
* excluded, deferring to method validation.
* Create a binder with a target object and a {@code MethodParameter}.
* If the target is {@code null}, then
* {@link WebExchangeDataBinder#setTargetType targetType} is set.
* @since 6.1
*/
public WebExchangeDataBinder createDataBinder(
ServerWebExchange exchange, @Nullable Object target, String name, MethodParameter parameter) {
ServerWebExchange exchange, @Nullable Object target, String name, @Nullable MethodParameter parameter) {
WebExchangeDataBinder dataBinder = new ExtendedWebExchangeDataBinder(target, name);
if (target == null && parameter != null) {
dataBinder.setTargetType(ResolvableType.forMethodParameter(parameter));
}
WebExchangeDataBinder dataBinder = createDataBinder(exchange, target, name);
if (this.methodValidationApplicable) {
MethodValidationInitializer.updateBinder(dataBinder, parameter);
if (this.initializer != null) {
this.initializer.initBinder(dataBinder);
}
dataBinder = initDataBinder(dataBinder, exchange);
if (this.methodValidationApplicable && parameter != null) {
MethodValidationInitializer.initBinder(dataBinder, parameter);
}
return dataBinder;
}
/**
* Initialize the data binder instance for the given exchange.
* @throws ServerErrorException if {@code @InitBinder} method invocation fails
*/
protected WebExchangeDataBinder initDataBinder(WebExchangeDataBinder binder, ServerWebExchange exchange) {
return binder;
}
/**
* Extended variant of {@link WebExchangeDataBinder}, adding path variables.
@ -170,7 +173,7 @@ public class BindingContext { @@ -170,7 +173,7 @@ public class BindingContext {
*/
private static class MethodValidationInitializer {
public static void updateBinder(DataBinder binder, MethodParameter parameter) {
public static void initBinder(DataBinder binder, MethodParameter parameter) {
if (ReactiveAdapterRegistry.getSharedInstance().getAdapter(parameter.getParameterType()) == null) {
for (Annotation annotation : parameter.getParameterAnnotations()) {
if (annotation.annotationType().getName().equals("jakarta.validation.Valid")) {

174
spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolver.java

@ -17,10 +17,7 @@ @@ -17,10 +17,7 @@
package org.springframework.web.reactive.result.method.annotation;
import java.lang.annotation.Annotation;
import java.lang.reflect.Constructor;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
@ -31,7 +28,6 @@ import org.springframework.context.i18n.LocaleContextHolder; @@ -31,7 +28,6 @@ import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.core.MethodParameter;
import org.springframework.core.ReactiveAdapter;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.core.ResolvableType;
import org.springframework.lang.Nullable;
import org.springframework.ui.Model;
import org.springframework.util.Assert;
@ -100,72 +96,74 @@ public class ModelAttributeMethodArgumentResolver extends HandlerMethodArgumentR @@ -100,72 +96,74 @@ public class ModelAttributeMethodArgumentResolver extends HandlerMethodArgumentR
public Mono<Object> resolveArgument(
MethodParameter parameter, BindingContext context, ServerWebExchange exchange) {
ResolvableType type = ResolvableType.forMethodParameter(parameter);
Class<?> resolvedType = type.resolve();
ReactiveAdapter adapter = (resolvedType != null ? getAdapterRegistry().getAdapter(resolvedType) : null);
ResolvableType valueType = (adapter != null ? type.getGeneric() : type);
Assert.state(adapter == null || !adapter.isMultiValue(),
() -> getClass().getSimpleName() + " does not support multi-value reactive type wrapper: " +
parameter.getGenericParameterType());
Class<?> resolvedType = parameter.getParameterType();
ReactiveAdapter adapter = getAdapterRegistry().getAdapter(resolvedType);
Assert.state(adapter == null || !adapter.isMultiValue(), "Multi-value publisher is not supported");
String name = ModelInitializer.getNameForParameter(parameter);
Mono<?> attributeMono = prepareAttributeMono(name, valueType, context, exchange);
// unsafe(): we're intercepting, already serialized Publisher signals
Mono<WebExchangeDataBinder> dataBinderMono = initDataBinder(
name, (adapter != null ? parameter.nested() : parameter), context, exchange);
// unsafe() is OK: source is Reactive Streams Publisher
Sinks.One<BindingResult> bindingResultSink = Sinks.unsafe().one();
Map<String, Object> model = context.getModel().asMap();
model.put(BindingResult.MODEL_KEY_PREFIX + name, bindingResultSink.asMono());
return attributeMono.flatMap(attribute -> {
WebExchangeDataBinder binder = context.createDataBinder(exchange, attribute, name, parameter);
return (!bindingDisabled(parameter) ? bindRequestParameters(binder, exchange) : Mono.empty())
.doOnError(bindingResultSink::tryEmitError)
.doOnSuccess(aVoid -> {
validateIfApplicable(binder, parameter, exchange);
BindingResult bindingResult = binder.getBindingResult();
model.put(BindingResult.MODEL_KEY_PREFIX + name, bindingResult);
model.put(name, attribute);
// Ignore result: serialized and buffered (should never fail)
bindingResultSink.tryEmitValue(bindingResult);
})
.then(Mono.fromCallable(() -> {
BindingResult errors = binder.getBindingResult();
if (adapter != null) {
return adapter.fromPublisher(errors.hasErrors() ?
Mono.error(new WebExchangeBindException(parameter, errors)) : attributeMono);
}
else {
if (errors.hasErrors() && !hasErrorsArgument(parameter)) {
throw new WebExchangeBindException(parameter, errors);
}
return attribute;
}
}));
});
return dataBinderMono
.flatMap(binder -> {
Object attribute = binder.getTarget();
Assert.state(attribute != null, "Expected model attribute instance");
return (!bindingDisabled(parameter) ? bindRequestParameters(binder, exchange) : Mono.empty())
.doOnError(bindingResultSink::tryEmitError)
.doOnSuccess(aVoid -> {
validateIfApplicable(binder, parameter, exchange);
BindingResult bindingResult = binder.getBindingResult();
model.put(BindingResult.MODEL_KEY_PREFIX + name, bindingResult);
model.put(name, attribute);
// Ignore result: serialized and buffered (should never fail)
bindingResultSink.tryEmitValue(bindingResult);
})
.then(Mono.fromCallable(() -> {
BindingResult errors = binder.getBindingResult();
if (adapter != null) {
Mono<Object> mono = (errors.hasErrors() ?
Mono.error(new WebExchangeBindException(parameter, errors)) :
Mono.just(attribute));
return adapter.fromPublisher(mono);
}
else {
if (errors.hasErrors() && !hasErrorsArgument(parameter)) {
throw new WebExchangeBindException(parameter, errors);
}
return attribute;
}
}));
});
}
private Mono<?> prepareAttributeMono(
String name, ResolvableType type, BindingContext context, ServerWebExchange exchange) {
Object attribute = context.getModel().asMap().get(name);
private Mono<WebExchangeDataBinder> initDataBinder(
String name, MethodParameter parameter, BindingContext context, ServerWebExchange exchange) {
if (attribute == null) {
attribute = removeReactiveAttribute(context.getModel(), name);
Object value = context.getModel().asMap().get(name);
if (value == null) {
value = removeReactiveAttribute(name, context.getModel());
}
if (attribute == null) {
return createAttribute(name, type.toClass(), context, exchange);
if (value != null) {
ReactiveAdapter adapter = getAdapterRegistry().getAdapter(null, value);
Assert.isTrue(adapter == null || !adapter.isMultiValue(), "Multi-value publisher is not supported");
return (adapter != null ? Mono.from(adapter.toPublisher(value)) : Mono.just(value))
.map(attr -> context.createDataBinder(exchange, attr, name, parameter));
}
else {
WebExchangeDataBinder binder = context.createDataBinder(exchange, null, name, parameter);
return constructAttribute(binder, exchange).thenReturn(binder);
}
ReactiveAdapter adapter = getAdapterRegistry().getAdapter(null, attribute);
Assert.isTrue(adapter == null || !adapter.isMultiValue(), "Model attribute must be single-value publisher");
return (adapter != null ? Mono.from(adapter.toPublisher(attribute)) : Mono.justOrEmpty(attribute));
}
@Nullable
private Object removeReactiveAttribute(Model model, String name) {
private Object removeReactiveAttribute(String name, Model model) {
for (Map.Entry<String, Object> entry : model.asMap().entrySet()) {
if (entry.getKey().startsWith(name)) {
ReactiveAdapter adapter = getAdapterRegistry().getAdapter(null, entry.getValue());
@ -181,66 +179,24 @@ public class ModelAttributeMethodArgumentResolver extends HandlerMethodArgumentR @@ -181,66 +179,24 @@ public class ModelAttributeMethodArgumentResolver extends HandlerMethodArgumentR
return null;
}
private Mono<?> createAttribute(
String attributeName, Class<?> clazz, BindingContext context, ServerWebExchange exchange) {
Constructor<?> ctor = BeanUtils.getResolvableConstructor(clazz);
return constructAttribute(ctor, attributeName, context, exchange);
}
private Mono<?> constructAttribute(Constructor<?> ctor, String attributeName,
BindingContext context, ServerWebExchange exchange) {
if (ctor.getParameterCount() == 0) {
// A single default constructor -> clearly a standard JavaBeans arrangement.
return Mono.just(BeanUtils.instantiateClass(ctor));
}
// A single data class constructor -> resolve constructor arguments from request parameters.
WebExchangeDataBinder binder = context.createDataBinder(exchange, null, attributeName);
return getValuesToBind(binder, exchange).map(bindValues -> {
String[] paramNames = BeanUtils.getParameterNames(ctor);
Class<?>[] paramTypes = ctor.getParameterTypes();
Object[] args = new Object[paramTypes.length];
String fieldDefaultPrefix = binder.getFieldDefaultPrefix();
String fieldMarkerPrefix = binder.getFieldMarkerPrefix();
for (int i = 0; i < paramNames.length; i++) {
String paramName = paramNames[i];
Class<?> paramType = paramTypes[i];
Object value = bindValues.get(paramName);
if (value == null) {
if (fieldDefaultPrefix != null) {
value = bindValues.get(fieldDefaultPrefix + paramName);
}
if (value == null && fieldMarkerPrefix != null) {
if (bindValues.get(fieldMarkerPrefix + paramName) != null) {
value = binder.getEmptyValue(paramType);
}
}
}
value = (value instanceof List<?> list ? list.toArray() : value);
MethodParameter methodParam = MethodParameter.forFieldAwareConstructor(ctor, i, paramName);
if (value == null && methodParam.isOptional()) {
args[i] = (methodParam.getParameterType() == Optional.class ? Optional.empty() : null);
}
else {
args[i] = binder.convertIfNecessary(value, paramTypes[i], methodParam);
}
}
return BeanUtils.instantiateClass(ctor, args);
});
/**
* Protected method to obtain the values for data binding.
* @deprecated and not called; replaced by built-in support for
* constructor initialization in {@link org.springframework.validation.DataBinder}
*/
@Deprecated(since = "6.1", forRemoval = true)
public Mono<Map<String, Object>> getValuesToBind(WebExchangeDataBinder binder, ServerWebExchange exchange) {
throw new UnsupportedOperationException();
}
/**
* Protected method to obtain the values for data binding. By default this
* method delegates to {@link WebExchangeDataBinder#getValuesToBind}.
* @param binder the data binder in use
* Extension point to create the attribute, binding the request to constructor args.
* @param binder the data binder instance to use for the binding
* @param exchange the current exchange
* @return a map of bind values
* @since 5.3
* @since 6.1
*/
public Mono<Map<String, Object>> getValuesToBind(WebExchangeDataBinder binder, ServerWebExchange exchange) {
return binder.getValuesToBind(exchange);
protected Mono<Void> constructAttribute(WebExchangeDataBinder binder, ServerWebExchange exchange) {
return binder.construct(exchange);
}
/**

15
spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/InitBinderBindingContextTests.java

@ -56,7 +56,7 @@ public class InitBinderBindingContextTests { @@ -56,7 +56,7 @@ public class InitBinderBindingContextTests {
public void createBinder() throws Exception {
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"));
BindingContext context = createBindingContext("initBinder", WebDataBinder.class);
WebDataBinder dataBinder = context.createDataBinder(exchange, null, null);
WebDataBinder dataBinder = context.createDataBinder(exchange, null);
assertThat(dataBinder.getDisallowedFields()).isNotNull();
assertThat(dataBinder.getDisallowedFields()[0]).isEqualTo("id");
@ -69,7 +69,7 @@ public class InitBinderBindingContextTests { @@ -69,7 +69,7 @@ public class InitBinderBindingContextTests {
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"));
BindingContext context = createBindingContext("initBinder", WebDataBinder.class);
WebDataBinder dataBinder = context.createDataBinder(exchange, null, null);
WebDataBinder dataBinder = context.createDataBinder(exchange, null);
assertThat(dataBinder.getConversionService()).isSameAs(conversionService);
}
@ -78,7 +78,7 @@ public class InitBinderBindingContextTests { @@ -78,7 +78,7 @@ public class InitBinderBindingContextTests {
public void createBinderWithAttrName() throws Exception {
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"));
BindingContext context = createBindingContext("initBinderWithAttributeName", WebDataBinder.class);
WebDataBinder dataBinder = context.createDataBinder(exchange, null, "foo");
WebDataBinder dataBinder = context.createDataBinder(exchange, "foo");
assertThat(dataBinder.getDisallowedFields()).isNotNull();
assertThat(dataBinder.getDisallowedFields()[0]).isEqualTo("id");
@ -88,7 +88,7 @@ public class InitBinderBindingContextTests { @@ -88,7 +88,7 @@ public class InitBinderBindingContextTests {
public void createBinderWithAttrNameNoMatch() throws Exception {
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"));
BindingContext context = createBindingContext("initBinderWithAttributeName", WebDataBinder.class);
WebDataBinder dataBinder = context.createDataBinder(exchange, null, "invalidName");
WebDataBinder dataBinder = context.createDataBinder(exchange, "invalidName");
assertThat(dataBinder.getDisallowedFields()).isNull();
}
@ -97,7 +97,7 @@ public class InitBinderBindingContextTests { @@ -97,7 +97,7 @@ public class InitBinderBindingContextTests {
public void createBinderNullAttrName() throws Exception {
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"));
BindingContext context = createBindingContext("initBinderWithAttributeName", WebDataBinder.class);
WebDataBinder dataBinder = context.createDataBinder(exchange, null, null);
WebDataBinder dataBinder = context.createDataBinder(exchange, null);
assertThat(dataBinder.getDisallowedFields()).isNull();
}
@ -106,8 +106,7 @@ public class InitBinderBindingContextTests { @@ -106,8 +106,7 @@ public class InitBinderBindingContextTests {
public void returnValueNotExpected() throws Exception {
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"));
BindingContext context = createBindingContext("initBinderReturnValue", WebDataBinder.class);
assertThatIllegalStateException().isThrownBy(() ->
context.createDataBinder(exchange, null, "invalidName"));
assertThatIllegalStateException().isThrownBy(() -> context.createDataBinder(exchange, "invalidName"));
}
@Test
@ -118,7 +117,7 @@ public class InitBinderBindingContextTests { @@ -118,7 +117,7 @@ public class InitBinderBindingContextTests {
this.argumentResolvers.add(new RequestParamMethodArgumentResolver(null, adapterRegistry, false));
BindingContext context = createBindingContext("initBinderTypeConversion", WebDataBinder.class, int.class);
WebDataBinder dataBinder = context.createDataBinder(exchange, null, "foo");
WebDataBinder dataBinder = context.createDataBinder(exchange, "foo");
assertThat(dataBinder.getDisallowedFields()).isNotNull();
assertThat(dataBinder.getDisallowedFields()[0]).isEqualToIgnoringCase("requestParam-22");

Loading…
Cancel
Save