Skip to content

Add MultiDecoder to select a decoder per response - #3528

Merged
velo merged 7 commits into
masterfrom
feat/multi-decoder
Aug 20, 2026
Merged

Add MultiDecoder to select a decoder per response#3528
velo merged 7 commits into
masterfrom
feat/multi-decoder

Conversation

@velo

@velo velo commented Aug 19, 2026

Copy link
Copy Markdown
Member

The decoder counterpart of #3527. 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 — and today that means one
Feign instance per format.

MultiDecoder hands each response to the first decoder that accepts it. Decoders come from two
places:

  • a decoder that implements PredicatedDecoder declares its own applicability and can simply be
    listed;
  • any other decoder is paired with a DecoderPredicate, via PredicatedDecoder.of(...).
Feign.builder()
    .decoders(
        new JacksonDecoder(),
        new JAXBDecoder(),
        PredicatedDecoder.of(DecoderPredicate.any(), new DefaultDecoder())); // the default, last
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())
        .build();

Decoders are consulted in registration order and that is the whole rule — there is no default slot
and no implicit fallback. A default is just a decoder with a yes-man predicate, registered last and
visibly so. This mirrors where #3527 ended up after @trumpetinc's review there.

What is in here

  • MultiDecoder, PredicatedDecoder and DecoderPredicate in feign.codec, all @Experimental.
  • BaseBuilder.decoders(PredicatedDecoder...), plural, as the shorthand. decoder(Decoder) is
    untouched and not deprecated.
  • canDecode has no default — an existing decoder cannot claim everything by slapping
    implements PredicatedDecoder on its declaration. DecoderPredicate is the functional
    interface; PredicatedDecoder.of(predicate, decoder) attaches a lambda to a decoder you do not
    own, and PredicatedDecoder.narrowing(predicate, decoder) ANDs onto what the decoder already
    declares.
  • Nothing matching is an error, not a silent fallback. The DecodeException names the status, the
    content type, the expected type and every decoder that was consulted, in order.
  • DecoderPredicate.any(), jsonContentType(), xmlContentType(), contentType(...),
    emptyBody(), status(...), returnType(...), composed with and/or/negate, plus
    describedAs("it is Tuesday", lambda) to name your own. Every predicate carries a description so
    the failure message can read back what it considered.
  • Util.isJsonContentType(Response), Util.isXmlContentType(Response) and
    Util.hasContentType(Response, String), so a decoder can declare itself without hand-rolling
    content-type parsing. Suffixed types such as application/vnd.github+json and
    application/soap+xml match.
  • 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.
  • OptionalDecoder and the metrics modules' MeteredDecoder forward canDecode to the decoder
    they wrap, so wrapping does not erase a delegate's self-declaration. OptionalDecoder unwraps
    Optional<T> before forwarding, matching what it does on decode.

Notes

  • 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.
    Predicates get the status, the headers and the expected return type; that is documented on both
    DecoderPredicate and PredicatedDecoder, and covered by a test that asserts the selected
    decoder still receives an unread body.
  • Decoder itself is unchanged, so every existing decoder keeps working — one that does not
    implement PredicatedDecoder declares nothing, which is why it belongs behind an explicit
    predicate rather than being listed bare.
  • A Capability sees the MultiDecoder as a single decoder, not one per delegate.

mvn clean install -Pdev passes.

velo added 6 commits August 19, 2026 11:27
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>
Signed-off-by: Marvin Froeder <velo.br@gmail.com>
Signed-off-by: Marvin Froeder <velo.br@gmail.com>
… predicate

Signed-off-by: Marvin Froeder <velo.br@gmail.com>
Signed-off-by: Marvin Froeder <velo.br@gmail.com>
@velo
velo merged commit 4b7c1eb into master Aug 20, 2026
4 checks passed
@velo
velo deleted the feat/multi-decoder branch August 20, 2026 15:38
@velo velo mentioned this pull request Aug 20, 2026
@trumpetinc

Copy link
Copy Markdown
Collaborator

@velo The description above is still referencing first argument as the default, so I'm assuming this is still a work in progress? Let me know when this is solid enough that you would like a code review.

@velo

velo commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

@trumpetinc — that was a stale description, sorry: I rewrote it after the direction your review took #3527 but never pushed the edit here. Fixed now.

The code that merged has no default slot: decoders(...) is plural, delegates are consulted in registration order, canDecode is abstract, and a default is PredicatedDecoder.of(DecoderPredicate.any(), new DefaultDecoder()) listed last — nothing matching throws a DecodeException naming every decoder it tried. It mirrors the encoder side exactly.

It did merge on the 20th, but the whole surface is @Experimental, so a review is still very much worth having — I'd rather move it now than after it freezes. Your #3527 review lands on the decode side one-for-one (MultiDecoder as a PredicatedDecoder, all headers in the error message, the .any() wording, empty-list guard), so I'll take both in one follow-up PR and tag you.

@velo

velo commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

@trumpetinc — your #3527 review is applied to both sides in #3534, ready for you.

Taken: Multi{Encoder,Decoder} are now predicated so they nest (with nested sets unfolded in the failure message); Default{Encoder,Decoder} declare themselves — you were right, and I was wrong to call it a yes-man predicate, though yours needs the null-body case DefaultEncoder.encode also handles; empty builder throws at wiring time; Builder.narrow(...) added; the form encoder javadoc and delegate note.

Pushed back on two, with reasoning in the PR description: narrowing isn't redundant with add(predicate, encoder)of replaces the encoder's own predicate rather than ANDing with it, which is what lets you widen a GsonEncoder onto a vendor content type — and the failure message reports Content-Type + Accept rather than the whole header map, since EncodeException messages reach logs and Authorization lives in there. The detail isn't lost: every predicate carries its own description and is printed alongside the codec it guards, so a predicate keyed on an unusual header says so itself.

Also sent you a repo invite so I can request you as a reviewer properly instead of @-mentioning you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants