Skip to content

Allow reading the request body in both security and main server logic - #5491

Open
magdzikk wants to merge 34 commits into
masterfrom
fix/extract-body-from-request-4442
Open

Allow reading the request body in both security and main server logic#5491
magdzikk wants to merge 34 commits into
masterfrom
fix/extract-body-from-request-4442

Conversation

@magdzikk

@magdzikk magdzikk commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #4442.

The problem

An endpoint that reads the request body in serverSecurityLogic and again in the main logic crashes on streaming backends:

val secureBase = endpoint
  .securityIn(stringBody)                    // read #1
  .serverSecurityLogic { body => ... }

secureBase.in(jsonBody[Person])              // read #2
  .serverLogic(user => person => ...)
java.lang.IllegalStateException: This publisher only supports one subscriber
  at org.playframework.netty.HandlerPublisher.subscribe(HandlerPublisher.java:167)

The failure is backend-dependent: vertx and armeria materialise the body eagerly and happen to work, so today the DSL permits something whose success is accidental per interpreter.

The approach

This PR adds an extractBodyFromRequest wrapper - it allows reading the body second time, but is ignored during client generation:

val secureBase = endpoint
  .securityIn(auth.bearer[String]())
  .securityIn(extractBodyFromRequest(stringBody))   // server-side only
  .serverSecurityLogic { case (token, raw) => verifyHmac(token, raw) }

secureBase.post
  .in("people")
  .in(jsonBody[Person])                             // the documented body
  .serverLogic(user => person => ...)

We only support byte-like types: String, Array[Byte], ByteBuffer, InputStream, InputStreamRange. Other types are rejected at compile time (File, multipart, streaming and oneOfBody).

⚠️ Breaking change

An endpoint declaring two ordinary bodies now throws IllegalArgumentException at route construction:

endpoint.securityIn(stringBody).in(stringBody)   // now rejected at startup

This previously "worked" on eagerly-buffering backends (vertx, armeria, play) and failed confusingly on streaming ones. No endpoint in tapir itself does this, but user code may. Migration is one line: wrap either declaration in extractBodyFromRequest.

magdzikk and others added 27 commits August 20, 2026 15:20
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… reads

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…through comment

