diff --git a/core/src/main/java/feign/Util.java b/core/src/main/java/feign/Util.java index 5393a5569..8ecb19970 100644 --- a/core/src/main/java/feign/Util.java +++ b/core/src/main/java/feign/Util.java @@ -69,6 +69,9 @@ public class Util { /** The HTTP Accept-Encoding header field name. */ public static final String ACCEPT_ENCODING = "Accept-Encoding"; + /** The HTTP Accept header field name. */ + public static final String ACCEPT = "Accept"; + /** The HTTP Retry-After header field name. */ public static final String RETRY_AFTER = "Retry-After"; diff --git a/core/src/main/java/feign/codec/DefaultDecoder.java b/core/src/main/java/feign/codec/DefaultDecoder.java index c6ada1025..04bcf1f12 100644 --- a/core/src/main/java/feign/codec/DefaultDecoder.java +++ b/core/src/main/java/feign/codec/DefaultDecoder.java @@ -22,6 +22,19 @@ public class DefaultDecoder extends StringDecoder { + /** + * Accepts exactly what {@link #decode} handles: everything {@link StringDecoder} accepts, plus a + * {@code byte[]} return type. + * + * @param response {@inheritDoc} + * @param type {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public boolean canDecode(Response response, Type type) { + return byte[].class.equals(type) || super.canDecode(response, type); + } + @Override public Object decode(Response response, Type type) throws IOException { if (response.status() == 404 || response.status() == 204) return Util.emptyValueOf(type); diff --git a/core/src/main/java/feign/codec/DefaultEncoder.java b/core/src/main/java/feign/codec/DefaultEncoder.java index 39a0f8dab..bfa8efbdf 100644 --- a/core/src/main/java/feign/codec/DefaultEncoder.java +++ b/core/src/main/java/feign/codec/DefaultEncoder.java @@ -20,7 +20,21 @@ import feign.RequestTemplate; import java.lang.reflect.Type; -public class DefaultEncoder implements Encoder { +public class DefaultEncoder implements PredicatedEncoder { + + /** + * Accepts exactly what {@link #encode} handles: a {@code String} or {@code byte[]} body, and a + * null body, which is sent as no body at all. + * + * @param object {@inheritDoc} + * @param bodyType {@inheritDoc} + * @param template {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return bodyType == String.class || bodyType == byte[].class || object == null; + } @Override public void encode(Object object, Type bodyType, RequestTemplate template) { diff --git a/core/src/main/java/feign/codec/MultiDecoder.java b/core/src/main/java/feign/codec/MultiDecoder.java index f48ee1975..47e54e1d4 100644 --- a/core/src/main/java/feign/codec/MultiDecoder.java +++ b/core/src/main/java/feign/codec/MultiDecoder.java @@ -17,6 +17,7 @@ import feign.Experimental; import feign.FeignException; +import feign.Request; import feign.Response; import feign.Util; import java.io.IOException; @@ -52,11 +53,20 @@ * naming what was tried. Add a decoder guarded by {@link DecoderPredicate#any()} last to act as a * default, as above. * + *

A multi-decoder is itself a {@link PredicatedDecoder}, accepting whatever any of its decoders + * accepts, so one can be added to another. That is how a library ships a set of decoders as a + * single unit: given a hypothetical {@code AcmeFeign.decoders()} returning a multi-decoder over + * that library's decoders, the whole set is added in one go: + * + *

+ * Feign.builder().decoders(AcmeFeign.decoders(), new JacksonDecoder());
+ * 
+ * * @see PredicatedDecoder * @see DecoderPredicate */ @Experimental -public class MultiDecoder implements Decoder { +public class MultiDecoder implements PredicatedDecoder { private final List decoders; @@ -69,6 +79,18 @@ public static Builder builder() { return new Builder(); } + /** + * Whether any of the decoders accepts the response. + * + * @param response {@inheritDoc} + * @param type {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public boolean canDecode(Response response, Type type) { + return decoders.stream().anyMatch(decoder -> decoder.canDecode(response, type)); + } + /** * Decodes using the first decoder that accepts the response. * @@ -95,31 +117,61 @@ private String unableToDecode(Response response, Type type) { StringBuilder message = new StringBuilder("Unable to decode ") .append(response.status()) - .append(" response (Content-Type: ") - .append(contentTypes(response)) + .append(" response (") + .append(headers(response)) .append(") as ") - .append(type == null ? "the expected type" : type.getTypeName()); - if (decoders.isEmpty()) { - return message.append(". No decoders were configured.").toString(); - } - message.append(". Decoders tried, in order:"); - for (PredicatedDecoder decoder : decoders) { - message.append("\n - ").append(PairedDecoder.describe(decoder)); - } + .append(type == null ? "the expected type" : type.getTypeName()) + .append(". Decoders tried, in order:"); + appendTo(message, "\n "); return message - .append("\nAdd a decoder guarded by DecoderPredicate.any() last to act as a default.") + .append("\nRegister a decoder that accepts it, or add a catch-all") + .append(" (DecoderPredicate.any()) last.") .toString(); } - private static String contentTypes(Response response) { - String contentTypes = - response.headers().entrySet().stream() - .filter(header -> Util.CONTENT_TYPE.equalsIgnoreCase(header.getKey())) + /** + * Lists the decoders one per line, unfolding nested multi-decoders so that a set contributed as a + * single unit still shows what it contains. + */ + private void appendTo(StringBuilder message, String indent) { + for (PredicatedDecoder decoder : decoders) { + if (decoder instanceof MultiDecoder) { + message.append(indent).append("- MultiDecoder:"); + ((MultiDecoder) decoder).appendTo(message, indent + " "); + } else { + message.append(indent).append("- ").append(PairedDecoder.describe(decoder)); + } + } + } + + /** + * The headers a decoder is most likely to have been chosen on: what came back, and what was asked + * for. Everything else a predicate looks at belongs in that predicate's own description, which is + * listed alongside it. + */ + private static String headers(Response response) { + String contentType = header(response.headers(), Util.CONTENT_TYPE); + StringBuilder headers = + new StringBuilder(Util.CONTENT_TYPE) + .append(": ") + .append(contentType == null ? "not set" : contentType); + Request request = response.request(); + String accept = request == null ? null : header(request.headers(), Util.ACCEPT); + if (accept != null) { + headers.append(", ").append(Util.ACCEPT).append(": ").append(accept); + } + return headers.toString(); + } + + private static String header(Map> headers, String name) { + String values = + headers.entrySet().stream() + .filter(header -> name.equalsIgnoreCase(header.getKey())) .map(Map.Entry::getValue) .filter(Objects::nonNull) .flatMap(Collection::stream) .collect(Collectors.joining(", ")); - return contentTypes.isEmpty() ? "not set" : contentTypes; + return values.isEmpty() ? null : values; } @Override @@ -148,7 +200,10 @@ public Builder add(PredicatedDecoder decoder) { /** * Adds any decoder, guarded by the given predicate. Use this for decoders that do not implement - * {@link PredicatedDecoder}, including ones you do not control. + * {@link PredicatedDecoder}, including ones you do not control. The predicate is the whole + * answer: whatever the decoder may declare about itself is replaced, so this can widen a + * decoder as well as narrow it. Use {@link #narrow(DecoderPredicate, Decoder)} to keep the + * decoder's own declaration. * * @param predicate decides whether the decoder handles a response * @param decoder the decoder to delegate to @@ -157,8 +212,34 @@ public Builder add(DecoderPredicate predicate, Decoder decoder) { return add(PredicatedDecoder.of(predicate, decoder)); } - /** Builds the multi-decoder. */ + /** + * Adds a decoder, narrowed by the given predicate. If the decoder is itself a {@link + * PredicatedDecoder}, the predicate applies in addition to the decoder's own {@code canDecode} + * rather than instead of it: both have to accept the response. + * + *
+     * MultiDecoder.builder()
+     *     .narrow(DecoderPredicate.status(200), new JacksonDecoder())
+     *     .add(DecoderPredicate.any(), new DefaultDecoder())
+     *     .build();
+     * 
+ * + * @param predicate narrows what the decoder handles + * @param decoder the decoder to delegate to + */ + public Builder narrow(DecoderPredicate predicate, Decoder decoder) { + return add(PredicatedDecoder.narrowing(predicate, decoder)); + } + + /** + * Builds the multi-decoder. + * + * @throws IllegalStateException if no decoder was added + */ public MultiDecoder build() { + if (decoders.isEmpty()) { + throw new IllegalStateException("at least one decoder is required"); + } return new MultiDecoder(decoders); } } diff --git a/core/src/main/java/feign/codec/MultiEncoder.java b/core/src/main/java/feign/codec/MultiEncoder.java index 23feaf35b..b17b615ed 100644 --- a/core/src/main/java/feign/codec/MultiEncoder.java +++ b/core/src/main/java/feign/codec/MultiEncoder.java @@ -50,11 +50,20 @@ * naming what was tried. Add an encoder guarded by {@link EncoderPredicate#any()} last to act as a * default, as above. * + *

A multi-encoder is itself a {@link PredicatedEncoder}, accepting whatever any of its encoders + * accepts, so one can be added to another. That is how a library ships a set of encoders as a + * single unit: given a hypothetical {@code AcmeFeign.encoders()} returning a multi-encoder over + * that library's encoders, the whole set is added in one go: + * + *

+ * Feign.builder().encoders(AcmeFeign.encoders(), new JacksonEncoder());
+ * 
+ * * @see PredicatedEncoder * @see EncoderPredicate */ @Experimental -public class MultiEncoder implements Encoder { +public class MultiEncoder implements PredicatedEncoder { private final List encoders; @@ -67,6 +76,19 @@ public static Builder builder() { return new Builder(); } + /** + * Whether any of the encoders accepts the request. + * + * @param object {@inheritDoc} + * @param bodyType {@inheritDoc} + * @param template {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return encoders.stream().anyMatch(encoder -> encoder.canEncode(object, bodyType, template)); + } + /** * Encodes using the first encoder that accepts the request. * @@ -91,33 +113,61 @@ private String unableToEncode(Type bodyType, RequestTemplate template) { StringBuilder message = new StringBuilder("Unable to encode ") .append(bodyType == null ? "request body" : bodyType.getTypeName()) - .append(" (Content-Type: ") - .append(contentTypes(template)) + .append(" (") + .append(headers(template)) .append(')'); if (template.method() != null) { message.append(" for ").append(template.method()).append(' ').append(template.path()); } - if (encoders.isEmpty()) { - return message.append(". No encoders were configured.").toString(); - } message.append(". Encoders tried, in order:"); - for (PredicatedEncoder encoder : encoders) { - message.append("\n - ").append(PairedEncoder.describe(encoder)); - } + appendTo(message, "\n "); return message - .append("\nAdd an encoder guarded by EncoderPredicate.any() last to act as a default.") + .append("\nRegister an encoder that accepts it, or add a catch-all") + .append(" (EncoderPredicate.any()) last.") .toString(); } - private static String contentTypes(RequestTemplate template) { - String contentTypes = + /** + * Lists the encoders one per line, unfolding nested multi-encoders so that a set contributed as a + * single unit still shows what it contains. + */ + private void appendTo(StringBuilder message, String indent) { + for (PredicatedEncoder encoder : encoders) { + if (encoder instanceof MultiEncoder) { + message.append(indent).append("- MultiEncoder:"); + ((MultiEncoder) encoder).appendTo(message, indent + " "); + } else { + message.append(indent).append("- ").append(PairedEncoder.describe(encoder)); + } + } + } + + /** + * The headers an encoder is most likely to have been chosen on. Everything else a predicate looks + * at belongs in that predicate's own description, which is listed alongside it. + */ + private static String headers(RequestTemplate template) { + String contentType = header(template, Util.CONTENT_TYPE); + StringBuilder headers = + new StringBuilder(Util.CONTENT_TYPE) + .append(": ") + .append(contentType == null ? "not set" : contentType); + String accept = header(template, Util.ACCEPT); + if (accept != null) { + headers.append(", ").append(Util.ACCEPT).append(": ").append(accept); + } + return headers.toString(); + } + + private static String header(RequestTemplate template, String name) { + String values = template.headers().entrySet().stream() - .filter(header -> Util.CONTENT_TYPE.equalsIgnoreCase(header.getKey())) + .filter(header -> name.equalsIgnoreCase(header.getKey())) .map(Map.Entry::getValue) .filter(Objects::nonNull) .flatMap(Collection::stream) .collect(Collectors.joining(", ")); - return contentTypes.isEmpty() ? "not set" : contentTypes; + return values.isEmpty() ? null : values; } @Override @@ -146,7 +196,10 @@ public Builder add(PredicatedEncoder encoder) { /** * Adds any encoder, guarded by the given predicate. Use this for encoders that do not implement - * {@link PredicatedEncoder}, including ones you do not control. + * {@link PredicatedEncoder}, including ones you do not control. The predicate is the whole + * answer: whatever the encoder may declare about itself is replaced, so this can widen an + * encoder as well as narrow it. Use {@link #narrow(EncoderPredicate, Encoder)} to keep the + * encoder's own declaration. * * @param predicate decides whether the encoder handles a request * @param encoder the encoder to delegate to @@ -155,8 +208,34 @@ public Builder add(EncoderPredicate predicate, Encoder encoder) { return add(PredicatedEncoder.of(predicate, encoder)); } - /** Builds the multi-encoder. */ + /** + * Adds an encoder, narrowed by the given predicate. If the encoder is itself a {@link + * PredicatedEncoder}, the predicate applies in addition to the encoder's own {@code canEncode} + * rather than instead of it: both have to accept the request. + * + *
+     * MultiEncoder.builder()
+     *     .narrow(EncoderPredicate.contentType("application/vnd.acme+json"), new GsonEncoder())
+     *     .add(EncoderPredicate.any(), new DefaultEncoder())
+     *     .build();
+     * 
+ * + * @param predicate narrows what the encoder handles + * @param encoder the encoder to delegate to + */ + public Builder narrow(EncoderPredicate predicate, Encoder encoder) { + return add(PredicatedEncoder.narrowing(predicate, encoder)); + } + + /** + * Builds the multi-encoder. + * + * @throws IllegalStateException if no encoder was added + */ public MultiEncoder build() { + if (encoders.isEmpty()) { + throw new IllegalStateException("at least one encoder is required"); + } return new MultiEncoder(encoders); } } diff --git a/core/src/main/java/feign/codec/PredicatedDecoder.java b/core/src/main/java/feign/codec/PredicatedDecoder.java index d8d48467a..9b3de742c 100644 --- a/core/src/main/java/feign/codec/PredicatedDecoder.java +++ b/core/src/main/java/feign/codec/PredicatedDecoder.java @@ -71,6 +71,9 @@ public interface PredicatedDecoder extends Decoder { * PredicatedDecoder.of(DecoderPredicate.any(), new DefaultDecoder())); * * + *

The predicate is used instead of the decoder's own, not in addition to it. {@link + * MultiDecoder.Builder#add(DecoderPredicate, Decoder)} is the same thing at the call site. + * * @param predicate decides whether the decoder handles a response * @param decoder the decoder to delegate to */ @@ -90,6 +93,9 @@ static PredicatedDecoder of(DecoderPredicate predicate, Decoder decoder) { *

A decoder that does not implement {@link PredicatedDecoder} declares nothing to narrow, so * this behaves like {@link #of(DecoderPredicate, Decoder)}. * + *

The predicate is used in addition to the decoder's own, not instead of it. {@link + * MultiDecoder.Builder#narrow(DecoderPredicate, Decoder)} is the same thing at the call site. + * * @param predicate narrows what the decoder handles * @param decoder the decoder to delegate to */ diff --git a/core/src/main/java/feign/codec/PredicatedEncoder.java b/core/src/main/java/feign/codec/PredicatedEncoder.java index f9cd135ac..56cc30c87 100644 --- a/core/src/main/java/feign/codec/PredicatedEncoder.java +++ b/core/src/main/java/feign/codec/PredicatedEncoder.java @@ -71,6 +71,9 @@ public interface PredicatedEncoder extends Encoder { * PredicatedEncoder.of(EncoderPredicate.any(), new Encoder.Default())); * * + *

The predicate is used instead of the encoder's own, not in addition to it. {@link + * MultiEncoder.Builder#add(EncoderPredicate, Encoder)} is the same thing at the call site. + * * @param predicate decides whether the encoder handles a request * @param encoder the encoder to delegate to */ @@ -90,6 +93,9 @@ static PredicatedEncoder of(EncoderPredicate predicate, Encoder encoder) { *

An encoder that does not implement {@link PredicatedEncoder} declares nothing to narrow, so * this behaves like {@link #of(EncoderPredicate, Encoder)}. * + *

The predicate is used in addition to the encoder's own, not instead of it. {@link + * MultiEncoder.Builder#narrow(EncoderPredicate, Encoder)} is the same thing at the call site. + * * @param predicate narrows what the encoder handles * @param encoder the encoder to delegate to */ diff --git a/core/src/main/java/feign/codec/StringDecoder.java b/core/src/main/java/feign/codec/StringDecoder.java index 0982110b4..e88649b8a 100644 --- a/core/src/main/java/feign/codec/StringDecoder.java +++ b/core/src/main/java/feign/codec/StringDecoder.java @@ -22,7 +22,23 @@ import java.io.IOException; import java.lang.reflect.Type; -public class StringDecoder implements Decoder { +public class StringDecoder implements PredicatedDecoder { + + /** + * Accepts exactly what {@link #decode} handles: a {@code String} return type, and any type at all + * when there is no body to read. + * + * @param response {@inheritDoc} + * @param type {@inheritDoc} + * @return {@inheritDoc} + */ + @Override + public boolean canDecode(Response response, Type type) { + return response.status() == 404 + || response.status() == 204 + || response.body() == null + || String.class.equals(type); + } @Override public Object decode(Response response, Type type) throws IOException { diff --git a/core/src/test/java/feign/codec/DefaultDecoderTest.java b/core/src/test/java/feign/codec/DefaultDecoderTest.java index a88ff4dd8..d2bc51568 100644 --- a/core/src/test/java/feign/codec/DefaultDecoderTest.java +++ b/core/src/test/java/feign/codec/DefaultDecoderTest.java @@ -36,7 +36,15 @@ @SuppressWarnings("deprecation") class DefaultDecoderTest { - private final Decoder decoder = new DefaultDecoder(); + private final DefaultDecoder decoder = new DefaultDecoder(); + + @Test + void declaresTheTypesItDecodes() throws Exception { + assertThat(decoder.canDecode(knownResponse(), String.class)).isTrue(); + assertThat(decoder.canDecode(knownResponse(), byte[].class)).isTrue(); + assertThat(decoder.canDecode(knownResponse(), Document.class)).isFalse(); + assertThat(decoder.canDecode(nullBodyResponse(), Document.class)).isTrue(); + } @Test void decodesToString() throws Exception { diff --git a/core/src/test/java/feign/codec/DefaultEncoderTest.java b/core/src/test/java/feign/codec/DefaultEncoderTest.java index 9aecbb905..0770161cb 100644 --- a/core/src/test/java/feign/codec/DefaultEncoderTest.java +++ b/core/src/test/java/feign/codec/DefaultEncoderTest.java @@ -26,7 +26,16 @@ class DefaultEncoderTest { - private final Encoder encoder = new DefaultEncoder(); + private final DefaultEncoder encoder = new DefaultEncoder(); + + @Test + void declaresTheTypesItEncodes() { + RequestTemplate template = new RequestTemplate(); + assertThat(encoder.canEncode("content", String.class, template)).isTrue(); + assertThat(encoder.canEncode(new byte[0], byte[].class, template)).isTrue(); + assertThat(encoder.canEncode(null, Clock.class, template)).isTrue(); + assertThat(encoder.canEncode(Clock.systemUTC(), Clock.class, template)).isFalse(); + } @Test void encodesStrings() throws Exception { diff --git a/core/src/test/java/feign/codec/MultiDecoderTest.java b/core/src/test/java/feign/codec/MultiDecoderTest.java index 5ca7502b1..5bc1aebbe 100644 --- a/core/src/test/java/feign/codec/MultiDecoderTest.java +++ b/core/src/test/java/feign/codec/MultiDecoderTest.java @@ -242,7 +242,8 @@ void throwsWhenNoDecoderAcceptsTheResponse() { + " Decoders tried, in order:" + "\n - SelfDeclaringJsonDecoder" + "\n - RecordingDecoder when Content-Type is XML" - + "\nAdd a decoder guarded by DecoderPredicate.any() last to act as a default."); + + "\nRegister a decoder that accepts it, or add a catch-all" + + " (DecoderPredicate.any()) last."); } @Test @@ -256,14 +257,9 @@ void theFailureReportsAMissingContentType() { @Test void throwsWhenNoDecodersAreConfigured() { - Decoder decoder = MultiDecoder.builder().build(); - - assertThatThrownBy( - () -> decoder.decode(responseWithContentType("application/json"), String.class)) - .isInstanceOf(DecodeException.class) - .hasMessage( - "Unable to decode 200 response (Content-Type: application/json) as java.lang.String." - + " No decoders were configured."); + assertThatThrownBy(() -> MultiDecoder.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("at least one decoder is required"); } @Test @@ -297,6 +293,98 @@ void rejectsNullDecoders() { .hasMessage("decoder cannot be null"); } + @Test + void aMultiDecoderNestsInsideAnother() throws IOException { + RecordingDecoder xml = new RecordingDecoder("xml"); + PredicatedDecoder contributed = + MultiDecoder.builder() + .add(new SelfDeclaringJsonDecoder()) + .add(DecoderPredicate.xmlContentType(), xml) + .build(); + + Decoder decoder = + MultiDecoder.builder() + .add(contributed) + .add(DecoderPredicate.any(), new RecordingDecoder("fallback")) + .build(); + + assertThat(decoder.decode(responseWithContentType("application/xml"), String.class)) + .isEqualTo("xml"); + assertThat(xml.invoked).isTrue(); + } + + @Test + void aNestedMultiDecoderAcceptsWhateverItsDecodersAccept() { + PredicatedDecoder contributed = + MultiDecoder.builder().add(new SelfDeclaringJsonDecoder()).build(); + + assertThat(contributed.canDecode(responseWithContentType("application/json"), String.class)) + .isTrue(); + assertThat(contributed.canDecode(responseWithContentType("text/plain"), String.class)) + .isFalse(); + } + + @Test + void theFailureUnfoldsNestedDecoders() { + PredicatedDecoder contributed = + MultiDecoder.builder() + .add(new SelfDeclaringJsonDecoder()) + .add(DecoderPredicate.xmlContentType(), new RecordingDecoder("xml")) + .build(); + + Decoder decoder = + MultiDecoder.builder().add(new SelfDeclaringJsonDecoder()).add(contributed).build(); + + assertThatThrownBy(() -> decoder.decode(responseWithContentType("text/plain"), String.class)) + .isInstanceOf(DecodeException.class) + .hasMessageContaining( + "Decoders tried, in order:" + + "\n - SelfDeclaringJsonDecoder" + + "\n - MultiDecoder:" + + "\n - SelfDeclaringJsonDecoder" + + "\n - RecordingDecoder when Content-Type is XML"); + } + + @Test + void theFailureReportsTheRequestedAcceptHeader() { + Map> requestHeaders = new HashMap<>(); + requestHeaders.put("Accept", Collections.singletonList("application/json")); + Response response = + Response.builder() + .status(200) + .reason("OK") + .headers( + Collections.singletonMap("Content-Type", Collections.singletonList("text/plain"))) + .request(Request.create(HttpMethod.GET, "/api", requestHeaders, null, Util.UTF_8, null)) + .body("body", Util.UTF_8) + .build(); + + Decoder decoder = MultiDecoder.builder().add(new SelfDeclaringJsonDecoder()).build(); + + assertThatThrownBy(() -> decoder.decode(response, String.class)) + .isInstanceOf(DecodeException.class) + .hasMessageContaining("(Content-Type: text/plain, Accept: application/json)"); + } + + @Test + void narrowKeepsTheDecodersOwnDeclaration() throws IOException { + SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); + Decoder decoder = + MultiDecoder.builder() + .narrow(DecoderPredicate.status(200), json) + .add(DecoderPredicate.any(), new RecordingDecoder("fallback")) + .build(); + + assertThat( + decoder.decode(responseWithContentType("application/json", 500, "body"), String.class)) + .isEqualTo("fallback"); + assertThat(json.invoked).isFalse(); + + assertThat(decoder.decode(responseWithContentType("application/json"), String.class)) + .isEqualTo("json"); + assertThat(json.invoked).isTrue(); + } + @Test void describesItsDecoders() { Decoder decoder = diff --git a/core/src/test/java/feign/codec/MultiEncoderTest.java b/core/src/test/java/feign/codec/MultiEncoderTest.java index 88914e77e..b07f9f702 100644 --- a/core/src/test/java/feign/codec/MultiEncoderTest.java +++ b/core/src/test/java/feign/codec/MultiEncoderTest.java @@ -254,7 +254,8 @@ void throwsWhenNoEncoderAcceptsTheRequest() { + " Encoders tried, in order:" + "\n - SelfDeclaringJsonEncoder" + "\n - RecordingEncoder when Content-Type is XML" - + "\nAdd an encoder guarded by EncoderPredicate.any() last to act as a default."); + + "\nRegister an encoder that accepts it, or add a catch-all" + + " (EncoderPredicate.any()) last."); } @Test @@ -282,14 +283,9 @@ void theFailureReportsAMissingContentType() { @Test void throwsWhenNoEncodersAreConfigured() { - Encoder encoder = MultiEncoder.builder().build(); - - assertThatThrownBy( - () -> encoder.encode("body", String.class, templateWithContentType("application/json"))) - .isInstanceOf(EncodeException.class) - .hasMessage( - "Unable to encode java.lang.String (Content-Type: application/json)." - + " No encoders were configured."); + assertThatThrownBy(() -> MultiEncoder.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("at least one encoder is required"); } @Test @@ -305,6 +301,90 @@ void rejectsNullArguments() { .hasMessage("encoder cannot be null"); } + @Test + void aMultiEncoderNestsInsideAnother() { + RecordingEncoder xml = new RecordingEncoder("xml"); + PredicatedEncoder contributed = + MultiEncoder.builder() + .add(new SelfDeclaringJsonEncoder()) + .add(EncoderPredicate.xmlContentType(), xml) + .build(); + + Encoder encoder = + MultiEncoder.builder() + .add(contributed) + .add(EncoderPredicate.any(), new RecordingEncoder("fallback")) + .build(); + + RequestTemplate template = templateWithContentType("application/xml"); + encoder.encode("body", String.class, template); + + assertThat(xml.invoked).isTrue(); + } + + @Test + void aNestedMultiEncoderAcceptsWhateverItsEncodersAccept() { + PredicatedEncoder contributed = + MultiEncoder.builder().add(new SelfDeclaringJsonEncoder()).build(); + + assertThat( + contributed.canEncode( + "body", String.class, templateWithContentType("application/json"))) + .isTrue(); + assertThat(contributed.canEncode("body", String.class, templateWithContentType("text/plain"))) + .isFalse(); + } + + @Test + void theFailureUnfoldsNestedEncoders() { + PredicatedEncoder contributed = + MultiEncoder.builder() + .add(new SelfDeclaringJsonEncoder()) + .add(EncoderPredicate.xmlContentType(), new RecordingEncoder("xml")) + .build(); + + Encoder encoder = + MultiEncoder.builder().add(new SelfDeclaringJsonEncoder()).add(contributed).build(); + + assertThatThrownBy( + () -> encoder.encode("body", String.class, templateWithContentType("text/plain"))) + .isInstanceOf(EncodeException.class) + .hasMessageContaining( + "Encoders tried, in order:" + + "\n - SelfDeclaringJsonEncoder" + + "\n - MultiEncoder:" + + "\n - SelfDeclaringJsonEncoder" + + "\n - RecordingEncoder when Content-Type is XML"); + } + + @Test + void theFailureReportsTheAcceptHeaderWhenThereIsOne() { + RequestTemplate template = templateWithContentType("text/plain"); + template.header("Accept", "application/json"); + + Encoder encoder = MultiEncoder.builder().add(new SelfDeclaringJsonEncoder()).build(); + + assertThatThrownBy(() -> encoder.encode("body", String.class, template)) + .isInstanceOf(EncodeException.class) + .hasMessageContaining("(Content-Type: text/plain, Accept: application/json)"); + } + + @Test + void narrowKeepsTheEncodersOwnDeclaration() { + SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); + Encoder encoder = + MultiEncoder.builder() + .narrow(EncoderPredicate.bodyType(byte[].class), json) + .add(EncoderPredicate.any(), new RecordingEncoder("fallback")) + .build(); + + encoder.encode("body", String.class, templateWithContentType("application/json")); + assertThat(json.invoked).isFalse(); + + encoder.encode(new byte[0], byte[].class, templateWithContentType("application/json")); + assertThat(json.invoked).isTrue(); + } + @Test void toStringDescribesEncoders() { Encoder encoder = diff --git a/core/src/test/java/feign/codec/StringDecoderTest.java b/core/src/test/java/feign/codec/StringDecoderTest.java new file mode 100644 index 000000000..838f01e47 --- /dev/null +++ b/core/src/test/java/feign/codec/StringDecoderTest.java @@ -0,0 +1,78 @@ +/* + * 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.codec; + +import static feign.Util.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import feign.Request; +import feign.Request.HttpMethod; +import feign.Response; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; + +class StringDecoderTest { + + private final StringDecoder decoder = new StringDecoder(); + + @Test + void declaresTheTypesItDecodes() { + assertThat(decoder.canDecode(knownResponse(200), String.class)).isTrue(); + assertThat(decoder.canDecode(knownResponse(200), byte[].class)).isFalse(); + assertThat(decoder.canDecode(knownResponse(200), Document.class)).isFalse(); + } + + @Test + void acceptsAnyTypeWhenThereIsNoBodyToRead() { + assertThat(decoder.canDecode(nullBodyResponse(), Document.class)).isTrue(); + assertThat(decoder.canDecode(knownResponse(404), Document.class)).isTrue(); + assertThat(decoder.canDecode(knownResponse(204), Document.class)).isTrue(); + } + + @Test + void decodesToString() throws Exception { + assertThat(decoder.decode(knownResponse(200), String.class)).isEqualTo("response body"); + } + + private Response knownResponse(int status) { + String content = "response body"; + InputStream inputStream = new ByteArrayInputStream(content.getBytes(UTF_8)); + Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singleton("text/plain")); + return Response.builder() + .status(status) + .reason("OK") + .headers(headers) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, UTF_8)) + .body(inputStream, content.length()) + .build(); + } + + private Response nullBodyResponse() { + return Response.builder() + .status(200) + .reason("OK") + .headers(Collections.>emptyMap()) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, UTF_8)) + .build(); + } +} diff --git a/form/src/main/java/feign/form/FormEncoder.java b/form/src/main/java/feign/form/FormEncoder.java index 3d2ad676a..19551b0fb 100644 --- a/form/src/main/java/feign/form/FormEncoder.java +++ b/form/src/main/java/feign/form/FormEncoder.java @@ -79,7 +79,10 @@ public FormEncoder() { * * @param delegate delegate encoder, if this encoder couldn't encode object. {@code null} leaves * this encoder without one, in which case anything it cannot encode itself fails with an - * {@link EncodeException} rather than being passed on. + * {@link EncodeException} rather than being passed on. Prefer {@link + * #createPredicatedFormEncoder()} together with {@code BaseBuilder.encoders(...)}: chaining + * belongs there rather than in a delegate, and this constructor is expected to be deprecated + * once that surface stops being experimental. */ public FormEncoder(Encoder delegate) { this.delegate = delegate == null ? NO_DELEGATE : delegate; @@ -112,8 +115,8 @@ public static PredicatedEncoder createPredicatedFormEncoder() { } /** - * The requests a delegate-less form encoder can handle: a form or multipart {@code Content-Type}, - * carrying a body this encoder knows how to turn into fields. + * Creates a predicate for the requests a delegate-less form encoder can handle: a form or + * multipart {@code Content-Type}, carrying a body this encoder knows how to turn into fields. * * @return the predicate */