Allow reading the request body in both security and main server logic - #5491
Allow reading the request body in both security and main server logic#5491magdzikk wants to merge 34 commits into
Conversation
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>
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()) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
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
| * | ||
| * Set using [[EndpointIO.Body.asSecondary]]. | ||
| */ | ||
| case class SecondaryBody() |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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")) |
There was a problem hiding this comment.
these tests look very similar to the ones in ServerSecurityTests. Aren't the ones over there - which run with real interpreters - sufficient?
adamw
left a comment
There was a problem hiding this comment.
🤖 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:
CachingRequestBodyignores the request'sContent-Typecharset, which changes how the primary body decodes on http4s, play, akka, pekko and finatra.- Armeria runs the verification per request rather than at route construction.
- 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) => |
There was a problem hiding this comment.
🤖 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), |
There was a problem hiding this comment.
🤖 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 |
There was a problem hiding this comment.
🤖 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") |
There was a problem hiding this comment.
🤖 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 | |||
There was a problem hiding this comment.
🤖 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 { |
There was a problem hiding this comment.
🤖 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" —
asSecondaryis a case-classcopy; 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 { |
There was a problem hiding this comment.
🤖 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 |
There was a problem hiding this comment.
🤖 Claude-generated review
Unused — and it's the only change in this file.
| 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. |
There was a problem hiding this comment.
🤖 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.
| ```{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. | ||
| ``` |
There was a problem hiding this comment.
🤖 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 startupIllegalArgumentException; EndpointVerifiernow 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.
Fixes #4442.
The problem
An endpoint that reads the request body in
serverSecurityLogicand again in the main logic crashes on streaming backends: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
extractBodyFromRequestwrapper - it allows reading the body second time, but is ignored during client generation:We only support byte-like types:
String,Array[Byte],ByteBuffer,InputStream,InputStreamRange. Other types are rejected at compile time (File, multipart, streaming andoneOfBody).An endpoint declaring two ordinary bodies now throws
IllegalArgumentExceptionat route construction:endpoint.securityIn(stringBody).in(stringBody) // now rejected at startupThis 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.