Skip to content

Apply the mapping of oneOfBody(...).map(...) when used as an input#5427

Merged
adamw merged 2 commits into
masterfrom
fix/one-of-body-input-mapping
Jul 24, 2026
Merged

Apply the mapping of oneOfBody(...).map(...) when used as an input#5427
adamw merged 2 commits into
masterfrom
fix/one-of-body-input-mapping

Conversation

@adamw

@adamw adamw commented Jul 21, 2026

Copy link
Copy Markdown
Member

Fixes #5425.

Problem

EndpointIO.OneOfBody[O, T] carries a mapping: Mapping[O, T] (composed via .map on a oneOfBody(...)). The mapping was only applied on the output side (server response encode in EncodeOutputs, client response decode in ClientOutputParams). On the input side it was silently dropped:

  • server request decode: the shared ServerInterpreter (and both sttp stub-server decoders) decoded the request body with only the chosen variant's codec, so server logic received O instead of T — typically a ClassCastException → HTTP 500;
  • client request encode: all five client interpreters delegated directly to the head variant, feeding the unmapped T value to the variant's codec — ClassCastException when building the request.

Additionally, ClientOutputParams decoded mapped one-of outputs in the wrong order (mapping before variant codec) — harmless for identity mappings, wrong for anything else.

Fix

  • OneOfBody gained private[tapir] variantBodyWithAppliedMapping / headVariantBodyWithAppliedMapping, returning the variant's body with the one-of mapping composed into its codec (via Atom.map). For identity mappings the variant's body is returned unchanged — this method sits on the per-request hot path (DecodeBasicInputs.addBodyInput wraps every plain body input in a synthetic oneOfBody), so the no-op case must not copy the body/codec, and decode failures for unmapped bodies keep reporting the original input instance.
  • Mapping.id is now a singleton so that identity mappings are detectable (eq).
  • RichOneOfBody.chooseBodyToDecode returns the mapped body — fixing ServerInterpreter and both stub decoders with no changes to those files.
  • The five client interpreters (sttp-client, sttp-client4, http4s, play, play29) use headVariantBodyWithAppliedMapping; the stream-variant branches now encode through the (mapped) codec instead of casting the raw value.
  • ClientOutputParams decodes variant codec first, then the mapping.

Tests

  • New shared endpoints in 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 into ServerOneOfBodyTests (all server backends) and ClientBasicTests (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).
  • Stub-server regression tests in both sttp-stub modules: server-logic run through ServerInterpreter and a client-interpreter → stub round trip through SttpRequestDecoder.

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) StreamBodyWrapper client 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

adamw and others added 2 commits July 21, 2026 10:03
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
adamw merged commit 32e693b into master Jul 24, 2026
22 checks passed
@adamw
adamw deleted the fix/one-of-body-input-mapping branch July 24, 2026 08:07
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>
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.

[BUG] oneOfBody(...).map(...) silently drops the mapping when used as an input

1 participant