From ce01195cff4ac1ce6e50a6c56dc8e6a05528c822 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 1 Sep 2026 10:40:16 -0600 Subject: [PATCH 01/11] Add streaming decoders --- .../main/java/feign/InvocationContext.java | 10 +- api/src/main/java/feign/Util.java | 3 + .../java/feign/utils/ContentTypeParser.java | 108 ++++++++++++++ .../java/feign/core/codec/DefaultDecoder.java | 6 +- .../codec/InputStreamAndReaderDecoder.java | 54 +++++++ .../InputStreamAndReaderDecoderTest.java | 134 ++++++++++++++++++ 6 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 api/src/main/java/feign/utils/ContentTypeParser.java create mode 100644 core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java create mode 100644 core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java diff --git a/api/src/main/java/feign/InvocationContext.java b/api/src/main/java/feign/InvocationContext.java index 26f50ba897..60eddbc358 100755 --- a/api/src/main/java/feign/InvocationContext.java +++ b/api/src/main/java/feign/InvocationContext.java @@ -21,6 +21,7 @@ import feign.codec.DecodeException; import feign.codec.Decoder; import feign.codec.ErrorDecoder; +import java.io.Closeable; import java.io.IOException; import java.lang.reflect.Type; @@ -71,6 +72,8 @@ public Object proceed() throws Exception { return disconnectResponseBodyIfNeeded(response); } + boolean noClose = false; + try { final boolean shouldDecodeResponseBody = (response.status() >= 200 && response.status() < 300) @@ -86,6 +89,11 @@ public Object proceed() throws Exception { } Class rawType = Types.getRawType(returnType); + + if (Closeable.class.isAssignableFrom(rawType)) { + noClose = true; + } + if (TypedResponse.class.isAssignableFrom(rawType)) { Type bodyType = Types.resolveLastTypeParameter(returnType, TypedResponse.class); return TypedResponse.builder(response).body(decode(response, bodyType)).build(); @@ -93,7 +101,7 @@ public Object proceed() throws Exception { return decode(response, returnType); } finally { - if (closeAfterDecode) { + if (closeAfterDecode && !noClose) { ensureClosed(response.body()); } } diff --git a/api/src/main/java/feign/Util.java b/api/src/main/java/feign/Util.java index 639bb021a7..c79a203cbc 100644 --- a/api/src/main/java/feign/Util.java +++ b/api/src/main/java/feign/Util.java @@ -59,6 +59,9 @@ public class Util { /** The HTTP Content-Length header field name. */ public static final String CONTENT_LENGTH = "Content-Length"; + /** The HTTP Content-Type header field name. */ + public static final String CONTENT_TYPE = "Content-Type"; + /** The HTTP Content-Encoding header field name. */ public static final String CONTENT_ENCODING = "Content-Encoding"; diff --git a/api/src/main/java/feign/utils/ContentTypeParser.java b/api/src/main/java/feign/utils/ContentTypeParser.java new file mode 100644 index 0000000000..2acc800aff --- /dev/null +++ b/api/src/main/java/feign/utils/ContentTypeParser.java @@ -0,0 +1,108 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.utils; + +import feign.Util; +import java.nio.charset.Charset; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +public final class ContentTypeParser { + + private ContentTypeParser() {} + + /** + * Parses and returns information about the Content-Type header + * + * @param headers the headers to parse + * @return a ContentTypeResult that has the content-type information (or Optional.empty() if the + * Content-Type header is not in the headers) + */ + public static Optional parseContentTypeFromHeaders( + Map> headers) { + // The header map *should* be a case insensitive treemap + for (String val : headers.getOrDefault(Util.CONTENT_TYPE, Collections.emptyList())) { + return Optional.of(parseContentTypeHeader(val)); + } + + return Optional.empty(); + } + + /** + * Parses and returns information about a string that is a valid formatting Content-Type header + * value + * + * @param contentTypeHeader the Content-Type header value to parse + * @return a ContentTypeResult that has the content-type information (or Optional.empty() if the + * Content-Type header is not in the headers) + */ + public static ContentTypeResult parseContentTypeHeader(String contentTypeHeader) { + + String[] contentTypeParmeters = contentTypeHeader.split(";"); + String contentType = contentTypeParmeters[0]; + String charsetString = ""; + if (contentTypeParmeters.length > 1) { + String[] charsetParts = contentTypeParmeters[1].split("="); + if (charsetParts.length == 2 && "charset".equalsIgnoreCase(charsetParts[0].trim())) { + // TODO: 20260727 - this doesn't implement the full parser definition for the content-type + // header (esp related to quoted strings, etc...) - see + // https://www.w3.org/Protocols/rfc1341/4_Content-Type.html + charsetString = charsetParts[1].trim(); + if (charsetString.length() > 1 + && charsetString.startsWith("\"") + && charsetString.endsWith("\"")) + charsetString = charsetString.substring(1, charsetString.length() - 1); + } + } + + return new ContentTypeResult(contentType, charsetOrNull(charsetString)); + } + + private static Charset charsetOrNull(String charsetStr) { + + try { + return Charset.forName(charsetStr); + } catch (Exception e) { + return null; + } + } + + /** Represents the parsed results of a Content-Type header */ + public static class ContentTypeResult { + public static final ContentTypeResult MISSING = new ContentTypeResult("", null); + + /** The content type portion of the header string */ + private String contentType; + + /** The charset portion of the header string (if specified) */ + private Optional charset; + + public ContentTypeResult(String contentType, Charset charset) { + this.contentType = contentType; + this.charset = Optional.ofNullable(charset); + } + + public String getContentType() { + return contentType; + } + + public Optional getCharset() { + return charset; + } + } +} diff --git a/core/src/main/java/feign/core/codec/DefaultDecoder.java b/core/src/main/java/feign/core/codec/DefaultDecoder.java index 663cc02248..333bd1b0f4 100644 --- a/core/src/main/java/feign/core/codec/DefaultDecoder.java +++ b/core/src/main/java/feign/core/codec/DefaultDecoder.java @@ -15,11 +15,12 @@ */ package feign.core.codec; -import feign.Response; -import feign.Util; import java.io.IOException; import java.lang.reflect.Type; +import feign.Response; +import feign.Util; + public class DefaultDecoder extends StringDecoder { @Override @@ -29,6 +30,7 @@ public Object decode(Response response, Type type) throws IOException { if (byte[].class.equals(type)) { return Util.toByteArray(response.body().asInputStream()); } + return super.decode(response, type); } } diff --git a/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java b/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java new file mode 100644 index 0000000000..54564ade12 --- /dev/null +++ b/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java @@ -0,0 +1,54 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.core.codec; + +import feign.FeignException; +import feign.Response; +import feign.Util; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.utils.ContentTypeParser; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.lang.reflect.Type; + +public class InputStreamAndReaderDecoder implements Decoder { + private final Decoder delegateDecoder; + + public InputStreamAndReaderDecoder(Decoder delegate) { + this.delegateDecoder = delegate; + } + + @Override + public Object decode(Response response, Type type) + throws IOException, DecodeException, FeignException { + + if (InputStream.class.equals(type)) return response.body().asInputStream(); + + if (Reader.class.equals(type)) + return response + .body() + .asReader( + ContentTypeParser.parseContentTypeFromHeaders(response.headers()) + .map(ctr -> ctr.getCharset().orElse(Util.UTF_8)) + .orElse(Util.UTF_8)); + + if (delegateDecoder == null) return null; + + return delegateDecoder.decode(response, type); + } +} diff --git a/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java b/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java new file mode 100644 index 0000000000..220b21a59a --- /dev/null +++ b/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java @@ -0,0 +1,134 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.core.codec; + +import static org.assertj.core.api.Assertions.assertThat; + +import feign.Feign; +import feign.RequestLine; +import feign.Util; +import feign.core.codec.InputStreamAndReaderDecoder; + +import java.io.InputStream; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.Random; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.internal.BufferMockResponseBody; +import okio.Buffer; +import org.junit.jupiter.api.Test; + +public class InputStreamAndReaderDecoderTest { + public final MockWebServer server = new MockWebServer(); + + interface LargeStreamTestInterface { + + @RequestLine("GET /") + InputStream getLargeStream(); + + @RequestLine("GET /") + Reader getLargeReader(); + } + + @Test + void streamingResponse() throws Exception { + + server.start(); + + byte[] expectedResponse = new byte[16184]; + new Random().nextBytes(expectedResponse); + server.enqueue( + new MockResponse.Builder() + .body(new BufferMockResponseBody(new Buffer().write(expectedResponse))) + .build()); + + LargeStreamTestInterface api = + Feign.builder() + .decoder(new InputStreamAndReaderDecoder(null)) + .target(LargeStreamTestInterface.class, "http://localhost:" + server.getPort()); + + try (InputStream is = api.getLargeStream()) { + byte[] out = is.readAllBytes(); + assertThat(out.length).isEqualTo(expectedResponse.length); + assertThat(out).isEqualTo(expectedResponse); + } + } + + @Test + void streamingReaderResponse() throws Exception { + + server.start(); + + String expectedResponse = + new Random() + .ints(1, 1500 + 1) + .limit(16184) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + + server.enqueue( + new MockResponse.Builder() + .body( + new BufferMockResponseBody( + new Buffer().write(expectedResponse.getBytes(StandardCharsets.UTF_16)))) + .addHeader("content-type", "text/plan; charset=utf-16") + .build()); + + LargeStreamTestInterface api = + Feign.builder() + .decoder(new InputStreamAndReaderDecoder(null)) + .target(LargeStreamTestInterface.class, "http://localhost:" + server.getPort()); + + try (Reader r = api.getLargeReader()) { + String out = Util.toString(r); + assertThat(out.length()).isEqualTo(expectedResponse.length()); + assertThat(out).isEqualTo(expectedResponse); + } + } + + @Test + void streamingReaderResponseWithNoCharset() throws Exception { + + server.start(); + + String expectedResponse = + new Random() + .ints(1, 1500 + 1) + .limit(16184) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + + server.enqueue( + new MockResponse.Builder() + .body( + new BufferMockResponseBody( + new Buffer().write(expectedResponse.getBytes(Util.UTF_8)))) + .addHeader("content-type", "text/plan") + .build()); + + LargeStreamTestInterface api = + Feign.builder() + .decoder(new InputStreamAndReaderDecoder(null)) + .target(LargeStreamTestInterface.class, "http://localhost:" + server.getPort()); + + try (Reader r = api.getLargeReader()) { + String out = Util.toString(r); + assertThat(out.length()).isEqualTo(expectedResponse.length()); + assertThat(out).isEqualTo(expectedResponse); + } + } +} From 7e289f7a9136b6c261849f838c9702d4c605658d Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 1 Sep 2026 10:48:51 -0600 Subject: [PATCH 02/11] code formatting --- .../java/feign/core/codec/InputStreamAndReaderDecoderTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java b/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java index 220b21a59a..6afcd7e1ff 100644 --- a/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java +++ b/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java @@ -20,8 +20,6 @@ import feign.Feign; import feign.RequestLine; import feign.Util; -import feign.core.codec.InputStreamAndReaderDecoder; - import java.io.InputStream; import java.io.Reader; import java.nio.charset.StandardCharsets; From 2ebf9577314946e47a93eb390435af1054660500 Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 2 Sep 2026 07:17:29 -0600 Subject: [PATCH 03/11] Apache download no longer hosting mvnd 1.0.2 - moving to 1.0.6 --- .mvn/wrapper/maven-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 332fe58e14..4c1d7756e9 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -15,5 +15,5 @@ # specific language governing permissions and limitations # under the License. wrapperVersion=3.3.1 -distributionUrl=https://downloads.apache.org/maven/mvnd/1.0.2/maven-mvnd-1.0.2-linux-amd64.zip +distributionUrl=https://downloads.apache.org/maven/mvnd/1.0.6/maven-mvnd-1.0.6-linux-amd64.zip distributionType=mvnd From 683b2297c817cf6e4a056edd4b5302bad3312b5e Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 2 Sep 2026 07:35:38 -0600 Subject: [PATCH 04/11] Fix formatting --- core/src/main/java/feign/core/codec/DefaultDecoder.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/feign/core/codec/DefaultDecoder.java b/core/src/main/java/feign/core/codec/DefaultDecoder.java index 333bd1b0f4..bb00bd1aa3 100644 --- a/core/src/main/java/feign/core/codec/DefaultDecoder.java +++ b/core/src/main/java/feign/core/codec/DefaultDecoder.java @@ -15,11 +15,10 @@ */ package feign.core.codec; -import java.io.IOException; -import java.lang.reflect.Type; - import feign.Response; import feign.Util; +import java.io.IOException; +import java.lang.reflect.Type; public class DefaultDecoder extends StringDecoder { @@ -30,7 +29,7 @@ public Object decode(Response response, Type type) throws IOException { if (byte[].class.equals(type)) { return Util.toByteArray(response.body().asInputStream()); } - + return super.decode(response, type); } } From 274fba4686e71185338da1e382a8aad4e168159a Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 2 Sep 2026 09:26:07 -0600 Subject: [PATCH 05/11] Make InputStreamAndReaderDecoder a PredicatedDecoder --- .../codec/InputStreamAndReaderDecoder.java | 33 +++++++++++-------- .../InputStreamAndReaderDecoderTest.java | 16 +++++---- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java b/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java index 54564ade12..e5590b5211 100644 --- a/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java +++ b/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java @@ -15,23 +15,21 @@ */ package feign.core.codec; -import feign.FeignException; -import feign.Response; -import feign.Util; -import feign.codec.DecodeException; -import feign.codec.Decoder; -import feign.utils.ContentTypeParser; +import static java.lang.String.format; + import java.io.IOException; import java.io.InputStream; import java.io.Reader; import java.lang.reflect.Type; -public class InputStreamAndReaderDecoder implements Decoder { - private final Decoder delegateDecoder; +import feign.FeignException; +import feign.Response; +import feign.Util; +import feign.codec.DecodeException; +import feign.codec.PredicatedDecoder; +import feign.utils.ContentTypeParser; - public InputStreamAndReaderDecoder(Decoder delegate) { - this.delegateDecoder = delegate; - } +public class InputStreamAndReaderDecoder implements PredicatedDecoder { @Override public Object decode(Response response, Type type) @@ -47,8 +45,17 @@ public Object decode(Response response, Type type) .map(ctr -> ctr.getCharset().orElse(Util.UTF_8)) .orElse(Util.UTF_8)); - if (delegateDecoder == null) return null; + throw new DecodeException( + response.status(), + format("%s is not a type supported by this decoder.", type), + response.request()); + } - return delegateDecoder.decode(response, type); + @Override + public boolean canDecode(Response response, Type type) { + if (InputStream.class.equals(type)) return true; + if (Reader.class.equals(type)) return true; + + return false; } } diff --git a/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java b/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java index 6afcd7e1ff..093f7830b5 100644 --- a/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java +++ b/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java @@ -17,18 +17,20 @@ import static org.assertj.core.api.Assertions.assertThat; -import feign.Feign; -import feign.RequestLine; -import feign.Util; import java.io.InputStream; import java.io.Reader; import java.nio.charset.StandardCharsets; import java.util.Random; + +import org.junit.jupiter.api.Test; + +import feign.Feign; +import feign.RequestLine; +import feign.Util; import mockwebserver3.MockResponse; import mockwebserver3.MockWebServer; import mockwebserver3.internal.BufferMockResponseBody; import okio.Buffer; -import org.junit.jupiter.api.Test; public class InputStreamAndReaderDecoderTest { public final MockWebServer server = new MockWebServer(); @@ -56,7 +58,7 @@ void streamingResponse() throws Exception { LargeStreamTestInterface api = Feign.builder() - .decoder(new InputStreamAndReaderDecoder(null)) + .decoder(new InputStreamAndReaderDecoder()) .target(LargeStreamTestInterface.class, "http://localhost:" + server.getPort()); try (InputStream is = api.getLargeStream()) { @@ -88,7 +90,7 @@ void streamingReaderResponse() throws Exception { LargeStreamTestInterface api = Feign.builder() - .decoder(new InputStreamAndReaderDecoder(null)) + .decoder(new InputStreamAndReaderDecoder()) .target(LargeStreamTestInterface.class, "http://localhost:" + server.getPort()); try (Reader r = api.getLargeReader()) { @@ -120,7 +122,7 @@ void streamingReaderResponseWithNoCharset() throws Exception { LargeStreamTestInterface api = Feign.builder() - .decoder(new InputStreamAndReaderDecoder(null)) + .decoder(new InputStreamAndReaderDecoder()) .target(LargeStreamTestInterface.class, "http://localhost:" + server.getPort()); try (Reader r = api.getLargeReader()) { From 5a55368b222e9088bb8f6159cb29a125679964ab Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 2 Sep 2026 09:27:52 -0600 Subject: [PATCH 06/11] Fix formatting --- .../codec/InputStreamAndReaderDecoder.java | 19 +++++++++---------- .../InputStreamAndReaderDecoderTest.java | 10 ++++------ 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java b/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java index e5590b5211..9e98e4e091 100644 --- a/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java +++ b/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java @@ -17,17 +17,16 @@ import static java.lang.String.format; -import java.io.IOException; -import java.io.InputStream; -import java.io.Reader; -import java.lang.reflect.Type; - import feign.FeignException; import feign.Response; import feign.Util; import feign.codec.DecodeException; import feign.codec.PredicatedDecoder; import feign.utils.ContentTypeParser; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.lang.reflect.Type; public class InputStreamAndReaderDecoder implements PredicatedDecoder { @@ -46,16 +45,16 @@ public Object decode(Response response, Type type) .orElse(Util.UTF_8)); throw new DecodeException( - response.status(), - format("%s is not a type supported by this decoder.", type), - response.request()); + response.status(), + format("%s is not a type supported by this decoder.", type), + response.request()); } @Override public boolean canDecode(Response response, Type type) { if (InputStream.class.equals(type)) return true; if (Reader.class.equals(type)) return true; - - return false; + + return false; } } diff --git a/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java b/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java index 093f7830b5..912fadbae9 100644 --- a/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java +++ b/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java @@ -17,20 +17,18 @@ import static org.assertj.core.api.Assertions.assertThat; +import feign.Feign; +import feign.RequestLine; +import feign.Util; import java.io.InputStream; import java.io.Reader; import java.nio.charset.StandardCharsets; import java.util.Random; - -import org.junit.jupiter.api.Test; - -import feign.Feign; -import feign.RequestLine; -import feign.Util; import mockwebserver3.MockResponse; import mockwebserver3.MockWebServer; import mockwebserver3.internal.BufferMockResponseBody; import okio.Buffer; +import org.junit.jupiter.api.Test; public class InputStreamAndReaderDecoderTest { public final MockWebServer server = new MockWebServer(); From f3612cf4351370049b2a8caf50962983ec160996 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 8 Sep 2026 11:01:51 -0600 Subject: [PATCH 07/11] Make sure mock web server gets closed --- .../InputStreamAndReaderDecoderTest.java | 145 +++++++++--------- 1 file changed, 76 insertions(+), 69 deletions(-) diff --git a/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java b/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java index 912fadbae9..6e54b65943 100644 --- a/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java +++ b/core/src/test/java/feign/core/codec/InputStreamAndReaderDecoderTest.java @@ -31,7 +31,6 @@ import org.junit.jupiter.api.Test; public class InputStreamAndReaderDecoderTest { - public final MockWebServer server = new MockWebServer(); interface LargeStreamTestInterface { @@ -45,88 +44,96 @@ interface LargeStreamTestInterface { @Test void streamingResponse() throws Exception { - server.start(); + try (MockWebServer server = new MockWebServer()) { - byte[] expectedResponse = new byte[16184]; - new Random().nextBytes(expectedResponse); - server.enqueue( - new MockResponse.Builder() - .body(new BufferMockResponseBody(new Buffer().write(expectedResponse))) - .build()); + server.start(); - LargeStreamTestInterface api = - Feign.builder() - .decoder(new InputStreamAndReaderDecoder()) - .target(LargeStreamTestInterface.class, "http://localhost:" + server.getPort()); + byte[] expectedResponse = new byte[16184]; + new Random().nextBytes(expectedResponse); + server.enqueue( + new MockResponse.Builder() + .body(new BufferMockResponseBody(new Buffer().write(expectedResponse))) + .build()); - try (InputStream is = api.getLargeStream()) { - byte[] out = is.readAllBytes(); - assertThat(out.length).isEqualTo(expectedResponse.length); - assertThat(out).isEqualTo(expectedResponse); + LargeStreamTestInterface api = + Feign.builder() + .decoder(new InputStreamAndReaderDecoder()) + .target(LargeStreamTestInterface.class, "http://localhost:" + server.getPort()); + + try (InputStream is = api.getLargeStream()) { + byte[] out = is.readAllBytes(); + assertThat(out.length).isEqualTo(expectedResponse.length); + assertThat(out).isEqualTo(expectedResponse); + } } } @Test void streamingReaderResponse() throws Exception { - - server.start(); - - String expectedResponse = - new Random() - .ints(1, 1500 + 1) - .limit(16184) - .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) - .toString(); - - server.enqueue( - new MockResponse.Builder() - .body( - new BufferMockResponseBody( - new Buffer().write(expectedResponse.getBytes(StandardCharsets.UTF_16)))) - .addHeader("content-type", "text/plan; charset=utf-16") - .build()); - - LargeStreamTestInterface api = - Feign.builder() - .decoder(new InputStreamAndReaderDecoder()) - .target(LargeStreamTestInterface.class, "http://localhost:" + server.getPort()); - - try (Reader r = api.getLargeReader()) { - String out = Util.toString(r); - assertThat(out.length()).isEqualTo(expectedResponse.length()); - assertThat(out).isEqualTo(expectedResponse); + try (MockWebServer server = new MockWebServer()) { + + server.start(); + + String expectedResponse = + new Random() + .ints(1, 1500 + 1) + .limit(16184) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + + server.enqueue( + new MockResponse.Builder() + .body( + new BufferMockResponseBody( + new Buffer().write(expectedResponse.getBytes(StandardCharsets.UTF_16)))) + .addHeader("content-type", "text/plan; charset=utf-16") + .build()); + + LargeStreamTestInterface api = + Feign.builder() + .decoder(new InputStreamAndReaderDecoder()) + .target(LargeStreamTestInterface.class, "http://localhost:" + server.getPort()); + + try (Reader r = api.getLargeReader()) { + String out = Util.toString(r); + assertThat(out.length()).isEqualTo(expectedResponse.length()); + assertThat(out).isEqualTo(expectedResponse); + } } } @Test void streamingReaderResponseWithNoCharset() throws Exception { - server.start(); - - String expectedResponse = - new Random() - .ints(1, 1500 + 1) - .limit(16184) - .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) - .toString(); - - server.enqueue( - new MockResponse.Builder() - .body( - new BufferMockResponseBody( - new Buffer().write(expectedResponse.getBytes(Util.UTF_8)))) - .addHeader("content-type", "text/plan") - .build()); - - LargeStreamTestInterface api = - Feign.builder() - .decoder(new InputStreamAndReaderDecoder()) - .target(LargeStreamTestInterface.class, "http://localhost:" + server.getPort()); - - try (Reader r = api.getLargeReader()) { - String out = Util.toString(r); - assertThat(out.length()).isEqualTo(expectedResponse.length()); - assertThat(out).isEqualTo(expectedResponse); + try (MockWebServer server = new MockWebServer()) { + + server.start(); + + String expectedResponse = + new Random() + .ints(1, 1500 + 1) + .limit(16184) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + + server.enqueue( + new MockResponse.Builder() + .body( + new BufferMockResponseBody( + new Buffer().write(expectedResponse.getBytes(Util.UTF_8)))) + .addHeader("content-type", "text/plan") + .build()); + + LargeStreamTestInterface api = + Feign.builder() + .decoder(new InputStreamAndReaderDecoder()) + .target(LargeStreamTestInterface.class, "http://localhost:" + server.getPort()); + + try (Reader r = api.getLargeReader()) { + String out = Util.toString(r); + assertThat(out.length()).isEqualTo(expectedResponse.length()); + assertThat(out).isEqualTo(expectedResponse); + } } } } From 660459a68419bdfbb6aca75a51cb919440575f12 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 8 Sep 2026 16:06:20 -0600 Subject: [PATCH 08/11] Parse charset using existing code --- .../java/feign/utils/ContentTypeParser.java | 108 ------------------ .../codec/InputStreamAndReaderDecoder.java | 16 +-- 2 files changed, 6 insertions(+), 118 deletions(-) delete mode 100644 api/src/main/java/feign/utils/ContentTypeParser.java diff --git a/api/src/main/java/feign/utils/ContentTypeParser.java b/api/src/main/java/feign/utils/ContentTypeParser.java deleted file mode 100644 index 2acc800aff..0000000000 --- a/api/src/main/java/feign/utils/ContentTypeParser.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) - * - * 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 feign.utils; - -import feign.Util; -import java.nio.charset.Charset; -import java.util.Collection; -import java.util.Collections; -import java.util.Map; -import java.util.Optional; - -public final class ContentTypeParser { - - private ContentTypeParser() {} - - /** - * Parses and returns information about the Content-Type header - * - * @param headers the headers to parse - * @return a ContentTypeResult that has the content-type information (or Optional.empty() if the - * Content-Type header is not in the headers) - */ - public static Optional parseContentTypeFromHeaders( - Map> headers) { - // The header map *should* be a case insensitive treemap - for (String val : headers.getOrDefault(Util.CONTENT_TYPE, Collections.emptyList())) { - return Optional.of(parseContentTypeHeader(val)); - } - - return Optional.empty(); - } - - /** - * Parses and returns information about a string that is a valid formatting Content-Type header - * value - * - * @param contentTypeHeader the Content-Type header value to parse - * @return a ContentTypeResult that has the content-type information (or Optional.empty() if the - * Content-Type header is not in the headers) - */ - public static ContentTypeResult parseContentTypeHeader(String contentTypeHeader) { - - String[] contentTypeParmeters = contentTypeHeader.split(";"); - String contentType = contentTypeParmeters[0]; - String charsetString = ""; - if (contentTypeParmeters.length > 1) { - String[] charsetParts = contentTypeParmeters[1].split("="); - if (charsetParts.length == 2 && "charset".equalsIgnoreCase(charsetParts[0].trim())) { - // TODO: 20260727 - this doesn't implement the full parser definition for the content-type - // header (esp related to quoted strings, etc...) - see - // https://www.w3.org/Protocols/rfc1341/4_Content-Type.html - charsetString = charsetParts[1].trim(); - if (charsetString.length() > 1 - && charsetString.startsWith("\"") - && charsetString.endsWith("\"")) - charsetString = charsetString.substring(1, charsetString.length() - 1); - } - } - - return new ContentTypeResult(contentType, charsetOrNull(charsetString)); - } - - private static Charset charsetOrNull(String charsetStr) { - - try { - return Charset.forName(charsetStr); - } catch (Exception e) { - return null; - } - } - - /** Represents the parsed results of a Content-Type header */ - public static class ContentTypeResult { - public static final ContentTypeResult MISSING = new ContentTypeResult("", null); - - /** The content type portion of the header string */ - private String contentType; - - /** The charset portion of the header string (if specified) */ - private Optional charset; - - public ContentTypeResult(String contentType, Charset charset) { - this.contentType = contentType; - this.charset = Optional.ofNullable(charset); - } - - public String getContentType() { - return contentType; - } - - public Optional getCharset() { - return charset; - } - } -} diff --git a/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java b/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java index 9e98e4e091..0dcdd9c24d 100644 --- a/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java +++ b/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java @@ -17,17 +17,16 @@ import static java.lang.String.format; -import feign.FeignException; -import feign.Response; -import feign.Util; -import feign.codec.DecodeException; -import feign.codec.PredicatedDecoder; -import feign.utils.ContentTypeParser; import java.io.IOException; import java.io.InputStream; import java.io.Reader; import java.lang.reflect.Type; +import feign.FeignException; +import feign.Response; +import feign.codec.DecodeException; +import feign.codec.PredicatedDecoder; + public class InputStreamAndReaderDecoder implements PredicatedDecoder { @Override @@ -39,10 +38,7 @@ public Object decode(Response response, Type type) if (Reader.class.equals(type)) return response .body() - .asReader( - ContentTypeParser.parseContentTypeFromHeaders(response.headers()) - .map(ctr -> ctr.getCharset().orElse(Util.UTF_8)) - .orElse(Util.UTF_8)); + .asReader(response.charset()); throw new DecodeException( response.status(), From 6702ab5fd016d9cd31dc162844513bf1a46de351 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 8 Sep 2026 16:20:20 -0600 Subject: [PATCH 09/11] Kill accidental diff --- core/src/main/java/feign/core/codec/DefaultDecoder.java | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/main/java/feign/core/codec/DefaultDecoder.java b/core/src/main/java/feign/core/codec/DefaultDecoder.java index bb00bd1aa3..663cc02248 100644 --- a/core/src/main/java/feign/core/codec/DefaultDecoder.java +++ b/core/src/main/java/feign/core/codec/DefaultDecoder.java @@ -29,7 +29,6 @@ public Object decode(Response response, Type type) throws IOException { if (byte[].class.equals(type)) { return Util.toByteArray(response.body().asInputStream()); } - return super.decode(response, type); } } From c0352093f490f82a64c2919e6db656f071d24ef0 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 8 Sep 2026 16:20:40 -0600 Subject: [PATCH 10/11] Revert change - it should come in from a merge from master into 14.x, not in this PR. --- .mvn/wrapper/maven-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 4c1d7756e9..332fe58e14 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -15,5 +15,5 @@ # specific language governing permissions and limitations # under the License. wrapperVersion=3.3.1 -distributionUrl=https://downloads.apache.org/maven/mvnd/1.0.6/maven-mvnd-1.0.6-linux-amd64.zip +distributionUrl=https://downloads.apache.org/maven/mvnd/1.0.2/maven-mvnd-1.0.2-linux-amd64.zip distributionType=mvnd From 5f5113a4b1a410ec8e84a002f79cb058c5136f5a Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 8 Sep 2026 16:22:20 -0600 Subject: [PATCH 11/11] Fix formatting --- .../core/codec/InputStreamAndReaderDecoder.java | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java b/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java index 0dcdd9c24d..db78f0b4a9 100644 --- a/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java +++ b/core/src/main/java/feign/core/codec/InputStreamAndReaderDecoder.java @@ -17,15 +17,14 @@ import static java.lang.String.format; -import java.io.IOException; -import java.io.InputStream; -import java.io.Reader; -import java.lang.reflect.Type; - import feign.FeignException; import feign.Response; import feign.codec.DecodeException; import feign.codec.PredicatedDecoder; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.lang.reflect.Type; public class InputStreamAndReaderDecoder implements PredicatedDecoder { @@ -35,10 +34,7 @@ public Object decode(Response response, Type type) if (InputStream.class.equals(type)) return response.body().asInputStream(); - if (Reader.class.equals(type)) - return response - .body() - .asReader(response.charset()); + if (Reader.class.equals(type)) return response.body().asReader(response.charset()); throw new DecodeException( response.status(),