Apply the mapping of oneOfBody(...).map(...) when used as an input#5427
Merged
Conversation
The mapping carried by EndpointIO.OneOfBody was only applied on the output side (server encode, client decode). On the input side it was silently dropped: servers decoded request bodies using only the chosen variant's codec, and clients encoded request bodies by delegating directly to the head variant - typically causing a ClassCastException at runtime. The variant chosen for decoding (RichOneOfBody.chooseBodyToDecode) and for client-side encoding is now returned with the one-of-body's mapping composed into the variant's codec, so all server interpreters, the sttp stub servers, and all client interpreters apply it. Also fixes the inverted decode order in ClientOutputParams: the variant codec must decode the raw body before the one-of-body's mapping is applied, not after. Fixes #5425 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Mapping.id is now a singleton, so that identity mappings are detectable; OneOfBody.variantBodyWithAppliedMapping returns the variant's body unchanged for identity mappings. This avoids copying the body & codec on the per-request hot path (server interpreters wrap every plain body input in a one-of body), and keeps reporting the original input instance in decode failures for unmapped bodies. - headVariantBodyWithAppliedMapping returns an Option, removing the partial variants.head and the guard duplicated across the five client interpreters. - New test for the ClientOutputParams decode-order fix: a mapped one-of output decoded by clients through a non-identity variant codec (verified failing with the old order). - New server test for a mapped one-of output (guards EncodeOutputs against double application of the mapping). - Mirrored the mapped one-of body round-trip test in the sttp3 stub server, covering its own SttpRequestDecoder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
adamw
added a commit
that referenced
this pull request
Jul 24, 2026
Follow-up to #5427 (same bug family, spotted during its review). ## Problem The sttp-client3, http4s, and Play (play + play29) client interpreters cast the input value directly to the backend's stream type when setting a plain (non-`oneOfBody`) streaming request body — ignoring the stream body's codec. For stream bodies with a non-identity codec, e.g. `streamTextBody(...).map(...)`, this caused a `ClassCastException` when building the request: ``` java.lang.ClassCastException: class StreamWrapper cannot be cast to class fs2.Stream ``` The sttp-client4 interpreter already encoded through the codec, as do the server interpreters (stream input decode, stream output encode) and the client-side response decoding — client request encode was the one asymmetric leg of the round trip. ## Fix The plain `StreamBodyWrapper` branch in the four client interpreters now encodes through the body's codec before casting to the backend stream type, using the same idiom as the adjacent `oneOfBody` stream-variant branches from #5427. For the common identity codecs (`streamTextBody` & co. without `.map`) `encode` is the identity, so existing endpoints are unaffected. ## Tests New shared endpoint `in_stream_mapped_out_string` (a `streamTextBody(...).map(StreamWrapper(_))(_.stream)` input) in `tests/Streaming.scala`, exercised from `ClientStreamingTests` — so it runs in all seven client streaming suites (sttp3 fs2 + zio, sttp4 fs2 + zio, http4s, play, play29). Verified failing before the fix on http4s, sttp3 (both variants), and play with the exact `ClassCastException`s above, and passing on sttp-client4 (which was never affected). Verified locally: full client suites green — sttp3 (163), sttp4 (158), http4s (69, + 69 on Scala 3), play/play29 (68 each) — plus cross-compilation on Scala 2.12/2.13/3 including the JS tests module. ## Noted for possible follow-up (pre-existing, untouched here) - `sttp4 ws/WebSocketEndpointToSttpClient.scala` silently discards a streaming request body on WS endpoints (`val (reqWithInput, _) = ...`). - `sttp4 EndpointToSttpClientBase` one-of stream-variant branch casts the encoded value to `InputStream` unconditionally, which would CCE for genuine fs2/pekko streams via the streaming client. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5425.
Problem
EndpointIO.OneOfBody[O, T]carries amapping: Mapping[O, T](composed via.mapon aoneOfBody(...)). The mapping was only applied on the output side (server response encode inEncodeOutputs, client response decode inClientOutputParams). On the input side it was silently dropped:ServerInterpreter(and both sttp stub-server decoders) decoded the request body with only the chosen variant's codec, so server logic receivedOinstead ofT— typically aClassCastException→ HTTP 500;Tvalue to the variant's codec —ClassCastExceptionwhen building the request.Additionally,
ClientOutputParamsdecoded mapped one-of outputs in the wrong order (mapping before variant codec) — harmless for identity mappings, wrong for anything else.Fix
OneOfBodygainedprivate[tapir] variantBodyWithAppliedMapping/headVariantBodyWithAppliedMapping, returning the variant's body with the one-of mapping composed into its codec (viaAtom.map). For identity mappings the variant's body is returned unchanged — this method sits on the per-request hot path (DecodeBasicInputs.addBodyInputwraps every plain body input in a syntheticoneOfBody), so the no-op case must not copy the body/codec, and decode failures for unmapped bodies keep reporting the original input instance.Mapping.idis now a singleton so that identity mappings are detectable (eq).RichOneOfBody.chooseBodyToDecodereturns the mapped body — fixingServerInterpreterand both stub decoders with no changes to those files.headVariantBodyWithAppliedMapping; the stream-variant branches now encode through the (mapped) codec instead of casting the raw value.ClientOutputParamsdecodes variant codec first, then the mapping.Tests
tests/OneOfBody.scala: a mapped one-of input (in_one_of_json_xml_text_mapped_out_string) and a mapped one-of output (in_string_out_one_of_json_xml_text_mapped), wired intoServerOneOfBodyTests(all server backends) andClientBasicTests(all client interpreters). All new tests were verified to fail before the fix (the output-decode test was checked against the old decode order specifically).ServerInterpreterand a client-interpreter → stub round trip throughSttpRequestDecoder.Verified locally: stub suites (Scala 2.13 + 3), sttp-client3/4, http4s-client (2.13 + 3), play/play29 client suites against the test server, jdkhttp server one-of tests, cross-compilation for 2.12/2.13/3 + JS core,
core/mimaReportBinaryIssues, scalafmt.Note: a pre-existing inconsistency remains out of scope — the plain (non-one-of)
StreamBodyWrapperclient branches still cast the raw value without applying the codec (same family as this bug, but untouched here to keep the change focused).🤖 Generated with Claude Code