diff --git a/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java b/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java index 1769051c88da..74499468cd88 100644 --- a/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java +++ b/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java @@ -19,12 +19,15 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.lang.reflect.Type; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.function.Function; import java.util.function.Predicate; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; import org.springframework.core.ResolvableType; @@ -32,8 +35,12 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpRequest; import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.converter.GenericHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.SmartHttpMessageConverter; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; @@ -48,6 +55,8 @@ */ final class StatusHandler { + private static final Log logger = LogFactory.getLog(StatusHandler.class); + private final ResponsePredicate predicate; private final RestClient.ResponseSpec.ErrorHandler handler; @@ -163,16 +172,13 @@ private static String getErrorMessage( return preface + bodyText; } - @SuppressWarnings({"NullAway", "removal"}) + @SuppressWarnings("NullAway") private static Function initBodyConvertFunction( ClientHttpResponse response, byte[] body, List> messageConverters) { return resolvableType -> { try { - HttpMessageConverterExtractor extractor = - new HttpMessageConverterExtractor<>(resolvableType.getType(), messageConverters); - - return extractor.extractData(new ClientHttpResponseDecorator(response) { + return extractData(resolvableType, messageConverters, new ClientHttpResponseDecorator(response) { @Override public InputStream getBody() { return new ByteArrayInputStream(body); @@ -185,6 +191,65 @@ public InputStream getBody() { }; } + @SuppressWarnings({"rawtypes", "unchecked"}) + private static @Nullable Object extractData(ResolvableType resolvableType, + List> messageConverters, ClientHttpResponse response) throws IOException { + + IntrospectingClientHttpResponse responseWrapper = new IntrospectingClientHttpResponse(response); + if (!responseWrapper.hasMessageBody() || responseWrapper.hasEmptyMessageBody()) { + return null; + } + MediaType contentType = getContentType(responseWrapper); + Type responseType = resolvableType.getType(); + Class responseClass = (responseType instanceof Class clazz ? clazz : null); + + try { + for (HttpMessageConverter messageConverter : messageConverters) { + if (messageConverter instanceof GenericHttpMessageConverter genericMessageConverter) { + if (genericMessageConverter.canRead(responseType, null, contentType)) { + if (logger.isDebugEnabled()) { + logger.debug("Reading to [" + resolvableType + "]"); + } + return genericMessageConverter.read(responseType, null, responseWrapper); + } + } + else if (messageConverter instanceof SmartHttpMessageConverter smartMessageConverter) { + if (smartMessageConverter.canRead(resolvableType, contentType)) { + if (logger.isDebugEnabled()) { + logger.debug("Reading to [" + resolvableType + "]"); + } + return smartMessageConverter.read(resolvableType, responseWrapper, null); + } + } + else if (responseClass != null && messageConverter.canRead(responseClass, contentType)) { + if (logger.isDebugEnabled()) { + logger.debug("Reading to [" + responseClass.getName() + "] as \"" + contentType + "\""); + } + return messageConverter.read((Class) responseClass, responseWrapper); + } + } + } + catch (IOException | HttpMessageNotReadableException ex) { + throw new RestClientException("Error while extracting response for type [" + + responseType + "] and content type [" + contentType + "]", ex); + } + + throw new UnknownContentTypeException(responseType, contentType, + responseWrapper.getStatusCode(), responseWrapper.getStatusText(), + responseWrapper.getHeaders(), RestClientUtils.getBody(responseWrapper)); + } + + private static MediaType getContentType(ClientHttpResponse response) { + MediaType contentType = response.getHeaders().getContentType(); + if (contentType == null) { + if (logger.isTraceEnabled()) { + logger.trace("No content-type, using 'application/octet-stream'"); + } + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + return contentType; + } + @FunctionalInterface private interface ResponsePredicate { diff --git a/spring-web/src/test/java/org/springframework/web/client/StatusHandlerTests.java b/spring-web/src/test/java/org/springframework/web/client/StatusHandlerTests.java new file mode 100644 index 000000000000..d28aa5630fda --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/client/StatusHandlerTests.java @@ -0,0 +1,154 @@ +/* + * Copyright 2002-present 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.client; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.SmartHttpMessageConverter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link StatusHandler}. + * + * @author Sakshi Chitnis + */ +class StatusHandlerTests { + + private static final MediaType CONTENT_TYPE = MediaType.TEXT_PLAIN; + + private static final String BODY = "Foo"; + + private final ClientHttpResponse response = mock(); + + + @BeforeEach + void setUp() throws IOException { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(CONTENT_TYPE); + headers.setContentLength(BODY.length()); + given(this.response.getStatusCode()).willReturn(HttpStatus.BAD_REQUEST); + given(this.response.getStatusText()).willReturn(HttpStatus.BAD_REQUEST.getReasonPhrase()); + given(this.response.getHeaders()).willReturn(headers); + given(this.response.getBody()).willReturn(new ByteArrayInputStream(BODY.getBytes(StandardCharsets.UTF_8))); + } + + + @Test + void convertBodyWithHttpMessageConverter() throws IOException { + HttpMessageConverter converter = mock(); + given(converter.canRead(String.class, CONTENT_TYPE)).willReturn(true); + given(converter.read(eq(String.class), any(HttpInputMessage.class))).willReturn(BODY); + + RestClientResponseException exception = createException(converter); + + assertThat(exception.getResponseBodyAs(String.class)).isEqualTo(BODY); + } + + @Test + void convertBodyWithGenericHttpMessageConverter() throws IOException { + ParameterizedTypeReference> targetType = new ParameterizedTypeReference<>() {}; + Type type = targetType.getType(); + GenericHttpMessageConverter> converter = mock(); + given(converter.canRead(type, null, CONTENT_TYPE)).willReturn(true); + given(converter.read(eq(type), isNull(), any(HttpInputMessage.class))).willReturn(List.of(BODY)); + + RestClientResponseException exception = createException(converter); + + assertThat(exception.getResponseBodyAs(targetType)).containsExactly(BODY); + } + + @Test + void convertBodyWithSmartHttpMessageConverter() throws IOException { + ParameterizedTypeReference> targetType = new ParameterizedTypeReference<>() {}; + ResolvableType resolvableType = ResolvableType.forType(targetType.getType()); + SmartHttpMessageConverter> converter = mock(); + given(converter.canRead(resolvableType, CONTENT_TYPE)).willReturn(true); + given(converter.read(eq(resolvableType), any(HttpInputMessage.class), isNull())).willReturn(List.of(BODY)); + + RestClientResponseException exception = createException(converter); + + assertThat(exception.getResponseBodyAs(targetType)).containsExactly(BODY); + } + + @Test + void emptyBodyIsNotConverted() throws IOException { + HttpHeaders headers = new HttpHeaders(); + headers.setContentLength(0); + given(this.response.getHeaders()).willReturn(headers); + given(this.response.getBody()).willReturn(new ByteArrayInputStream(new byte[0])); + HttpMessageConverter converter = mock(); + + RestClientResponseException exception = createException(converter); + + assertThat(exception.getResponseBodyAs(String.class)).isNull(); + } + + @Test + void cannotConvertBody() throws IOException { + HttpMessageConverter converter = mock(); + given(converter.canRead(String.class, CONTENT_TYPE)).willReturn(false); + + RestClientResponseException exception = createException(converter); + + assertThatExceptionOfType(UnknownContentTypeException.class) + .isThrownBy(() -> exception.getResponseBodyAs(String.class)) + .satisfies(ex -> assertThat(ex.getResponseBodyAsString()).isEqualTo(BODY)); + } + + @Test + void converterThrowsIOException() throws IOException { + HttpMessageConverter converter = mock(); + given(converter.canRead(String.class, CONTENT_TYPE)).willReturn(true); + given(converter.read(eq(String.class), any(HttpInputMessage.class))).willThrow(IOException.class); + + RestClientResponseException exception = createException(converter); + + assertThatExceptionOfType(RestClientException.class) + .isThrownBy(() -> exception.getResponseBodyAs(String.class)) + .withMessageContaining("Error while extracting response for type [class java.lang.String] " + + "and content type [text/plain]") + .withCauseInstanceOf(IOException.class); + } + + private RestClientResponseException createException(HttpMessageConverter converter) throws IOException { + return StatusHandler.createException(this.response, List.of(converter)); + } + +}