From 4b0dedc45e2f82d59c8414996f34520be1760698 Mon Sep 17 00:00:00 2001 From: Arjen Poutsma Date: Mon, 29 May 2017 14:16:09 +0200 Subject: [PATCH] Add ExtractingResponseErrorHandler This commit introduces ExtractingResponseErrorHandler: an alternative ResponseErrorHandler that uses `HttpMessageConverter`s to convert HTTP error responses to `RestClientException`. Issue: SPR-15544 --- .../ExtractingResponseErrorHandler.java | 161 +++++++++++++++++ .../ExtractingResponseErrorHandlerTests.java | 170 ++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 spring-web/src/main/java/org/springframework/web/client/ExtractingResponseErrorHandler.java create mode 100644 spring-web/src/test/java/org/springframework/web/client/ExtractingResponseErrorHandlerTests.java diff --git a/spring-web/src/main/java/org/springframework/web/client/ExtractingResponseErrorHandler.java b/spring-web/src/main/java/org/springframework/web/client/ExtractingResponseErrorHandler.java new file mode 100644 index 0000000000..f51e86241e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/client/ExtractingResponseErrorHandler.java @@ -0,0 +1,161 @@ +/* + * 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.client; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.http.HttpStatus; +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; + +/** + * Implementation of {@link ResponseErrorHandler} that uses {@link HttpMessageConverter}s to + * convert HTTP error responses to {@link RestClientException}. + *

To use this error handler, you must specify a + * {@linkplain #setStatusMapping(Map) status mapping} and/or a + * {@linkplain #setSeriesMapping(Map) series mapping}. If either of these mappings has a match + * for the {@linkplain ClientHttpResponse#getStatusCode() status code} of a given + * {@code ClientHttpResponse}, {@link #hasError(ClientHttpResponse)} will return + * {@code true} and {@link #handleError(ClientHttpResponse)} will attempt to use the + * {@linkplain #setMessageConverters(List) configured message converters} to convert the response + * into the mapped subclass of {@link RestClientException}. Note that the + * {@linkplain #setStatusMapping(Map) status mapping} takes precedence over + * {@linkplain #setSeriesMapping(Map) series mapping}. + *

If there is no match, this error handler will default to the behavior of + * {@link DefaultResponseErrorHandler}. Note that you can override this default behavior by + * specifying a {@linkplain #setSeriesMapping(Map) series mapping} from + * {@link HttpStatus.Series#CLIENT_ERROR} and/or {@link HttpStatus.Series#SERVER_ERROR} to + * {@code null}. + * + * @author Simon Galperin + * @author Arjen Poutsma + * @see RestTemplate#setErrorHandler(ResponseErrorHandler) + * @since 5.0 + */ +public class ExtractingResponseErrorHandler extends DefaultResponseErrorHandler + implements InitializingBean { + + private List> messageConverters; + + private final Map> statusMapping = + new LinkedHashMap<>(); + + private final Map> seriesMapping = + new LinkedHashMap<>(); + + /** + * Create a new, empty {@code ExtractingResponseErrorHandler}. + *

Note that {@link #setMessageConverters(List)} must be called when using this constructor. + */ + public ExtractingResponseErrorHandler() { + } + + /** + * Create a new {@code ExtractingResponseErrorHandler} with the given + * {@link HttpMessageConverter} instances. + * @param messageConverters the message converters to use + */ + public ExtractingResponseErrorHandler(List> messageConverters) { + setMessageConverters(messageConverters); + } + + /** + * Sets the message converters to use by this extractor. + */ + public void setMessageConverters(List> messageConverters) { + this.messageConverters = messageConverters; + } + + /** + * Set the mapping from HTTP status code to {@code RestClientException} subclass. + * If this mapping has a match + * for the {@linkplain ClientHttpResponse#getStatusCode() status code} of a given + * {@code ClientHttpResponse}, {@link #hasError(ClientHttpResponse)} will return + * {@code true} and {@link #handleError(ClientHttpResponse)} will attempt to use the + * {@linkplain #setMessageConverters(List) configured message converters} to convert the + * response into the mapped subclass of {@link RestClientException}. + */ + public void setStatusMapping( + Map> statusMapping) { + if (!CollectionUtils.isEmpty(statusMapping)) { + this.statusMapping.putAll(statusMapping); + } + } + + /** + * Set the mapping from HTTP status series to {@code RestClientException} subclass. + * If this mapping has a match + * for the {@linkplain ClientHttpResponse#getStatusCode() status code} of a given + * {@code ClientHttpResponse}, {@link #hasError(ClientHttpResponse)} will return + * {@code true} and {@link #handleError(ClientHttpResponse)} will attempt to use the + * {@linkplain #setMessageConverters(List) configured message converters} to convert the + * response into the mapped subclass of {@link RestClientException}. + */ + public void setSeriesMapping( + Map> seriesMapping) { + if (!CollectionUtils.isEmpty(seriesMapping)) { + this.seriesMapping.putAll(seriesMapping); + } + } + + @Override + public void afterPropertiesSet() throws Exception { + Assert.notEmpty(this.messageConverters, "'messageConverters' is required"); + } + + @Override + protected boolean hasError(HttpStatus statusCode) { + return this.statusMapping.containsKey(statusCode) || + this.seriesMapping.containsKey(statusCode.series()) || + super.hasError(statusCode); + } + + @Override + public void handleError(ClientHttpResponse response) throws IOException { + HttpStatus statusCode = getHttpStatusCode(response); + if (this.statusMapping.containsKey(statusCode)) { + extract(this.statusMapping.get(statusCode), response); + } + else if (this.seriesMapping.containsKey(statusCode.series())) { + extract(this.seriesMapping.get(statusCode.series()), response); + } + else { + super.handleError(response); + } + } + + private void extract(@Nullable Class exceptionClass, + ClientHttpResponse response) throws IOException { + + if (exceptionClass == null) { + return; + } + HttpMessageConverterExtractor extractor = + new HttpMessageConverterExtractor<>(exceptionClass, this.messageConverters); + RestClientException exception = extractor.extractData(response); + if (exception != null) { + throw exception; + } + } +} diff --git a/spring-web/src/test/java/org/springframework/web/client/ExtractingResponseErrorHandlerTests.java b/spring-web/src/test/java/org/springframework/web/client/ExtractingResponseErrorHandlerTests.java new file mode 100644 index 0000000000..76b028a863 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/client/ExtractingResponseErrorHandlerTests.java @@ -0,0 +1,170 @@ +/* + * 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.client; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; + +import static org.junit.Assert.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +/** + * @author Arjen Poutsma + */ +public class ExtractingResponseErrorHandlerTests { + + private ExtractingResponseErrorHandler errorHandler; + + private final ClientHttpResponse response = mock(ClientHttpResponse.class); + + @Before + public void setUp() throws Exception { + HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + this.errorHandler = new ExtractingResponseErrorHandler( + Collections.singletonList(converter)); + + this.errorHandler.setStatusMapping( + Collections.singletonMap(HttpStatus.I_AM_A_TEAPOT, MyRestClientException.class)); + this.errorHandler.setSeriesMapping(Collections + .singletonMap(HttpStatus.Series.SERVER_ERROR, MyRestClientException.class)); + this.errorHandler.afterPropertiesSet(); + } + + @Test + public void hasError() throws Exception { + given(this.response.getStatusCode()).willReturn(HttpStatus.I_AM_A_TEAPOT); + assertTrue(this.errorHandler.hasError(this.response)); + + given(this.response.getStatusCode()).willReturn(HttpStatus.INTERNAL_SERVER_ERROR); + assertTrue(this.errorHandler.hasError(this.response)); + + given(this.response.getStatusCode()).willReturn(HttpStatus.OK); + assertFalse(this.errorHandler.hasError(this.response)); + } + + @Test + public void handleErrorStatusMatch() throws Exception { + given(this.response.getStatusCode()).willReturn(HttpStatus.I_AM_A_TEAPOT); + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setContentType(MediaType.APPLICATION_JSON); + given(this.response.getHeaders()).willReturn(responseHeaders); + + byte[] body = "{\"foo\":\"bar\"}".getBytes(StandardCharsets.UTF_8); + responseHeaders.setContentLength(body.length); + given(this.response.getBody()).willReturn(new ByteArrayInputStream(body)); + + try { + this.errorHandler.handleError(this.response); + fail("MyRestClientException expected"); + } + catch (MyRestClientException ex) { + assertEquals("bar", ex.getFoo()); + } + } + + @Test + public void handleErrorSeriesMatch() throws Exception { + given(this.response.getStatusCode()).willReturn(HttpStatus.INTERNAL_SERVER_ERROR); + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setContentType(MediaType.APPLICATION_JSON); + given(this.response.getHeaders()).willReturn(responseHeaders); + + byte[] body = "{\"foo\":\"bar\"}".getBytes(StandardCharsets.UTF_8); + responseHeaders.setContentLength(body.length); + given(this.response.getBody()).willReturn(new ByteArrayInputStream(body)); + + try { + this.errorHandler.handleError(this.response); + fail("MyRestClientException expected"); + } + catch (MyRestClientException ex) { + assertEquals("bar", ex.getFoo()); + } + } + + @Test + public void handleNoMatch() throws Exception { + given(this.response.getStatusCode()).willReturn(HttpStatus.NOT_FOUND); + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setContentType(MediaType.APPLICATION_JSON); + given(this.response.getHeaders()).willReturn(responseHeaders); + + byte[] body = "{\"foo\":\"bar\"}".getBytes(StandardCharsets.UTF_8); + responseHeaders.setContentLength(body.length); + given(this.response.getBody()).willReturn(new ByteArrayInputStream(body)); + + try { + this.errorHandler.handleError(this.response); + fail("HttpClientErrorException expected"); + } + catch (HttpClientErrorException ex) { + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + assertArrayEquals(body, ex.getResponseBodyAsByteArray()); + } + } + + @Test + public void handleNoMatchOverride() throws Exception { + this.errorHandler.setSeriesMapping(Collections + .singletonMap(HttpStatus.Series.CLIENT_ERROR, null)); + given(this.response.getStatusCode()).willReturn(HttpStatus.NOT_FOUND); + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setContentType(MediaType.APPLICATION_JSON); + given(this.response.getHeaders()).willReturn(responseHeaders); + + byte[] body = "{\"foo\":\"bar\"}".getBytes(StandardCharsets.UTF_8); + responseHeaders.setContentLength(body.length); + given(this.response.getBody()).willReturn(new ByteArrayInputStream(body)); + + this.errorHandler.handleError(this.response); + } + + @SuppressWarnings("serial") + private static class MyRestClientException extends RestClientException { + + private String foo; + + public MyRestClientException(String msg) { + super(msg); + } + + public MyRestClientException(String msg, Throwable ex) { + super(msg, ex); + } + + public String getFoo() { + return this.foo; + } + + public void setFoo(String foo) { + this.foo = foo; + } + } + +} \ No newline at end of file