diff --git a/CHANGELOG.md b/CHANGELOG.md
index 513e923ab2..3a2b483532 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,16 @@
### Version 13.14
-* `JAXBContextFactory.withProperty` is now applied when creating Unmarshallers, not only
- Marshallers. Marshaller-only properties are skipped on unmarshal (#3056).
+* Add `@Experimental` `MultiEncoder`, `PredicatedEncoder` and `EncoderPredicate`, letting a single
+ client route each request to the right encoder. Encoders declare what they can handle by
+ implementing `PredicatedEncoder`; anything else is paired with a predicate via
+ `PredicatedEncoder.of(predicate, encoder)` or `MultiEncoder.builder()`. Encoders are consulted in
+ the order given and a request nothing accepts fails with an `EncodeException` naming what was
+ tried, so a default is an encoder guarded by `EncoderPredicate.any()` listed last. `FormEncoder`
+ and `SpringFormEncoder` gain `createPredicatedFormEncoder()`, a delegate-free flavour that can
+ take part. The first-party JSON encoders (Gson, Jackson, Jackson 3, Jackson Jr, Jackson JAXB,
+ 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 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 cbaae10766..037cbb3188 100644
--- a/README.md
+++ b/README.md
@@ -709,6 +709,131 @@ public class Example {
}
```
+#### Multiple encoders
+
+> This API is `@Experimental` and may change incompatibly, or be removed, in a future release.
+
+A single client sometimes has to speak more than one format — JSON for most endpoints, XML for
+a legacy one, plain bytes for an upload. `MultiEncoder` hands each request to the first encoder that
+accepts it.
+
+Most first-party encoders already declare what they can handle, so they can simply be listed, in the
+order they should be consulted:
+
+```java
+interface MixedClient {
+ @RequestLine("POST /orders")
+ @Headers("Content-Type: application/json")
+ void createOrder(Order order);
+
+ @RequestLine("POST /legacy/orders")
+ @Headers("Content-Type: application/xml")
+ void createLegacyOrder(Order order);
+}
+
+public class Example {
+ public static void main(String[] args) {
+ MixedClient client = Feign.builder()
+ .encoders(new GsonEncoder(), new JAXBEncoder())
+ .target(MixedClient.class, "https://foo.com");
+ }
+}
+```
+
+There is no implicit fallback. A request that no encoder accepts fails with an `EncodeException`
+naming the encoders that were tried and what each one wants:
+
+```
+Unable to encode java.lang.String (Content-Type: text/plain) for POST /orders. Encoders tried, in order:
+ - GsonEncoder
+ - JAXBEncoder
+Add an encoder guarded by EncoderPredicate.any() last to act as a default.
+```
+
+To get a default, pair an encoder with the predicate that accepts everything and list it **last**:
+
+```java
+Feign.builder()
+ .encoders(
+ new GsonEncoder(),
+ new JAXBEncoder(),
+ PredicatedEncoder.of(EncoderPredicate.any(), new DefaultEncoder()));
+```
+
+The same pairing works for any encoder that does not declare itself, including one you do not
+control. `MultiEncoder.builder()` spells it out when a lambda reads better than a wrapper:
+
+```java
+Encoder encoder =
+ MultiEncoder.builder()
+ .add(new GsonEncoder()) // declares itself
+ .add(EncoderPredicate.xmlContentType(), someXmlEncoder) // paired
+ .add((object, bodyType, template) -> bodyType == byte[].class, binaryEncoder)
+ .add(EncoderPredicate.any(), new DefaultEncoder()) // the default, last
+ .build();
+```
+
+Encoders are consulted in the order they were added, so put the narrowest one first. Note that
+`Content-Type: application/json` with a null body is claimed by a JSON encoder before
+`EncoderPredicate.emptyBody()` gets a chance — order accordingly.
+
+##### Declaring your own encoder
+
+Implement `PredicatedEncoder` and say what you handle. `canEncode` has no default: an encoder that
+declares nothing would claim every request, which is rarely what its author meant.
+
+```java
+public class MyEncoder implements PredicatedEncoder {
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isJsonContentType(template);
+ }
+
+ @Override
+ public void encode(Object object, Type bodyType, RequestTemplate template) {
+ // ...
+ }
+}
+```
+
+`EncoderPredicate` is the `@FunctionalInterface` here, so predicates can be lambdas. It ships with
+`any()`, `jsonContentType()`, `xmlContentType()`, `contentType(mediaType)`, `emptyBody()`,
+`bodyType(type)` and `formEncoded()`, 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
+`EncoderPredicate.describedAs("it is Tuesday", ...)` to read as well.
+
+`PredicatedEncoder.of(predicate, encoder)` replaces whatever the encoder says about itself, so it
+can widen an encoder as well as narrow it. To keep the encoder's own declaration and add to it, use
+`narrowing`:
+
+```java
+// only this vendor content type, and only what Gson would have taken anyway
+PredicatedEncoder.narrowing(
+ EncoderPredicate.contentType("application/vnd.acme+json"), new GsonEncoder());
+```
+
+**If you wrap an encoder, forward `canEncode` to your delegate**, otherwise wrapping silently
+changes what the encoder handles. The metrics modules' `MeteredEncoder` forwards for exactly this
+reason.
+
+##### Form encoders
+
+`FormEncoder` and `SpringFormEncoder` wrap a delegate encoder, so they cannot honestly declare what
+they handle — the delegate's applicability is unknown to them. Instead, each offers a
+delegate-free flavour that does:
+
+```java
+Feign.builder()
+ .encoders(
+ FormEncoder.createPredicatedFormEncoder(), // form and multipart requests only
+ new JacksonEncoder());
+```
+
+It accepts form and multipart requests carrying a map or a user pojo, and leaves everything else to
+the encoders registered alongside it. Constructing one directly with a `null` delegate does the same
+thing: anything it cannot encode itself fails with an `EncodeException` instead of being passed on.
+
### @Body templates
The `@Body` annotation indicates a template to expand using parameters annotated with `@Param`. 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 754fcd3067..b598888621 100644
--- a/core/src/main/java/feign/BaseBuilder.java
+++ b/core/src/main/java/feign/BaseBuilder.java
@@ -26,7 +26,10 @@
import feign.codec.DefaultEncoder;
import feign.codec.DefaultErrorDecoder;
import feign.codec.Encoder;
+import feign.codec.EncoderPredicate;
import feign.codec.ErrorDecoder;
+import feign.codec.MultiEncoder;
+import feign.codec.PredicatedEncoder;
import feign.interceptor.MethodInterceptor;
import feign.interceptor.MethodInterceptors;
import java.lang.reflect.Field;
@@ -94,6 +97,37 @@ public B encoder(Encoder encoder) {
return thisB();
}
+ /**
+ * Configures a {@link MultiEncoder} built from encoders that declare their own applicability.
+ *
+ *
Encoders are consulted in the order given, and the first one that accepts the request
+ * encodes it. There is no implicit fallback: pair an encoder with {@link EncoderPredicate#any()}
+ * and list it last to act as a default, otherwise a request nothing accepts fails with an {@link
+ * feign.codec.EncodeException}.
+ *
+ *
+ * Feign.builder()
+ * .encoders(
+ * new JacksonEncoder(),
+ * new JAXBEncoder(),
+ * PredicatedEncoder.of(EncoderPredicate.any(), new DefaultEncoder()))
+ *
+ *
+ * To pair a predicate with an encoder that does not implement {@link PredicatedEncoder}, use
+ * {@link PredicatedEncoder#of(EncoderPredicate, Encoder)} as above, or {@link
+ * MultiEncoder#builder()} for the same thing spelled out.
+ *
+ * @param encoders the predicated encoders, consulted in the order given
+ */
+ @Experimental
+ public B encoders(PredicatedEncoder... encoders) {
+ MultiEncoder.Builder builder = MultiEncoder.builder();
+ for (PredicatedEncoder encoder : encoders) {
+ builder.add(encoder);
+ }
+ return encoder(builder.build());
+ }
+
public B decoder(Decoder decoder) {
this.decoder = decoder;
return thisB();
diff --git a/core/src/main/java/feign/Util.java b/core/src/main/java/feign/Util.java
index 91cb7e5a1a..589ae406ca 100644
--- a/core/src/main/java/feign/Util.java
+++ b/core/src/main/java/feign/Util.java
@@ -51,6 +51,7 @@
import java.util.TreeMap;
import java.util.function.Predicate;
import java.util.function.Supplier;
+import java.util.regex.Pattern;
import java.util.stream.Stream;
/** Utilities, typically copied in from guava, so as to avoid dependency conflicts. */
@@ -62,6 +63,9 @@ public class Util {
/** 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 Accept-Encoding header field name. */
public static final String ACCEPT_ENCODING = "Accept-Encoding";
@@ -83,6 +87,15 @@ public class Util {
private static final int BUF_SIZE = 0x800; // 2K chars (4K bytes)
+ // matches application/json, text/json, application/vnd.github+json,
+ // application/json;charset=utf-8
+ private static final Pattern JSON_CONTENT_TYPE =
+ Pattern.compile("(?i)\\w+/(?:[\\w._-]+\\+)?json.*");
+
+ // matches application/xml, text/xml, application/soap+xml, application/xml;charset=utf-8
+ private static final Pattern XML_CONTENT_TYPE =
+ Pattern.compile("(?i)\\w+/(?:[\\w._-]+\\+)?xml.*");
+
/** Type literal for {@code Map}. */
public static final Type MAP_STRING_WILDCARD =
new Types.ParameterizedTypeImpl(
@@ -371,4 +384,67 @@ public static String getThreadIdentifier() {
+ "_"
+ currentThread.getId();
}
+
+ /**
+ * Checks whether the {@code Content-Type} header of the given template 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 template the request template to check
+ * @return {@code true} if the content type is JSON, {@code false} otherwise
+ */
+ @Experimental
+ public static boolean isJsonContentType(RequestTemplate template) {
+ return hasContentTypeMatching(template, JSON_CONTENT_TYPE);
+ }
+
+ /**
+ * Checks whether the {@code Content-Type} header of the given template 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 template the request template to check
+ * @return {@code true} if the content type is XML, {@code false} otherwise
+ */
+ @Experimental
+ public static boolean isXmlContentType(RequestTemplate template) {
+ return hasContentTypeMatching(template, 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}.
+ *
+ * @param template the request template to check
+ * @param mediaType the media type to look for, for example {@code
+ * application/x-www-form-urlencoded}
+ * @return {@code true} if the content type matches, {@code false} otherwise
+ */
+ @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()) == ';');
+ });
+ }
+
+ private static Stream contentTypes(RequestTemplate template) {
+ return template.headers().entrySet().stream()
+ .filter(header -> CONTENT_TYPE.equalsIgnoreCase(header.getKey()))
+ .map(Map.Entry::getValue)
+ .filter(Objects::nonNull)
+ .flatMap(Collection::stream)
+ .filter(Objects::nonNull);
+ }
+
+ private static boolean hasContentTypeMatching(RequestTemplate template, Pattern pattern) {
+ return contentTypes(template)
+ .anyMatch(contentType -> pattern.matcher(contentType.trim()).matches());
+ }
}
diff --git a/core/src/main/java/feign/codec/EncoderPredicate.java b/core/src/main/java/feign/codec/EncoderPredicate.java
new file mode 100644
index 0000000000..150fd22ded
--- /dev/null
+++ b/core/src/main/java/feign/codec/EncoderPredicate.java
@@ -0,0 +1,150 @@
+/*
+ * 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.RequestTemplate;
+import feign.Util;
+import java.lang.reflect.Type;
+import java.util.Objects;
+
+/**
+ * Decides whether a request can be handled by an {@link Encoder}.
+ *
+ * Predicates receive the same three arguments as {@link Encoder#encode(Object, Type,
+ * RequestTemplate)}, so they can discriminate on the body, on its declared type, or on anything
+ * already present in the template such as the {@code Content-Type} header.
+ *
+ *
Every predicate built here describes itself, so a {@link MultiEncoder} that cannot route a
+ * request can say what it did consider. Wrap your own lambdas in {@link #describedAs(String,
+ * EncoderPredicate)} to get the same in error messages.
+ *
+ * @see PredicatedEncoder
+ * @see MultiEncoder
+ */
+@Experimental
+@FunctionalInterface
+public interface EncoderPredicate {
+
+ /**
+ * Whether the encoder this predicate guards can handle the request.
+ *
+ * @param object what would be encoded as the request body
+ * @param bodyType the type the object would be encoded as. {@link Encoder#MAP_STRING_WILDCARD}
+ * indicates form encoding.
+ * @param template the request template that would be populated
+ * @return {@code true} if the request can be encoded, {@code false} otherwise
+ */
+ boolean canEncode(Object object, Type bodyType, RequestTemplate template);
+
+ /**
+ * Wraps a predicate so that it describes itself, which is what a {@link MultiEncoder} reports
+ * when no encoder accepts a request.
+ *
+ * @param description how the predicate reads in an error message, for example {@code
+ * "Content-Type is JSON"}
+ * @param predicate the predicate to describe
+ */
+ static EncoderPredicate describedAs(String description, EncoderPredicate predicate) {
+ Objects.requireNonNull(description, "description cannot be null");
+ Objects.requireNonNull(predicate, "predicate cannot be null");
+ return new EncoderPredicate() {
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return predicate.canEncode(object, bodyType, template);
+ }
+
+ @Override
+ public String toString() {
+ return description;
+ }
+ };
+ }
+
+ /**
+ * Matches every request. Pair this with an encoder registered last to make it the default of a
+ * {@link MultiEncoder}.
+ */
+ static EncoderPredicate any() {
+ return describedAs("any request", (object, bodyType, template) -> true);
+ }
+
+ /** Matches requests whose {@code Content-Type} header denotes JSON. */
+ static EncoderPredicate jsonContentType() {
+ return describedAs(
+ "Content-Type is JSON", (object, bodyType, template) -> Util.isJsonContentType(template));
+ }
+
+ /** Matches requests whose {@code Content-Type} header denotes XML. */
+ static EncoderPredicate xmlContentType() {
+ return describedAs(
+ "Content-Type is XML", (object, bodyType, template) -> Util.isXmlContentType(template));
+ }
+
+ /**
+ * Matches requests whose {@code Content-Type} header starts with the given media type, ignoring
+ * case and any parameters such as {@code ;charset=utf-8}.
+ */
+ static EncoderPredicate contentType(String mediaType) {
+ Objects.requireNonNull(mediaType, "mediaType cannot be null");
+ return describedAs(
+ "Content-Type is " + mediaType,
+ (object, bodyType, template) -> Util.hasContentType(template, mediaType));
+ }
+
+ /** Matches requests carrying no body. */
+ static EncoderPredicate emptyBody() {
+ return describedAs("body is empty", (object, bodyType, template) -> object == null);
+ }
+
+ /** Matches requests whose declared body type is exactly the given type. */
+ static EncoderPredicate bodyType(Type type) {
+ Objects.requireNonNull(type, "type cannot be null");
+ return describedAs(
+ "body type is " + type.getTypeName(),
+ (object, bodyType, template) -> type.equals(bodyType));
+ }
+
+ /** Matches form-encoded requests, as signalled by {@link Encoder#MAP_STRING_WILDCARD}. */
+ static EncoderPredicate formEncoded() {
+ return describedAs(
+ "body is form encoded",
+ (object, bodyType, template) -> Encoder.MAP_STRING_WILDCARD.equals(bodyType));
+ }
+
+ default EncoderPredicate and(EncoderPredicate other) {
+ Objects.requireNonNull(other, "other cannot be null");
+ return describedAs(
+ "(" + this + " and " + other + ")",
+ (object, bodyType, template) ->
+ canEncode(object, bodyType, template) && other.canEncode(object, bodyType, template));
+ }
+
+ default EncoderPredicate or(EncoderPredicate other) {
+ Objects.requireNonNull(other, "other cannot be null");
+ return describedAs(
+ "(" + this + " or " + other + ")",
+ (object, bodyType, template) ->
+ canEncode(object, bodyType, template) || other.canEncode(object, bodyType, template));
+ }
+
+ default EncoderPredicate negate() {
+ return describedAs(
+ "not (" + this + ")",
+ (object, bodyType, template) -> !canEncode(object, bodyType, template));
+ }
+}
diff --git a/core/src/main/java/feign/codec/MultiEncoder.java b/core/src/main/java/feign/codec/MultiEncoder.java
new file mode 100644
index 0000000000..23feaf35ba
--- /dev/null
+++ b/core/src/main/java/feign/codec/MultiEncoder.java
@@ -0,0 +1,163 @@
+/*
+ * 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.RequestTemplate;
+import feign.Util;
+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;
+
+/**
+ * An {@link Encoder} that hands each request to the first encoder that accepts it.
+ *
+ *
Encoders come from two places. An encoder that implements {@link PredicatedEncoder} declares
+ * its own applicability and can simply be added; any other encoder is paired with an {@link
+ * EncoderPredicate} at the call site:
+ *
+ *
+ * Feign.builder()
+ * .encoder(
+ * MultiEncoder.builder()
+ * .add(new JacksonEncoder())
+ * .add(EncoderPredicate.xmlContentType(), new JAXBEncoder())
+ * .add((object, bodyType, template) -> bodyType == byte[].class, new BinaryEncoder())
+ * .add(EncoderPredicate.any(), new DefaultEncoder())
+ * .build());
+ *
+ *
+ * Encoders are consulted in the order they were added, so the narrowest one comes first. There
+ * is no implicit fallback: a request no encoder accepts fails with an {@link EncodeException}
+ * naming what was tried. Add an encoder guarded by {@link EncoderPredicate#any()} last to act as a
+ * default, as above.
+ *
+ * @see PredicatedEncoder
+ * @see EncoderPredicate
+ */
+@Experimental
+public class MultiEncoder implements Encoder {
+
+ private final List encoders;
+
+ private MultiEncoder(List encoders) {
+ this.encoders = Collections.unmodifiableList(new ArrayList<>(encoders));
+ }
+
+ /** Starts building a multi-encoder. */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Encodes using the first encoder that accepts the request.
+ *
+ * @param object {@inheritDoc}
+ * @param bodyType {@inheritDoc}
+ * @param template {@inheritDoc}
+ * @throws EncodeException when no encoder accepts the request, or the chosen one fails
+ */
+ @Override
+ public void encode(Object object, Type bodyType, RequestTemplate template)
+ throws EncodeException {
+ for (PredicatedEncoder encoder : encoders) {
+ if (encoder.canEncode(object, bodyType, template)) {
+ encoder.encode(object, bodyType, template);
+ return;
+ }
+ }
+ throw new EncodeException(unableToEncode(bodyType, template));
+ }
+
+ 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(')');
+ 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));
+ }
+ return message
+ .append("\nAdd an encoder guarded by EncoderPredicate.any() last to act as a default.")
+ .toString();
+ }
+
+ private static String contentTypes(RequestTemplate template) {
+ String contentTypes =
+ template.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 "MultiEncoder"
+ + encoders.stream().map(PairedEncoder::describe).collect(Collectors.toList());
+ }
+
+ /** Collects the encoders of a {@link MultiEncoder}. */
+ @Experimental
+ public static final class Builder {
+
+ private final List encoders = new ArrayList<>();
+
+ private Builder() {}
+
+ /**
+ * Adds an encoder that declares its own applicability.
+ *
+ * @param encoder the encoder, consulted via {@link PredicatedEncoder#canEncode}
+ */
+ public Builder add(PredicatedEncoder encoder) {
+ encoders.add(Objects.requireNonNull(encoder, "encoder cannot be null"));
+ return this;
+ }
+
+ /**
+ * Adds any encoder, guarded by the given predicate. Use this for encoders that do not implement
+ * {@link PredicatedEncoder}, including ones you do not control.
+ *
+ * @param predicate decides whether the encoder handles a request
+ * @param encoder the encoder to delegate to
+ */
+ public Builder add(EncoderPredicate predicate, Encoder encoder) {
+ return add(PredicatedEncoder.of(predicate, encoder));
+ }
+
+ /** Builds the multi-encoder. */
+ public MultiEncoder build() {
+ return new MultiEncoder(encoders);
+ }
+ }
+}
diff --git a/core/src/main/java/feign/codec/PairedEncoder.java b/core/src/main/java/feign/codec/PairedEncoder.java
new file mode 100644
index 0000000000..61a63f7636
--- /dev/null
+++ b/core/src/main/java/feign/codec/PairedEncoder.java
@@ -0,0 +1,77 @@
+/*
+ * 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.RequestTemplate;
+import java.lang.reflect.Type;
+import java.util.Objects;
+
+/** An encoder that does not declare itself, guarded by a predicate supplied at the call site. */
+final class PairedEncoder implements PredicatedEncoder {
+
+ private final EncoderPredicate predicate;
+
+ private final Encoder encoder;
+
+ PairedEncoder(EncoderPredicate predicate, Encoder encoder) {
+ this.predicate = Objects.requireNonNull(predicate, "predicate cannot be null");
+ this.encoder = Objects.requireNonNull(encoder, "encoder cannot be null");
+ }
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return predicate.canEncode(object, bodyType, template);
+ }
+
+ @Override
+ public void encode(Object object, Type bodyType, RequestTemplate template)
+ throws EncodeException {
+ encoder.encode(object, bodyType, template);
+ }
+
+ @Override
+ public String toString() {
+ return describe(encoder) + " when " + predicate;
+ }
+
+ /** Requires both the predicate and, when the encoder declares one, its own applicability. */
+ static EncoderPredicate narrow(EncoderPredicate predicate, Encoder encoder) {
+ Objects.requireNonNull(predicate, "predicate cannot be null");
+ Objects.requireNonNull(encoder, "encoder cannot be null");
+ if (!(encoder instanceof PredicatedEncoder)) {
+ return predicate;
+ }
+ if (encoder instanceof PairedEncoder) {
+ return predicate.and(((PairedEncoder) encoder).predicate);
+ }
+ PredicatedEncoder predicated = (PredicatedEncoder) encoder;
+ return predicate.and(
+ EncoderPredicate.describedAs(describe(encoder) + " accepts it", predicated::canEncode));
+ }
+
+ /** The encoder's own {@code toString} when it has one, its class name otherwise. */
+ static String describe(Encoder encoder) {
+ Class> type = encoder.getClass();
+ try {
+ if (type.getMethod("toString").getDeclaringClass() != Object.class) {
+ return encoder.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/PredicatedEncoder.java b/core/src/main/java/feign/codec/PredicatedEncoder.java
new file mode 100644
index 0000000000..f9cd135acf
--- /dev/null
+++ b/core/src/main/java/feign/codec/PredicatedEncoder.java
@@ -0,0 +1,110 @@
+/*
+ * 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.RequestTemplate;
+import java.lang.reflect.Type;
+
+/**
+ * An {@link Encoder} that knows which requests it can handle.
+ *
+ * Encoders implement this to declare their own applicability, so a {@link MultiEncoder} can
+ * route each request to the right one without the call site having to wrap anything:
+ *
+ *
+ * public class JacksonEncoder implements PredicatedEncoder {
+ *
+ * @Override
+ * public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ * return Util.isJsonContentType(template);
+ * }
+ *
+ * @Override
+ * public void encode(Object object, Type bodyType, RequestTemplate template) {
+ * // ...
+ * }
+ * }
+ *
+ *
+ * {@code canEncode} is deliberately abstract: an encoder that says nothing about what it handles
+ * would claim every request, which is almost never what its author meant. Use {@link
+ * #of(EncoderPredicate, Encoder)} to give an existing encoder a predicate instead of implementing
+ * this on it, and {@link EncoderPredicate} — which is a {@code @FunctionalInterface} —
+ * to write that predicate as a lambda.
+ *
+ *
Encoders that wrap another encoder should forward {@code canEncode} to their delegate, so that
+ * wrapping does not discard the delegate's applicability.
+ *
+ * @see MultiEncoder
+ * @see EncoderPredicate
+ */
+@Experimental
+public interface PredicatedEncoder extends Encoder {
+
+ /**
+ * Pairs any encoder with a predicate, for encoders that do not declare themselves, 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
+ * #narrowing(EncoderPredicate, Encoder)} to keep the encoder's own declaration.
+ *
+ *
An encoder paired with {@link EncoderPredicate#any()} accepts everything, which is how a
+ * {@link MultiEncoder} is given a default:
+ *
+ *
+ * Feign.builder()
+ * .encoders(
+ * new JacksonEncoder(),
+ * PredicatedEncoder.of(EncoderPredicate.any(), new Encoder.Default()));
+ *
+ *
+ * @param predicate decides whether the encoder handles a request
+ * @param encoder the encoder to delegate to
+ */
+ static PredicatedEncoder of(EncoderPredicate predicate, Encoder encoder) {
+ return new PairedEncoder(predicate, encoder);
+ }
+
+ /**
+ * Narrows an encoder that already declares itself, by requiring both the given predicate and the
+ * encoder's own {@code canEncode} to accept the request:
+ *
+ *
+ * PredicatedEncoder.narrowing(
+ * EncoderPredicate.contentType("application/vnd.acme+json"), new GsonEncoder());
+ *
+ *
+ * An encoder that does not implement {@link PredicatedEncoder} declares nothing to narrow, so
+ * this behaves like {@link #of(EncoderPredicate, Encoder)}.
+ *
+ * @param predicate narrows what the encoder handles
+ * @param encoder the encoder to delegate to
+ */
+ static PredicatedEncoder narrowing(EncoderPredicate predicate, Encoder encoder) {
+ return new PairedEncoder(PairedEncoder.narrow(predicate, encoder), encoder);
+ }
+
+ /**
+ * Whether this encoder can handle the request.
+ *
+ * @param object what to encode as the request body
+ * @param bodyType the type the object should be encoded as. {@link Encoder#MAP_STRING_WILDCARD}
+ * indicates form encoding.
+ * @param template the request template to populate
+ * @return {@code true} if this encoder can encode the request, {@code false} otherwise
+ */
+ boolean canEncode(Object object, Type bodyType, RequestTemplate template);
+}
diff --git a/core/src/test/java/feign/codec/EncoderPredicateTest.java b/core/src/test/java/feign/codec/EncoderPredicateTest.java
new file mode 100644
index 0000000000..5028567d6a
--- /dev/null
+++ b/core/src/test/java/feign/codec/EncoderPredicateTest.java
@@ -0,0 +1,148 @@
+/*
+ * 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.RequestTemplate;
+import org.junit.jupiter.api.Test;
+
+class EncoderPredicateTest {
+
+ private static RequestTemplate template(String contentType) {
+ RequestTemplate template = new RequestTemplate();
+ if (contentType != null) {
+ template.header("Content-Type", contentType);
+ }
+ return template;
+ }
+
+ private static boolean test(EncoderPredicate predicate, String contentType) {
+ return predicate.canEncode("body", String.class, template(contentType));
+ }
+
+ @Test
+ void anyMatchesEverything() {
+ EncoderPredicate any = EncoderPredicate.any();
+
+ assertThat(test(any, "application/json")).isTrue();
+ assertThat(test(any, null)).isTrue();
+ assertThat(any.canEncode(null, null, template(null))).isTrue();
+ }
+
+ @Test
+ void jsonContentTypeMatchesJsonOnly() {
+ EncoderPredicate json = EncoderPredicate.jsonContentType();
+
+ assertThat(test(json, "application/json")).isTrue();
+ assertThat(test(json, "application/json;charset=utf-8")).isTrue();
+ assertThat(test(json, "application/vnd.github+json")).isTrue();
+ assertThat(test(json, "text/json")).isTrue();
+ assertThat(test(json, "application/xml")).isFalse();
+ assertThat(test(json, null)).isFalse();
+ }
+
+ @Test
+ void xmlContentTypeMatchesXmlOnly() {
+ EncoderPredicate xml = EncoderPredicate.xmlContentType();
+
+ assertThat(test(xml, "application/xml")).isTrue();
+ assertThat(test(xml, "text/xml")).isTrue();
+ assertThat(test(xml, "application/soap+xml")).isTrue();
+ assertThat(test(xml, "application/json")).isFalse();
+ assertThat(test(xml, null)).isFalse();
+ }
+
+ @Test
+ void contentTypeMatchesExactMediaTypeIgnoringParameters() {
+ EncoderPredicate form = EncoderPredicate.contentType("application/x-www-form-urlencoded");
+
+ assertThat(test(form, "application/x-www-form-urlencoded")).isTrue();
+ assertThat(test(form, "APPLICATION/X-WWW-FORM-URLENCODED")).isTrue();
+ assertThat(test(form, "application/x-www-form-urlencoded;charset=utf-8")).isTrue();
+ assertThat(test(form, "application/x-www-form-urlencoded-extra")).isFalse();
+ assertThat(test(form, "application/json")).isFalse();
+ }
+
+ @Test
+ void headerNameIsMatchedCaseInsensitively() {
+ RequestTemplate template = new RequestTemplate();
+ template.header("content-type", "application/json");
+
+ assertThat(EncoderPredicate.jsonContentType().canEncode("body", String.class, template))
+ .isTrue();
+ }
+
+ @Test
+ void emptyBodyMatchesNullBodyOnly() {
+ EncoderPredicate empty = EncoderPredicate.emptyBody();
+
+ assertThat(empty.canEncode(null, String.class, template(null))).isTrue();
+ assertThat(empty.canEncode("body", String.class, template(null))).isFalse();
+ }
+
+ @Test
+ void bodyTypeMatchesExactType() {
+ EncoderPredicate bytes = EncoderPredicate.bodyType(byte[].class);
+
+ assertThat(bytes.canEncode(new byte[0], byte[].class, template(null))).isTrue();
+ assertThat(bytes.canEncode("body", String.class, template(null))).isFalse();
+ }
+
+ @Test
+ void formEncodedMatchesTheFormBodyTypeMarker() {
+ EncoderPredicate form = EncoderPredicate.formEncoded();
+
+ assertThat(form.canEncode(null, Encoder.MAP_STRING_WILDCARD, template(null))).isTrue();
+ assertThat(form.canEncode("body", String.class, template(null))).isFalse();
+ }
+
+ @Test
+ void predicatesDescribeThemselves() {
+ assertThat(EncoderPredicate.any()).hasToString("any request");
+ assertThat(EncoderPredicate.jsonContentType()).hasToString("Content-Type is JSON");
+ assertThat(EncoderPredicate.xmlContentType()).hasToString("Content-Type is XML");
+ assertThat(EncoderPredicate.contentType("text/plain"))
+ .hasToString("Content-Type is text/plain");
+ assertThat(EncoderPredicate.emptyBody()).hasToString("body is empty");
+ assertThat(EncoderPredicate.bodyType(byte[].class)).hasToString("body type is byte[]");
+ assertThat(EncoderPredicate.formEncoded()).hasToString("body is form encoded");
+ assertThat(EncoderPredicate.describedAs("it is Tuesday", (o, b, t) -> true))
+ .hasToString("it is Tuesday");
+ }
+
+ @Test
+ void combinedPredicatesDescribeThemselves() {
+ EncoderPredicate json = EncoderPredicate.jsonContentType();
+ EncoderPredicate xml = EncoderPredicate.xmlContentType();
+
+ assertThat(json.or(xml)).hasToString("(Content-Type is JSON or Content-Type is XML)");
+ assertThat(json.and(xml)).hasToString("(Content-Type is JSON and Content-Type is XML)");
+ assertThat(json.negate()).hasToString("not (Content-Type is JSON)");
+ }
+
+ @Test
+ void combinators() {
+ EncoderPredicate json = EncoderPredicate.jsonContentType();
+ EncoderPredicate xml = EncoderPredicate.xmlContentType();
+
+ assertThat(test(json.or(xml), "application/xml")).isTrue();
+ assertThat(test(json.or(xml), "text/plain")).isFalse();
+ assertThat(test(json.and(xml), "application/json")).isFalse();
+ assertThat(test(json.negate(), "application/xml")).isTrue();
+ assertThat(test(json.negate(), "application/json")).isFalse();
+ }
+}
diff --git a/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java b/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java
new file mode 100644
index 0000000000..b72fe9cdc9
--- /dev/null
+++ b/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java
@@ -0,0 +1,221 @@
+/*
+ * 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.Headers;
+import feign.RequestLine;
+import feign.RequestTemplate;
+import feign.Response;
+import feign.Util;
+import java.lang.reflect.Type;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+
+/** How {@link MultiEncoder} behaves once configured on a {@link Feign} builder. */
+class MultiEncoderCapabilityTest {
+
+ interface MixedApi {
+ @RequestLine("POST /json")
+ @Headers("Content-Type: application/json")
+ void json(String body);
+
+ @RequestLine("POST /xml")
+ @Headers("Content-Type: application/xml")
+ void xml(String body);
+ }
+
+ static class TaggingEncoder implements Encoder {
+ private final String tag;
+
+ TaggingEncoder(String tag) {
+ this.tag = tag;
+ }
+
+ @Override
+ public void encode(Object object, Type bodyType, RequestTemplate template) {
+ template.body(tag);
+ }
+ }
+
+ /** A capability that wraps the encoder, the way the metrics modules do. */
+ public static class CountingCapability implements Capability {
+ int wrapped;
+ int encodeCalls;
+
+ @Override
+ public Encoder enrich(Encoder encoder) {
+ wrapped++;
+ return (object, bodyType, template) -> {
+ encodeCalls++;
+ encoder.encode(object, bodyType, template);
+ };
+ }
+ }
+
+ private static MixedApi target(Feign.Builder builder, AtomicReference captured) {
+ return builder
+ .client(
+ (request, options) -> {
+ captured.set(new String(request.body(), Util.UTF_8));
+ return Response.builder()
+ .status(200)
+ .reason("OK")
+ .request(request)
+ .headers(Collections.emptyMap())
+ .body("", Util.UTF_8)
+ .build();
+ })
+ .target(MixedApi.class, "http://localhost:1");
+ }
+
+ private static RequestTemplate template(String contentType) {
+ RequestTemplate template = new RequestTemplate();
+ template.header("Content-Type", contentType);
+ return template;
+ }
+
+ @Test
+ void encodersOnTheBuilderRouteInTheOrderGiven() {
+ AtomicReference captured = new AtomicReference<>();
+
+ MixedApi api =
+ target(
+ Feign.builder()
+ .encoders(
+ PredicatedEncoder.of(
+ EncoderPredicate.jsonContentType(), new TaggingEncoder("json")),
+ PredicatedEncoder.of(EncoderPredicate.any(), new TaggingEncoder("fallback"))),
+ captured);
+
+ api.json("{}");
+ assertThat(captured.get()).isEqualTo("json");
+
+ api.xml(" ");
+ assertThat(captured.get()).isEqualTo("fallback");
+ }
+
+ @Test
+ void encodersOnTheBuilderFailWhenNothingAccepts() {
+ MixedApi api =
+ target(
+ Feign.builder()
+ .encoders(
+ PredicatedEncoder.of(
+ EncoderPredicate.jsonContentType(), new TaggingEncoder("json"))),
+ new AtomicReference<>());
+
+ assertThatThrownBy(() -> api.xml(" "))
+ .isInstanceOf(EncodeException.class)
+ .hasMessageContaining("Unable to encode java.lang.String (Content-Type: application/xml)")
+ .hasMessageContaining("TaggingEncoder when Content-Type is JSON");
+ }
+
+ @Test
+ void capabilityWrapsTheCompositeAndRoutingStillWorks() {
+ CountingCapability capability = new CountingCapability();
+ AtomicReference captured = new AtomicReference<>();
+
+ MixedApi api =
+ target(
+ Feign.builder()
+ .encoder(
+ MultiEncoder.builder()
+ .add(EncoderPredicate.jsonContentType(), new TaggingEncoder("json"))
+ .add(EncoderPredicate.xmlContentType(), new TaggingEncoder("xml"))
+ .add(EncoderPredicate.any(), new TaggingEncoder("fallback"))
+ .build())
+ .addCapability(capability),
+ captured);
+
+ api.json("{}");
+ assertThat(captured.get()).isEqualTo("json");
+
+ api.xml(" ");
+ assertThat(captured.get()).isEqualTo("xml");
+
+ // the capability sees the MultiEncoder as one encoder, not one per delegate
+ assertThat(capability.wrapped).isEqualTo(1);
+ assertThat(capability.encodeCalls).isEqualTo(2);
+ }
+
+ /**
+ * A wrapper that answers {@code canEncode} for itself instead of forwarding claims every request,
+ * which is why the metrics modules' {@code MeteredEncoder} forwards it to its delegate.
+ */
+ @Test
+ void wrappingWithoutForwardingCanEncodeErasesSelfDeclaration() {
+ PredicatedEncoder jsonOnly =
+ new PredicatedEncoder() {
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isJsonContentType(template);
+ }
+
+ @Override
+ public void encode(Object object, Type bodyType, RequestTemplate template) {
+ template.body("json");
+ }
+ };
+
+ PredicatedEncoder naive =
+ new PredicatedEncoder() {
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return true;
+ }
+
+ @Override
+ public void encode(Object object, Type bodyType, RequestTemplate template) {
+ jsonOnly.encode(object, bodyType, template);
+ }
+ };
+
+ PredicatedEncoder forwarding =
+ new PredicatedEncoder() {
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return jsonOnly.canEncode(object, bodyType, template);
+ }
+
+ @Override
+ public void encode(Object object, Type bodyType, RequestTemplate template) {
+ jsonOnly.encode(object, bodyType, template);
+ }
+ };
+
+ RequestTemplate naiveTemplate = template("application/xml");
+ MultiEncoder.builder()
+ .add(naive)
+ .add(EncoderPredicate.any(), new TaggingEncoder("fallback"))
+ .build()
+ .encode("body", String.class, naiveTemplate);
+ assertThat(naiveTemplate.requestBody().asString()).isEqualTo("json");
+
+ RequestTemplate forwardedTemplate = template("application/xml");
+ MultiEncoder.builder()
+ .add(forwarding)
+ .add(EncoderPredicate.any(), new TaggingEncoder("fallback"))
+ .build()
+ .encode("body", String.class, forwardedTemplate);
+ assertThat(forwardedTemplate.requestBody().asString()).isEqualTo("fallback");
+ }
+}
diff --git a/core/src/test/java/feign/codec/MultiEncoderTest.java b/core/src/test/java/feign/codec/MultiEncoderTest.java
new file mode 100644
index 0000000000..88914e77ec
--- /dev/null
+++ b/core/src/test/java/feign/codec/MultiEncoderTest.java
@@ -0,0 +1,320 @@
+/*
+ * 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.RequestTemplate;
+import feign.Util;
+import java.lang.reflect.Type;
+import org.junit.jupiter.api.Test;
+
+class MultiEncoderTest {
+
+ /** A plain encoder, with no opinion about what it can handle. */
+ private static class RecordingEncoder implements Encoder {
+ private final String body;
+ boolean invoked;
+
+ RecordingEncoder(String body) {
+ this.body = body;
+ }
+
+ @Override
+ public void encode(Object object, Type bodyType, RequestTemplate template) {
+ invoked = true;
+ template.body(Request.Body.create(body));
+ }
+ }
+
+ /** An encoder that declares its own applicability, the way feign-gson and friends now do. */
+ private static class SelfDeclaringJsonEncoder extends RecordingEncoder
+ implements PredicatedEncoder {
+
+ SelfDeclaringJsonEncoder() {
+ super("json");
+ }
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isJsonContentType(template);
+ }
+ }
+
+ private static RequestTemplate templateWithContentType(String contentType) {
+ RequestTemplate template = new RequestTemplate();
+ if (contentType != null) {
+ template.header("Content-Type", contentType);
+ }
+ return template;
+ }
+
+ @Test
+ void routesToTheEncoderThatDeclaresItCanHandleTheRequest() {
+ SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder();
+ RecordingEncoder fallback = new RecordingEncoder("fallback");
+
+ Encoder encoder =
+ MultiEncoder.builder().add(json).add(EncoderPredicate.any(), fallback).build();
+
+ RequestTemplate template = templateWithContentType("application/json");
+ encoder.encode("body", String.class, template);
+
+ assertThat(json.invoked).isTrue();
+ assertThat(fallback.invoked).isFalse();
+ assertThat(template.requestBody().asString()).isEqualTo("json");
+ }
+
+ @Test
+ void pairsAPredicateWithAnEncoderThatDoesNotDeclareItself() {
+ RecordingEncoder xml = new RecordingEncoder("xml");
+ RecordingEncoder fallback = new RecordingEncoder("fallback");
+
+ Encoder encoder =
+ MultiEncoder.builder()
+ .add(EncoderPredicate.xmlContentType(), xml)
+ .add(EncoderPredicate.any(), fallback)
+ .build();
+
+ encoder.encode("body", String.class, templateWithContentType("application/xml"));
+
+ assertThat(xml.invoked).isTrue();
+ assertThat(fallback.invoked).isFalse();
+ }
+
+ @Test
+ void mixesSelfDeclaringEncodersAndPairs() {
+ SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder();
+ RecordingEncoder xml = new RecordingEncoder("xml");
+ RecordingEncoder binary = new RecordingEncoder("binary");
+ RecordingEncoder fallback = new RecordingEncoder("fallback");
+
+ Encoder encoder =
+ MultiEncoder.builder()
+ .add(json)
+ .add(EncoderPredicate.xmlContentType(), xml)
+ .add(EncoderPredicate.bodyType(byte[].class), binary)
+ .add(EncoderPredicate.any(), fallback)
+ .build();
+
+ encoder.encode(
+ new byte[] {1}, byte[].class, templateWithContentType("application/octet-stream"));
+
+ assertThat(binary.invoked).isTrue();
+ assertThat(json.invoked).isFalse();
+ assertThat(xml.invoked).isFalse();
+ assertThat(fallback.invoked).isFalse();
+ }
+
+ @Test
+ void matchesSuffixedContentTypes() {
+ SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder();
+
+ Encoder encoder = MultiEncoder.builder().add(json).build();
+
+ encoder.encode("body", String.class, templateWithContentType("application/vnd.github+json"));
+
+ assertThat(json.invoked).isTrue();
+ }
+
+ @Test
+ void fallsBackToTheEncoderThatAcceptsAnything() {
+ SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder();
+ RecordingEncoder fallback = new RecordingEncoder("fallback");
+
+ Encoder encoder =
+ MultiEncoder.builder().add(json).add(EncoderPredicate.any(), fallback).build();
+
+ RequestTemplate template = templateWithContentType("text/plain");
+ encoder.encode("body", String.class, template);
+
+ assertThat(json.invoked).isFalse();
+ assertThat(fallback.invoked).isTrue();
+ assertThat(template.requestBody().asString()).isEqualTo("fallback");
+ }
+
+ @Test
+ void fallsBackWhenNoContentTypeIsSet() {
+ SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder();
+ RecordingEncoder fallback = new RecordingEncoder("fallback");
+
+ Encoder encoder =
+ MultiEncoder.builder().add(json).add(EncoderPredicate.any(), fallback).build();
+
+ encoder.encode("body", String.class, templateWithContentType(null));
+
+ assertThat(fallback.invoked).isTrue();
+ }
+
+ @Test
+ void encodersAreConsultedInOrder() {
+ RecordingEncoder first = new RecordingEncoder("first");
+ RecordingEncoder second = new RecordingEncoder("second");
+
+ Encoder encoder =
+ MultiEncoder.builder()
+ .add(EncoderPredicate.jsonContentType(), first)
+ .add(EncoderPredicate.jsonContentType(), second)
+ .build();
+
+ encoder.encode("body", String.class, templateWithContentType("application/json"));
+
+ assertThat(first.invoked).isTrue();
+ assertThat(second.invoked).isFalse();
+ }
+
+ @Test
+ void pairingReplacesWhatTheEncoderDeclaresAboutItself() {
+ SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder();
+
+ Encoder encoder =
+ MultiEncoder.builder().add(PredicatedEncoder.of(EncoderPredicate.any(), json)).build();
+
+ encoder.encode("body", String.class, templateWithContentType("text/plain"));
+
+ assertThat(json.invoked).isTrue();
+ }
+
+ @Test
+ void narrowingKeepsWhatTheEncoderDeclaresAboutItself() {
+ SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder();
+ PredicatedEncoder narrowed =
+ PredicatedEncoder.narrowing(
+ EncoderPredicate.contentType("application/vnd.acme+json"), json);
+
+ assertThat(
+ narrowed.canEncode("body", String.class, templateWithContentType("application/json")))
+ .isFalse();
+ assertThat(
+ narrowed.canEncode(
+ "body", String.class, templateWithContentType("application/vnd.acme+json")))
+ .isTrue();
+ assertThat(narrowed)
+ .hasToString(
+ "SelfDeclaringJsonEncoder when (Content-Type is application/vnd.acme+json"
+ + " and SelfDeclaringJsonEncoder accepts it)");
+ }
+
+ @Test
+ void narrowingAnEncoderThatDeclaresNothingIsJustThePredicate() {
+ RecordingEncoder plain = new RecordingEncoder("plain");
+ PredicatedEncoder narrowed =
+ PredicatedEncoder.narrowing(EncoderPredicate.jsonContentType(), plain);
+
+ assertThat(narrowed).hasToString("RecordingEncoder when Content-Type is JSON");
+ assertThat(
+ narrowed.canEncode("body", String.class, templateWithContentType("application/json")))
+ .isTrue();
+ }
+
+ @Test
+ void propagatesEncodeExceptionFromDelegate() {
+ Encoder failing =
+ (object, bodyType, template) -> {
+ throw new EncodeException("boom");
+ };
+
+ Encoder encoder =
+ MultiEncoder.builder().add(EncoderPredicate.jsonContentType(), failing).build();
+
+ assertThatThrownBy(
+ () -> encoder.encode("body", String.class, templateWithContentType("application/json")))
+ .isInstanceOf(EncodeException.class)
+ .hasMessage("boom");
+ }
+
+ @Test
+ void throwsWhenNoEncoderAcceptsTheRequest() {
+ Encoder encoder =
+ MultiEncoder.builder()
+ .add(new SelfDeclaringJsonEncoder())
+ .add(EncoderPredicate.xmlContentType(), new RecordingEncoder("xml"))
+ .build();
+
+ assertThatThrownBy(
+ () -> encoder.encode("body", String.class, templateWithContentType("text/plain")))
+ .isInstanceOf(EncodeException.class)
+ .hasMessage(
+ "Unable to encode java.lang.String (Content-Type: text/plain)."
+ + " 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.");
+ }
+
+ @Test
+ void theFailureNamesTheRequestWhenTheTemplateHasOne() {
+ RequestTemplate template = templateWithContentType("text/plain");
+ template.method(Request.HttpMethod.POST);
+ template.uri("/orders");
+
+ Encoder encoder = MultiEncoder.builder().add(new SelfDeclaringJsonEncoder()).build();
+
+ assertThatThrownBy(() -> encoder.encode("body", String.class, template))
+ .isInstanceOf(EncodeException.class)
+ .hasMessageContaining(
+ "Unable to encode java.lang.String (Content-Type: text/plain) for POST /orders.");
+ }
+
+ @Test
+ void theFailureReportsAMissingContentType() {
+ Encoder encoder = MultiEncoder.builder().add(new SelfDeclaringJsonEncoder()).build();
+
+ assertThatThrownBy(() -> encoder.encode("body", String.class, templateWithContentType(null)))
+ .isInstanceOf(EncodeException.class)
+ .hasMessageContaining("(Content-Type: not set)");
+ }
+
+ @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.");
+ }
+
+ @Test
+ void rejectsNullArguments() {
+ assertThatThrownBy(() -> MultiEncoder.builder().add(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("encoder cannot be null");
+ assertThatThrownBy(() -> MultiEncoder.builder().add(null, new DefaultEncoder()))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("predicate cannot be null");
+ assertThatThrownBy(() -> MultiEncoder.builder().add(EncoderPredicate.any(), null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("encoder cannot be null");
+ }
+
+ @Test
+ void toStringDescribesEncoders() {
+ Encoder encoder =
+ MultiEncoder.builder()
+ .add(new SelfDeclaringJsonEncoder())
+ .add(EncoderPredicate.jsonContentType(), new RecordingEncoder("json"))
+ .build();
+
+ assertThat(encoder.toString())
+ .isEqualTo(
+ "MultiEncoder[SelfDeclaringJsonEncoder, RecordingEncoder when Content-Type is JSON]");
+ }
+}
diff --git a/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredEncoder.java b/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredEncoder.java
index 2a2c644c5f..f5eb123c45 100644
--- a/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredEncoder.java
+++ b/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredEncoder.java
@@ -20,10 +20,11 @@
import feign.RequestTemplate;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.PredicatedEncoder;
import java.lang.reflect.Type;
/** Warp feign {@link Encoder} with metrics. */
-public class MeteredEncoder implements Encoder {
+public class MeteredEncoder implements Encoder, PredicatedEncoder {
private final Encoder encoder;
private final MetricRegistry metricRegistry;
@@ -59,4 +60,10 @@ public void encode(Object object, Type bodyType, RequestTemplate template)
.update(template.body().length);
}
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return !(encoder instanceof PredicatedEncoder)
+ || ((PredicatedEncoder) encoder).canEncode(object, bodyType, template);
+ }
}
diff --git a/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredEncoder.java b/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredEncoder.java
index 77cc7b78cb..2cff8cd788 100644
--- a/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredEncoder.java
+++ b/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredEncoder.java
@@ -18,13 +18,14 @@
import feign.RequestTemplate;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.PredicatedEncoder;
import io.dropwizard.metrics5.MetricRegistry;
import io.dropwizard.metrics5.Timer.Context;
import java.lang.reflect.Type;
import java.util.Map;
/** Warp feign {@link Encoder} with metrics. */
-public class MeteredEncoder implements Encoder {
+public class MeteredEncoder implements Encoder, PredicatedEncoder {
private final Encoder encoder;
private final MetricRegistry metricRegistry;
@@ -71,4 +72,10 @@ public void encode(Object object, Type bodyType, RequestTemplate template)
.update(template.body().length);
}
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return !(encoder instanceof PredicatedEncoder)
+ || ((PredicatedEncoder) encoder).canEncode(object, bodyType, template);
+ }
}
diff --git a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java
index 06a98e8dd2..35efae25b6 100644
--- a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java
+++ b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java
@@ -22,12 +22,13 @@
import feign.codec.EncodeException;
import feign.codec.Encoder;
import feign.codec.JsonEncoder;
+import feign.codec.PredicatedEncoder;
import java.lang.reflect.Type;
/**
* @author changjin wei(魏昌进)
*/
-public class Fastjson2Encoder implements Encoder, JsonEncoder {
+public class Fastjson2Encoder implements Encoder, PredicatedEncoder, JsonEncoder {
private final JSONWriter.Feature[] features;
@@ -44,4 +45,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template)
throws EncodeException {
template.body(JSON.toJSONBytes(object, features), Util.UTF_8);
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isJsonContentType(template);
+ }
}
diff --git a/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java b/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java
index 67c9bd2177..a26600838a 100644
--- a/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java
+++ b/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java
@@ -22,6 +22,7 @@
import feign.codec.DefaultEncoder;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.PredicatedEncoder;
import feign.form.FormEncoder;
import feign.form.MultipartFormContentProcessor;
import java.lang.reflect.Type;
@@ -42,10 +43,22 @@ public SpringFormEncoder() {
this(new DefaultEncoder());
}
+ /**
+ * Creates a Spring form encoder that declares what it can handle, for use with {@code
+ * MultiEncoder}. It has no delegate, so a request it does not accept is left for the other
+ * encoders registered alongside it.
+ *
+ * @return a Spring form encoder guarded by {@link FormEncoder#formRequests()}
+ */
+ public static PredicatedEncoder createPredicatedFormEncoder() {
+ return PredicatedEncoder.of(FormEncoder.formRequests(), new SpringFormEncoder(null));
+ }
+
/**
* Constructor with specified delegate encoder.
*
- * @param delegate delegate encoder, if this encoder couldn't encode object.
+ * @param delegate delegate encoder, if this encoder couldn't encode object. {@code null} leaves
+ * this encoder without one, see {@link FormEncoder#FormEncoder(Encoder)}.
*/
public SpringFormEncoder(Encoder delegate) {
super(delegate);
diff --git a/form/src/main/java/feign/form/FormEncoder.java b/form/src/main/java/feign/form/FormEncoder.java
index fb05cde7cd..3d2ad676a7 100644
--- a/form/src/main/java/feign/form/FormEncoder.java
+++ b/form/src/main/java/feign/form/FormEncoder.java
@@ -25,6 +25,8 @@
import feign.codec.DefaultEncoder;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.EncoderPredicate;
+import feign.codec.PredicatedEncoder;
import java.lang.reflect.Type;
import java.nio.charset.Charset;
import java.nio.charset.IllegalCharsetNameException;
@@ -48,6 +50,16 @@ public class FormEncoder implements Encoder {
private static final Pattern CHARSET_PATTERN;
+ /** Stands in for a delegate that was never supplied, see {@link #FormEncoder(Encoder)}. */
+ private static final Encoder NO_DELEGATE =
+ (object, bodyType, template) -> {
+ throw new EncodeException(
+ "This form encoder has no delegate encoder, so it can only encode form and multipart"
+ + " requests, and "
+ + bodyType
+ + " is neither. Register an encoder that handles it.");
+ };
+
static {
CONTENT_TYPE_HEADER = "Content-Type";
CHARSET_PATTERN = Pattern.compile("(?<=charset=)([\\w\\-]+)");
@@ -65,13 +77,16 @@ public FormEncoder() {
/**
* Constructor with specified delegate encoder.
*
- * @param delegate delegate encoder, if this encoder couldn't encode object.
+ * @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.
*/
public FormEncoder(Encoder delegate) {
- this.delegate = delegate;
+ this.delegate = delegate == null ? NO_DELEGATE : delegate;
val list =
- asList(new MultipartFormContentProcessor(delegate), new UrlencodedFormContentProcessor());
+ asList(
+ new MultipartFormContentProcessor(this.delegate), new UrlencodedFormContentProcessor());
processors = new HashMap(list.size(), 1.F);
for (ContentProcessor processor : list) {
@@ -79,6 +94,37 @@ public FormEncoder(Encoder delegate) {
}
}
+ /**
+ * Creates a form encoder that declares what it can handle, for use with {@code MultiEncoder}.
+ *
+ * It has no delegate: a request it does not accept is left for the other encoders registered
+ * alongside it, instead of being swallowed by a fallback of its own.
+ *
+ *
+ * Feign.builder()
+ * .encoders(FormEncoder.createPredicatedFormEncoder(), new JacksonEncoder());
+ *
+ *
+ * @return a form encoder guarded by {@link #formRequests()}
+ */
+ public static PredicatedEncoder createPredicatedFormEncoder() {
+ return PredicatedEncoder.of(formRequests(), new FormEncoder(null));
+ }
+
+ /**
+ * 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
+ */
+ public static EncoderPredicate formRequests() {
+ return EncoderPredicate.describedAs(
+ "Content-Type is a form type and the body is a map or a user pojo",
+ (object, bodyType, template) ->
+ ContentType.of(getContentTypeValue(template.headers())) != ContentType.UNDEFINED
+ && (object instanceof Map || (bodyType != null && isUserPojo(bodyType))));
+ }
+
@Override
@SuppressWarnings("unchecked")
public void encode(Object object, Type bodyType, RequestTemplate template)
@@ -115,7 +161,7 @@ public final ContentProcessor getContentProcessor(ContentType type) {
}
@SuppressWarnings("PMD.AvoidBranchingStatementAsLastInLoop")
- private String getContentTypeValue(Map> headers) {
+ private static String getContentTypeValue(Map> headers) {
for (val entry : headers.entrySet()) {
if (!entry.getKey().equalsIgnoreCase(CONTENT_TYPE_HEADER)) {
continue;
diff --git a/form/src/test/java/feign/form/PredicatedFormEncoderTest.java b/form/src/test/java/feign/form/PredicatedFormEncoderTest.java
new file mode 100644
index 0000000000..449202a8da
--- /dev/null
+++ b/form/src/test/java/feign/form/PredicatedFormEncoderTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.form;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import feign.RequestTemplate;
+import feign.codec.EncodeException;
+import feign.codec.Encoder;
+import feign.codec.EncoderPredicate;
+import feign.codec.MultiEncoder;
+import feign.codec.PredicatedEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class PredicatedFormEncoderTest {
+
+ private static RequestTemplate template(String contentType) {
+ RequestTemplate template = new RequestTemplate();
+ if (contentType != null) {
+ template.header("Content-Type", contentType);
+ }
+ return template;
+ }
+
+ private static Map data() {
+ Map data = new LinkedHashMap<>();
+ data.put("foo", "bar");
+ return data;
+ }
+
+ @Test
+ void acceptsFormRequests() {
+ PredicatedEncoder encoder = FormEncoder.createPredicatedFormEncoder();
+
+ assertThat(
+ encoder.canEncode(
+ data(), Map.class, template("application/x-www-form-urlencoded; charset=utf-8")))
+ .isTrue();
+ assertThat(encoder.canEncode(data(), Map.class, template("multipart/form-data"))).isTrue();
+ }
+
+ @Test
+ void leavesEverythingElseToTheOtherEncoders() {
+ PredicatedEncoder encoder = FormEncoder.createPredicatedFormEncoder();
+
+ assertThat(encoder.canEncode("body", String.class, template("application/json"))).isFalse();
+ assertThat(encoder.canEncode(data(), Map.class, template(null))).isFalse();
+ assertThat(encoder.canEncode("body", String.class, template("multipart/form-data"))).isFalse();
+ }
+
+ @Test
+ void encodesTheFormItAccepted() {
+ RequestTemplate template = template("application/x-www-form-urlencoded");
+
+ FormEncoder.createPredicatedFormEncoder().encode(data(), Map.class, template);
+
+ assertThat(new String(template.body(), StandardCharsets.UTF_8)).isEqualTo("foo=bar");
+ }
+
+ @Test
+ void routesAlongsideOtherEncoders() {
+ Encoder json = (object, bodyType, template) -> template.body("json");
+
+ Encoder encoder =
+ MultiEncoder.builder()
+ .add(FormEncoder.createPredicatedFormEncoder())
+ .add(EncoderPredicate.jsonContentType(), json)
+ .build();
+
+ RequestTemplate form = template("application/x-www-form-urlencoded");
+ encoder.encode(data(), Map.class, form);
+ assertThat(new String(form.body(), StandardCharsets.UTF_8)).isEqualTo("foo=bar");
+
+ RequestTemplate other = template("application/json");
+ encoder.encode("body", String.class, other);
+ assertThat(other.requestBody().asString()).isEqualTo("json");
+ }
+
+ @Test
+ void withoutADelegateAnythingItCannotEncodeFails() {
+ RequestTemplate template = template("application/x-www-form-urlencoded");
+
+ assertThatThrownBy(() -> new FormEncoder(null).encode("body", String.class, template))
+ .isInstanceOf(EncodeException.class)
+ .hasMessageContaining("This form encoder has no delegate encoder");
+ }
+}
diff --git a/gson/src/main/java/feign/gson/GsonEncoder.java b/gson/src/main/java/feign/gson/GsonEncoder.java
index c4484bc6eb..1056d5f9ce 100644
--- a/gson/src/main/java/feign/gson/GsonEncoder.java
+++ b/gson/src/main/java/feign/gson/GsonEncoder.java
@@ -18,12 +18,14 @@
import com.google.gson.Gson;
import com.google.gson.TypeAdapter;
import feign.RequestTemplate;
+import feign.Util;
import feign.codec.Encoder;
import feign.codec.JsonEncoder;
+import feign.codec.PredicatedEncoder;
import java.lang.reflect.Type;
import java.util.Collections;
-public class GsonEncoder implements Encoder, JsonEncoder {
+public class GsonEncoder implements Encoder, PredicatedEncoder, JsonEncoder {
private final Gson gson;
@@ -43,4 +45,9 @@ public GsonEncoder(Gson gson) {
public void encode(Object object, Type bodyType, RequestTemplate template) {
template.body(gson.toJson(object, bodyType));
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isJsonContentType(template);
+ }
}
diff --git a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java
index 3786edb36e..67f42eda57 100644
--- a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java
+++ b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java
@@ -21,14 +21,16 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider;
import feign.RequestTemplate;
+import feign.Util;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.PredicatedEncoder;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.charset.Charset;
-public final class JacksonJaxbJsonEncoder implements Encoder {
+public final class JacksonJaxbJsonEncoder implements Encoder, PredicatedEncoder {
private final JacksonJaxbJsonProvider jacksonJaxbJsonProvider;
public JacksonJaxbJsonEncoder() {
@@ -51,4 +53,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template)
throw new EncodeException(e.getMessage(), e);
}
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isJsonContentType(template);
+ }
}
diff --git a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java
index 44118eed76..e1ee4cfd43 100644
--- a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java
+++ b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java
@@ -18,13 +18,15 @@
import com.fasterxml.jackson.jr.ob.JSON;
import com.fasterxml.jackson.jr.ob.JacksonJrExtension;
import feign.RequestTemplate;
+import feign.Util;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.PredicatedEncoder;
import java.io.IOException;
import java.lang.reflect.Type;
/** A {@link Encoder} that uses Jackson Jr to convert objects to String or byte representation. */
-public class JacksonJrEncoder extends JacksonJrMapper implements Encoder {
+public class JacksonJrEncoder extends JacksonJrMapper implements Encoder, PredicatedEncoder {
public JacksonJrEncoder() {
super();
@@ -61,4 +63,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) {
throw new EncodeException(e.getMessage(), e);
}
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isJsonContentType(template);
+ }
}
diff --git a/jackson/src/main/java/feign/jackson/JacksonEncoder.java b/jackson/src/main/java/feign/jackson/JacksonEncoder.java
index 48b169a8d3..1c1a9d83e3 100644
--- a/jackson/src/main/java/feign/jackson/JacksonEncoder.java
+++ b/jackson/src/main/java/feign/jackson/JacksonEncoder.java
@@ -26,10 +26,11 @@
import feign.codec.EncodeException;
import feign.codec.Encoder;
import feign.codec.JsonEncoder;
+import feign.codec.PredicatedEncoder;
import java.lang.reflect.Type;
import java.util.Collections;
-public class JacksonEncoder implements Encoder, JsonEncoder {
+public class JacksonEncoder implements Encoder, PredicatedEncoder, JsonEncoder {
private final ObjectMapper mapper;
@@ -58,4 +59,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) {
throw new EncodeException(e.getMessage(), e);
}
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isJsonContentType(template);
+ }
}
diff --git a/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java b/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java
index 90342c0162..41a5ab5893 100644
--- a/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java
+++ b/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java
@@ -21,6 +21,7 @@
import feign.codec.EncodeException;
import feign.codec.Encoder;
import feign.codec.JsonEncoder;
+import feign.codec.PredicatedEncoder;
import java.lang.reflect.Type;
import java.util.Collections;
import tools.jackson.core.JacksonException;
@@ -29,7 +30,7 @@
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
-public class Jackson3Encoder implements Encoder, JsonEncoder {
+public class Jackson3Encoder implements Encoder, PredicatedEncoder, JsonEncoder {
private final JsonMapper mapper;
@@ -60,4 +61,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) {
throw new EncodeException(e.getMessage(), e);
}
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isJsonContentType(template);
+ }
}
diff --git a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java
index 4ea3b7e998..b5eed6dc3d 100644
--- a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java
+++ b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java
@@ -16,8 +16,10 @@
package feign.jaxb;
import feign.RequestTemplate;
+import feign.Util;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.PredicatedEncoder;
import jakarta.xml.bind.JAXBException;
import jakarta.xml.bind.Marshaller;
import java.io.StringWriter;
@@ -42,7 +44,7 @@
* The JAXBContextFactory should be reused across requests as it caches the created JAXB
* contexts.
*/
-public class JAXBEncoder implements Encoder {
+public class JAXBEncoder implements Encoder, PredicatedEncoder {
private final JAXBContextFactory jaxbContextFactory;
@@ -65,4 +67,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) {
throw new EncodeException(e.toString(), e);
}
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isXmlContentType(template);
+ }
}
diff --git a/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java
index aae439cae6..ace9b148cd 100644
--- a/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java
+++ b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java
@@ -16,8 +16,10 @@
package feign.jaxb;
import feign.RequestTemplate;
+import feign.Util;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.PredicatedEncoder;
import java.io.StringWriter;
import java.lang.reflect.Type;
import javax.xml.bind.JAXBException;
@@ -42,7 +44,7 @@
*
The JAXBContextFactory should be reused across requests as it caches the created JAXB
* contexts.
*/
-public class JAXBEncoder implements Encoder {
+public class JAXBEncoder implements Encoder, PredicatedEncoder {
private final JAXBContextFactory jaxbContextFactory;
@@ -65,4 +67,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) {
throw new EncodeException(e.toString(), e);
}
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isXmlContentType(template);
+ }
}
diff --git a/json/src/main/java/feign/json/JsonEncoder.java b/json/src/main/java/feign/json/JsonEncoder.java
index 655bb7594a..9e0b3a078f 100644
--- a/json/src/main/java/feign/json/JsonEncoder.java
+++ b/json/src/main/java/feign/json/JsonEncoder.java
@@ -18,8 +18,10 @@
import static java.lang.String.format;
import feign.RequestTemplate;
+import feign.Util;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.PredicatedEncoder;
import java.lang.reflect.Type;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -51,7 +53,7 @@
* github.create("openfeign", "feign", contributor);
*
*/
-public class JsonEncoder implements Encoder {
+public class JsonEncoder implements Encoder, PredicatedEncoder {
@Override
public void encode(Object object, Type bodyType, RequestTemplate template)
@@ -63,4 +65,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template)
throw new EncodeException(format("%s is not a type supported by this encoder.", bodyType));
}
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isJsonContentType(template);
+ }
}
diff --git a/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java b/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java
index 2fb73d12f5..197c2721f6 100644
--- a/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java
+++ b/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java
@@ -20,11 +20,12 @@
import feign.RequestTemplate;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.PredicatedEncoder;
import io.micrometer.core.instrument.*;
import java.lang.reflect.Type;
/** Wrap feign {@link Encoder} with metrics. */
-public class MeteredEncoder implements Encoder {
+public class MeteredEncoder implements Encoder, PredicatedEncoder {
private final Encoder encoder;
private final MeterRegistry meterRegistry;
@@ -79,4 +80,10 @@ protected DistributionSummary createSummary(
protected Tag[] extraTags(Object object, Type bodyType, RequestTemplate template) {
return EMPTY_TAGS_ARRAY;
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return !(encoder instanceof PredicatedEncoder)
+ || ((PredicatedEncoder) encoder).canEncode(object, bodyType, template);
+ }
}
diff --git a/moshi/src/main/java/feign/moshi/MoshiEncoder.java b/moshi/src/main/java/feign/moshi/MoshiEncoder.java
index b65f705e27..1e7283cef4 100644
--- a/moshi/src/main/java/feign/moshi/MoshiEncoder.java
+++ b/moshi/src/main/java/feign/moshi/MoshiEncoder.java
@@ -18,11 +18,13 @@
import com.squareup.moshi.JsonAdapter;
import com.squareup.moshi.Moshi;
import feign.RequestTemplate;
+import feign.Util;
import feign.codec.Encoder;
import feign.codec.JsonEncoder;
+import feign.codec.PredicatedEncoder;
import java.lang.reflect.Type;
-public class MoshiEncoder implements Encoder, JsonEncoder {
+public class MoshiEncoder implements Encoder, PredicatedEncoder, JsonEncoder {
private final Moshi moshi;
@@ -43,4 +45,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) {
JsonAdapter jsonAdapter = moshi.adapter(bodyType).indent(" ");
template.body(jsonAdapter.toJson(object));
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isJsonContentType(template);
+ }
}
diff --git a/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java b/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java
index 2b4a59cabb..860ab67aa4 100644
--- a/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java
+++ b/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java
@@ -16,8 +16,10 @@
package feign.soap;
import feign.RequestTemplate;
+import feign.Util;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.PredicatedEncoder;
import feign.jaxb.JAXBContextFactory;
import jakarta.xml.bind.JAXBException;
import jakarta.xml.bind.Marshaller;
@@ -78,7 +80,7 @@
* The JAXBContextFactory should be reused across requests as it caches the created JAXB
* contexts.
*/
-public class SOAPEncoder implements Encoder {
+public class SOAPEncoder implements Encoder, PredicatedEncoder {
private static final String DEFAULT_SOAP_PROTOCOL = SOAPConstants.SOAP_1_1_PROTOCOL;
@@ -220,4 +222,9 @@ public SOAPEncoder build() {
return new SOAPEncoder(this);
}
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isXmlContentType(template);
+ }
}
diff --git a/soap/src/main/java/feign/soap/SOAPEncoder.java b/soap/src/main/java/feign/soap/SOAPEncoder.java
index d22d97fefa..a9bbe81e03 100644
--- a/soap/src/main/java/feign/soap/SOAPEncoder.java
+++ b/soap/src/main/java/feign/soap/SOAPEncoder.java
@@ -16,8 +16,10 @@
package feign.soap;
import feign.RequestTemplate;
+import feign.Util;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.PredicatedEncoder;
import feign.jaxb.JAXBContextFactory;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -82,7 +84,7 @@
*
The JAXBContextFactory should be reused across requests as it caches the created JAXB
* contexts.
*/
-public class SOAPEncoder implements Encoder {
+public class SOAPEncoder implements Encoder, PredicatedEncoder {
private static final String DEFAULT_SOAP_PROTOCOL = SOAPConstants.SOAP_1_1_PROTOCOL;
@@ -224,4 +226,9 @@ public SOAPEncoder build() {
return new SOAPEncoder(this);
}
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isXmlContentType(template);
+ }
}
diff --git a/src/docs/overview-mindmap.iuml b/src/docs/overview-mindmap.iuml
index afd6aefbf3..c1396675a6 100644
--- a/src/docs/overview-mindmap.iuml
+++ b/src/docs/overview-mindmap.iuml
@@ -31,6 +31,7 @@
left side
** encoders/decoders
+*** Multi encoder (predicate based, experimental)
*** GSON
*** JAXB
*** JAXB Jakarta