Port multi-encoder to master using predicates - #3527
Conversation
Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com> Signed-off-by: Marvin Froeder <velo.br@gmail.com>
Co-authored-by: Yevhen Vasyliev <ye.vasyliev@gmail.com> Signed-off-by: Marvin Froeder <velo.br@gmail.com>
Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com> Signed-off-by: Marvin Froeder <velo.br@gmail.com>
Co-authored-by: Yevhen Vasyliev <ye.vasyliev@gmail.com> Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com> Signed-off-by: Marvin Froeder <velo.br@gmail.com>
Signed-off-by: Marvin Froeder <velo.br@gmail.com>
Signed-off-by: Marvin Froeder <velo.br@gmail.com>
|
@yvasyliev @trumpetinc — this is your multi-encoder work from #3485 ported to The commits carry your authorship and co-author trailers, so the credit stays with you. What had to change, and why. #3485 is built on Where it landed, after the direction in #3485 (comment) and #3485 (comment): // encoders that declare what they handle can just be listed
Feign.builder().encoder(new DefaultEncoder(), new GsonEncoder(), new JAXBEncoder())
// anything that doesn't declare itself gets paired with a predicate
MultiEncoder.builder(new DefaultEncoder())
.add(new GsonEncoder())
.add(EncoderPredicate.xmlContentType(), someXmlEncoder)
.add((object, bodyType, template) -> bodyType == byte[].class, binary)
.build()
Specifically useful to hear from you:
One thing worth flagging: a wrapper that doesn't forward The whole surface is |
|
BTW, if you want to re-open this PR with you as author, I can close it and leave to you as author |
|
Quick Summary of my understanding Then if we want the Encoder to "just work" without having to add a custom predicate for it, then that maker of the custom Encoder needs to implement PredicateEncoder and provide a predicate. ok - I see what you are doing. It effectively adds the canEncode() method to any "modern" Encoder that wants to partake in the MultiEncoder paradigm. And it forces a semantic change based on participating in the new MultiExtractor mechanism instead of forcing everyone to adopt the change. Any existing legacy Encoder won't be able to be part of MultiEncoder until it is updated. I like this approach. Question/Suggestion 1: Why not back-fill this for DefaultEncoder and say that MultiEncoder doesn't support a default encoder at all? Question/Suggestion 2 I am a little concerned about the semantics of the overloaded BaseBuilder.encoder() method. When users see: they are going to expect that enc1 will be tried first, then enc2, then enc3. Not enc2, enc3, enc1. Having the "default" encoder be first is really non-obvious. I realize this will force a different method name on builder (like I'm thinking that removing the concept of a default encoder from MultiEncoder will simplify things considerably, at a pretty low cost to existing users. Basically, existing users with existing non-predicate encoder continue using the existing encoder() method. Users who want to take advantage of chaining use encoders(). And encoder() can eventually be deprecated. I look forward to hearing your thoughts. |
Yes - this addresses it cleanly, especially because PredicatedEncoder implements Encoder. You have basically inverted the problem in a very elegant way. I believe that with your approach it would be possible to stack PredicatedEncoders right? So if someone wanted to be more selected with a GsonEncoder than the default GsonEncoder predicate, they could do that? |
Removal of the default method (eventually replacing it with a returned boolean) was needed because it forces implementers to account for the change in semantics. You address this by forcing implementers to add To be clear, the approach in this PR does address the semantic change issue - but I still recommend not having default on canEncode(). Here's why: There is a reasonable discussion about how much we want to protect users from themselves, but if someone has a custom Encoder implementation, and they just slap PredicateEncoder on the implements line of the class, it is very unlikely to produce good results. I really think that each PredicateEncoder needs to decide the exact criteria under which it could return true from this method. For JsonEncoder, not a problem - it can encode anything, so canEncode will always return true. But that is not the general case. I think a little extra code on the built-in PredicateEncoder implementations is worth it to force Encoder implementors to explicitly declare their predicate. I won't object if we leave it as default (because you have forced the developer to actually make changes to their code) - but I think it's still better to not give them a default that could shoot them in the foot. |
I think it should be driven by the order of the encoders. If we remove the concept of a default encoder from MultiEncoder (per my discussion above), then this becomes simple for the user to understand. If they want the DefaultEncoder to win, they should put it first. Let's just make DefaultEncoder so it is a PredicateEncoder. |
I reviewed all of the encoders that we had to touch in https://github.com/OpenFeign/feign/pull/3485/changes and the only ones that I found that you didn't cover already were the form encoders. FormEncoder and SpringFormEncoder currently wrap a delegate. We will eventually want to see people move to the new multipart encoding that @yvasyliev is proposing, so it may not be worth back-porting - but I don't think it would be difficult to make those a PredicatedEncoder. The challenge is that for backwards compatibility we are going to have to continue allowing the delegate to be passed in on the constructor - but the FormEncoder doesn't know the predicate characteristics of the delegate, which means that it will be unable to accurately implement canEncode() that covers the delegate. FormEncoder could check the type of it's delegate and pass canEncode() if possible, but if the user passes a custom Encoder (non-predicated) as delegate we are stuck. Maybe the way to handle this is to intentionally not make FormEncoder predicated (like you have done). Then add a FormEncoder.createPredicatedFormEncoder() method (disallowing delegate, and not passing and handle the null delegate gracefully... |
… predicate Signed-off-by: Marvin Froeder <velo.br@gmail.com>
|
@trumpetinc — all of this landed, thanks. Point by point: The default encoder is gone. You were right that Feign.builder()
.encoders(
new JacksonEncoder(),
new JAXBEncoder(),
PredicatedEncoder.of(EncoderPredicate.any(), new DefaultEncoder())); // the default, lastA default is now just an encoder with a yes-man predicate. Nothing matching is an error, not a silent fallback. Predicates describe themselves, so the failure can say what it considered:
PredicatedEncoder.of((object, bodyType, template) -> bodyType == byte[].class, binaryEncoder);Stacking works, in both directions. PredicatedEncoder.narrowing(
EncoderPredicate.contentType("application/vnd.acme+json"), new GsonEncoder());
// canEncode == (Content-Type is application/vnd.acme+json and GsonEncoder accepts it)The form encoders got your factory. Feign.builder()
.encoders(FormEncoder.createPredicatedFormEncoder(), new JacksonEncoder());The constructors still take a delegate for backwards compatibility, and On the ordering footgun ( Same treatment is going onto the decode side in #3528. |
|
@velo "footgun" is my new favorite phrase - I'll be using that in my design sessions - thank you for the laugh. Some things to consider:
|
| StringBuilder message = | ||
| new StringBuilder("Unable to encode ") | ||
| .append(bodyType == null ? "request body" : bodyType.getTypeName()) | ||
| .append(" (Content-Type: ") |
There was a problem hiding this comment.
Instead of reporting only the content-type header, I suggest enumerating all of the headers in the template. This will provide more useful info for users who specify additional request headers via annotations, etc...
| message.append("\n - ").append(PairedEncoder.describe(encoder)); | ||
| } | ||
| return message | ||
| .append("\nAdd an encoder guarded by EncoderPredicate.any() last to act as a default.") |
There was a problem hiding this comment.
I do not think that adding encoders with predicate .any() should be general guidance. It could also be confusing for users who are using builder.encoders() as the concept of predicates is going to be a hidden implementation detail for most users.
Suggested wording "Add an encoder that supports processing of this request".
|
|
||
| /** Builds the multi-encoder. */ | ||
| public MultiEncoder build() { | ||
| return new MultiEncoder(encoders); |
There was a problem hiding this comment.
suggest throwing exception if encoders is empty
| * @see EncoderPredicate | ||
| */ | ||
| @Experimental | ||
| public class MultiEncoder implements Encoder { |
There was a problem hiding this comment.
Suggest making MultiEncoder implement PredicatedEncoder.
This is easy to implement and opens up all sorts of possibilities for composition.
Here's the predicate:
return encoders.stream().anyMatch(e -> e.canEncode(object, bodyType, template));
| return describe(encoder) + " when " + predicate; | ||
| } | ||
|
|
||
| /** Requires both the predicate and, when the encoder declares one, its own applicability. */ |
There was a problem hiding this comment.
I'm trying to wrap my head around the need for PairedEncoder.narrow() - couldn't we achieve the same thing with multiEncoderBuilder.add(newPredicate, originalPredicatedEncoder) ? The newPredicate will be evaluated first, then if it passes, the predicate in the originalPredicateEncoder would be evaluated.
I think this is effectively the EncoderPredicate.and() that is happening in PairdEncoder.narrow() ?
There was a problem hiding this comment.
I retract my comment on this. narrow() allows users to add a non-predicate encoder to the builder.encoders() method without having to mess with MultiEncoder.builder(). That makes sense. It also allows tweaking the predicate without having to use MultiEncoder.builder() directly.
|
|
||
| /** The encoder's own {@code toString} when it has one, its class name otherwise. */ | ||
| static String describe(Encoder encoder) { | ||
| Class<?> type = encoder.getClass(); |
There was a problem hiding this comment.
The default toString method includes the class name pretty cleanly - am I missing a corner case where encoder.toString() would be insufficient on it's own?
Basically, is the reflection here worth it?
There was a problem hiding this comment.
I retract my comment on this. Making it easy for users to troubleshoot their configuration issues is good - and a little effort making the messages easier to read is worth it.
| }); | ||
| } | ||
|
|
||
| private static Stream<String> contentTypes(RequestTemplate template) { |
There was a problem hiding this comment.
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!
| } | ||
|
|
||
| /** | ||
| * The requests a delegate-less form encoder can handle: a form or multipart {@code Content-Type}, |
There was a problem hiding this comment.
minor thing, but this comment is a little hard to read...
Suggest adding 'Creates a predicate for' to the beginning:
"Creates a predicate for requests a delegate-less form encoder can handle"
| * @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. |
There was a problem hiding this comment.
At some point, it may be worth deprecating and calling out that the preferred way to specify downstream encoders is to use builder.encoders() instead of the delegate. I get why we wouldn't want to do that while things are @experimental.
|
@trumpetinc — thanks for both retractions; our comments crossed in flight. We landed in the same place on That leaves the header question as the only genuinely open item from your review. #3534 reports Also: I have sent you a repo invite (triage), so you can be a formal reviewer instead of an @-mention — you are already requested on #3534, which takes effect once you accept. |
|
Good point about not wanting the Authorization header to land in the logs... (I really wish the original Feign implementation had used a dedicated class to specify headers instead of passing a Map around - it would have made sanitizing the headers super simple)... Quick discussion about reporting the headers: I think it's also worth pointing out that because of where Encoder operates in the lifecycle, most uses will not have Authorization header in the template (most users will add a request listener to insert that auth header). But there are some cases where an explicit @Header might have been added, and we do need to account for that. My counter-argument for including all headers (possibly with masking or eliminating the auth header) is that for complex use cases, users may be using custom headers to drive encoding behavior. Not having that information could make debugging more challenging. My counter-counter argument is that if someone is doing that level of complexity, they an set breakpoints and debug it. So at this point, I think the current approach is going to cover 99.9% of scenarios. And if more complex error reporting becomes a need, it can be handled in a future non-breaking PR. The fact that adding more headers later is non-breaking indicates that going with "least risky" is the correct course of action. So I support your decision to leave that as-is. |
Ports the multi-encoder feature from #3485 to
master.Credit: this is @yvasyliev's and @trumpetinc's work — commits carry their authorship and co-author trailers.
Usage
Most first-party encoders now declare what they can handle, so they can simply be listed:
For an encoder that does not declare itself — including one you do not control — pair it with a predicate:
The first argument is the default encoder, consulted last. Delegates are tried in the order added.
Why a port instead of a cherry-pick
#3485 targets
14.xand could not come across as-is:Encoder#encodechangingvoid->boolean, which is source and binary breaking for every third-partyEncoder. Legitimate on14.x, not on13.14.api/module and thefeign.core.codecpackage, neither of which exists onmaster.A predicate carries the "can you handle this?" decision instead, so
Encoderis untouched and every existing encoder keeps working.Design
Settled over #3485 (comment) and #3485 (comment). Four shapes were prototyped as compiling Java before picking this one; notes in the "Alternatives rejected" section below.
EncoderPredicate—@FunctionalInterface,boolean canEncode(Object, Type, RequestTemplate), withjsonContentType(),xmlContentType(),contentType(mediaType),emptyBody(),bodyType(type),formEncoded()andand/or/negatePredicatedEncoder extends Encoder—@FunctionalInterface;canEncodeis adefaultreturningtrue, soencode()stays the single abstract methodMultiEncoder.builder(defaultEncoder)—.add(PredicatedEncoder)and.add(EncoderPredicate, Encoder)BaseBuilder.encoder(Encoder defaultEncoder, PredicatedEncoder... encoders)Util.isJsonContentType/isXmlContentType/hasContentTypeThe whole surface is
@Experimentalwhile the shape settles — aMultiDecodercounterpart would likely influence it.Encoders granted
PredicatedEncoderAll keep
implements Encoderexplicitly.Util.isJsonContentType): Gson, Jackson, Jackson 3, Jackson Jr, Jackson JAXB, Moshi, Fastjson2, JSON-javaUtil.isXmlContentType): JAXB, JAXB Jakarta, SOAP, SOAP JakartacanEncodeto the wrapped encoder): the threeMeteredEncodersDeliberately left alone:
DefaultEncoder(it is the fallback),FormEncoder/SpringFormEncoder(they already have their own delegate-fallback logic), andGraphqlEncoder(it rewrites the body toMAP_STRING_WILDCARDbefore delegating, so forwarding its delegate's predicate against the originalbodyTypewould be wrong).Capabilities interaction
Verified by test (
MultiEncoderCapabilityTest), not by assertion:Capability.enrich(Encoder)wraps theMultiEncoderas one unit; routing is unaffected. Capabilities never reach the delegates, so nothing is erased.canEncodeclaims every request, since the default accepts everything. That is why the metrics modules forward it, and it is documented onPredicatedEncoderand in the README.feign.Encodertimer for the composite rather than per format.Alternatives rejected
implements Encoder, PredicatedEncoderwhereMultiEncodertakes plainEncoders and asks viainstanceof): an encoder that has not opted in accepts everything and silently becomes a greedy catch-all, making the default encoder unreachable. Ordering would decide correctness with no signal at the call site.Deviations from #3485
PredicatingEncoder->PredicatedEncoder, and the predicate method iscanEncoderather thantest.headers().getOrDefault("Content-Type", ...)lookup. Since avoid duplicate Content-Length header in DefaultClient #3451 was the same bug one header field over, the header name is now matched case-insensitively and the regexes are compiledPatternconstants. Covered by a test.Docs
canEncodecontract for wrappers.Verification
coreplus all 15 touched modules, plusgraphql/form/form-spring: greentest-compile: onlyfeign-jaxrs3fails, identically on plainmaster(javax.ws.rs.client.ClientBuildernot found) — pre-existing and unrelatedgit-code-format-maven-plugin:validate-code-formatpassesFollow-ups (not in this PR)
MultiDecodercounterpart — the decode side was never covered by Multi encoder #3485.Capability.invokecallsmethod.invokewithoutsetAccessible(true), so a capability declared as aprivateclass fails withUnable to enrich .... Pre-existing, unrelated, found while writing the capability test.