Skip to content

Commit 9f7a321

Browse files
committed
Support streaming with HTTP interfaces + RestClient
Closes gh-32358
1 parent 02af9e5 commit 9f7a321

File tree

4 files changed

+68
-1
lines changed

4 files changed

+68
-1
lines changed

framework-docs/modules/ROOT/pages/integration/rest-clients.adoc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -999,6 +999,10 @@ Method parameters cannot be `null` unless the `required` attribute (where availa
999999
parameter annotation) is set to `false`, or the parameter is marked optional as determined by
10001000
{spring-framework-api}/core/MethodParameter.html#isOptional()[`MethodParameter#isOptional`].
10011001

1002+
`RestClientAdapter` provides additional support for a method parameter of type
1003+
`StreamingHttpOutputMessage.Body` that allows sending the request body by writing to an
1004+
`OutputStream`.
1005+
10021006

10031007

10041008
[[rest-http-interface.custom-resolver]]
@@ -1094,6 +1098,11 @@ depends on how the underlying HTTP client is configured. You can set a `blockTim
10941098
value on the adapter level as well, but we recommend relying on timeout settings of the
10951099
underlying HTTP client, which operates at a lower level and provides more control.
10961100

1101+
`RestClientAdapter` provides supports additional support for a return value of type
1102+
`InputStream` or `ResponseEntity<InputStream>` that provides access to the raw response
1103+
body content.
1104+
1105+
10971106

10981107
[[rest-http-interface-exceptions]]
10991108
=== Error Handling

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.web.client.support;
1818

19+
import java.io.InputStream;
1920
import java.net.URI;
2021
import java.util.ArrayList;
2122
import java.util.List;
@@ -27,6 +28,7 @@
2728
import org.springframework.http.HttpHeaders;
2829
import org.springframework.http.HttpMethod;
2930
import org.springframework.http.ResponseEntity;
31+
import org.springframework.http.StreamingHttpOutputMessage;
3032
import org.springframework.util.Assert;
3133
import org.springframework.web.client.RestClient;
3234
import org.springframework.web.service.invoker.HttpExchangeAdapter;
@@ -70,8 +72,12 @@ public HttpHeaders exchangeForHeaders(HttpRequestValues values) {
7072
return newRequest(values).retrieve().toBodilessEntity().getHeaders();
7173
}
7274

75+
@SuppressWarnings("unchecked")
7376
@Override
7477
public <T> @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
78+
if (bodyType.getType().equals(InputStream.class)) {
79+
return (T) newRequest(values).exchange((request, response) -> response.getBody(), false);
80+
}
7581
return newRequest(values).retrieve().body(bodyType);
7682
}
7783

@@ -80,8 +86,15 @@ public ResponseEntity<Void> exchangeForBodilessEntity(HttpRequestValues values)
8086
return newRequest(values).retrieve().toBodilessEntity();
8187
}
8288

89+
@SuppressWarnings("unchecked")
8390
@Override
8491
public <T> ResponseEntity<T> exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
92+
if (bodyType.getType().equals(InputStream.class)) {
93+
return (ResponseEntity<T>) newRequest(values).exchangeForRequiredValue((request, response) ->
94+
ResponseEntity.status(response.getStatusCode())
95+
.headers(response.getHeaders())
96+
.body(response.getBody()), false);
97+
}
8598
return newRequest(values).retrieve().toEntity(bodyType);
8699
}
87100

@@ -130,7 +143,10 @@ else if (values.getUriTemplate() != null) {
130143

131144
B body = (B) values.getBodyValue();
132145
if (body != null) {
133-
if (values.getBodyValueType() != null) {
146+
if (body instanceof StreamingHttpOutputMessage.Body streamingBody) {
147+
bodySpec.body(streamingBody);
148+
}
149+
else if (values.getBodyValueType() != null) {
134150
bodySpec.body(body, (ParameterizedTypeReference<? super B>) values.getBodyValueType());
135151
}
136152
else {

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.springframework.core.ParameterizedTypeReference;
2525
import org.springframework.core.ReactiveAdapter;
2626
import org.springframework.core.ReactiveAdapterRegistry;
27+
import org.springframework.http.StreamingHttpOutputMessage;
2728
import org.springframework.util.Assert;
2829
import org.springframework.util.ClassUtils;
2930
import org.springframework.web.bind.annotation.RequestBody;
@@ -66,6 +67,11 @@ public RequestBodyArgumentResolver(HttpExchangeAdapter exchangeAdapter) {
6667
public boolean resolve(
6768
@Nullable Object argument, MethodParameter parameter, HttpRequestValues.Builder requestValues) {
6869

70+
if (parameter.getParameterType().equals(StreamingHttpOutputMessage.Body.class)) {
71+
requestValues.setBodyValue(argument);
72+
return true;
73+
}
74+
6975
RequestBody annot = parameter.getParameterAnnotation(RequestBody.class);
7076
if (annot == null) {
7177
return false;

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717
package org.springframework.web.client.support;
1818

1919
import java.io.IOException;
20+
import java.io.InputStream;
2021
import java.lang.annotation.ElementType;
2122
import java.lang.annotation.Retention;
2223
import java.lang.annotation.RetentionPolicy;
2324
import java.lang.annotation.Target;
2425
import java.net.URI;
26+
import java.nio.charset.StandardCharsets;
2527
import java.util.LinkedHashSet;
2628
import java.util.Optional;
2729
import java.util.Set;
@@ -41,8 +43,10 @@
4143
import org.springframework.http.HttpStatus;
4244
import org.springframework.http.MediaType;
4345
import org.springframework.http.ResponseEntity;
46+
import org.springframework.http.StreamingHttpOutputMessage;
4447
import org.springframework.util.LinkedMultiValueMap;
4548
import org.springframework.util.MultiValueMap;
49+
import org.springframework.util.StreamUtils;
4650
import org.springframework.web.bind.annotation.CookieValue;
4751
import org.springframework.web.bind.annotation.PathVariable;
4852
import org.springframework.web.bind.annotation.RequestBody;
@@ -300,6 +304,25 @@ void putWithSameNameCookies(MockWebServer server, Service service) throws Except
300304
assertThat(request.getHeader("Cookie")).isEqualTo("testCookie=test1; testCookie=test2");
301305
}
302306

307+
@Test
308+
void getInputStream() throws Exception {
309+
InputStream inputStream = initService().getInputStream();
310+
311+
RecordedRequest request = this.anotherServer.takeRequest();
312+
assertThat(request.getPath()).isEqualTo("/input-stream");
313+
assertThat(StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8)).isEqualTo("Hello Spring 2!");
314+
}
315+
316+
@Test
317+
void postOutputStream() throws Exception {
318+
String body = "test stream";
319+
initService().postOutputStream(outputStream -> outputStream.write(body.getBytes()));
320+
321+
RecordedRequest request = this.anotherServer.takeRequest();
322+
assertThat(request.getPath()).isEqualTo("/output-stream");
323+
assertThat(request.getBody().readUtf8()).isEqualTo(body);
324+
}
325+
303326

304327
private static MockWebServer anotherServer() {
305328
MockWebServer server = new MockWebServer();
@@ -309,6 +332,13 @@ private static MockWebServer anotherServer() {
309332
return server;
310333
}
311334

335+
private Service initService() {
336+
String url = this.anotherServer.url("/").toString();
337+
RestClient restClient = RestClient.builder().baseUrl(url).build();
338+
RestClientAdapter adapter = RestClientAdapter.create(restClient);
339+
return HttpServiceProxyFactory.builderFor(adapter).build().createClient(Service.class);
340+
}
341+
312342

313343
private interface Service {
314344

@@ -353,6 +383,12 @@ ResponseEntity<String> getWithUriBuilderFactory(
353383
void putWithSameNameCookies(
354384
@CookieValue("testCookie") String firstCookie, @CookieValue("testCookie") String secondCookie);
355385

386+
@GetExchange(url = "/input-stream")
387+
InputStream getInputStream();
388+
389+
@PostExchange(url = "/output-stream")
390+
void postOutputStream(StreamingHttpOutputMessage.Body body);
391+
356392
}
357393

358394

0 commit comments

Comments
 (0)