Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@
Moshi, Fastjson2, JSON-java) and XML encoders (JAXB, JAXB Jakarta, SOAP, SOAP Jakarta) now declare
themselves, and the metrics modules' `MeteredEncoder` forwards `canEncode` to the encoder it
wraps. The `Encoder` interface is unchanged, so existing encoders keep working (#3485).
* Add `@Experimental` `MultiDecoder`, `PredicatedDecoder` and `DecoderPredicate`, the decode-side
counterpart, letting a single client route each response to the right decoder. Decoders declare
what they can handle by implementing `PredicatedDecoder`; anything else is paired with a predicate
via `PredicatedDecoder.of(predicate, decoder)` or `MultiDecoder.builder()`. Decoders are consulted
in the order given and a response nothing accepts fails with a `DecodeException` naming what was
tried, so a default is a decoder guarded by `DecoderPredicate.any()` listed last. The first-party
JSON decoders (Gson, Jackson, Jackson 3, Jackson Jr, Jackson JAXB, Moshi, Fastjson2, JSON-java)
and XML decoders (JAXB, JAXB Jakarta, SAX, SOAP, SOAP Jakarta) now declare themselves, and
`OptionalDecoder` and the metrics modules' `MeteredDecoder` forward `canDecode` to the decoder
they wrap. The `Decoder` interface is unchanged, so existing decoders keep working.
* `JAXBContextFactory.withProperty` is now applied when creating Unmarshallers, not only
Marshallers. Marshaller-only properties are skipped on unmarshal (#3056).
* Add support for the HTTP QUERY method (RFC 10008) — safe, idempotent, and cacheable with a
request body. `HttpCacheInterceptor` includes QUERY in its default cacheable set and
incorporates a body hash into the cache key to reduce cross-body collisions.
Expand Down
110 changes: 110 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
34 changes: 34 additions & 0 deletions core/src/main/java/feign/BaseBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@
import feign.Request.Options;
import feign.codec.Codec;
import feign.codec.Decoder;
import feign.codec.DecoderPredicate;
import feign.codec.DefaultDecoder;
import feign.codec.DefaultEncoder;
import feign.codec.DefaultErrorDecoder;
import feign.codec.Encoder;
import feign.codec.EncoderPredicate;
import feign.codec.ErrorDecoder;
import feign.codec.MultiDecoder;
import feign.codec.MultiEncoder;
import feign.codec.PredicatedDecoder;
import feign.codec.PredicatedEncoder;
import feign.interceptor.MethodInterceptor;
import feign.interceptor.MethodInterceptors;
Expand Down Expand Up @@ -133,6 +136,37 @@ public B decoder(Decoder decoder) {
return thisB();
}

/**
* Configures a {@link MultiDecoder} built from decoders that declare their own applicability.
*
* <p>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}.
*
* <pre>
* Feign.builder()
* .decoders(
* new JacksonDecoder(),
* new JAXBDecoder(),
* PredicatedDecoder.of(DecoderPredicate.any(), new DefaultDecoder()))
* </pre>
*
* <p>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();
Expand Down
90 changes: 76 additions & 14 deletions core/src/main/java/feign/Util.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,12 @@ public class Util {
/** The HTTP Content-Length header field name. */
public static final String CONTENT_LENGTH = "Content-Length";

/** The HTTP Content-Encoding header field name. */
public static final String CONTENT_ENCODING = "Content-Encoding";

/** The HTTP Content-Type header field name. */
public static final String CONTENT_TYPE = "Content-Type";

/** The HTTP Content-Encoding header field name. */
public static final String CONTENT_ENCODING = "Content-Encoding";

/** The HTTP Accept-Encoding header field name. */
public static final String ACCEPT_ENCODING = "Accept-Encoding";

Expand Down Expand Up @@ -399,6 +399,20 @@ public static boolean isJsonContentType(RequestTemplate template) {
return hasContentTypeMatching(template, JSON_CONTENT_TYPE);
}

/**
* Checks whether the {@code Content-Type} header of the given response denotes JSON.
*
* <p>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.
*
Expand All @@ -413,6 +427,20 @@ public static boolean isXmlContentType(RequestTemplate template) {
return hasContentTypeMatching(template, XML_CONTENT_TYPE);
}

/**
* Checks whether the {@code Content-Type} header of the given response denotes XML.
*
* <p>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}.
Expand All @@ -424,18 +452,45 @@ public static boolean isXmlContentType(RequestTemplate template) {
*/
@Experimental
public static boolean hasContentType(RequestTemplate template, String mediaType) {
return contentTypes(template)
.anyMatch(
contentType -> {
String trimmed = contentType.trim();
return trimmed.regionMatches(true, 0, mediaType, 0, mediaType.length())
&& (trimmed.length() == mediaType.length()
|| trimmed.charAt(mediaType.length()) == ';');
});
return matchesMediaType(contentTypes(template), mediaType);
}

/**
* Checks whether the {@code Content-Type} header of the given response starts with the given
* media type, ignoring case and any parameters such as {@code ;charset=utf-8}.
*
* @param response the response to check
* @param mediaType the media type to look for, for example {@code text/csv}
* @return {@code true} if the content type matches, {@code false} otherwise
*/
@Experimental
public static boolean hasContentType(Response response, String mediaType) {
return matchesMediaType(contentTypes(response), mediaType);
}

private static boolean matchesMediaType(Stream<String> 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<String> contentTypes(RequestTemplate template) {
return template.headers().entrySet().stream()
return contentTypes(template.headers());
}

private static Stream<String> contentTypes(Response response) {
if (response == null || response.headers() == null) {
return Stream.empty();
}
return contentTypes(response.headers());
}

private static Stream<String> contentTypes(Map<String, Collection<String>> headers) {
return headers.entrySet().stream()
.filter(header -> CONTENT_TYPE.equalsIgnoreCase(header.getKey()))
.map(Map.Entry::getValue)
.filter(Objects::nonNull)
Expand All @@ -444,7 +499,14 @@ private static Stream<String> contentTypes(RequestTemplate template) {
}

private static boolean hasContentTypeMatching(RequestTemplate template, Pattern pattern) {
return contentTypes(template)
.anyMatch(contentType -> pattern.matcher(contentType.trim()).matches());
return matchesPattern(contentTypes(template), pattern);
}

private static boolean hasContentTypeMatching(Response response, Pattern pattern) {
return matchesPattern(contentTypes(response), pattern);
}

private static boolean matchesPattern(Stream<String> contentTypes, Pattern pattern) {
return contentTypes.anyMatch(contentType -> pattern.matcher(contentType.trim()).matches());
}
}
Loading