diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/AbstractNamedValueArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/AbstractNamedValueArgumentResolver.java index cf8c7ed6a61..de8ad19ce2a 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/AbstractNamedValueArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/AbstractNamedValueArgumentResolver.java @@ -129,7 +129,7 @@ public abstract class AbstractNamedValueArgumentResolver implements HttpServiceA */ @Nullable protected NamedValueInfo createNamedValueInfo( - MethodParameter parameter, HttpRequestValues.Metadata requestValues) { + MethodParameter parameter, HttpRequestValues.Metadata metadata) { return createNamedValueInfo(parameter); } @@ -246,6 +246,8 @@ public abstract class AbstractNamedValueArgumentResolver implements HttpServiceA * @param required whether it is marked as required * @param defaultValue fallback value, possibly {@link ValueConstants#DEFAULT_NONE} * @param label how it should appear in error messages, e.g. "path variable", "request header" + * @param multiValued whether this argument resolver supports sending multiple values; + * if not, then multiple values are formatted as a String value */ public NamedValueInfo( String name, boolean required, @Nullable String defaultValue, String label, boolean multiValued) { diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestParamArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestParamArgumentResolver.java index 03fdbf12466..019be4aa968 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestParamArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestParamArgumentResolver.java @@ -55,61 +55,79 @@ import org.springframework.web.bind.annotation.RequestParam; */ public class RequestParamArgumentResolver extends AbstractNamedValueArgumentResolver { - private boolean formatAsSingleValue = true; + private boolean favorSingleValue; public RequestParamArgumentResolver(ConversionService conversionService) { super(conversionService); } - public RequestParamArgumentResolver(ConversionService conversionService, boolean formatAsSingleValue) { - super(conversionService); - this.formatAsSingleValue = formatAsSingleValue; - } - - @Override - @Nullable - protected NamedValueInfo createNamedValueInfo(MethodParameter parameter, HttpRequestValues.Metadata requestValues) { - MediaType contentType = requestValues.getContentType(); - if (contentType != null && isMultiValueFormContentType(contentType)) { - this.formatAsSingleValue = true; - } + /** + * Whether to format multiple values (e.g. collection, array) as a single + * String value through the configured {@link ConversionService} unless the + * content type is form data, or it is a multipart request. + *

By default, this is {@code false} in which case formatting is not applied, + * and a separate parameter with the same name is created for each value. + * @since 6.2 + */ + public void setFavorSingleValue(boolean favorSingleValue) { + this.favorSingleValue = favorSingleValue; + } - return createNamedValueInfo(parameter); + /** + * Return the setting for {@link #setFavorSingleValue favorSingleValue}. + * @since 6.2 + */ + public boolean isFavorSingleValue() { + return this.favorSingleValue; } + @Override @Nullable - protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter, HttpRequestValues.Metadata metadata) { RequestParam annot = parameter.getParameterAnnotation(RequestParam.class); if (annot == null) { return null; } - - return (annot == null ? null : - new NamedValueInfo(annot.name(), annot.required(), annot.defaultValue(), - "request parameter", this.formatAsSingleValue)); + return new NamedValueInfo( + annot.name(), annot.required(), annot.defaultValue(), "request parameter", + supportsMultipleValues(parameter, metadata)); } @Override - protected void addRequestValue( - String name, Object value, MethodParameter parameter, HttpRequestValues.Builder requestValues) { - - requestValues.addRequestParameter(name, (String) value); + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + // Shouldn't be called since we override createNamedValueInfo with HttpRequestValues.Metadata + throw new UnsupportedOperationException(); } - protected boolean isFormatAsSingleValue() { - return this.formatAsSingleValue; + /** + * Determine whether the resolver should send multi-value request parameters + * as individual values. If not, they are formatted to a single String value. + * The default implementation uses {@link #isFavorSingleValue()} to decide + * unless the content type is form data, or it is a multipart request. + * @since 6.2 + */ + protected boolean supportsMultipleValues(MethodParameter parameter, HttpRequestValues.Metadata metadata) { + return (!isFavorSingleValue() || isFormOrMultipartContent(metadata)); } - protected void setFormatAsSingleValue(boolean formatAsSingleValue) { - this.formatAsSingleValue = formatAsSingleValue; + /** + * Whether the content type is form data, or it is a multipart request. + * @since 6.2 + */ + protected boolean isFormOrMultipartContent(HttpRequestValues.Metadata metadata) { + MediaType mediaType = metadata.getContentType(); + return (mediaType != null && (mediaType.getType().equals("multipart") || + mediaType.isCompatibleWith(MediaType.APPLICATION_FORM_URLENCODED))); } - protected boolean isMultiValueFormContentType(MediaType contentType) { - return contentType.equals(MediaType.APPLICATION_FORM_URLENCODED) - || contentType.getType().equals("multipart"); + @Override + protected void addRequestValue( + String name, Object value, MethodParameter parameter, HttpRequestValues.Builder requestValues) { + + requestValues.addRequestParameter(name, (String) value); } } diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestParamArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestParamArgumentResolverTests.java index f920f90cbbe..d68d725bdb1 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestParamArgumentResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestParamArgumentResolverTests.java @@ -16,17 +16,18 @@ package org.springframework.web.service.invoker; -import java.util.HashMap; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; -import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.util.MultiValueMap; 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 static org.assertj.core.api.Assertions.assertThat; @@ -62,24 +63,18 @@ class RequestParamArgumentResolverTests { } @Test - @SuppressWarnings("unchecked") void requestParamWithDisabledFormattingCollectionValue() { - ConversionService conversionService = new DefaultConversionService(); - boolean formatAsSingleValue = false; - Service service = builder.customArgumentResolver( - new RequestParamArgumentResolver(conversionService, formatAsSingleValue)) - .build() - .createClient(Service.class); - List collectionParams = List.of("1", "2", "3"); - service.getForm("value 1", collectionParams); - - Object uriVariables = this.client.getRequestValues().getUriVariables(); - assertThat(uriVariables).isNotInstanceOf(MultiValueMap.class).isInstanceOf(HashMap.class); - assertThat((HashMap) uriVariables).hasSize(4) - .containsEntry("queryParam0", "param1") - .containsEntry("queryParam0[0]", "value 1") - .containsEntry("queryParam1", "param2") - .containsEntry("queryParam1[0]", String.join(",", collectionParams)); + RequestParamArgumentResolver resolver = new RequestParamArgumentResolver(new DefaultConversionService()); + resolver.setFavorSingleValue(true); + + Service service = builder.customArgumentResolver(resolver).build().createClient(Service.class); + service.getWithParams("value 1", 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("param1=value%201¶m2=1,2,3"); } private interface Service { @@ -88,7 +83,7 @@ class RequestParamArgumentResolverTests { void postForm(@RequestParam String param1, @RequestParam String param2); @GetExchange - void getForm(@RequestParam String param1, @RequestParam List param2); + void getWithParams(@RequestParam String param1, @RequestParam List param2); } }