Skip to content

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

Merged
adamw merged 100 commits into
masterfrom
fix/extract-body-from-request-4442
Oct 19, 2026
Merged

adamw merged 100 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, so today the DSL permits something whose success is accidental per interpreter:

  • works where the body is held in memory: vertx, armeria, finatra, AWS Lambda, zio-http (by default), the sttp stubs;
  • works on akka-http, pekko-http and http4s (Ember) only when the whole body arrives with the request headers;
  • fails on Netty (above), Play and Nima;
  • on jdkhttp, the second read silently gets an empty body.

The approach

Mark one of the two declarations with asSecondary:

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

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

The secondary body is decoded on the server from a buffered copy of the request body. It isn't part of the API contract: it's left out of the documentation, and clients don't send it (they still take a value for it).

Only bodies which can be re-read from buffered bytes can be secondary: String, Array[Byte], ByteBuffer, InputStream, InputStreamRange. Others are rejected at compile time.

Release notes

  • An endpoint with more than one ordinary request body is now rejected when routes (or an sttp stub backend) are created. Previously it failed on each request, or worked only on some backends. Mark one of the bodies with asSecondary; hiding its schema isn't enough.
  • EndpointVerifier reports these problems as a new InvalidBodyDefinitionError, so existing EndpointVerifier(...) shouldBe empty checks may start failing.
  • All servers now decode string request bodies with the Content-Type charset, falling back to the codec's charset. Before, Netty, jdkhttp, Nima, zio-http, Vert.x and the sttp stubs ignored the request's charset, and Akka/Pekko fell back to UTF-8.
  • For server interpreter authors: build routes with PrepareServerEndpoints instead of FilterServerEndpoints, and decode whole string bodies with RequestBody.requestCharset(request).getOrElse(codecCharset).

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
magdzikk and others added 18 commits October 14, 2026 15:30
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ared

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adamw

adamw commented Oct 15, 2026

Copy link
Copy Markdown
Member

🤖 Automated review (generated with Claude Code review agents; main claims checked against the code, but please verify before acting).

No high-severity bugs found. In both orders (secondary body in securityIn or in in), the backend body is read once, max content length is enforced on the buffered path, and all server interpreters in the repo run the verification.

Design

  1. Maybe a smaller design is enough. securityIn and in are decoded separately, so the server could buffer the body whenever both declare one, without the asSecondary DSL. Clients already set security inputs first and main inputs second, so the main body wins. Docs would only need to pick the last body instead of body.headOption (EndpointToOpenAPIPaths.scala:70), or users could use .schema(_.hidden(true)). This would remove the marker, SecondaryBody, most of EndpointBodyVerifier, the Values change, the stub decoder changes and the isSecondary cases in clients/docs/grpc. Endpoints that work today on vertx/armeria/stubs would keep working. What we lose: the compile-time check for non-bufferable bodies (a check when routes are built could replace it). Worth deciding this first.
  2. Several bodies in one input group (.in(stringBody).in(stringBody.asSecondary)) is more than [BUG] Cannot use a body in serverSecurityLogic: causes java.lang.IllegalStateException: This publisher only supports one subscriber #4442 needs. It's the only reason for the index-based setBasicInputValue, decodeSecondaryBodies and the in_string_secondary_string_out_string fixture.
  3. Every interpreter must remember to check isSecondary. The marker is an attribute, so each Body match needs an if b.isSecondary guard, now copied into ~10 places (5 clients, 3 docs, 2 grpc). An external interpreter still compiles and will send/document the secondary body. A dedicated type, or traversal helpers that skip secondary bodies, would make this safe by default.
  4. Docs and verifier disagree. The verifier rejects secondary oneOfBody variants and secondary outputs, but EndpointToParameters.scala:17 and EndpointToOperationResponse.scala:132 silently drop them.