ByteArrayBody and ByteBufferBody previously returned views over the same
cached array; since byteArrayBody/byteBufferBody are identity codecs, a
caller mutating the returned value in place silently corrupted the cache
for subsequent reads. Now clone the array before handing it out for both
representations. Also corrected the comment/class doc claiming file and
multipart bodies "cannot be extracted" - they can be combined with an
extracted body in the type system; that combination is instead rejected
by EndpointVerifier at route construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fix a stray pre-rename reference to EndpointVerifier in a
CachingRequestBody comment (should be EndpointBodyVerifier), and run
scalafmt on ExtractedBody.scala and Tapir.scala so core/scalafmtCheckAll
passes on the lines this branch added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run scalafmt over DecodeBasicInputs.scala, CachingRequestBody.scala, and
ServerInterpreterExtractedBodyTest.scala so serverCore/scalafmtCheckAll
passes; churn stays confined to lines this branch introduced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fail fast at server startup instead of at request time: FilterServerEndpoints.apply
now calls EndpointBodyVerifier.verify and throws IllegalArgumentException for
structurally-invalid endpoints (e.g. two primary bodies split across securityIn/in).
This covers netty, http4s, akka, pekko, play, armeria, jdkhttp, nima and the stubs.
Backends that build routes from a single endpoint without going through
FilterServerEndpoints (vertx future/cats/zio route and blockingRoute, finatra
toRoute) get the equivalent verifyOne check; zio-http's list-shaped toHttp gets
the verify check.
Extracted bodies (extractBodyFromRequest) share the wire body with the
primary input, so they must not be documented as a second request body.
Filters them out of OpenAPI paths/parameters, apispec schema generation,
the default-400 heuristic, and the gRPC protobuf message interpreter.
Each client's setInputParams now short-circuits on EndpointIO.Body inputs
marked as extracted (RichEndpointIOBody#isExtracted), skipping them instead
of encoding them onto the outgoing request. There is only one body on the
wire; the extracted declaration exists purely so server-side security logic
can decode it a second time.
Adds a ServerSecurityTests case pairing extractBodyFromRequest(stringBody)
in securityIn with stringBody in the main in, asserting both the security
and main logic see the same decoded payload, plus a companion case
asserting a security failure short-circuits cleanly. Runs across all
~22 wired backend test modules.
Explains how to mark a duplicate request body read in serverSecurityLogic
with extractBodyFromRequest, why it stays off the wire and out of the
generated docs, and the compile-time restriction to replayable raw body
types. Documents EndpointBodyVerifier: errors thrown at route construction
vs. warnings, which are surfaced only by calling the verifier directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…anyPrimaries wording

primaryBodies treated OneOfBody as an opaque atom, so streamingPrimary and
nonReplayablePrimary never inspected its variants: an extracted body combined
with a oneOfBody of streaming or file/multipart variants passed verification
and reproduced the double-subscribe bug at request time. Flatten variants via
bodyAsAtom before those checks.

Also compute primary body counts per container so the "declares a request
body in both securityIn and in" message is only used when that's actually the
shape; two bodies in the same container now get accurate wording.
Inline pattern match in flatMap failed to type-check on 2.12 (existential
wildcard binding across branches); extracting it into a small helper with an
explicit signature fixes it on both 2.12 and 2.13.
Discard the unused interpreter.apply result in
ServerInterpreterExtractedBodyTest, and drop the now-unused idMonad val and
its imports from FilterServerEndpointsTest (Identity is still used by
serverSecurityLogic[Unit, Identity]).
Explain that an ordinary file/multipart/streaming body can't be combined
with an extracted body either, and call out that two ordinary bodies
(.securityIn(stringBody).in(stringBody)) now throw IllegalArgumentException
at route construction - a breaking change for backends that used to buffer
eagerly (Vert.x, Armeria, Play) - with the one-line extractBodyFromRequest
migration.
…protobuf

- Mark CachingRequestBody.cachedBytes @volatile; cheap given the whole body
  is already buffered, and removes an argument from review.
- Document in CachingRequestBody.bytes that the security and main decode
  phases always pass the same maxBytes, because se.info and se.endpoint.info
  are the same object.
- EndpointToProtobufService.forIO now skips extracted bodies, matching
  EndpointToProtobufMessage, so a .proto file can no longer reference an
  undefined message for a body that's excluded from the API contract.
… joining

throwOnErrors is a validation entry point, not a path-routing helper, so it
belongs next to verify/verifyOne rather than on FilterServerEndpoints. Update
all call sites: FilterServerEndpoints, zio-http, the three vertx interpreters,
and finatra. Pure move, no behaviour change except joining multiple error
messages with newlines instead of spaces, so several invalid endpoints don't
run together into one unreadable line.
The parameter shadowed the class field of the same name, while
decodeStreamingBody two lines away deliberately reads the field. Rename it
throughout decodeBody (both overloads), decodeExtractedBodies, and
decodeExtractedBody so the distinction between "this call's body reader" and
"the interpreter's underlying request body" is visible. No behaviour change.
Asserts maxBytes is passed through to the delegate on the first read, and
that a later read with a different maxBytes has no effect once the body is
cached.
Pattern-matching on RawBodyType already refines R, so most asInstanceOf
calls were unnecessary; the InputStream case uses an ascription instead.
Comments trimmed to one line each, and the @volatile comment no longer
claims a plain var is what's used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop the throwOnErrors doc, which repeated the object's and added nothing
over the method name, and cut the duplicated sentence from
EndpointBodyProblems. The object doc now records the one non-obvious
fact instead: warnings are not logged, so callers must assert on them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop the mechanism detail (ReplayableRawBody instances, which bodies are
EndpointIO.Body values) - readers need the restriction, not how it's
implemented - and cut the drained-request rationale from the paragraph
that follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@magdzikk
magdzikk marked this pull request as ready for review August 28, 2026 11:08
Comment thread doc/endpoint/security.md Outdated
Comment thread doc/server/logic.md Outdated
The docs describe tapir as it is; what used to work on eagerly-buffering
backends belongs in release notes, not here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
*/
def extractBodyFromRequest[R, T](body: EndpointIO.Body[R, T])(implicit ev: ReplayableRawBody[R]): EndpointIO.Body[R, T] = {
val _ = ev // evidence is only a compile-time restriction
body.attribute(ExtractedBody.attributeKey, ExtractedBody())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I understand that the extractBodyFromRequest(stringBody) syntax mirrors extractFromRequest, but I'm wondering if there it wouldn't be more readable if this used the usual attribute-adding syntax using an extension method, which could explicitly say that this is a second body definition. E.g. stringBody.extractAsSecondary or stringBody.secondaryForServer or sth like this

@magdzikk magdzikk Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agree. What do you think about stringBody.asSecondary? I now pushed such rename (and b.isSecondary instead of b.isExtracted), but let me know if you think it's too short and we can try to find something more descriptive.

Comment thread core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala Outdated
magdzikk and others added 6 commits August 31, 2026 17:22
Filtering matches requests to endpoints; verifying body definitions is a
separate concern. PrepareServerEndpoints now verifies and returns the
filter, and server interpreters call it instead. It is the last point at
which the endpoints are still a list - ServerInterpreter only ever sees
a ServerRequest => List[ServerEndpoint] - so it is the only shared place
they can be checked before serving starts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marking a variant compiles, as oneOfBody takes bodies, but the server
interpreters only look for the marker on a top-level body input. The
endpoint was previously reported as declaring two primary bodies, with
a message telling the user to apply the marker they had already applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…om-request-4442

# Conflicts:
#	docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToOpenAPIPaths.scala
@magdzikk
magdzikk requested a review from adamw September 1, 2026 11:36
*
* Set using [[EndpointIO.Body.asSecondary]].
*/
case class SecondaryBody()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

both SecondaryBody & ReplayableRawBody aren't really directly used by users. So maybe they don't have to be in the main namespace, which is auto-imported by import sttp.tapir.*. Instead, maybe we could put them e.g. inside EndpointIO.Body companion object? Or maybe there's a better place existing in the hierarchy?

override val streams: Streams[S] = delegate.streams

// The interpreter sequences the reads and this instance is per-request, so a plain var would do; @volatile only
// guards against the happens-before coming from an arbitrary backend's F.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

so do we need the @volatile? even if F-s operations cross threads, there will be memory barriers between thread switches, relating to operations on instances of this class?

.in(stringBody)
.out(stringBody)
.serverSecurityLogic[String, Identity](raw => Right(s"security:$raw"))
.serverLogic(principal => body => Right(s"$principal|logic:$body"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

these tests look very similar to the ones in ServerSecurityTests. Aren't the ones over there - which run with real interpreters - sufficient?

@adamw adamw left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Claude-generated review. Findings below were verified against the code; I've left out anything I couldn't confirm.

The mechanism itself holds up: the cache is per-request and can't be mutated by callers, decode ordering is right in both directions, both phases share the same maxBytes (ServerEndpoint.info delegates to endpoint.info), decodeStreamingBody correctly stays on the undecorated body, and every server and client interpreter is covered.

Three things worth deciding before merge:

  1. CachingRequestBody ignores the request's Content-Type charset, which changes how the primary body decodes on http4s, play, akka, pekko and finatra.
  2. Armeria runs the verification per request rather than at route construction.
  3. The hidden-schema pattern from #3820 now throws at startup — it's the existing workaround for this exact problem, so it needs either support or a release note.


override def toRaw[R](serverRequest: ServerRequest, bodyType: RawBodyType[R], maxBytes: Option[Long]): F[RawValue[R]] =
bodyType match {
case RawBodyType.StringBody(charset) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Claude-generated review

The charset here is the codec's default. Several backends instead prefer the charset from the request's Content-Type and fall back to the codec default: http4s Http4sRequestBody.scala:42, play/play29 PlayRequestBody.scala:65, finatra FinatraRequestBody.scala:44, akka/pekko via FromEntityUnmarshaller[String].

The wrapper is installed for the whole endpoint (ServerInterpreter.scala:116), so this changes how the primary body decodes too. With securityIn(stringBody.asSecondary).in(stringBody), a request with Content-Type: text/plain; charset=ISO-8859-1 and body café decodes correctly today and as mojibake after. Adding a secondary body silently breaks an unrelated one.

Deriving it the way the backends do:

case RawBodyType.StringBody(defaultCharset) =>
  val cs = serverRequest.contentTypeParsed.flatMap(_.charset).map(Charset.forName).getOrElse(defaultCharset)
  bytes(serverRequest, maxBytes).map(bs => RawValue(new String(bs, cs)))

No test covers a non-default request charset.

val future = new CompletableFuture[HttpResponse]()
val interpreter: ServerInterpreter[ArmeriaStreams, Future, ArmeriaResponseType, ArmeriaStreams] = new ServerInterpreter(
FilterServerEndpoints(serverEndpoints),
PrepareServerEndpoints(serverEndpoints),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Claude-generated review

serve runs per request, so the whole verification now runs on every request: two asVectorOfBasicInputs() traversals per endpoint, plus endpoint.showShort, which EndpointBodyVerifier.scala:56 builds eagerly even when there are no problems. This line used to be a cheap FilterServerEndpoints wrap.

It also changes the failure mode: an invalid endpoint set throws per request (a 500 each time) rather than at startup, which contradicts security.md ("rejected ... when routes are constructed") and the InvalidBodyDefinitionError scaladoc.

serverEndpoints is a constructor field, so a class-level private val fixes it. Same in TapirCatsService.scala:44 and TapirZioService.scala:39, and the same shape (test-only, lower impact) in stub/StubServerInterpreter.scala:28 and stub4/StubServerInterpreter.scala:29.

Making shown a lazy val is worth doing regardless.

case EndpointIO.Pair(left, right, _, _) => forIO(left) ++ forIO(right)
case EndpointIO.Header(_, codec, _) => ToKeyedSchemas(codec)
case EndpointIO.Headers(_, _) => List.empty
case b @ EndpointIO.Body(_, _, _) if b.isSecondary => List.empty

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Claude-generated review

forIO is reached from forOutput (line 68) as well as forInput, and asSecondary is defined on EndpointIO.Body — so out(jsonBody[Person].asSecondary) compiles. The schema is then never registered, but EndpointToOperationResponse.scala:132 still emits the media type, so ToSchemaReference falls into its "assuming external reference" branch and the spec gets a dangling $ref.

EndpointBodyVerifier only inspects securityInput/input, so nothing catches it. Either skip secondary bodies in EndpointToOperationResponse too, or have the verifier reject a secondary body found in output/errorOutput.

Unrelated, same file: the import on line 6 was widened from internal.IterableToListMap to internal._, but this case uses b.isSecondary, which is a member of Body — the widening isn't needed.

case class SecondaryBody()

object SecondaryBody {
val attributeKey: AttributeKey[SecondaryBody] = new AttributeKey[SecondaryBody]("sttp.tapir.SecondaryBody")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Claude-generated review

SecondaryBody and attributeKey are public, and so is Body.attribute — so fileBody.attribute(SecondaryBody.attributeKey, SecondaryBody()) compiles and produces exactly the state ReplayableRawBody exists to prevent. asSecondary/isSecondary are the whole intended surface; both can be private[tapir]. The key is identified by its string, so lookup is unaffected.

@@ -0,0 +1,127 @@
package sttp.tapir.server

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Claude-generated review

The module is right — testing depends on core only — but the package makes this permanently MiMa-tracked: enableMimaSettings runs on core and excludes only sttp.tapir.internal.*, generic.internal.* and typelevel.internal.* (build.sbt:114-118), and private[tapir] is public in bytecode. Any later change to EndpointBodyProblems, or a rename of verifyOne, will need a new exclusion.

sttp.tapir.internal gets the exclusion for free, and is where the PR already put isSecondaryBodyInput.

Two smaller things: throwOnErrors (line 24) is private[tapir] inside an already-private[tapir] object, and all six call sites spell out throwOnErrors(verify(...)) / throwOnErrors(verifyOne(...)) — one verifyOrThrow with an overload would read better.

stringBody.asSecondary.attribute(SecondaryBody.attributeKey) shouldBe Some(SecondaryBody())
}

it should "mark a json-style string body as secondary" in {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Claude-generated review

Some trimming:

  • line 11 duplicates line 7 — same attribute(...) call, different body constructor, no distinct code path.
  • line 25 "preserve the codec and body type" — asSecondary is a case-class copy; this tests Scala.
  • lines 7, 21 and 51 assert the same fact through three accessors, and line 16 already asserts two of them together.

Worth a comment on line 39: assertDoesNotCompile("oneOfBody(...).asSecondary") passes because OneOfBody has no asSecondary member at all, not because of the ReplayableRawBody constraint — a future reader could easily take it as evidence the implicit is doing the work.

result.head shouldBe a[InvalidBodyDefinitionError]
}

it should "accept an secondary body alongside an ordinary one" in {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Claude-generated review

"accept an secondary body alongside an ordinary one" is redundant with EndpointBodyVerifierTest; the EndpointVerifier wiring is already proven by the two tests above it.

Typo in the test names: "an secondary" → "a secondary", here and on line 345, and in DecodeBasicInputsValuesTest.scala:11.

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import sttp.model.{Header, Method, QueryParams, Uri}
import sttp.shared.Identity

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Claude-generated review

Unused — and it's the only change in this file.

Comment thread doc/endpoint/security.md
A secondary body is still decoded on the server, using its own codec, but it isn't part of the endpoint's API
contract: there's only one request body on the wire, so the secondary declaration is excluded from the generated
documentation, and ignored by client interpreters. The unmarked body - `jsonBody[Person]` above - is the one that's
documented, and the one clients actually send.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Claude-generated review

"ignored by client interpreters" is true of what gets sent, but the value is still required: the secondary body stays in the client's input tuple and is discarded. SecondaryBodyClientTest shows it as (...)("sent")("ignored"). Worth a sentence, since it's the first thing a caller hits.

Comment thread doc/endpoint/security.md
```{warning}
Declaring two *ordinary* request bodies - one in `securityIn`, one in `in`, neither marked with
`asSecondary` - is rejected the same way, since only one request body may be part of the API contract.
```

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Claude-generated review

Two things for the release notes, since they change behaviour for existing users:

  • two ordinary bodies moves from a request-time IllegalStateException("Double body definition") to a startup IllegalArgumentException;
  • EndpointVerifier now reports endpoint shapes that previously passed, so existing verification tests can start failing.

The hidden-schema case (see the comment on EndpointBodyVerifier) belongs there too.

Related: DecodeBasicInputs.verifyNoBody is now a backstop only for hand-built ServerInterpreters — worth a comment saying so.

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] Cannot use a body in serverSecurityLogic: causes java.lang.IllegalStateException: This publisher only supports one subscriber

2 participants