Skip to content

Commit 93c7a62

Browse files
author
SFRJ2737
committed
Allow use of form-backing object for client requests
See gh-32142 Signed-off-by: Hermann Pencole <[email protected]>
1 parent 1c64f86 commit 93c7a62

File tree

3 files changed

+223
-0
lines changed

3 files changed

+223
-0
lines changed

spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ private List<HttpServiceArgumentResolver> initArgumentResolvers() {
242242
resolvers.add(new RequestBodyArgumentResolver(this.exchangeAdapter));
243243
resolvers.add(new PathVariableArgumentResolver(service));
244244
resolvers.add(new RequestParamArgumentResolver(service));
245+
resolvers.add(new ModelAttributeArgumentResolver(service));
245246
resolvers.add(new RequestPartArgumentResolver(this.exchangeAdapter));
246247
resolvers.add(new CookieValueArgumentResolver(service));
247248
if (this.exchangeAdapter.supportsRequestAttributes()) {
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright 2002-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.service.invoker;
18+
19+
import org.jspecify.annotations.Nullable;
20+
import org.springframework.beans.BeanWrapper;
21+
import org.springframework.beans.PropertyAccessorFactory;
22+
import org.springframework.core.MethodParameter;
23+
import org.springframework.core.convert.ConversionService;
24+
import org.springframework.util.ObjectUtils;
25+
import org.springframework.web.bind.annotation.BindParam;
26+
import org.springframework.web.bind.annotation.ModelAttribute;
27+
28+
import java.beans.PropertyDescriptor;
29+
import java.lang.reflect.Field;
30+
import java.util.Arrays;
31+
import java.util.Collection;
32+
import java.util.HashMap;
33+
import java.util.Map;
34+
35+
/**
36+
* Resolves {@link ModelAttribute}-annotated method parameters by expanding a bean
37+
* into request parameters for an HTTP client.
38+
*
39+
* <p>Behavior:
40+
* <ul>
41+
* <li>Each readable bean property yields a request parameter named after the property.</li>
42+
* <li>{@link BindParam} can override the parameter name. It is supported on both fields and
43+
* getter methods; if both are present, the getter annotation wins.</li>
44+
* <li>Null property values are skipped.</li>
45+
* <li>Values are converted to strings via the configured {@link ConversionService} when
46+
* possible; otherwise, {@code toString()} is used as a fallback.</li>
47+
* </ul>
48+
*
49+
* @author Hermann Pencole
50+
* @since 7.0
51+
*/
52+
public class ModelAttributeArgumentResolver extends AbstractNamedValueArgumentResolver {
53+
private final ConversionService conversionService;
54+
55+
/**
56+
* Constructor for a resolver to a String value.
57+
* @param conversionService the {@link ConversionService} to use to format
58+
* Object to String values
59+
*/
60+
public ModelAttributeArgumentResolver(ConversionService conversionService) {
61+
super();
62+
this.conversionService = conversionService;
63+
}
64+
65+
66+
@Override
67+
protected @Nullable NamedValueInfo createNamedValueInfo(MethodParameter parameter) {
68+
ModelAttribute annot = parameter.getParameterAnnotation(ModelAttribute.class);
69+
if (annot == null) {
70+
return null;
71+
}
72+
return new NamedValueInfo(
73+
annot.name(), false, null, "model attribute",
74+
true);
75+
}
76+
77+
@Override
78+
protected void addRequestValue(String name, Object argument, MethodParameter parameter, HttpRequestValues.Builder requestValues) {
79+
// Create a map to store custom parameter names
80+
Map<String, String> customParamNames = new HashMap<>();
81+
82+
// Retrieve all @BindParam annotations
83+
Class<?> clazz = argument.getClass();
84+
for (Field field : clazz.getDeclaredFields()) {
85+
BindParam bindParam = field.getAnnotation(BindParam.class);
86+
if (bindParam != null) {
87+
customParamNames.put(field.getName(), bindParam.value());
88+
}
89+
}
90+
91+
// Convert object to query parameters
92+
BeanWrapper wrapper = PropertyAccessorFactory.forBeanPropertyAccess(argument);
93+
for (PropertyDescriptor descriptor : wrapper.getPropertyDescriptors()) {
94+
String propertyName = descriptor.getName();
95+
if (!"class".equals(propertyName)) {
96+
Object value = wrapper.getPropertyValue(propertyName);
97+
if (value != null) {
98+
// Use a custom name if it exists, otherwise use the property name
99+
String paramName = customParamNames.getOrDefault(propertyName, propertyName);
100+
requestValues.addRequestParameter(paramName, convertSingleToString(value));
101+
}
102+
}
103+
}
104+
}
105+
106+
/**
107+
* Convert an arbitrary value to a string using the configured {@link ConversionService}
108+
* when possible, otherwise falls back to {@code toString()}.
109+
*/
110+
private String convertSingleToString(Object value) {
111+
try {
112+
if (this.conversionService.canConvert(value.getClass(), String.class)) {
113+
String converted = this.conversionService.convert(value, String.class);
114+
return converted != null ? converted : "";
115+
}
116+
} catch (Exception ignore) {
117+
// Fallback to toString below
118+
}
119+
return String.valueOf(value);
120+
}
121+
122+
123+
124+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright 2002-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.service.invoker;
18+
19+
import org.junit.jupiter.api.Test;
20+
import org.springframework.core.convert.support.DefaultConversionService;
21+
import org.springframework.util.MultiValueMap;
22+
import org.springframework.web.bind.annotation.BindParam;
23+
import org.springframework.web.bind.annotation.ModelAttribute;
24+
import org.springframework.web.bind.annotation.RequestParam;
25+
import org.springframework.web.service.annotation.GetExchange;
26+
import org.springframework.web.service.annotation.PostExchange;
27+
import org.springframework.web.util.UriComponents;
28+
import org.springframework.web.util.UriComponentsBuilder;
29+
30+
import java.util.List;
31+
import java.util.Map;
32+
33+
import static org.assertj.core.api.Assertions.assertThat;
34+
35+
/**
36+
* Tests for {@link ModelAttributeArgumentResolver}.
37+
*
38+
* <p>Additional tests for this resolver:
39+
* <ul>
40+
* <li>Base class functionality in {@link NamedValueArgumentResolverTests}
41+
* <li>Form data vs query params in {@link HttpRequestValuesTests}
42+
* </ul>
43+
*
44+
* @author Hermann Pencole
45+
*/
46+
class ModelAttributeArgumentResolverTests {
47+
48+
private final TestExchangeAdapter client = new TestExchangeAdapter();
49+
50+
private final HttpServiceProxyFactory.Builder builder = HttpServiceProxyFactory.builderFor(this.client);
51+
52+
53+
@Test
54+
void requestParam() {
55+
Service service = builder.build().createClient(Service.class);
56+
service.postForm(new MyBean1_2("value 1", true),new MyBean3_4("value 3", List.of("1", "2", "3")));
57+
58+
Object body = this.client.getRequestValues().getBodyValue();
59+
assertThat(body).isInstanceOf(MultiValueMap.class);
60+
assertThat((MultiValueMap<String, String>) body).hasSize(4)
61+
.containsEntry("param.1", List.of("value 1"))
62+
.containsEntry("param2", List.of("true"))
63+
.containsEntry("param.3", List.of("value 3"))
64+
.containsEntry("param4", List.of("1,2,3"));
65+
}
66+
67+
@Test
68+
void requestParamWithDisabledFormattingCollectionValue() {
69+
Service service = builder.build().createClient(Service.class);
70+
service.getWithParams(new MyBean1_2("value 1", true),new MyBean3_4("value 3", List.of("1", "2", "3")));
71+
72+
HttpRequestValues values = this.client.getRequestValues();
73+
String uriTemplate = values.getUriTemplate();
74+
Map<String, String> uriVariables = values.getUriVariables();
75+
UriComponents uri = UriComponentsBuilder.fromUriString(uriTemplate).buildAndExpand(uriVariables).encode();
76+
assertThat(uri.getQuery()).isEqualTo("param.1=value%201&param2=true&param.3=value%203&param4=1,2,3");
77+
}
78+
79+
private interface Service {
80+
81+
@PostExchange(contentType = "application/x-www-form-urlencoded")
82+
void postForm(@ModelAttribute MyBean1_2 param1_2, @ModelAttribute MyBean3_4 param3_4);
83+
84+
@GetExchange
85+
void getWithParams(@ModelAttribute MyBean1_2 param1_2, @ModelAttribute MyBean3_4 param3_4);
86+
}
87+
88+
private record MyBean1_2 (
89+
@BindParam("param.1") String param1,
90+
Boolean param2
91+
){}
92+
93+
private record MyBean3_4 (
94+
@BindParam("param.3") String param3,
95+
List<String> param4
96+
){}
97+
98+
}

0 commit comments

Comments
 (0)