diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a2b483532..594fff548f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,18 @@
Moshi, Fastjson2, JSON-java) and XML encoders (JAXB, JAXB Jakarta, SOAP, SOAP Jakarta) now declare
themselves, and the metrics modules' `MeteredEncoder` forwards `canEncode` to the encoder it
wraps. The `Encoder` interface is unchanged, so existing encoders keep working (#3485).
+* Add `@Experimental` `MultiDecoder`, `PredicatedDecoder` and `DecoderPredicate`, the decode-side
+ counterpart, letting a single client route each response to the right decoder. Decoders declare
+ what they can handle by implementing `PredicatedDecoder`; anything else is paired with a predicate
+ via `PredicatedDecoder.of(predicate, decoder)` or `MultiDecoder.builder()`. Decoders are consulted
+ in the order given and a response nothing accepts fails with a `DecodeException` naming what was
+ tried, so a default is a decoder guarded by `DecoderPredicate.any()` listed last. The first-party
+ JSON decoders (Gson, Jackson, Jackson 3, Jackson Jr, Jackson JAXB, Moshi, Fastjson2, JSON-java)
+ and XML decoders (JAXB, JAXB Jakarta, SAX, SOAP, SOAP Jakarta) now declare themselves, and
+ `OptionalDecoder` and the metrics modules' `MeteredDecoder` forward `canDecode` to the decoder
+ they wrap. The `Decoder` interface is unchanged, so existing decoders keep working.
+* `JAXBContextFactory.withProperty` is now applied when creating Unmarshallers, not only
+ Marshallers. Marshaller-only properties are skipped on unmarshal (#3056).
* Add support for the HTTP QUERY method (RFC 10008) — safe, idempotent, and cacheable with a
request body. `HttpCacheInterceptor` includes QUERY in its default cacheable set and
incorporates a body hash into the cache key to reduce cross-body collisions.
diff --git a/README.md b/README.md
index 037cbb3188..b45995fd9d 100644
--- a/README.md
+++ b/README.md
@@ -663,6 +663,116 @@ public class Example {
}
```
+#### Multiple decoders
+
+> This API is `@Experimental` and may change incompatibly, or be removed, in a future release.
+
+A single client sometimes has to read more than one format — JSON for most endpoints, XML for
+a legacy one, plain text for a health check. `MultiDecoder` hands each response to the first decoder
+that accepts it.
+
+Most first-party decoders already declare what they can handle, so they can simply be listed, in the
+order they should be consulted:
+
+```java
+interface MixedClient {
+ @RequestLine("GET /orders/{id}")
+ Order order(@Param("id") String id);
+
+ @RequestLine("GET /legacy/orders/{id}")
+ Order legacyOrder(@Param("id") String id);
+}
+
+public class Example {
+ public static void main(String[] args) {
+ MixedClient client = Feign.builder()
+ .decoders(new GsonDecoder(), new JAXBDecoder())
+ .target(MixedClient.class, "https://foo.com");
+ }
+}
+```
+
+Routing is driven by what the server actually sent back, so a client that talks to endpoints
+answering `application/json` and `application/xml` no longer needs one Feign instance per format.
+
+There is no implicit fallback. A response that no decoder accepts fails with a `DecodeException`
+naming the decoders that were tried and what each one wants:
+
+```
+Unable to decode 200 response (Content-Type: text/plain) as com.example.Order. Decoders tried, in order:
+ - GsonDecoder
+ - JAXBDecoder
+Add a decoder guarded by DecoderPredicate.any() last to act as a default.
+```
+
+To get a default, pair a decoder with the predicate that accepts everything and list it **last**:
+
+```java
+Feign.builder()
+ .decoders(
+ new GsonDecoder(),
+ new JAXBDecoder(),
+ PredicatedDecoder.of(DecoderPredicate.any(), new DefaultDecoder()));
+```
+
+The same pairing works for any decoder that does not declare itself, including one you do not
+control. `MultiDecoder.builder()` spells it out when a lambda reads better than a wrapper:
+
+```java
+Decoder decoder =
+ MultiDecoder.builder()
+ .add(new GsonDecoder()) // declares itself
+ .add(DecoderPredicate.xmlContentType(), someXmlDecoder) // paired
+ .add((response, type) -> type == byte[].class, binaryDecoder)
+ .add(DecoderPredicate.any(), new DefaultDecoder()) // the default, last
+ .build();
+```
+
+Decoders are consulted in the order they were added, so put the narrowest one first.
+
+##### Declaring your own decoder
+
+Implement `PredicatedDecoder` and say what you handle. `canDecode` has no default: a decoder that
+declares nothing would claim every response, which is rarely what its author meant.
+
+```java
+public class MyDecoder implements PredicatedDecoder {
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
+
+ @Override
+ public Object decode(Response response, Type type) throws IOException {
+ // ...
+ }
+}
+```
+
+`DecoderPredicate` is the `@FunctionalInterface` here, so predicates can be lambdas. It ships with
+`any()`, `jsonContentType()`, `xmlContentType()`, `contentType(mediaType)`, `emptyBody()`,
+`status(codes...)` and `returnType(type)`, plus `and`/`or`/`negate` to combine them. Each one
+describes itself, which is what shows up in the error message above; wrap your own lambdas in
+`DecoderPredicate.describedAs("it is Tuesday", ...)` to read as well.
+
+`PredicatedDecoder.of(predicate, decoder)` replaces whatever the decoder says about itself, so it
+can widen a decoder as well as narrow it. To keep the decoder's own declaration and add to it, use
+`narrowing`:
+
+```java
+// JSON responses as usual, but only when the call actually succeeded
+PredicatedDecoder.narrowing(DecoderPredicate.status(200, 201), new GsonDecoder());
+```
+
+**Predicates must not read the response body.** For most clients it is a single-pass stream, so
+consuming it in `canDecode` would leave nothing for the decoder that is eventually chosen. Decide
+on the status, the headers and the expected type instead.
+
+**If you wrap a decoder, forward `canDecode` to your delegate**, otherwise wrapping silently changes
+what the decoder handles. `OptionalDecoder` and the metrics modules' `MeteredDecoder` forward for
+exactly this reason.
+
### Encoders
The simplest way to send a request body to a server is to define a `POST` method that has a `String` or `byte[]` parameter without any annotations on it. You will likely need to add a `Content-Type` header.
diff --git a/core/src/main/java/feign/BaseBuilder.java b/core/src/main/java/feign/BaseBuilder.java
index b598888621..81374deb05 100644
--- a/core/src/main/java/feign/BaseBuilder.java
+++ b/core/src/main/java/feign/BaseBuilder.java
@@ -22,13 +22,16 @@
import feign.Request.Options;
import feign.codec.Codec;
import feign.codec.Decoder;
+import feign.codec.DecoderPredicate;
import feign.codec.DefaultDecoder;
import feign.codec.DefaultEncoder;
import feign.codec.DefaultErrorDecoder;
import feign.codec.Encoder;
import feign.codec.EncoderPredicate;
import feign.codec.ErrorDecoder;
+import feign.codec.MultiDecoder;
import feign.codec.MultiEncoder;
+import feign.codec.PredicatedDecoder;
import feign.codec.PredicatedEncoder;
import feign.interceptor.MethodInterceptor;
import feign.interceptor.MethodInterceptors;
@@ -133,6 +136,37 @@ public B decoder(Decoder decoder) {
return thisB();
}
+ /**
+ * Configures a {@link MultiDecoder} built from decoders that declare their own applicability.
+ *
+ *
Decoders are consulted in the order given, and the first one that accepts the response
+ * decodes it. There is no implicit fallback: pair a decoder with {@link DecoderPredicate#any()}
+ * and list it last to act as a default, otherwise a response nothing accepts fails with a {@link
+ * feign.codec.DecodeException}.
+ *
+ *
+ * Feign.builder()
+ * .decoders(
+ * new JacksonDecoder(),
+ * new JAXBDecoder(),
+ * PredicatedDecoder.of(DecoderPredicate.any(), new DefaultDecoder()))
+ *
+ *
+ * To pair a predicate with a decoder that does not implement {@link PredicatedDecoder}, use
+ * {@link PredicatedDecoder#of(DecoderPredicate, Decoder)} as above, or {@link
+ * MultiDecoder#builder()} for the same thing spelled out.
+ *
+ * @param decoders the predicated decoders, consulted in the order given
+ */
+ @Experimental
+ public B decoders(PredicatedDecoder... decoders) {
+ MultiDecoder.Builder builder = MultiDecoder.builder();
+ for (PredicatedDecoder decoder : decoders) {
+ builder.add(decoder);
+ }
+ return decoder(builder.build());
+ }
+
public B codec(Codec codec) {
this.encoder = codec.encoder();
this.decoder = codec.decoder();
diff --git a/core/src/main/java/feign/Util.java b/core/src/main/java/feign/Util.java
index 589ae406ca..5393a5569b 100644
--- a/core/src/main/java/feign/Util.java
+++ b/core/src/main/java/feign/Util.java
@@ -60,12 +60,12 @@ public class Util {
/** The HTTP Content-Length header field name. */
public static final String CONTENT_LENGTH = "Content-Length";
- /** The HTTP Content-Encoding header field name. */
- public static final String CONTENT_ENCODING = "Content-Encoding";
-
/** 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";
+
/** The HTTP Accept-Encoding header field name. */
public static final String ACCEPT_ENCODING = "Accept-Encoding";
@@ -399,6 +399,20 @@ public static boolean isJsonContentType(RequestTemplate template) {
return hasContentTypeMatching(template, JSON_CONTENT_TYPE);
}
+ /**
+ * Checks whether the {@code Content-Type} header of the given response denotes JSON.
+ *
+ *
Matches {@code application/json} as well as suffixed types such as {@code
+ * application/vnd.github+json}. The header name is matched case-insensitively.
+ *
+ * @param response the response to check
+ * @return {@code true} if the content type is JSON, {@code false} otherwise
+ */
+ @Experimental
+ public static boolean isJsonContentType(Response response) {
+ return hasContentTypeMatching(response, JSON_CONTENT_TYPE);
+ }
+
/**
* Checks whether the {@code Content-Type} header of the given template denotes XML.
*
@@ -413,6 +427,20 @@ public static boolean isXmlContentType(RequestTemplate template) {
return hasContentTypeMatching(template, XML_CONTENT_TYPE);
}
+ /**
+ * Checks whether the {@code Content-Type} header of the given response denotes XML.
+ *
+ *
Matches {@code application/xml} and {@code text/xml} as well as suffixed types such as
+ * {@code application/soap+xml}. The header name is matched case-insensitively.
+ *
+ * @param response the response to check
+ * @return {@code true} if the content type is XML, {@code false} otherwise
+ */
+ @Experimental
+ public static boolean isXmlContentType(Response response) {
+ return hasContentTypeMatching(response, XML_CONTENT_TYPE);
+ }
+
/**
* Checks whether the {@code Content-Type} header of the given template starts with the given
* media type, ignoring case and any parameters such as {@code ;charset=utf-8}.
@@ -424,18 +452,45 @@ public static boolean isXmlContentType(RequestTemplate template) {
*/
@Experimental
public static boolean hasContentType(RequestTemplate template, String mediaType) {
- return contentTypes(template)
- .anyMatch(
- contentType -> {
- String trimmed = contentType.trim();
- return trimmed.regionMatches(true, 0, mediaType, 0, mediaType.length())
- && (trimmed.length() == mediaType.length()
- || trimmed.charAt(mediaType.length()) == ';');
- });
+ return matchesMediaType(contentTypes(template), mediaType);
+ }
+
+ /**
+ * Checks whether the {@code Content-Type} header of the given response starts with the given
+ * media type, ignoring case and any parameters such as {@code ;charset=utf-8}.
+ *
+ * @param response the response to check
+ * @param mediaType the media type to look for, for example {@code text/csv}
+ * @return {@code true} if the content type matches, {@code false} otherwise
+ */
+ @Experimental
+ public static boolean hasContentType(Response response, String mediaType) {
+ return matchesMediaType(contentTypes(response), mediaType);
+ }
+
+ private static boolean matchesMediaType(Stream contentTypes, String mediaType) {
+ return contentTypes.anyMatch(
+ contentType -> {
+ String trimmed = contentType.trim();
+ return trimmed.regionMatches(true, 0, mediaType, 0, mediaType.length())
+ && (trimmed.length() == mediaType.length()
+ || trimmed.charAt(mediaType.length()) == ';');
+ });
}
private static Stream contentTypes(RequestTemplate template) {
- return template.headers().entrySet().stream()
+ return contentTypes(template.headers());
+ }
+
+ private static Stream contentTypes(Response response) {
+ if (response == null || response.headers() == null) {
+ return Stream.empty();
+ }
+ return contentTypes(response.headers());
+ }
+
+ private static Stream contentTypes(Map> headers) {
+ return headers.entrySet().stream()
.filter(header -> CONTENT_TYPE.equalsIgnoreCase(header.getKey()))
.map(Map.Entry::getValue)
.filter(Objects::nonNull)
@@ -444,7 +499,14 @@ private static Stream contentTypes(RequestTemplate template) {
}
private static boolean hasContentTypeMatching(RequestTemplate template, Pattern pattern) {
- return contentTypes(template)
- .anyMatch(contentType -> pattern.matcher(contentType.trim()).matches());
+ return matchesPattern(contentTypes(template), pattern);
+ }
+
+ private static boolean hasContentTypeMatching(Response response, Pattern pattern) {
+ return matchesPattern(contentTypes(response), pattern);
+ }
+
+ private static boolean matchesPattern(Stream contentTypes, Pattern pattern) {
+ return contentTypes.anyMatch(contentType -> pattern.matcher(contentType.trim()).matches());
}
}
diff --git a/core/src/main/java/feign/codec/DecoderPredicate.java b/core/src/main/java/feign/codec/DecoderPredicate.java
new file mode 100644
index 0000000000..941d9cbab4
--- /dev/null
+++ b/core/src/main/java/feign/codec/DecoderPredicate.java
@@ -0,0 +1,154 @@
+/*
+ * 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 feign.Experimental;
+import feign.Response;
+import feign.Util;
+import java.lang.reflect.Type;
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * Decides whether a response can be handled by a {@link Decoder}.
+ *
+ * Predicates receive the same two arguments as {@link Decoder#decode(Response, Type)}, so they
+ * can discriminate on the response status, on anything in its headers such as the {@code
+ * Content-Type}, or on the type the caller expects back.
+ *
+ *
Predicates must not read the response body. The body is a single-pass stream
+ * for most clients, so consuming it here would leave nothing for the decoder that is eventually
+ * chosen.
+ *
+ *
Every predicate built here describes itself, so a {@link MultiDecoder} that cannot route a
+ * response can say what it did consider. Wrap your own lambdas in {@link #describedAs(String,
+ * DecoderPredicate)} to get the same in error messages.
+ *
+ * @see PredicatedDecoder
+ * @see MultiDecoder
+ */
+@Experimental
+@FunctionalInterface
+public interface DecoderPredicate {
+
+ /**
+ * Whether the decoder this predicate guards can handle the response.
+ *
+ * @param response the response that would be decoded. Its body must not be read.
+ * @param type the {@link java.lang.reflect.Method#getGenericReturnType() generic return type} the
+ * caller expects back
+ * @return {@code true} if the response can be decoded, {@code false} otherwise
+ */
+ boolean canDecode(Response response, Type type);
+
+ /**
+ * Wraps a predicate so that it describes itself, which is what a {@link MultiDecoder} reports
+ * when no decoder accepts a response.
+ *
+ * @param description how the predicate reads in an error message, for example {@code
+ * "Content-Type is JSON"}
+ * @param predicate the predicate to describe
+ */
+ static DecoderPredicate describedAs(String description, DecoderPredicate predicate) {
+ Objects.requireNonNull(description, "description cannot be null");
+ Objects.requireNonNull(predicate, "predicate cannot be null");
+ return new DecoderPredicate() {
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return predicate.canDecode(response, type);
+ }
+
+ @Override
+ public String toString() {
+ return description;
+ }
+ };
+ }
+
+ /**
+ * Matches every response. Pair this with a decoder registered last to make it the default of a
+ * {@link MultiDecoder}.
+ */
+ static DecoderPredicate any() {
+ return describedAs("any response", (response, type) -> true);
+ }
+
+ /** Matches responses whose {@code Content-Type} header denotes JSON. */
+ static DecoderPredicate jsonContentType() {
+ return describedAs(
+ "Content-Type is JSON", (response, type) -> Util.isJsonContentType(response));
+ }
+
+ /** Matches responses whose {@code Content-Type} header denotes XML. */
+ static DecoderPredicate xmlContentType() {
+ return describedAs("Content-Type is XML", (response, type) -> Util.isXmlContentType(response));
+ }
+
+ /**
+ * Matches responses whose {@code Content-Type} header starts with the given media type, ignoring
+ * case and any parameters such as {@code ;charset=utf-8}.
+ */
+ static DecoderPredicate contentType(String mediaType) {
+ Objects.requireNonNull(mediaType, "mediaType cannot be null");
+ return describedAs(
+ "Content-Type is " + mediaType,
+ (response, type) -> Util.hasContentType(response, mediaType));
+ }
+
+ /** Matches responses carrying no body, such as a {@code 204 No Content}. */
+ static DecoderPredicate emptyBody() {
+ return describedAs(
+ "body is empty",
+ (response, type) ->
+ response.body() == null
+ || (response.body().length() != null && response.body().length() == 0));
+ }
+
+ /** Matches responses whose status is one of the given codes. */
+ static DecoderPredicate status(int... statuses) {
+ int[] accepted = Arrays.copyOf(statuses, statuses.length);
+ Arrays.sort(accepted);
+ return describedAs(
+ "status is one of " + Arrays.toString(accepted),
+ (response, type) -> Arrays.binarySearch(accepted, response.status()) >= 0);
+ }
+
+ /** Matches responses the caller expects to come back as exactly the given type. */
+ static DecoderPredicate returnType(Type expected) {
+ Objects.requireNonNull(expected, "expected cannot be null");
+ return describedAs(
+ "return type is " + expected.getTypeName(), (response, type) -> expected.equals(type));
+ }
+
+ default DecoderPredicate and(DecoderPredicate other) {
+ Objects.requireNonNull(other, "other cannot be null");
+ return describedAs(
+ "(" + this + " and " + other + ")",
+ (response, type) -> canDecode(response, type) && other.canDecode(response, type));
+ }
+
+ default DecoderPredicate or(DecoderPredicate other) {
+ Objects.requireNonNull(other, "other cannot be null");
+ return describedAs(
+ "(" + this + " or " + other + ")",
+ (response, type) -> canDecode(response, type) || other.canDecode(response, type));
+ }
+
+ default DecoderPredicate negate() {
+ return describedAs("not (" + this + ")", (response, type) -> !canDecode(response, type));
+ }
+}
diff --git a/core/src/main/java/feign/codec/MultiDecoder.java b/core/src/main/java/feign/codec/MultiDecoder.java
new file mode 100644
index 0000000000..f48ee1975e
--- /dev/null
+++ b/core/src/main/java/feign/codec/MultiDecoder.java
@@ -0,0 +1,165 @@
+/*
+ * 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 feign.Experimental;
+import feign.FeignException;
+import feign.Response;
+import feign.Util;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * A {@link Decoder} that hands each response to the first decoder that accepts it.
+ *
+ *
Decoders come from two places. A decoder that implements {@link PredicatedDecoder} declares
+ * its own applicability and can simply be added; any other decoder is paired with a {@link
+ * DecoderPredicate} at the call site:
+ *
+ *
+ * Feign.builder()
+ * .decoder(
+ * MultiDecoder.builder()
+ * .add(new JacksonDecoder())
+ * .add(DecoderPredicate.xmlContentType(), new JAXBDecoder())
+ * .add((response, type) -> type == byte[].class, new BinaryDecoder())
+ * .add(DecoderPredicate.any(), new DefaultDecoder())
+ * .build());
+ *
+ *
+ * Decoders are consulted in the order they were added, so the narrowest one comes first. There
+ * is no implicit fallback: a response no decoder accepts fails with a {@link DecodeException}
+ * naming what was tried. Add a decoder guarded by {@link DecoderPredicate#any()} last to act as a
+ * default, as above.
+ *
+ * @see PredicatedDecoder
+ * @see DecoderPredicate
+ */
+@Experimental
+public class MultiDecoder implements Decoder {
+
+ private final List decoders;
+
+ private MultiDecoder(List decoders) {
+ this.decoders = Collections.unmodifiableList(new ArrayList<>(decoders));
+ }
+
+ /** Starts building a multi-decoder. */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Decodes using the first decoder that accepts the response.
+ *
+ * @param response {@inheritDoc}
+ * @param type {@inheritDoc}
+ * @return {@inheritDoc}
+ * @throws IOException {@inheritDoc}
+ * @throws DecodeException when no decoder accepts the response, or the chosen one fails
+ * @throws FeignException {@inheritDoc}
+ */
+ @Override
+ public Object decode(Response response, Type type)
+ throws IOException, DecodeException, FeignException {
+ for (PredicatedDecoder decoder : decoders) {
+ if (decoder.canDecode(response, type)) {
+ return decoder.decode(response, type);
+ }
+ }
+ throw new DecodeException(
+ response.status(), unableToDecode(response, type), response.request());
+ }
+
+ 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(") 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));
+ }
+ return message
+ .append("\nAdd a decoder guarded by DecoderPredicate.any() last to act as a default.")
+ .toString();
+ }
+
+ private static String contentTypes(Response response) {
+ String contentTypes =
+ response.headers().entrySet().stream()
+ .filter(header -> Util.CONTENT_TYPE.equalsIgnoreCase(header.getKey()))
+ .map(Map.Entry::getValue)
+ .filter(Objects::nonNull)
+ .flatMap(Collection::stream)
+ .collect(Collectors.joining(", "));
+ return contentTypes.isEmpty() ? "not set" : contentTypes;
+ }
+
+ @Override
+ public String toString() {
+ return "MultiDecoder"
+ + decoders.stream().map(PairedDecoder::describe).collect(Collectors.toList());
+ }
+
+ /** Collects the decoders of a {@link MultiDecoder}. */
+ @Experimental
+ public static final class Builder {
+
+ private final List decoders = new ArrayList<>();
+
+ private Builder() {}
+
+ /**
+ * Adds a decoder that declares its own applicability.
+ *
+ * @param decoder the decoder, consulted via {@link PredicatedDecoder#canDecode}
+ */
+ public Builder add(PredicatedDecoder decoder) {
+ decoders.add(Objects.requireNonNull(decoder, "decoder cannot be null"));
+ return this;
+ }
+
+ /**
+ * Adds any decoder, guarded by the given predicate. Use this for decoders that do not implement
+ * {@link PredicatedDecoder}, including ones you do not control.
+ *
+ * @param predicate decides whether the decoder handles a response
+ * @param decoder the decoder to delegate to
+ */
+ public Builder add(DecoderPredicate predicate, Decoder decoder) {
+ return add(PredicatedDecoder.of(predicate, decoder));
+ }
+
+ /** Builds the multi-decoder. */
+ public MultiDecoder build() {
+ return new MultiDecoder(decoders);
+ }
+ }
+}
diff --git a/core/src/main/java/feign/codec/PairedDecoder.java b/core/src/main/java/feign/codec/PairedDecoder.java
new file mode 100644
index 0000000000..f2922259bc
--- /dev/null
+++ b/core/src/main/java/feign/codec/PairedDecoder.java
@@ -0,0 +1,79 @@
+/*
+ * 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 feign.FeignException;
+import feign.Response;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.util.Objects;
+
+/** A decoder that does not declare itself, guarded by a predicate supplied at the call site. */
+final class PairedDecoder implements PredicatedDecoder {
+
+ private final DecoderPredicate predicate;
+
+ private final Decoder decoder;
+
+ PairedDecoder(DecoderPredicate predicate, Decoder decoder) {
+ this.predicate = Objects.requireNonNull(predicate, "predicate cannot be null");
+ this.decoder = Objects.requireNonNull(decoder, "decoder cannot be null");
+ }
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return predicate.canDecode(response, type);
+ }
+
+ @Override
+ public Object decode(Response response, Type type)
+ throws IOException, DecodeException, FeignException {
+ return decoder.decode(response, type);
+ }
+
+ @Override
+ public String toString() {
+ return describe(decoder) + " when " + predicate;
+ }
+
+ /** Requires both the predicate and, when the decoder declares one, its own applicability. */
+ static DecoderPredicate narrow(DecoderPredicate predicate, Decoder decoder) {
+ Objects.requireNonNull(predicate, "predicate cannot be null");
+ Objects.requireNonNull(decoder, "decoder cannot be null");
+ if (!(decoder instanceof PredicatedDecoder)) {
+ return predicate;
+ }
+ if (decoder instanceof PairedDecoder) {
+ return predicate.and(((PairedDecoder) decoder).predicate);
+ }
+ PredicatedDecoder predicated = (PredicatedDecoder) decoder;
+ return predicate.and(
+ DecoderPredicate.describedAs(describe(decoder) + " accepts it", predicated::canDecode));
+ }
+
+ /** The decoder's own {@code toString} when it has one, its class name otherwise. */
+ static String describe(Decoder decoder) {
+ Class> type = decoder.getClass();
+ try {
+ if (type.getMethod("toString").getDeclaringClass() != Object.class) {
+ return decoder.toString();
+ }
+ } catch (NoSuchMethodException ignored) {
+ // cannot happen, every class has toString
+ }
+ return type.getSimpleName().isEmpty() ? type.getName() : type.getSimpleName();
+ }
+}
diff --git a/core/src/main/java/feign/codec/PredicatedDecoder.java b/core/src/main/java/feign/codec/PredicatedDecoder.java
new file mode 100644
index 0000000000..d8d48467a5
--- /dev/null
+++ b/core/src/main/java/feign/codec/PredicatedDecoder.java
@@ -0,0 +1,112 @@
+/*
+ * 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 feign.Experimental;
+import feign.Response;
+import java.lang.reflect.Type;
+
+/**
+ * A {@link Decoder} that knows which responses it can handle.
+ *
+ * Decoders implement this to declare their own applicability, so a {@link MultiDecoder} can
+ * route each response to the right one without the call site having to wrap anything:
+ *
+ *
+ * public class JacksonDecoder implements PredicatedDecoder {
+ *
+ * @Override
+ * public boolean canDecode(Response response, Type type) {
+ * return Util.isJsonContentType(response);
+ * }
+ *
+ * @Override
+ * public Object decode(Response response, Type type) throws IOException {
+ * // ...
+ * }
+ * }
+ *
+ *
+ * {@code canDecode} is deliberately abstract: a decoder that says nothing about what it handles
+ * would claim every response, which is almost never what its author meant. Use {@link
+ * #of(DecoderPredicate, Decoder)} to give an existing decoder a predicate instead of implementing
+ * this on it, and {@link DecoderPredicate} — which is a {@code @FunctionalInterface} —
+ * to write that predicate as a lambda.
+ *
+ *
Decoders that wrap another decoder should forward {@code canDecode} to their delegate, so that
+ * wrapping does not discard the delegate's applicability.
+ *
+ * @see MultiDecoder
+ * @see DecoderPredicate
+ */
+@Experimental
+public interface PredicatedDecoder extends Decoder {
+
+ /**
+ * Pairs any decoder with a predicate, for decoders that do not declare themselves, 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
+ * #narrowing(DecoderPredicate, Decoder)} to keep the decoder's own declaration.
+ *
+ *
A decoder paired with {@link DecoderPredicate#any()} accepts everything, which is how a
+ * {@link MultiDecoder} is given a default:
+ *
+ *
+ * Feign.builder()
+ * .decoders(
+ * new JacksonDecoder(),
+ * PredicatedDecoder.of(DecoderPredicate.any(), new DefaultDecoder()));
+ *
+ *
+ * @param predicate decides whether the decoder handles a response
+ * @param decoder the decoder to delegate to
+ */
+ static PredicatedDecoder of(DecoderPredicate predicate, Decoder decoder) {
+ return new PairedDecoder(predicate, decoder);
+ }
+
+ /**
+ * Narrows a decoder that already declares itself, by requiring both the given predicate and the
+ * decoder's own {@code canDecode} to accept the response:
+ *
+ *
+ * PredicatedDecoder.narrowing(
+ * DecoderPredicate.status(200), new JacksonDecoder());
+ *
+ *
+ * A decoder that does not implement {@link PredicatedDecoder} declares nothing to narrow, so
+ * this behaves like {@link #of(DecoderPredicate, Decoder)}.
+ *
+ * @param predicate narrows what the decoder handles
+ * @param decoder the decoder to delegate to
+ */
+ static PredicatedDecoder narrowing(DecoderPredicate predicate, Decoder decoder) {
+ return new PairedDecoder(PairedDecoder.narrow(predicate, decoder), decoder);
+ }
+
+ /**
+ * Whether this decoder can handle the response.
+ *
+ *
The response body must not be read here: it is a single-pass stream for most clients, so
+ * consuming it would leave nothing for the decoder that is eventually chosen.
+ *
+ * @param response the response that would be decoded. Its body must not be read.
+ * @param type the {@link java.lang.reflect.Method#getGenericReturnType() generic return type} the
+ * caller expects back
+ * @return {@code true} if this decoder can decode the response, {@code false} otherwise
+ */
+ boolean canDecode(Response response, Type type);
+}
diff --git a/core/src/main/java/feign/optionals/OptionalDecoder.java b/core/src/main/java/feign/optionals/OptionalDecoder.java
index 475ee74b95..0edb3bedeb 100644
--- a/core/src/main/java/feign/optionals/OptionalDecoder.java
+++ b/core/src/main/java/feign/optionals/OptionalDecoder.java
@@ -18,13 +18,14 @@
import feign.Response;
import feign.Util;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Objects;
import java.util.Optional;
-public final class OptionalDecoder implements Decoder {
+public final class OptionalDecoder implements Decoder, PredicatedDecoder {
final Decoder delegate;
public OptionalDecoder(Decoder delegate) {
@@ -44,6 +45,16 @@ public Object decode(Response response, Type type) throws IOException {
return Optional.ofNullable(delegate.decode(response, enclosedType));
}
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ if (!(delegate instanceof PredicatedDecoder)) {
+ return true;
+ }
+ Type enclosedType =
+ isOptional(type) ? Util.resolveLastTypeParameter(type, Optional.class) : type;
+ return ((PredicatedDecoder) delegate).canDecode(response, enclosedType);
+ }
+
static boolean isOptional(Type type) {
if (!(type instanceof ParameterizedType)) {
return false;
diff --git a/core/src/test/java/feign/codec/DecoderPredicateTest.java b/core/src/test/java/feign/codec/DecoderPredicateTest.java
new file mode 100644
index 0000000000..a99645a46d
--- /dev/null
+++ b/core/src/test/java/feign/codec/DecoderPredicateTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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 org.assertj.core.api.Assertions.assertThat;
+
+import feign.Request;
+import feign.Request.HttpMethod;
+import feign.Response;
+import feign.Util;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class DecoderPredicateTest {
+
+ private static Response response(String contentType) {
+ return response(contentType, 200, "body");
+ }
+
+ private static Response response(String contentType, int status, String body) {
+ Map> headers = new HashMap<>();
+ if (contentType != null) {
+ headers.put("Content-Type", Collections.singletonList(contentType));
+ }
+ Response.Builder builder =
+ Response.builder()
+ .status(status)
+ .reason("OK")
+ .headers(headers)
+ .request(
+ Request.create(
+ HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8, null));
+ if (body != null) {
+ builder.body(body, Util.UTF_8);
+ }
+ return builder.build();
+ }
+
+ @Test
+ void jsonContentTypeMatchesPlainAndSuffixedTypes() {
+ DecoderPredicate predicate = DecoderPredicate.jsonContentType();
+
+ assertThat(predicate.canDecode(response("application/json"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/json;charset=utf-8"), String.class))
+ .isTrue();
+ assertThat(predicate.canDecode(response("APPLICATION/JSON"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("text/json"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/vnd.github+json"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/xml"), String.class)).isFalse();
+ assertThat(predicate.canDecode(response("application/x-json-stream"), String.class)).isFalse();
+ assertThat(predicate.canDecode(response(null), String.class)).isFalse();
+ }
+
+ @Test
+ void xmlContentTypeMatchesPlainAndSuffixedTypes() {
+ DecoderPredicate predicate = DecoderPredicate.xmlContentType();
+
+ assertThat(predicate.canDecode(response("application/xml"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("text/xml;charset=utf-8"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/soap+xml"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/json"), String.class)).isFalse();
+ assertThat(predicate.canDecode(response(null), String.class)).isFalse();
+ }
+
+ @Test
+ void contentTypeIgnoresCaseAndParameters() {
+ DecoderPredicate predicate = DecoderPredicate.contentType("text/csv");
+
+ assertThat(predicate.canDecode(response("text/csv"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("TEXT/CSV;charset=utf-8"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("text/csv-x"), String.class)).isFalse();
+ assertThat(predicate.canDecode(response("text/plain"), String.class)).isFalse();
+ }
+
+ @Test
+ void emptyBodyMatchesResponsesWithoutContent() {
+ DecoderPredicate predicate = DecoderPredicate.emptyBody();
+
+ assertThat(predicate.canDecode(response("application/json", 204, null), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/json", 200, ""), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/json", 200, "body"), String.class))
+ .isFalse();
+ }
+
+ @Test
+ void statusMatchesTheGivenCodes() {
+ DecoderPredicate predicate = DecoderPredicate.status(204, 404);
+
+ assertThat(predicate.canDecode(response("application/json", 204, null), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/json", 404, null), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/json", 200, "body"), String.class))
+ .isFalse();
+ }
+
+ @Test
+ void returnTypeMatchesTheExpectedType() {
+ DecoderPredicate predicate = DecoderPredicate.returnType(byte[].class);
+
+ assertThat(predicate.canDecode(response("application/octet-stream"), byte[].class)).isTrue();
+ assertThat(predicate.canDecode(response("application/octet-stream"), String.class)).isFalse();
+ }
+
+ @Test
+ void combinesPredicates() {
+ DecoderPredicate json = DecoderPredicate.jsonContentType();
+ DecoderPredicate ok = DecoderPredicate.status(200);
+
+ assertThat(json.and(ok).canDecode(response("application/json"), String.class)).isTrue();
+ assertThat(json.and(ok).canDecode(response("application/json", 204, null), String.class))
+ .isFalse();
+ assertThat(json.or(ok).canDecode(response("text/plain"), String.class)).isTrue();
+ assertThat(json.negate().canDecode(response("text/plain"), String.class)).isTrue();
+ }
+}
diff --git a/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java b/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java
new file mode 100644
index 0000000000..d286cc03a0
--- /dev/null
+++ b/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java
@@ -0,0 +1,238 @@
+/*
+ * 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 org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import feign.Capability;
+import feign.Feign;
+import feign.Param;
+import feign.Request;
+import feign.Request.HttpMethod;
+import feign.RequestLine;
+import feign.Response;
+import feign.Util;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/** How {@link MultiDecoder} behaves end to end and when a {@link Capability} wraps the decoder. */
+class MultiDecoderCapabilityTest {
+
+ interface MixedApi {
+ @RequestLine("GET /{path}")
+ String get(@Param("path") String path);
+ }
+
+ static class TaggingDecoder implements Decoder {
+ private final String tag;
+
+ TaggingDecoder(String tag) {
+ this.tag = tag;
+ }
+
+ @Override
+ public Object decode(Response response, Type type) {
+ return tag;
+ }
+ }
+
+ /** A capability that wraps the decoder, the way the metrics modules do. */
+ public static class CountingCapability implements Capability {
+ int wrapped;
+ int decodeCalls;
+
+ @Override
+ public Decoder enrich(Decoder decoder) {
+ wrapped++;
+ return (response, type) -> {
+ decodeCalls++;
+ return decoder.decode(response, type);
+ };
+ }
+ }
+
+ private static Response response(String contentType, String body) {
+ return response(
+ contentType,
+ body,
+ Request.create(
+ HttpMethod.GET, "http://localhost:1/", Collections.emptyMap(), null, Util.UTF_8, null));
+ }
+
+ private static Response response(String contentType, String body, Request request) {
+ Map> headers = new HashMap<>();
+ headers.put("Content-Type", Collections.singletonList(contentType));
+ return Response.builder()
+ .status(200)
+ .reason("OK")
+ .headers(headers)
+ .body(body, Util.UTF_8)
+ .request(request)
+ .build();
+ }
+
+ private static MixedApi target(Feign.Builder builder, Map contentTypes) {
+ return builder
+ .client(
+ (request, options) -> {
+ String path = request.url().substring(request.url().lastIndexOf('/') + 1);
+ return response(contentTypes.get(path), "payload", request);
+ })
+ .target(MixedApi.class, "http://localhost:1");
+ }
+
+ @Test
+ void capabilityWrapsTheCompositeAndRoutingStillWorks() {
+ CountingCapability capability = new CountingCapability();
+ Map contentTypes = new HashMap<>();
+ contentTypes.put("json", "application/json");
+ contentTypes.put("xml", "application/xml");
+
+ MixedApi api =
+ target(
+ Feign.builder()
+ .decoder(
+ MultiDecoder.builder()
+ .add(DecoderPredicate.jsonContentType(), new TaggingDecoder("json"))
+ .add(DecoderPredicate.xmlContentType(), new TaggingDecoder("xml"))
+ .add(DecoderPredicate.any(), new TaggingDecoder("fallback"))
+ .build())
+ .addCapability(capability),
+ contentTypes);
+
+ assertThat(api.get("json")).isEqualTo("json");
+ assertThat(api.get("xml")).isEqualTo("xml");
+
+ // the capability sees the MultiDecoder as one decoder, not one per delegate
+ assertThat(capability.wrapped).isEqualTo(1);
+ assertThat(capability.decodeCalls).isEqualTo(2);
+ }
+
+ @Test
+ void decodersOnTheBuilderRouteInTheOrderGiven() {
+ Map contentTypes = new HashMap<>();
+ contentTypes.put("json", "application/json");
+ contentTypes.put("csv", "text/csv");
+
+ MixedApi api =
+ target(
+ Feign.builder()
+ .decoders(
+ new SelfDeclaringJsonDecoder(),
+ PredicatedDecoder.of(DecoderPredicate.any(), new TaggingDecoder("fallback"))),
+ contentTypes);
+
+ assertThat(api.get("json")).isEqualTo("json");
+ assertThat(api.get("csv")).isEqualTo("fallback");
+ }
+
+ @Test
+ void decodersOnTheBuilderFailWhenNothingAccepts() {
+ Map contentTypes = new HashMap<>();
+ contentTypes.put("csv", "text/csv");
+
+ MixedApi api = target(Feign.builder().decoders(new SelfDeclaringJsonDecoder()), contentTypes);
+
+ assertThatThrownBy(() -> api.get("csv"))
+ .isInstanceOf(DecodeException.class)
+ .hasMessageContaining("Unable to decode 200 response (Content-Type: text/csv)")
+ .hasMessageContaining("SelfDeclaringJsonDecoder");
+ }
+
+ /** The selected decoder still receives an unread body: predicates must not consume it. */
+ @Test
+ void predicatesLeaveTheBodyForTheSelectedDecoder() throws IOException {
+ Decoder decoder =
+ MultiDecoder.builder()
+ .add(
+ DecoderPredicate.jsonContentType(),
+ (response, type) -> Util.toString(response.body().asReader(Util.UTF_8)))
+ .build();
+
+ assertThat(decoder.decode(response("application/json", "payload"), String.class))
+ .isEqualTo("payload");
+ }
+
+ static class SelfDeclaringJsonDecoder implements Decoder, PredicatedDecoder {
+
+ @Override
+ public Object decode(Response response, Type type) {
+ return "json";
+ }
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
+ }
+
+ /**
+ * A wrapper that answers {@code canDecode} for itself instead of forwarding claims every
+ * response, which is why the metrics modules' {@code MeteredDecoder} forwards it to its delegate.
+ */
+ @Test
+ void wrappingWithoutForwardingCanDecodeErasesSelfDeclaration() throws IOException {
+ PredicatedDecoder jsonOnly = new SelfDeclaringJsonDecoder();
+
+ PredicatedDecoder naive =
+ new PredicatedDecoder() {
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return true;
+ }
+
+ @Override
+ public Object decode(Response response, Type type) throws IOException {
+ return jsonOnly.decode(response, type);
+ }
+ };
+
+ PredicatedDecoder forwarding =
+ new PredicatedDecoder() {
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return jsonOnly.canDecode(response, type);
+ }
+
+ @Override
+ public Object decode(Response response, Type type) throws IOException {
+ return jsonOnly.decode(response, type);
+ }
+ };
+
+ assertThat(
+ MultiDecoder.builder()
+ .add(naive)
+ .add(DecoderPredicate.any(), new TaggingDecoder("fallback"))
+ .build()
+ .decode(response("application/xml", "payload"), String.class))
+ .isEqualTo("json");
+
+ assertThat(
+ MultiDecoder.builder()
+ .add(forwarding)
+ .add(DecoderPredicate.any(), new TaggingDecoder("fallback"))
+ .build()
+ .decode(response("application/xml", "payload"), String.class))
+ .isEqualTo("fallback");
+ }
+}
diff --git a/core/src/test/java/feign/codec/MultiDecoderTest.java b/core/src/test/java/feign/codec/MultiDecoderTest.java
new file mode 100644
index 0000000000..5ca7502b1f
--- /dev/null
+++ b/core/src/test/java/feign/codec/MultiDecoderTest.java
@@ -0,0 +1,312 @@
+/*
+ * 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 org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import feign.Request;
+import feign.Request.HttpMethod;
+import feign.Response;
+import feign.Util;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class MultiDecoderTest {
+
+ /** A plain decoder, with no opinion about what it can handle. */
+ private static class RecordingDecoder implements Decoder {
+ private final String result;
+ boolean invoked;
+
+ RecordingDecoder(String result) {
+ this.result = result;
+ }
+
+ @Override
+ public Object decode(Response response, Type type) {
+ invoked = true;
+ return result;
+ }
+ }
+
+ /** A decoder that declares its own applicability, the way feign-gson and friends now do. */
+ private static class SelfDeclaringJsonDecoder extends RecordingDecoder
+ implements PredicatedDecoder {
+
+ SelfDeclaringJsonDecoder() {
+ super("json");
+ }
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
+ }
+
+ private static Response responseWithContentType(String contentType) {
+ return responseWithContentType(contentType, 200, "body");
+ }
+
+ private static Response responseWithContentType(String contentType, int status, String body) {
+ Map> headers = new HashMap<>();
+ if (contentType != null) {
+ headers.put("Content-Type", Collections.singletonList(contentType));
+ }
+ Response.Builder builder =
+ Response.builder()
+ .status(status)
+ .reason("OK")
+ .headers(headers)
+ .request(
+ Request.create(
+ HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8, null));
+ if (body != null) {
+ builder.body(body, Util.UTF_8);
+ }
+ return builder.build();
+ }
+
+ @Test
+ void routesToTheDecoderThatDeclaresItCanHandleTheResponse() throws IOException {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+ RecordingDecoder fallback = new RecordingDecoder("fallback");
+
+ Decoder decoder =
+ MultiDecoder.builder().add(json).add(DecoderPredicate.any(), fallback).build();
+
+ assertThat(decoder.decode(responseWithContentType("application/json"), String.class))
+ .isEqualTo("json");
+ assertThat(json.invoked).isTrue();
+ assertThat(fallback.invoked).isFalse();
+ }
+
+ @Test
+ void pairsAPredicateWithADecoderThatDoesNotDeclareItself() throws IOException {
+ RecordingDecoder xml = new RecordingDecoder("xml");
+ RecordingDecoder fallback = new RecordingDecoder("fallback");
+
+ Decoder decoder =
+ MultiDecoder.builder()
+ .add(DecoderPredicate.xmlContentType(), xml)
+ .add(DecoderPredicate.any(), fallback)
+ .build();
+
+ assertThat(decoder.decode(responseWithContentType("application/xml"), String.class))
+ .isEqualTo("xml");
+ assertThat(fallback.invoked).isFalse();
+ }
+
+ @Test
+ void mixesSelfDeclaringDecodersAndPairs() throws IOException {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+ RecordingDecoder xml = new RecordingDecoder("xml");
+ RecordingDecoder csv = new RecordingDecoder("csv");
+ RecordingDecoder fallback = new RecordingDecoder("fallback");
+
+ Decoder decoder =
+ MultiDecoder.builder()
+ .add(json)
+ .add(DecoderPredicate.xmlContentType(), xml)
+ .add(DecoderPredicate.contentType("text/csv"), csv)
+ .add(DecoderPredicate.any(), fallback)
+ .build();
+
+ assertThat(decoder.decode(responseWithContentType("text/csv;charset=utf-8"), String.class))
+ .isEqualTo("csv");
+ assertThat(json.invoked).isFalse();
+ assertThat(xml.invoked).isFalse();
+ assertThat(fallback.invoked).isFalse();
+ }
+
+ @Test
+ void matchesSuffixedContentTypes() throws IOException {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+
+ Decoder decoder = MultiDecoder.builder().add(json).build();
+
+ assertThat(decoder.decode(responseWithContentType("application/vnd.github+json"), String.class))
+ .isEqualTo("json");
+ }
+
+ @Test
+ void fallsBackToTheDecoderThatAcceptsAnything() throws IOException {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+ RecordingDecoder fallback = new RecordingDecoder("fallback");
+
+ Decoder decoder =
+ MultiDecoder.builder().add(json).add(DecoderPredicate.any(), fallback).build();
+
+ assertThat(decoder.decode(responseWithContentType("text/plain"), String.class))
+ .isEqualTo("fallback");
+ assertThat(json.invoked).isFalse();
+ }
+
+ @Test
+ void fallsBackWhenTheResponseCarriesNoContentType() throws IOException {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+ RecordingDecoder fallback = new RecordingDecoder("fallback");
+
+ Decoder decoder =
+ MultiDecoder.builder().add(json).add(DecoderPredicate.any(), fallback).build();
+
+ assertThat(decoder.decode(responseWithContentType(null), String.class)).isEqualTo("fallback");
+ }
+
+ @Test
+ void consultsDecodersInTheOrderTheyWereAdded() throws IOException {
+ RecordingDecoder first = new RecordingDecoder("first");
+ RecordingDecoder second = new RecordingDecoder("second");
+
+ Decoder decoder =
+ MultiDecoder.builder()
+ .add(DecoderPredicate.jsonContentType(), first)
+ .add(DecoderPredicate.jsonContentType(), second)
+ .build();
+
+ assertThat(decoder.decode(responseWithContentType("application/json"), String.class))
+ .isEqualTo("first");
+ assertThat(second.invoked).isFalse();
+ }
+
+ @Test
+ void pairingReplacesWhatTheDecoderDeclaresAboutItself() throws IOException {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+
+ Decoder decoder =
+ MultiDecoder.builder().add(PredicatedDecoder.of(DecoderPredicate.any(), json)).build();
+
+ assertThat(decoder.decode(responseWithContentType("text/plain"), String.class))
+ .isEqualTo("json");
+ }
+
+ @Test
+ void narrowingKeepsWhatTheDecoderDeclaresAboutItself() {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+ PredicatedDecoder narrowed = PredicatedDecoder.narrowing(DecoderPredicate.status(200), json);
+
+ assertThat(narrowed.canDecode(responseWithContentType("application/json"), String.class))
+ .isTrue();
+ assertThat(
+ narrowed.canDecode(
+ responseWithContentType("application/json", 204, null), String.class))
+ .isFalse();
+ assertThat(narrowed.canDecode(responseWithContentType("text/plain"), String.class)).isFalse();
+ assertThat(narrowed)
+ .hasToString(
+ "SelfDeclaringJsonDecoder when (status is one of [200]"
+ + " and SelfDeclaringJsonDecoder accepts it)");
+ }
+
+ @Test
+ void narrowingADecoderThatDeclaresNothingIsJustThePredicate() {
+ RecordingDecoder plain = new RecordingDecoder("plain");
+ PredicatedDecoder narrowed =
+ PredicatedDecoder.narrowing(DecoderPredicate.jsonContentType(), plain);
+
+ assertThat(narrowed).hasToString("RecordingDecoder when Content-Type is JSON");
+ assertThat(narrowed.canDecode(responseWithContentType("application/json"), String.class))
+ .isTrue();
+ }
+
+ @Test
+ void throwsWhenNoDecoderAcceptsTheResponse() {
+ Decoder decoder =
+ MultiDecoder.builder()
+ .add(new SelfDeclaringJsonDecoder())
+ .add(DecoderPredicate.xmlContentType(), new RecordingDecoder("xml"))
+ .build();
+
+ assertThatThrownBy(() -> decoder.decode(responseWithContentType("text/plain"), String.class))
+ .isInstanceOf(DecodeException.class)
+ .hasMessage(
+ "Unable to decode 200 response (Content-Type: text/plain) as java.lang.String."
+ + " 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.");
+ }
+
+ @Test
+ void theFailureReportsAMissingContentType() {
+ Decoder decoder = MultiDecoder.builder().add(new SelfDeclaringJsonDecoder()).build();
+
+ assertThatThrownBy(() -> decoder.decode(responseWithContentType(null), String.class))
+ .isInstanceOf(DecodeException.class)
+ .hasMessageContaining("(Content-Type: not set)");
+ }
+
+ @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.");
+ }
+
+ @Test
+ void propagatesIoExceptionsFromTheSelectedDecoder() {
+ Decoder failing =
+ (response, type) -> {
+ throw new IOException("boom");
+ };
+
+ Decoder decoder =
+ MultiDecoder.builder().add(DecoderPredicate.jsonContentType(), failing).build();
+
+ assertThatThrownBy(
+ () -> decoder.decode(responseWithContentType("application/json"), String.class))
+ .isInstanceOf(IOException.class)
+ .hasMessage("boom");
+ }
+
+ @Test
+ void rejectsNullDecoders() {
+ MultiDecoder.Builder builder = MultiDecoder.builder();
+
+ assertThatThrownBy(() -> builder.add((PredicatedDecoder) null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("decoder cannot be null");
+ assertThatThrownBy(() -> builder.add(null, new RecordingDecoder("x")))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("predicate cannot be null");
+ assertThatThrownBy(() -> builder.add(DecoderPredicate.jsonContentType(), null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("decoder cannot be null");
+ }
+
+ @Test
+ void describesItsDecoders() {
+ Decoder decoder =
+ MultiDecoder.builder()
+ .add(new SelfDeclaringJsonDecoder())
+ .add(DecoderPredicate.jsonContentType(), new RecordingDecoder("json"))
+ .build();
+
+ assertThat(decoder.toString())
+ .isEqualTo(
+ "MultiDecoder[SelfDeclaringJsonDecoder, RecordingDecoder when Content-Type is JSON]");
+ }
+}
diff --git a/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java b/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java
index e47fbe852d..b51d154b27 100644
--- a/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java
+++ b/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java
@@ -22,11 +22,12 @@
import feign.Response;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.lang.reflect.Type;
/** Warp feign {@link Decoder} with metrics. */
-public class MeteredDecoder implements Decoder {
+public class MeteredDecoder implements Decoder, PredicatedDecoder {
private final Decoder decoder;
private final MetricRegistry metricRegistry;
@@ -73,4 +74,10 @@ public Object decode(Response response, Type type)
return decoded;
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return !(decoder instanceof PredicatedDecoder)
+ || ((PredicatedDecoder) decoder).canDecode(response, type);
+ }
}
diff --git a/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredDecoder.java b/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredDecoder.java
index 653d29e62e..5f56b57701 100644
--- a/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredDecoder.java
+++ b/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredDecoder.java
@@ -20,6 +20,7 @@
import feign.Response;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import feign.utils.ExceptionUtils;
import io.dropwizard.metrics5.MetricRegistry;
import io.dropwizard.metrics5.Timer.Context;
@@ -28,7 +29,7 @@
import java.util.Map;
/** Warp feign {@link Decoder} with metrics. */
-public class MeteredDecoder implements Decoder {
+public class MeteredDecoder implements Decoder, PredicatedDecoder {
private final Decoder decoder;
private final MetricRegistry metricRegistry;
@@ -110,4 +111,10 @@ public Object decode(Response response, Type type)
return decoded;
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return !(decoder instanceof PredicatedDecoder)
+ || ((PredicatedDecoder) decoder).canDecode(response, type);
+ }
}
diff --git a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java
index 80b1ada8d7..e8631ae0c5 100644
--- a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java
+++ b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java
@@ -26,6 +26,7 @@
import feign.Util;
import feign.codec.Decoder;
import feign.codec.JsonDecoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Type;
@@ -33,7 +34,7 @@
/**
* @author changjin wei(魏昌进)
*/
-public class Fastjson2Decoder implements Decoder, JsonDecoder {
+public class Fastjson2Decoder implements Decoder, PredicatedDecoder, JsonDecoder {
private final JSONReader.Feature[] features;
@@ -69,4 +70,9 @@ public Object convert(Object object, Type type) {
}
return JSON.parseObject(JSON.toJSONString(object), type);
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/gson/src/main/java/feign/gson/GsonDecoder.java b/gson/src/main/java/feign/gson/GsonDecoder.java
index 5fa6ee0369..8a908087c1 100644
--- a/gson/src/main/java/feign/gson/GsonDecoder.java
+++ b/gson/src/main/java/feign/gson/GsonDecoder.java
@@ -24,12 +24,13 @@
import feign.Util;
import feign.codec.Decoder;
import feign.codec.JsonDecoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Type;
import java.util.Collections;
-public class GsonDecoder implements Decoder, JsonDecoder {
+public class GsonDecoder implements Decoder, PredicatedDecoder, JsonDecoder {
private final Gson gson;
@@ -66,4 +67,9 @@ public Object decode(Response response, Type type) throws IOException {
public Object convert(Object object, Type type) {
return gson.fromJson(gson.toJsonTree(object), type);
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java
index ed1cb12a2c..98a3e0e833 100644
--- a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java
+++ b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java
@@ -24,10 +24,11 @@
import feign.Response;
import feign.Util;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.lang.reflect.Type;
-public final class JacksonJaxbJsonDecoder implements Decoder {
+public final class JacksonJaxbJsonDecoder implements Decoder, PredicatedDecoder {
private final JacksonJaxbJsonProvider jacksonJaxbJsonProvider;
public JacksonJaxbJsonDecoder() {
@@ -45,4 +46,9 @@ public Object decode(Response response, Type type) throws IOException, FeignExce
return jacksonJaxbJsonProvider.readFrom(
Object.class, type, null, APPLICATION_JSON_TYPE, null, response.body().asInputStream());
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java
index 3edb1f8dda..7d4581f5f2 100644
--- a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java
+++ b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java
@@ -23,6 +23,7 @@
import feign.codec.DecodeException;
import feign.codec.Decoder;
import feign.codec.JsonDecoder;
+import feign.codec.PredicatedDecoder;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
@@ -34,7 +35,8 @@
/**
* A {@link JsonDecoder} that uses Jackson Jr to convert objects to String or byte representation.
*/
-public class JacksonJrDecoder extends JacksonJrMapper implements Decoder, JsonDecoder {
+public class JacksonJrDecoder extends JacksonJrMapper
+ implements Decoder, PredicatedDecoder, JsonDecoder {
@FunctionalInterface
protected interface Transformer {
@@ -134,4 +136,9 @@ public Object convert(Object object, Type type) throws IOException {
}
throw new IOException("Cannot convert to type: " + type.getTypeName());
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/jackson/src/main/java/feign/jackson/JacksonDecoder.java b/jackson/src/main/java/feign/jackson/JacksonDecoder.java
index 370f745dc7..db5a0dbe58 100644
--- a/jackson/src/main/java/feign/jackson/JacksonDecoder.java
+++ b/jackson/src/main/java/feign/jackson/JacksonDecoder.java
@@ -23,13 +23,14 @@
import feign.Util;
import feign.codec.Decoder;
import feign.codec.JsonDecoder;
+import feign.codec.PredicatedDecoder;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Type;
import java.util.Collections;
-public class JacksonDecoder implements Decoder, JsonDecoder {
+public class JacksonDecoder implements Decoder, PredicatedDecoder, JsonDecoder {
private final ObjectMapper mapper;
@@ -76,4 +77,9 @@ public Object decode(Response response, Type type) throws IOException {
public Object convert(Object object, Type type) {
return mapper.convertValue(object, mapper.constructType(type));
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java b/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java
index 5726d582bd..363b17287e 100644
--- a/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java
+++ b/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java
@@ -19,6 +19,7 @@
import feign.Util;
import feign.codec.Decoder;
import feign.codec.JsonDecoder;
+import feign.codec.PredicatedDecoder;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
@@ -29,7 +30,7 @@
import tools.jackson.databind.JacksonModule;
import tools.jackson.databind.json.JsonMapper;
-public class Jackson3Decoder implements Decoder, JsonDecoder {
+public class Jackson3Decoder implements Decoder, PredicatedDecoder, JsonDecoder {
private final JsonMapper mapper;
@@ -77,4 +78,9 @@ public Object decode(Response response, Type type) throws IOException {
public Object convert(Object object, Type type) {
return mapper.convertValue(object, mapper.constructType(type));
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBDecoder.java b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBDecoder.java
index 6a40861cd1..feaf8237b5 100644
--- a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBDecoder.java
+++ b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBDecoder.java
@@ -19,6 +19,7 @@
import feign.Util;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import jakarta.xml.bind.JAXBException;
import java.io.IOException;
import java.lang.reflect.ParameterizedType;
@@ -48,7 +49,7 @@
* The JAXBContextFactory should be reused across requests as it caches the created JAXB
* contexts.
*/
-public class JAXBDecoder implements Decoder {
+public class JAXBDecoder implements Decoder, PredicatedDecoder {
private final JAXBContextFactory jaxbContextFactory;
private final boolean namespaceAware;
@@ -123,4 +124,9 @@ public JAXBDecoder build() {
return new JAXBDecoder(this);
}
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isXmlContentType(response);
+ }
}
diff --git a/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java
index 9d998d26dc..a7132472ec 100644
--- a/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java
+++ b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java
@@ -19,6 +19,7 @@
import feign.Util;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
@@ -48,7 +49,7 @@
*
The JAXBContextFactory should be reused across requests as it caches the created JAXB
* contexts.
*/
-public class JAXBDecoder implements Decoder {
+public class JAXBDecoder implements Decoder, PredicatedDecoder {
private final JAXBContextFactory jaxbContextFactory;
private final boolean namespaceAware;
@@ -123,4 +124,9 @@ public JAXBDecoder build() {
return new JAXBDecoder(this);
}
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isXmlContentType(response);
+ }
}
diff --git a/json/src/main/java/feign/json/JsonDecoder.java b/json/src/main/java/feign/json/JsonDecoder.java
index edf7fd80f0..5f0a264869 100644
--- a/json/src/main/java/feign/json/JsonDecoder.java
+++ b/json/src/main/java/feign/json/JsonDecoder.java
@@ -21,6 +21,7 @@
import feign.Util;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
@@ -53,7 +54,7 @@
* System.out.println(contributors.getJSONObject(0).getString("login"));
*
*/
-public class JsonDecoder implements Decoder, feign.codec.JsonDecoder {
+public class JsonDecoder implements Decoder, PredicatedDecoder, feign.codec.JsonDecoder {
@Override
public Object decode(Response response, Type type) throws IOException, DecodeException {
@@ -114,4 +115,9 @@ public Object convert(Object object, Type type) throws IOException {
}
throw new IOException(type.getTypeName() + " is not a type supported by this decoder.");
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/micrometer/src/main/java/feign/micrometer/MeteredDecoder.java b/micrometer/src/main/java/feign/micrometer/MeteredDecoder.java
index 65b8067ebc..926452254e 100644
--- a/micrometer/src/main/java/feign/micrometer/MeteredDecoder.java
+++ b/micrometer/src/main/java/feign/micrometer/MeteredDecoder.java
@@ -19,6 +19,7 @@
import feign.RequestTemplate;
import feign.Response;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import feign.utils.ExceptionUtils;
import io.micrometer.core.instrument.*;
import java.io.IOException;
@@ -26,7 +27,7 @@
import java.util.Optional;
/** Wrap feign {@link Decoder} with metrics. */
-public class MeteredDecoder implements Decoder {
+public class MeteredDecoder implements Decoder, PredicatedDecoder {
private final Decoder decoder;
private final MeterRegistry meterRegistry;
@@ -117,4 +118,10 @@ protected Tag[] extraTags(Response response, Type type, Exception e) {
RequestTemplate template = response.request().requestTemplate();
return new Tag[] {Tag.of("uri", template.methodMetadata().template().path())};
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return !(decoder instanceof PredicatedDecoder)
+ || ((PredicatedDecoder) decoder).canDecode(response, type);
+ }
}
diff --git a/moshi/src/main/java/feign/moshi/MoshiDecoder.java b/moshi/src/main/java/feign/moshi/MoshiDecoder.java
index ac08ee96a4..9f4e006f42 100644
--- a/moshi/src/main/java/feign/moshi/MoshiDecoder.java
+++ b/moshi/src/main/java/feign/moshi/MoshiDecoder.java
@@ -22,12 +22,13 @@
import feign.Util;
import feign.codec.Decoder;
import feign.codec.JsonDecoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.lang.reflect.Type;
import okio.BufferedSource;
import okio.Okio;
-public class MoshiDecoder implements Decoder, JsonDecoder {
+public class MoshiDecoder implements Decoder, PredicatedDecoder, JsonDecoder {
private final Moshi moshi;
public MoshiDecoder(Moshi moshi) {
@@ -67,4 +68,9 @@ public Object convert(Object object, Type type) throws IOException {
JsonAdapter adapter = moshi.adapter(type);
return adapter.fromJsonValue(object);
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/sax/src/main/java/feign/sax/SAXDecoder.java b/sax/src/main/java/feign/sax/SAXDecoder.java
index 6aa799d0a2..20da34bea1 100644
--- a/sax/src/main/java/feign/sax/SAXDecoder.java
+++ b/sax/src/main/java/feign/sax/SAXDecoder.java
@@ -24,6 +24,7 @@
import feign.Util;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
@@ -53,7 +54,7 @@
* .target(MyApi.class, "http://api");
*
*/
-public class SAXDecoder implements Decoder {
+public class SAXDecoder implements Decoder, PredicatedDecoder {
private final Map> handlerFactories;
@@ -176,4 +177,9 @@ public ContentHandlerWithResult create() {
}
}
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isXmlContentType(response);
+ }
}
diff --git a/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java b/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java
index 37386f0268..619ce01d7f 100644
--- a/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java
+++ b/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java
@@ -19,6 +19,7 @@
import feign.Util;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import feign.jaxb.JAXBContextFactory;
import jakarta.xml.bind.JAXBException;
import jakarta.xml.bind.Unmarshaller;
@@ -75,7 +76,7 @@
* @see SOAPErrorDecoder
* @see SOAPFaultException
*/
-public class SOAPDecoder implements Decoder {
+public class SOAPDecoder implements Decoder, PredicatedDecoder {
private final JAXBContextFactory jaxbContextFactory;
private final String soapProtocol;
@@ -175,4 +176,9 @@ public SOAPDecoder build() {
return new SOAPDecoder(this);
}
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isXmlContentType(response);
+ }
}
diff --git a/soap/src/main/java/feign/soap/SOAPDecoder.java b/soap/src/main/java/feign/soap/SOAPDecoder.java
index 8079a622a4..bf63ba4c27 100644
--- a/soap/src/main/java/feign/soap/SOAPDecoder.java
+++ b/soap/src/main/java/feign/soap/SOAPDecoder.java
@@ -19,6 +19,7 @@
import feign.Util;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import feign.jaxb.JAXBContextFactory;
import java.io.IOException;
import java.lang.reflect.ParameterizedType;
@@ -79,7 +80,7 @@
* @see SOAPErrorDecoder
* @see SOAPFaultException
*/
-public class SOAPDecoder implements Decoder {
+public class SOAPDecoder implements Decoder, PredicatedDecoder {
private final JAXBContextFactory jaxbContextFactory;
private final String soapProtocol;
@@ -179,4 +180,9 @@ public SOAPDecoder build() {
return new SOAPDecoder(this);
}
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isXmlContentType(response);
+ }
}
diff --git a/src/docs/overview-mindmap.iuml b/src/docs/overview-mindmap.iuml
index c1396675a6..e618f55064 100644
--- a/src/docs/overview-mindmap.iuml
+++ b/src/docs/overview-mindmap.iuml
@@ -1,64 +1,65 @@
-@startmindmap
-* Feign
-** clients
-*** java.net.URL
-*** Apache HTTP
-*** Apache HC5
-*** Google HTTP
-*** Java 11 Http2
-*** OK Http
-*** Ribbon
-** async clients
-*** java.net.URL
-*** Apache HC5
-*** OkHttp
-*** Vertx
-*** Reactive Wrappers
-** contracts
-*** Feign
-*** JAX-RS
-*** JAX-RS 2
-*** JAX-RS 3 / Jakarta
-*** JAX-RS 4
-*** Spring
-*** SOAP
-*** SOAP Jakarta
-*** Spring boot (3rd party)
-** language
-*** Kotlin
-*** GraphQL
-
-left side
-
-** encoders/decoders
-*** Multi encoder (predicate based, experimental)
-*** GSON
-*** JAXB
-*** JAXB Jakarta
-*** Jackson
-*** Jackson 3
-*** Jackson JAXB
-*** Jackson Jr
-*** Sax
-*** JSON-java
-*** Moshi
-*** Fastjson2
-*** Form
-*** Form Spring
-** metrics
-*** Dropwizard Metrics 4
-*** Dropwizard Metrics 5
-*** Micrometer
-** interceptors
-*** RequestInterceptor
-*** ResponseInterceptor
-*** MethodInterceptor
-**** Bean Validation (JSR-303)
-**** Bean Validation (Jakarta)
-**** HTTP Cache (ETag / Last-Modified)
-** extras
-*** Hystrix
-*** SLF4J
-*** Mock
-*** Annotation Error Decoder
-@endmindmap
+@startmindmap
+* Feign
+** clients
+*** java.net.URL
+*** Apache HTTP
+*** Apache HC5
+*** Google HTTP
+*** Java 11 Http2
+*** OK Http
+*** Ribbon
+** async clients
+*** java.net.URL
+*** Apache HC5
+*** OkHttp
+*** Vertx
+*** Reactive Wrappers
+** contracts
+*** Feign
+*** JAX-RS
+*** JAX-RS 2
+*** JAX-RS 3 / Jakarta
+*** JAX-RS 4
+*** Spring
+*** SOAP
+*** SOAP Jakarta
+*** Spring boot (3rd party)
+** language
+*** Kotlin
+*** GraphQL
+
+left side
+
+** encoders/decoders
+*** Multi encoder (predicate based, experimental)
+*** Multi decoder (predicate based, experimental)
+*** GSON
+*** JAXB
+*** JAXB Jakarta
+*** Jackson
+*** Jackson 3
+*** Jackson JAXB
+*** Jackson Jr
+*** Sax
+*** JSON-java
+*** Moshi
+*** Fastjson2
+*** Form
+*** Form Spring
+** metrics
+*** Dropwizard Metrics 4
+*** Dropwizard Metrics 5
+*** Micrometer
+** interceptors
+*** RequestInterceptor
+*** ResponseInterceptor
+*** MethodInterceptor
+**** Bean Validation (JSR-303)
+**** Bean Validation (Jakarta)
+**** HTTP Cache (ETag / Last-Modified)
+** extras
+*** Hystrix
+*** SLF4J
+*** Mock
+*** Annotation Error Decoder
+@endmindmap