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
13 changes: 11 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
### Version 13.14

* `JAXBContextFactory.withProperty` is now applied when creating Unmarshallers, not only
Marshallers. Marshaller-only properties are skipped on unmarshal (#3056).
* Add `@Experimental` `MultiEncoder`, `PredicatedEncoder` and `EncoderPredicate`, letting a single
client route each request to the right encoder. Encoders declare what they can handle by
implementing `PredicatedEncoder`; anything else is paired with a predicate via
`PredicatedEncoder.of(predicate, encoder)` or `MultiEncoder.builder()`. Encoders are consulted in
the order given and a request nothing accepts fails with an `EncodeException` naming what was
tried, so a default is an encoder guarded by `EncoderPredicate.any()` listed last. `FormEncoder`
and `SpringFormEncoder` gain `createPredicatedFormEncoder()`, a delegate-free flavour that can
take part. The first-party JSON encoders (Gson, Jackson, Jackson 3, Jackson Jr, Jackson JAXB,
Moshi, Fastjson2, JSON-java) and XML encoders (JAXB, JAXB Jakarta, SOAP, SOAP Jakarta) now declare
themselves, and the metrics modules' `MeteredEncoder` forwards `canEncode` to the encoder it
wraps. The `Encoder` interface is unchanged, so existing encoders keep working (#3485).
* Add support for the HTTP QUERY method (RFC 10008) — safe, idempotent, and cacheable with a
request body. `HttpCacheInterceptor` includes QUERY in its default cacheable set and
incorporates a body hash into the cache key to reduce cross-body collisions.
Expand Down
125 changes: 125 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,131 @@ public class Example {
}
```

#### Multiple encoders

> This API is `@Experimental` and may change incompatibly, or be removed, in a future release.

A single client sometimes has to speak more than one format — JSON for most endpoints, XML for
a legacy one, plain bytes for an upload. `MultiEncoder` hands each request to the first encoder that
accepts it.

Most first-party encoders already declare what they can handle, so they can simply be listed, in the
order they should be consulted:

```java
interface MixedClient {
@RequestLine("POST /orders")
@Headers("Content-Type: application/json")
void createOrder(Order order);

@RequestLine("POST /legacy/orders")
@Headers("Content-Type: application/xml")
void createLegacyOrder(Order order);
}

public class Example {
public static void main(String[] args) {
MixedClient client = Feign.builder()
.encoders(new GsonEncoder(), new JAXBEncoder())
.target(MixedClient.class, "https://foo.com");
}
}
```

There is no implicit fallback. A request that no encoder accepts fails with an `EncodeException`
naming the encoders that were tried and what each one wants:

```
Unable to encode java.lang.String (Content-Type: text/plain) for POST /orders. Encoders tried, in order:
- GsonEncoder
- JAXBEncoder
Add an encoder guarded by EncoderPredicate.any() last to act as a default.
```

To get a default, pair an encoder with the predicate that accepts everything and list it **last**:

```java
Feign.builder()
.encoders(
new GsonEncoder(),
new JAXBEncoder(),
PredicatedEncoder.of(EncoderPredicate.any(), new DefaultEncoder()));
```

The same pairing works for any encoder that does not declare itself, including one you do not
control. `MultiEncoder.builder()` spells it out when a lambda reads better than a wrapper:

```java
Encoder encoder =
MultiEncoder.builder()
.add(new GsonEncoder()) // declares itself
.add(EncoderPredicate.xmlContentType(), someXmlEncoder) // paired
.add((object, bodyType, template) -> bodyType == byte[].class, binaryEncoder)
.add(EncoderPredicate.any(), new DefaultEncoder()) // the default, last
.build();
```

Encoders are consulted in the order they were added, so put the narrowest one first. Note that
`Content-Type: application/json` with a null body is claimed by a JSON encoder before
`EncoderPredicate.emptyBody()` gets a chance — order accordingly.

##### Declaring your own encoder

Implement `PredicatedEncoder` and say what you handle. `canEncode` has no default: an encoder that
declares nothing would claim every request, which is rarely what its author meant.

```java
public class MyEncoder implements PredicatedEncoder {

@Override
public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
return Util.isJsonContentType(template);
}

@Override
public void encode(Object object, Type bodyType, RequestTemplate template) {
// ...
}
}
```

`EncoderPredicate` is the `@FunctionalInterface` here, so predicates can be lambdas. It ships with
`any()`, `jsonContentType()`, `xmlContentType()`, `contentType(mediaType)`, `emptyBody()`,
`bodyType(type)` and `formEncoded()`, plus `and`/`or`/`negate` to combine them. Each one describes
itself, which is what shows up in the error message above; wrap your own lambdas in
`EncoderPredicate.describedAs("it is Tuesday", ...)` to read as well.

`PredicatedEncoder.of(predicate, encoder)` replaces whatever the encoder says about itself, so it
can widen an encoder as well as narrow it. To keep the encoder's own declaration and add to it, use
`narrowing`:

```java
// only this vendor content type, and only what Gson would have taken anyway
PredicatedEncoder.narrowing(
EncoderPredicate.contentType("application/vnd.acme+json"), new GsonEncoder());
```

**If you wrap an encoder, forward `canEncode` to your delegate**, otherwise wrapping silently
changes what the encoder handles. The metrics modules' `MeteredEncoder` forwards for exactly this
reason.

##### Form encoders

`FormEncoder` and `SpringFormEncoder` wrap a delegate encoder, so they cannot honestly declare what
they handle — the delegate's applicability is unknown to them. Instead, each offers a
delegate-free flavour that does:

```java
Feign.builder()
.encoders(
FormEncoder.createPredicatedFormEncoder(), // form and multipart requests only
new JacksonEncoder());
```

It accepts form and multipart requests carrying a map or a user pojo, and leaves everything else to
the encoders registered alongside it. Constructing one directly with a `null` delegate does the same
thing: anything it cannot encode itself fails with an `EncodeException` instead of being passed on.

### @Body templates
The `@Body` annotation indicates a template to expand using parameters annotated with `@Param`. You will likely need to add a `Content-Type` header.

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 @@ -26,7 +26,10 @@
import feign.codec.DefaultEncoder;
import feign.codec.DefaultErrorDecoder;
import feign.codec.Encoder;
import feign.codec.EncoderPredicate;
import feign.codec.ErrorDecoder;
import feign.codec.MultiEncoder;
import feign.codec.PredicatedEncoder;
import feign.interceptor.MethodInterceptor;
import feign.interceptor.MethodInterceptors;
import java.lang.reflect.Field;
Expand Down Expand Up @@ -94,6 +97,37 @@ public B encoder(Encoder encoder) {
return thisB();
}

/**
* Configures a {@link MultiEncoder} built from encoders that declare their own applicability.
*
* <p>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}.
*
* <pre>
* Feign.builder()
* .encoders(
* new JacksonEncoder(),
* new JAXBEncoder(),
* PredicatedEncoder.of(EncoderPredicate.any(), new DefaultEncoder()))
* </pre>
*
* <p>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();
Expand Down
76 changes: 76 additions & 0 deletions core/src/main/java/feign/Util.java
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -62,6 +63,9 @@ public class Util {
/** The HTTP Content-Encoding header field name. */
public static final String CONTENT_ENCODING = "Content-Encoding";

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

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

Expand All @@ -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<String, ?>}. */
public static final Type MAP_STRING_WILDCARD =
new Types.ParameterizedTypeImpl(
Expand Down Expand Up @@ -371,4 +384,67 @@ public static String getThreadIdentifier() {
+ "_"
+ currentThread.getId();
}

/**
* Checks whether the {@code Content-Type} header of the given template 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 template the request template to check
* @return {@code true} if the content type is JSON, {@code false} otherwise
*/
@Experimental
public static boolean isJsonContentType(RequestTemplate template) {
return hasContentTypeMatching(template, JSON_CONTENT_TYPE);
}

/**
* Checks whether the {@code Content-Type} header of the given template denotes XML.
*
* <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 template the request template to check
* @return {@code true} if the content type is XML, {@code false} otherwise
*/
@Experimental
public static boolean isXmlContentType(RequestTemplate template) {
return hasContentTypeMatching(template, XML_CONTENT_TYPE);
}

/**
* Checks whether the {@code Content-Type} header of the given template starts with the given
* media type, ignoring case and any parameters such as {@code ;charset=utf-8}.
*
* @param template the request template to check
* @param mediaType the media type to look for, for example {@code
* application/x-www-form-urlencoded}
* @return {@code true} if the content type matches, {@code false} otherwise
*/
@Experimental
public static boolean hasContentType(RequestTemplate template, String mediaType) {
return contentTypes(template)
.anyMatch(
contentType -> {
String trimmed = contentType.trim();
return trimmed.regionMatches(true, 0, mediaType, 0, mediaType.length())
&& (trimmed.length() == mediaType.length()
|| trimmed.charAt(mediaType.length()) == ';');
});
}

private static Stream<String> contentTypes(RequestTemplate template) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI - I have added a more robust content type header parser to one of the other PRs I have outstanding ( https://github.com/OpenFeign/feign/pull/3494/changes#diff-3d5ee4752b168285974eb09fc4782f489edeadba936b5c71dc59ff6a043d779d )

There is a lot of one-off code in Feign related to parsing the content type header, would be good to centralize that!

return template.headers().entrySet().stream()
.filter(header -> CONTENT_TYPE.equalsIgnoreCase(header.getKey()))
.map(Map.Entry::getValue)
.filter(Objects::nonNull)
.flatMap(Collection::stream)
.filter(Objects::nonNull);
}

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