diff --git a/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java b/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java index 67c4ecca906..126c1e88aad 100644 --- a/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java +++ b/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -85,7 +85,8 @@ public final class RestClientAdapter implements HttpExchangeAdapter { return newRequest(values).retrieve().toEntity(bodyType); } - private RestClient.RequestBodySpec newRequest(HttpRequestValues values) { + @SuppressWarnings("unchecked") + private RestClient.RequestBodySpec newRequest(HttpRequestValues values) { HttpMethod httpMethod = values.getHttpMethod(); Assert.notNull(httpMethod, "HttpMethod is required"); @@ -123,8 +124,14 @@ public final class RestClientAdapter implements HttpExchangeAdapter { bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes())); - if (values.getBodyValue() != null) { - bodySpec.body(values.getBodyValue()); + B body = (B) values.getBodyValue(); + if (body != null) { + if (values.getBodyValueType() != null) { + bodySpec.body(body, (ParameterizedTypeReference) values.getBodyValueType()); + } + else { + bodySpec.body(body); + } } return bodySpec; diff --git a/spring-web/src/main/java/org/springframework/web/client/support/RestTemplateAdapter.java b/spring-web/src/main/java/org/springframework/web/client/support/RestTemplateAdapter.java index 364caa6be32..40a1a6f7a5c 100644 --- a/spring-web/src/main/java/org/springframework/web/client/support/RestTemplateAdapter.java +++ b/spring-web/src/main/java/org/springframework/web/client/support/RestTemplateAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 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. @@ -86,7 +86,7 @@ public final class RestTemplateAdapter implements HttpExchangeAdapter { return this.restTemplate.exchange(newRequest(values), bodyType); } - private RequestEntity newRequest(HttpRequestValues values) { + private RequestEntity newRequest(HttpRequestValues values) { HttpMethod httpMethod = values.getHttpMethod(); Assert.notNull(httpMethod, "HttpMethod is required"); @@ -120,11 +120,16 @@ public final class RestTemplateAdapter implements HttpExchangeAdapter { builder.header(HttpHeaders.COOKIE, String.join("; ", cookies)); } - if (values.getBodyValue() != null) { - return builder.body(values.getBodyValue()); + Object body = values.getBodyValue(); + if (body == null) { + return builder.build(); } - return builder.build(); + if (values.getBodyValueType() != null) { + return builder.body(body, values.getBodyValueType().getType()); + } + + return builder.body(body); } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java index dfed3ab366c..10bcb69b6be 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java @@ -23,6 +23,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -75,6 +76,9 @@ public class HttpRequestValues { @Nullable private final Object bodyValue; + @Nullable + private ParameterizedTypeReference bodyValueType; + /** * Construct {@link HttpRequestValues}. @@ -177,6 +181,15 @@ public class HttpRequestValues { return this.bodyValue; } + /** + * Return the type for the {@linkplain #getBodyValue() body value}. + * @since 6.2.7 + */ + @Nullable + public ParameterizedTypeReference getBodyValueType() { + return this.bodyValueType; + } + public static Builder builder() { return new Builder(); @@ -253,6 +266,9 @@ public class HttpRequestValues { @Nullable private Object bodyValue; + @Nullable + private ParameterizedTypeReference bodyValueType; + /** * Set the HTTP method for the request. */ @@ -389,6 +405,15 @@ public class HttpRequestValues { this.bodyValue = bodyValue; } + /** + * Variant of {@link #setBodyValue(Object)} with the body type. + * @since 6.2.7 + */ + public void setBodyValue(@Nullable Object bodyValue, @Nullable ParameterizedTypeReference valueType) { + setBodyValue(bodyValue); + this.bodyValueType = valueType; + } + // Implementation of {@link Metadata} methods @@ -465,9 +490,14 @@ public class HttpRequestValues { Map attributes = (this.attributes != null ? new HashMap<>(this.attributes) : Collections.emptyMap()); - return createRequestValues( + HttpRequestValues requestValues = createRequestValues( this.httpMethod, uri, uriBuilderFactory, uriTemplate, uriVars, headers, cookies, attributes, bodyValue); + + // In 6.2.x only, temporarily work around protected methods + requestValues.bodyValueType = this.bodyValueType; + + return requestValues; } protected boolean hasParts() { diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestBodyArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestBodyArgumentResolver.java index a84ef532acd..2aa8d5f5892 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestBodyArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestBodyArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -83,15 +83,16 @@ public class RequestBodyArgumentResolver implements HttpServiceArgumentResolver if (this.reactiveAdapterRegistry != null) { ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(parameter.getParameterType()); if (adapter != null) { - MethodParameter nestedParameter = parameter.nested(); + MethodParameter nestedParam = parameter.nested(); String message = "Async type for @RequestBody should produce value(s)"; Assert.isTrue(!adapter.isNoValue(), message); - Assert.isTrue(nestedParameter.getNestedParameterType() != Void.class, message); + Assert.isTrue(nestedParam.getNestedParameterType() != Void.class, message); - if (requestValues instanceof ReactiveHttpRequestValues.Builder reactiveRequestValues) { - reactiveRequestValues.setBodyPublisher( - adapter.toPublisher(argument), asParameterizedTypeRef(nestedParameter)); + if (requestValues instanceof ReactiveHttpRequestValues.Builder rrv) { + rrv.setBodyPublisher( + adapter.toPublisher(argument), + ParameterizedTypeReference.forType(nestedParam.getNestedGenericParameterType())); } else { throw new IllegalStateException( @@ -103,12 +104,8 @@ public class RequestBodyArgumentResolver implements HttpServiceArgumentResolver } // Not a reactive type - requestValues.setBodyValue(argument); + requestValues.setBodyValue(argument, ParameterizedTypeReference.forType(parameter.getGenericParameterType())); return true; } - private static ParameterizedTypeReference asParameterizedTypeRef(MethodParameter nestedParam) { - return ParameterizedTypeReference.forType(nestedParam.getNestedGenericParameterType()); - } - } diff --git a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java index 2f5e87f985c..156404475a4 100644 --- a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java @@ -22,7 +22,9 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.net.URI; +import java.util.LinkedHashSet; import java.util.Optional; +import java.util.Set; import java.util.function.BiFunction; import java.util.stream.Stream; @@ -267,6 +269,19 @@ class RestClientAdapterTests { assertThat(this.anotherServer.getRequestCount()).isEqualTo(0); } + @ParameterizedAdapterTest // gh-34793 + void postSet(MockWebServer server, Service service) throws InterruptedException { + Set persons = new LinkedHashSet<>(); + persons.add(new Person("John")); + persons.add(new Person("Richard")); + service.postPersonSet(persons); + + RecordedRequest request = server.takeRequest(); + assertThat(request.getMethod()).isEqualTo("POST"); + assertThat(request.getPath()).isEqualTo("/persons"); + assertThat(request.getBody().readUtf8()).isEqualTo("[{\"name\":\"John\"},{\"name\":\"Richard\"}]"); + } + private static MockWebServer anotherServer() { MockWebServer server = new MockWebServer(); @@ -297,6 +312,9 @@ class RestClientAdapterTests { @PostExchange void postMultipart(MultipartFile file, @RequestPart String anotherPart); + @PostExchange(url = "/persons", contentType = MediaType.APPLICATION_JSON_VALUE) + void postPersonSet(@RequestBody Set set); + @PutExchange void putWithCookies(@CookieValue String firstCookie, @CookieValue String secondCookie); @@ -315,4 +333,19 @@ class RestClientAdapterTests { ResponseEntity getWithIgnoredUriBuilderFactory(URI uri, UriBuilderFactory uriBuilderFactory); } + + static final class Person { + + private final String name; + + Person(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + } + } diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestBodyArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestBodyArgumentResolverTests.java index 3b82d00164a..bab548d13d4 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestBodyArgumentResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestBodyArgumentResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -54,6 +54,7 @@ class RequestBodyArgumentResolverTests { this.service.execute(body); assertThat(getBodyValue()).isEqualTo(body); + assertThat(getBodyValueType()).isEqualTo(new ParameterizedTypeReference() {}); assertThat(getPublisherBody()).isNull(); } @@ -173,6 +174,11 @@ class RequestBodyArgumentResolverTests { return getReactiveRequestValues().getBodyValue(); } + @Nullable + private ParameterizedTypeReference getBodyValueType() { + return getReactiveRequestValues().getBodyValueType(); + } + @Nullable private Publisher getPublisherBody() { return getReactiveRequestValues().getBodyPublisher(); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java index 03e85490baf..9f947358f71 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -98,8 +98,8 @@ public final class WebClientAdapter extends AbstractReactorHttpExchangeAdapter { return newRequest(requestValues).retrieve().toEntityFlux(bodyType); } - @SuppressWarnings("ReactiveStreamsUnusedPublisher") - private WebClient.RequestBodySpec newRequest(HttpRequestValues values) { + @SuppressWarnings({"ReactiveStreamsUnusedPublisher", "unchecked"}) + private WebClient.RequestBodySpec newRequest(HttpRequestValues values) { HttpMethod httpMethod = values.getHttpMethod(); Assert.notNull(httpMethod, "HttpMethod is required"); @@ -130,12 +130,18 @@ public final class WebClientAdapter extends AbstractReactorHttpExchangeAdapter { bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes())); if (values.getBodyValue() != null) { - bodySpec.bodyValue(values.getBodyValue()); + if (values.getBodyValueType() != null) { + B body = (B) values.getBodyValue(); + bodySpec.bodyValue(body, (ParameterizedTypeReference) values.getBodyValueType()); + } + else { + bodySpec.bodyValue(values.getBodyValue()); + } } - else if (values instanceof ReactiveHttpRequestValues reactiveRequestValues) { - Publisher body = reactiveRequestValues.getBodyPublisher(); + else if (values instanceof ReactiveHttpRequestValues rhrv) { + Publisher body = rhrv.getBodyPublisher(); if (body != null) { - ParameterizedTypeReference elementType = reactiveRequestValues.getBodyPublisherElementType(); + ParameterizedTypeReference elementType = rhrv.getBodyPublisherElementType(); Assert.notNull(elementType, "Publisher body element type is required"); bodySpec.body(body, elementType); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java index 5ee065af5ab..82120cb3b5a 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -21,7 +21,9 @@ import java.io.IOException; import java.net.URI; import java.time.Duration; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Set; import java.util.function.Consumer; import okhttp3.mockwebserver.MockResponse; @@ -39,6 +41,7 @@ import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; @@ -168,6 +171,22 @@ class WebClientAdapterTests { "Content-Type: text/plain;charset=UTF-8", "Content-Length: 5", "test2"); } + @Test // gh-34793 + void postSet() throws InterruptedException { + prepareResponse(response -> response.setResponseCode(201)); + + Set persons = new LinkedHashSet<>(); + persons.add(new Person("John")); + persons.add(new Person("Richard")); + + initService().postPersonSet(persons); + + RecordedRequest request = server.takeRequest(); + assertThat(request.getMethod()).isEqualTo("POST"); + assertThat(request.getPath()).isEqualTo("/persons"); + assertThat(request.getBody().readUtf8()).isEqualTo("[{\"name\":\"John\"},{\"name\":\"Richard\"}]"); + } + @Test void uriBuilderFactory() throws Exception { String ignoredResponseBody = "hello"; @@ -251,6 +270,9 @@ class WebClientAdapterTests { @PostExchange void postMultipart(MultipartFile file, @RequestPart String anotherPart); + @PostExchange("/persons") + void postPersonSet(@RequestBody Set set); + @GetExchange("/greeting") String getWithUriBuilderFactory(UriBuilderFactory uriBuilderFactory); @@ -263,4 +285,19 @@ class WebClientAdapterTests { } + + static final class Person { + + private final String name; + + Person(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + } + }