Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ private List<HttpServiceArgumentResolver> 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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}



}
Original file line number Diff line number Diff line change
@@ -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
){}

}