Skip to content

Commit 8f21479

Browse files
committed
Add body conversion capabilities in RestClient::exchange
This commit introduces a ConvertibleClientHttpResponse type that extends ClientHttpResponse, and that can convert the body to a desired type. Before this commit, it was not easy to use the configured HTTP message converters in combination with RestClient::exchange. Closes gh-31597
1 parent dd97dee commit 8f21479

File tree

3 files changed

+208
-56
lines changed

3 files changed

+208
-56
lines changed

spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java

Lines changed: 132 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
package org.springframework.web.client;
1818

1919
import java.io.IOException;
20+
import java.io.InputStream;
21+
import java.io.UncheckedIOException;
2022
import java.lang.reflect.ParameterizedType;
2123
import java.lang.reflect.Type;
2224
import java.net.URI;
@@ -185,6 +187,61 @@ public Builder mutate() {
185187
return new DefaultRestClientBuilder(this.builder);
186188
}
187189

190+
@SuppressWarnings({"rawtypes", "unchecked"})
191+
private <T> T readWithMessageConverters(ClientHttpResponse clientResponse, Runnable callback, Type bodyType, Class<T> bodyClass) {
192+
MediaType contentType = getContentType(clientResponse);
193+
194+
try (clientResponse) {
195+
callback.run();
196+
197+
for (HttpMessageConverter<?> messageConverter : this.messageConverters) {
198+
if (messageConverter instanceof GenericHttpMessageConverter genericHttpMessageConverter) {
199+
if (genericHttpMessageConverter.canRead(bodyType, null, contentType)) {
200+
if (logger.isDebugEnabled()) {
201+
logger.debug("Reading to [" + ResolvableType.forType(bodyType) + "]");
202+
}
203+
return (T) genericHttpMessageConverter.read(bodyType, null, clientResponse);
204+
}
205+
}
206+
if (messageConverter.canRead(bodyClass, contentType)) {
207+
if (logger.isDebugEnabled()) {
208+
logger.debug("Reading to [" + bodyClass.getName() + "] as \"" + contentType + "\"");
209+
}
210+
return (T) messageConverter.read((Class)bodyClass, clientResponse);
211+
}
212+
}
213+
throw new UnknownContentTypeException(bodyType, contentType,
214+
clientResponse.getStatusCode(), clientResponse.getStatusText(),
215+
clientResponse.getHeaders(), RestClientUtils.getBody(clientResponse));
216+
}
217+
catch (UncheckedIOException | IOException | HttpMessageNotReadableException ex) {
218+
throw new RestClientException("Error while extracting response for type [" +
219+
ResolvableType.forType(bodyType) + "] and content type [" + contentType + "]", ex);
220+
}
221+
}
222+
223+
private static MediaType getContentType(ClientHttpResponse clientResponse) {
224+
MediaType contentType = clientResponse.getHeaders().getContentType();
225+
if (contentType == null) {
226+
contentType = MediaType.APPLICATION_OCTET_STREAM;
227+
}
228+
return contentType;
229+
}
230+
231+
@SuppressWarnings("unchecked")
232+
private static <T> Class<T> bodyClass(Type type) {
233+
if (type instanceof Class<?> clazz) {
234+
return (Class<T>) clazz;
235+
}
236+
if (type instanceof ParameterizedType parameterizedType &&
237+
parameterizedType.getRawType() instanceof Class<?> rawType) {
238+
return (Class<T>) rawType;
239+
}
240+
return (Class<T>) Object.class;
241+
}
242+
243+
244+
188245

