diff --git a/spring-context/src/main/java/org/springframework/validation/DataBinder.java b/spring-context/src/main/java/org/springframework/validation/DataBinder.java index 6e48f6a6c0..207324ab86 100644 --- a/spring-context/src/main/java/org/springframework/validation/DataBinder.java +++ b/spring-context/src/main/java/org/springframework/validation/DataBinder.java @@ -170,6 +170,9 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { @Nullable private String[] requiredFields; + @Nullable + private NameResolver nameResolver; + @Nullable private ConversionService conversionService; @@ -225,7 +228,7 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { /** * Set the type for the target object. When the target is {@code null}, - * setting the targetType allows using {@link #construct(ValueResolver)} to + * setting the targetType allows using {@link #construct} to * create the target. * @param targetType the type of the target object * @since 6.1 @@ -252,7 +255,7 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { *

Default is "true" on a standard DataBinder. Note that since Spring 4.1 this feature is supported * for bean property access (DataBinder's default mode) and field access. *

Used for setter/field injection via {@link #bind(PropertyValues)}, and not - * applicable to constructor initialization via {@link #construct(ValueResolver)}. + * applicable to constructor binding via {@link #construct}. * @see #initBeanPropertyAccess() * @see org.springframework.beans.BeanWrapper#setAutoGrowNestedPaths */ @@ -274,7 +277,7 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { *

Default is 256, preventing OutOfMemoryErrors in case of large indexes. * Raise this limit if your auto-growing needs are unusually high. *

Used for setter/field injection via {@link #bind(PropertyValues)}, and not - * applicable to constructor initialization via {@link #construct(ValueResolver)}. + * applicable to constructor binding via {@link #construct}. * @see #initBeanPropertyAccess() * @see org.springframework.beans.BeanWrapper#setAutoGrowCollectionLimit */ @@ -431,8 +434,8 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { *

Note that this setting only applies to binding operations * on this DataBinder, not to retrieving values via its * {@link #getBindingResult() BindingResult}. - *

Used for setter/field inject via {@link #bind(PropertyValues)}, and not - * applicable to constructor initialization via {@link #construct(ValueResolver)}, + *

Used for binding to fields with {@link #bind(PropertyValues)}, and not + * applicable to constructor binding via {@link #construct}, * which uses only the values it needs. * @see #bind */ @@ -456,8 +459,8 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { *

Note that this setting only applies to binding operations * on this DataBinder, not to retrieving values via its * {@link #getBindingResult() BindingResult}. - *

Used for setter/field inject via {@link #bind(PropertyValues)}, and not - * applicable to constructor initialization via {@link #construct(ValueResolver)}, + *

Used for binding to fields with {@link #bind(PropertyValues)}, and not + * applicable to constructor binding via {@link #construct}, * which uses only the values it needs. * @see #bind */ @@ -487,8 +490,8 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { *

More sophisticated matching can be implemented by overriding the * {@link #isAllowed} method. *

Alternatively, specify a list of disallowed field patterns. - *

Used for setter/field inject via {@link #bind(PropertyValues)}, and not - * applicable to constructor initialization via {@link #construct(ValueResolver)}, + *

Used for binding to fields with {@link #bind(PropertyValues)}, and not + * applicable to constructor binding via {@link #construct}, * which uses only the values it needs. * @param allowedFields array of allowed field patterns * @see #setDisallowedFields @@ -526,8 +529,8 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { *

More sophisticated matching can be implemented by overriding the * {@link #isAllowed} method. *

Alternatively, specify a list of allowed field patterns. - *

Used for setter/field inject via {@link #bind(PropertyValues)}, and not - * applicable to constructor initialization via {@link #construct(ValueResolver)}, + *

Used for binding to fields with {@link #bind(PropertyValues)}, and not + * applicable to constructor binding via {@link #construct}, * which uses only the values it needs. * @param disallowedFields array of disallowed field patterns * @see #setAllowedFields @@ -562,8 +565,8 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { * incoming property values, a corresponding "missing field" error * will be created, with error code "required" (by the default * binding error processor). - *

Used for setter/field inject via {@link #bind(PropertyValues)}, and not - * applicable to constructor initialization via {@link #construct(ValueResolver)}, + *

Used for binding to fields with {@link #bind(PropertyValues)}, and not + * applicable to constructor binding via {@link #construct}, * which uses only the values it needs. * @param requiredFields array of field names * @see #setBindingErrorProcessor @@ -586,6 +589,28 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { return this.requiredFields; } + /** + * Configure a resolver to determine the name of the value to bind to a + * constructor parameter in {@link #construct}. + *

If not configured, or if the name cannot be resolved, by default + * {@link org.springframework.core.DefaultParameterNameDiscoverer} is used. + * @param nameResolver the resolver to use + * @since 6.1 + */ + public void setNameResolver(NameResolver nameResolver) { + this.nameResolver = nameResolver; + } + + /** + * Return the {@link #setNameResolver configured} name resolver for + * constructor parameters. + * @since 6.1 + */ + @Nullable + public NameResolver getNameResolver() { + return this.nameResolver; + } + /** * Set the strategy to use for resolving errors into message codes. * Applies the given strategy to the underlying errors holder. @@ -885,11 +910,19 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { Set failedParamNames = new HashSet<>(4); for (int i = 0; i < paramNames.length; i++) { - String paramPath = nestedPath + paramNames[i]; + MethodParameter param = MethodParameter.forFieldAwareConstructor(ctor, i, paramNames[i]); + String lookupName = null; + if (this.nameResolver != null) { + lookupName = this.nameResolver.resolveName(param); + } + if (lookupName == null) { + lookupName = paramNames[i]; + } + + String paramPath = nestedPath + lookupName; Class paramType = paramTypes[i]; Object value = valueResolver.resolveValue(paramPath, paramType); - MethodParameter param = MethodParameter.forFieldAwareConstructor(ctor, i, paramNames[i]); if (value == null && !BeanUtils.isSimpleValueType(param.nestedIfOptional().getNestedParameterType())) { ResolvableType type = ResolvableType.forMethodParameter(param); args[i] = createObject(type, paramPath + ".", valueResolver); @@ -1188,16 +1221,36 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { /** - * Contract to resolve a value in {@link #construct(ValueResolver)}. + * Strategy to determine the name of the value to bind to a method parameter. + * Supported on constructor parameters with {@link #construct constructor + * binding} which performs lookups via {@link ValueResolver#resolveValue}. + */ + public interface NameResolver { + + /** + * Return the name to use for the given method parameter, or {@code null} + * if unresolved. For constructor parameters, the name is determined via + * {@link org.springframework.core.DefaultParameterNameDiscoverer} if + * unresolved. + */ + @Nullable + String resolveName(MethodParameter parameter); + + } + + /** + * Strategy for {@link #construct constructor binding} to look up the values + * to bind to a given constructor parameter. */ @FunctionalInterface public interface ValueResolver { /** - * Look up the value for a constructor argument. - * @param name the argument name - * @param type the argument type - * @return the resolved value, possibly {@code null} + * Resolve the value for the given name and target parameter type. + * @param name the name to use for the lookup, possibly a nested path + * for constructor parameters on nested objects + * @param type the target type, based on the constructor parameter type + * @return the resolved value, possibly {@code null} if none found */ @Nullable Object resolveValue(String name, Class type); @@ -1217,5 +1270,4 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { } } - } diff --git a/spring-context/src/test/java/org/springframework/validation/DataBinderConstructTests.java b/spring-context/src/test/java/org/springframework/validation/DataBinderConstructTests.java new file mode 100644 index 0000000000..9516eba02c --- /dev/null +++ b/spring-context/src/test/java/org/springframework/validation/DataBinderConstructTests.java @@ -0,0 +1,154 @@ +/* + * 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.validation; + +import java.beans.ConstructorProperties; +import java.util.Map; +import java.util.Optional; + +import jakarta.validation.constraints.NotNull; +import org.junit.jupiter.api.Test; + +import org.springframework.core.ResolvableType; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.util.Assert; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link DataBinder} with constructor binding. + * + * @author Rossen Stoyanchev + */ +public class DataBinderConstructTests { + + + @Test + void dataClassBinding() { + MapValueResolver valueResolver = new MapValueResolver(Map.of("param1", "value1", "param2", "true")); + DataBinder binder = initDataBinder(DataClass.class); + binder.construct(valueResolver); + + DataClass dataClass = getTarget(binder); + assertThat(dataClass.param1()).isEqualTo("value1"); + assertThat(dataClass.param2()).isEqualTo(true); + assertThat(dataClass.param3()).isEqualTo(0); + } + + @Test + void dataClassBindingWithOptionalParameter() { + MapValueResolver valueResolver = + new MapValueResolver(Map.of("param1", "value1", "param2", "true", "optionalParam", "8")); + + DataBinder binder = initDataBinder(DataClass.class); + binder.construct(valueResolver); + + DataClass dataClass = getTarget(binder); + assertThat(dataClass.param1()).isEqualTo("value1"); + assertThat(dataClass.param2()).isEqualTo(true); + assertThat(dataClass.param3()).isEqualTo(8); + } + + @Test + void dataClassBindingWithMissingParameter() { + MapValueResolver valueResolver = new MapValueResolver(Map.of("param1", "value1")); + DataBinder binder = initDataBinder(DataClass.class); + binder.construct(valueResolver); + + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getAllErrors()).hasSize(1); + assertThat(bindingResult.getFieldValue("param1")).isEqualTo("value1"); + assertThat(bindingResult.getFieldValue("param2")).isNull(); + assertThat(bindingResult.getFieldValue("param3")).isNull(); + } + + @Test + void dataClassBindingWithConversionError() { + MapValueResolver valueResolver = new MapValueResolver(Map.of("param1", "value1", "param2", "x")); + DataBinder binder = initDataBinder(DataClass.class); + binder.construct(valueResolver); + + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getAllErrors()).hasSize(1); + assertThat(bindingResult.getFieldValue("param1")).isEqualTo("value1"); + assertThat(bindingResult.getFieldValue("param2")).isEqualTo("x"); + assertThat(bindingResult.getFieldValue("param3")).isNull(); + } + + @SuppressWarnings("SameParameterValue") + private static DataBinder initDataBinder(Class targetType) { + DataBinder binder = new DataBinder(null); + binder.setTargetType(ResolvableType.forClass(targetType)); + binder.setConversionService(new DefaultFormattingConversionService()); + return binder; + } + + @SuppressWarnings("unchecked") + private static T getTarget(DataBinder dataBinder) { + assertThat(dataBinder.getBindingResult().getAllErrors()).isEmpty(); + Object target = dataBinder.getTarget(); + assertThat(target).isNotNull(); + return (T) target; + } + + + private static class DataClass { + + @NotNull + private final String param1; + + private final boolean param2; + + private int param3; + + @ConstructorProperties({"param1", "param2", "optionalParam"}) + DataClass(String param1, boolean p2, Optional optionalParam) { + this.param1 = param1; + this.param2 = p2; + Assert.notNull(optionalParam, "Optional must not be null"); + optionalParam.ifPresent(integer -> this.param3 = integer); + } + + public String param1() { + return this.param1; + } + + public boolean param2() { + return this.param2; + } + + public int param3() { + return this.param3; + } + } + + + private static class MapValueResolver implements DataBinder.ValueResolver { + + private final Map values; + + private MapValueResolver(Map values) { + this.values = values; + } + + @Override + public Object resolveValue(String name, Class type) { + return values.get(name); + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/BindParam.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/BindParam.java new file mode 100644 index 0000000000..e2ec4a08f6 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/BindParam.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2018 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.bind.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to bind values from a web request such as request parameters or + * path variables to fields of a Java object. Supported on constructor parameters + * of {@link ModelAttribute @ModelAttribute} controller method arguments + * + * @author Rossen Stoyanchev + * @since 6.1 + * @see org.springframework.web.bind.WebDataBinder#construct + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface BindParam { + + /** + * The lookup name to use for the bind value. + */ + String value() default ""; + +} diff --git a/spring-web/src/main/java/org/springframework/web/bind/support/BindParamNameResolver.java b/spring-web/src/main/java/org/springframework/web/bind/support/BindParamNameResolver.java new file mode 100644 index 0000000000..b4f5c104bf --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/bind/support/BindParamNameResolver.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2018 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.bind.support; + +import org.springframework.core.MethodParameter; +import org.springframework.util.StringUtils; +import org.springframework.validation.DataBinder; +import org.springframework.web.bind.annotation.BindParam; + +/** + * {@link org.springframework.validation.DataBinder.NameResolver} that determines + * the bind value name from a {@link BindParam @BindParam} method parameter + * annotation. + * + * @author Rossen Stoyanchev + * @since 6.1 + */ +public final class BindParamNameResolver implements DataBinder.NameResolver { + + @Override + public String resolveName(MethodParameter parameter) { + BindParam bindParam = parameter.getParameterAnnotation(BindParam.class); + if (bindParam != null) { + if (StringUtils.hasText(bindParam.value())) { + return bindParam.value(); + } + } + return null; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/bind/support/DefaultDataBinderFactory.java b/spring-web/src/main/java/org/springframework/web/bind/support/DefaultDataBinderFactory.java index 372b714af5..a96e2ff951 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/support/DefaultDataBinderFactory.java +++ b/spring-web/src/main/java/org/springframework/web/bind/support/DefaultDataBinderFactory.java @@ -91,6 +91,7 @@ public class DefaultDataBinderFactory implements WebDataBinderFactory { @Nullable ResolvableType type) throws Exception { WebDataBinder dataBinder = createBinderInstance(target, objectName, webRequest); + dataBinder.setNameResolver(new BindParamNameResolver()); if (target == null && type != null) { dataBinder.setTargetType(type); @@ -99,6 +100,7 @@ public class DefaultDataBinderFactory implements WebDataBinderFactory { if (this.initializer != null) { this.initializer.initBinder(dataBinder); } + initBinder(dataBinder, webRequest); if (this.methodValidationApplicable && type != null) { @@ -112,7 +114,7 @@ public class DefaultDataBinderFactory implements WebDataBinderFactory { /** * Extension point to create the WebDataBinder instance. - * By default this is {@code WebRequestDataBinder}. + * By default, this is {@code WebRequestDataBinder}. * @param target the binding target or {@code null} for type conversion only * @param objectName the binding target object name * @param webRequest the current request diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java b/spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java index 9906303d1b..48c7b4845d 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java @@ -32,6 +32,7 @@ import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.validation.DataBinder; import org.springframework.validation.support.BindingAwareConcurrentModel; +import org.springframework.web.bind.support.BindParamNameResolver; import org.springframework.web.bind.support.WebBindingInitializer; import org.springframework.web.bind.support.WebExchangeDataBinder; import org.springframework.web.server.ServerErrorException; @@ -138,6 +139,8 @@ public class BindingContext { ServerWebExchange exchange, @Nullable Object target, String name, @Nullable ResolvableType targetType) { WebExchangeDataBinder dataBinder = new ExtendedWebExchangeDataBinder(target, name); + dataBinder.setNameResolver(new BindParamNameResolver()); + if (target == null && targetType != null) { dataBinder.setTargetType(targetType); } @@ -145,6 +148,7 @@ public class BindingContext { if (this.initializer != null) { this.initializer.initBinder(dataBinder); } + dataBinder = initDataBinder(dataBinder, exchange); if (this.methodValidationApplicable && targetType != null) { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java index 73eff3ad60..f17e50b3a8 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java @@ -16,7 +16,6 @@ package org.springframework.web.servlet.mvc.method.annotation; -import java.beans.ConstructorProperties; import java.beans.PropertyEditorSupport; import java.io.IOException; import java.io.Serializable; @@ -114,6 +113,7 @@ import org.springframework.validation.FieldError; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; import org.springframework.web.accept.ContentNegotiationManagerFactoryBean; import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.annotation.BindParam; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; @@ -4057,8 +4057,7 @@ class ServletAnnotationControllerHandlerMethodTests extends AbstractServletHandl private int param3; - @ConstructorProperties({"param1", "param2", "optionalParam"}) - public DataClass(String param1, boolean p2, Optional optionalParam) { + public DataClass(String param1, @BindParam("param2") boolean p2, Optional optionalParam) { this.param1 = param1; this.param2 = p2; Assert.notNull(optionalParam, "Optional must not be null"); @@ -4152,8 +4151,9 @@ class ServletAnnotationControllerHandlerMethodTests extends AbstractServletHandl public int param3; - @ConstructorProperties({"param1", "param2", "optionalParam"}) - public MultipartFileDataClass(MultipartFile param1, boolean p2, Optional optionalParam) { + public MultipartFileDataClass( + MultipartFile param1, @BindParam("param2") boolean p2, Optional optionalParam) { + this.param1 = param1; this.param2 = p2; Assert.notNull(optionalParam, "Optional must not be null"); @@ -4184,8 +4184,7 @@ class ServletAnnotationControllerHandlerMethodTests extends AbstractServletHandl public int param3; - @ConstructorProperties({"param1", "param2", "optionalParam"}) - public ServletPartDataClass(Part param1, boolean p2, Optional optionalParam) { + public ServletPartDataClass(Part param1, @BindParam("param2") boolean p2, Optional optionalParam) { this.param1 = param1; this.param2 = p2; Assert.notNull(optionalParam, "Optional must not be null");