diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java index 330456f500e6..5733d3326f92 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java @@ -242,6 +242,7 @@ private List initArgumentResolvers() { resolvers.add(new RequestBodyArgumentResolver(this.exchangeAdapter)); resolvers.add(new PathVariableArgumentResolver(service)); resolvers.add(new RequestParamArgumentResolver(service)); + resolvers.add(new ModelAttributeArgumentResolver(service)); resolvers.add(new RequestPartArgumentResolver(this.exchangeAdapter)); resolvers.add(new CookieValueArgumentResolver(service)); if (this.exchangeAdapter.supportsRequestAttributes()) { diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolver.java new file mode 100644 index 000000000000..40f631cc1bcc --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolver.java @@ -0,0 +1,124 @@ +/* + * Copyright 2002-present 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.service.invoker; + +import org.jspecify.annotations.Nullable; +import org.springframework.beans.BeanWrapper; +import org.springframework.beans.PropertyAccessorFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.util.ObjectUtils; +import org.springframework.web.bind.annotation.BindParam; +import org.springframework.web.bind.annotation.ModelAttribute; + +import java.beans.PropertyDescriptor; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +/** + * Resolves {@link ModelAttribute}-annotated method parameters by expanding a bean + * into request parameters for an HTTP client. + * + *

Behavior: + *

+ * + * @author Hermann Pencole + * @since 7.0 + */ +public class ModelAttributeArgumentResolver extends AbstractNamedValueArgumentResolver { + private final ConversionService conversionService; + + /** + * Constructor for a resolver to a String value. + * @param conversionService the {@link ConversionService} to use to format + * Object to String values + */ + public ModelAttributeArgumentResolver(ConversionService conversionService) { + super(); + this.conversionService = conversionService; + } + + + @Override + protected @Nullable NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + ModelAttribute annot = parameter.getParameterAnnotation(ModelAttribute.class); + if (annot == null) { + return null; + } + return new NamedValueInfo( + annot.name(), false, null, "model attribute", + true); + } + + @Override + protected void addRequestValue(String name, Object argument, MethodParameter parameter, HttpRequestValues.Builder requestValues) { + // Create a map to store custom parameter names + Map customParamNames = new HashMap<>(); + + // Retrieve all @BindParam annotations + Class clazz = argument.getClass(); + for (Field field : clazz.getDeclaredFields()) { + BindParam bindParam = field.getAnnotation(BindParam.class); + if (bindParam != null) { + customParamNames.put(field.getName(), bindParam.value()); + } + } + + // Convert object to query parameters + BeanWrapper wrapper = PropertyAccessorFactory.forBeanPropertyAccess(argument); + for (PropertyDescriptor descriptor : wrapper.getPropertyDescriptors()) { + String propertyName = descriptor.getName(); + if (!"class".equals(propertyName)) { + Object value = wrapper.getPropertyValue(propertyName); + if (value != null) { + // Use a custom name if it exists, otherwise use the property name + String paramName = customParamNames.getOrDefault(propertyName, propertyName); + requestValues.addRequestParameter(paramName, convertSingleToString(value)); + } + } + } + } + + /** + * Convert an arbitrary value to a string using the configured {@link ConversionService} + * when possible, otherwise falls back to {@code toString()}. + */ + private String convertSingleToString(Object value) { + try { + if (this.conversionService.canConvert(value.getClass(), String.class)) { + String converted = this.conversionService.convert(value, String.class); + return converted != null ? converted : ""; + } + } catch (Exception ignore) { + // Fallback to toString below + } + return String.valueOf(value); + } + + + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolverTests.java new file mode 100644 index 000000000000..05ade07f7775 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolverTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2002-present 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.service.invoker; + +import org.junit.jupiter.api.Test; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.BindParam; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.PostExchange; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ModelAttributeArgumentResolver}. + * + *

Additional tests for this resolver: + *

+ * + * @author Hermann Pencole + */ +class ModelAttributeArgumentResolverTests { + + private final TestExchangeAdapter client = new TestExchangeAdapter(); + + private final HttpServiceProxyFactory.Builder builder = HttpServiceProxyFactory.builderFor(this.client); + + + @Test + void requestParam() { + Service service = builder.build().createClient(Service.class); + service.postForm(new MyBean1_2("value 1", true),new MyBean3_4("value 3", List.of("1", "2", "3"))); + + Object body = this.client.getRequestValues().getBodyValue(); + assertThat(body).isInstanceOf(MultiValueMap.class); + assertThat((MultiValueMap) body).hasSize(4) + .containsEntry("param.1", List.of("value 1")) + .containsEntry("param2", List.of("true")) + .containsEntry("param.3", List.of("value 3")) + .containsEntry("param4", List.of("1,2,3")); + } + + @Test + void requestParamWithDisabledFormattingCollectionValue() { + Service service = builder.build().createClient(Service.class); + service.getWithParams(new MyBean1_2("value 1", true),new MyBean3_4("value 3", List.of("1", "2", "3"))); + + HttpRequestValues values = this.client.getRequestValues(); + String uriTemplate = values.getUriTemplate(); + Map uriVariables = values.getUriVariables(); + UriComponents uri = UriComponentsBuilder.fromUriString(uriTemplate).buildAndExpand(uriVariables).encode(); + assertThat(uri.getQuery()).isEqualTo("param.1=value%201¶m2=true¶m.3=value%203¶m4=1,2,3"); + } + + private interface Service { + + @PostExchange(contentType = "application/x-www-form-urlencoded") + void postForm(@ModelAttribute MyBean1_2 param1_2, @ModelAttribute MyBean3_4 param3_4); + + @GetExchange + void getWithParams(@ModelAttribute MyBean1_2 param1_2, @ModelAttribute MyBean3_4 param3_4); + } + + private record MyBean1_2 ( + @BindParam("param.1") String param1, + Boolean param2 + ){} + + private record MyBean3_4 ( + @BindParam("param.3") String param3, + List param4 + ){} + +}