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