From 5394cc0c63471e7de8399c97ea201bbc0a4dd4d4 Mon Sep 17 00:00:00 2001 From: Arjen Poutsma Date: Wed, 2 Aug 2017 10:19:05 +0200 Subject: [PATCH] WebClientException should allow access to status code of the response This commit changes the WebClient so that it now throws a `WebClientResponseException` for `ResponseSpec.bodyTo`. This newly introduces exception contains the status code, headers, and body of the response message. As a consequence of the above, we had to change `onStatus` so that the `exceptionFunction` now returns a `Mono` rather than a `Throwable`, which it was before. The Mono allows for asynchronous operations, such as reading the contents of the body. Issue: SPR-15824 --- .../org/springframework/http/HttpHeaders.java | 4 + .../function/client/DefaultWebClient.java | 107 +++++++++++------ .../reactive/function/client/WebClient.java | 22 ++-- .../function/client/WebClientException.java | 9 +- .../client/WebClientResponseException.java | 112 ++++++++++++++++++ .../client/WebClientIntegrationTests.java | 14 ++- 6 files changed, 216 insertions(+), 52 deletions(-) create mode 100644 spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java diff --git a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java index 5ae97660c2c..8349df24a11 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -73,6 +73,10 @@ public class HttpHeaders implements MultiValueMap, Serializable private static final long serialVersionUID = -8578554704772377436L; + /** + * The empty {@code HttpHeaders} instance (immutable). + */ + public static final HttpHeaders EMPTY = new HttpHeaders(new LinkedHashMap<>(0), true); /** * The HTTP {@code Accept} header field name. * @see Section 5.3.2 of RFC 7231 diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java index eed882f9b0a..592e96fed61 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java @@ -18,6 +18,7 @@ package org.springframework.web.reactive.function.client; import java.net.URI; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -26,7 +27,6 @@ import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; @@ -36,6 +36,8 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -46,6 +48,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MimeType; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.reactive.function.BodyExtractors; @@ -378,22 +381,11 @@ class DefaultWebClient implements WebClient { private static class DefaultResponseSpec implements ResponseSpec { - private static final Function> DEFAULT_STATUS_HANDLER = - clientResponse -> { - HttpStatus statusCode = clientResponse.statusCode(); - if (statusCode.isError()) { - return Optional.of(new WebClientException( - "ClientResponse has erroneous status code: " + statusCode.value() + - " " + statusCode.getReasonPhrase())); - } else { - return Optional.empty(); - } - }; + private static final StatusHandler DEFAULT_STATUS_HANDLER = new StatusHandler(HttpStatus::isError, DefaultResponseSpec::createResponseException); private final Mono responseMono; - private List>> statusHandlers = - new ArrayList<>(1); + private List statusHandlers = new ArrayList<>(1); DefaultResponseSpec(Mono responseMono) { @@ -403,7 +395,7 @@ class DefaultWebClient implements WebClient { @Override public ResponseSpec onStatus(Predicate statusPredicate, - Function exceptionFunction) { + Function> exceptionFunction) { Assert.notNull(statusPredicate, "'statusPredicate' must not be null"); Assert.notNull(exceptionFunction, "'exceptionFunction' must not be null"); @@ -412,60 +404,107 @@ class DefaultWebClient implements WebClient { this.statusHandlers.clear(); } - Function> statusHandler = - clientResponse -> { - if (statusPredicate.test(clientResponse.statusCode())) { - return Optional.of(exceptionFunction.apply(clientResponse)); - } - else { - return Optional.empty(); - } - }; - this.statusHandlers.add(statusHandler); + this.statusHandlers.add(new StatusHandler(statusPredicate, exceptionFunction)); return this; } @Override + @SuppressWarnings("unchecked") public Mono bodyToMono(Class bodyType) { return this.responseMono.flatMap( response -> bodyToPublisher(response, BodyExtractors.toMono(bodyType), - Mono::error)); + this::monoThrowableToMono)); } @Override + @SuppressWarnings("unchecked") public Mono bodyToMono(ParameterizedTypeReference typeReference) { return this.responseMono.flatMap( response -> bodyToPublisher(response, BodyExtractors.toMono(typeReference), - Mono::error)); + mono -> (Mono)mono)); } - @Override + private Mono monoThrowableToMono(Mono mono) { + return mono.flatMap(Mono::error); + } + + @Override public Flux bodyToFlux(Class elementType) { return this.responseMono.flatMapMany( response -> bodyToPublisher(response, BodyExtractors.toFlux(elementType), - Flux::error)); + this::monoThrowableToFlux)); } @Override public Flux bodyToFlux(ParameterizedTypeReference typeReference) { return this.responseMono.flatMapMany( response -> bodyToPublisher(response, BodyExtractors.toFlux(typeReference), - Flux::error)); + this::monoThrowableToFlux)); + } + + private Flux monoThrowableToFlux(Mono mono) { + return mono.flatMapMany(Flux::error); } private > T bodyToPublisher(ClientResponse response, BodyExtractor extractor, - Function errorFunction) { + Function, T> errorFunction) { return this.statusHandlers.stream() - .map(statusHandler -> statusHandler.apply(response)) - .filter(Optional::isPresent) + .filter(statusHandler -> statusHandler.test(response.statusCode())) .findFirst() - .map(Optional::get) + .map(statusHandler -> statusHandler.apply(response)) .map(errorFunction::apply) .orElse(response.body(extractor)); } + private static Mono createResponseException(ClientResponse response) { + + return response.body(BodyExtractors.toDataBuffers()) + .reduce(DataBuffer::write) + .map(dataBuffer -> { + byte[] bytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(bytes); + DataBufferUtils.release(dataBuffer); + return bytes; + }) + .map(bodyBytes -> { + String msg = String.format("ClientResponse has erroneous status code: %d %s", response.statusCode().value(), + response.statusCode().getReasonPhrase()); + Charset charset = response.headers().contentType() + .map(MimeType::getCharset) + .orElse(StandardCharsets.ISO_8859_1); + return new WebClientResponseException(msg, + response.statusCode().value(), + response.statusCode().getReasonPhrase(), + response.headers().asHttpHeaders(), + bodyBytes, + charset + ); + }); + } + + private static class StatusHandler { + + private final Predicate predicate; + + private final Function> exceptionFunction; + + public StatusHandler(Predicate predicate, + Function> exceptionFunction) { + this.predicate = predicate; + this.exceptionFunction = exceptionFunction; + } + + public boolean test(HttpStatus status) { + return this.predicate.test(status); + } + + public Mono apply(ClientResponse response) { + return this.exceptionFunction.apply(response); + } + } + } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java index 1c8772dbc02..dbb4a722d4d 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java @@ -562,15 +562,15 @@ public interface WebClient { * Register a custom error function that gets invoked when the given {@link HttpStatus} * predicate applies. The exception returned from the function will be returned from * {@link #bodyToMono(Class)} and {@link #bodyToFlux(Class)}. - *

By default, an error handler is register that throws a {@link WebClientException} - * when the response status code is 4xx or 5xx. + *

By default, an error handler is register that throws a + * {@link WebClientResponseException} when the response status code is 4xx or 5xx. * @param statusPredicate a predicate that indicates whether {@code exceptionFunction} * applies * @param exceptionFunction the function that returns the exception * @return this builder */ ResponseSpec onStatus(Predicate statusPredicate, - Function exceptionFunction); + Function> exceptionFunction); /** * Extract the body to a {@code Mono}. By default, if the response has status code 4xx or @@ -578,8 +578,8 @@ public interface WebClient { * with {@link #onStatus(Predicate, Function)}. * @param bodyType the expected response body type * @param response body type - * @return a mono containing the body, or a {@link WebClientException} if the status code is - * 4xx or 5xx + * @return a mono containing the body, or a {@link WebClientResponseException} if the + * status code is 4xx or 5xx */ Mono bodyToMono(Class bodyType); @@ -589,8 +589,8 @@ public interface WebClient { * with {@link #onStatus(Predicate, Function)}. * @param typeReference a type reference describing the expected response body type * @param response body type - * @return a mono containing the body, or a {@link WebClientException} if the status code is - * 4xx or 5xx + * @return a mono containing the body, or a {@link WebClientResponseException} if the + * status code is 4xx or 5xx */ Mono bodyToMono(ParameterizedTypeReference typeReference); @@ -600,8 +600,8 @@ public interface WebClient { * with {@link #onStatus(Predicate, Function)}. * @param elementType the type of element in the response * @param the type of elements in the response - * @return a flux containing the body, or a {@link WebClientException} if the status code is - * 4xx or 5xx + * @return a flux containing the body, or a {@link WebClientResponseException} if the + * status code is 4xx or 5xx */ Flux bodyToFlux(Class elementType); @@ -611,8 +611,8 @@ public interface WebClient { * with {@link #onStatus(Predicate, Function)}. * @param typeReference a type reference describing the expected response body type * @param the type of elements in the response - * @return a flux containing the body, or a {@link WebClientException} if the status code is - * 4xx or 5xx + * @return a flux containing the body, or a {@link WebClientResponseException} if the + * status code is 4xx or 5xx */ Flux bodyToFlux(ParameterizedTypeReference typeReference); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientException.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientException.java index 66e40023414..a8514336081 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientException.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientException.java @@ -19,13 +19,14 @@ package org.springframework.web.reactive.function.client; import org.springframework.core.NestedRuntimeException; /** - * Exception published by {@link WebClient} in case of errors. - * + * Abstract base class for exception published by {@link WebClient} in case of errors. + * * @author Arjen Poutsma * @since 5.0 */ -@SuppressWarnings("serial") -public class WebClientException extends NestedRuntimeException { +public abstract class WebClientException extends NestedRuntimeException { + + private static final long serialVersionUID = 472776714118912855L; /** * Construct a new instance of {@code WebClientException} with the given message. 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 new file mode 100644 index 00000000000..44d11558d5c --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java @@ -0,0 +1,112 @@ +/* + * Copyright 2002-2017 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 + * + * http://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.reactive.function.client; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.lang.Nullable; + +/** + * Exceptions that contain actual HTTP response data. + * + * @author Arjen Poutsma + * @since 5.0 + */ +public class WebClientResponseException extends WebClientException { + + private static final long serialVersionUID = 4127543205414951611L; + + + private final int statusCode; + + private final String statusText; + + private final byte[] responseBody; + + private final HttpHeaders headers; + + private final Charset responseCharset; + + + /** + * Construct a new instance of with the given response data. + * @param statusCode the raw status code value + * @param statusText the status text + * @param headers the response headers (may be {@code null}) + * @param responseBody the response body content (may be {@code null}) + * @param responseCharset the response body charset (may be {@code null}) + */ + public WebClientResponseException(String message, int statusCode, String statusText, + @Nullable HttpHeaders headers, @Nullable byte[] responseBody, + @Nullable Charset responseCharset) { + + super(message); + + this.statusCode = statusCode; + this.statusText = statusText; + this.headers = (headers != null ? headers : HttpHeaders.EMPTY); + this.responseBody = (responseBody != null ? responseBody : new byte[0]); + this.responseCharset = (responseCharset != null ? responseCharset : StandardCharsets.ISO_8859_1); + } + + + /** + * Return the HTTP status code value. + */ + public HttpStatus getStatusCode() { + return HttpStatus.valueOf(this.statusCode); + } + + /** + * Return the raw HTTP status code value. + */ + public int getRawStatusCode() { + return this.statusCode; + } + + /** + * Return the HTTP status text. + */ + public String getStatusText() { + return this.statusText; + } + + /** + * Return the HTTP response headers. + */ + public HttpHeaders getHeaders() { + return this.headers; + } + + /** + * Return the response body as a byte array. + */ + public byte[] getResponseBodyAsByteArray() { + return this.responseBody; + } + + /** + * Return the response body as a string. + */ + public String getResponseBodyAsString() { + return new String(this.responseBody, this.responseCharset); + } + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientIntegrationTests.java index d6109d6496b..adf1671c799 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientIntegrationTests.java @@ -419,8 +419,9 @@ public class WebClientIntegrationTests { @Test public void retrieveBodyToMonoInternalServerError() throws Exception { + String errorMessage = "Internal Server error"; this.server.enqueue(new MockResponse().setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + .setHeader("Content-Type", "text/plain").setBody(errorMessage)); Mono result = this.webClient.get() .uri("/greeting?name=Spring") @@ -428,7 +429,14 @@ public class WebClientIntegrationTests { .bodyToMono(String.class); StepVerifier.create(result) - .expectError(WebClientException.class) + .expectErrorSatisfies(throwable -> { + assertTrue(throwable instanceof WebClientResponseException); + WebClientResponseException ex = (WebClientResponseException) throwable; + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, ex.getStatusCode()); + assertEquals(MediaType.TEXT_PLAIN, ex.getHeaders().getContentType()); + assertEquals(errorMessage, ex.getResponseBodyAsString()); + }) .verify(Duration.ofSeconds(3)); RecordedRequest recordedRequest = server.takeRequest(); @@ -445,7 +453,7 @@ public class WebClientIntegrationTests { Mono result = this.webClient.get() .uri("/greeting?name=Spring") .retrieve() - .onStatus(HttpStatus::is5xxServerError, response -> new MyException("500 error!")) + .onStatus(HttpStatus::is5xxServerError, response -> Mono.just(new MyException("500 error!"))) .bodyToMono(String.class); StepVerifier.create(result)