189246
private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec {
190247

@@ -409,7 +466,8 @@ private <T> T exchangeInternal(ExchangeFunction<T> exchangeFunction, boolean clo
409466
}
410467
clientResponse = clientRequest.execute();
411468
observationContext.setResponse(clientResponse);
412-
return exchangeFunction.exchange(clientRequest, clientResponse);
469+
ConvertibleClientHttpResponse convertibleWrapper = new DefaultConvertibleClientHttpResponse(clientResponse);
470+
return exchangeFunction.exchange(clientRequest, convertibleWrapper);
413471
}
414472
catch (IOException ex) {
415473
ResourceAccessException resourceAccessException = createResourceAccessException(uri, this.httpMethod, ex);
@@ -542,14 +600,14 @@ private ResponseSpec onStatusInternal(StatusHandler statusHandler) {
542600

543601
@Override
544602
public <T> T body(Class<T> bodyType) {
545-
return readWithMessageConverters(bodyType, bodyType);
603+
return readBody(bodyType, bodyType);
546604
}
547605

548606
@Override
549607
public <T> T body(ParameterizedTypeReference<T> bodyType) {
550608
Type type = bodyType.getType();
551609
Class<T> bodyClass = bodyClass(type);
552-
return readWithMessageConverters(type, bodyClass);
610+
return readBody(type, bodyClass);
553611
}
554612

555613
@Override
@@ -565,7 +623,7 @@ public <T> ResponseEntity<T> toEntity(ParameterizedTypeReference<T> bodyType) {
565623
}
566624

567625
private <T> ResponseEntity<T> toEntityInternal(Type bodyType, Class<T> bodyClass) {
568-
T body = readWithMessageConverters(bodyType, bodyClass);
626+
T body = readBody(bodyType, bodyClass);
569627
try {
570628
return ResponseEntity.status(this.clientResponse.getStatusCode())
571629
.headers(this.clientResponse.getHeaders())
@@ -579,77 +637,96 @@ private <T> ResponseEntity<T> toEntityInternal(Type bodyType, Class<T> bodyClass
579637
@Override
580638
public ResponseEntity<Void> toBodilessEntity() {
581639
try (this.clientResponse) {
582-
applyStatusHandlers(this.clientRequest, this.clientResponse);
640+
applyStatusHandlers();
583641
return ResponseEntity.status(this.clientResponse.getStatusCode())
584642
.headers(this.clientResponse.getHeaders())
585643
.build();
586644
}
645+
catch (UncheckedIOException ex) {
646+
throw new ResourceAccessException("Could not retrieve response status code: " + ex.getMessage(), ex.getCause());
647+
}
587648
catch (IOException ex) {
588649
throw new ResourceAccessException("Could not retrieve response status code: " + ex.getMessage(), ex);
589650
}
590651
}
591652

592-
@SuppressWarnings("unchecked")
593-
private static <T> Class<T> bodyClass(Type type) {
594-
if (type instanceof Class<?> clazz) {
595-
return (Class<T>) clazz;
596-
}
597-
if (type instanceof ParameterizedType parameterizedType &&
598-
parameterizedType.getRawType() instanceof Class<?> rawType) {
599-
return (Class<T>) rawType;
600-
}
601-
return (Class<T>) Object.class;
602-
}
603653

604-
@SuppressWarnings({"rawtypes", "unchecked"})
605-
private <T> T readWithMessageConverters(Type bodyType, Class<T> bodyClass) {
606-
MediaType contentType = getContentType();
654+
private <T> T readBody(Type bodyType, Class<T> bodyClass) {
655+
return DefaultRestClient.this.readWithMessageConverters(this.clientResponse, this::applyStatusHandlers,
656+
bodyType, bodyClass);
607657

608-
try (this.clientResponse) {
609-
applyStatusHandlers(this.clientRequest, this.clientResponse);
610-
611-
for (HttpMessageConverter<?> messageConverter : DefaultRestClient.this.messageConverters) {
612-
if (messageConverter instanceof GenericHttpMessageConverter genericHttpMessageConverter) {
613-
if (genericHttpMessageConverter.canRead(bodyType, null, contentType)) {
614-
if (logger.isDebugEnabled()) {
615-
logger.debug("Reading to [" + ResolvableType.forType(bodyType) + "]");
616-
}
617-
return (T) genericHttpMessageConverter.read(bodyType, null, this.clientResponse);
618-
}
619-
}
620-
if (messageConverter.canRead(bodyClass, contentType)) {
621-
if (logger.isDebugEnabled()) {
622-
logger.debug("Reading to [" + bodyClass.getName() + "] as \"" + contentType + "\"");
623-
}
624-
return (T) messageConverter.read((Class)bodyClass, this.clientResponse);
658+
}
659+
660+
private void applyStatusHandlers() {
661+
try {
662+
ClientHttpResponse response = this.clientResponse;
663+
if (response instanceof DefaultConvertibleClientHttpResponse convertibleResponse) {
664+
response = convertibleResponse.delegate;
665+
}
666+
for (StatusHandler handler : this.statusHandlers) {
667+
if (handler.test(response)) {
668+
handler.handle(this.clientRequest, response);
669+
return;
625670
}
626671
}
627-
throw new UnknownContentTypeException(bodyType, contentType,
628-
this.clientResponse.getStatusCode(), this.clientResponse.getStatusText(),
629-
this.clientResponse.getHeaders(), RestClientUtils.getBody(this.clientResponse));
630672
}
631-
catch (IOException | HttpMessageNotReadableException ex) {
632-
throw new RestClientException("Error while extracting response for type [" +
633-
ResolvableType.forType(bodyType) + "] and content type [" + contentType + "]", ex);
673+
catch (IOException ex) {
674+
throw new UncheckedIOException(ex);
634675
}
635676
}
677+
}
636678

637-
private MediaType getContentType() {
638-
MediaType contentType = this.clientResponse.getHeaders().getContentType();
639-
if (contentType == null) {
640-
contentType = MediaType.APPLICATION_OCTET_STREAM;
641-
}
642-
return contentType;
679+
680+
private class DefaultConvertibleClientHttpResponse implements RequestHeadersSpec.ConvertibleClientHttpResponse {
681+
682+
private final ClientHttpResponse delegate;
683+
684+
685+
public DefaultConvertibleClientHttpResponse(ClientHttpResponse delegate) {
686+
this.delegate = delegate;
643687
}
644688

645-
private void applyStatusHandlers(HttpRequest request, ClientHttpResponse response) throws IOException {
646-
for (StatusHandler handler : this.statusHandlers) {
647-
if (handler.test(response)) {
648-
handler.handle(request, response);
649-
return;
650-
}
651-
}
689+
690+
@Nullable
691+
@Override
692+
public <T> T bodyTo(Class<T> bodyType) {
693+
return readWithMessageConverters(this.delegate, () -> {} , bodyType, bodyType);
694+
}
695+
696+
@Nullable
697+
@Override
698+
public <T> T bodyTo(ParameterizedTypeReference<T> bodyType) {
699+
Type type = bodyType.getType();
700+
Class<T> bodyClass = bodyClass(type);
701+
return readWithMessageConverters(this.delegate, () -> {} , type, bodyClass);
702+
}
703+
704+
@Override
705+
public InputStream getBody() throws IOException {
706+
return this.delegate.getBody();
707+
}
708+
709+
@Override
710+
public HttpHeaders getHeaders() {
711+
return this.delegate.getHeaders();
712+
}
713+
714+
@Override
715+
public HttpStatusCode getStatusCode() throws IOException {
716+
return this.delegate.getStatusCode();
652717
}
718+
719+
@Override
720+
public String getStatusText() throws IOException {
721+
return this.delegate.getStatusText();
722+
}
723+
724+
@Override
725+
public void close() {
726+
this.delegate.close();
727+
}
728+
653729
}
654730

731+
655732
}

spring-web/src/main/java/org/springframework/web/client/RestClient.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,33 @@ interface ExchangeFunction<T> {
623623
* @return the exchanged type
624624
* @throws IOException in case of I/O errors
625625
*/
626-
T exchange(HttpRequest clientRequest, ClientHttpResponse clientResponse) throws IOException;
626+
T exchange(HttpRequest clientRequest, ConvertibleClientHttpResponse clientResponse) throws IOException;
627+
}
628+
629+
630+
/**
631+
* Extension of {@link ClientHttpResponse} that can convert the body.
632+
*/
633+
interface ConvertibleClientHttpResponse extends ClientHttpResponse {
634+
635+
/**
636+
* Extract the response body as an object of the given type.
637+
* @param bodyType the type of return value
638+
* @param <T> the body type
639+
* @return the body, or {@code null} if no response body was available
640+
*/
641+
@Nullable
642+
<T> T bodyTo(Class<T> bodyType);
643+
644+
/**
645+
* Extract the response body as an object of the given type.
646+
* @param bodyType the type of return value
647+
* @param <T> the body type
648+
* @return the body, or {@code null} if no response body was available
649+
*/
650+
@Nullable
651+
<T> T bodyTo(ParameterizedTypeReference<T> bodyType);
652+
627653
}
628654
}
629655

spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,55 @@ void exchangeForPlainText(ClientHttpRequestFactory requestFactory) {
661661
});
662662
}
663663

664+
@ParameterizedRestClientTest
665+
void exchangeForJson(ClientHttpRequestFactory requestFactory) {
666+
startServer(requestFactory);
667+
668+
prepareResponse(response -> response
669+
.setHeader("Content-Type", "application/json")
670+
.setBody("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"));
671+
672+
Pojo result = this.restClient.get()
673+
.uri("/pojo")
674+
.accept(MediaType.APPLICATION_JSON)
675+
.exchange((request, response) -> response.bodyTo(Pojo.class));
676+
677+
assertThat(result.getFoo()).isEqualTo("foofoo");
678+
assertThat(result.getBar()).isEqualTo("barbar");
679+
680+
expectRequestCount(1);
681+
expectRequest(request -> {
682+
assertThat(request.getPath()).isEqualTo("/pojo");
683+
assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json");
684+
});
685+
}
686+
687+
@ParameterizedRestClientTest
688+
void exchangeForJsonArray(ClientHttpRequestFactory requestFactory) {
689+
startServer(requestFactory);
690+
691+
prepareResponse(response -> response
692+
.setHeader("Content-Type", "application/json")
693+
.setBody("[{\"bar\":\"bar1\",\"foo\":\"foo1\"},{\"bar\":\"bar2\",\"foo\":\"foo2\"}]"));
694+
695+
List<Pojo> result = this.restClient.get()
696+
.uri("/pojo")
697+
.accept(MediaType.APPLICATION_JSON)
698+
.exchange((request, response) -> response.bodyTo(new ParameterizedTypeReference<>() {}));
699+
700+
assertThat(result).hasSize(2);
701+
assertThat(result.get(0).getFoo()).isEqualTo("foo1");
702+
assertThat(result.get(0).getBar()).isEqualTo("bar1");
703+
assertThat(result.get(1).getFoo()).isEqualTo("foo2");
704+
assertThat(result.get(1).getBar()).isEqualTo("bar2");
705+
706+
expectRequestCount(1);
707+
expectRequest(request -> {
708+
assertThat(request.getPath()).isEqualTo("/pojo");
709+
assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json");
710+
});
711+
}
712+
664713
@ParameterizedRestClientTest
665714
void exchangeFor404(ClientHttpRequestFactory requestFactory) {
666715
startServer(requestFactory);

0 commit comments

Comments
 (0)