diff --git a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java index ad7fe2674a..4f83fb491d 100644 --- a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java +++ b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java @@ -74,6 +74,12 @@ public class ProblemDetail { this.instance = other.instance; } + /** + * For deserialization. + */ + protected ProblemDetail() { + } + /** * Variant of {@link #setType(URI)} for chained initialization. diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java b/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java index d7e9d87359..86589daaac 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java @@ -16,17 +16,26 @@ package org.springframework.web.client; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import org.springframework.core.ResolvableType; import org.springframework.core.log.LogFormatUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.converter.HttpMessageConverter; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.FileCopyUtils; import org.springframework.util.ObjectUtils; @@ -50,6 +59,20 @@ import org.springframework.util.ObjectUtils; */ public class DefaultResponseErrorHandler implements ResponseErrorHandler { + @Nullable + private List> messageConverters; + + + /** + * For internal use from the RestTemplate, to pass the message converters + * to use to decode error content. + * @since 6.0 + */ + void setMessageConverters(List> converters) { + this.messageConverters = Collections.unmodifiableList(converters); + } + + /** * Delegates to {@link #hasError(HttpStatusCode)} with the response status code. * @see ClientHttpResponse#getStatusCode() @@ -155,15 +178,48 @@ public class DefaultResponseErrorHandler implements ResponseErrorHandler { Charset charset = getCharset(response); String message = getErrorMessage(statusCode.value(), statusText, body, charset); + RestClientResponseException ex; if (statusCode.is4xxClientError()) { - throw HttpClientErrorException.create(message, statusCode, statusText, headers, body, charset); + ex = HttpClientErrorException.create(message, statusCode, statusText, headers, body, charset); } else if (statusCode.is5xxServerError()) { - throw HttpServerErrorException.create(message, statusCode, statusText, headers, body, charset); + ex = HttpServerErrorException.create(message, statusCode, statusText, headers, body, charset); } else { - throw new UnknownHttpStatusCodeException(message, statusCode.value(), statusText, headers, body, charset); + ex = new UnknownHttpStatusCodeException(message, statusCode.value(), statusText, headers, body, charset); } + + if (!CollectionUtils.isEmpty(this.messageConverters)) { + ex.setBodyConvertFunction(initBodyConvertFunction(response, body)); + } + + throw ex; + } + + /** + * Return a function for decoding the error content. This can be passed to + * {@link RestClientResponseException#setBodyConvertFunction(Function)}. + * @since 6.0 + */ + protected Function initBodyConvertFunction(ClientHttpResponse response, byte[] body) { + Assert.state(!CollectionUtils.isEmpty(this.messageConverters), "Expected message converters"); + return resolvableType -> { + try { + HttpMessageConverterExtractor extractor = + new HttpMessageConverterExtractor<>(resolvableType.getType(), this.messageConverters); + + return extractor.extractData(new ClientHttpResponseDecorator(response) { + @Override + public InputStream getBody() { + return new ByteArrayInputStream(body); + } + }); + } + catch (IOException ex) { + throw new RestClientException( + "Error while extracting response for type [" + resolvableType + "]", ex); + } + }; } /** diff --git a/spring-web/src/main/java/org/springframework/web/client/RestClientResponseException.java b/spring-web/src/main/java/org/springframework/web/client/RestClientResponseException.java index 754633c7ee..f8747f0d7f 100644 --- a/spring-web/src/main/java/org/springframework/web/client/RestClientResponseException.java +++ b/spring-web/src/main/java/org/springframework/web/client/RestClientResponseException.java @@ -19,10 +19,14 @@ package org.springframework.web.client; import java.io.UnsupportedEncodingException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.function.Function; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; /** * Common base class for exceptions that contain actual HTTP response data. @@ -49,6 +53,9 @@ public class RestClientResponseException extends RestClientException { @Nullable private final String responseCharset; + @Nullable + private Function bodyConvertFunction; + /** * Construct a new instance of with the given response data. @@ -153,4 +160,43 @@ public class RestClientResponseException extends RestClientException { } } + /** + * Convert the error response content to the specified type. + * @param targetType the type to convert to + * @param the expected target type + * @return the converted object, or {@code null} if there is no content + * @since 6.0 + */ + @Nullable + public E getResponseBodyAs(Class targetType) { + return getResponseBodyAs(ResolvableType.forClass(targetType)); + } + + /** + * Variant of {@link #getResponseBodyAs(Class)} with + * {@link ParameterizedTypeReference}. + * @since 6.0 + */ + @Nullable + public E getResponseBodyAs(ParameterizedTypeReference targetType) { + return getResponseBodyAs(ResolvableType.forType(targetType.getType())); + } + + @SuppressWarnings("unchecked") + @Nullable + private E getResponseBodyAs(ResolvableType targetType) { + Assert.state(this.bodyConvertFunction != null, "Function to convert body not set"); + return (E) this.bodyConvertFunction.apply(targetType); + } + + /** + * Provide a function to use to decode the response error content + * via {@link #getResponseBodyAs(Class)}. + * @param bodyConvertFunction the function to use + * @since 6.0 + */ + public void setBodyConvertFunction(Function bodyConvertFunction) { + this.bodyConvertFunction = bodyConvertFunction; + } + } diff --git a/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java b/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java index 31c7c4224c..611cc426a0 100644 --- a/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java +++ b/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2022 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. @@ -195,6 +195,7 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat this.messageConverters.add(new MappingJackson2CborHttpMessageConverter()); } + updateErrorHandlerConverters(); this.uriTemplateHandler = initUriTemplateHandler(); } @@ -219,9 +220,16 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat validateConverters(messageConverters); this.messageConverters.addAll(messageConverters); this.uriTemplateHandler = initUriTemplateHandler(); + updateErrorHandlerConverters(); } + private void updateErrorHandlerConverters() { + if (this.errorHandler instanceof DefaultResponseErrorHandler handler) { + handler.setMessageConverters(this.messageConverters); + } + } + private static DefaultUriBuilderFactory initUriTemplateHandler() { DefaultUriBuilderFactory uriFactory = new DefaultUriBuilderFactory(); uriFactory.setEncodingMode(EncodingMode.URI_COMPONENT); // for backwards compatibility.. @@ -240,6 +248,7 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat this.messageConverters.clear(); this.messageConverters.addAll(messageConverters); } + updateErrorHandlerConverters(); } private void validateConverters(List> messageConverters) { @@ -262,6 +271,7 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat public void setErrorHandler(ResponseErrorHandler errorHandler) { Assert.notNull(errorHandler, "ResponseErrorHandler must not be null"); this.errorHandler = errorHandler; + updateErrorHandlerConverters(); } /** diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientResponse.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientResponse.java index 267f6edcfb..6252a4e2f6 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientResponse.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2022 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. @@ -22,15 +22,19 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.OptionalLong; +import java.util.function.Function; import java.util.function.Supplier; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; import org.springframework.core.codec.Hints; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpRequest; import org.springframework.http.HttpStatus; @@ -39,8 +43,11 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.http.client.reactive.ClientHttpResponse; +import org.springframework.http.codec.DecoderHttpMessageReader; import org.springframework.http.codec.HttpMessageReader; import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.util.MimeType; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.BodyExtractor; @@ -201,11 +208,15 @@ class DefaultClientResponse implements ClientResponse { .defaultIfEmpty(EMPTY) .onErrorReturn(ex -> !(ex instanceof Error), EMPTY) .map(bodyBytes -> { + HttpRequest request = this.requestSupplier.get(); - Charset charset = headers().contentType().map(MimeType::getCharset).orElse(null); + Optional mediaType = headers().contentType(); + Charset charset = mediaType.map(MimeType::getCharset).orElse(null); HttpStatusCode statusCode = statusCode(); + + WebClientResponseException exception; if (statusCode instanceof HttpStatus httpStatus) { - return WebClientResponseException.create( + exception = WebClientResponseException.create( statusCode, httpStatus.getReasonPhrase(), headers().asHttpHeaders(), @@ -214,16 +225,35 @@ class DefaultClientResponse implements ClientResponse { request); } else { - return new UnknownHttpStatusCodeException( + exception = new UnknownHttpStatusCodeException( statusCode, headers().asHttpHeaders(), bodyBytes, charset, request); } + exception.setBodyDecodeFunction(initDecodeFunction(bodyBytes, mediaType.orElse(null))); + return exception; }); } + private Function initDecodeFunction(byte[] body, @Nullable MediaType contentType) { + return targetType -> { + Decoder decoder = null; + for (HttpMessageReader reader : strategies().messageReaders()) { + if (reader.canRead(targetType, contentType)) { + if (reader instanceof DecoderHttpMessageReader decoderReader) { + decoder = decoderReader.getDecoder(); + break; + } + } + } + Assert.state(decoder != null, "No suitable decoder"); + DataBuffer buffer = DefaultDataBufferFactory.sharedInstance.wrap(body); + return decoder.decode(buffer, targetType, null, Collections.emptyMap()); + }; + } + @Override public Mono createError() { return createException().flatMap(Mono::error); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java index 7b41a40ece..87ef4e56a9 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java @@ -18,12 +18,16 @@ package org.springframework.web.reactive.function.client; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.function.Function; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpRequest; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; /** * Exceptions that contain actual HTTP response data. @@ -51,6 +55,10 @@ public class WebClientResponseException extends WebClientException { @Nullable private final HttpRequest request; + @SuppressWarnings("MutableException") + @Nullable + private Function bodyDecodeFunction; + /** * Constructor with response data only, and a default message. @@ -194,6 +202,37 @@ public class WebClientResponseException extends WebClientException { (this.responseCharset != null ? this.responseCharset : defaultCharset)); } + /** + * Decode the error content to the specified type. + * @param targetType the type to decode to + * @param the expected target type + * @return the decoded content, or {@code null} if there is no content + * @throws IllegalStateException if a Decoder cannot be found + * @throws org.springframework.core.codec.DecodingException if decoding fails + * @since 6.0 + */ + @Nullable + public E getResponseBodyAs(Class targetType) { + return getResponseBodyAs(ResolvableType.forClass(targetType)); + } + + /** + * Variant of {@link #getResponseBodyAs(Class)} with + * {@link ParameterizedTypeReference}. + * @since 6.0 + */ + @Nullable + public E getResponseBodyAs(ParameterizedTypeReference targetType) { + return getResponseBodyAs(ResolvableType.forType(targetType.getType())); + } + + @SuppressWarnings("unchecked") + @Nullable + private E getResponseBodyAs(ResolvableType targetType) { + Assert.state(this.bodyDecodeFunction != null, "Decoder function not set"); + return (E) this.bodyDecodeFunction.apply(targetType); + } + /** * Return the corresponding request. * @since 5.1.4 @@ -203,6 +242,17 @@ public class WebClientResponseException extends WebClientException { return this.request; } + /** + * Provide a function to find a decoder the given target type. + * For use with {@link #getResponseBodyAs(Class)}. + * @param decoderFunction the function to find a decoder with + * @since 6.0 + */ + public void setBodyDecodeFunction(Function decoderFunction) { + this.bodyDecodeFunction = decoderFunction; + } + + /** * Create {@code WebClientResponseException} or an HTTP status specific subclass. * @since 5.1 diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index 6d3fa39799..41d0560396 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -103,9 +103,11 @@ + +