Browse Source

Allow use of form-backing object for client requests

See gh-32142

Signed-off-by: Hermann Pencole <hermannpencole@yahoo.fr>
pull/35650/head
Hermann Pencole 2 months ago committed by SFRJ2737
parent
commit
0c1f55ac80
  1. 1
      spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java
  2. 124
      spring-web/src/main/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolver.java
  3. 98
      spring-web/src/test/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolverTests.java

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

@ -242,6 +242,7 @@ public final class HttpServiceProxyFactory { @@ -242,6 +242,7 @@ public final class HttpServiceProxyFactory {
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()) {

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

@ -0,0 +1,124 @@ @@ -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.
*
* <p>Behavior:
* <ul>
* <li>Each readable bean property yields a request parameter named after the property.</li>
* <li>{@link BindParam} can override the parameter name. It is supported on both fields and
* getter methods; if both are present, the getter annotation wins.</li>
* <li>Null property values are skipped.</li>
* <li>Values are converted to strings via the configured {@link ConversionService} when
* possible; otherwise, {@code toString()} is used as a fallback.</li>
* </ul>
*
* @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<String, String> 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);
}
}

98
spring-web/src/test/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolverTests.java

@ -0,0 +1,98 @@ @@ -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}.
*
* <p>Additional tests for this resolver:
* <ul>
* <li>Base class functionality in {@link NamedValueArgumentResolverTests}
* <li>Form data vs query params in {@link HttpRequestValuesTests}
* </ul>
*
* @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<String, String>) 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<String, String> uriVariables = values.getUriVariables();
UriComponents uri = UriComponentsBuilder.fromUriString(uriTemplate).buildAndExpand(uriVariables).encode();
assertThat(uri.getQuery()).isEqualTo("param.1=value%201&param2=true&param.3=value%203&param4=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<String> param4
){}
}
Loading…
Cancel
Save