Skip to content

Commit c48ff35

Browse files
committed
HTTP Service proxy sets body type
Closes gh-34793
1 parent 190dabb commit c48ff35

File tree

8 files changed

+151
-30
lines changed

8 files changed

+151
-30
lines changed

spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java

+11-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -85,7 +85,8 @@ public <T> ResponseEntity<T> exchangeForEntity(HttpRequestValues values, Paramet
8585
return newRequest(values).retrieve().toEntity(bodyType);
8686
}
8787

88-
private RestClient.RequestBodySpec newRequest(HttpRequestValues values) {
88+
@SuppressWarnings("unchecked")
89+
private <B> RestClient.RequestBodySpec newRequest(HttpRequestValues values) {
8990

9091
HttpMethod httpMethod = values.getHttpMethod();
9192
Assert.notNull(httpMethod, "HttpMethod is required");
@@ -123,8 +124,14 @@ else if (values.getUriTemplate() != null) {
123124

124125
bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes()));
125126

126-
if (values.getBodyValue() != null) {
127-
bodySpec.body(values.getBodyValue());
127+
B body = (B) values.getBodyValue();
128+
if (body != null) {
129+
if (values.getBodyValueType() != null) {
130+
bodySpec.body(body, (ParameterizedTypeReference<? super B>) values.getBodyValueType());
131+
}
132+
else {
133+
bodySpec.body(body);
134+
}
128135
}
129136

130137
return bodySpec;

spring-web/src/main/java/org/springframework/web/client/support/RestTemplateAdapter.java

+10-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2023 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -86,7 +86,7 @@ public <T> ResponseEntity<T> exchangeForEntity(HttpRequestValues values, Paramet
8686
return this.restTemplate.exchange(newRequest(values), bodyType);
8787
}
8888

89-
private RequestEntity<?> newRequest(HttpRequestValues values) {
89+
private <B> RequestEntity<?> newRequest(HttpRequestValues values) {
9090
HttpMethod httpMethod = values.getHttpMethod();
9191
Assert.notNull(httpMethod, "HttpMethod is required");
9292

@@ -120,11 +120,16 @@ else if (values.getUriTemplate() != null) {
120120
builder.header(HttpHeaders.COOKIE, String.join("; ", cookies));
121121
}
122122

123-
if (values.getBodyValue() != null) {
124-
return builder.body(values.getBodyValue());
123+
Object body = values.getBodyValue();
124+
if (body == null) {
125+
return builder.build();
125126
}
126127

127-
return builder.build();
128+
if (values.getBodyValueType() != null) {
129+
return builder.body(body, values.getBodyValueType().getType());
130+
}
131+
132+
return builder.body(body);
128133
}
129134

130135

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

+31-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.List;
2424
import java.util.Map;
2525

26+
import org.springframework.core.ParameterizedTypeReference;
2627
import org.springframework.http.HttpEntity;
2728
import org.springframework.http.HttpHeaders;
2829
import org.springframework.http.HttpMethod;
@@ -75,6 +76,9 @@ public class HttpRequestValues {
7576
@Nullable
7677
private final Object bodyValue;
7778

79+
@Nullable
80+
private ParameterizedTypeReference<?> bodyValueType;
81+
7882

7983
/**
8084
* Construct {@link HttpRequestValues}.
@@ -177,6 +181,15 @@ public Object getBodyValue() {
177181
return this.bodyValue;
178182
}
179183

184+
/**
185+
* Return the type for the {@linkplain #getBodyValue() body value}.
186+
* @since 6.2.7
187+
*/
188+
@Nullable
189+
public ParameterizedTypeReference<?> getBodyValueType() {
190+
return this.bodyValueType;
191+
}
192+
180193

181194
public static Builder builder() {
182195
return new Builder();
@@ -253,6 +266,9 @@ public static class Builder implements Metadata {
253266
@Nullable
254267
private Object bodyValue;
255268

269+
@Nullable
270+
private ParameterizedTypeReference<?> bodyValueType;
271+
256272
/**
257273
* Set the HTTP method for the request.
258274
*/
@@ -389,6 +405,15 @@ public void setBodyValue(@Nullable Object bodyValue) {
389405
this.bodyValue = bodyValue;
390406
}
391407

408+
/**
409+
* Variant of {@link #setBodyValue(Object)} with the body type.
410+
* @since 6.2.7
411+
*/
412+
public void setBodyValue(@Nullable Object bodyValue, @Nullable ParameterizedTypeReference<?> valueType) {
413+
setBodyValue(bodyValue);
414+
this.bodyValueType = valueType;
415+
}
416+
392417

393418
// Implementation of {@link Metadata} methods
394419

@@ -465,9 +490,14 @@ else if (uri != null) {
465490
Map<String, Object> attributes = (this.attributes != null ?
466491
new HashMap<>(this.attributes) : Collections.emptyMap());
467492

468-
return createRequestValues(
493+
HttpRequestValues requestValues = createRequestValues(
469494
this.httpMethod, uri, uriBuilderFactory, uriTemplate, uriVars,
470495
headers, cookies, attributes, bodyValue);
496+
497+
// In 6.2.x only, temporarily work around protected methods
498+
requestValues.bodyValueType = this.bodyValueType;
499+
500+
return requestValues;
471501
}
472502

473503
protected boolean hasParts() {

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

+8-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -83,15 +83,16 @@ public boolean resolve(
8383
if (this.reactiveAdapterRegistry != null) {
8484
ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(parameter.getParameterType());
8585
if (adapter != null) {
86-
MethodParameter nestedParameter = parameter.nested();
86+
MethodParameter nestedParam = parameter.nested();
8787

8888
String message = "Async type for @RequestBody should produce value(s)";
8989
Assert.isTrue(!adapter.isNoValue(), message);
90-
Assert.isTrue(nestedParameter.getNestedParameterType() != Void.class, message);
90+
Assert.isTrue(nestedParam.getNestedParameterType() != Void.class, message);
9191

92-
if (requestValues instanceof ReactiveHttpRequestValues.Builder reactiveRequestValues) {
93-
reactiveRequestValues.setBodyPublisher(
94-
adapter.toPublisher(argument), asParameterizedTypeRef(nestedParameter));
92+
if (requestValues instanceof ReactiveHttpRequestValues.Builder rrv) {
93+
rrv.setBodyPublisher(
94+
adapter.toPublisher(argument),
95+
ParameterizedTypeReference.forType(nestedParam.getNestedGenericParameterType()));
9596
}
9697
else {
9798
throw new IllegalStateException(
@@ -103,12 +104,8 @@ public boolean resolve(
103104
}
104105

105106
// Not a reactive type
106-
requestValues.setBodyValue(argument);
107+
requestValues.setBodyValue(argument, ParameterizedTypeReference.forType(parameter.getGenericParameterType()));
107108
return true;
108109
}
109110

110-
private static ParameterizedTypeReference<Object> asParameterizedTypeRef(MethodParameter nestedParam) {
111-
return ParameterizedTypeReference.forType(nestedParam.getNestedGenericParameterType());
112-
}
113-
114111
}

spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java

+33
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
import java.lang.annotation.RetentionPolicy;
2323
import java.lang.annotation.Target;
2424
import java.net.URI;
25+
import java.util.LinkedHashSet;
2526
import java.util.Optional;
27+
import java.util.Set;
2628
import java.util.function.BiFunction;
2729
import java.util.stream.Stream;
2830

@@ -267,6 +269,19 @@ void getWithIgnoredUriBuilderFactory(MockWebServer server, Service service) thro
267269
assertThat(this.anotherServer.getRequestCount()).isEqualTo(0);
268270
}
269271

272+
@ParameterizedAdapterTest // gh-34793
273+
void postSet(MockWebServer server, Service service) throws InterruptedException {
274+
Set<Person> persons = new LinkedHashSet<>();
275+
persons.add(new Person("John"));
276+
persons.add(new Person("Richard"));
277+
service.postPersonSet(persons);
278+
279+
RecordedRequest request = server.takeRequest();
280+
assertThat(request.getMethod()).isEqualTo("POST");
281+
assertThat(request.getPath()).isEqualTo("/persons");
282+
assertThat(request.getBody().readUtf8()).isEqualTo("[{\"name\":\"John\"},{\"name\":\"Richard\"}]");
283+
}
284+
270285

271286
private static MockWebServer anotherServer() {
272287
MockWebServer server = new MockWebServer();
@@ -297,6 +312,9 @@ private interface Service {
297312
@PostExchange
298313
void postMultipart(MultipartFile file, @RequestPart String anotherPart);
299314

315+
@PostExchange(url = "/persons", contentType = MediaType.APPLICATION_JSON_VALUE)
316+
void postPersonSet(@RequestBody Set<Person> set);
317+
300318
@PutExchange
301319
void putWithCookies(@CookieValue String firstCookie, @CookieValue String secondCookie);
302320

@@ -315,4 +333,19 @@ ResponseEntity<String> getWithUriBuilderFactory(UriBuilderFactory uriBuilderFact
315333
ResponseEntity<String> getWithIgnoredUriBuilderFactory(URI uri, UriBuilderFactory uriBuilderFactory);
316334
}
317335

336+
337+
static final class Person {
338+
339+
private final String name;
340+
341+
Person(String name) {
342+
this.name = name;
343+
}
344+
345+
public String getName() {
346+
return this.name;
347+
}
348+
349+
}
350+
318351
}

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

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -54,6 +54,7 @@ void stringBody() {
5454
this.service.execute(body);
5555

5656
assertThat(getBodyValue()).isEqualTo(body);
57+
assertThat(getBodyValueType()).isEqualTo(new ParameterizedTypeReference<String>() {});
5758
assertThat(getPublisherBody()).isNull();
5859
}
5960

@@ -173,6 +174,11 @@ private Object getBodyValue() {
173174
return getReactiveRequestValues().getBodyValue();
174175
}
175176

177+
@Nullable
178+
private ParameterizedTypeReference<?> getBodyValueType() {
179+
return getReactiveRequestValues().getBodyValueType();
180+
}
181+
176182
@Nullable
177183
private Publisher<?> getPublisherBody() {
178184
return getReactiveRequestValues().getBodyPublisher();

spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java

+13-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -98,8 +98,8 @@ public <T> Mono<ResponseEntity<Flux<T>>> exchangeForEntityFlux(HttpRequestValues
9898
return newRequest(requestValues).retrieve().toEntityFlux(bodyType);
9999
}
100100

101-
@SuppressWarnings("ReactiveStreamsUnusedPublisher")
102-
private WebClient.RequestBodySpec newRequest(HttpRequestValues values) {
101+
@SuppressWarnings({"ReactiveStreamsUnusedPublisher", "unchecked"})
102+
private <B> WebClient.RequestBodySpec newRequest(HttpRequestValues values) {
103103

104104
HttpMethod httpMethod = values.getHttpMethod();
105105
Assert.notNull(httpMethod, "HttpMethod is required");
@@ -130,12 +130,18 @@ else if (values.getUriTemplate() != null) {
130130
bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes()));
131131

132132
if (values.getBodyValue() != null) {
133-
bodySpec.bodyValue(values.getBodyValue());
133+
if (values.getBodyValueType() != null) {
134+
B body = (B) values.getBodyValue();
135+
bodySpec.bodyValue(body, (ParameterizedTypeReference<B>) values.getBodyValueType());
136+
}
137+
else {
138+
bodySpec.bodyValue(values.getBodyValue());
139+
}
134140
}
135-
else if (values instanceof ReactiveHttpRequestValues reactiveRequestValues) {
136-
Publisher<?> body = reactiveRequestValues.getBodyPublisher();
141+
else if (values instanceof ReactiveHttpRequestValues rhrv) {
142+
Publisher<?> body = rhrv.getBodyPublisher();
137143
if (body != null) {
138-
ParameterizedTypeReference<?> elementType = reactiveRequestValues.getBodyPublisherElementType();
144+
ParameterizedTypeReference<?> elementType = rhrv.getBodyPublisherElementType();
139145
Assert.notNull(elementType, "Publisher body element type is required");
140146
bodySpec.body(body, elementType);
141147
}

spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java

+38-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -21,7 +21,9 @@
2121
import java.net.URI;
2222
import java.time.Duration;
2323
import java.util.HashMap;
24+
import java.util.LinkedHashSet;
2425
import java.util.Map;
26+
import java.util.Set;
2527
import java.util.function.Consumer;
2628

2729
import okhttp3.mockwebserver.MockResponse;
@@ -39,6 +41,7 @@
3941
import org.springframework.util.MultiValueMap;
4042
import org.springframework.web.bind.annotation.PathVariable;
4143
import org.springframework.web.bind.annotation.RequestAttribute;
44+
import org.springframework.web.bind.annotation.RequestBody;
4245
import org.springframework.web.bind.annotation.RequestParam;
4346
import org.springframework.web.bind.annotation.RequestPart;
4447
import org.springframework.web.multipart.MultipartFile;
@@ -168,6 +171,22 @@ void multipart() throws InterruptedException {
168171
"Content-Type: text/plain;charset=UTF-8", "Content-Length: 5", "test2");
169172
}
170173

174+
@Test // gh-34793
175+
void postSet() throws InterruptedException {
176+
prepareResponse(response -> response.setResponseCode(201));
177+
178+
Set<Person> persons = new LinkedHashSet<>();
179+
persons.add(new Person("John"));
180+
persons.add(new Person("Richard"));
181+
182+
initService().postPersonSet(persons);
183+
184+
RecordedRequest request = server.takeRequest();
185+
assertThat(request.getMethod()).isEqualTo("POST");
186+
assertThat(request.getPath()).isEqualTo("/persons");
187+
assertThat(request.getBody().readUtf8()).isEqualTo("[{\"name\":\"John\"},{\"name\":\"Richard\"}]");
188+
}
189+
171190
@Test
172191
void uriBuilderFactory() throws Exception {
173192
String ignoredResponseBody = "hello";
@@ -251,6 +270,9 @@ private interface Service {
251270
@PostExchange
252271
void postMultipart(MultipartFile file, @RequestPart String anotherPart);
253272

273+
@PostExchange("/persons")
274+
void postPersonSet(@RequestBody Set<Person> set);
275+
254276
@GetExchange("/greeting")
255277
String getWithUriBuilderFactory(UriBuilderFactory uriBuilderFactory);
256278

@@ -263,4 +285,19 @@ String getWithUriBuilderFactory(UriBuilderFactory uriBuilderFactory,
263285

264286
}
265287

288+
289+
static final class Person {
290+
291+
private final String name;
292+
293+
Person(String name) {
294+
this.name = name;
295+
}
296+
297+
public String getName() {
298+
return this.name;
299+
}
300+
301+
}
302+
266303
}

0 commit comments

Comments
 (0)