Correctness / security

  1. Custom interpreters skip verification. It only runs via PrepareServerEndpoints or direct EndpointBodyVerifier calls (vertx, finatra, zio-http). FilterServerEndpoints / new ServerInterpreter don't run it, and the verifier is private[tapir]. A custom interpreter with a secondary body next to a file/multipart/stream body reads an already consumed body (the [BUG] Cannot use a body in serverSecurityLogic: causes java.lang.IllegalStateException: This publisher only supports one subscriber #4442 error again). Suggestion: verify inside FilterServerEndpoints.apply and drop PrepareServerEndpoints; this also removes the renames in ~20 interpreters.
  2. AWS Lambda: verified bytes and main body can differ (AwsRequestBody.scala:24,38-40). For non-base64 bodies, the secondary bytes come from body.getBytes() (platform charset), but the primary string is the original request.body. With a non-UTF-8 default charset or an unpaired surrogate, an HMAC check can pass on bytes that don't match what the main logic gets. Fix: getBytes(UTF_8), and new String(bytes, UTF_8) in decodeStringBody.
  3. Stub matchers may see an empty second body. SttpRequestDecoder (stub and stub4) calls forceBodyAsByteArray once per body input; with an InputStream request body the second read is empty. Read the bytes once.

API compatibility

  1. DecodeBasicInputsResult.Values gets a third field: source and binary break for interpreter authors. MiMa only runs on core, so it won't catch it. hasSecondaryBody on the sealed trait is always false for Failure, and is computed per request though it only depends on the endpoint.
  2. stub4: protected def handleRequest(req) was replaced by requestHandler(), which breaks subclasses.
  3. RequestBody.decodeStringBody must stay in sync with each backend's toRaw(StringBody). akka/pekko/armeria copy the charset rule instead of sharing it, and the default ignores the request charset, so external backends may decode differently. Either share the code with toRaw, or use one rule in CachingRequestBody (Content-Type charset, else codec default).
  4. InvalidBodyDefinitionError(message: String, rejectedByServers: Boolean) is stringly typed, unlike the other EndpointVerificationErrors, which have one case class per problem. This becomes public API once released.

Tests

The tests can fail when they should, and the cross-backend tests run on all servers, including Netty/jdkhttp/Nima where the body can be read only once. Gaps:

  • no test for a secondary body in in that fails to decode, or a primary that fails next to a secondary;
  • no empty body case for the akka/pekko/aws/vertx decodeStringBody overrides;
  • the Try in CachingRequestBody isn't tested (IdentityMonad can't tell a throw from a failed effect; TryMonad can);
  • no secondary body next to a oneOfBody primary;
  • assertDoesNotCompile in SecondaryBodyTest passes on any compile error: prefer assertTypeError, and add positive checks for inputStreamBody.asSecondary / inputStreamRangeBody.asSecondary;
  • AWS Lambda tests don't run the new ServerSecurityTests cases.

Minor

  • Vert.x handlers/package.scala:36 doesn't skip secondary bodies (safe only thanks to the verifier).
  • filterOutHiddenInputs → filterOutUndocumentedInputs is an unrelated rename.
  • doc/server/interceptors.md:20 still mentions FilterServerEndpoints.
  • The allowed secondary body types are listed in 5 places; only 2 are checked by the compiler.
  • rejectedByServers is explained twice (scaladoc and doc/testing.md); security.md could be shorter.

magdzikk and others added 4 commits October 15, 2026 11:59
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A secondary body next to a primary one in the same securityIn or in group
is rejected when routes are built, so each group has a single body slot.
This drops the extra secondary-body decoding pass and keeps
DecodeBasicInputsResult.Values unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On akka, pekko and armeria, toRaw decoded string bodies separately from
decodeStringBody, which copied its rule. Both now go through one
definition, so a buffered body can't drift from a directly read one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scaladoc keeps a one-line definition of the field; testing.md carries
the explanation. The security.md section states the same rules in fewer
paragraphs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@magdzikk

Copy link
Copy Markdown
Contributor Author
  1. AWS Lambda: verified bytes and main body can differ

