diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml
index 2ce7b7f8ab..6945fb7109 100644
--- a/.mvn/extensions.xml
+++ b/.mvn/extensions.xml
@@ -24,6 +24,6 @@
com.gradle
common-custom-user-data-maven-extension
- 2.3.0
+ 2.4.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c48cf93c6..6686ee4c43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,9 +8,34 @@
### Version 13.14
+* 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 `@Experimental` `MultiDecoder`, `PredicatedDecoder` and `DecoderPredicate`, the decode-side
+ counterpart, letting a single client route each response to the right decoder. Decoders declare
+ what they can handle by implementing `PredicatedDecoder`; anything else is paired with a predicate
+ via `PredicatedDecoder.of(predicate, decoder)` or `MultiDecoder.builder()`. Decoders are consulted
+ in the order given and a response nothing accepts fails with a `DecodeException` naming what was
+ tried, so a default is a decoder guarded by `DecoderPredicate.any()` listed last. The first-party
+ JSON decoders (Gson, Jackson, Jackson 3, Jackson Jr, Jackson JAXB, Moshi, Fastjson2, JSON-java)
+ and XML decoders (JAXB, JAXB Jakarta, SAX, SOAP, SOAP Jakarta) now declare themselves, and
+ `OptionalDecoder` and the metrics modules' `MeteredDecoder` forward `canDecode` to the decoder
+ they wrap. The `Decoder` interface is unchanged, so existing decoders keep working.
+* `JAXBContextFactory.withProperty` is now applied when creating Unmarshallers, not only
+ Marshallers. Marshaller-only properties are skipped on unmarshal (#3056).
* Add support for the HTTP QUERY method (RFC 10008) — safe, idempotent, and cacheable with a
request body. `HttpCacheInterceptor` includes QUERY in its default cacheable set and
incorporates a body hash into the cache key to reduce cross-body collisions.
+* Flatten `feign-bom` on install/deploy so importing the BOM does not pull `feign-parent`
+ dependency management (for example Jackson) into consumer projects such as Spring Boot.
### Version 13.12
diff --git a/README.md b/README.md
index cbaae10766..b45995fd9d 100644
--- a/README.md
+++ b/README.md
@@ -663,6 +663,116 @@ public class Example {
}
```
+#### Multiple decoders
+
+> This API is `@Experimental` and may change incompatibly, or be removed, in a future release.
+
+A single client sometimes has to read more than one format — JSON for most endpoints, XML for
+a legacy one, plain text for a health check. `MultiDecoder` hands each response to the first decoder
+that accepts it.
+
+Most first-party decoders already declare what they can handle, so they can simply be listed, in the
+order they should be consulted:
+
+```java
+interface MixedClient {
+ @RequestLine("GET /orders/{id}")
+ Order order(@Param("id") String id);
+
+ @RequestLine("GET /legacy/orders/{id}")
+ Order legacyOrder(@Param("id") String id);
+}
+
+public class Example {
+ public static void main(String[] args) {
+ MixedClient client = Feign.builder()
+ .decoders(new GsonDecoder(), new JAXBDecoder())
+ .target(MixedClient.class, "https://foo.com");
+ }
+}
+```
+
+Routing is driven by what the server actually sent back, so a client that talks to endpoints
+answering `application/json` and `application/xml` no longer needs one Feign instance per format.
+
+There is no implicit fallback. A response that no decoder accepts fails with a `DecodeException`
+naming the decoders that were tried and what each one wants:
+
+```
+Unable to decode 200 response (Content-Type: text/plain) as com.example.Order. Decoders tried, in order:
+ - GsonDecoder
+ - JAXBDecoder
+Add a decoder guarded by DecoderPredicate.any() last to act as a default.
+```
+
+To get a default, pair a decoder with the predicate that accepts everything and list it **last**:
+
+```java
+Feign.builder()
+ .decoders(
+ new GsonDecoder(),
+ new JAXBDecoder(),
+ PredicatedDecoder.of(DecoderPredicate.any(), new DefaultDecoder()));
+```
+
+The same pairing works for any decoder that does not declare itself, including one you do not
+control. `MultiDecoder.builder()` spells it out when a lambda reads better than a wrapper:
+
+```java
+Decoder decoder =
+ MultiDecoder.builder()
+ .add(new GsonDecoder()) // declares itself
+ .add(DecoderPredicate.xmlContentType(), someXmlDecoder) // paired
+ .add((response, type) -> type == byte[].class, binaryDecoder)
+ .add(DecoderPredicate.any(), new DefaultDecoder()) // the default, last
+ .build();
+```
+
+Decoders are consulted in the order they were added, so put the narrowest one first.
+
+##### Declaring your own decoder
+
+Implement `PredicatedDecoder` and say what you handle. `canDecode` has no default: a decoder that
+declares nothing would claim every response, which is rarely what its author meant.
+
+```java
+public class MyDecoder implements PredicatedDecoder {
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
+
+ @Override
+ public Object decode(Response response, Type type) throws IOException {
+ // ...
+ }
+}
+```
+
+`DecoderPredicate` is the `@FunctionalInterface` here, so predicates can be lambdas. It ships with
+`any()`, `jsonContentType()`, `xmlContentType()`, `contentType(mediaType)`, `emptyBody()`,
+`status(codes...)` and `returnType(type)`, plus `and`/`or`/`negate` to combine them. Each one
+describes itself, which is what shows up in the error message above; wrap your own lambdas in
+`DecoderPredicate.describedAs("it is Tuesday", ...)` to read as well.
+
+`PredicatedDecoder.of(predicate, decoder)` replaces whatever the decoder says about itself, so it
+can widen a decoder as well as narrow it. To keep the decoder's own declaration and add to it, use
+`narrowing`:
+
+```java
+// JSON responses as usual, but only when the call actually succeeded
+PredicatedDecoder.narrowing(DecoderPredicate.status(200, 201), new GsonDecoder());
+```
+
+**Predicates must not read the response body.** For most clients it is a single-pass stream, so
+consuming it in `canDecode` would leave nothing for the decoder that is eventually chosen. Decide
+on the status, the headers and the expected type instead.
+
+**If you wrap a decoder, forward `canDecode` to your delegate**, otherwise wrapping silently changes
+what the decoder handles. `OptionalDecoder` and the metrics modules' `MeteredDecoder` forward for
+exactly this reason.
+
### Encoders
The simplest way to send a request body to a server is to define a `POST` method that has a `String` or `byte[]` parameter without any annotations on it. You will likely need to add a `Content-Type` header.
@@ -709,6 +819,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/api/src/main/java/feign/BaseBuilder.java b/api/src/main/java/feign/BaseBuilder.java
index 1bccd8f6a6..ba8ee41590 100644
--- a/api/src/main/java/feign/BaseBuilder.java
+++ b/api/src/main/java/feign/BaseBuilder.java
@@ -21,8 +21,14 @@
import feign.Request.Options;
import feign.codec.Codec;
import feign.codec.Decoder;
+import feign.codec.DecoderPredicate;
import feign.codec.Encoder;
+import feign.codec.EncoderPredicate;
import feign.codec.ErrorDecoder;
+import feign.codec.MultiDecoder;
+import feign.codec.MultiEncoder;
+import feign.codec.PredicatedDecoder;
+import feign.codec.PredicatedEncoder;
import feign.interceptor.MethodInterceptor;
import feign.interceptor.MethodInterceptors;
import java.lang.reflect.Field;
@@ -98,11 +104,73 @@ 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();
}
+ /**
+ * Configures a {@link MultiDecoder} built from decoders that declare their own applicability.
+ *
+ *
Decoders are consulted in the order given, and the first one that accepts the response
+ * decodes it. There is no implicit fallback: pair a decoder with {@link DecoderPredicate#any()}
+ * and list it last to act as a default, otherwise a response nothing accepts fails with a {@link
+ * feign.codec.DecodeException}.
+ *
+ *
+ * Feign.builder()
+ * .decoders(
+ * new JacksonDecoder(),
+ * new JAXBDecoder(),
+ * PredicatedDecoder.of(DecoderPredicate.any(), new DefaultDecoder()))
+ *
+ *
+ * To pair a predicate with a decoder that does not implement {@link PredicatedDecoder}, use
+ * {@link PredicatedDecoder#of(DecoderPredicate, Decoder)} as above, or {@link
+ * MultiDecoder#builder()} for the same thing spelled out.
+ *
+ * @param decoders the predicated decoders, consulted in the order given
+ */
+ @Experimental
+ public B decoders(PredicatedDecoder... decoders) {
+ MultiDecoder.Builder builder = MultiDecoder.builder();
+ for (PredicatedDecoder decoder : decoders) {
+ builder.add(decoder);
+ }
+ return decoder(builder.build());
+ }
+
public B codec(Codec codec) {
this.encoder = codec.encoder();
this.decoder = codec.decoder();
diff --git a/api/src/main/java/feign/Contract.java b/api/src/main/java/feign/Contract.java
index dc040918a3..a1976d9f44 100644
--- a/api/src/main/java/feign/Contract.java
+++ b/api/src/main/java/feign/Contract.java
@@ -60,7 +60,9 @@ public List parseAndValidateMetadata(Class> targetType) {
if (method.getDeclaringClass() == Object.class
|| (method.getModifiers() & Modifier.STATIC) != 0
|| Util.isDefault(method)
- || method.isAnnotationPresent(FeignIgnore.class)) {
+ || method.isAnnotationPresent(FeignIgnore.class)
+ || method.isSynthetic()
+ || method.isBridge()) {
continue;
}
final MethodMetadata metadata = parseAndValidateMetadata(targetType, method);
diff --git a/api/src/main/java/feign/ReflectiveFeign.java b/api/src/main/java/feign/ReflectiveFeign.java
index bf3f265aab..14325cc893 100644
--- a/api/src/main/java/feign/ReflectiveFeign.java
+++ b/api/src/main/java/feign/ReflectiveFeign.java
@@ -155,9 +155,67 @@ public Map apply(Target target, C requestContext) {
}
}
+ for (Method method : target.type().getMethods()) {
+ if (!method.isBridge()) {
+ continue;
+ }
+ Method bridged = resolveBridgedMethod(method);
+ MethodHandler handler = result.get(bridged);
+ if (handler != null) {
+ result.put(method, handler);
+ }
+ }
+
return result;
}
+ static Method resolveBridgedMethod(Method bridgeMethod) {
+ Method matched = null;
+ Class>[] bridgeParams = bridgeMethod.getParameterTypes();
+ for (Method candidate : bridgeMethod.getDeclaringClass().getDeclaredMethods()) {
+ if (candidate.isBridge()
+ || candidate.isSynthetic()
+ || !candidate.getName().equals(bridgeMethod.getName())
+ || candidate.getParameterCount() != bridgeParams.length) {
+ continue;
+ }
+ Class>[] candidateParams = candidate.getParameterTypes();
+ boolean paramsMatch = true;
+ for (int i = 0; i < bridgeParams.length; i++) {
+ if (!bridgeParams[i].isAssignableFrom(candidateParams[i])) {
+ paramsMatch = false;
+ break;
+ }
+ }
+ if (!paramsMatch) {
+ continue;
+ }
+ Class> bridgeReturn = bridgeMethod.getReturnType();
+ Class> candidateReturn = candidate.getReturnType();
+ if (bridgeReturn != void.class && !bridgeReturn.isAssignableFrom(candidateReturn)) {
+ continue;
+ }
+ if (matched == null || isMoreSpecific(candidate, matched)) {
+ matched = candidate;
+ }
+ }
+ return matched != null ? matched : bridgeMethod;
+ }
+
+ private static boolean isMoreSpecific(Method candidate, Method current) {
+ Class>[] candidateParams = candidate.getParameterTypes();
+ Class>[] currentParams = current.getParameterTypes();
+ for (int i = 0; i < candidateParams.length; i++) {
+ if (candidateParams[i] != currentParams[i]
+ && currentParams[i].isAssignableFrom(candidateParams[i])) {
+ return true;
+ }
+ }
+ Class> candidateReturn = candidate.getReturnType();
+ Class> currentReturn = current.getReturnType();
+ return candidateReturn != currentReturn && currentReturn.isAssignableFrom(candidateReturn);
+ }
+
private MethodHandler createMethodHandler(
final Target> target, final MethodMetadata md, final C requestContext) {
if (md.isIgnored()) {
diff --git a/api/src/main/java/feign/Util.java b/api/src/main/java/feign/Util.java
index 639bb021a7..99d23be42a 100644
--- a/api/src/main/java/feign/Util.java
+++ b/api/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. */
@@ -59,6 +60,9 @@ public class Util {
/** The HTTP Content-Length header field name. */
public static final String CONTENT_LENGTH = "Content-Length";
+ /** The HTTP Content-Type header field name. */
+ public static final String CONTENT_TYPE = "Content-Type";
+
/** The HTTP Content-Encoding header field name. */
public static final String CONTENT_ENCODING = "Content-Encoding";
@@ -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,129 @@ 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 response denotes JSON.
+ *
+ *
Matches {@code application/json} as well as suffixed types such as {@code
+ * application/vnd.github+json}. The header name is matched case-insensitively.
+ *
+ * @param response the response to check
+ * @return {@code true} if the content type is JSON, {@code false} otherwise
+ */
+ @Experimental
+ public static boolean isJsonContentType(Response response) {
+ return hasContentTypeMatching(response, JSON_CONTENT_TYPE);
+ }
+
+ /**
+ * Checks whether the {@code Content-Type} header of the given template denotes XML.
+ *
+ *
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 response denotes XML.
+ *
+ *
Matches {@code application/xml} and {@code text/xml} as well as suffixed types such as
+ * {@code application/soap+xml}. The header name is matched case-insensitively.
+ *
+ * @param response the response to check
+ * @return {@code true} if the content type is XML, {@code false} otherwise
+ */
+ @Experimental
+ public static boolean isXmlContentType(Response response) {
+ return hasContentTypeMatching(response, XML_CONTENT_TYPE);
+ }
+
+ /**
+ * Checks whether the {@code Content-Type} header of the given template starts with the given
+ * media type, ignoring case and any parameters such as {@code ;charset=utf-8}.
+ *
+ * @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 matchesMediaType(contentTypes(template), mediaType);
+ }
+
+ /**
+ * Checks whether the {@code Content-Type} header of the given response starts with the given
+ * media type, ignoring case and any parameters such as {@code ;charset=utf-8}.
+ *
+ * @param response the response to check
+ * @param mediaType the media type to look for, for example {@code text/csv}
+ * @return {@code true} if the content type matches, {@code false} otherwise
+ */
+ @Experimental
+ public static boolean hasContentType(Response response, String mediaType) {
+ return matchesMediaType(contentTypes(response), mediaType);
+ }
+
+ private static boolean matchesMediaType(Stream contentTypes, String mediaType) {
+ return contentTypes.anyMatch(
+ contentType -> {
+ String trimmed = contentType.trim();
+ return trimmed.regionMatches(true, 0, mediaType, 0, mediaType.length())
+ && (trimmed.length() == mediaType.length()
+ || trimmed.charAt(mediaType.length()) == ';');
+ });
+ }
+
+ private static Stream contentTypes(RequestTemplate template) {
+ return contentTypes(template.headers());
+ }
+
+ private static Stream contentTypes(Response response) {
+ if (response == null || response.headers() == null) {
+ return Stream.empty();
+ }
+ return contentTypes(response.headers());
+ }
+
+ private static Stream contentTypes(Map> headers) {
+ return headers.entrySet().stream()
+ .filter(header -> CONTENT_TYPE.equalsIgnoreCase(header.getKey()))
+ .map(Map.Entry::getValue)
+ .filter(Objects::nonNull)
+ .flatMap(Collection::stream)
+ .filter(Objects::nonNull);
+ }
+
+ private static boolean hasContentTypeMatching(RequestTemplate template, Pattern pattern) {
+ return matchesPattern(contentTypes(template), pattern);
+ }
+
+ private static boolean hasContentTypeMatching(Response response, Pattern pattern) {
+ return matchesPattern(contentTypes(response), pattern);
+ }
+
+ private static boolean matchesPattern(Stream contentTypes, Pattern pattern) {
+ return contentTypes.anyMatch(contentType -> pattern.matcher(contentType.trim()).matches());
+ }
}
diff --git a/api/src/main/java/feign/codec/DecoderPredicate.java b/api/src/main/java/feign/codec/DecoderPredicate.java
new file mode 100644
index 0000000000..941d9cbab4
--- /dev/null
+++ b/api/src/main/java/feign/codec/DecoderPredicate.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright © 2012 The Feign Authors (feign@commonhaus.dev)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package feign.codec;
+
+import feign.Experimental;
+import feign.Response;
+import feign.Util;
+import java.lang.reflect.Type;
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * Decides whether a response can be handled by a {@link Decoder}.
+ *
+ * Predicates receive the same two arguments as {@link Decoder#decode(Response, Type)}, so they
+ * can discriminate on the response status, on anything in its headers such as the {@code
+ * Content-Type}, or on the type the caller expects back.
+ *
+ *
Predicates must not read the response body. The body is a single-pass stream
+ * for most clients, so consuming it here would leave nothing for the decoder that is eventually
+ * chosen.
+ *
+ *
Every predicate built here describes itself, so a {@link MultiDecoder} that cannot route a
+ * response can say what it did consider. Wrap your own lambdas in {@link #describedAs(String,
+ * DecoderPredicate)} to get the same in error messages.
+ *
+ * @see PredicatedDecoder
+ * @see MultiDecoder
+ */
+@Experimental
+@FunctionalInterface
+public interface DecoderPredicate {
+
+ /**
+ * Whether the decoder this predicate guards can handle the response.
+ *
+ * @param response the response that would be decoded. Its body must not be read.
+ * @param type the {@link java.lang.reflect.Method#getGenericReturnType() generic return type} the
+ * caller expects back
+ * @return {@code true} if the response can be decoded, {@code false} otherwise
+ */
+ boolean canDecode(Response response, Type type);
+
+ /**
+ * Wraps a predicate so that it describes itself, which is what a {@link MultiDecoder} reports
+ * when no decoder accepts a response.
+ *
+ * @param description how the predicate reads in an error message, for example {@code
+ * "Content-Type is JSON"}
+ * @param predicate the predicate to describe
+ */
+ static DecoderPredicate describedAs(String description, DecoderPredicate predicate) {
+ Objects.requireNonNull(description, "description cannot be null");
+ Objects.requireNonNull(predicate, "predicate cannot be null");
+ return new DecoderPredicate() {
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return predicate.canDecode(response, type);
+ }
+
+ @Override
+ public String toString() {
+ return description;
+ }
+ };
+ }
+
+ /**
+ * Matches every response. Pair this with a decoder registered last to make it the default of a
+ * {@link MultiDecoder}.
+ */
+ static DecoderPredicate any() {
+ return describedAs("any response", (response, type) -> true);
+ }
+
+ /** Matches responses whose {@code Content-Type} header denotes JSON. */
+ static DecoderPredicate jsonContentType() {
+ return describedAs(
+ "Content-Type is JSON", (response, type) -> Util.isJsonContentType(response));
+ }
+
+ /** Matches responses whose {@code Content-Type} header denotes XML. */
+ static DecoderPredicate xmlContentType() {
+ return describedAs("Content-Type is XML", (response, type) -> Util.isXmlContentType(response));
+ }
+
+ /**
+ * Matches responses whose {@code Content-Type} header starts with the given media type, ignoring
+ * case and any parameters such as {@code ;charset=utf-8}.
+ */
+ static DecoderPredicate contentType(String mediaType) {
+ Objects.requireNonNull(mediaType, "mediaType cannot be null");
+ return describedAs(
+ "Content-Type is " + mediaType,
+ (response, type) -> Util.hasContentType(response, mediaType));
+ }
+
+ /** Matches responses carrying no body, such as a {@code 204 No Content}. */
+ static DecoderPredicate emptyBody() {
+ return describedAs(
+ "body is empty",
+ (response, type) ->
+ response.body() == null
+ || (response.body().length() != null && response.body().length() == 0));
+ }
+
+ /** Matches responses whose status is one of the given codes. */
+ static DecoderPredicate status(int... statuses) {
+ int[] accepted = Arrays.copyOf(statuses, statuses.length);
+ Arrays.sort(accepted);
+ return describedAs(
+ "status is one of " + Arrays.toString(accepted),
+ (response, type) -> Arrays.binarySearch(accepted, response.status()) >= 0);
+ }
+
+ /** Matches responses the caller expects to come back as exactly the given type. */
+ static DecoderPredicate returnType(Type expected) {
+ Objects.requireNonNull(expected, "expected cannot be null");
+ return describedAs(
+ "return type is " + expected.getTypeName(), (response, type) -> expected.equals(type));
+ }
+
+ default DecoderPredicate and(DecoderPredicate other) {
+ Objects.requireNonNull(other, "other cannot be null");
+ return describedAs(
+ "(" + this + " and " + other + ")",
+ (response, type) -> canDecode(response, type) && other.canDecode(response, type));
+ }
+
+ default DecoderPredicate or(DecoderPredicate other) {
+ Objects.requireNonNull(other, "other cannot be null");
+ return describedAs(
+ "(" + this + " or " + other + ")",
+ (response, type) -> canDecode(response, type) || other.canDecode(response, type));
+ }
+
+ default DecoderPredicate negate() {
+ return describedAs("not (" + this + ")", (response, type) -> !canDecode(response, type));
+ }
+}
diff --git a/api/src/main/java/feign/codec/EncoderPredicate.java b/api/src/main/java/feign/codec/EncoderPredicate.java
new file mode 100644
index 0000000000..150fd22ded
--- /dev/null
+++ b/api/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/api/src/main/java/feign/codec/MultiDecoder.java b/api/src/main/java/feign/codec/MultiDecoder.java
new file mode 100644
index 0000000000..f48ee1975e
--- /dev/null
+++ b/api/src/main/java/feign/codec/MultiDecoder.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright © 2012 The Feign Authors (feign@commonhaus.dev)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package feign.codec;
+
+import feign.Experimental;
+import feign.FeignException;
+import feign.Response;
+import feign.Util;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * A {@link Decoder} that hands each response to the first decoder that accepts it.
+ *
+ *
Decoders come from two places. A decoder that implements {@link PredicatedDecoder} declares
+ * its own applicability and can simply be added; any other decoder is paired with a {@link
+ * DecoderPredicate} at the call site:
+ *
+ *
+ * Feign.builder()
+ * .decoder(
+ * MultiDecoder.builder()
+ * .add(new JacksonDecoder())
+ * .add(DecoderPredicate.xmlContentType(), new JAXBDecoder())
+ * .add((response, type) -> type == byte[].class, new BinaryDecoder())
+ * .add(DecoderPredicate.any(), new DefaultDecoder())
+ * .build());
+ *
+ *
+ * Decoders are consulted in the order they were added, so the narrowest one comes first. There
+ * is no implicit fallback: a response no decoder accepts fails with a {@link DecodeException}
+ * naming what was tried. Add a decoder guarded by {@link DecoderPredicate#any()} last to act as a
+ * default, as above.
+ *
+ * @see PredicatedDecoder
+ * @see DecoderPredicate
+ */
+@Experimental
+public class MultiDecoder implements Decoder {
+
+ private final List decoders;
+
+ private MultiDecoder(List decoders) {
+ this.decoders = Collections.unmodifiableList(new ArrayList<>(decoders));
+ }
+
+ /** Starts building a multi-decoder. */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Decodes using the first decoder that accepts the response.
+ *
+ * @param response {@inheritDoc}
+ * @param type {@inheritDoc}
+ * @return {@inheritDoc}
+ * @throws IOException {@inheritDoc}
+ * @throws DecodeException when no decoder accepts the response, or the chosen one fails
+ * @throws FeignException {@inheritDoc}
+ */
+ @Override
+ public Object decode(Response response, Type type)
+ throws IOException, DecodeException, FeignException {
+ for (PredicatedDecoder decoder : decoders) {
+ if (decoder.canDecode(response, type)) {
+ return decoder.decode(response, type);
+ }
+ }
+ throw new DecodeException(
+ response.status(), unableToDecode(response, type), response.request());
+ }
+
+ private String unableToDecode(Response response, Type type) {
+ StringBuilder message =
+ new StringBuilder("Unable to decode ")
+ .append(response.status())
+ .append(" response (Content-Type: ")
+ .append(contentTypes(response))
+ .append(") as ")
+ .append(type == null ? "the expected type" : type.getTypeName());
+ if (decoders.isEmpty()) {
+ return message.append(". No decoders were configured.").toString();
+ }
+ message.append(". Decoders tried, in order:");
+ for (PredicatedDecoder decoder : decoders) {
+ message.append("\n - ").append(PairedDecoder.describe(decoder));
+ }
+ return message
+ .append("\nAdd a decoder guarded by DecoderPredicate.any() last to act as a default.")
+ .toString();
+ }
+
+ private static String contentTypes(Response response) {
+ String contentTypes =
+ response.headers().entrySet().stream()
+ .filter(header -> Util.CONTENT_TYPE.equalsIgnoreCase(header.getKey()))
+ .map(Map.Entry::getValue)
+ .filter(Objects::nonNull)
+ .flatMap(Collection::stream)
+ .collect(Collectors.joining(", "));
+ return contentTypes.isEmpty() ? "not set" : contentTypes;
+ }
+
+ @Override
+ public String toString() {
+ return "MultiDecoder"
+ + decoders.stream().map(PairedDecoder::describe).collect(Collectors.toList());
+ }
+
+ /** Collects the decoders of a {@link MultiDecoder}. */
+ @Experimental
+ public static final class Builder {
+
+ private final List decoders = new ArrayList<>();
+
+ private Builder() {}
+
+ /**
+ * Adds a decoder that declares its own applicability.
+ *
+ * @param decoder the decoder, consulted via {@link PredicatedDecoder#canDecode}
+ */
+ public Builder add(PredicatedDecoder decoder) {
+ decoders.add(Objects.requireNonNull(decoder, "decoder cannot be null"));
+ return this;
+ }
+
+ /**
+ * Adds any decoder, guarded by the given predicate. Use this for decoders that do not implement
+ * {@link PredicatedDecoder}, including ones you do not control.
+ *
+ * @param predicate decides whether the decoder handles a response
+ * @param decoder the decoder to delegate to
+ */
+ public Builder add(DecoderPredicate predicate, Decoder decoder) {
+ return add(PredicatedDecoder.of(predicate, decoder));
+ }
+
+ /** Builds the multi-decoder. */
+ public MultiDecoder build() {
+ return new MultiDecoder(decoders);
+ }
+ }
+}
diff --git a/api/src/main/java/feign/codec/MultiEncoder.java b/api/src/main/java/feign/codec/MultiEncoder.java
new file mode 100644
index 0000000000..23feaf35ba
--- /dev/null
+++ b/api/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/api/src/main/java/feign/codec/PairedDecoder.java b/api/src/main/java/feign/codec/PairedDecoder.java
new file mode 100644
index 0000000000..f2922259bc
--- /dev/null
+++ b/api/src/main/java/feign/codec/PairedDecoder.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright © 2012 The Feign Authors (feign@commonhaus.dev)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package feign.codec;
+
+import feign.FeignException;
+import feign.Response;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.util.Objects;
+
+/** A decoder that does not declare itself, guarded by a predicate supplied at the call site. */
+final class PairedDecoder implements PredicatedDecoder {
+
+ private final DecoderPredicate predicate;
+
+ private final Decoder decoder;
+
+ PairedDecoder(DecoderPredicate predicate, Decoder decoder) {
+ this.predicate = Objects.requireNonNull(predicate, "predicate cannot be null");
+ this.decoder = Objects.requireNonNull(decoder, "decoder cannot be null");
+ }
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return predicate.canDecode(response, type);
+ }
+
+ @Override
+ public Object decode(Response response, Type type)
+ throws IOException, DecodeException, FeignException {
+ return decoder.decode(response, type);
+ }
+
+ @Override
+ public String toString() {
+ return describe(decoder) + " when " + predicate;
+ }
+
+ /** Requires both the predicate and, when the decoder declares one, its own applicability. */
+ static DecoderPredicate narrow(DecoderPredicate predicate, Decoder decoder) {
+ Objects.requireNonNull(predicate, "predicate cannot be null");
+ Objects.requireNonNull(decoder, "decoder cannot be null");
+ if (!(decoder instanceof PredicatedDecoder)) {
+ return predicate;
+ }
+ if (decoder instanceof PairedDecoder) {
+ return predicate.and(((PairedDecoder) decoder).predicate);
+ }
+ PredicatedDecoder predicated = (PredicatedDecoder) decoder;
+ return predicate.and(
+ DecoderPredicate.describedAs(describe(decoder) + " accepts it", predicated::canDecode));
+ }
+
+ /** The decoder's own {@code toString} when it has one, its class name otherwise. */
+ static String describe(Decoder decoder) {
+ Class> type = decoder.getClass();
+ try {
+ if (type.getMethod("toString").getDeclaringClass() != Object.class) {
+ return decoder.toString();
+ }
+ } catch (NoSuchMethodException ignored) {
+ // cannot happen, every class has toString
+ }
+ return type.getSimpleName().isEmpty() ? type.getName() : type.getSimpleName();
+ }
+}
diff --git a/api/src/main/java/feign/codec/PairedEncoder.java b/api/src/main/java/feign/codec/PairedEncoder.java
new file mode 100644
index 0000000000..61a63f7636
--- /dev/null
+++ b/api/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/api/src/main/java/feign/codec/PredicatedDecoder.java b/api/src/main/java/feign/codec/PredicatedDecoder.java
new file mode 100644
index 0000000000..d8d48467a5
--- /dev/null
+++ b/api/src/main/java/feign/codec/PredicatedDecoder.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright © 2012 The Feign Authors (feign@commonhaus.dev)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package feign.codec;
+
+import feign.Experimental;
+import feign.Response;
+import java.lang.reflect.Type;
+
+/**
+ * A {@link Decoder} that knows which responses it can handle.
+ *
+ * Decoders implement this to declare their own applicability, so a {@link MultiDecoder} can
+ * route each response to the right one without the call site having to wrap anything:
+ *
+ *
+ * public class JacksonDecoder implements PredicatedDecoder {
+ *
+ * @Override
+ * public boolean canDecode(Response response, Type type) {
+ * return Util.isJsonContentType(response);
+ * }
+ *
+ * @Override
+ * public Object decode(Response response, Type type) throws IOException {
+ * // ...
+ * }
+ * }
+ *
+ *
+ * {@code canDecode} is deliberately abstract: a decoder that says nothing about what it handles
+ * would claim every response, which is almost never what its author meant. Use {@link
+ * #of(DecoderPredicate, Decoder)} to give an existing decoder a predicate instead of implementing
+ * this on it, and {@link DecoderPredicate} — which is a {@code @FunctionalInterface} —
+ * to write that predicate as a lambda.
+ *
+ *
Decoders that wrap another decoder should forward {@code canDecode} to their delegate, so that
+ * wrapping does not discard the delegate's applicability.
+ *
+ * @see MultiDecoder
+ * @see DecoderPredicate
+ */
+@Experimental
+public interface PredicatedDecoder extends Decoder {
+
+ /**
+ * Pairs any decoder with a predicate, for decoders that do not declare themselves, including ones
+ * you do not control. The predicate is the whole answer: whatever the decoder may declare about
+ * itself is replaced, so this can widen a decoder as well as narrow it. Use {@link
+ * #narrowing(DecoderPredicate, Decoder)} to keep the decoder's own declaration.
+ *
+ *
A decoder paired with {@link DecoderPredicate#any()} accepts everything, which is how a
+ * {@link MultiDecoder} is given a default:
+ *
+ *
+ * Feign.builder()
+ * .decoders(
+ * new JacksonDecoder(),
+ * PredicatedDecoder.of(DecoderPredicate.any(), new DefaultDecoder()));
+ *
+ *
+ * @param predicate decides whether the decoder handles a response
+ * @param decoder the decoder to delegate to
+ */
+ static PredicatedDecoder of(DecoderPredicate predicate, Decoder decoder) {
+ return new PairedDecoder(predicate, decoder);
+ }
+
+ /**
+ * Narrows a decoder that already declares itself, by requiring both the given predicate and the
+ * decoder's own {@code canDecode} to accept the response:
+ *
+ *
+ * PredicatedDecoder.narrowing(
+ * DecoderPredicate.status(200), new JacksonDecoder());
+ *
+ *
+ * A decoder that does not implement {@link PredicatedDecoder} declares nothing to narrow, so
+ * this behaves like {@link #of(DecoderPredicate, Decoder)}.
+ *
+ * @param predicate narrows what the decoder handles
+ * @param decoder the decoder to delegate to
+ */
+ static PredicatedDecoder narrowing(DecoderPredicate predicate, Decoder decoder) {
+ return new PairedDecoder(PairedDecoder.narrow(predicate, decoder), decoder);
+ }
+
+ /**
+ * Whether this decoder can handle the response.
+ *
+ *
The response body must not be read here: it is a single-pass stream for most clients, so
+ * consuming it would leave nothing for the decoder that is eventually chosen.
+ *
+ * @param response the response that would be decoded. Its body must not be read.
+ * @param type the {@link java.lang.reflect.Method#getGenericReturnType() generic return type} the
+ * caller expects back
+ * @return {@code true} if this decoder can decode the response, {@code false} otherwise
+ */
+ boolean canDecode(Response response, Type type);
+}
diff --git a/api/src/main/java/feign/codec/PredicatedEncoder.java b/api/src/main/java/feign/codec/PredicatedEncoder.java
new file mode 100644
index 0000000000..f9cd135acf
--- /dev/null
+++ b/api/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/api/src/main/java/feign/optionals/OptionalDecoder.java b/api/src/main/java/feign/optionals/OptionalDecoder.java
index 475ee74b95..0edb3bedeb 100644
--- a/api/src/main/java/feign/optionals/OptionalDecoder.java
+++ b/api/src/main/java/feign/optionals/OptionalDecoder.java
@@ -18,13 +18,14 @@
import feign.Response;
import feign.Util;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Objects;
import java.util.Optional;
-public final class OptionalDecoder implements Decoder {
+public final class OptionalDecoder implements Decoder, PredicatedDecoder {
final Decoder delegate;
public OptionalDecoder(Decoder delegate) {
@@ -44,6 +45,16 @@ public Object decode(Response response, Type type) throws IOException {
return Optional.ofNullable(delegate.decode(response, enclosedType));
}
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ if (!(delegate instanceof PredicatedDecoder)) {
+ return true;
+ }
+ Type enclosedType =
+ isOptional(type) ? Util.resolveLastTypeParameter(type, Optional.class) : type;
+ return ((PredicatedDecoder) delegate).canDecode(response, enclosedType);
+ }
+
static boolean isOptional(Type type) {
if (!(type instanceof ParameterizedType)) {
return false;
diff --git a/benchmark/pom.xml b/benchmark/pom.xml
index 9b50a24cb9..d3e39e8da7 100644
--- a/benchmark/pom.xml
+++ b/benchmark/pom.xml
@@ -32,7 +32,7 @@
1.37
0.5.3
1.3.8
- 4.2.16.Final
+ 4.2.17.Final
true
diff --git a/core/src/main/java/feign/core/DefaultClient.java b/core/src/main/java/feign/core/DefaultClient.java
index 16821e66ad..26951dd529 100644
--- a/core/src/main/java/feign/core/DefaultClient.java
+++ b/core/src/main/java/feign/core/DefaultClient.java
@@ -181,7 +181,7 @@ public HttpURLConnection convertAndSend(Request request, Options options) throws
hasAcceptHeader = true;
}
for (String value : request.headers().get(field)) {
- if (field.equals(CONTENT_LENGTH)) {
+ if (field.equalsIgnoreCase(CONTENT_LENGTH)) {
if (!gzipEncodedRequest && !deflateEncodedRequest) {
contentLength = Integer.valueOf(value);
}
@@ -202,12 +202,7 @@ else if (field.equals(ACCEPT_ENCODING)) {
Optional body = request.body();
- if (body.isPresent()
- && (body.get().contentLength() != 0 || request.httpMethod() != Request.HttpMethod.GET)) {
- /*
- * Ignore disableRequestBuffering flag if the empty body was set, to ensure that internal
- * retry logic applies to such requests.
- */
+ if (body.isPresent() && body.get().contentLength() != 0) {
if (disableRequestBuffering) {
if (contentLength != null) {
connection.setFixedLengthStreamingMode(contentLength);
@@ -230,10 +225,15 @@ else if (field.equals(ACCEPT_ENCODING)) {
} catch (IOException suppressed) { // NOPMD
}
}
- }
-
- if (body.isEmpty() && request.httpMethod().isWithBody()) {
- // To use this Header, set 'sun.net.http.allowRestrictedHeaders' property true.
+ } else if (request.httpMethod().isWithBody()) {
+ /*
+ * Avoid calling connection.getOutputStream() for an empty body: HttpURLConnection defaults
+ * the Content-Type to application/x-www-form-urlencoded as soon as an output stream (a
+ * "poster") is created, even when zero bytes are written to it. Setting Content-Length
+ * directly ensures internal retry logic still applies to such requests, without triggering
+ * that default.
+ * To use this Header, set 'sun.net.http.allowRestrictedHeaders' property true.
+ */
connection.addRequestProperty("Content-Length", "0");
}
diff --git a/core/src/test/java/feign/BridgeMethodTest.java b/core/src/test/java/feign/BridgeMethodTest.java
new file mode 100644
index 0000000000..d7e2802058
--- /dev/null
+++ b/core/src/test/java/feign/BridgeMethodTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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;
+
+import static feign.assertj.FeignAssertions.assertThat;
+
+import feign.core.DefaultContract;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+
+class BridgeMethodTest {
+
+ interface CrudApi {
+ @RequestLine("GET /items/{id}")
+ String get(@Param("id") T id);
+ }
+
+ interface UserApi extends CrudApi {
+ @Override
+ @RequestLine("GET /users/{id}")
+ String get(@Param("id") String id);
+ }
+
+ @Test
+ void contractSkipsBridgeMethodsFromGenericOverride() {
+ List metadata = new DefaultContract().parseAndValidateMetadata(UserApi.class);
+
+ assertThat(metadata).hasSize(1);
+ assertThat(metadata.get(0).configKey()).isEqualTo("UserApi#get(String)");
+ assertThat(metadata.get(0).template()).hasMethod("GET").hasUrl("/users/{id}");
+ }
+
+ @Test
+ void callsThroughGenericSuperInterfaceUseBridgedHandler() {
+ AtomicReference captured = new AtomicReference<>();
+
+ CrudApi api =
+ (CrudApi)
+ Feign.builder()
+ .client(
+ (request, options) -> {
+ captured.set(request);
+ return Response.builder()
+ .status(200)
+ .reason("OK")
+ .request(request)
+ .headers(Collections.emptyMap())
+ .body("ok", Util.UTF_8)
+ .build();
+ })
+ .target(UserApi.class, "http://localhost:1");
+
+ assertThat(api.get("1")).isEqualTo("ok");
+ assertThat(captured.get().url()).isEqualTo("http://localhost:1/users/1");
+ }
+}
diff --git a/core/src/test/java/feign/client/AbstractClientTest.java b/core/src/test/java/feign/client/AbstractClientTest.java
index 73bf6ec8fe..df29c691c0 100644
--- a/core/src/test/java/feign/client/AbstractClientTest.java
+++ b/core/src/test/java/feign/client/AbstractClientTest.java
@@ -231,6 +231,16 @@ public void noResponseBodyForPut() throws Exception {
api.noPutBody();
}
+ @Test
+ public void emptyStringBodyForPost() throws Exception {
+ server.enqueue(new MockResponse.Builder().build());
+
+ TestInterface api =
+ newBuilder().target(TestInterface.class, "http://localhost:" + server.getPort());
+
+ api.postEmptyStringBody("");
+ }
+
/**
* Some client implementation tests should override this test if the PATCH operation is
* unsupported.
@@ -619,6 +629,9 @@ public interface TestInterface {
@RequestLine("POST")
String noPostBody();
+ @RequestLine("POST /")
+ String postEmptyStringBody(String body);
+
@RequestLine("PUT")
String noPutBody();
diff --git a/core/src/test/java/feign/codec/DecoderPredicateTest.java b/core/src/test/java/feign/codec/DecoderPredicateTest.java
new file mode 100644
index 0000000000..db94f3aece
--- /dev/null
+++ b/core/src/test/java/feign/codec/DecoderPredicateTest.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright © 2012 The Feign Authors (feign@commonhaus.dev)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package feign.codec;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import feign.Request;
+import feign.Request.HttpMethod;
+import feign.Response;
+import feign.Util;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class DecoderPredicateTest {
+
+ private static Response response(String contentType) {
+ return response(contentType, 200, "body");
+ }
+
+ private static Response response(String contentType, int status, String body) {
+ Map> headers = new HashMap<>();
+ if (contentType != null) {
+ headers.put("Content-Type", Collections.singletonList(contentType));
+ }
+ Response.Builder builder =
+ Response.builder()
+ .status(status)
+ .reason("OK")
+ .headers(headers)
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, null));
+ if (body != null) {
+ builder.body(body, Util.UTF_8);
+ }
+ return builder.build();
+ }
+
+ @Test
+ void jsonContentTypeMatchesPlainAndSuffixedTypes() {
+ DecoderPredicate predicate = DecoderPredicate.jsonContentType();
+
+ assertThat(predicate.canDecode(response("application/json"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/json;charset=utf-8"), String.class))
+ .isTrue();
+ assertThat(predicate.canDecode(response("APPLICATION/JSON"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("text/json"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/vnd.github+json"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/xml"), String.class)).isFalse();
+ assertThat(predicate.canDecode(response("application/x-json-stream"), String.class)).isFalse();
+ assertThat(predicate.canDecode(response(null), String.class)).isFalse();
+ }
+
+ @Test
+ void xmlContentTypeMatchesPlainAndSuffixedTypes() {
+ DecoderPredicate predicate = DecoderPredicate.xmlContentType();
+
+ assertThat(predicate.canDecode(response("application/xml"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("text/xml;charset=utf-8"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/soap+xml"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/json"), String.class)).isFalse();
+ assertThat(predicate.canDecode(response(null), String.class)).isFalse();
+ }
+
+ @Test
+ void contentTypeIgnoresCaseAndParameters() {
+ DecoderPredicate predicate = DecoderPredicate.contentType("text/csv");
+
+ assertThat(predicate.canDecode(response("text/csv"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("TEXT/CSV;charset=utf-8"), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("text/csv-x"), String.class)).isFalse();
+ assertThat(predicate.canDecode(response("text/plain"), String.class)).isFalse();
+ }
+
+ @Test
+ void emptyBodyMatchesResponsesWithoutContent() {
+ DecoderPredicate predicate = DecoderPredicate.emptyBody();
+
+ assertThat(predicate.canDecode(response("application/json", 204, null), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/json", 200, ""), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/json", 200, "body"), String.class))
+ .isFalse();
+ }
+
+ @Test
+ void statusMatchesTheGivenCodes() {
+ DecoderPredicate predicate = DecoderPredicate.status(204, 404);
+
+ assertThat(predicate.canDecode(response("application/json", 204, null), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/json", 404, null), String.class)).isTrue();
+ assertThat(predicate.canDecode(response("application/json", 200, "body"), String.class))
+ .isFalse();
+ }
+
+ @Test
+ void returnTypeMatchesTheExpectedType() {
+ DecoderPredicate predicate = DecoderPredicate.returnType(byte[].class);
+
+ assertThat(predicate.canDecode(response("application/octet-stream"), byte[].class)).isTrue();
+ assertThat(predicate.canDecode(response("application/octet-stream"), String.class)).isFalse();
+ }
+
+ @Test
+ void combinesPredicates() {
+ DecoderPredicate json = DecoderPredicate.jsonContentType();
+ DecoderPredicate ok = DecoderPredicate.status(200);
+
+ assertThat(json.and(ok).canDecode(response("application/json"), String.class)).isTrue();
+ assertThat(json.and(ok).canDecode(response("application/json", 204, null), String.class))
+ .isFalse();
+ assertThat(json.or(ok).canDecode(response("text/plain"), String.class)).isTrue();
+ assertThat(json.negate().canDecode(response("text/plain"), String.class)).isTrue();
+ }
+}
diff --git a/core/src/test/java/feign/codec/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/MultiDecoderCapabilityTest.java b/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java
new file mode 100644
index 0000000000..b5af2c58e5
--- /dev/null
+++ b/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java
@@ -0,0 +1,237 @@
+/*
+ * Copyright © 2012 The Feign Authors (feign@commonhaus.dev)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package feign.codec;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import feign.Capability;
+import feign.Feign;
+import feign.Param;
+import feign.Request;
+import feign.Request.HttpMethod;
+import feign.RequestLine;
+import feign.Response;
+import feign.Util;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/** How {@link MultiDecoder} behaves end to end and when a {@link Capability} wraps the decoder. */
+class MultiDecoderCapabilityTest {
+
+ interface MixedApi {
+ @RequestLine("GET /{path}")
+ String get(@Param("path") String path);
+ }
+
+ static class TaggingDecoder implements Decoder {
+ private final String tag;
+
+ TaggingDecoder(String tag) {
+ this.tag = tag;
+ }
+
+ @Override
+ public Object decode(Response response, Type type) {
+ return tag;
+ }
+ }
+
+ /** A capability that wraps the decoder, the way the metrics modules do. */
+ public static class CountingCapability implements Capability {
+ int wrapped;
+ int decodeCalls;
+
+ @Override
+ public Decoder enrich(Decoder decoder) {
+ wrapped++;
+ return (response, type) -> {
+ decodeCalls++;
+ return decoder.decode(response, type);
+ };
+ }
+ }
+
+ private static Response response(String contentType, String body) {
+ return response(
+ contentType,
+ body,
+ Request.create(HttpMethod.GET, "http://localhost:1/", Collections.emptyMap(), null, null));
+ }
+
+ private static Response response(String contentType, String body, Request request) {
+ Map> headers = new HashMap<>();
+ headers.put("Content-Type", Collections.singletonList(contentType));
+ return Response.builder()
+ .status(200)
+ .reason("OK")
+ .headers(headers)
+ .body(body, Util.UTF_8)
+ .request(request)
+ .build();
+ }
+
+ private static MixedApi target(Feign.Builder builder, Map contentTypes) {
+ return builder
+ .client(
+ (request, options) -> {
+ String path = request.url().substring(request.url().lastIndexOf('/') + 1);
+ return response(contentTypes.get(path), "payload", request);
+ })
+ .target(MixedApi.class, "http://localhost:1");
+ }
+
+ @Test
+ void capabilityWrapsTheCompositeAndRoutingStillWorks() {
+ CountingCapability capability = new CountingCapability();
+ Map contentTypes = new HashMap<>();
+ contentTypes.put("json", "application/json");
+ contentTypes.put("xml", "application/xml");
+
+ MixedApi api =
+ target(
+ Feign.builder()
+ .decoder(
+ MultiDecoder.builder()
+ .add(DecoderPredicate.jsonContentType(), new TaggingDecoder("json"))
+ .add(DecoderPredicate.xmlContentType(), new TaggingDecoder("xml"))
+ .add(DecoderPredicate.any(), new TaggingDecoder("fallback"))
+ .build())
+ .addCapability(capability),
+ contentTypes);
+
+ assertThat(api.get("json")).isEqualTo("json");
+ assertThat(api.get("xml")).isEqualTo("xml");
+
+ // the capability sees the MultiDecoder as one decoder, not one per delegate
+ assertThat(capability.wrapped).isEqualTo(1);
+ assertThat(capability.decodeCalls).isEqualTo(2);
+ }
+
+ @Test
+ void decodersOnTheBuilderRouteInTheOrderGiven() {
+ Map contentTypes = new HashMap<>();
+ contentTypes.put("json", "application/json");
+ contentTypes.put("csv", "text/csv");
+
+ MixedApi api =
+ target(
+ Feign.builder()
+ .decoders(
+ new SelfDeclaringJsonDecoder(),
+ PredicatedDecoder.of(DecoderPredicate.any(), new TaggingDecoder("fallback"))),
+ contentTypes);
+
+ assertThat(api.get("json")).isEqualTo("json");
+ assertThat(api.get("csv")).isEqualTo("fallback");
+ }
+
+ @Test
+ void decodersOnTheBuilderFailWhenNothingAccepts() {
+ Map contentTypes = new HashMap<>();
+ contentTypes.put("csv", "text/csv");
+
+ MixedApi api = target(Feign.builder().decoders(new SelfDeclaringJsonDecoder()), contentTypes);
+
+ assertThatThrownBy(() -> api.get("csv"))
+ .isInstanceOf(DecodeException.class)
+ .hasMessageContaining("Unable to decode 200 response (Content-Type: text/csv)")
+ .hasMessageContaining("SelfDeclaringJsonDecoder");
+ }
+
+ /** The selected decoder still receives an unread body: predicates must not consume it. */
+ @Test
+ void predicatesLeaveTheBodyForTheSelectedDecoder() throws IOException {
+ Decoder decoder =
+ MultiDecoder.builder()
+ .add(
+ DecoderPredicate.jsonContentType(),
+ (response, type) -> Util.toString(response.body().asReader(Util.UTF_8)))
+ .build();
+
+ assertThat(decoder.decode(response("application/json", "payload"), String.class))
+ .isEqualTo("payload");
+ }
+
+ static class SelfDeclaringJsonDecoder implements Decoder, PredicatedDecoder {
+
+ @Override
+ public Object decode(Response response, Type type) {
+ return "json";
+ }
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
+ }
+
+ /**
+ * A wrapper that answers {@code canDecode} for itself instead of forwarding claims every
+ * response, which is why the metrics modules' {@code MeteredDecoder} forwards it to its delegate.
+ */
+ @Test
+ void wrappingWithoutForwardingCanDecodeErasesSelfDeclaration() throws IOException {
+ PredicatedDecoder jsonOnly = new SelfDeclaringJsonDecoder();
+
+ PredicatedDecoder naive =
+ new PredicatedDecoder() {
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return true;
+ }
+
+ @Override
+ public Object decode(Response response, Type type) throws IOException {
+ return jsonOnly.decode(response, type);
+ }
+ };
+
+ PredicatedDecoder forwarding =
+ new PredicatedDecoder() {
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return jsonOnly.canDecode(response, type);
+ }
+
+ @Override
+ public Object decode(Response response, Type type) throws IOException {
+ return jsonOnly.decode(response, type);
+ }
+ };
+
+ assertThat(
+ MultiDecoder.builder()
+ .add(naive)
+ .add(DecoderPredicate.any(), new TaggingDecoder("fallback"))
+ .build()
+ .decode(response("application/xml", "payload"), String.class))
+ .isEqualTo("json");
+
+ assertThat(
+ MultiDecoder.builder()
+ .add(forwarding)
+ .add(DecoderPredicate.any(), new TaggingDecoder("fallback"))
+ .build()
+ .decode(response("application/xml", "payload"), String.class))
+ .isEqualTo("fallback");
+ }
+}
diff --git a/core/src/test/java/feign/codec/MultiDecoderTest.java b/core/src/test/java/feign/codec/MultiDecoderTest.java
new file mode 100644
index 0000000000..d6732bc22b
--- /dev/null
+++ b/core/src/test/java/feign/codec/MultiDecoderTest.java
@@ -0,0 +1,310 @@
+/*
+ * Copyright © 2012 The Feign Authors (feign@commonhaus.dev)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package feign.codec;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import feign.Request;
+import feign.Request.HttpMethod;
+import feign.Response;
+import feign.Util;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class MultiDecoderTest {
+
+ /** A plain decoder, with no opinion about what it can handle. */
+ private static class RecordingDecoder implements Decoder {
+ private final String result;
+ boolean invoked;
+
+ RecordingDecoder(String result) {
+ this.result = result;
+ }
+
+ @Override
+ public Object decode(Response response, Type type) {
+ invoked = true;
+ return result;
+ }
+ }
+
+ /** A decoder that declares its own applicability, the way feign-gson and friends now do. */
+ private static class SelfDeclaringJsonDecoder extends RecordingDecoder
+ implements PredicatedDecoder {
+
+ SelfDeclaringJsonDecoder() {
+ super("json");
+ }
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
+ }
+
+ private static Response responseWithContentType(String contentType) {
+ return responseWithContentType(contentType, 200, "body");
+ }
+
+ private static Response responseWithContentType(String contentType, int status, String body) {
+ Map> headers = new HashMap<>();
+ if (contentType != null) {
+ headers.put("Content-Type", Collections.singletonList(contentType));
+ }
+ Response.Builder builder =
+ Response.builder()
+ .status(status)
+ .reason("OK")
+ .headers(headers)
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, null));
+ if (body != null) {
+ builder.body(body, Util.UTF_8);
+ }
+ return builder.build();
+ }
+
+ @Test
+ void routesToTheDecoderThatDeclaresItCanHandleTheResponse() throws IOException {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+ RecordingDecoder fallback = new RecordingDecoder("fallback");
+
+ Decoder decoder =
+ MultiDecoder.builder().add(json).add(DecoderPredicate.any(), fallback).build();
+
+ assertThat(decoder.decode(responseWithContentType("application/json"), String.class))
+ .isEqualTo("json");
+ assertThat(json.invoked).isTrue();
+ assertThat(fallback.invoked).isFalse();
+ }
+
+ @Test
+ void pairsAPredicateWithADecoderThatDoesNotDeclareItself() throws IOException {
+ RecordingDecoder xml = new RecordingDecoder("xml");
+ RecordingDecoder fallback = new RecordingDecoder("fallback");
+
+ Decoder decoder =
+ MultiDecoder.builder()
+ .add(DecoderPredicate.xmlContentType(), xml)
+ .add(DecoderPredicate.any(), fallback)
+ .build();
+
+ assertThat(decoder.decode(responseWithContentType("application/xml"), String.class))
+ .isEqualTo("xml");
+ assertThat(fallback.invoked).isFalse();
+ }
+
+ @Test
+ void mixesSelfDeclaringDecodersAndPairs() throws IOException {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+ RecordingDecoder xml = new RecordingDecoder("xml");
+ RecordingDecoder csv = new RecordingDecoder("csv");
+ RecordingDecoder fallback = new RecordingDecoder("fallback");
+
+ Decoder decoder =
+ MultiDecoder.builder()
+ .add(json)
+ .add(DecoderPredicate.xmlContentType(), xml)
+ .add(DecoderPredicate.contentType("text/csv"), csv)
+ .add(DecoderPredicate.any(), fallback)
+ .build();
+
+ assertThat(decoder.decode(responseWithContentType("text/csv;charset=utf-8"), String.class))
+ .isEqualTo("csv");
+ assertThat(json.invoked).isFalse();
+ assertThat(xml.invoked).isFalse();
+ assertThat(fallback.invoked).isFalse();
+ }
+
+ @Test
+ void matchesSuffixedContentTypes() throws IOException {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+
+ Decoder decoder = MultiDecoder.builder().add(json).build();
+
+ assertThat(decoder.decode(responseWithContentType("application/vnd.github+json"), String.class))
+ .isEqualTo("json");
+ }
+
+ @Test
+ void fallsBackToTheDecoderThatAcceptsAnything() throws IOException {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+ RecordingDecoder fallback = new RecordingDecoder("fallback");
+
+ Decoder decoder =
+ MultiDecoder.builder().add(json).add(DecoderPredicate.any(), fallback).build();
+
+ assertThat(decoder.decode(responseWithContentType("text/plain"), String.class))
+ .isEqualTo("fallback");
+ assertThat(json.invoked).isFalse();
+ }
+
+ @Test
+ void fallsBackWhenTheResponseCarriesNoContentType() throws IOException {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+ RecordingDecoder fallback = new RecordingDecoder("fallback");
+
+ Decoder decoder =
+ MultiDecoder.builder().add(json).add(DecoderPredicate.any(), fallback).build();
+
+ assertThat(decoder.decode(responseWithContentType(null), String.class)).isEqualTo("fallback");
+ }
+
+ @Test
+ void consultsDecodersInTheOrderTheyWereAdded() throws IOException {
+ RecordingDecoder first = new RecordingDecoder("first");
+ RecordingDecoder second = new RecordingDecoder("second");
+
+ Decoder decoder =
+ MultiDecoder.builder()
+ .add(DecoderPredicate.jsonContentType(), first)
+ .add(DecoderPredicate.jsonContentType(), second)
+ .build();
+
+ assertThat(decoder.decode(responseWithContentType("application/json"), String.class))
+ .isEqualTo("first");
+ assertThat(second.invoked).isFalse();
+ }
+
+ @Test
+ void pairingReplacesWhatTheDecoderDeclaresAboutItself() throws IOException {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+
+ Decoder decoder =
+ MultiDecoder.builder().add(PredicatedDecoder.of(DecoderPredicate.any(), json)).build();
+
+ assertThat(decoder.decode(responseWithContentType("text/plain"), String.class))
+ .isEqualTo("json");
+ }
+
+ @Test
+ void narrowingKeepsWhatTheDecoderDeclaresAboutItself() {
+ SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder();
+ PredicatedDecoder narrowed = PredicatedDecoder.narrowing(DecoderPredicate.status(200), json);
+
+ assertThat(narrowed.canDecode(responseWithContentType("application/json"), String.class))
+ .isTrue();
+ assertThat(
+ narrowed.canDecode(
+ responseWithContentType("application/json", 204, null), String.class))
+ .isFalse();
+ assertThat(narrowed.canDecode(responseWithContentType("text/plain"), String.class)).isFalse();
+ assertThat(narrowed)
+ .hasToString(
+ "SelfDeclaringJsonDecoder when (status is one of [200]"
+ + " and SelfDeclaringJsonDecoder accepts it)");
+ }
+
+ @Test
+ void narrowingADecoderThatDeclaresNothingIsJustThePredicate() {
+ RecordingDecoder plain = new RecordingDecoder("plain");
+ PredicatedDecoder narrowed =
+ PredicatedDecoder.narrowing(DecoderPredicate.jsonContentType(), plain);
+
+ assertThat(narrowed).hasToString("RecordingDecoder when Content-Type is JSON");
+ assertThat(narrowed.canDecode(responseWithContentType("application/json"), String.class))
+ .isTrue();
+ }
+
+ @Test
+ void throwsWhenNoDecoderAcceptsTheResponse() {
+ Decoder decoder =
+ MultiDecoder.builder()
+ .add(new SelfDeclaringJsonDecoder())
+ .add(DecoderPredicate.xmlContentType(), new RecordingDecoder("xml"))
+ .build();
+
+ assertThatThrownBy(() -> decoder.decode(responseWithContentType("text/plain"), String.class))
+ .isInstanceOf(DecodeException.class)
+ .hasMessage(
+ "Unable to decode 200 response (Content-Type: text/plain) as java.lang.String."
+ + " Decoders tried, in order:"
+ + "\n - SelfDeclaringJsonDecoder"
+ + "\n - RecordingDecoder when Content-Type is XML"
+ + "\nAdd a decoder guarded by DecoderPredicate.any() last to act as a default.");
+ }
+
+ @Test
+ void theFailureReportsAMissingContentType() {
+ Decoder decoder = MultiDecoder.builder().add(new SelfDeclaringJsonDecoder()).build();
+
+ assertThatThrownBy(() -> decoder.decode(responseWithContentType(null), String.class))
+ .isInstanceOf(DecodeException.class)
+ .hasMessageContaining("(Content-Type: not set)");
+ }
+
+ @Test
+ void throwsWhenNoDecodersAreConfigured() {
+ Decoder decoder = MultiDecoder.builder().build();
+
+ assertThatThrownBy(
+ () -> decoder.decode(responseWithContentType("application/json"), String.class))
+ .isInstanceOf(DecodeException.class)
+ .hasMessage(
+ "Unable to decode 200 response (Content-Type: application/json) as java.lang.String."
+ + " No decoders were configured.");
+ }
+
+ @Test
+ void propagatesIoExceptionsFromTheSelectedDecoder() {
+ Decoder failing =
+ (response, type) -> {
+ throw new IOException("boom");
+ };
+
+ Decoder decoder =
+ MultiDecoder.builder().add(DecoderPredicate.jsonContentType(), failing).build();
+
+ assertThatThrownBy(
+ () -> decoder.decode(responseWithContentType("application/json"), String.class))
+ .isInstanceOf(IOException.class)
+ .hasMessage("boom");
+ }
+
+ @Test
+ void rejectsNullDecoders() {
+ MultiDecoder.Builder builder = MultiDecoder.builder();
+
+ assertThatThrownBy(() -> builder.add((PredicatedDecoder) null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("decoder cannot be null");
+ assertThatThrownBy(() -> builder.add(null, new RecordingDecoder("x")))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("predicate cannot be null");
+ assertThatThrownBy(() -> builder.add(DecoderPredicate.jsonContentType(), null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("decoder cannot be null");
+ }
+
+ @Test
+ void describesItsDecoders() {
+ Decoder decoder =
+ MultiDecoder.builder()
+ .add(new SelfDeclaringJsonDecoder())
+ .add(DecoderPredicate.jsonContentType(), new RecordingDecoder("json"))
+ .build();
+
+ assertThat(decoder.toString())
+ .isEqualTo(
+ "MultiDecoder[SelfDeclaringJsonDecoder, RecordingDecoder when Content-Type is JSON]");
+ }
+}
diff --git a/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java b/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java
new file mode 100644
index 0000000000..941dfa5337
--- /dev/null
+++ b/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java
@@ -0,0 +1,232 @@
+/*
+ * 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.Request;
+import feign.RequestLine;
+import feign.RequestTemplate;
+import feign.Response;
+import feign.Util;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+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(Request.Body.of(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(request.body().get().writeToString(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 String bodyOf(RequestTemplate template) {
+ try {
+ return template.requestBody().get().writeToString(Util.UTF_8);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ 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(Request.Body.of("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(bodyOf(naiveTemplate)).isEqualTo("json");
+
+ RequestTemplate forwardedTemplate = template("application/xml");
+ MultiEncoder.builder()
+ .add(forwarding)
+ .add(EncoderPredicate.any(), new TaggingEncoder("fallback"))
+ .build()
+ .encode("body", String.class, forwardedTemplate);
+ assertThat(bodyOf(forwardedTemplate)).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..a1a223d59c
--- /dev/null
+++ b/core/src/test/java/feign/codec/MultiEncoderTest.java
@@ -0,0 +1,331 @@
+/*
+ * 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 feign.core.codec.DefaultEncoder;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+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.of(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 String bodyOf(RequestTemplate template) {
+ try {
+ return template.requestBody().get().writeToString(Util.UTF_8);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ 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(bodyOf(template)).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(bodyOf(template)).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/core/src/test/java/feign/core/DefaultClientTest.java b/core/src/test/java/feign/core/DefaultClientTest.java
index 0e236773e6..e41e9252de 100644
--- a/core/src/test/java/feign/core/DefaultClientTest.java
+++ b/core/src/test/java/feign/core/DefaultClientTest.java
@@ -42,6 +42,7 @@
import java.util.Map;
import java.util.zip.GZIPOutputStream;
import mockwebserver3.MockResponse;
+import mockwebserver3.RecordedRequest;
import okio.Buffer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
@@ -145,6 +146,45 @@ void noRequestBodyForPostWithAllowRestrictedHeaders() throws Exception {
.hasHeaders(entry("Content-Length", Collections.singletonList("0")));
}
+ @Test
+ void lowerCaseContentLengthHeaderIsUsedForFixedLengthStreamingMode() throws Exception {
+ server.enqueue(new MockResponse.Builder().build());
+ byte[] body = "hello".getBytes(StandardCharsets.UTF_8);
+ Map> headers = new LinkedHashMap<>();
+ headers.put("content-length", Collections.singletonList(String.valueOf(body.length)));
+ Request request =
+ Request.create(
+ HttpMethod.POST,
+ "http://localhost:" + server.getPort() + "/",
+ headers,
+ Request.Body.of(body),
+ null);
+
+ // the two-arg constructor disables request buffering, so a recognised Content-Length selects
+ // fixed-length streaming mode and the JDK emits the header itself, exactly once
+ new DefaultClient(null, null).execute(request, new Request.Options());
+
+ RecordedRequest recordedRequest = server.takeRequest();
+ assertThat(recordedRequest.getHeaders().values("Content-Length"))
+ .containsExactly(String.valueOf(body.length));
+ assertThat(recordedRequest.getHeaders().get("Transfer-Encoding")).isNull();
+ }
+
+ @Test
+ @EnabledIfSystemProperty(named = "sun.net.http.allowRestrictedHeaders", matches = "true")
+ public void contentLengthHeaderIsNotDuplicatedForBodylessRequest() throws Exception {
+ server.enqueue(new MockResponse.Builder().build());
+ Map> headers = new LinkedHashMap<>();
+ headers.put("content-length", Collections.singletonList("0"));
+ Request request =
+ Request.create(
+ HttpMethod.POST, "http://localhost:" + server.getPort() + "/", headers, null, null);
+
+ new DefaultClient(null, null).execute(request, new Request.Options());
+
+ assertThat(server.takeRequest().getHeaders().values("Content-Length")).containsExactly("0");
+ }
+
@Test
void emptyBodyDoesNotConvertGetToPost() throws Exception {
server.enqueue(new MockResponse.Builder().body("foo").build());
@@ -161,6 +201,15 @@ void emptyBodyDoesNotConvertGetToPost() throws Exception {
MockWebServerAssertions.assertThat(server.takeRequest()).hasMethod("GET");
}
+ @Test
+ @Override
+ public void emptyStringBodyForPost() throws Exception {
+ super.emptyStringBodyForPost();
+ MockWebServerAssertions.assertThat(server.takeRequest())
+ .hasMethod("POST")
+ .hasNoHeaderNamed("Content-Type");
+ }
+
@Test
@Override
public void noResponseBodyForPut() throws Exception {
diff --git a/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java b/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java
index e47fbe852d..b51d154b27 100644
--- a/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java
+++ b/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java
@@ -22,11 +22,12 @@
import feign.Response;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.lang.reflect.Type;
/** Warp feign {@link Decoder} with metrics. */
-public class MeteredDecoder implements Decoder {
+public class MeteredDecoder implements Decoder, PredicatedDecoder {
private final Decoder decoder;
private final MetricRegistry metricRegistry;
@@ -73,4 +74,10 @@ public Object decode(Response response, Type type)
return decoded;
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return !(decoder instanceof PredicatedDecoder)
+ || ((PredicatedDecoder) decoder).canDecode(response, type);
+ }
}
diff --git a/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredEncoder.java b/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredEncoder.java
index 4263d14d51..4cb46daa28 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;
@@ -61,4 +62,10 @@ public void encode(Object object, Type bodyType, RequestTemplate template)
metricSuppliers.histograms())
.update(body.contentLength()));
}
+
+ @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/MeteredDecoder.java b/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredDecoder.java
index 653d29e62e..5f56b57701 100644
--- a/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredDecoder.java
+++ b/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredDecoder.java
@@ -20,6 +20,7 @@
import feign.Response;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import feign.utils.ExceptionUtils;
import io.dropwizard.metrics5.MetricRegistry;
import io.dropwizard.metrics5.Timer.Context;
@@ -28,7 +29,7 @@
import java.util.Map;
/** Warp feign {@link Decoder} with metrics. */
-public class MeteredDecoder implements Decoder {
+public class MeteredDecoder implements Decoder, PredicatedDecoder {
private final Decoder decoder;
private final MetricRegistry metricRegistry;
@@ -110,4 +111,10 @@ public Object decode(Response response, Type type)
return decoded;
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return !(decoder instanceof PredicatedDecoder)
+ || ((PredicatedDecoder) decoder).canDecode(response, type);
+ }
}
diff --git a/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredEncoder.java b/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredEncoder.java
index 09f3058be4..3f89af5f09 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;
@@ -73,4 +74,10 @@ public void encode(Object object, Type bodyType, RequestTemplate template)
metricSuppliers.histograms())
.update(body.contentLength()));
}
+
+ @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/Fastjson2Decoder.java b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java
index 80b1ada8d7..e8631ae0c5 100644
--- a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java
+++ b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java
@@ -26,6 +26,7 @@
import feign.Util;
import feign.codec.Decoder;
import feign.codec.JsonDecoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Type;
@@ -33,7 +34,7 @@
/**
* @author changjin wei(魏昌进)
*/
-public class Fastjson2Decoder implements Decoder, JsonDecoder {
+public class Fastjson2Decoder implements Decoder, PredicatedDecoder, JsonDecoder {
private final JSONReader.Feature[] features;
@@ -69,4 +70,9 @@ public Object convert(Object object, Type type) {
}
return JSON.parseObject(JSON.toJSONString(object), type);
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java
index 7e79af331a..42ec2938aa 100644
--- a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java
+++ b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java
@@ -19,15 +19,17 @@
import com.alibaba.fastjson2.JSONWriter;
import feign.Request;
import feign.RequestTemplate;
+import feign.Util;
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 +46,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template)
throws EncodeException {
template.body(Request.Body.of(JSON.toJSONBytes(object, features)));
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isJsonContentType(template);
+ }
}
diff --git a/feign-bom/pom.xml b/feign-bom/pom.xml
index f54b8d76b8..20757486e0 100644
--- a/feign-bom/pom.xml
+++ b/feign-bom/pom.xml
@@ -267,4 +267,49 @@
+
+
+
+
+ org.codehaus.mojo
+ flatten-maven-plugin
+ ${flatten-maven-plugin.version}
+
+ bom
+
+ ${project.build.directory}
+
+ remove
+
+
+
+
+ flatten
+
+ flatten
+
+ process-resources
+
+
+ flatten.clean
+
+ clean
+
+ clean
+
+
+
+
+ com.github.ekryd.sortpom
+ sortpom-maven-plugin
+
+
+ ${project.basedir}/pom.xml
+
+
+
+
+
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 e177767c19..d30b6f4522 100644
--- a/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java
+++ b/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java
@@ -21,6 +21,7 @@
import feign.RequestTemplate;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.PredicatedEncoder;
import feign.core.codec.DefaultEncoder;
import feign.form.FormEncoder;
import feign.form.MultipartFormContentProcessor;
@@ -41,10 +42,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 deb4656733..5ffc023a3b 100644
--- a/form/src/main/java/feign/form/FormEncoder.java
+++ b/form/src/main/java/feign/form/FormEncoder.java
@@ -24,6 +24,8 @@
import feign.RequestTemplate;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.EncoderPredicate;
+import feign.codec.PredicatedEncoder;
import feign.core.codec.DefaultEncoder;
import java.lang.reflect.Type;
import java.nio.charset.Charset;
@@ -47,6 +49,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\\-]+)");
@@ -64,13 +76,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;
final var 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) {
@@ -78,6 +93,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)
@@ -114,7 +160,7 @@ public final ContentProcessor getContentProcessor(ContentType type) {
}
@SuppressWarnings("PMD.AvoidBranchingStatementAsLastInLoop")
- private String getContentTypeValue(Map> headers) {
+ private static String getContentTypeValue(Map> headers) {
for (var 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..68c54874df
--- /dev/null
+++ b/form/src/test/java/feign/form/PredicatedFormEncoderTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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.Request;
+import feign.RequestTemplate;
+import feign.Util;
+import feign.codec.EncodeException;
+import feign.codec.Encoder;
+import feign.codec.EncoderPredicate;
+import feign.codec.MultiEncoder;
+import feign.codec.PredicatedEncoder;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+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 String bodyOf(RequestTemplate template) {
+ try {
+ return template.requestBody().get().writeToString(Util.UTF_8);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ 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(bodyOf(template)).isEqualTo("foo=bar");
+ }
+
+ @Test
+ void routesAlongsideOtherEncoders() {
+ Encoder json = (object, bodyType, template) -> template.body(Request.Body.of("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(bodyOf(form)).isEqualTo("foo=bar");
+
+ RequestTemplate other = template("application/json");
+ encoder.encode("body", String.class, other);
+ assertThat(bodyOf(other)).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/graphql-apt/README.md b/graphql-apt/README.md
index cc6935233b..dc0b27cb48 100644
--- a/graphql-apt/README.md
+++ b/graphql-apt/README.md
@@ -56,6 +56,29 @@ public record CharByRegion(String id, Location location) {
}
```
+### Multiple root fields
+
+An operation selecting several root fields gets a record with one component per field, mirroring the operation's own selection set rather than a single field's type:
+
+```graphql
+query authorPage($authorId: ID!) {
+ books(authorId: $authorId) { id title }
+ reviews(authorId: $authorId) { id rating }
+}
+```
+
+```java
+public record AuthorPage(Optional> books, Optional> reviews) {
+
+ public record Books(String id, String title) {}
+
+ public record Reviews(String id, Integer rating) {}
+
+}
+```
+
+With a single root field the record still mirrors that field's type, so `{ character(id: "1") { id name } }` keeps generating `record CharacterResult(String id, String name)`.
+
### Conflicting return type error
If two queries use the same return type name but select different fields, the processor reports compilation errors on both methods showing which fields each selects:
diff --git a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java
index 2ea251ca80..c69b19a8d0 100644
--- a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java
+++ b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java
@@ -247,22 +247,7 @@ private void processMethod(
var returnTypeName = getSimpleTypeName(method.getReturnType());
if (returnTypeName != null && !isExistingExternalType(method.getReturnType(), targetPackage)) {
- var rootType = getRootType(operation, registry);
- if (rootType != null) {
- var rootField = findRootField(operation.getSelectionSet());
- if (rootField != null && rootField.getSelectionSet() != null) {
- var rootFieldDef = GraphqlTypeMapper.findFieldDefinition(rootType, rootField.getName());
- if (rootFieldDef != null) {
- var fieldTypeName = GraphqlTypeMapper.unwrapTypeName(rootFieldDef.getType());
- var fieldObjectType =
- registry.getType(fieldTypeName, ObjectTypeDefinition.class).orElse(null);
- if (fieldObjectType != null) {
- generator.generateResultType(
- returnTypeName, rootField.getSelectionSet(), fieldObjectType, method);
- }
- }
- }
- }
+ generateReturnType(returnTypeName, operation, registry, generator, method);
}
var params = method.getParameters();
@@ -294,16 +279,58 @@ private OperationDefinition findOperation(Document document) {
return null;
}
- private Field findRootField(SelectionSet selectionSet) {
- if (selectionSet == null) {
- return null;
+ /**
+ * A single root field is the operation result itself, so the record mirrors that field's type.
+ * Several root fields are all part of the result — the decoder binds the whole {@code data} map —
+ * so the record mirrors the operation's own selection set, one component per root field.
+ */
+ private void generateReturnType(
+ String returnTypeName,
+ OperationDefinition operation,
+ TypeDefinitionRegistry registry,
+ TypeGenerator generator,
+ ExecutableElement method) {
+ var rootType = getRootType(operation, registry);
+ if (rootType == null) {
+ return;
}
- for (var selection : selectionSet.getSelections()) {
- if (selection instanceof Field field) {
- return field;
- }
+
+ var rootFields = rootFields(operation.getSelectionSet());
+ if (rootFields.size() > 1) {
+ generator.generateResultType(returnTypeName, operation.getSelectionSet(), rootType, method);
+ return;
}
- return null;
+
+ if (rootFields.isEmpty()) {
+ return;
+ }
+
+ var rootField = rootFields.get(0);
+ if (rootField.getSelectionSet() == null) {
+ return;
+ }
+
+ var rootFieldDef = GraphqlTypeMapper.findFieldDefinition(rootType, rootField.getName());
+ if (rootFieldDef == null) {
+ return;
+ }
+
+ var fieldTypeName = GraphqlTypeMapper.unwrapTypeName(rootFieldDef.getType());
+ var fieldObjectType = registry.getType(fieldTypeName, ObjectTypeDefinition.class).orElse(null);
+ if (fieldObjectType != null) {
+ generator.generateResultType(
+ returnTypeName, rootField.getSelectionSet(), fieldObjectType, method);
+ }
+ }
+
+ private List rootFields(SelectionSet selectionSet) {
+ if (selectionSet == null) {
+ return List.of();
+ }
+ return selectionSet.getSelections().stream()
+ .filter(Field.class::isInstance)
+ .map(Field.class::cast)
+ .toList();
}
private ObjectTypeDefinition getRootType(
@@ -358,12 +385,15 @@ private boolean isJavaBuiltIn(String typeName) {
return JAVA_BUILT_INS.contains(typeName);
}
+ /** Wrappers that carry the operation result rather than being it. */
+ private static final Set RESULT_CONTAINERS = Set.of("List", "Stream", "Publisher");
+
private String getSimpleTypeName(TypeMirror typeMirror) {
if (typeMirror instanceof DeclaredType declaredType) {
var typeElement = declaredType.asElement();
var simpleName = typeElement.getSimpleName().toString();
- if ("List".equals(simpleName)) {
+ if (RESULT_CONTAINERS.contains(simpleName)) {
var typeArgs = declaredType.getTypeArguments();
if (!typeArgs.isEmpty()) {
return getSimpleTypeName(typeArgs.get(0));
@@ -376,7 +406,7 @@ private String getSimpleTypeName(TypeMirror typeMirror) {
}
private boolean isExistingExternalType(TypeMirror typeMirror, String targetPackage) {
- var unwrapped = unwrapListTypeMirror(typeMirror);
+ var unwrapped = unwrapContainerTypeMirror(typeMirror);
if (unwrapped.getKind() == TypeKind.ERROR) {
return false;
}
@@ -392,13 +422,13 @@ private boolean isExistingExternalType(TypeMirror typeMirror, String targetPacka
return false;
}
- private TypeMirror unwrapListTypeMirror(TypeMirror typeMirror) {
+ private TypeMirror unwrapContainerTypeMirror(TypeMirror typeMirror) {
if (typeMirror instanceof DeclaredType declaredType) {
var simpleName = declaredType.asElement().getSimpleName().toString();
- if ("List".equals(simpleName)) {
+ if (RESULT_CONTAINERS.contains(simpleName)) {
var typeArgs = declaredType.getTypeArguments();
if (!typeArgs.isEmpty()) {
- return typeArgs.get(0);
+ return unwrapContainerTypeMirror(typeArgs.get(0));
}
}
}
diff --git a/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java
index e4488eed11..3bd7b9bc18 100644
--- a/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java
+++ b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java
@@ -635,6 +635,102 @@ interface InnerApi {
"public record Specs(Optional lengthMeters, Optional classification) {}");
}
+ @Test
+ void multipleRootFieldsGenerateOneComponentPerField() {
+ var source =
+ JavaFileObjects.forSourceString(
+ "test.MultiRootApi",
+ """
+ package test;
+
+ import feign.graphql.GraphqlSchema;
+ import feign.graphql.GraphqlQuery;
+
+ @GraphqlSchema("test-schema.graphql")
+ interface MultiRootApi {
+ @GraphqlQuery(\"""
+ query overview($id: ID!) {
+ character(id: $id) { id name }
+ starship(id: $id) { id name }
+ }\""")
+ Overview overview(String id);
+ }
+ """);
+
+ var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source);
+
+ assertThat(compilation).succeeded();
+
+ var contents =
+ assertThat(compilation).generatedSourceFile("test.Overview").contentsAsUtf8String();
+
+ contents.contains(
+ "public record Overview(Optional character, Optional starship) {");
+ contents.contains("public record Character(String id, String name) {}");
+ contents.contains("public record Starship(String id, String name) {}");
+ }
+
+ @Test
+ void multipleRootFieldsKeepListAndScalarShapes() {
+ var source =
+ JavaFileObjects.forSourceString(
+ "test.MultiRootListApi",
+ """
+ package test;
+
+ import feign.graphql.GraphqlSchema;
+ import feign.graphql.GraphqlQuery;
+
+ @GraphqlSchema("test-schema.graphql")
+ interface MultiRootListApi {
+ @GraphqlQuery(\"""
+ query page($id: ID!) {
+ characters { id name }
+ starship(id: $id) { id name }
+ }\""")
+ Page page(String id);
+ }
+ """);
+
+ var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source);
+
+ assertThat(compilation).succeeded();
+
+ var contents = assertThat(compilation).generatedSourceFile("test.Page").contentsAsUtf8String();
+
+ contents.contains(
+ "public record Page(Optional> characters, Optional starship) {");
+ contents.contains("public record Characters(String id, String name) {}");
+ }
+
+ @Test
+ void singleRootFieldStillMirrorsThatFieldType() {
+ var source =
+ JavaFileObjects.forSourceString(
+ "test.SingleRootApi",
+ """
+ package test;
+
+ import feign.graphql.GraphqlSchema;
+ import feign.graphql.GraphqlQuery;
+
+ @GraphqlSchema("test-schema.graphql")
+ interface SingleRootApi {
+ @GraphqlQuery("query one($id: ID!) { character(id: $id) { id name } }")
+ One one(String id);
+ }
+ """);
+
+ var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source);
+
+ assertThat(compilation).succeeded();
+
+ assertThat(compilation)
+ .generatedSourceFile("test.One")
+ .contentsAsUtf8String()
+ .contains("public record One(String id, String name) {}");
+ }
+
@Test
void differentQueriesDifferentNestedFields() {
var source =
diff --git a/graphql/README.md b/graphql/README.md
index 6f9ba883e2..6acfcf6019 100644
--- a/graphql/README.md
+++ b/graphql/README.md
@@ -100,6 +100,89 @@ The processor generates a record for the input type as well:
public record CreateUserInput(String name, String email) {}
```
+## Subscriptions
+
+`subscription` operations are detected from the query text and executed over the
+[graphql-transport-ws](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md) WebSocket
+protocol instead of HTTP. The endpoint is the target URL with its scheme swapped to `ws`/`wss`, and
+one connection is opened per call.
+
+Queries, mutations and subscriptions can live on the same interface: only subscriptions are routed
+to a WebSocket, everything else goes over the regular Feign client — including whichever one you
+configured with `.client(...)` — with its own timeouts, retryer and interceptors unchanged.
+
+The return type decides how many events you get and whether the call blocks:
+
+| Return type | Events | Behaviour |
+| --- | --- | --- |
+| `T` | first only | blocks until the first event, then unsubscribes |
+| `Optional` | first only | as above, empty if the server completes without one |
+| `CompletableFuture` | first only | returns immediately, completes with the first event |
+| `Stream` | all | returns once subscribed, then blocks on each element |
+| `Flow.Publisher` | all | returns immediately, elements are pushed to the subscriber |
+
+```java
+@GraphqlSchema("my-schema.graphql")
+interface StockApi {
+
+ @GraphqlQuery("subscription($symbol: String!) { priceChanged(symbol: $symbol) { symbol price } }")
+ Price nextPrice(@Param("symbol") String symbol);
+
+ @GraphqlQuery("subscription($symbol: String!) { priceChanged(symbol: $symbol) { symbol price } }")
+ Stream onPrice(@Param("symbol") String symbol);
+
+ @GraphqlQuery("subscription($symbol: String!) { priceChanged(symbol: $symbol) { symbol price } }")
+ Flow.Publisher publishPrice(@Param("symbol") String symbol);
+}
+```
+
+The single-event forms close the subscription as soon as they have their event. The multi-event
+forms hand you the lifecycle: closing the `Stream` — or cancelling the `Flow.Subscription` — sends
+`complete` and closes the WebSocket, so consume a `Stream` with try-with-resources:
+
+```java
+try (var prices = api.onPrice("ACME")) {
+ prices.forEach(System.out::println);
+}
+```
+
+`Stream` here is the ordinary `java.util.stream.Stream`: synchronous and pull-based, with no timeout
+facilities of its own. So the blocking forms — `T`, `Optional` and `Stream` — are bounded by
+an event timeout, which defaults to **60 seconds** and applies to each event rather than to the
+subscription as a whole. Override it when creating the capability:
+
+```java
+Feign.builder()
+ // wait at most 5s for each event; Duration.ZERO waits indefinitely
+ .addCapability(new GraphqlCapability(new JacksonCodec(), Duration.ofSeconds(5)))
+ .target(StockApi.class, "https://example.com/graphql");
+```
+
+Exceeding it raises `SocketTimeoutException` from the blocking call or the stream element. A
+subscription that can legitimately sit idle for longer needs `Duration.ZERO`.
+
+`Flow.Publisher` and `CompletableFuture` are deliberately *not* bounded by it — their caller
+already owns the deadline, via cancelling the subscription or
+`get(timeout, unit)`/`orTimeout(...)`. Cancelling either one closes the underlying WebSocket.
+
+Those two asynchronous forms each need a worker for as long as the subscription is open. They run on
+a bounded daemon pool by default; pass your own to own the lifecycle:
+
+```java
+new GraphqlCapability(new JacksonCodec(), Duration.ofSeconds(5), myExecutor)
+```
+
+Reads are demand-driven — the client asks the socket for another frame only once the consumer has
+taken the previous event — so a slow consumer applies backpressure to the server instead of growing
+a queue in memory.
+
+`Flow.Publisher` is `java.util.concurrent.Flow.Publisher`, so it plugs into Reactor
+(`JdkFlowAdapter.flowPublisherToFlux`) or RxJava (`Flowable.fromPublisher`) without extra
+dependencies here.
+
+A server `error` message, or `errors` inside a payload, is raised as `GraphqlErrorException`.
+Request headers (for example `Authorization`) are forwarded to the WebSocket handshake.
+
## Custom Scalars
When your schema defines custom scalars, map them to Java types using `@Scalar` on default methods:
@@ -132,6 +215,40 @@ The processor maps `DateTime` fields to `java.time.Instant` in the generated rec
public record Event(String id, String name, Instant startTime) {}
```
+## Multiple Root Fields
+
+A single operation can select more than one root field, and all of them are decoded — one round trip instead of one call per field:
+
+```java
+@GraphqlQuery("""
+ query authorPage($authorId: ID!) {
+ books(authorId: $authorId) { id title }
+ reviews(authorId: $authorId) { id rating }
+ }
+ """)
+AuthorPage authorPage(String authorId);
+```
+
+The processor generates a record with one component per root field, each with its own inner record:
+
+```java
+public record AuthorPage(Optional> books, Optional> reviews) {
+
+ public record Books(String id, String title) {}
+
+ public record Reviews(String id, Integer rating) {}
+
+}
+```
+
+When the response carries several root fields the whole `data` map binds to the return type, so every field lands on its matching component:
+
+```json
+{"data": {"books": [...], "reviews": [...]}}
+```
+
+Operations with a single root field are unaffected: that field is still unwrapped and decoded into the return type directly.
+
## Single Result from Array Queries
When a GraphQL query returns an array type (e.g. `[User!]`) but the Java method declares a single return type, the decoder automatically unwraps the first element:
diff --git a/graphql/src/main/java/feign/graphql/GraphqlCapability.java b/graphql/src/main/java/feign/graphql/GraphqlCapability.java
index 9ace756bd0..3b0970e55e 100644
--- a/graphql/src/main/java/feign/graphql/GraphqlCapability.java
+++ b/graphql/src/main/java/feign/graphql/GraphqlCapability.java
@@ -16,6 +16,7 @@
package feign.graphql;
import feign.Capability;
+import feign.Client;
import feign.Contract;
import feign.Experimental;
import feign.RequestInterceptors;
@@ -24,7 +25,11 @@
import feign.codec.JsonCodec;
import feign.codec.JsonDecoder;
import feign.codec.JsonEncoder;
+import java.time.Duration;
import java.util.ArrayList;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicLong;
@Experimental
public class GraphqlCapability implements Capability {
@@ -33,15 +38,70 @@ public class GraphqlCapability implements Capability {
private final GraphqlEncoder graphqlEncoder;
private final GraphqlDecoder graphqlDecoder;
private final GraphqlRequestInterceptor interceptor;
+ private final JsonEncoder jsonEncoder;
+ private final JsonDecoder jsonDecoder;
public GraphqlCapability(JsonCodec codec) {
this(codec.encoder(), codec.decoder());
}
+ /**
+ * @param eventTimeout how long a blocking subscription call waits for an event before failing
+ * with {@link java.net.SocketTimeoutException}; {@link java.time.Duration#ZERO} waits
+ * indefinitely. Does not apply to {@code Flow.Publisher} or {@code CompletableFuture}
+ * subscriptions, whose caller owns the deadline.
+ */
+ public GraphqlCapability(JsonCodec codec, Duration eventTimeout) {
+ this(codec.encoder(), codec.decoder(), eventTimeout);
+ }
+
+ /**
+ * @param executor runs the worker behind each {@code Flow.Publisher} and {@code
+ * CompletableFuture} subscription, and delivers to their subscribers. Supply your own to own
+ * the lifecycle; the default is bounded and daemon, and is never shut down.
+ */
+ public GraphqlCapability(JsonCodec codec, Duration eventTimeout, Executor executor) {
+ this(codec.encoder(), codec.decoder(), eventTimeout, executor);
+ }
+
public GraphqlCapability(JsonEncoder encoder, JsonDecoder decoder) {
+ this(encoder, decoder, GraphqlDecoder.DEFAULT_EVENT_TIMEOUT);
+ }
+
+ /**
+ * @param eventTimeout see {@link #GraphqlCapability(JsonCodec, Duration)}
+ */
+ public GraphqlCapability(JsonEncoder encoder, JsonDecoder decoder, Duration eventTimeout) {
+ this(encoder, decoder, eventTimeout, defaultExecutor());
+ }
+
+ /**
+ * @param executor see {@link #GraphqlCapability(JsonCodec, Duration, Executor)}
+ */
+ public GraphqlCapability(
+ JsonEncoder encoder, JsonDecoder decoder, Duration eventTimeout, Executor executor) {
this.graphqlEncoder = new GraphqlEncoder(encoder, contract);
- this.graphqlDecoder = new GraphqlDecoder(decoder);
+ this.graphqlDecoder = new GraphqlDecoder(decoder, eventTimeout, executor);
this.interceptor = new GraphqlRequestInterceptor(encoder, contract);
+ this.jsonEncoder = encoder;
+ this.jsonDecoder = decoder;
+ }
+
+ /**
+ * Each open {@code Flow.Publisher} or {@code CompletableFuture} subscription holds one worker for
+ * its lifetime, so the default pool grows on demand and reaps idle threads rather than capping
+ * concurrent subscriptions at a guess. Supply a bounded executor to cap them deliberately: the
+ * excess is refused with {@code RejectedExecutionException} rather than left hanging.
+ */
+ private static Executor defaultExecutor() {
+ var threads = new AtomicLong();
+ return Executors.newCachedThreadPool(
+ runnable -> {
+ var thread =
+ new Thread(runnable, "feign-graphql-subscription-" + threads.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ });
}
@Override
@@ -59,6 +119,11 @@ public Decoder enrich(Decoder decoder) {
return graphqlDecoder;
}
+ @Override
+ public Client enrich(Client client) {
+ return new GraphqlSubscriptionClient(client, contract, jsonEncoder, jsonDecoder);
+ }
+
@Override
public RequestInterceptors enrich(RequestInterceptors requestInterceptors) {
var enriched = new ArrayList<>(requestInterceptors.interceptors());
diff --git a/graphql/src/main/java/feign/graphql/GraphqlContract.java b/graphql/src/main/java/feign/graphql/GraphqlContract.java
index cb77db3c21..c5f3988346 100644
--- a/graphql/src/main/java/feign/graphql/GraphqlContract.java
+++ b/graphql/src/main/java/feign/graphql/GraphqlContract.java
@@ -31,6 +31,8 @@ public class GraphqlContract extends DefaultContract {
private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\s*(\\w+)\\s*:");
+ private static final Pattern SUBSCRIPTION_PATTERN = Pattern.compile("^\\s*subscription\\b");
+
private final Map metadata = new ConcurrentHashMap<>();
public GraphqlContract() {
@@ -45,7 +47,8 @@ public GraphqlContract() {
}
var variableName = extractFirstVariable(query);
- metadata.put(data.configKey(), new QueryMetadata(query, variableName));
+ metadata.put(
+ data.configKey(), new QueryMetadata(query, variableName, isSubscription(query)));
});
}
@@ -97,13 +100,19 @@ static String extractFirstVariable(String query) {
return null;
}
+ static boolean isSubscription(String query) {
+ return SUBSCRIPTION_PATTERN.matcher(query).find();
+ }
+
static class QueryMetadata {
final String query;
final String variableName;
+ final boolean subscription;
- QueryMetadata(String query, String variableName) {
+ QueryMetadata(String query, String variableName, boolean subscription) {
this.query = query;
this.variableName = variableName;
+ this.subscription = subscription;
}
}
}
diff --git a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java
index 78d8d25efe..353d84fd3c 100644
--- a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java
+++ b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java
@@ -21,25 +21,53 @@
import feign.Util;
import feign.codec.Decoder;
import feign.codec.JsonDecoder;
+import feign.graphql.GraphqlSubscriptionClient.Subscription;
import java.io.IOException;
+import java.io.UncheckedIOException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
+import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Flow;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.SubmissionPublisher;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Stream;
@Experimental
public class GraphqlDecoder implements Decoder {
+ /** How long a blocking subscription call waits for an event before giving up. */
+ public static final Duration DEFAULT_EVENT_TIMEOUT = Duration.ofSeconds(60);
+
private final JsonDecoder jsonDecoder;
+ private final long eventTimeoutMillis;
+ private final Executor executor;
public GraphqlDecoder(JsonDecoder jsonDecoder) {
+ this(jsonDecoder, DEFAULT_EVENT_TIMEOUT, Runnable::run);
+ }
+
+ public GraphqlDecoder(JsonDecoder jsonDecoder, Duration eventTimeout, Executor executor) {
+ if (eventTimeout.isNegative()) {
+ throw new IllegalArgumentException("eventTimeout must not be negative: " + eventTimeout);
+ }
this.jsonDecoder = jsonDecoder;
+ this.eventTimeoutMillis = eventTimeout.toMillis();
+ this.executor = executor;
}
@Override
public Object decode(Response response, Type type) throws IOException {
+ if (response.body() instanceof Subscription subscription) {
+ return subscribe(subscription, type);
+ }
+
Type targetType = type;
boolean optional = isOptionalType(type);
if (optional) {
@@ -67,11 +95,16 @@ private Object doDecode(Response response, Type type) throws IOException {
return Util.emptyValueOf(type);
}
+ return unwrap(root, type, response.status(), response.request());
+ }
+
+ @SuppressWarnings("unchecked")
+ private Object unwrap(Map root, Type type, int status, Request request)
+ throws IOException {
var errors = root.get("errors");
if (errors instanceof List> errorList && !errorList.isEmpty()) {
- var operationField = resolveOperationField(root, response);
- throw new GraphqlErrorException(
- response.status(), operationField, errors.toString(), response.request());
+ var operationField = resolveOperationField(root, request);
+ throw new GraphqlErrorException(status, operationField, errors.toString(), request);
}
var data = root.get("data");
@@ -80,13 +113,17 @@ private Object doDecode(Response response, Type type) throws IOException {
}
var dataMap = (Map) data;
- var fieldNames = dataMap.keySet().iterator();
- if (!fieldNames.hasNext()) {
+ if (dataMap.isEmpty()) {
return Util.emptyValueOf(type);
}
- var firstField = fieldNames.next();
- var operationData = dataMap.get(firstField);
+ // A single root field is the operation result itself; several root fields are its components,
+ // so the whole data map binds to the return type and no field gets dropped.
+ if (dataMap.size() > 1) {
+ return jsonDecoder.convert(dataMap, type);
+ }
+
+ var operationData = dataMap.values().iterator().next();
if (operationData == null) {
return Util.emptyValueOf(type);
}
@@ -102,7 +139,7 @@ private Object doDecode(Response response, Type type) throws IOException {
}
@SuppressWarnings("unchecked")
- private String resolveOperationField(Map root, Response response) {
+ private String resolveOperationField(Map root, Request request) {
var data = root.get("data");
if (data instanceof Map) {
var dataMap = (Map) data;
@@ -112,14 +149,14 @@ private String resolveOperationField(Map root, Response response
}
}
- if (response.request() != null && response.request().body().isPresent()) {
+ if (request != null && request.body().isPresent()) {
try {
var fakeResponse =
Response.builder()
.status(200)
.headers(Collections.emptyMap())
- .request(response.request())
- .body(bodyAsByteArray(response.request()))
+ .request(request)
+ .body(bodyAsByteArray(request))
.build();
var requestBody = (Map) jsonDecoder.decode(fakeResponse, Map.class);
if (requestBody != null) {
@@ -142,6 +179,118 @@ private byte[] bodyAsByteArray(Request request) throws IOException {
return body.isPresent() ? body.get().writeToByteArray() : null;
}
+ /**
+ * The return type picks the semantics: {@code Stream} blocks on every element and {@code
+ * Flow.Publisher} pushes them, while {@code T} and {@code Optional} block for the first
+ * event only and {@code CompletableFuture} delivers that first event asynchronously. Every
+ * single-value form unsubscribes as soon as it has its event.
+ *
+ * The blocking forms are bounded by the configured event timeout; the asynchronous ones are
+ * not, since their caller already owns the deadline.
+ */
+ private Object subscribe(Subscription subscription, Type type) {
+ subscription.detach();
+
+ if (isRawType(type, Stream.class)) {
+ return elements(subscription, typeArgument(type), eventTimeoutMillis);
+ }
+ if (isRawType(type, Flow.Publisher.class)) {
+ return publish(subscription, typeArgument(type));
+ }
+ if (isRawType(type, CompletableFuture.class)) {
+ return futureOf(subscription, typeArgument(type));
+ }
+ if (isRawType(type, Optional.class)) {
+ return first(subscription, typeArgument(type), eventTimeoutMillis);
+ }
+ return first(subscription, type, eventTimeoutMillis).orElseGet(() -> Util.emptyValueOf(type));
+ }
+
+ private Optional first(Subscription subscription, Type elementType, long timeoutMillis) {
+ try (var elements = elements(subscription, elementType, timeoutMillis)) {
+ return elements.findFirst();
+ }
+ }
+
+ private CompletableFuture futureOf(Subscription subscription, Type elementType) {
+ var future = new CompletableFuture<>();
+ // Cancelling must reach the socket, or a cancelled future leaks the connection and its worker.
+ future.whenComplete(
+ (ignored, error) -> {
+ if (future.isCancelled()) {
+ subscription.unsubscribe();
+ }
+ });
+ try {
+ executor.execute(
+ () -> {
+ try {
+ future.complete(first(subscription, elementType, 0).orElse(null));
+ } catch (Throwable e) {
+ future.completeExceptionally(e);
+ }
+ });
+ } catch (RejectedExecutionException e) {
+ subscription.unsubscribe();
+ future.completeExceptionally(e);
+ }
+ return future;
+ }
+
+ private Stream elements(Subscription subscription, Type elementType, long timeoutMillis) {
+ return subscription
+ .payloads(timeoutMillis)
+ .map(
+ payload -> {
+ try {
+ return unwrap(payload, elementType, 200, subscription.request());
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ });
+ }
+
+ private Flow.Publisher publish(Subscription subscription, Type elementType) {
+ // Runnable::run delivers on the pump thread: one worker per subscription in total, delivery can
+ // never be rejected by a busy pool, and onNext is inherently ordered.
+ var publisher = new SubmissionPublisher<>(Runnable::run, Flow.defaultBufferSize());
+ var started = new AtomicBoolean();
+ // Pumping starts on the first subscribe, so hasSubscribers() is meaningful from the first
+ // element onwards and there is no pre-subscribe window to latch around.
+ return subscriber -> {
+ publisher.subscribe(subscriber);
+ if (!started.compareAndSet(false, true)) {
+ return;
+ }
+ try {
+ executor.execute(
+ () -> {
+ try (var elements = elements(subscription, elementType, 0)) {
+ var iterator = elements.iterator();
+ while (iterator.hasNext() && publisher.hasSubscribers()) {
+ publisher.submit(iterator.next());
+ }
+ publisher.close();
+ } catch (Throwable e) {
+ publisher.closeExceptionally(e);
+ }
+ });
+ } catch (RejectedExecutionException e) {
+ // A subscriber must always get a terminal signal; stranding it is worse than failing it.
+ subscription.unsubscribe();
+ publisher.closeExceptionally(e);
+ }
+ };
+ }
+
+ private static boolean isRawType(Type type, Class> raw) {
+ return type instanceof ParameterizedType pt && pt.getRawType() == raw;
+ }
+
+ private static Type typeArgument(Type type) {
+ return ((ParameterizedType) type).getActualTypeArguments()[0];
+ }
+
private boolean isOptionalType(Type type) {
if (type instanceof ParameterizedType pt && pt.getRawType() instanceof Class> cls) {
return cls == Optional.class;
diff --git a/graphql/src/main/java/feign/graphql/GraphqlSubscriptionClient.java b/graphql/src/main/java/feign/graphql/GraphqlSubscriptionClient.java
new file mode 100644
index 0000000000..5248752ade
--- /dev/null
+++ b/graphql/src/main/java/feign/graphql/GraphqlSubscriptionClient.java
@@ -0,0 +1,509 @@
+/*
+ * 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.graphql;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import feign.Client;
+import feign.Experimental;
+import feign.Request;
+import feign.RequestTemplate;
+import feign.Response;
+import feign.Util;
+import feign.codec.JsonDecoder;
+import feign.codec.JsonEncoder;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.io.Reader;
+import java.io.UncheckedIOException;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.nio.charset.Charset;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Locale;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Spliterator;
+import java.util.Spliterators;
+import java.util.UUID;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+/**
+ * Executes {@code subscription} operations over the graphql-transport-ws
+ * WebSocket protocol, delegating every other request to the wrapped {@link Client}.
+ *
+ * The endpoint is the target URL with its scheme swapped to {@code ws}/{@code wss}. One
+ * WebSocket connection is opened per subscription call and is closed when the returned {@code
+ * Stream} or {@code Flow.Publisher} is closed/cancelled.
+ */
+@Experimental
+public class GraphqlSubscriptionClient implements Client {
+
+ private final Client delegate;
+ private final GraphqlContract contract;
+ private final JsonEncoder jsonEncoder;
+ private final JsonDecoder jsonDecoder;
+ private final HttpClient httpClient;
+
+ public GraphqlSubscriptionClient(
+ Client delegate, GraphqlContract contract, JsonEncoder encoder, JsonDecoder decoder) {
+ this(delegate, contract, encoder, decoder, HttpClient.newHttpClient());
+ }
+
+ public GraphqlSubscriptionClient(
+ Client delegate,
+ GraphqlContract contract,
+ JsonEncoder encoder,
+ JsonDecoder decoder,
+ HttpClient httpClient) {
+ this.delegate = delegate;
+ this.contract = contract;
+ this.jsonEncoder = encoder;
+ this.jsonDecoder = decoder;
+ this.httpClient = httpClient;
+ }
+
+ @Override
+ public Response execute(Request request, Request.Options options) throws IOException {
+ var meta =
+ request.requestTemplate() == null
+ ? null
+ : contract.lookupMetadata(request.requestTemplate());
+ if (meta == null || !meta.subscription) {
+ return delegate.execute(request, options);
+ }
+ return subscribe(request, options, meta);
+ }
+
+ private Response subscribe(
+ Request request, Request.Options options, GraphqlContract.QueryMetadata meta)
+ throws IOException {
+ var subscription = new Subscription(request, meta, jsonEncoder, jsonDecoder);
+
+ var builder = httpClient.newWebSocketBuilder().subprotocols("graphql-transport-ws");
+ if (options != null && options.connectTimeoutMillis() > 0) {
+ builder.connectTimeout(Duration.ofMillis(options.connectTimeoutMillis()));
+ }
+ request
+ .headers()
+ .forEach(
+ (name, values) -> {
+ if (isForwardable(name)) {
+ values.forEach(value -> builder.header(name, value));
+ }
+ });
+
+ try {
+ subscription.attach(builder.buildAsync(webSocketUri(request.url()), subscription).join());
+ } catch (CompletionException e) {
+ var cause = e.getCause() == null ? e : e.getCause();
+ throw new IOException("failed to open GraphQL subscription to " + request.url(), cause);
+ }
+
+ // 204 keeps feign's logger from draining and replacing the body, which would drop the live
+ // subscription. Nothing here ever crosses the wire.
+ return Response.builder()
+ .status(HttpURLConnection.HTTP_NO_CONTENT)
+ .reason("Subscribed")
+ .request(request)
+ .headers(Collections.emptyMap())
+ .body(subscription)
+ .build();
+ }
+
+ /** Headers the JDK WebSocket handshake rejects or manages itself. */
+ private static boolean isForwardable(String header) {
+ var name = header.toLowerCase(Locale.ROOT);
+ return !name.equals("connection")
+ && !name.equals("upgrade")
+ && !name.equals("host")
+ && !name.equals("content-type")
+ && !name.equalsIgnoreCase(Util.CONTENT_LENGTH)
+ && !name.startsWith("sec-websocket-");
+ }
+
+ static URI webSocketUri(String url) {
+ var uri = URI.create(url);
+ var scheme = "https".equalsIgnoreCase(uri.getScheme()) ? "wss" : "ws";
+ return URI.create(scheme + url.substring(url.indexOf(':')));
+ }
+
+ /**
+ * The messages this client sends, one record per wire shape rather than per type: the
+ * configured {@link JsonEncoder} writes every component, so a shape carrying a component the
+ * protocol does not define for that message would send it as null.
+ */
+ sealed interface ClientMessage {
+
+ /** A bare type, covering {@code connection_init} and {@code pong}. */
+ record Control(String type) implements ClientMessage {}
+
+ /** A reference to a running operation. */
+ record Complete(String id, String type) implements ClientMessage {}
+
+ /** A reference to an operation plus the request that starts it. */
+ record Subscribe(String id, String type, Operation payload) implements ClientMessage {}
+ }
+
+ /** The GraphQL request a subscription starts, as feign already encoded it into the body. */
+ record Operation(String query, Map variables) {}
+
+ /**
+ * The envelope of a server message. Carries every component graphql-transport-ws defines, so a
+ * strict mapper has nothing unknown to reject.
+ *
+ * @param payload stays untyped: {@code next} carries a {@code {data, errors}} object while {@code
+ * error} carries a list of errors.
+ */
+ record ServerMessage(String id, String type, Object payload) {
+
+ @SuppressWarnings("unchecked")
+ Map payloadFields() {
+ return payload instanceof Map, ?> fields ? (Map) fields : Map.of();
+ }
+ }
+
+ /**
+ * A live subscription: the WebSocket listener, the queue of decoded {@code next} payloads and the
+ * {@link Response.Body} handed to {@link GraphqlDecoder} all in one, because they share a
+ * lifecycle.
+ */
+ static final class Subscription implements WebSocket.Listener, Response.Body {
+
+ private static final Object DONE = new Object();
+
+ /**
+ * Unique per connection, so a stray message for another operation is never mistaken for ours.
+ */
+ private final String operationId = UUID.randomUUID().toString();
+
+ /** Bounded: demand-driven reads keep this near empty, the capacity is a safety net. */
+ private final BlockingQueue events = new LinkedBlockingQueue<>(1024);
+
+ private final StringBuilder partial = new StringBuilder();
+ private final Request request;
+ private final GraphqlContract.QueryMetadata meta;
+ private final JsonEncoder jsonEncoder;
+ private final JsonDecoder jsonDecoder;
+
+ /**
+ * The already-encoded request body, decoded back so it can be sent as the subscribe payload.
+ */
+ private final Operation operation;
+
+ private final AtomicBoolean detached = new AtomicBoolean();
+ private final AtomicBoolean unsubscribed = new AtomicBoolean();
+
+ private volatile WebSocket webSocket;
+ private CompletableFuture> sends = CompletableFuture.completedFuture(null);
+
+ Subscription(
+ Request request,
+ GraphqlContract.QueryMetadata meta,
+ JsonEncoder jsonEncoder,
+ JsonDecoder jsonDecoder)
+ throws IOException {
+ this.request = request;
+ this.meta = meta;
+ this.jsonEncoder = jsonEncoder;
+ this.jsonDecoder = jsonDecoder;
+
+ this.operation =
+ request.body().isEmpty()
+ ? new Operation(meta.query, Map.of())
+ : decode(request.body().get().writeToString(UTF_8), Operation.class);
+ }
+
+ void attach(WebSocket webSocket) {
+ this.webSocket = webSocket;
+ }
+
+ /**
+ * Hands the subscription lifecycle to the decoder, so feign closing the response body right
+ * after decoding no longer tears it down.
+ */
+ void detach() {
+ detached.set(true);
+ }
+
+ Request request() {
+ return request;
+ }
+
+ /**
+ * Blocking stream of raw {@code {data, errors}} payloads, one per {@code next} message.
+ *
+ * @param timeoutMillis how long to wait for each event; {@code 0} waits indefinitely
+ */
+ Stream> payloads(long timeoutMillis) {
+ return StreamSupport.stream(
+ Spliterators.spliteratorUnknownSize(
+ new PayloadIterator(timeoutMillis), Spliterator.ORDERED),
+ false)
+ .onClose(this::unsubscribe);
+ }
+
+ @Override
+ public void onOpen(WebSocket ws) {
+ send(ws, new ClientMessage.Control("connection_init"));
+ ws.request(1);
+ }
+
+ @Override
+ public CompletionStage> onText(WebSocket ws, CharSequence data, boolean last) {
+ partial.append(data);
+ if (!last) {
+ ws.request(1);
+ return null;
+ }
+ var text = partial.toString();
+ partial.setLength(0);
+ // Only control frames pull the next one eagerly. A queued payload waits for the consumer to
+ // take it, which is what bounds the queue.
+ if (!handle(ws, text)) {
+ ws.request(1);
+ }
+ return null;
+ }
+
+ @Override
+ public void onError(WebSocket ws, Throwable error) {
+ publish(error);
+ }
+
+ @Override
+ public CompletionStage> onClose(WebSocket ws, int statusCode, String reason) {
+ publish(DONE);
+ return null;
+ }
+
+ /**
+ * @return true when this message queued an event, so the next frame waits for the consumer.
+ */
+ private boolean handle(WebSocket ws, String text) {
+ ServerMessage message;
+ try {
+ message = decode(text, ServerMessage.class);
+ } catch (IOException | RuntimeException e) {
+ return publish(e);
+ }
+ if (message == null || (message.id() != null && !message.id().equals(operationId))) {
+ return false;
+ }
+
+ return switch (message.type()) {
+ case "connection_ack" -> {
+ send(ws, new ClientMessage.Subscribe(operationId, "subscribe", operation));
+ yield false;
+ }
+ case "next" -> publish(message.payloadFields());
+ case "error" ->
+ publish(
+ new GraphqlErrorException(
+ HttpURLConnection.HTTP_OK,
+ GraphqlContract.extractOperationField(meta.query),
+ String.valueOf(message.payload()),
+ request));
+ case "complete" -> publish(DONE);
+ case "ping" -> {
+ send(ws, new ClientMessage.Control("pong"));
+ yield false;
+ }
+ default -> false;
+ };
+ }
+
+ private boolean publish(Object event) {
+ if (!events.offer(event)) {
+ // Unreachable while reads are demand-driven; failing loudly beats growing without bound.
+ events.clear();
+ events.offer(new IllegalStateException("GraphQL subscription event queue overflowed"));
+ }
+ return true;
+ }
+
+ private T decode(String json, Class type) throws IOException {
+ var envelope =
+ Response.builder()
+ .status(HttpURLConnection.HTTP_OK)
+ .headers(Collections.emptyMap())
+ .request(request)
+ .body(json, UTF_8)
+ .build();
+ return type.cast(jsonDecoder.decode(envelope, type));
+ }
+
+ /**
+ * Sends are serialized: the JDK rejects a send while another is still in flight. A failure is
+ * surfaced to the consumer and the chain reset, so it cannot silently swallow later sends.
+ */
+ private synchronized void send(WebSocket ws, ClientMessage message) {
+ var json = toJson(message);
+ sends =
+ sends
+ .thenCompose(ignored -> ws.sendText(json, true))
+ .handle(
+ (ignored, error) -> {
+ if (error != null) {
+ publish(error);
+ }
+ return null;
+ });
+ }
+
+ private String toJson(ClientMessage message) {
+ var template = new RequestTemplate();
+ jsonEncoder.encode(message, message.getClass(), template);
+ try {
+ return template.requestBody().get().writeToString(UTF_8);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ void unsubscribe() {
+ if (!unsubscribed.compareAndSet(false, true)) {
+ return;
+ }
+ var ws = webSocket;
+ publish(DONE);
+ if (ws == null) {
+ return;
+ }
+ ws.request(1); // let the closing handshake be delivered
+ synchronized (this) {
+ send(ws, new ClientMessage.Complete(operationId, "complete"));
+ // whenComplete, not thenRun: the socket must close even if an earlier send failed.
+ sends.whenComplete((ignored, error) -> ws.sendClose(WebSocket.NORMAL_CLOSURE, ""));
+ }
+ }
+
+ @Override
+ public Integer length() {
+ return null;
+ }
+
+ @Override
+ public boolean isRepeatable() {
+ return false;
+ }
+
+ @Override
+ public InputStream asInputStream() {
+ return InputStream.nullInputStream();
+ }
+
+ @Override
+ public Reader asReader(Charset charset) {
+ return Reader.nullReader();
+ }
+
+ /**
+ * Feign closes the response body right after decoding, which for a detached subscription is a
+ * no-op — the caller owns it from there. Still attached means the decoder never took ownership
+ * (a {@code void} method, say), so the socket is closed here rather than leaked.
+ */
+ @Override
+ public void close() {
+ if (!detached.get()) {
+ unsubscribe();
+ }
+ }
+
+ private final class PayloadIterator implements Iterator> {
+
+ private final long timeoutMillis;
+
+ private Object pending;
+
+ PayloadIterator(long timeoutMillis) {
+ this.timeoutMillis = timeoutMillis;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public boolean hasNext() {
+ if (pending == null) {
+ pending = take();
+ }
+ if (pending instanceof Throwable error) {
+ pending = DONE;
+ throw asUnchecked(error);
+ }
+ return pending != DONE;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public Map next() {
+ if (!hasNext()) {
+ throw new NoSuchElementException();
+ }
+ var payload = (Map) pending;
+ pending = null;
+ return payload;
+ }
+
+ private Object take() {
+ try {
+ var event =
+ timeoutMillis <= 0
+ ? events.take()
+ : events.poll(timeoutMillis, TimeUnit.MILLISECONDS);
+ if (event == null) {
+ return new SocketTimeoutException(
+ "no GraphQL subscription event within " + timeoutMillis + "ms");
+ }
+ if (event != DONE) {
+ var ws = webSocket;
+ if (ws != null) {
+ ws.request(1); // consuming an event is what authorises the next read
+ }
+ }
+ return event;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return new InterruptedIOException("interrupted awaiting a GraphQL subscription event");
+ }
+ }
+ }
+
+ private static RuntimeException asUnchecked(Throwable error) {
+ if (error instanceof RuntimeException runtime) {
+ return runtime;
+ }
+ if (error instanceof IOException io) {
+ return new UncheckedIOException(io);
+ }
+ return new IllegalStateException(error);
+ }
+ }
+}
diff --git a/graphql/src/test/java/feign/graphql/GraphqlClientTest.java b/graphql/src/test/java/feign/graphql/GraphqlClientTest.java
index 72fb9fa6ed..d73549cc54 100644
--- a/graphql/src/test/java/feign/graphql/GraphqlClientTest.java
+++ b/graphql/src/test/java/feign/graphql/GraphqlClientTest.java
@@ -55,6 +55,12 @@ public static class CreateUserResult {
public String name;
}
+ public record Book(String id, String title) {}
+
+ public record Review(String id, Integer rating) {}
+
+ public record AuthorPage(List books, List reviews) {}
+
@Headers("Content-Type: application/json")
interface TestApi {
@@ -78,6 +84,12 @@ interface TestApi {
@GraphqlQuery("query topUser($limit: Int!) {" + " topUsers(limit: $limit) { id name email } }")
User topUser(int limit);
+
+ @GraphqlQuery(
+ "query authorPage($authorId: ID!) {"
+ + " books(authorId: $authorId) { id title }"
+ + " reviews(authorId: $authorId) { id rating } }")
+ AuthorPage authorPage(String authorId);
}
@BeforeEach
@@ -222,6 +234,28 @@ void optionalReturnTypeEmptyWhenNull() throws Exception {
assertThat(user).isEmpty();
}
+ @Test
+ void multipleRootFieldsDecodedIntoSingleResult() throws Exception {
+ server.enqueue(
+ new MockResponse.Builder()
+ .body(
+ "{\"data\":{\"books\":[{\"id\":\"1\",\"title\":\"Dune\"}],"
+ + "\"reviews\":[{\"id\":\"9\",\"rating\":5}]}}")
+ .addHeader("Content-Type", "application/json")
+ .build());
+
+ var page = buildClient().authorPage("42");
+
+ assertThat(page.books()).hasSize(1);
+ assertThat(page.books().getFirst().title()).isEqualTo("Dune");
+ assertThat(page.reviews()).hasSize(1);
+ assertThat(page.reviews().getFirst().rating()).isEqualTo(5);
+
+ var recorded = server.takeRequest();
+ var body = mapper.readTree(recorded.getBody().utf8());
+ assertThat(body.get("variables").get("authorId").asText()).isEqualTo("42");
+ }
+
@Test
void authHeaderPassedThrough() throws Exception {
server.enqueue(
diff --git a/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java b/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java
index 3f94947818..29563b68fd 100644
--- a/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java
+++ b/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java
@@ -53,6 +53,14 @@ public record UserWithAddress(String id, Optional address) {}
public record DeeplyNested(String value, Optional nested) {}
+ public record Book(String id, String title) {}
+
+ public record Review(String id, Integer rating) {}
+
+ public record AuthorPage(List books, List reviews) {}
+
+ public record MixedPage(User getUser, List books) {}
+
@Test
void decodesDataField() throws Exception {
var json = "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Alice\"}}}";
@@ -320,6 +328,69 @@ void returnsEmptyListForNullOperationDataWithListType() throws Exception {
assertThat(result).isEmpty();
}
+ @Test
+ void decodesMultipleRootFieldsIntoRecord() throws Exception {
+ var json =
+ "{\"data\":{\"books\":[{\"id\":\"1\",\"title\":\"Dune\"}],"
+ + "\"reviews\":[{\"id\":\"9\",\"rating\":5}]}}";
+ var response = buildResponse(json);
+
+ var page = (AuthorPage) decoder.decode(response, AuthorPage.class);
+
+ assertThat(page.books()).hasSize(1);
+ assertThat(page.books().getFirst().title()).isEqualTo("Dune");
+ assertThat(page.reviews()).hasSize(1);
+ assertThat(page.reviews().getFirst().rating()).isEqualTo(5);
+ }
+
+ @Test
+ void decodesMultipleRootFieldsOfDifferentShapes() throws Exception {
+ var json =
+ "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Alice\"},"
+ + "\"books\":[{\"id\":\"1\",\"title\":\"Dune\"}]}}";
+ var response = buildResponse(json);
+
+ var page = (MixedPage) decoder.decode(response, MixedPage.class);
+
+ assertThat(page.getUser().name).isEqualTo("Alice");
+ assertThat(page.books()).hasSize(1);
+ }
+
+ @Test
+ void keepsNullRootFieldWhenDecodingMultipleRootFields() throws Exception {
+ var json = "{\"data\":{\"books\":[{\"id\":\"1\",\"title\":\"Dune\"}],\"reviews\":null}}";
+ var response = buildResponse(json);
+
+ var page = (AuthorPage) decoder.decode(response, AuthorPage.class);
+
+ assertThat(page.books()).hasSize(1);
+ assertThat(page.reviews()).isNull();
+ }
+
+ @Test
+ void decodesMultipleRootFieldsIntoOptional() throws Exception {
+ var json =
+ "{\"data\":{\"books\":[{\"id\":\"1\",\"title\":\"Dune\"}],"
+ + "\"reviews\":[{\"id\":\"9\",\"rating\":5}]}}";
+ var response = buildResponse(json);
+
+ @SuppressWarnings("unchecked")
+ var page = (Optional) decoder.decode(response, optionalOf(AuthorPage.class));
+
+ assertThat(page).isPresent();
+ assertThat(page.get().reviews()).hasSize(1);
+ }
+
+ @Test
+ void throwsGraphqlErrorExceptionOnErrorsWithMultipleRootFields() {
+ var json = "{\"errors\":[{\"message\":\"Boom\"}],\"data\":{\"books\":null,\"reviews\":null}}";
+ var response = buildResponse(json);
+
+ assertThatThrownBy(() -> decoder.decode(response, AuthorPage.class))
+ .isInstanceOf(GraphqlErrorException.class)
+ .hasMessageContaining("Boom");
+ }
+
private Response buildResponse(String body) {
return Response.builder()
.status(200)
diff --git a/graphql/src/test/java/feign/graphql/GraphqlSubscriptionConcurrencyTest.java b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionConcurrencyTest.java
new file mode 100644
index 0000000000..135ca2fc37
--- /dev/null
+++ b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionConcurrencyTest.java
@@ -0,0 +1,390 @@
+/*
+ * 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.graphql;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import feign.Feign;
+import feign.jackson.JacksonCodec;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Flow;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import mockwebserver3.Dispatcher;
+import mockwebserver3.MockResponse;
+import mockwebserver3.MockWebServer;
+import mockwebserver3.RecordedRequest;
+import okhttp3.Response;
+import okhttp3.WebSocket;
+import okhttp3.WebSocketListener;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Exercises the subscription wiring under concurrent load: many sockets open at once, sharing one
+ * capability, one JSON codec and one worker pool.
+ */
+class GraphqlSubscriptionConcurrencyTest {
+
+ private static final int SUBSCRIPTIONS = 24;
+ private static final int EVENTS_EACH = 20;
+
+ private final ObjectMapper mapper =
+ new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
+ private MockWebServer server;
+
+ /** Counts sockets the client closed, so leaks show up as a shortfall. */
+ private final CountDownLatch closed = new CountDownLatch(SUBSCRIPTIONS);
+
+ private final AtomicInteger openSockets = new AtomicInteger();
+
+ /**
+ * When false the server acknowledges the subscribe and then stays quiet, as a real feed would.
+ */
+ private volatile boolean emitEvents = true;
+
+ public static class Price {
+ public String symbol;
+ public double price;
+ }
+
+ interface StockApi {
+
+ @GraphqlQuery(
+ "subscription onPrice($symbol: String!) {"
+ + " priceChanged(symbol: $symbol) { symbol price } }")
+ Stream onPrice(String symbol);
+
+ @GraphqlQuery(
+ "subscription onPrice($symbol: String!) {"
+ + " priceChanged(symbol: $symbol) { symbol price } }")
+ Flow.Publisher publishPrice(String symbol);
+
+ @GraphqlQuery(
+ "subscription onPrice($symbol: String!) {"
+ + " priceChanged(symbol: $symbol) { symbol price } }")
+ CompletableFuture futurePrice(String symbol);
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ server = new MockWebServer();
+ // A dispatcher rather than a queue: every connection gets its own upgrade and its own listener,
+ // so the subscriptions are genuinely independent sockets.
+ server.setDispatcher(
+ new Dispatcher() {
+ @Override
+ public MockResponse dispatch(RecordedRequest request) {
+ return new MockResponse.Builder().webSocketUpgrade(new EchoingServer()).build();
+ }
+ });
+ server.start();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ server.close();
+ }
+
+ /** Replays the handshake, then emits the requested symbol back with the client's own id. */
+ private final class EchoingServer extends WebSocketListener {
+
+ @Override
+ public void onOpen(WebSocket webSocket, Response response) {
+ openSockets.incrementAndGet();
+ }
+
+ @Override
+ public void onMessage(WebSocket webSocket, String text) {
+ try {
+ var message = mapper.readTree(text);
+ var type = message.get("type").asText();
+ if ("connection_init".equals(type)) {
+ webSocket.send("{\"type\":\"connection_ack\"}");
+ return;
+ }
+ if (!"subscribe".equals(type)) {
+ return;
+ }
+ if (!emitEvents) {
+ return;
+ }
+ var id = message.get("id").asText();
+ var symbol = message.get("payload").get("variables").get("symbol").asText();
+ for (var i = 0; i < EVENTS_EACH; i++) {
+ webSocket.send(
+ "{\"id\":\""
+ + id
+ + "\",\"type\":\"next\",\"payload\":{\"data\":{\"priceChanged\":{\"symbol\":\""
+ + symbol
+ + "\",\"price\":"
+ + i
+ + "}}}}");
+ }
+ webSocket.send("{\"id\":\"" + id + "\",\"type\":\"complete\"}");
+ } catch (Exception e) {
+ throw new IllegalStateException("bad client message: " + text, e);
+ }
+ }
+
+ @Override
+ public void onClosing(WebSocket webSocket, int code, String reason) {
+ webSocket.close(code, reason);
+ closed.countDown();
+ }
+
+ @Override
+ public void onFailure(WebSocket webSocket, Throwable t, Response response) {
+ closed.countDown();
+ }
+ }
+
+ private StockApi buildClient() {
+ return Feign.builder()
+ .addCapability(new GraphqlCapability(new JacksonCodec(mapper), Duration.ofSeconds(30)))
+ .target(StockApi.class, server.url("/graphql").toString());
+ }
+
+ private StockApi buildClient(Executor executor) {
+ return Feign.builder()
+ .addCapability(
+ new GraphqlCapability(new JacksonCodec(mapper), Duration.ofSeconds(30), executor))
+ .target(StockApi.class, server.url("/graphql").toString());
+ }
+
+ private int drain(Flow.Publisher publisher) throws Exception {
+ var delivered = new AtomicInteger();
+ var done = new CountDownLatch(1);
+ publisher.subscribe(
+ new Flow.Subscriber() {
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Price item) {
+ delivered.incrementAndGet();
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ done.countDown();
+ }
+
+ @Override
+ public void onComplete() {
+ done.countDown();
+ }
+ });
+ assertThat(done.await(60, TimeUnit.SECONDS)).isTrue();
+ return delivered.get();
+ }
+
+ private List runAllAtOnce(List> tasks) throws Exception {
+ var pool = Executors.newFixedThreadPool(tasks.size());
+ try {
+ var barrier = new CyclicBarrier(tasks.size());
+ var futures =
+ tasks.stream()
+ .map(
+ task ->
+ pool.submit(
+ () -> {
+ barrier.await(30, TimeUnit.SECONDS);
+ return task.call();
+ }))
+ .toList();
+ var results = new java.util.ArrayList();
+ for (var future : futures) {
+ results.add(future.get(60, TimeUnit.SECONDS));
+ }
+ return results;
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ @Test
+ void concurrentStreamsStayIsolated() throws Exception {
+ var api = buildClient();
+
+ List>> tasks =
+ IntStream.range(0, SUBSCRIPTIONS)
+ .>>mapToObj(
+ index ->
+ () -> {
+ try (var prices = api.onPrice("SYM" + index)) {
+ return prices.toList();
+ }
+ })
+ .toList();
+
+ var results = runAllAtOnce(tasks);
+
+ // Every subscription sees exactly its own events, in order, with nothing from its neighbours.
+ for (var index = 0; index < SUBSCRIPTIONS; index++) {
+ var prices = results.get(index);
+ assertThat(prices).hasSize(EVENTS_EACH);
+ assertThat(prices).extracting(price -> price.symbol).containsOnly("SYM" + index);
+ assertThat(prices)
+ .extracting(price -> price.price)
+ .containsExactlyElementsOf(
+ IntStream.range(0, EVENTS_EACH).mapToObj(i -> (double) i).toList());
+ }
+
+ assertThat(openSockets).hasValue(SUBSCRIPTIONS);
+ assertThat(closed.await(30, TimeUnit.SECONDS))
+ .as("every socket should have been closed, not leaked")
+ .isTrue();
+ }
+
+ @Test
+ void concurrentPublishersDeliverEveryEvent() throws Exception {
+ var api = buildClient();
+
+ List> tasks =
+ IntStream.range(0, SUBSCRIPTIONS)
+ .>mapToObj(
+ index ->
+ () -> {
+ var delivered = new AtomicInteger();
+ var done = new CountDownLatch(1);
+ api.publishPrice("SYM" + index)
+ .subscribe(
+ new Flow.Subscriber() {
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Price item) {
+ delivered.incrementAndGet();
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ done.countDown();
+ }
+
+ @Override
+ public void onComplete() {
+ done.countDown();
+ }
+ });
+ assertThat(done.await(60, TimeUnit.SECONDS)).isTrue();
+ return delivered.get();
+ })
+ .toList();
+
+ assertThat(runAllAtOnce(tasks)).containsOnly(EVENTS_EACH);
+ assertThat(closed.await(30, TimeUnit.SECONDS)).isTrue();
+ }
+
+ @Test
+ void aPoolSizedForTheSubscriptionsIsEnough() throws Exception {
+ // One worker per open subscription is the documented cost. Needing a second thread per
+ // subscription for delivery would starve this pool and hang instead.
+ var pool = Executors.newFixedThreadPool(SUBSCRIPTIONS);
+ try {
+ var api = buildClient(pool);
+ List> tasks =
+ IntStream.range(0, SUBSCRIPTIONS)
+ .>mapToObj(index -> () -> drain(api.publishPrice("SYM" + index)))
+ .toList();
+
+ assertThat(runAllAtOnce(tasks)).containsOnly(EVENTS_EACH);
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ @Test
+ void aPoolTooSmallRefusesRatherThanStranding() throws Exception {
+ var pool = new ThreadPoolExecutor(0, 4, 60L, TimeUnit.SECONDS, new SynchronousQueue<>());
+ try {
+ var api = buildClient(pool);
+ List> tasks =
+ IntStream.range(0, SUBSCRIPTIONS)
+ .>mapToObj(index -> () -> drain(api.publishPrice("SYM" + index)))
+ .toList();
+
+ // Far more subscriptions than workers. Some are refused, but every subscriber must reach a
+ // terminal signal — drain() asserts that. Leaving one waiting forever is the failure mode.
+ assertThat(runAllAtOnce(tasks)).hasSize(SUBSCRIPTIONS);
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ @Test
+ void longLivedSubscriptionsAreNotCappedByCoreCount() throws Exception {
+ emitEvents = false;
+ var api = buildClient();
+
+ // Each of these holds its worker parked on the queue for as long as it is open, which is what a
+ // real feed does. A pool sized from the core count would refuse the excess synchronously.
+ var futures =
+ IntStream.range(0, SUBSCRIPTIONS)
+ .mapToObj(index -> api.futurePrice("SYM" + index))
+ .toList();
+
+ assertThat(futures)
+ .as("no subscription should have been refused a worker")
+ .allSatisfy(future -> assertThat(future).isNotCompleted());
+
+ futures.forEach(future -> future.cancel(true));
+ assertThat(closed.await(30, TimeUnit.SECONDS)).isTrue();
+ }
+
+ @Test
+ void closingMidStreamFromAnotherThreadTerminatesPromptly() throws Exception {
+ var api = buildClient();
+
+ List> tasks =
+ IntStream.range(0, SUBSCRIPTIONS)
+ .>mapToObj(
+ index ->
+ () -> {
+ // Take a couple of events and walk away while the server is still pushing.
+ try (var prices = api.onPrice("SYM" + index)) {
+ return prices.limit(2).toList().size();
+ }
+ })
+ .toList();
+
+ assertThat(runAllAtOnce(tasks)).containsOnly(2);
+ assertThat(closed.await(30, TimeUnit.SECONDS))
+ .as("abandoning a stream must still close its socket")
+ .isTrue();
+ }
+}
diff --git a/graphql/src/test/java/feign/graphql/GraphqlSubscriptionTest.java b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionTest.java
new file mode 100644
index 0000000000..30218f179e
--- /dev/null
+++ b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionTest.java
@@ -0,0 +1,466 @@
+/*
+ * 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.graphql;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import feign.Feign;
+import feign.jackson.JacksonCodec;
+import java.net.SocketTimeoutException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Flow;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import mockwebserver3.MockResponse;
+import mockwebserver3.MockWebServer;
+import okhttp3.Response;
+import okhttp3.WebSocket;
+import okhttp3.WebSocketListener;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class GraphqlSubscriptionTest {
+
+ private final ObjectMapper mapper =
+ new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
+ private MockWebServer server;
+
+ private final CountDownLatch closed = new CountDownLatch(1);
+ private boolean expectsWebSocket;
+
+ public static class Price {
+ public String symbol;
+ public double price;
+ }
+
+ interface StockApi {
+
+ @GraphqlQuery(
+ "subscription onPrice($symbol: String!) {"
+ + " priceChanged(symbol: $symbol) { symbol price } }")
+ Stream onPrice(String symbol);
+
+ @GraphqlQuery(
+ "subscription onPrice($symbol: String!) {"
+ + " priceChanged(symbol: $symbol) { symbol price } }")
+ Flow.Publisher publishPrice(String symbol);
+
+ // blocks until the first event, then unsubscribes
+ @GraphqlQuery(
+ "subscription onPrice($symbol: String!) {"
+ + " priceChanged(symbol: $symbol) { symbol price } }")
+ Price firstPrice(String symbol);
+
+ @GraphqlQuery(
+ "subscription onPrice($symbol: String!) {"
+ + " priceChanged(symbol: $symbol) { symbol price } }")
+ Optional maybeFirstPrice(String symbol);
+
+ @GraphqlQuery(
+ "subscription onPrice($symbol: String!) {"
+ + " priceChanged(symbol: $symbol) { symbol price } }")
+ CompletableFuture futurePrice(String symbol);
+
+ @GraphqlQuery(
+ "subscription onPrice($symbol: String!) {"
+ + " priceChanged(symbol: $symbol) { symbol price } }")
+ void ignoredPrice(String symbol);
+
+ // ordinary query on the same interface — goes over HTTP, not the web socket
+ @GraphqlQuery(
+ "query lastPrice($symbol: String!) { lastPrice(symbol: $symbol) { symbol price } }")
+ Price lastPrice(String symbol);
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ server = new MockWebServer();
+ server.start();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (expectsWebSocket) {
+ assertThat(closed.await(10, TimeUnit.SECONDS))
+ .as("client should have closed the web socket")
+ .isTrue();
+ }
+ server.close();
+ }
+
+ private StockApi buildClient() {
+ return Feign.builder()
+ .addCapability(new GraphqlCapability(new JacksonCodec(mapper)))
+ .target(StockApi.class, server.url("/graphql").toString());
+ }
+
+ /** {@code {id}} is replaced with the id the client actually subscribed with. */
+ private static final String NEXT =
+ "{\"id\":\"{id}\",\"type\":\"next\",\"payload\":{\"data\":{\"priceChanged\":"
+ + "{\"symbol\":\"%s\",\"price\":%s}}}}";
+
+ /** Replays the graphql-transport-ws handshake, then whatever the test queued. */
+ private void enqueueServer(List received, String... afterSubscribe) {
+ expectsWebSocket = true;
+ server.enqueue(
+ new MockResponse.Builder()
+ .webSocketUpgrade(
+ new WebSocketListener() {
+ @Override
+ public void onMessage(WebSocket webSocket, String text) {
+ received.add(text);
+ try {
+ var message = mapper.readTree(text);
+ if ("connection_init".equals(message.get("type").asText())) {
+ webSocket.send("{\"type\":\"connection_ack\"}");
+ } else if ("subscribe".equals(message.get("type").asText())) {
+ var id = message.get("id").asText();
+ for (var queued : afterSubscribe) {
+ webSocket.send(queued.replace("{id}", id));
+ }
+ }
+ } catch (Exception e) {
+ throw new IllegalStateException("bad client message: " + text, e);
+ }
+ }
+
+ @Override
+ public void onClosing(WebSocket webSocket, int code, String reason) {
+ webSocket.close(code, reason);
+ closed.countDown();
+ }
+
+ @Override
+ public void onFailure(WebSocket webSocket, Throwable t, Response response) {
+ closed.countDown();
+ }
+ })
+ .build());
+ }
+
+ @Test
+ void streamBlocksUntilEachEventArrives() throws Exception {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(
+ received,
+ String.format(NEXT, "ACME", "10.5"),
+ String.format(NEXT, "ACME", "11.25"),
+ "{\"id\":\"{id}\",\"type\":\"complete\"}");
+
+ List prices;
+ try (var stream = buildClient().onPrice("ACME")) {
+ prices = stream.collect(Collectors.toList());
+ }
+
+ assertThat(prices).extracting(price -> price.symbol).containsExactly("ACME", "ACME");
+ assertThat(prices).extracting(price -> price.price).containsExactly(10.5, 11.25);
+
+ assertThat(mapper.readTree(received.get(0)).get("type").asText()).isEqualTo("connection_init");
+
+ var subscribe = mapper.readTree(received.get(1));
+ assertThat(subscribe.get("type").asText()).isEqualTo("subscribe");
+ assertThat(subscribe.get("payload").get("variables").get("symbol").asText()).isEqualTo("ACME");
+
+ var id = subscribe.get("id").asText();
+ assertThat(UUID.fromString(id)).hasToString(id);
+ }
+
+ @Test
+ void publisherReturnsImmediatelyAndPushes() throws Exception {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(
+ received,
+ String.format(NEXT, "ACME", "10.5"),
+ String.format(NEXT, "ACME", "11.25"),
+ "{\"id\":\"{id}\",\"type\":\"complete\"}");
+
+ var publisher = buildClient().publishPrice("ACME");
+
+ var delivered = new ArrayList();
+ var completed = new CountDownLatch(1);
+ publisher.subscribe(
+ new Flow.Subscriber() {
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(Price item) {
+ delivered.add(item);
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ completed.countDown();
+ }
+
+ @Override
+ public void onComplete() {
+ completed.countDown();
+ }
+ });
+
+ assertThat(completed.await(10, TimeUnit.SECONDS)).isTrue();
+ assertThat(delivered).extracting(price -> price.price).containsExactly(10.5, 11.25);
+ }
+
+ @Test
+ void serverErrorMessageFailsTheStream() {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(
+ received,
+ "{\"id\":\"{id}\",\"type\":\"error\",\"payload\":[{\"message\":\"unknown symbol\"}]}");
+
+ try (var stream = buildClient().onPrice("NOPE")) {
+ assertThatThrownBy(stream::findFirst)
+ .isInstanceOf(GraphqlErrorException.class)
+ .hasMessageContaining("unknown symbol");
+ }
+ }
+
+ @Test
+ void errorsInsidePayloadFailTheStream() {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(
+ received,
+ "{\"id\":\"{id}\",\"type\":\"next\",\"payload\":{\"errors\":[{\"message\":\"boom\"}]}}");
+
+ try (var stream = buildClient().onPrice("ACME")) {
+ assertThatThrownBy(stream::findFirst)
+ .isInstanceOf(GraphqlErrorException.class)
+ .hasMessageContaining("boom");
+ }
+ }
+
+ @Test
+ void plainReturnTypeBlocksForTheFirstEventOnly() {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(received, String.format(NEXT, "ACME", "10.5"), String.format(NEXT, "ACME", "99"));
+
+ var price = buildClient().firstPrice("ACME");
+
+ assertThat(price.price).isEqualTo(10.5);
+ }
+
+ @Test
+ void optionalReturnTypeBlocksForTheFirstEvent() {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(received, String.format(NEXT, "ACME", "10.5"));
+
+ assertThat(buildClient().maybeFirstPrice("ACME"))
+ .hasValueSatisfying(price -> assertThat(price.price).isEqualTo(10.5));
+ }
+
+ @Test
+ void optionalReturnTypeIsEmptyWhenTheServerCompletesWithoutEvents() {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(received, "{\"id\":\"{id}\",\"type\":\"complete\"}");
+
+ assertThat(buildClient().maybeFirstPrice("ACME")).isEmpty();
+ }
+
+ @Test
+ void futureReturnsImmediatelyAndCompletesWithTheFirstEvent() throws Exception {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(received, String.format(NEXT, "ACME", "10.5"));
+
+ var future = buildClient().futurePrice("ACME");
+
+ assertThat(future.get(10, TimeUnit.SECONDS).price).isEqualTo(10.5);
+ }
+
+ @Test
+ void voidReturnTypeClosesTheSubscription() {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(received, String.format(NEXT, "ACME", "10.5"));
+
+ buildClient().ignoredPrice("ACME");
+ // tearDown asserts the socket was closed rather than leaked
+ }
+
+ @Test
+ void eventTimeoutBoundsBlockingCalls() {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(received);
+
+ var api =
+ Feign.builder()
+ .addCapability(new GraphqlCapability(new JacksonCodec(mapper), Duration.ofMillis(250)))
+ .target(StockApi.class, server.url("/graphql").toString());
+
+ assertThatThrownBy(() -> api.firstPrice("ACME"))
+ .rootCause()
+ .isInstanceOf(SocketTimeoutException.class);
+ }
+
+ @Test
+ void eventTimeoutAlsoBoundsStreamElements() {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(received, String.format(NEXT, "ACME", "10.5"));
+
+ var api =
+ Feign.builder()
+ .addCapability(new GraphqlCapability(new JacksonCodec(mapper), Duration.ofMillis(250)))
+ .target(StockApi.class, server.url("/graphql").toString());
+
+ try (var stream = api.onPrice("ACME")) {
+ // the server never completes, so the second element hits the timeout
+ assertThatThrownBy(stream::toList).rootCause().isInstanceOf(SocketTimeoutException.class);
+ }
+ }
+
+ @Test
+ void asyncFormsAreNotBoundedByTheEventTimeout() throws Exception {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(received);
+
+ var api =
+ Feign.builder()
+ .addCapability(new GraphqlCapability(new JacksonCodec(mapper), Duration.ofMillis(100)))
+ .target(StockApi.class, server.url("/graphql").toString());
+
+ var future = api.futurePrice("ACME");
+
+ assertThatThrownBy(() -> future.get(500, TimeUnit.MILLISECONDS))
+ .isInstanceOf(TimeoutException.class);
+ assertThat(future).isNotCompleted();
+
+ // deliberately still waiting on the server, so there is no close to assert on
+ expectsWebSocket = false;
+ }
+
+ @Test
+ void queriesAndSubscriptionsShareOneClient() throws Exception {
+ server.enqueue(
+ new MockResponse.Builder()
+ .body("{\"data\":{\"lastPrice\":{\"symbol\":\"ACME\",\"price\":9.75}}}")
+ .addHeader("Content-Type", "application/json")
+ .build());
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(
+ received, String.format(NEXT, "ACME", "10.5"), "{\"id\":\"{id}\",\"type\":\"complete\"}");
+
+ var api = buildClient();
+
+ assertThat(api.lastPrice("ACME").price).isEqualTo(9.75);
+ try (var stream = api.onPrice("ACME")) {
+ assertThat(stream.toList()).extracting(price -> price.price).containsExactly(10.5);
+ }
+
+ var query = server.takeRequest();
+ assertThat(query.getMethod()).isEqualTo("POST");
+ assertThat(query.getHeaders().get("Upgrade")).isNull();
+
+ var handshake = server.takeRequest();
+ assertThat(handshake.getHeaders().get("Upgrade")).isEqualToIgnoringCase("websocket");
+ assertThat(handshake.getHeaders().get("Sec-WebSocket-Protocol"))
+ .contains("graphql-transport-ws");
+ }
+
+ @Test
+ void eventsForAnotherOperationAreIgnored() {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(
+ received,
+ "{\"id\":\"someone-else\",\"type\":\"next\",\"payload\":{\"data\":{\"priceChanged\":"
+ + "{\"symbol\":\"NOPE\",\"price\":1}}}}",
+ String.format(NEXT, "ACME", "10.5"),
+ "{\"id\":\"{id}\",\"type\":\"complete\"}");
+
+ try (var stream = buildClient().onPrice("ACME")) {
+ assertThat(stream.toList()).extracting(price -> price.symbol).containsExactly("ACME");
+ }
+ }
+
+ @Test
+ void slowConsumerStillReceivesEveryEvent() {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(
+ received,
+ String.format(NEXT, "ACME", "1"),
+ String.format(NEXT, "ACME", "2"),
+ String.format(NEXT, "ACME", "3"),
+ String.format(NEXT, "ACME", "4"),
+ String.format(NEXT, "ACME", "5"),
+ "{\"id\":\"{id}\",\"type\":\"complete\"}");
+
+ // Reads are demand-driven, so a consumer that lags must still be handed every event in order.
+ // Broken demand accounting stalls here until the event timeout instead.
+ List prices;
+ try (var stream = buildClient().onPrice("ACME")) {
+ prices =
+ stream
+ .peek(
+ price -> {
+ try {
+ Thread.sleep(20);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ })
+ .toList();
+ }
+
+ assertThat(prices).extracting(price -> price.price).containsExactly(1.0, 2.0, 3.0, 4.0, 5.0);
+ }
+
+ @Test
+ void cancellingTheFutureClosesTheSubscription() {
+ var received = new CopyOnWriteArrayList();
+ enqueueServer(received);
+
+ var future = buildClient().futurePrice("ACME");
+ assertThat(future.cancel(true)).isTrue();
+
+ // tearDown asserts the web socket was closed rather than left hanging on a cancelled future
+ }
+
+ @Test
+ void defaultEventTimeoutIsOneMinute() {
+ assertThat(GraphqlDecoder.DEFAULT_EVENT_TIMEOUT).isEqualTo(Duration.ofMinutes(1));
+ }
+
+ @Test
+ void subscriptionDetection() {
+ assertThat(GraphqlContract.isSubscription("subscription onPrice { a }")).isTrue();
+ assertThat(GraphqlContract.isSubscription(" \n subscription { a }")).isTrue();
+ assertThat(GraphqlContract.isSubscription("query subscriptionLike { a }")).isFalse();
+ assertThat(GraphqlContract.isSubscription("mutation m { a }")).isFalse();
+ }
+
+ @Test
+ void webSocketUriSwapsScheme() {
+ assertThat(GraphqlSubscriptionClient.webSocketUri("http://host:8080/graphql"))
+ .hasToString("ws://host:8080/graphql");
+ assertThat(GraphqlSubscriptionClient.webSocketUri("https://host/graphql"))
+ .hasToString("wss://host/graphql");
+ }
+}
diff --git a/gson/src/main/java/feign/gson/GsonDecoder.java b/gson/src/main/java/feign/gson/GsonDecoder.java
index 5fa6ee0369..8a908087c1 100644
--- a/gson/src/main/java/feign/gson/GsonDecoder.java
+++ b/gson/src/main/java/feign/gson/GsonDecoder.java
@@ -24,12 +24,13 @@
import feign.Util;
import feign.codec.Decoder;
import feign.codec.JsonDecoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Type;
import java.util.Collections;
-public class GsonDecoder implements Decoder, JsonDecoder {
+public class GsonDecoder implements Decoder, PredicatedDecoder, JsonDecoder {
private final Gson gson;
@@ -66,4 +67,9 @@ public Object decode(Response response, Type type) throws IOException {
public Object convert(Object object, Type type) {
return gson.fromJson(gson.toJsonTree(object), type);
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/gson/src/main/java/feign/gson/GsonEncoder.java b/gson/src/main/java/feign/gson/GsonEncoder.java
index c0c3d0eb2f..6732670295 100644
--- a/gson/src/main/java/feign/gson/GsonEncoder.java
+++ b/gson/src/main/java/feign/gson/GsonEncoder.java
@@ -19,12 +19,14 @@
import com.google.gson.TypeAdapter;
import feign.Request;
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;
@@ -44,4 +46,9 @@ public GsonEncoder(Gson gson) {
public void encode(Object object, Type bodyType, RequestTemplate template) {
template.body(Request.Body.of(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/JacksonJaxbJsonDecoder.java b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java
index ed1cb12a2c..98a3e0e833 100644
--- a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java
+++ b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java
@@ -24,10 +24,11 @@
import feign.Response;
import feign.Util;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.lang.reflect.Type;
-public final class JacksonJaxbJsonDecoder implements Decoder {
+public final class JacksonJaxbJsonDecoder implements Decoder, PredicatedDecoder {
private final JacksonJaxbJsonProvider jacksonJaxbJsonProvider;
public JacksonJaxbJsonDecoder() {
@@ -45,4 +46,9 @@ public Object decode(Response response, Type type) throws IOException, FeignExce
return jacksonJaxbJsonProvider.readFrom(
Object.class, type, null, APPLICATION_JSON_TYPE, null, response.body().asInputStream());
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java
index 6e4caeaede..384ed20a18 100644
--- a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java
+++ b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java
@@ -22,13 +22,15 @@
import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider;
import feign.Request;
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;
-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/JacksonJrDecoder.java b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java
index 3edb1f8dda..7d4581f5f2 100644
--- a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java
+++ b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java
@@ -23,6 +23,7 @@
import feign.codec.DecodeException;
import feign.codec.Decoder;
import feign.codec.JsonDecoder;
+import feign.codec.PredicatedDecoder;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
@@ -34,7 +35,8 @@
/**
* A {@link JsonDecoder} that uses Jackson Jr to convert objects to String or byte representation.
*/
-public class JacksonJrDecoder extends JacksonJrMapper implements Decoder, JsonDecoder {
+public class JacksonJrDecoder extends JacksonJrMapper
+ implements Decoder, PredicatedDecoder, JsonDecoder {
@FunctionalInterface
protected interface Transformer {
@@ -134,4 +136,9 @@ public Object convert(Object object, Type type) throws IOException {
}
throw new IOException("Cannot convert to type: " + type.getTypeName());
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java
index 26fe78179c..6f4665cea5 100644
--- a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java
+++ b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java
@@ -19,13 +19,15 @@
import com.fasterxml.jackson.jr.ob.JacksonJrExtension;
import feign.Request;
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();
@@ -62,4 +64,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/JacksonDecoder.java b/jackson/src/main/java/feign/jackson/JacksonDecoder.java
index 370f745dc7..db5a0dbe58 100644
--- a/jackson/src/main/java/feign/jackson/JacksonDecoder.java
+++ b/jackson/src/main/java/feign/jackson/JacksonDecoder.java
@@ -23,13 +23,14 @@
import feign.Util;
import feign.codec.Decoder;
import feign.codec.JsonDecoder;
+import feign.codec.PredicatedDecoder;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Type;
import java.util.Collections;
-public class JacksonDecoder implements Decoder, JsonDecoder {
+public class JacksonDecoder implements Decoder, PredicatedDecoder, JsonDecoder {
private final ObjectMapper mapper;
@@ -76,4 +77,9 @@ public Object decode(Response response, Type type) throws IOException {
public Object convert(Object object, Type type) {
return mapper.convertValue(object, mapper.constructType(type));
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/jackson/src/main/java/feign/jackson/JacksonEncoder.java b/jackson/src/main/java/feign/jackson/JacksonEncoder.java
index 280cd3bb58..348806c274 100644
--- a/jackson/src/main/java/feign/jackson/JacksonEncoder.java
+++ b/jackson/src/main/java/feign/jackson/JacksonEncoder.java
@@ -23,13 +23,15 @@
import com.fasterxml.jackson.databind.SerializationFeature;
import feign.Request;
import feign.RequestTemplate;
+import feign.Util;
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 +60,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/Jackson3Decoder.java b/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java
index 5726d582bd..363b17287e 100644
--- a/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java
+++ b/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java
@@ -19,6 +19,7 @@
import feign.Util;
import feign.codec.Decoder;
import feign.codec.JsonDecoder;
+import feign.codec.PredicatedDecoder;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
@@ -29,7 +30,7 @@
import tools.jackson.databind.JacksonModule;
import tools.jackson.databind.json.JsonMapper;
-public class Jackson3Decoder implements Decoder, JsonDecoder {
+public class Jackson3Decoder implements Decoder, PredicatedDecoder, JsonDecoder {
private final JsonMapper mapper;
@@ -77,4 +78,9 @@ public Object decode(Response response, Type type) throws IOException {
public Object convert(Object object, Type type) {
return mapper.convertValue(object, mapper.constructType(type));
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java b/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java
index 273e6d8f79..00d52da27d 100644
--- a/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java
+++ b/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java
@@ -18,9 +18,11 @@
import com.fasterxml.jackson.annotation.JsonInclude;
import feign.Request;
import feign.RequestTemplate;
+import feign.Util;
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 +31,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 +62,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/JAXBContextFactory.java b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBContextFactory.java
index 07918bc6ce..36692a6f9f 100644
--- a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBContextFactory.java
+++ b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBContextFactory.java
@@ -66,6 +66,7 @@ private JAXBContextFactory(
/** Creates a new {@link jakarta.xml.bind.Unmarshaller} that handles the supplied class. */
public Unmarshaller createUnmarshaller(Class> clazz) throws JAXBException {
Unmarshaller unmarshaller = getContext(clazz).createUnmarshaller();
+ setUnmarshallerProperties(unmarshaller);
if (unmarshallerEventHandler != null) {
unmarshaller.setEventHandler(unmarshallerEventHandler);
}
@@ -90,6 +91,16 @@ private void setMarshallerProperties(Marshaller marshaller) throws PropertyExcep
}
}
+ private void setUnmarshallerProperties(Unmarshaller unmarshaller) {
+ for (Entry en : properties.entrySet()) {
+ try {
+ unmarshaller.setProperty(en.getKey(), en.getValue());
+ } catch (PropertyException ignored) {
+ // The same map holds marshaller-only properties (for example JAXB_FORMATTED_OUTPUT).
+ }
+ }
+ }
+
private JAXBContext getContext(Class> clazz) throws JAXBException {
JAXBContextCacheKey cacheKey = jaxbContextInstantationMode.getJAXBContextCacheKey(clazz);
JAXBContext jaxbContext = this.jaxbContexts.get(cacheKey);
@@ -164,7 +175,8 @@ public Builder withMarshallerFragment(Boolean value) {
}
/**
- * Sets the given property of any Marshaller created by this factory.
+ * Sets the given property of any Marshaller or Unmarshaller created by this factory.
+ * Marshaller-only properties are ignored when creating an Unmarshaller.
*
* Example :
*
diff --git a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBDecoder.java b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBDecoder.java
index 6a40861cd1..feaf8237b5 100644
--- a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBDecoder.java
+++ b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBDecoder.java
@@ -19,6 +19,7 @@
import feign.Util;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import jakarta.xml.bind.JAXBException;
import java.io.IOException;
import java.lang.reflect.ParameterizedType;
@@ -48,7 +49,7 @@
*
The JAXBContextFactory should be reused across requests as it caches the created JAXB
* contexts.
*/
-public class JAXBDecoder implements Decoder {
+public class JAXBDecoder implements Decoder, PredicatedDecoder {
private final JAXBContextFactory jaxbContextFactory;
private final boolean namespaceAware;
@@ -123,4 +124,9 @@ public JAXBDecoder build() {
return new JAXBDecoder(this);
}
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isXmlContentType(response);
+ }
}
diff --git a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java
index a25845800b..3e9a768b7d 100644
--- a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java
+++ b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java
@@ -17,8 +17,10 @@
import feign.Request;
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;
@@ -43,7 +45,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;
@@ -66,4 +68,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-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java
index 7410696700..68b90a981e 100644
--- a/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java
+++ b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java
@@ -28,6 +28,7 @@
import feign.codec.Encoder;
import jakarta.xml.bind.MarshalException;
import jakarta.xml.bind.UnmarshalException;
+import jakarta.xml.bind.ValidationEventHandler;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlElement;
@@ -39,10 +40,13 @@
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
+import java.util.concurrent.Callable;
+import java.util.concurrent.atomic.AtomicBoolean;
import javax.xml.XMLConstants;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
+import org.glassfish.jaxb.runtime.IDResolver;
import org.junit.jupiter.api.Test;
@SuppressWarnings("deprecation")
@@ -209,6 +213,53 @@ void decodesXml() throws Exception {
assertThat(decoder.decode(response, MockObject.class)).isEqualTo(mock);
}
+ @Test
+ void decodesXmlUsingFactoryProperty() throws Exception {
+ MockObject mock = new MockObject();
+ mock.value = "Test";
+
+ String mockXml =
+ """
+ \
+ Test \
+ """;
+
+ Response response =
+ Response.builder()
+ .status(200)
+ .reason("OK")
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, null))
+ .headers(Collections.emptyMap())
+ .body(mockXml, UTF_8)
+ .build();
+
+ AtomicBoolean started = new AtomicBoolean();
+ IDResolver resolver =
+ new IDResolver() {
+ @Override
+ public void startDocument(ValidationEventHandler eventHandler) {
+ started.set(true);
+ }
+
+ @Override
+ public void bind(String id, Object obj) {}
+
+ @Override
+ public Callable> resolve(String id, Class targetType) {
+ return () -> null;
+ }
+ };
+
+ JAXBContextFactory factory =
+ new JAXBContextFactory.Builder()
+ .withMarshallerFormattedOutput(true)
+ .withProperty(IDResolver.class.getName(), resolver)
+ .build();
+
+ assertThat(new JAXBDecoder(factory).decode(response, MockObject.class)).isEqualTo(mock);
+ assertThat(started).isTrue();
+ }
+
@Test
void doesntDecodeParameterizedTypes() throws Exception {
diff --git a/jaxb-jakarta/src/test/java/feign/jaxb/JAXBContextFactoryTest.java b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBContextFactoryTest.java
index 87008e6ab1..fee68a88c9 100644
--- a/jaxb-jakarta/src/test/java/feign/jaxb/JAXBContextFactoryTest.java
+++ b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBContextFactoryTest.java
@@ -26,9 +26,11 @@
import java.util.Arrays;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.Callable;
import javax.xml.XMLConstants;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
+import org.glassfish.jaxb.runtime.IDResolver;
import org.junit.jupiter.api.Test;
class JAXBContextFactoryTest {
@@ -94,6 +96,34 @@ void buildsMarshallerWithSchema() throws Exception {
assertThat(marshaller.getSchema()).isSameAs(schema);
}
+ @Test
+ void buildsUnmarshallerWithProperty() throws Exception {
+ IDResolver resolver =
+ new IDResolver() {
+ @Override
+ public void bind(String id, Object obj) {}
+
+ @Override
+ public Callable> resolve(String id, Class targetType) {
+ return () -> null;
+ }
+ };
+ JAXBContextFactory factory =
+ new JAXBContextFactory.Builder().withProperty(IDResolver.class.getName(), resolver).build();
+
+ Unmarshaller unmarshaller = factory.createUnmarshaller(Object.class);
+ assertThat(unmarshaller.getProperty(IDResolver.class.getName())).isSameAs(resolver);
+ }
+
+ @Test
+ void buildsUnmarshallerWhenFactoryHasMarshallerProperties() throws Exception {
+ JAXBContextFactory factory =
+ new JAXBContextFactory.Builder().withMarshallerFormattedOutput(true).build();
+
+ Unmarshaller unmarshaller = factory.createUnmarshaller(Object.class);
+ assertThat(unmarshaller).isNotNull();
+ }
+
@Test
void buildsUnmarshallerWithSchema() throws Exception {
Schema schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI).newSchema();
diff --git a/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java b/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java
index 3f057d8c0d..dcd7f6c248 100644
--- a/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java
+++ b/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java
@@ -66,6 +66,7 @@ private JAXBContextFactory(
/** Creates a new {@link javax.xml.bind.Unmarshaller} that handles the supplied class. */
public Unmarshaller createUnmarshaller(Class> clazz) throws JAXBException {
Unmarshaller unmarshaller = getContext(clazz).createUnmarshaller();
+ setUnmarshallerProperties(unmarshaller);
if (unmarshallerEventHandler != null) {
unmarshaller.setEventHandler(unmarshallerEventHandler);
}
@@ -90,6 +91,16 @@ private void setMarshallerProperties(Marshaller marshaller) throws PropertyExcep
}
}
+ private void setUnmarshallerProperties(Unmarshaller unmarshaller) {
+ for (Entry en : properties.entrySet()) {
+ try {
+ unmarshaller.setProperty(en.getKey(), en.getValue());
+ } catch (PropertyException ignored) {
+ // The same map holds marshaller-only properties (for example JAXB_FORMATTED_OUTPUT).
+ }
+ }
+ }
+
private JAXBContext getContext(Class> clazz) throws JAXBException {
JAXBContextCacheKey cacheKey = jaxbContextInstantationMode.getJAXBContextCacheKey(clazz);
JAXBContext jaxbContext = this.jaxbContexts.get(cacheKey);
@@ -164,7 +175,8 @@ public Builder withMarshallerFragment(Boolean value) {
}
/**
- * Sets the given property of any Marshaller created by this factory.
+ * Sets the given property of any Marshaller or Unmarshaller created by this factory.
+ * Marshaller-only properties are ignored when creating an Unmarshaller.
*
* Example :
*
diff --git a/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java
index 9d998d26dc..a7132472ec 100644
--- a/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java
+++ b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java
@@ -19,6 +19,7 @@
import feign.Util;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
@@ -48,7 +49,7 @@
*
The JAXBContextFactory should be reused across requests as it caches the created JAXB
* contexts.
*/
-public class JAXBDecoder implements Decoder {
+public class JAXBDecoder implements Decoder, PredicatedDecoder {
private final JAXBContextFactory jaxbContextFactory;
private final boolean namespaceAware;
@@ -123,4 +124,9 @@ public JAXBDecoder build() {
return new JAXBDecoder(this);
}
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isXmlContentType(response);
+ }
}
diff --git a/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java
index 4c05d82e71..c3baa91d56 100644
--- a/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java
+++ b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java
@@ -17,8 +17,10 @@
import feign.Request;
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;
@@ -43,7 +45,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;
@@ -66,4 +68,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/test/java/feign/jaxb/JAXBCodecTest.java b/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java
index 97bc514d0a..2b1395b033 100644
--- a/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java
+++ b/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java
@@ -19,6 +19,7 @@
import static feign.assertj.FeignAssertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import com.sun.xml.bind.IDResolver;
import feign.Request;
import feign.Request.HttpMethod;
import feign.RequestTemplate;
@@ -33,6 +34,7 @@
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
import javax.xml.XMLConstants;
import javax.xml.bind.MarshalException;
import javax.xml.bind.UnmarshalException;
@@ -209,6 +211,53 @@ void decodesXml() throws Exception {
assertThat(decoder.decode(response, MockObject.class)).isEqualTo(mock);
}
+ @Test
+ void decodesXmlUsingFactoryProperty() throws Exception {
+ MockObject mock = new MockObject();
+ mock.value = "Test";
+
+ String mockXml =
+ """
+ \
+ Test \
+ """;
+
+ Response response =
+ Response.builder()
+ .status(200)
+ .reason("OK")
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, null))
+ .headers(Collections.emptyMap())
+ .body(mockXml, UTF_8)
+ .build();
+
+ AtomicBoolean started = new AtomicBoolean();
+ IDResolver resolver =
+ new IDResolver() {
+ @Override
+ public void startDocument(javax.xml.bind.ValidationEventHandler eventHandler) {
+ started.set(true);
+ }
+
+ @Override
+ public void bind(String id, Object obj) {}
+
+ @Override
+ public java.util.concurrent.Callable> resolve(String id, Class targetType) {
+ return () -> null;
+ }
+ };
+
+ JAXBContextFactory factory =
+ new JAXBContextFactory.Builder()
+ .withMarshallerFormattedOutput(true)
+ .withProperty(IDResolver.class.getName(), resolver)
+ .build();
+
+ assertThat(new JAXBDecoder(factory).decode(response, MockObject.class)).isEqualTo(mock);
+ assertThat(started).isTrue();
+ }
+
@Test
void doesntDecodeParameterizedTypes() throws Exception {
diff --git a/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java b/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java
index 959836849c..39edfead6b 100644
--- a/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java
+++ b/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java
@@ -17,12 +17,14 @@
import static org.assertj.core.api.Assertions.assertThat;
+import com.sun.xml.bind.IDResolver;
import feign.jaxb.mock.onepackage.AnotherMockedJAXBObject;
import feign.jaxb.mock.onepackage.MockedJAXBObject;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.Callable;
import javax.xml.XMLConstants;
import javax.xml.bind.Marshaller;
import javax.xml.bind.Unmarshaller;
@@ -94,6 +96,34 @@ void buildsMarshallerWithSchema() throws Exception {
assertThat(marshaller.getSchema()).isSameAs(schema);
}
+ @Test
+ void buildsUnmarshallerWithProperty() throws Exception {
+ IDResolver resolver =
+ new IDResolver() {
+ @Override
+ public void bind(String id, Object obj) {}
+
+ @Override
+ public Callable> resolve(String id, Class targetType) {
+ return () -> null;
+ }
+ };
+ JAXBContextFactory factory =
+ new JAXBContextFactory.Builder().withProperty(IDResolver.class.getName(), resolver).build();
+
+ Unmarshaller unmarshaller = factory.createUnmarshaller(Object.class);
+ assertThat(unmarshaller.getProperty(IDResolver.class.getName())).isSameAs(resolver);
+ }
+
+ @Test
+ void buildsUnmarshallerWhenFactoryHasMarshallerProperties() throws Exception {
+ JAXBContextFactory factory =
+ new JAXBContextFactory.Builder().withMarshallerFormattedOutput(true).build();
+
+ Unmarshaller unmarshaller = factory.createUnmarshaller(Object.class);
+ assertThat(unmarshaller).isNotNull();
+ }
+
@Test
void buildsUnmarshallerWithSchema() throws Exception {
Schema schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI).newSchema();
diff --git a/json/src/main/java/feign/json/JsonDecoder.java b/json/src/main/java/feign/json/JsonDecoder.java
index edf7fd80f0..5f0a264869 100644
--- a/json/src/main/java/feign/json/JsonDecoder.java
+++ b/json/src/main/java/feign/json/JsonDecoder.java
@@ -21,6 +21,7 @@
import feign.Util;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
@@ -53,7 +54,7 @@
* System.out.println(contributors.getJSONObject(0).getString("login"));
*
*/
-public class JsonDecoder implements Decoder, feign.codec.JsonDecoder {
+public class JsonDecoder implements Decoder, PredicatedDecoder, feign.codec.JsonDecoder {
@Override
public Object decode(Response response, Type type) throws IOException, DecodeException {
@@ -114,4 +115,9 @@ public Object convert(Object object, Type type) throws IOException {
}
throw new IOException(type.getTypeName() + " is not a type supported by this decoder.");
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/json/src/main/java/feign/json/JsonEncoder.java b/json/src/main/java/feign/json/JsonEncoder.java
index 1ef3cd6fb1..0d9118c70f 100644
--- a/json/src/main/java/feign/json/JsonEncoder.java
+++ b/json/src/main/java/feign/json/JsonEncoder.java
@@ -19,8 +19,10 @@
import feign.Request;
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;
@@ -52,7 +54,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)
@@ -64,4 +66,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/MeteredDecoder.java b/micrometer/src/main/java/feign/micrometer/MeteredDecoder.java
index 65b8067ebc..926452254e 100644
--- a/micrometer/src/main/java/feign/micrometer/MeteredDecoder.java
+++ b/micrometer/src/main/java/feign/micrometer/MeteredDecoder.java
@@ -19,6 +19,7 @@
import feign.RequestTemplate;
import feign.Response;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import feign.utils.ExceptionUtils;
import io.micrometer.core.instrument.*;
import java.io.IOException;
@@ -26,7 +27,7 @@
import java.util.Optional;
/** Wrap feign {@link Decoder} with metrics. */
-public class MeteredDecoder implements Decoder {
+public class MeteredDecoder implements Decoder, PredicatedDecoder {
private final Decoder decoder;
private final MeterRegistry meterRegistry;
@@ -117,4 +118,10 @@ protected Tag[] extraTags(Response response, Type type, Exception e) {
RequestTemplate template = response.request().requestTemplate();
return new Tag[] {Tag.of("uri", template.methodMetadata().template().path())};
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return !(decoder instanceof PredicatedDecoder)
+ || ((PredicatedDecoder) decoder).canDecode(response, type);
+ }
}
diff --git a/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java b/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java
index 784aa86c3b..16b9b0e364 100644
--- a/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java
+++ b/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java
@@ -21,6 +21,7 @@
import feign.RequestTemplate;
import feign.codec.EncodeException;
import feign.codec.Encoder;
+import feign.codec.PredicatedEncoder;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tag;
@@ -30,7 +31,7 @@
import java.util.Collections;
/** 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;
@@ -87,4 +88,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/MoshiDecoder.java b/moshi/src/main/java/feign/moshi/MoshiDecoder.java
index ac08ee96a4..9f4e006f42 100644
--- a/moshi/src/main/java/feign/moshi/MoshiDecoder.java
+++ b/moshi/src/main/java/feign/moshi/MoshiDecoder.java
@@ -22,12 +22,13 @@
import feign.Util;
import feign.codec.Decoder;
import feign.codec.JsonDecoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.lang.reflect.Type;
import okio.BufferedSource;
import okio.Okio;
-public class MoshiDecoder implements Decoder, JsonDecoder {
+public class MoshiDecoder implements Decoder, PredicatedDecoder, JsonDecoder {
private final Moshi moshi;
public MoshiDecoder(Moshi moshi) {
@@ -67,4 +68,9 @@ public Object convert(Object object, Type type) throws IOException {
JsonAdapter adapter = moshi.adapter(type);
return adapter.fromJsonValue(object);
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isJsonContentType(response);
+ }
}
diff --git a/moshi/src/main/java/feign/moshi/MoshiEncoder.java b/moshi/src/main/java/feign/moshi/MoshiEncoder.java
index fdd7507741..8ad8939e19 100644
--- a/moshi/src/main/java/feign/moshi/MoshiEncoder.java
+++ b/moshi/src/main/java/feign/moshi/MoshiEncoder.java
@@ -19,11 +19,13 @@
import com.squareup.moshi.Moshi;
import feign.Request;
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;
@@ -44,4 +46,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) {
JsonAdapter jsonAdapter = moshi.adapter(bodyType).indent(" ");
template.body(Request.Body.of(jsonAdapter.toJson(object)));
}
+
+ @Override
+ public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ return Util.isJsonContentType(template);
+ }
}
diff --git a/pom.xml b/pom.xml
index 8555d8fb51..e87728c696 100644
--- a/pom.xml
+++ b/pom.xml
@@ -165,21 +165,21 @@
${main.java.version}
${main.java.version}
- 5.4.0
- 33.6.0-jre
+ 5.5.0
+ 33.7.1-jre
2.2.0
2.14.0
1.15.2
2.0.18
- 20260719
+ 20260814
4.1.0
- 6.1.2
- 2.22.1
- 3.2.1
+ 6.1.3
+ 2.22.2
+ 3.2.2
3.27.7
5.23.0
- 2.0.63.android8
+ 2.0.64.android8
1.5.3
6.0
@@ -206,9 +206,10 @@
3.3.0
1.2.8
4.0.0
- 6.45.0
- 3.43.0
- 3.41.0
+ 1.8.0
+ 6.46.1
+ 3.44.0
+ 3.42.0
0.26.1
1.0
@@ -223,7 +224,7 @@
26.0
4.5.4
4.5.14
- 5.6.3
+ 5.6.4
1.13.0
1.5.18
3.1.0
diff --git a/sax/src/main/java/feign/sax/SAXDecoder.java b/sax/src/main/java/feign/sax/SAXDecoder.java
index 6aa799d0a2..20da34bea1 100644
--- a/sax/src/main/java/feign/sax/SAXDecoder.java
+++ b/sax/src/main/java/feign/sax/SAXDecoder.java
@@ -24,6 +24,7 @@
import feign.Util;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
@@ -53,7 +54,7 @@
* .target(MyApi.class, "http://api");
*
*/
-public class SAXDecoder implements Decoder {
+public class SAXDecoder implements Decoder, PredicatedDecoder {
private final Map> handlerFactories;
@@ -176,4 +177,9 @@ public ContentHandlerWithResult create() {
}
}
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isXmlContentType(response);
+ }
}
diff --git a/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java b/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java
index 37386f0268..619ce01d7f 100644
--- a/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java
+++ b/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java
@@ -19,6 +19,7 @@
import feign.Util;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import feign.jaxb.JAXBContextFactory;
import jakarta.xml.bind.JAXBException;
import jakarta.xml.bind.Unmarshaller;
@@ -75,7 +76,7 @@
* @see SOAPErrorDecoder
* @see SOAPFaultException
*/
-public class SOAPDecoder implements Decoder {
+public class SOAPDecoder implements Decoder, PredicatedDecoder {
private final JAXBContextFactory jaxbContextFactory;
private final String soapProtocol;
@@ -175,4 +176,9 @@ public SOAPDecoder build() {
return new SOAPDecoder(this);
}
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isXmlContentType(response);
+ }
}
diff --git a/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java b/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java
index 31bc768304..92a30913aa 100644
--- a/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java
+++ b/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java
@@ -17,8 +17,10 @@
import feign.Request;
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;
@@ -83,7 +85,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;
@@ -225,4 +227,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/SOAPDecoder.java b/soap/src/main/java/feign/soap/SOAPDecoder.java
index 8079a622a4..bf63ba4c27 100644
--- a/soap/src/main/java/feign/soap/SOAPDecoder.java
+++ b/soap/src/main/java/feign/soap/SOAPDecoder.java
@@ -19,6 +19,7 @@
import feign.Util;
import feign.codec.DecodeException;
import feign.codec.Decoder;
+import feign.codec.PredicatedDecoder;
import feign.jaxb.JAXBContextFactory;
import java.io.IOException;
import java.lang.reflect.ParameterizedType;
@@ -79,7 +80,7 @@
* @see SOAPErrorDecoder
* @see SOAPFaultException
*/
-public class SOAPDecoder implements Decoder {
+public class SOAPDecoder implements Decoder, PredicatedDecoder {
private final JAXBContextFactory jaxbContextFactory;
private final String soapProtocol;
@@ -179,4 +180,9 @@ public SOAPDecoder build() {
return new SOAPDecoder(this);
}
}
+
+ @Override
+ public boolean canDecode(Response response, Type type) {
+ return Util.isXmlContentType(response);
+ }
}
diff --git a/soap/src/main/java/feign/soap/SOAPEncoder.java b/soap/src/main/java/feign/soap/SOAPEncoder.java
index 711c44171f..995dc70b8e 100644
--- a/soap/src/main/java/feign/soap/SOAPEncoder.java
+++ b/soap/src/main/java/feign/soap/SOAPEncoder.java
@@ -17,8 +17,10 @@
import feign.Request;
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;
@@ -83,7 +85,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;
@@ -225,4 +227,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/config/bom.xml b/src/config/bom.xml
index a7f2fdab3b..30ee18a64f 100644
--- a/src/config/bom.xml
+++ b/src/config/bom.xml
@@ -65,4 +65,47 @@
+
+
+
+
+ org.codehaus.mojo
+ flatten-maven-plugin
+ ${flatten-maven-plugin.version}
+
+ bom
+
+ remove
+
+
+
+
+ flatten
+
+ flatten
+
+ process-resources
+
+
+ flatten.clean
+
+ clean
+
+ clean
+
+
+
+
+ com.github.ekryd.sortpom
+ sortpom-maven-plugin
+
+
+ ${project.basedir}/pom.xml
+
+
+
+
+
diff --git a/src/docs/overview-mindmap.iuml b/src/docs/overview-mindmap.iuml
index afd6aefbf3..e618f55064 100644
--- a/src/docs/overview-mindmap.iuml
+++ b/src/docs/overview-mindmap.iuml
@@ -1,63 +1,65 @@
-@startmindmap
-* Feign
-** clients
-*** java.net.URL
-*** Apache HTTP
-*** Apache HC5
-*** Google HTTP
-*** Java 11 Http2
-*** OK Http
-*** Ribbon
-** async clients
-*** java.net.URL
-*** Apache HC5
-*** OkHttp
-*** Vertx
-*** Reactive Wrappers
-** contracts
-*** Feign
-*** JAX-RS
-*** JAX-RS 2
-*** JAX-RS 3 / Jakarta
-*** JAX-RS 4
-*** Spring
-*** SOAP
-*** SOAP Jakarta
-*** Spring boot (3rd party)
-** language
-*** Kotlin
-*** GraphQL
-
-left side
-
-** encoders/decoders
-*** GSON
-*** JAXB
-*** JAXB Jakarta
-*** Jackson
-*** Jackson 3
-*** Jackson JAXB
-*** Jackson Jr
-*** Sax
-*** JSON-java
-*** Moshi
-*** Fastjson2
-*** Form
-*** Form Spring
-** metrics
-*** Dropwizard Metrics 4
-*** Dropwizard Metrics 5
-*** Micrometer
-** interceptors
-*** RequestInterceptor
-*** ResponseInterceptor
-*** MethodInterceptor
-**** Bean Validation (JSR-303)
-**** Bean Validation (Jakarta)
-**** HTTP Cache (ETag / Last-Modified)
-** extras
-*** Hystrix
-*** SLF4J
-*** Mock
-*** Annotation Error Decoder
-@endmindmap
+@startmindmap
+* Feign
+** clients
+*** java.net.URL
+*** Apache HTTP
+*** Apache HC5
+*** Google HTTP
+*** Java 11 Http2
+*** OK Http
+*** Ribbon
+** async clients
+*** java.net.URL
+*** Apache HC5
+*** OkHttp
+*** Vertx
+*** Reactive Wrappers
+** contracts
+*** Feign
+*** JAX-RS
+*** JAX-RS 2
+*** JAX-RS 3 / Jakarta
+*** JAX-RS 4
+*** Spring
+*** SOAP
+*** SOAP Jakarta
+*** Spring boot (3rd party)
+** language
+*** Kotlin
+*** GraphQL
+
+left side
+
+** encoders/decoders
+*** Multi encoder (predicate based, experimental)
+*** Multi decoder (predicate based, experimental)
+*** GSON
+*** JAXB
+*** JAXB Jakarta
+*** Jackson
+*** Jackson 3
+*** Jackson JAXB
+*** Jackson Jr
+*** Sax
+*** JSON-java
+*** Moshi
+*** Fastjson2
+*** Form
+*** Form Spring
+** metrics
+*** Dropwizard Metrics 4
+*** Dropwizard Metrics 5
+*** Micrometer
+** interceptors
+*** RequestInterceptor
+*** ResponseInterceptor
+*** MethodInterceptor
+**** Bean Validation (JSR-303)
+**** Bean Validation (Jakarta)
+**** HTTP Cache (ETag / Last-Modified)
+** extras
+*** Hystrix
+*** SLF4J
+*** Mock
+*** Annotation Error Decoder
+@endmindmap
diff --git a/vertx/feign-vertx/pom.xml b/vertx/feign-vertx/pom.xml
index ed81465284..47c21ef8b9 100644
--- a/vertx/feign-vertx/pom.xml
+++ b/vertx/feign-vertx/pom.xml
@@ -31,7 +31,7 @@
11
- 2.22.1
+ 2.22.2
diff --git a/vertx/feign-vertx4-test/pom.xml b/vertx/feign-vertx4-test/pom.xml
index 549e79fb33..38581958eb 100644
--- a/vertx/feign-vertx4-test/pom.xml
+++ b/vertx/feign-vertx4-test/pom.xml
@@ -30,7 +30,7 @@
Tests with Vertx 4.x.
- 4.5.31
+ 4.5.32
diff --git a/vertx/feign-vertx5-test/pom.xml b/vertx/feign-vertx5-test/pom.xml
index f24c15a1ac..e574a69db3 100644
--- a/vertx/feign-vertx5-test/pom.xml
+++ b/vertx/feign-vertx5-test/pom.xml
@@ -30,7 +30,7 @@
Tests with Vertx 5.x.
- 5.1.5
+ 5.1.6