From bc10583bf3e0ed2df9b1215f7846292c703abd11 Mon Sep 17 00:00:00 2001 From: kingj Date: Tue, 5 Nov 2019 01:53:50 +0900 Subject: [PATCH] Adding gzip support for Spring Decoder (#230) * Adding gzip support for Spring Decoder * Restoring test config * Switch to medium resource class in circleci. (#231) * Adding gzip support for Spring Decoder * Restoring test config * Creating extra property for Gzip Decoder * Fixing requested changes and updating doc * Missed one change request * Removing some properties --- docs/src/main/asciidoc/_configprops.adoc | 1 + .../main/asciidoc/spring-cloud-openfeign.adoc | 8 + .../openfeign/FeignAutoConfiguration.java | 3 + .../openfeign/support/DefaultGzipDecoder.java | 85 ++++++++++ .../DefaultGzipDecoderConfiguration.java | 59 +++++++ .../openfeign/support/SpringEncoder.java | 4 +- ...itional-spring-configuration-metadata.json | 6 + .../openfeign/DefaultGzipDecoderTests.java | 159 ++++++++++++++++++ 8 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/DefaultGzipDecoder.java create mode 100644 spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/DefaultGzipDecoderConfiguration.java create mode 100644 spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/DefaultGzipDecoderTests.java diff --git a/docs/src/main/asciidoc/_configprops.adoc b/docs/src/main/asciidoc/_configprops.adoc index 67c39490..370986ae 100644 --- a/docs/src/main/asciidoc/_configprops.adoc +++ b/docs/src/main/asciidoc/_configprops.adoc @@ -8,6 +8,7 @@ |feign.compression.request.mime-types | [text/xml, application/xml, application/json] | The list of supported mime types. |feign.compression.request.min-request-size | 2048 | The minimum threshold content size. |feign.compression.response.enabled | false | Enables the response from Feign to be compressed. +|feign.compression.response.useGzipDecoder | false | Enables the default gzip decoder to be used. |feign.httpclient.connection-timeout | 2000 | |feign.httpclient.connection-timer-repeat | 3000 | |feign.httpclient.disable-ssl-validation | false | diff --git a/docs/src/main/asciidoc/spring-cloud-openfeign.adoc b/docs/src/main/asciidoc/spring-cloud-openfeign.adoc index 1a1e2f8b..dda56830 100644 --- a/docs/src/main/asciidoc/spring-cloud-openfeign.adoc +++ b/docs/src/main/asciidoc/spring-cloud-openfeign.adoc @@ -418,6 +418,14 @@ feign.compression.request.min-request-size=2048 These properties allow you to be selective about the compressed media types and minimum request threshold length. +For http clients except OkHttpClient, default gzip decoder can be enabled to decode gzip response in ISO-8859-1 encoding: + +[source,java] +--- +feign.compression.response.enabled=true +feign.compression.response.useGzipDecoder=true +--- + === Feign logging A logger is created for each Feign client created. By default the name of the logger is the full class name of the interface used to create the Feign client. Feign logging only responds to the `DEBUG` level. diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignAutoConfiguration.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignAutoConfiguration.java index 51eae364..f05e63e7 100644 --- a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignAutoConfiguration.java +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignAutoConfiguration.java @@ -46,9 +46,11 @@ import org.springframework.cloud.commons.httpclient.ApacheHttpClientConnectionMa import org.springframework.cloud.commons.httpclient.ApacheHttpClientFactory; import org.springframework.cloud.commons.httpclient.OkHttpClientConnectionPoolFactory; import org.springframework.cloud.commons.httpclient.OkHttpClientFactory; +import org.springframework.cloud.openfeign.support.DefaultGzipDecoderConfiguration; import org.springframework.cloud.openfeign.support.FeignHttpClientProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; /** * @author Spencer Gibb @@ -58,6 +60,7 @@ import org.springframework.context.annotation.Configuration; @ConditionalOnClass(Feign.class) @EnableConfigurationProperties({ FeignClientProperties.class, FeignHttpClientProperties.class }) +@Import(DefaultGzipDecoderConfiguration.class) public class FeignAutoConfiguration { @Autowired(required = false) diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/DefaultGzipDecoder.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/DefaultGzipDecoder.java new file mode 100644 index 00000000..a1b89925 --- /dev/null +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/DefaultGzipDecoder.java @@ -0,0 +1,85 @@ +/* + * Copyright 2013-2019 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.cloud.openfeign.support; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.zip.GZIPInputStream; + +import feign.FeignException; +import feign.Response; +import feign.Util; +import feign.codec.Decoder; + +import org.springframework.cloud.openfeign.encoding.HttpEncoding; + +/** + * When response is compressed as gzip, this decompresses and uses {@link SpringDecoder} + * to decode. + * + * @author Jaesik Kim + */ +public class DefaultGzipDecoder implements Decoder { + + private Decoder decoder; + + public DefaultGzipDecoder(Decoder decoder) { + this.decoder = decoder; + } + + @Override + public Object decode(final Response response, Type type) + throws IOException, FeignException { + Collection encoding = response.headers() + .containsKey(HttpEncoding.CONTENT_ENCODING_HEADER) + ? response.headers().get(HttpEncoding.CONTENT_ENCODING_HEADER) + : null; + + if (encoding != null) { + if (encoding.contains(HttpEncoding.GZIP_ENCODING)) { + String decompressedBody = decompress(response); + if (decompressedBody != null) { + Response decompressedResponse = response.toBuilder() + .body(decompressedBody.getBytes()).build(); + return decoder.decode(decompressedResponse, type); + } + } + } + return decoder.decode(response, type); + } + + private String decompress(Response response) throws IOException { + if (response.body() == null) { + return null; + } + try (GZIPInputStream gzipInputStream = new GZIPInputStream( + response.body().asInputStream()); + BufferedReader reader = new BufferedReader( + new InputStreamReader(gzipInputStream, Util.ISO_8859_1))) { + String outputString = ""; + String line; + while ((line = reader.readLine()) != null) { + outputString += line; + } + return outputString; + } + } + +} diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/DefaultGzipDecoderConfiguration.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/DefaultGzipDecoderConfiguration.java new file mode 100644 index 00000000..5c0af2de --- /dev/null +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/DefaultGzipDecoderConfiguration.java @@ -0,0 +1,59 @@ +/* + * Copyright 2013-2019 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.cloud.openfeign.support; + +import feign.codec.Decoder; +import feign.optionals.OptionalDecoder; + +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.cloud.openfeign.FeignAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configures Default Gzip Decoder. + * + * @author Jaesik Kim + */ +@Configuration +@ConditionalOnProperty("feign.compression.response.enabled") +// The OK HTTP client uses "transparent" compression. +// If the accept-encoding header is present, it disables transparent compression +@ConditionalOnMissingBean(type = "okhttp3.OkHttpClient") +@AutoConfigureAfter(FeignAutoConfiguration.class) +public class DefaultGzipDecoderConfiguration { + + private ObjectFactory messageConverters; + + public DefaultGzipDecoderConfiguration( + ObjectFactory messageConverters) { + this.messageConverters = messageConverters; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty("feign.compression.response.useGzipDecoder") + public Decoder defaultGzipDecoder() { + return new OptionalDecoder(new ResponseEntityDecoder( + new DefaultGzipDecoder(new SpringDecoder(messageConverters)))); + } + +} diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringEncoder.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringEncoder.java index 471445cc..4bcf9a5f 100644 --- a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringEncoder.java +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringEncoder.java @@ -35,6 +35,7 @@ import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.ObjectFactory; import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.cloud.openfeign.encoding.HttpEncoding; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpOutputMessage; import org.springframework.http.MediaType; @@ -68,7 +69,8 @@ public class SpringEncoder implements Encoder { // template.body(conversionService.convert(object, String.class)); if (requestBody != null) { Class requestType = requestBody.getClass(); - Collection contentTypes = request.headers().get("Content-Type"); + Collection contentTypes = request.headers() + .get(HttpEncoding.CONTENT_TYPE); MediaType requestContentType = null; if (contentTypes != null && !contentTypes.isEmpty()) { diff --git a/spring-cloud-openfeign-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-openfeign-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json index fc025c9d..23a732c1 100644 --- a/spring-cloud-openfeign-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-openfeign-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -26,6 +26,12 @@ "description": "Enables the response from Feign to be compressed.", "defaultValue": "false" }, + { + "name": "feign.compression.response.useGzipDecoder", + "type": "java.lang.Boolean", + "description": "Enables the default gzip decoder to be used.", + "defaultValue": "false" + }, { "name": "feign.compression.request.enabled", "type": "java.lang.Boolean", diff --git a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/DefaultGzipDecoderTests.java b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/DefaultGzipDecoderTests.java new file mode 100644 index 00000000..55f4af4c --- /dev/null +++ b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/DefaultGzipDecoderTests.java @@ -0,0 +1,159 @@ +/* + * Copyright 2013-2019 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.cloud.openfeign; + +import java.util.Objects; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.openfeign.test.NoSecurityConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Jaesik Kim + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = DefaultGzipDecoderTests.Application.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + value = { "spring.application.name=defaultGzipDecoderTests", + "feign.compression.response.enabled=true", + "feign.compression.response.useGzipDecoder=true", + "feign.client.config.default.loggerLevel=full", + "logging.level.org.springframework.cloud.openfeign=DEBUG" }) +@DirtiesContext +public class DefaultGzipDecoderTests extends FeignClientFactoryBean { + + @Autowired + FeignContext context; + + @Value("${local.server.port}") + private int port = 0; + + public DefaultGzipDecoderTests() { + setName("tests"); + setContextId("test"); + } + + public TestClient testClient() { + setType(this.getClass()); + return feign(context).target(TestClient.class, "http://localhost:" + this.port); + } + + @Test + public void testBodyDecompress() { + ResponseEntity response = testClient().getGzipResponse(); + assertThat(response).as("response was null").isNotNull(); + assertThat(response.getStatusCode()).as("wrong status code") + .isEqualTo(HttpStatus.OK); + Hello hello = response.getBody(); + assertThat(hello).as("hello was null").isNotNull(); + assertThat(hello).as("first hello didn't match") + .isEqualTo(new Hello("hello world via response")); + } + + @Test + public void testNullBodyDecompress() { + ResponseEntity response = testClient().getNullResponse(); + assertThat(response).as("response was null").isNotNull(); + assertThat(response.getStatusCode()).as("wrong status code") + .isEqualTo(HttpStatus.OK); + Hello hello = response.getBody(); + assertThat(hello).as("hello was not null").isNull(); + assertThat(hello).as("null hello didn't match").isEqualTo(null); + } + + private static class Hello { + + private String message; + + Hello() { + } + + Hello(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Hello that = (Hello) o; + return Objects.equals(message, that.message); + } + + @Override + public int hashCode() { + return Objects.hash(message); + } + + } + + protected interface TestClient { + + @GetMapping("/helloGzipResponse") + ResponseEntity getGzipResponse(); + + @GetMapping("/nullGzipResponse") + ResponseEntity getNullResponse(); + + } + + @Configuration + @EnableAutoConfiguration + @RestController + @Import(NoSecurityConfiguration.class) + protected static class Application implements TestClient { + + @Override + public ResponseEntity getGzipResponse() { + return ResponseEntity.ok(new Hello("hello world via response")); + } + + @Override + public ResponseEntity getNullResponse() { + return ResponseEntity.ok(null); + } + + } + +}