Fixed - bodies are now encoded as UTF-8 when read as bytes.

  1. Several bodies in one input group

Fixed - now a second body in the same group is rejected.

  1. DecodeBasicInputsResult.Values gets a third field

Fixed by the change for #2.

  1. Stub matchers may see an empty second body.

Fixed by the change for #2.

  1. Custom interpreters skip verification. (...) Suggestion: verify inside FilterServerEndpoints.apply

We already talked about it in #5491 (comment), and decided to not put verification in the filtering method.

  1. Maybe a smaller design is enough. securityIn and in are decoded separately, so the server could buffer the body whenever both declare one, without the asSecondary DSL.

The need for a DSL was discussed in #4442, and I still think the points there are valid. Also I checked that buffering in the suggested way would cause problems with Content-Type.

  1. Every interpreter must remember to check isSecondary

A new EndpointIO subtype would touch every input match in the codebase and break binary compatibility. The clients' recursive matches can't use a filtering traversal either.

  1. Docs and verifier disagree.

It's true, but intended. Docs never validate endpoints, and the guards prevent the dangling $ref mentioned here: #5491 (comment) .

  1. stub4: protected def handleRequest(req) was replaced by requestHandler(), which breaks subclasses.

Yes, but it's a change from your commit - do you want me to fix it?

  1. RequestBody.decodeStringBody must stay in sync with each backend's toRaw(StringBody). (...) use one rule in CachingRequestBody

Fixed - shared the rule with toRaw on akka, pekko and armeria.

  1. InvalidBodyDefinitionError(message: String, rejectedByServers: Boolean) is stringly typed, unlike the other EndpointVerificationErrors, which have one case class per problem. This becomes public API once released.

A field was one of your suggestions here: #5491 (comment) and I liked it. If you prefer, I can change it to a case class per problem, but that would add ~9 new public types.

rejectedByServers is explained twice (scaladoc and doc/testing.md); security.md could be shorter.

Fixed.

adamw added 4 commits October 17, 2026 21:35
RequestBody.decodeStringBody is replaced by stringBodyCharset, which only
says which charset a backend uses for string bodies. Cached string bodies are
always decoded with new String(bytes, charset).

When the request doesn't specify a charset, Armeria, Akka and Pekko now use
the body codec's charset instead of always using UTF-8.
A secondary body can be declared in either securityIn or in.
…vers

RequestBody.requestCharset reads the charset from the Content-Type
header. Every server's toRaw and CachingRequestBody use it, falling back
to the codec's charset, so the per-backend stringBodyCharset hook is gone.

Netty, jdkhttp, Nima, zio-http, Vert.x and the stubs now honour the
request's charset. AWS Lambda decodes text bodies from their UTF-8 bytes,
like the cached path does.
…om-request-4442

# Conflicts:
#	client/play-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala
#	client/play29-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala
#	docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToParameters.scala
#	server/armeria-server/cats/src/main/scala/sttp/tapir/server/armeria/cats/TapirCatsService.scala
#	server/armeria-server/zio/src/main/scala/sttp/tapir/server/armeria/zio/TapirZioService.scala
#	server/netty-server/cats/src/main/scala/sttp/tapir/server/netty/cats/NettyCatsServerInterpreter.scala
#	server/netty-server/sync/src/main/scala/sttp/tapir/server/netty/sync/NettySyncServerInterpreter.scala
#	server/pekko-http-server/src/main/scala/sttp/tapir/server/pekkohttp/PekkoHttpServerInterpreter.scala
#	server/sttp-stub4-server/src/main/scala/sttp/tapir/server/stub4/TapirStubInterpreter.scala
#	server/vertx-server/src/main/scala/sttp/tapir/server/vertx/VertxFutureServerInterpreter.scala
#	server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpInterpreter.scala
@adamw
adamw merged commit 1f1bca5 into master Oct 19, 2026
22 checks passed
@adamw
adamw deleted the fix/extract-body-from-request-4442 branch October 19, 2026 01:21
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