3 changed files with 223 additions and 0 deletions
@ -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); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
} |
||||||
@ -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¶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<String> param4 |
||||||
|
){} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue