From 923b5c06507716486a3531cfb6afaba1a26e8960 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 20 Aug 2026 15:20:17 +0200 Subject: [PATCH 01/32] feat: add extractBodyFromRequest for server-side-only body inputs --- .../main/scala/sttp/tapir/ExtractedBody.scala | 36 ++++++++++++++++++ core/src/main/scala/sttp/tapir/Tapir.scala | 12 ++++++ .../tapir/ExtractBodyFromRequestTest.scala | 37 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 core/src/main/scala/sttp/tapir/ExtractedBody.scala create mode 100644 core/src/test/scala/sttp/tapir/ExtractBodyFromRequestTest.scala diff --git a/core/src/main/scala/sttp/tapir/ExtractedBody.scala b/core/src/main/scala/sttp/tapir/ExtractedBody.scala new file mode 100644 index 0000000000..9def4a8205 --- /dev/null +++ b/core/src/main/scala/sttp/tapir/ExtractedBody.scala @@ -0,0 +1,36 @@ +package sttp.tapir + +import java.io.InputStream +import java.nio.ByteBuffer +import scala.annotation.implicitNotFound + +/** Attribute value marking a body input as extracted: decoded from the request on the server, but not part of the API + * contract. Extracted bodies are excluded from documentation and ignored by client interpreters, which allows the + * request body to be decoded more than once - e.g. in `serverSecurityLogic` and again in the main logic. + * + * Set using [[Tapir.extractBodyFromRequest]]. + */ +case class ExtractedBody() + +object ExtractedBody { + val attributeKey: AttributeKey[ExtractedBody] = new AttributeKey[ExtractedBody]("sttp.tapir.ExtractedBody") +} + +/** Evidence that a raw body type can be re-read from buffered bytes, and is therefore usable as an extracted body. */ +@implicitNotFound( + "Cannot use a body with raw type ${R} as an extracted body. Only bodies which can be re-read from buffered bytes " + + "are supported: string, byte array, byte buffer, input stream. File, multipart and streaming bodies cannot be " + + "read twice." +) +trait ReplayableRawBody[R] + +object ReplayableRawBody { + private val instance: ReplayableRawBody[Any] = new ReplayableRawBody[Any] {} + private def of[R]: ReplayableRawBody[R] = instance.asInstanceOf[ReplayableRawBody[R]] + + implicit val forString: ReplayableRawBody[String] = of + implicit val forByteArray: ReplayableRawBody[Array[Byte]] = of + implicit val forByteBuffer: ReplayableRawBody[ByteBuffer] = of + implicit val forInputStream: ReplayableRawBody[InputStream] = of + implicit val forInputStreamRange: ReplayableRawBody[InputStreamRange] = of +} diff --git a/core/src/main/scala/sttp/tapir/Tapir.scala b/core/src/main/scala/sttp/tapir/Tapir.scala index 661cde7a9f..ecc390943b 100644 --- a/core/src/main/scala/sttp/tapir/Tapir.scala +++ b/core/src/main/scala/sttp/tapir/Tapir.scala @@ -225,6 +225,18 @@ trait Tapir extends TapirExtensions with TapirComputedInputs with TapirStaticCon def extractFromRequest[T](f: ServerRequest => T): EndpointInput.ExtractFromRequest[T] = EndpointInput.ExtractFromRequest(Codec.idPlain[ServerRequest]().map(f)(_ => null), EndpointIO.Info.empty) + /** Decode the request body a second time, server-side only. The resulting input is excluded from documentation and + * ignored by client interpreters, so an endpoint may declare one body as part of its contract (in `in`) and read the + * same request body again through this input (e.g. in `securityIn`). + * + * Only bodies which can be re-read from buffered bytes are supported; file, multipart and streaming bodies are + * rejected at compile time. + */ + 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()) + } + /** An output which maps to the status code in the response. */ def statusCode: EndpointOutput.StatusCode[sttp.model.StatusCode] = EndpointOutput.StatusCode(Map.empty, Codec.idPlain(), EndpointIO.Info.empty) diff --git a/core/src/test/scala/sttp/tapir/ExtractBodyFromRequestTest.scala b/core/src/test/scala/sttp/tapir/ExtractBodyFromRequestTest.scala new file mode 100644 index 0000000000..f644816194 --- /dev/null +++ b/core/src/test/scala/sttp/tapir/ExtractBodyFromRequestTest.scala @@ -0,0 +1,37 @@ +package sttp.tapir + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class ExtractBodyFromRequestTest extends AnyFlatSpec with Matchers { + it should "mark a string body as extracted" in { + extractBodyFromRequest(stringBody).attribute(ExtractedBody.attributeKey) shouldBe Some(ExtractedBody()) + } + + it should "mark a json-style string body as extracted" in { + val body = stringBodyUtf8AnyFormat(Codec.string) + extractBodyFromRequest(body).attribute(ExtractedBody.attributeKey) shouldBe Some(ExtractedBody()) + } + + it should "leave a plain body unmarked" in { + stringBody.attribute(ExtractedBody.attributeKey) shouldBe None + } + + it should "preserve the codec and body type" in { + val extracted = extractBodyFromRequest(byteArrayBody) + extracted.bodyType shouldBe RawBodyType.ByteArrayBody + extracted.codec shouldBe byteArrayBody.codec + } + + it should "not compile for file bodies" in { + assertDoesNotCompile("extractBodyFromRequest(fileBody)") + } + + it should "not compile for multipart bodies" in { + assertDoesNotCompile("extractBodyFromRequest(multipartBody)") + } + + it should "not compile for oneOfBody" in { + assertDoesNotCompile("""extractBodyFromRequest(oneOfBody(stringBody, stringBody))""") + } +} From dd127cfb85faa908496be9c65bc9f1a45117ff2c Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 20 Aug 2026 15:46:27 +0200 Subject: [PATCH 02/32] feat: render extracted bodies distinctly in show, add internal predicate Co-Authored-By: Claude Opus 5 --- core/src/main/scala/sttp/tapir/EndpointIO.scala | 3 ++- .../main/scala/sttp/tapir/internal/package.scala | 9 +++++++++ .../sttp/tapir/ExtractBodyFromRequestTest.scala | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/core/src/main/scala/sttp/tapir/EndpointIO.scala b/core/src/main/scala/sttp/tapir/EndpointIO.scala index 846d5bebfa..1ae96f6e7d 100644 --- a/core/src/main/scala/sttp/tapir/EndpointIO.scala +++ b/core/src/main/scala/sttp/tapir/EndpointIO.scala @@ -492,7 +492,8 @@ object EndpointIO { case _ => "" } val format = codec.format.mediaType - s"{body as $format$charset}" + val extracted = if (info.attribute(ExtractedBody.attributeKey).isDefined) "extracted " else "" + s"{${extracted}body as $format$charset}" } } diff --git a/core/src/main/scala/sttp/tapir/internal/package.scala b/core/src/main/scala/sttp/tapir/internal/package.scala index ab7e91d5e9..decfa706a0 100644 --- a/core/src/main/scala/sttp/tapir/internal/package.scala +++ b/core/src/main/scala/sttp/tapir/internal/package.scala @@ -360,4 +360,13 @@ package object internal { case null => true case _ => false } + + def isExtractedBodyInput(input: EndpointInput[?]): Boolean = input match { + case b: EndpointIO.Body[?, ?] => b.info.attribute(ExtractedBody.attributeKey).isDefined + case _ => false + } + + implicit class RichEndpointIOBody[R, T](body: EndpointIO.Body[R, T]) { + def isExtracted: Boolean = body.info.attribute(ExtractedBody.attributeKey).isDefined + } } diff --git a/core/src/test/scala/sttp/tapir/ExtractBodyFromRequestTest.scala b/core/src/test/scala/sttp/tapir/ExtractBodyFromRequestTest.scala index f644816194..9eba238e6d 100644 --- a/core/src/test/scala/sttp/tapir/ExtractBodyFromRequestTest.scala +++ b/core/src/test/scala/sttp/tapir/ExtractBodyFromRequestTest.scala @@ -34,4 +34,19 @@ class ExtractBodyFromRequestTest extends AnyFlatSpec with Matchers { it should "not compile for oneOfBody" in { assertDoesNotCompile("""extractBodyFromRequest(oneOfBody(stringBody, stringBody))""") } + + it should "render an extracted body distinctly in show" in { + extractBodyFromRequest(stringBody).show shouldBe "{extracted body as text/plain (UTF-8)}" + } + + it should "render a plain body unchanged in show" in { + stringBody.show shouldBe "{body as text/plain (UTF-8)}" + } + + it should "report extracted bodies through the internal predicate" in { + import sttp.tapir.internal._ + isExtractedBodyInput(extractBodyFromRequest(stringBody)) shouldBe true + isExtractedBodyInput(stringBody) shouldBe false + isExtractedBodyInput(query[String]("q")) shouldBe false + } } From bbf5628869a951c416ebae15077aa1d8183ff30c Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 20 Aug 2026 16:02:49 +0200 Subject: [PATCH 03/32] feat: track extracted body inputs separately when decoding basic inputs Co-Authored-By: Claude Opus 5 --- .../interpreter/DecodeBasicInputs.scala | 27 ++++++++--- .../DecodeBasicInputsValuesTest.scala | 48 +++++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 server/core/src/test/scala/sttp/tapir/server/interpreter/DecodeBasicInputsValuesTest.scala diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/DecodeBasicInputs.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/DecodeBasicInputs.scala index ed48caf8ec..8577aaf267 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/DecodeBasicInputs.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/DecodeBasicInputs.scala @@ -8,21 +8,32 @@ import sttp.tapir.{DecodeResult, EndpointIO, EndpointInput, StreamBodyIO, oneOfB import scala.annotation.tailrec -sealed trait DecodeBasicInputsResult +sealed trait DecodeBasicInputsResult { + + /** Whether any body input in this result is an extracted body, i.e. one which requires the request body to be + * readable more than once. + */ + def hasExtractedBody: Boolean +} object DecodeBasicInputsResult { /** @param basicInputsValues Values of basic inputs, in order as they are defined in the endpoint. */ case class Values( basicInputsValues: Vector[Any], - bodyInputWithIndex: Option[(Either[EndpointIO.OneOfBody[?, ?], EndpointIO.StreamBodyWrapper[?, ?]], Int)] + bodyInputWithIndex: Option[(Either[EndpointIO.OneOfBody[?, ?], EndpointIO.StreamBodyWrapper[?, ?]], Int)], + extractedBodyInputsWithIndex: Vector[(EndpointIO.Body[?, ?], Int)] = Vector.empty ) extends DecodeBasicInputsResult { + override def hasExtractedBody: Boolean = extractedBodyInputsWithIndex.nonEmpty + private def verifyNoBody(input: EndpointInput[?]): Unit = if (bodyInputWithIndex.isDefined) { throw new IllegalStateException(s"Double body definition: $input") } - def addBodyInput[O](input: EndpointIO.Body[?, O], bodyIndex: Int): Values = { - verifyNoBody(input) - copy(bodyInputWithIndex = Some((Left(oneOfBody(ContentTypeRange.AnyRange -> input)), bodyIndex))) - } + def addBodyInput[O](input: EndpointIO.Body[?, O], bodyIndex: Int): Values = + if (input.isExtracted) copy(extractedBodyInputsWithIndex = extractedBodyInputsWithIndex :+ ((input, bodyIndex))) + else { + verifyNoBody(input) + copy(bodyInputWithIndex = Some((Left(oneOfBody(ContentTypeRange.AnyRange -> input)), bodyIndex))) + } def addOneOfBodyInput(input: EndpointIO.OneOfBody[?, ?], bodyIndex: Int): Values = { verifyNoBody(input) copy(bodyInputWithIndex = Some((Left(input), bodyIndex))) @@ -40,7 +51,9 @@ object DecodeBasicInputsResult { def setBasicInputValue(v: Any, i: Int): Values = copy(basicInputsValues = basicInputsValues.updated(i, v)) } - case class Failure(input: EndpointInput.Basic[?], failure: DecodeResult.Failure) extends DecodeBasicInputsResult + case class Failure(input: EndpointInput.Basic[?], failure: DecodeResult.Failure) extends DecodeBasicInputsResult { + override def hasExtractedBody: Boolean = false + } def higherPriorityFailure(l: DecodeBasicInputsResult, r: DecodeBasicInputsResult): Option[Failure] = (l, r) match { case (f1: Failure, _: Values) => Some(f1) diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/DecodeBasicInputsValuesTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/DecodeBasicInputsValuesTest.scala new file mode 100644 index 0000000000..a3ee0ced66 --- /dev/null +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/DecodeBasicInputsValuesTest.scala @@ -0,0 +1,48 @@ +package sttp.tapir.server.interpreter + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.tapir._ + +class DecodeBasicInputsValuesTest extends AnyFlatSpec with Matchers { + private def emptyValues(size: Int) = + DecodeBasicInputsResult.Values(Vector.fill[Any](size)(null), None) + + it should "record an extracted body separately from the primary body" in { + val result = emptyValues(1).addBodyInput(extractBodyFromRequest(stringBody), 0) + + result.bodyInputWithIndex shouldBe None + result.extractedBodyInputsWithIndex.map(_._2) shouldBe Vector(0) + result.hasExtractedBody shouldBe true + } + + it should "record a primary body in bodyInputWithIndex" in { + val result = emptyValues(1).addBodyInput(stringBody, 0) + + result.bodyInputWithIndex shouldBe defined + result.extractedBodyInputsWithIndex shouldBe empty + result.hasExtractedBody shouldBe false + } + + it should "allow a primary body alongside several extracted bodies" in { + val result = emptyValues(3) + .addBodyInput(extractBodyFromRequest(stringBody), 0) + .addBodyInput(stringBody, 1) + .addBodyInput(extractBodyFromRequest(byteArrayBody), 2) + + result.bodyInputWithIndex.map(_._2) shouldBe Some(1) + result.extractedBodyInputsWithIndex.map(_._2) shouldBe Vector(0, 2) + } + + it should "still reject two primary bodies in one pass" in { + an[IllegalStateException] should be thrownBy { + emptyValues(2).addBodyInput(stringBody, 0).addBodyInput(stringBody, 1) + } + } + + it should "report no extracted body for a decode failure" in { + val failure: DecodeBasicInputsResult = + DecodeBasicInputsResult.Failure(stringBody, DecodeResult.Missing) + failure.hasExtractedBody shouldBe false + } +} From e01ab1048966944c58ab6ef062e8a35bd01e83af Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 20 Aug 2026 16:14:07 +0200 Subject: [PATCH 04/32] feat: add CachingRequestBody, buffering the request body for repeated reads Co-Authored-By: Claude Opus 5 --- .../interpreter/CachingRequestBody.scala | 56 +++++++++++++++ .../interpreter/CachingRequestBodyTest.scala | 72 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala create mode 100644 server/core/src/test/scala/sttp/tapir/server/interpreter/CachingRequestBodyTest.scala diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala new file mode 100644 index 0000000000..4486b61749 --- /dev/null +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala @@ -0,0 +1,56 @@ +package sttp.tapir.server.interpreter + +import sttp.capabilities.Streams +import sttp.monad.MonadError +import sttp.monad.syntax._ +import sttp.tapir.model.ServerRequest +import sttp.tapir.{InputStreamRange, RawBodyType} + +import java.io.ByteArrayInputStream +import java.nio.ByteBuffer + +/** Reads the request body from `delegate` at most once, buffering the bytes so that subsequent reads - e.g. an + * extracted body decoded during the security phase, followed by the endpoint's own body - are served from memory. + * + * Must be created per request: it holds that request's bytes. + */ +private[tapir] class CachingRequestBody[F[_], S](delegate: RequestBody[F, S])(implicit m: MonadError[F]) + extends RequestBody[F, S] { + + override val streams: Streams[S] = delegate.streams + + // A plain var needs no synchronisation here: the interpreter's flatMap chain reads the security-phase body strictly + // before the main-phase one, and this instance never outlives a single request. + private var cachedBytes: Option[Array[Byte]] = None + + override def toRaw[R](serverRequest: ServerRequest, bodyType: RawBodyType[R], maxBytes: Option[Long]): F[RawValue[R]] = + bodyType match { + case RawBodyType.StringBody(charset) => + bytes(serverRequest, maxBytes).map(bs => RawValue(new String(bs, charset)).asInstanceOf[RawValue[R]]) + case RawBodyType.ByteArrayBody => + bytes(serverRequest, maxBytes).map(bs => RawValue(bs).asInstanceOf[RawValue[R]]) + case RawBodyType.ByteBufferBody => + bytes(serverRequest, maxBytes).map(bs => RawValue(ByteBuffer.wrap(bs)).asInstanceOf[RawValue[R]]) + case RawBodyType.InputStreamBody => + bytes(serverRequest, maxBytes).map(bs => RawValue(new ByteArrayInputStream(bs)).asInstanceOf[RawValue[R]]) + case RawBodyType.InputStreamRangeBody => + bytes(serverRequest, maxBytes) + .map(bs => RawValue(InputStreamRange(() => new ByteArrayInputStream(bs))).asInstanceOf[RawValue[R]]) + // file and multipart bodies cannot be extracted (rejected at compile time), so they are always the endpoint's + // single primary body and can be read directly + case other => delegate.toRaw(serverRequest, other, maxBytes) + } + + override def toStream(serverRequest: ServerRequest, maxBytes: Option[Long]): streams.BinaryStream = + delegate.toStream(serverRequest, maxBytes).asInstanceOf[streams.BinaryStream] + + private def bytes(serverRequest: ServerRequest, maxBytes: Option[Long]): F[Array[Byte]] = + cachedBytes match { + case Some(bs) => bs.unit + case None => + delegate.toRaw(serverRequest, RawBodyType.ByteArrayBody, maxBytes).map { raw => + cachedBytes = Some(raw.value) + raw.value + } + } +} diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/CachingRequestBodyTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/CachingRequestBodyTest.scala new file mode 100644 index 0000000000..2757d0c6ec --- /dev/null +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/CachingRequestBodyTest.scala @@ -0,0 +1,72 @@ +package sttp.tapir.server.interpreter + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.capabilities.Streams +import sttp.monad.{IdentityMonad, MonadError} +import sttp.shared.Identity +import sttp.tapir._ +import sttp.tapir.capabilities.NoStreams +import sttp.tapir.model.ServerRequest +import sttp.tapir.server.TestUtil.createTestRequest + +import java.io.InputStream +import java.nio.charset.StandardCharsets + +class CachingRequestBodyTest extends AnyFlatSpec with Matchers { + private implicit val idMonad: MonadError[Identity] = IdentityMonad + + private class CountingRequestBody(content: String) extends RequestBody[Identity, NoStreams] { + var reads = 0 + override val streams: Streams[NoStreams] = NoStreams + override def toRaw[R](serverRequest: ServerRequest, bodyType: RawBodyType[R], maxBytes: Option[Long]): RawValue[R] = { + reads += 1 + bodyType match { + case RawBodyType.ByteArrayBody => RawValue(content.getBytes(StandardCharsets.UTF_8)).asInstanceOf[RawValue[R]] + case other => throw new IllegalStateException(s"unexpected body type: $other") + } + } + override def toStream(serverRequest: ServerRequest, maxBytes: Option[Long]): streams.BinaryStream = + throw new IllegalStateException("should not be called") + } + + private val request = createTestRequest(List("test")) + + it should "read the delegate only once for two string reads" in { + val delegate = new CountingRequestBody("hello") + val caching = new CachingRequestBody[Identity, NoStreams](delegate) + + caching.toRaw(request, RawBodyType.StringBody(StandardCharsets.UTF_8), None).value shouldBe "hello" + caching.toRaw(request, RawBodyType.StringBody(StandardCharsets.UTF_8), None).value shouldBe "hello" + + delegate.reads shouldBe 1 + } + + it should "serve different bytes-like representations from one read" in { + val delegate = new CountingRequestBody("abc") + val caching = new CachingRequestBody[Identity, NoStreams](delegate) + + caching.toRaw(request, RawBodyType.StringBody(StandardCharsets.UTF_8), None).value shouldBe "abc" + caching.toRaw(request, RawBodyType.ByteArrayBody, None).value shouldBe "abc".getBytes(StandardCharsets.UTF_8) + caching.toRaw(request, RawBodyType.ByteBufferBody, None).value.array() shouldBe "abc".getBytes(StandardCharsets.UTF_8) + + val stream: InputStream = caching.toRaw(request, RawBodyType.InputStreamBody, None).value + new String(stream.readAllBytes(), StandardCharsets.UTF_8) shouldBe "abc" + + val range = caching.toRaw(request, RawBodyType.InputStreamRangeBody, None).value + new String(range.inputStream().readAllBytes(), StandardCharsets.UTF_8) shouldBe "abc" + + delegate.reads shouldBe 1 + } + + it should "give a fresh input stream on each read" in { + val delegate = new CountingRequestBody("xy") + val caching = new CachingRequestBody[Identity, NoStreams](delegate) + + val first: InputStream = caching.toRaw(request, RawBodyType.InputStreamBody, None).value + new String(first.readAllBytes(), StandardCharsets.UTF_8) shouldBe "xy" + + val second: InputStream = caching.toRaw(request, RawBodyType.InputStreamBody, None).value + new String(second.readAllBytes(), StandardCharsets.UTF_8) shouldBe "xy" + } +} From a55c6025b422cf5116b3bcfad5edb733d4b367e0 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 20 Aug 2026 16:34:15 +0200 Subject: [PATCH 05/32] fix: copy cached bytes on handout in CachingRequestBody, correct fallthrough 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 --- .../interpreter/CachingRequestBody.scala | 17 +++++++----- .../interpreter/CachingRequestBodyTest.scala | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala index 4486b61749..6011c5ed52 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala @@ -9,8 +9,9 @@ import sttp.tapir.{InputStreamRange, RawBodyType} import java.io.ByteArrayInputStream import java.nio.ByteBuffer -/** Reads the request body from `delegate` at most once, buffering the bytes so that subsequent reads - e.g. an - * extracted body decoded during the security phase, followed by the endpoint's own body - are served from memory. +/** Reads a bytes-like request body from `delegate` at most once, buffering the bytes so that subsequent reads - e.g. + * an extracted body decoded during the security phase, followed by the endpoint's own body - are served from + * memory. * * Must be created per request: it holds that request's bytes. */ @@ -28,16 +29,20 @@ private[tapir] class CachingRequestBody[F[_], S](delegate: RequestBody[F, S])(im case RawBodyType.StringBody(charset) => bytes(serverRequest, maxBytes).map(bs => RawValue(new String(bs, charset)).asInstanceOf[RawValue[R]]) case RawBodyType.ByteArrayBody => - bytes(serverRequest, maxBytes).map(bs => RawValue(bs).asInstanceOf[RawValue[R]]) + // clone: byteArrayBody is an identity codec, so the caller receives this array as-is and could mutate it + // in place, corrupting the cache for the next read + bytes(serverRequest, maxBytes).map(bs => RawValue(bs.clone()).asInstanceOf[RawValue[R]]) case RawBodyType.ByteBufferBody => - bytes(serverRequest, maxBytes).map(bs => RawValue(ByteBuffer.wrap(bs)).asInstanceOf[RawValue[R]]) + // clone for the same reason as ByteArrayBody above; wrap (not asReadOnlyBuffer) so .array() keeps working + bytes(serverRequest, maxBytes).map(bs => RawValue(ByteBuffer.wrap(bs.clone())).asInstanceOf[RawValue[R]]) case RawBodyType.InputStreamBody => bytes(serverRequest, maxBytes).map(bs => RawValue(new ByteArrayInputStream(bs)).asInstanceOf[RawValue[R]]) case RawBodyType.InputStreamRangeBody => bytes(serverRequest, maxBytes) .map(bs => RawValue(InputStreamRange(() => new ByteArrayInputStream(bs))).asInstanceOf[RawValue[R]]) - // file and multipart bodies cannot be extracted (rejected at compile time), so they are always the endpoint's - // single primary body and can be read directly + // File and multipart bodies are never served from the cache. An endpoint combining one of them with an + // extracted body is rejected by EndpointVerifier at route construction, so this branch only ever sees an + // endpoint whose sole body is the primary one. case other => delegate.toRaw(serverRequest, other, maxBytes) } diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/CachingRequestBodyTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/CachingRequestBodyTest.scala index 2757d0c6ec..e5c08b796f 100644 --- a/server/core/src/test/scala/sttp/tapir/server/interpreter/CachingRequestBodyTest.scala +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/CachingRequestBodyTest.scala @@ -69,4 +69,30 @@ class CachingRequestBodyTest extends AnyFlatSpec with Matchers { val second: InputStream = caching.toRaw(request, RawBodyType.InputStreamBody, None).value new String(second.readAllBytes(), StandardCharsets.UTF_8) shouldBe "xy" } + + it should "not let mutating a returned byte array corrupt the cache" in { + val delegate = new CountingRequestBody("hello") + val caching = new CachingRequestBody[Identity, NoStreams](delegate) + + val first = caching.toRaw(request, RawBodyType.ByteArrayBody, None).value + java.util.Arrays.fill(first, 'X'.toByte) + + caching.toRaw(request, RawBodyType.ByteArrayBody, None).value shouldBe "hello".getBytes(StandardCharsets.UTF_8) + caching.toRaw(request, RawBodyType.StringBody(StandardCharsets.UTF_8), None).value shouldBe "hello" + + delegate.reads shouldBe 1 + } + + it should "not let mutating a returned byte buffer corrupt the cache" in { + val delegate = new CountingRequestBody("hello") + val caching = new CachingRequestBody[Identity, NoStreams](delegate) + + val first = caching.toRaw(request, RawBodyType.ByteBufferBody, None).value + while (first.hasRemaining) { val _ = first.put('X'.toByte) } + + caching.toRaw(request, RawBodyType.ByteBufferBody, None).value.array() shouldBe "hello".getBytes(StandardCharsets.UTF_8) + caching.toRaw(request, RawBodyType.StringBody(StandardCharsets.UTF_8), None).value shouldBe "hello" + + delegate.reads shouldBe 1 + } } From 09897b14f9c11263eaae75c3555b33f8315c2ab4 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 20 Aug 2026 16:47:37 +0200 Subject: [PATCH 06/32] feat: decode extracted bodies, buffering the request body per request --- .../interpreter/ServerInterpreter.scala | 66 ++++++++++++++-- .../ServerInterpreterExtractedBodyTest.scala | 79 +++++++++++++++++++ 2 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala index 9e29a0a7d0..a4c40f89bb 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala @@ -111,12 +111,17 @@ class ServerInterpreter[R, F[_], B, S]( val rawValues = ConcurrentHashMap.newKeySet[RawValue[?]]() val addRawValue: RawValue[?] => Unit = rawValues.add(_): Unit + // if the endpoint reads the body more than once, buffer it so that the backend's request is consumed only once + val endpointRequestBody: RequestBody[F, S] = + if (securityBasicInputs.hasExtractedBody || regularBasicInputs.hasExtractedBody) new CachingRequestBody(requestBody) + else requestBody + (for { // 2. if the decoding failed, short-circuiting further processing with the decode failure that has a lower sort // index (so that the correct one is passed to the decode failure handler) _ <- resultOrValueFrom(DecodeBasicInputsResult.higherPriorityFailure(securityBasicInputs, regularBasicInputs)) // 3. computing the security input value - securityValues <- resultOrValueFrom(decodeBody(request, securityBasicInputs, se.info, addRawValue)) + securityValues <- resultOrValueFrom(decodeBody(request, securityBasicInputs, se.info, addRawValue, endpointRequestBody)) securityParams <- resultOrValueFrom(InputValue(se.endpoint.securityInput, securityValues)) inputValues <- resultOrValueFrom(regularBasicInputs) a = securityParams.asAny.asInstanceOf[A] @@ -142,7 +147,7 @@ class ServerInterpreter[R, F[_], B, S]( case Right(u) => for { // 5. decoding the body of regular inputs, computing the input value, and running the main logic - values <- resultOrValueFrom(decodeBody(request, inputValues, se.endpoint.info, addRawValue)) + values <- resultOrValueFrom(decodeBody(request, inputValues, se.endpoint.info, addRawValue, endpointRequestBody)) params <- resultOrValueFrom(InputValue(se.endpoint.input, values)) response <- resultOrValueFrom.value( endpointHandler(defaultSecurityFailureResponse, endpointInterceptors) @@ -178,15 +183,16 @@ class ServerInterpreter[R, F[_], B, S]( request: ServerRequest, result: DecodeBasicInputsResult, endpointInfo: EndpointInfo, - addRawValue: RawValue[?] => Unit - ): F[DecodeBasicInputsResult] = + addRawValue: RawValue[?] => Unit, + requestBody: RequestBody[F, S] + ): F[DecodeBasicInputsResult] = { + val maxBodyLength = endpointInfo.attribute(AttributeKey[MaxContentLength]).map(_.value) result match { case values: DecodeBasicInputsResult.Values => - val maxBodyLength = endpointInfo.attribute(AttributeKey[MaxContentLength]).map(_.value) - values.bodyInputWithIndex match { + val primaryDecoded: F[DecodeBasicInputsResult] = values.bodyInputWithIndex match { case Some((Left(oneOfBodyInput), _)) => oneOfBodyInput.chooseBodyToDecode(request.contentTypeParsed) match { - case Some(Left(body)) => decodeBody(request, values, body, maxBodyLength, addRawValue) + case Some(Left(body)) => decodeBody(request, values, body, maxBodyLength, addRawValue, requestBody) case Some(Right(body: EndpointIO.StreamBodyWrapper[Any, Any])) => decodeStreamingBody(request, values, body, maxBodyLength) case None => unsupportedInputMediaTypeResponse(request, oneOfBodyInput) } @@ -194,8 +200,51 @@ class ServerInterpreter[R, F[_], B, S]( decodeStreamingBody(request, values, bodyInput, maxBodyLength) case None => (values: DecodeBasicInputsResult).unit } + + primaryDecoded.flatMap { + case v: DecodeBasicInputsResult.Values => decodeExtractedBodies(request, v, maxBodyLength, addRawValue, requestBody) + case failure => failure.unit + } case failure: DecodeBasicInputsResult.Failure => (failure: DecodeBasicInputsResult).unit } + } + + private def decodeExtractedBodies( + request: ServerRequest, + values: DecodeBasicInputsResult.Values, + maxBodyLength: Option[Long], + addRawValue: RawValue[?] => Unit, + requestBody: RequestBody[F, S] + ): F[DecodeBasicInputsResult] = + values.extractedBodyInputsWithIndex.foldLeft((values: DecodeBasicInputsResult).unit) { case (acc, (bodyInput, index)) => + acc.flatMap { + case v: DecodeBasicInputsResult.Values => + decodeExtractedBody(request, v, bodyInput.asInstanceOf[EndpointIO.Body[Any, Any]], index, maxBodyLength, addRawValue, requestBody) + case failure => failure.unit + } + } + + private def decodeExtractedBody[RAW, T]( + request: ServerRequest, + values: DecodeBasicInputsResult.Values, + bodyInput: EndpointIO.Body[RAW, T], + index: Int, + maxBodyLength: Option[Long], + addRawValue: RawValue[?] => Unit, + requestBody: RequestBody[F, S] + ): F[DecodeBasicInputsResult] = + requestBody + .toRaw(request, bodyInput.bodyType, maxBodyLength) + .flatMap { v => + addRawValue(v) + bodyInput.codec.decode(v.value) match { + case DecodeResult.Value(bodyV) => (values.setBasicInputValue(bodyV, index): DecodeBasicInputsResult).unit + case failure: DecodeResult.Failure => (DecodeBasicInputsResult.Failure(bodyInput, failure): DecodeBasicInputsResult).unit + } + } + .handleError { case e @ (StreamMaxLengthExceededException(_) | InvalidMultipartBodyException(_, _)) => + (DecodeBasicInputsResult.Failure(bodyInput, DecodeResult.Error("", e)): DecodeBasicInputsResult).unit + } private def decodeStreamingBody( request: ServerRequest, @@ -213,7 +262,8 @@ class ServerInterpreter[R, F[_], B, S]( values: DecodeBasicInputsResult.Values, bodyInput: EndpointIO.Body[RAW, T], maxBodyLength: Option[Long], - addRawValue: RawValue[?] => Unit + addRawValue: RawValue[?] => Unit, + requestBody: RequestBody[F, S] ): F[DecodeBasicInputsResult] = { requestBody .toRaw(request, bodyInput.bodyType, maxBodyLength) diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala new file mode 100644 index 0000000000..4cab9b6d43 --- /dev/null +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala @@ -0,0 +1,79 @@ +package sttp.tapir.server.interpreter + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.capabilities.Streams +import sttp.model.Method +import sttp.monad.{IdentityMonad, MonadError} +import sttp.shared.Identity +import sttp.tapir._ +import sttp.tapir.capabilities.NoStreams +import sttp.tapir.model.ServerRequest +import sttp.tapir.server.TestUtil._ +import sttp.tapir.server.interceptor.RequestResult + +import java.nio.charset.StandardCharsets + +class ServerInterpreterExtractedBodyTest extends AnyFlatSpec with Matchers { + private implicit val idMonad: MonadError[Identity] = IdentityMonad + + private class CountingRequestBody(content: String) extends RequestBody[Identity, NoStreams] { + var reads = 0 + override val streams: Streams[NoStreams] = NoStreams + override def toRaw[R](serverRequest: ServerRequest, bodyType: RawBodyType[R], maxBytes: Option[Long]): RawValue[R] = { + reads += 1 + RawValue(content.getBytes(StandardCharsets.UTF_8)).asInstanceOf[RawValue[R]] + } + override def toStream(serverRequest: ServerRequest, maxBytes: Option[Long]): streams.BinaryStream = + throw new IllegalStateException("should not be called") + } + + it should "decode the same request body for security and main logic, reading it once" in { + val se = endpoint.post + .in("test") + .securityIn(extractBodyFromRequest(stringBody)) + .in(stringBody) + .out(stringBody) + .serverSecurityLogic[String, Identity](raw => Right(s"security:$raw")) + .serverLogic(principal => body => Right(s"$principal|logic:$body")) + + val requestBody = new CountingRequestBody("payload") + val interpreter = new ServerInterpreter[Any, Identity, String, NoStreams]( + _ => List(se), + requestBody, + StringToResponseBody, + Nil, + _ => () + ) + + val result = interpreter.apply(createTestRequest(List("test"), _method = Method.POST)) + + result shouldBe a[RequestResult.Response[_]] + val response = result.asInstanceOf[RequestResult.Response[String]].response + response.body shouldBe Some("security:payload|logic:payload") + requestBody.reads shouldBe 1 + } + + it should "not read the body a second time when security logic fails" in { + val se = endpoint.post + .in("test") + .securityIn(extractBodyFromRequest(stringBody)) + .in(stringBody) + .out(stringBody) + .errorOut(stringBody) + .serverSecurityLogic[Unit, Identity](_ => Left("denied")) + .serverLogic(_ => body => Right(body)) + + val requestBody = new CountingRequestBody("payload") + val interpreter = new ServerInterpreter[Any, Identity, String, NoStreams]( + _ => List(se), + requestBody, + StringToResponseBody, + Nil, + _ => () + ) + + interpreter.apply(createTestRequest(List("test"), _method = Method.POST)) + requestBody.reads shouldBe 1 + } +} From 9235564608a4f8ea4aabc5195521dc55f4c6464e Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 10:45:51 +0200 Subject: [PATCH 07/32] docs: explain why decodeStreamingBody bypasses the caching request body --- .../scala/sttp/tapir/server/interpreter/ServerInterpreter.scala | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala index a4c40f89bb..8a0a79e943 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala @@ -252,6 +252,8 @@ class ServerInterpreter[R, F[_], B, S]( bodyInput: EndpointIO.StreamBodyWrapper[Any, Any], maxBodyLength: Option[Long] ): F[DecodeBasicInputsResult] = + // never served from the cache: a body is either buffered for repeated reads or streamed lazily, not both; + // `EndpointBodyVerifier` rejects a streaming body combined with an extracted body at route construction (bodyInput.codec.decode(requestBody.toStream(request, maxBodyLength)) match { case DecodeResult.Value(bodyV) => values.setBodyInputValue(bodyV) case failure: DecodeResult.Failure => DecodeBasicInputsResult.Failure(bodyInput, failure): DecodeBasicInputsResult From e6c183f120e5cc73754710e369cd3a34df6b8845 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 11:39:11 +0200 Subject: [PATCH 08/32] feat: add EndpointBodyVerifier reporting body-definition errors and warnings --- .../tapir/server/EndpointBodyVerifier.scala | 93 +++++++++++++++++++ .../server/EndpointBodyVerifierTest.scala | 79 ++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala create mode 100644 server/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala diff --git a/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala b/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala new file mode 100644 index 0000000000..18d3206029 --- /dev/null +++ b/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala @@ -0,0 +1,93 @@ +package sttp.tapir.server + +import sttp.model.Method +import sttp.tapir.internal._ +import sttp.tapir.{AnyEndpoint, EndpointIO, EndpointInput, RawBodyType} + +/** Structural problems found in an endpoint description. Errors make the endpoint unserveable and are thrown when routes are constructed; + * warnings describe endpoints which work on the server, but whose published contract is probably not what the author intended. + */ +case class EndpointBodyProblems(errors: List[String], warnings: List[String]) { + def ++(other: EndpointBodyProblems): EndpointBodyProblems = + EndpointBodyProblems(errors ++ other.errors, warnings ++ other.warnings) +} + +object EndpointBodyProblems { + val Empty: EndpointBodyProblems = EndpointBodyProblems(Nil, Nil) +} + +/** Verifies that endpoint descriptions are structurally serveable. Called by server interpreters when routes are constructed; can also be + * called directly, e.g. to assert in tests that no warnings are present. + */ +object EndpointBodyVerifier { + def verify(endpoints: List[AnyEndpoint]): EndpointBodyProblems = + endpoints.map(verifyOne).foldLeft(EndpointBodyProblems.Empty)(_ ++ _) + + def verifyOne(endpoint: AnyEndpoint): EndpointBodyProblems = { + val inputs = endpoint.securityInput.asVectorOfBasicInputs() ++ endpoint.input.asVectorOfBasicInputs() + + val extracted = inputs.collect { case b: EndpointIO.Body[?, ?] if b.isExtracted => b } + val primaryBodies: Vector[EndpointInput.Basic[?]] = inputs.collect { + case b: EndpointIO.Body[?, ?] if !b.isExtracted => b + case b: EndpointIO.OneOfBody[?, ?] => b + case b: EndpointIO.StreamBodyWrapper[?, ?] => b + } + val streamingPrimary = primaryBodies.exists(_.isInstanceOf[EndpointIO.StreamBodyWrapper[?, ?]]) + val nonReplayablePrimary = primaryBodies.exists { + case b: EndpointIO.Body[?, ?] => + b.bodyType match { + case RawBodyType.FileBody => true + case _: RawBodyType.MultipartBody => true + case _ => false + } + case _ => false + } + val shown = endpoint.showShort + + val tooManyPrimaries = + if (primaryBodies.size > 1) + List( + s"Endpoint $shown declares a request body in both securityIn and in. Only one may be part of the API " + + s"contract. If both should decode the same request body, wrap the securityIn one: " + + s"extractBodyFromRequest(...)." + ) + else Nil + + val streamWithExtracted = + if (streamingPrimary && extracted.nonEmpty) + List( + s"Endpoint $shown combines a streaming body with an extracted body. The request body can either be " + + s"streamed lazily or buffered for repeated reads, not both." + ) + else Nil + + val nonReplayableWithExtracted = + if (nonReplayablePrimary && extracted.nonEmpty) + List( + s"Endpoint $shown combines a file or multipart body with an extracted body. Reading the extracted body " + + s"consumes the request; the file or multipart body would then be read from an already-drained request." + ) + else Nil + + val bodyCarryingMethod = endpoint.method.exists(m => m == Method.POST || m == Method.PUT || m == Method.PATCH) + val extractedWithoutPrimary = + if (extracted.nonEmpty && primaryBodies.isEmpty && bodyCarryingMethod) + List( + s"Endpoint $shown reads an extracted request body, but no request body is part of the API contract: it " + + s"will be absent from the documentation and clients will not send it. Either declare the body in `in` " + + s"as well, or drop extractBodyFromRequest and use the body input directly." + ) + else Nil + + val uselessMetadata = + extracted.filter(b => b.info.description.isDefined || b.info.examples.nonEmpty).map { b => + s"Endpoint $shown sets a description or example on the extracted body ${b.show}, which never reaches the " + + s"documentation, as extracted bodies are excluded from it." + } + + EndpointBodyProblems( + errors = tooManyPrimaries ++ streamWithExtracted ++ nonReplayableWithExtracted, + warnings = (extractedWithoutPrimary ++ uselessMetadata).toList + ) + } +} diff --git a/server/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala b/server/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala new file mode 100644 index 0000000000..efe42a101c --- /dev/null +++ b/server/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala @@ -0,0 +1,79 @@ +package sttp.tapir.server + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.tapir._ +import sttp.tapir.capabilities.NoStreams + +class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { + it should "accept an endpoint with one extracted and one primary body" in { + val e = endpoint.post.in("people").securityIn(extractBodyFromRequest(stringBody)).in(stringBody) + EndpointBodyVerifier.verifyOne(e) shouldBe EndpointBodyProblems(Nil, Nil) + } + + it should "accept an endpoint with a single plain body" in { + EndpointBodyVerifier.verifyOne(endpoint.post.in("people").in(stringBody)) shouldBe EndpointBodyProblems(Nil, Nil) + } + + it should "reject two primary bodies across securityIn and in" in { + val e = endpoint.post.in("people").securityIn(stringBody).in(stringBody) + val problems = EndpointBodyVerifier.verifyOne(e) + + problems.errors should have size 1 + problems.errors.head should include("declares a request body in both securityIn and in") + problems.errors.head should include("extractBodyFromRequest") + } + + it should "reject a streaming primary body combined with an extracted body" in { + val e = endpoint.post + .in("people") + .securityIn(extractBodyFromRequest(stringBody)) + .in[Nothing, Nothing, Unit, NoStreams](streamTextBody(NoStreams)(CodecFormat.TextPlain())) + val problems = EndpointBodyVerifier.verifyOne(e) + + problems.errors should have size 1 + problems.errors.head should include("streaming body") + } + + it should "reject a file body primary combined with an extracted body" in { + val e = endpoint.post + .in("people") + .securityIn(extractBodyFromRequest(stringBody)) + .in(fileBody) + val problems = EndpointBodyVerifier.verifyOne(e) + + problems.errors should have size 1 + problems.errors.head should include("file") + } + + it should "warn about an extracted body with no primary body on POST" in { + val e = endpoint.post.in("ingest").securityIn(extractBodyFromRequest(stringBody)) + val problems = EndpointBodyVerifier.verifyOne(e) + + problems.errors shouldBe empty + problems.warnings should have size 1 + problems.warnings.head should include("no request body is part of the API contract") + } + + it should "not warn about an extracted body with no primary body on GET" in { + val e = endpoint.get.in("ping").securityIn(extractBodyFromRequest(stringBody)) + EndpointBodyVerifier.verifyOne(e).warnings shouldBe empty + } + + it should "warn about metadata on an extracted body" in { + val e = endpoint.post + .in("people") + .securityIn(extractBodyFromRequest(stringBody.description("the raw payload"))) + .in(stringBody) + val problems = EndpointBodyVerifier.verifyOne(e) + + problems.warnings should have size 1 + problems.warnings.head should include("never reaches the documentation") + } + + it should "aggregate problems across endpoints" in { + val bad = endpoint.post.in("a").securityIn(stringBody).in(stringBody) + val good = endpoint.post.in("b").in(stringBody) + EndpointBodyVerifier.verify(List(bad, good)).errors should have size 1 + } +} From 7d11bbd4d449eac220806973873f6f4c8b23f55d Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 11:59:01 +0200 Subject: [PATCH 09/32] chore: naming-consistency and formatting cleanup 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 --- core/src/main/scala/sttp/tapir/ExtractedBody.scala | 6 +++--- core/src/main/scala/sttp/tapir/Tapir.scala | 9 ++++----- .../tapir/server/interpreter/CachingRequestBody.scala | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/core/src/main/scala/sttp/tapir/ExtractedBody.scala b/core/src/main/scala/sttp/tapir/ExtractedBody.scala index 9def4a8205..4fe2e6012e 100644 --- a/core/src/main/scala/sttp/tapir/ExtractedBody.scala +++ b/core/src/main/scala/sttp/tapir/ExtractedBody.scala @@ -4,9 +4,9 @@ import java.io.InputStream import java.nio.ByteBuffer import scala.annotation.implicitNotFound -/** Attribute value marking a body input as extracted: decoded from the request on the server, but not part of the API - * contract. Extracted bodies are excluded from documentation and ignored by client interpreters, which allows the - * request body to be decoded more than once - e.g. in `serverSecurityLogic` and again in the main logic. +/** Attribute value marking a body input as extracted: decoded from the request on the server, but not part of the API contract. Extracted + * bodies are excluded from documentation and ignored by client interpreters, which allows the request body to be decoded more than once - + * e.g. in `serverSecurityLogic` and again in the main logic. * * Set using [[Tapir.extractBodyFromRequest]]. */ diff --git a/core/src/main/scala/sttp/tapir/Tapir.scala b/core/src/main/scala/sttp/tapir/Tapir.scala index ecc390943b..bb20e9df78 100644 --- a/core/src/main/scala/sttp/tapir/Tapir.scala +++ b/core/src/main/scala/sttp/tapir/Tapir.scala @@ -225,12 +225,11 @@ trait Tapir extends TapirExtensions with TapirComputedInputs with TapirStaticCon def extractFromRequest[T](f: ServerRequest => T): EndpointInput.ExtractFromRequest[T] = EndpointInput.ExtractFromRequest(Codec.idPlain[ServerRequest]().map(f)(_ => null), EndpointIO.Info.empty) - /** Decode the request body a second time, server-side only. The resulting input is excluded from documentation and - * ignored by client interpreters, so an endpoint may declare one body as part of its contract (in `in`) and read the - * same request body again through this input (e.g. in `securityIn`). + /** Decode the request body a second time, server-side only. The resulting input is excluded from documentation and ignored by client + * interpreters, so an endpoint may declare one body as part of its contract (in `in`) and read the same request body again through this + * input (e.g. in `securityIn`). * - * Only bodies which can be re-read from buffered bytes are supported; file, multipart and streaming bodies are - * rejected at compile time. + * Only bodies which can be re-read from buffered bytes are supported; file, multipart and streaming bodies are rejected at compile time. */ 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 diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala index 6011c5ed52..af2a1cb550 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala @@ -41,7 +41,7 @@ private[tapir] class CachingRequestBody[F[_], S](delegate: RequestBody[F, S])(im bytes(serverRequest, maxBytes) .map(bs => RawValue(InputStreamRange(() => new ByteArrayInputStream(bs))).asInstanceOf[RawValue[R]]) // File and multipart bodies are never served from the cache. An endpoint combining one of them with an - // extracted body is rejected by EndpointVerifier at route construction, so this branch only ever sees an + // extracted body is rejected by EndpointBodyVerifier at route construction, so this branch only ever sees an // endpoint whose sole body is the primary one. case other => delegate.toRaw(serverRequest, other, maxBytes) } From 3d31b12c49caf3f6c83d16420e13912112bdd220 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 12:07:41 +0200 Subject: [PATCH 10/32] chore: format remaining serverCore files touched by this branch 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 --- .../tapir/server/interpreter/CachingRequestBody.scala | 10 ++++------ .../tapir/server/interpreter/DecodeBasicInputs.scala | 3 +-- .../ServerInterpreterExtractedBodyTest.scala | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala index af2a1cb550..1a139db81f 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala @@ -9,14 +9,12 @@ import sttp.tapir.{InputStreamRange, RawBodyType} import java.io.ByteArrayInputStream import java.nio.ByteBuffer -/** Reads a bytes-like request body from `delegate` at most once, buffering the bytes so that subsequent reads - e.g. - * an extracted body decoded during the security phase, followed by the endpoint's own body - are served from - * memory. +/** Reads a bytes-like request body from `delegate` at most once, buffering the bytes so that subsequent reads - e.g. an extracted body + * decoded during the security phase, followed by the endpoint's own body - are served from memory. * * Must be created per request: it holds that request's bytes. */ -private[tapir] class CachingRequestBody[F[_], S](delegate: RequestBody[F, S])(implicit m: MonadError[F]) - extends RequestBody[F, S] { +private[tapir] class CachingRequestBody[F[_], S](delegate: RequestBody[F, S])(implicit m: MonadError[F]) extends RequestBody[F, S] { override val streams: Streams[S] = delegate.streams @@ -52,7 +50,7 @@ private[tapir] class CachingRequestBody[F[_], S](delegate: RequestBody[F, S])(im private def bytes(serverRequest: ServerRequest, maxBytes: Option[Long]): F[Array[Byte]] = cachedBytes match { case Some(bs) => bs.unit - case None => + case None => delegate.toRaw(serverRequest, RawBodyType.ByteArrayBody, maxBytes).map { raw => cachedBytes = Some(raw.value) raw.value diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/DecodeBasicInputs.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/DecodeBasicInputs.scala index 8577aaf267..b8db108c38 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/DecodeBasicInputs.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/DecodeBasicInputs.scala @@ -10,8 +10,7 @@ import scala.annotation.tailrec sealed trait DecodeBasicInputsResult { - /** Whether any body input in this result is an extracted body, i.e. one which requires the request body to be - * readable more than once. + /** Whether any body input in this result is an extracted body, i.e. one which requires the request body to be readable more than once. */ def hasExtractedBody: Boolean } diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala index 4cab9b6d43..a9a4b71df3 100644 --- a/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala @@ -48,7 +48,7 @@ class ServerInterpreterExtractedBodyTest extends AnyFlatSpec with Matchers { val result = interpreter.apply(createTestRequest(List("test"), _method = Method.POST)) - result shouldBe a[RequestResult.Response[_]] + result shouldBe a[RequestResult.Response[?]] val response = result.asInstanceOf[RequestResult.Response[String]].response response.body shouldBe Some("security:payload|logic:payload") requestBody.reads shouldBe 1 From 83a427a8aaf3c3a1187cb8acb1c53a8c3006d51c Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 12:48:32 +0200 Subject: [PATCH 11/32] feat: reject invalid body definitions when routes are constructed 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. --- .../interpreter/FilterServerEndpoints.scala | 7 ++++- .../FilterServerEndpointsTest.scala | 27 +++++++++++++++++++ .../finatra/FinatraServerInterpreter.scala | 6 +++-- .../cats/VertxCatsServerInterpreter.scala | 6 +++-- .../vertx/VertxFutureServerInterpreter.scala | 8 ++++-- .../vertx/zio/VertxZioServerInterpreter.scala | 5 +++- .../server/ziohttp/ZioHttpInterpreter.scala | 5 +++- 7 files changed, 55 insertions(+), 9 deletions(-) diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/FilterServerEndpoints.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/FilterServerEndpoints.scala index 58861fef34..ca3f54913d 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/FilterServerEndpoints.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/FilterServerEndpoints.scala @@ -3,7 +3,7 @@ package sttp.tapir.server.interpreter import sttp.tapir.{AnyEndpoint, EndpointInput} import sttp.tapir.internal.RichEndpointInput import sttp.tapir.model.ServerRequest -import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.{EndpointBodyProblems, EndpointBodyVerifier, ServerEndpoint} class FilterServerEndpoints[R, F[_]](rootLayer: PathLayer[R, F]) extends (ServerRequest => List[ServerEndpoint[R, F]]) { @@ -98,11 +98,16 @@ object FilterServerEndpoints { } def apply[R, F[_]](serverEndpoints: List[ServerEndpoint[R, F]]): FilterServerEndpoints[R, F] = { + throwOnErrors(EndpointBodyVerifier.verify(serverEndpoints.map(_.endpoint))) + val segmentsToEndpoints: List[(List[PathSegment], ServerEndpoint[R, F])] = serverEndpoints.map(se => segmentsForEndpoint(se.endpoint) -> se) new FilterServerEndpoints[R, F](createLayer(segmentsToEndpoints)) } + + private[tapir] def throwOnErrors(problems: EndpointBodyProblems): Unit = + if (problems.errors.nonEmpty) throw new IllegalArgumentException(problems.errors.mkString(" ")) } private trait PathLayer[R, F[_]] { diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala index 3b2b449d4e..5f499d42ba 100644 --- a/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala @@ -4,6 +4,8 @@ import sttp.tapir._ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import sttp.model.{Header, Method, QueryParams, Uri} +import sttp.monad.{IdentityMonad, MonadError} +import sttp.shared.Identity import sttp.tapir.model.{ConnectionInfo, ServerRequest} import sttp.tapir.server.ServerEndpoint @@ -11,6 +13,8 @@ import scala.concurrent.Future import scala.collection.immutable.Seq class FilterServerEndpointsTest extends AnyFlatSpec with Matchers { + private implicit val idMonad: MonadError[Identity] = IdentityMonad + it should "filter endpoints with a single fixed path component" in { val e1 = endpoint.in("x").noLogic val e2 = endpoint.in("y").noLogic @@ -136,6 +140,29 @@ class FilterServerEndpointsTest extends AnyFlatSpec with Matchers { filter(requestWithPath("y")) shouldBe Nil } + it should "throw when an endpoint declares two primary bodies" in { + val se = endpoint.post + .in("people") + .securityIn(stringBody) + .in(stringBody) + .serverSecurityLogic[Unit, Identity](_ => Right(())) + .serverLogic(_ => _ => Right(())) + + val e = the[IllegalArgumentException] thrownBy FilterServerEndpoints(List(se)) + e.getMessage should include("extractBodyFromRequest") + } + + it should "accept an endpoint with an extracted body" in { + val se = endpoint.post + .in("people") + .securityIn(extractBodyFromRequest(stringBody)) + .in(stringBody) + .serverSecurityLogic[Unit, Identity](_ => Right(())) + .serverLogic(_ => _ => Right(())) + + noException should be thrownBy FilterServerEndpoints(List(se)) + } + implicit class NoLogic[I, E](e: PublicEndpoint[I, E, Unit, Any]) { def noLogic: ServerEndpoint[Any, Future] = e.serverLogicSuccessPure[Future](_ => ()) } diff --git a/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraServerInterpreter.scala b/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraServerInterpreter.scala index 6bbf17ee6c..8beff1b402 100644 --- a/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraServerInterpreter.scala +++ b/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraServerInterpreter.scala @@ -7,10 +7,10 @@ import sttp.monad.MonadError import sttp.tapir.EndpointInput.PathCapture import sttp.tapir.capabilities.NoStreams import sttp.tapir.internal._ -import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.{EndpointBodyVerifier, ServerEndpoint} import sttp.tapir.server.finatra.FinatraServerInterpreter.FutureMonadError import sttp.tapir.server.interceptor.RequestResult -import sttp.tapir.server.interpreter.ServerInterpreter +import sttp.tapir.server.interpreter.{FilterServerEndpoints, ServerInterpreter} import sttp.tapir._ trait FinatraServerInterpreter extends Logging { @@ -18,6 +18,8 @@ trait FinatraServerInterpreter extends Logging { def finatraServerOptions: FinatraServerOptions = FinatraServerOptions.default def toRoute(se: ServerEndpoint[Any, Future]): FinatraRoute = { + FilterServerEndpoints.throwOnErrors(EndpointBodyVerifier.verifyOne(se.endpoint)) + val serverInterpreter = new ServerInterpreter[Any, Future, FinatraContent, NoStreams]( _ => List(se), new FinatraRequestBody(finatraServerOptions), diff --git a/server/vertx-server/cats/src/main/scala/sttp/tapir/server/vertx/cats/VertxCatsServerInterpreter.scala b/server/vertx-server/cats/src/main/scala/sttp/tapir/server/vertx/cats/VertxCatsServerInterpreter.scala index 5c8f96bd3e..c088660fa4 100644 --- a/server/vertx-server/cats/src/main/scala/sttp/tapir/server/vertx/cats/VertxCatsServerInterpreter.scala +++ b/server/vertx-server/cats/src/main/scala/sttp/tapir/server/vertx/cats/VertxCatsServerInterpreter.scala @@ -8,9 +8,9 @@ import io.vertx.ext.web.{Route, Router, RoutingContext} import sttp.capabilities.{Streams, WebSockets} import sttp.capabilities.fs2.Fs2Streams import sttp.monad.MonadError -import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.{EndpointBodyVerifier, ServerEndpoint} import sttp.tapir.server.interceptor.RequestResult -import sttp.tapir.server.interpreter.{BodyListener, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} import sttp.tapir.server.vertx.{VertxBodyListener, VertxErrorHandler} import sttp.tapir.server.vertx.cats.VertxCatsServerInterpreter.{CatsFFromVFuture, CatsRunAsync, VertxFutureToCatsF, monadError} import sttp.tapir.server.vertx.decoders.{VertxRequestBody, VertxServerRequest} @@ -35,6 +35,8 @@ trait VertxCatsServerInterpreter[F[_]] extends CommonServerInterpreter with Vert def route( e: ServerEndpoint[Fs2Streams[F] with WebSockets, F] ): Router => Route = { router => + FilterServerEndpoints.throwOnErrors(EndpointBodyVerifier.verifyOne(e.endpoint)) + val routeDef = extractRouteDefinition(e.endpoint) val readStreamCompatible = fs2ReadStreamCompatible(vertxCatsServerOptions) optionsRouteIfCORSDefined(e)(router, routeDef, vertxCatsServerOptions) diff --git a/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/VertxFutureServerInterpreter.scala b/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/VertxFutureServerInterpreter.scala index 6f156a6ad5..6beac08857 100644 --- a/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/VertxFutureServerInterpreter.scala +++ b/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/VertxFutureServerInterpreter.scala @@ -4,9 +4,9 @@ import io.vertx.core.{Handler, Future => VFuture} import io.vertx.ext.web.{Route, Router, RoutingContext} import sttp.capabilities.WebSockets import sttp.monad.FutureMonad -import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.{EndpointBodyVerifier, ServerEndpoint} import sttp.tapir.server.interceptor.RequestResult -import sttp.tapir.server.interpreter.{BodyListener, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} import sttp.tapir.server.vertx.VertxFutureServerInterpreter.{FutureFromVFuture, FutureRunAsync, VertxFutureToScalaFuture} import sttp.tapir.server.vertx.decoders.{VertxRequestBody, VertxServerRequest} import sttp.tapir.server.vertx.encoders.{VertxOutputEncoders, VertxToResponseBody} @@ -26,6 +26,8 @@ trait VertxFutureServerInterpreter extends CommonServerInterpreter with VertxErr * A function, that given a router, will attach this endpoint to it */ def route[A, U, I, E, O](e: ServerEndpoint[VertxStreams with WebSockets, Future]): Router => Route = { router => + FilterServerEndpoints.throwOnErrors(EndpointBodyVerifier.verifyOne(e.endpoint)) + val routeDef = extractRouteDefinition(e.endpoint) optionsRouteIfCORSDefined(e)(router, routeDef, vertxFutureServerOptions) .foreach(_.handler(endpointHandler(e))) @@ -40,6 +42,8 @@ trait VertxFutureServerInterpreter extends CommonServerInterpreter with VertxErr * A function, that given a router, will attach this endpoint to it */ def blockingRoute(e: ServerEndpoint[VertxStreams with WebSockets, Future]): Router => Route = { router => + FilterServerEndpoints.throwOnErrors(EndpointBodyVerifier.verifyOne(e.endpoint)) + val routeDef = extractRouteDefinition(e.endpoint) optionsRouteIfCORSDefined(e)(router, routeDef, vertxFutureServerOptions) .foreach(_.handler(endpointHandler(e))) diff --git a/server/vertx-server/zio/src/main/scala/sttp/tapir/server/vertx/zio/VertxZioServerInterpreter.scala b/server/vertx-server/zio/src/main/scala/sttp/tapir/server/vertx/zio/VertxZioServerInterpreter.scala index 57f4652d69..e84c95cebb 100644 --- a/server/vertx-server/zio/src/main/scala/sttp/tapir/server/vertx/zio/VertxZioServerInterpreter.scala +++ b/server/vertx-server/zio/src/main/scala/sttp/tapir/server/vertx/zio/VertxZioServerInterpreter.scala @@ -4,8 +4,9 @@ import io.vertx.core.{Future, Handler} import io.vertx.ext.web.{Route, Router, RoutingContext} import sttp.capabilities.WebSockets import sttp.capabilities.zio.ZioStreams +import sttp.tapir.server.EndpointBodyVerifier import sttp.tapir.server.interceptor.RequestResult -import sttp.tapir.server.interpreter.{BodyListener, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} import sttp.tapir.server.vertx.VertxBodyListener import sttp.tapir.server.vertx.VertxErrorHandler import sttp.tapir.server.vertx.decoders.{VertxRequestBody, VertxServerRequest} @@ -25,6 +26,8 @@ trait VertxZioServerInterpreter[R] extends CommonServerInterpreter with VertxErr def route[R2](e: ZServerEndpoint[R2, ZioStreams with WebSockets])(implicit runtime: Runtime[R & R2] ): Router => Route = { router => + FilterServerEndpoints.throwOnErrors(EndpointBodyVerifier.verifyOne(e.endpoint)) + val routeDef = extractRouteDefinition(e.endpoint) optionsRouteIfCORSDefined(e.widen)(router, routeDef, vertxZioServerOptions) .foreach(_.handler(endpointHandler(e))) diff --git a/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpInterpreter.scala b/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpInterpreter.scala index 5af6afbf8e..09ae0db3b8 100644 --- a/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpInterpreter.scala +++ b/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpInterpreter.scala @@ -6,9 +6,10 @@ import sttp.model.{Header => SttpHeader} import sttp.monad.MonadError import sttp.tapir.EndpointInput import sttp.tapir.internal.RichEndpointInput +import sttp.tapir.server.EndpointBodyVerifier import sttp.tapir.server.interceptor.RequestResult import sttp.tapir.server.interceptor.reject.RejectInterceptor -import sttp.tapir.server.interpreter.ServerInterpreter +import sttp.tapir.server.interpreter.{FilterServerEndpoints, ServerInterpreter} import sttp.tapir.server.model.ServerResponse import sttp.tapir.ztapir._ import zio._ @@ -23,6 +24,8 @@ trait ZioHttpInterpreter[R] { toHttp(List(se)) def toHttp[R2](ses: List[ZServerEndpoint[R2, ZioStreams with WebSockets]]): Routes[R & R2, Response] = { + FilterServerEndpoints.throwOnErrors(EndpointBodyVerifier.verify(ses.map(_.endpoint))) + implicit val bodyListener: ZioHttpBodyListener[R & R2] = new ZioHttpBodyListener[R & R2] implicit val monadError: MonadError[RIO[R & R2, *]] = new RIOMonadError[R & R2] val widenedSes = ses.map(_.widen[R & R2]) From c283c589c8fdc07ac10bbae739626046c9470918 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 13:33:52 +0200 Subject: [PATCH 12/32] feat: exclude extracted bodies from generated documentation 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. --- .../apispec/schema/SchemasForEndpoints.scala | 3 +- .../EndpointInputToDecodeFailureOutput.scala | 3 +- .../docs/openapi/EndpointToOpenAPIPaths.scala | 2 +- .../docs/openapi/ExtractedBodyDocsTest.scala | 31 +++++++++++++++++++ .../protobuf/EndpointToProtobufMessage.scala | 2 ++ 5 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/ExtractedBodyDocsTest.scala diff --git a/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/SchemasForEndpoints.scala b/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/SchemasForEndpoints.scala index 30461798cf..3882a1f180 100644 --- a/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/SchemasForEndpoints.scala +++ b/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/SchemasForEndpoints.scala @@ -3,7 +3,7 @@ package sttp.tapir.docs.apispec.schema import sttp.apispec.{Schema => ASchema} import sttp.tapir.Schema.SName import sttp.tapir._ -import sttp.tapir.internal.IterableToListMap +import sttp.tapir.internal._ import scala.collection.immutable.ListMap @@ -74,6 +74,7 @@ class SchemasForEndpoints( 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.isExtracted => List.empty case EndpointIO.Body(_, codec, _) => ToKeyedSchemas(codec) case EndpointIO.OneOfBody(variants, _) => variants.flatMap(v => forIO(v.bodyAsAtom)) case EndpointIO.StreamBodyWrapper(StreamBodyIO(_, codec, _, _, _)) => ToKeyedSchemas(codec.schema) diff --git a/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointInputToDecodeFailureOutput.scala b/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointInputToDecodeFailureOutput.scala index 5aa66aa253..d8032b9cbf 100644 --- a/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointInputToDecodeFailureOutput.scala +++ b/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointInputToDecodeFailureOutput.scala @@ -8,7 +8,8 @@ import scala.annotation.tailrec private[openapi] object EndpointInputToDecodeFailureOutput { def defaultBadRequestDescription(input: EndpointInput[_]): Option[String] = { - val fallibleBasicInputs = input.asVectorOfBasicInputs(includeAuth = false).filter(inputMayFailWithBadRequest) + val fallibleBasicInputs = + input.asVectorOfBasicInputs(includeAuth = false).filterNot(isExtractedBodyInput).filter(inputMayFailWithBadRequest) if (fallibleBasicInputs.nonEmpty) Some(badRequestDescription(fallibleBasicInputs)) else None diff --git a/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToOpenAPIPaths.scala b/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToOpenAPIPaths.scala index 65dee11571..64f12da1fd 100644 --- a/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToOpenAPIPaths.scala +++ b/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToOpenAPIPaths.scala @@ -52,7 +52,7 @@ private[openapi] class EndpointToOpenAPIPaths( variants.filterNot(_.codec.schema.hidden), mapping ) - case a: EndpointInput.Atom[_] if !a.codec.schema.hidden => a + case a: EndpointInput.Atom[_] if !a.codec.schema.hidden && !isExtractedBodyInput(a) => a } private def endpointToOperation(defaultId: String, e: AnyEndpoint, inputs: Vector[EndpointInput.Basic[_]]): Operation = { diff --git a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/ExtractedBodyDocsTest.scala b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/ExtractedBodyDocsTest.scala new file mode 100644 index 0000000000..88aa24491f --- /dev/null +++ b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/ExtractedBodyDocsTest.scala @@ -0,0 +1,31 @@ +package sttp.tapir.docs.openapi + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.apispec.openapi.circe.yaml._ +import sttp.tapir._ + +class ExtractedBodyDocsTest extends AnyFlatSpec with Matchers { + it should "document only the primary body" in { + val e = endpoint.post + .in("people") + .securityIn(extractBodyFromRequest(stringBody)) + .in(byteArrayBody) + + // suppress the default 400 response, whose body is always documented as text/plain regardless of the + // endpoint's inputs, so the assertion below isolates the request body under test + val options = OpenAPIDocsOptions.default.copy(defaultDecodeFailureOutput = _ => None) + val yaml = OpenAPIDocsInterpreter(options).toOpenAPI(e, "Test", "1.0").toYaml + + yaml should include("application/octet-stream") + yaml should not include ("text/plain") + } + + it should "document no body when the only body is extracted" in { + val e = endpoint.post.in("ingest").securityIn(extractBodyFromRequest(stringBody)).out(stringBody) + + val yaml = OpenAPIDocsInterpreter().toOpenAPI(e, "Test", "1.0").toYaml + + yaml should not include ("requestBody") + } +} diff --git a/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufMessage.scala b/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufMessage.scala index 337c1bcccd..5d58678409 100644 --- a/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufMessage.scala +++ b/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufMessage.scala @@ -4,6 +4,7 @@ import sttp.tapir.Schema.SName import sttp.tapir.SchemaType.{SArray, SCoproduct, SDate, SDateTime, SInteger, SNumber, SProduct, SProductField, SString} import sttp.tapir.{Schema, _} import sttp.tapir.grpc.protobuf.model._ +import sttp.tapir.internal._ class EndpointToProtobufMessage { def apply(es: List[AnyEndpoint]): List[ProtobufMessage] = @@ -55,6 +56,7 @@ class EndpointToProtobufMessage { case EndpointIO.Pair(left, right, _, _) => forIO(left) ++ forIO(right) case EndpointIO.Header(_, codec, _) => ??? case EndpointIO.Headers(_, _) => List.empty + case b @ EndpointIO.Body(_, _, _) if b.isExtracted => List.empty case EndpointIO.Body(_, codec, _) => fromCodec(codec) case EndpointIO.OneOfBody(variants, _) => variants.flatMap(v => forIO(v.bodyAsAtom)) case EndpointIO.StreamBodyWrapper(StreamBodyIO(_, codec, _, _, _)) => ??? From 7c23c68eabb76e03e6dc36d3725fc0d68b293257 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 14:28:40 +0200 Subject: [PATCH 13/32] feat: client interpreters ignore extracted bodies 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. --- .../http4s/EndpointToHttp4sClient.scala | 6 +++-- .../client/play/EndpointToPlayClient.scala | 6 +++-- .../client/play/EndpointToPlayClient.scala | 6 +++-- .../client/sttp/EndpointToSttpClient.scala | 5 +++- .../sttp4/EndpointToSttpClientBase.scala | 5 +++- .../sttp4/ExtractedBodyClientTest.scala | 26 +++++++++++++++++++ 6 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/ExtractedBodyClientTest.scala diff --git a/client/http4s-client/src/main/scala/sttp/tapir/client/http4s/EndpointToHttp4sClient.scala b/client/http4s-client/src/main/scala/sttp/tapir/client/http4s/EndpointToHttp4sClient.scala index 1e8b3184ab..9e39ebec3b 100644 --- a/client/http4s-client/src/main/scala/sttp/tapir/client/http4s/EndpointToHttp4sClient.scala +++ b/client/http4s-client/src/main/scala/sttp/tapir/client/http4s/EndpointToHttp4sClient.scala @@ -13,7 +13,7 @@ import sttp.capabilities.fs2.Fs2Streams import sttp.model.ResponseMetadata import sttp.tapir.Codec.PlainCodec import sttp.tapir.client.ClientOutputParams -import sttp.tapir.internal.{Params, ParamsAsAny, RichEndpointOutput, SplitParams} +import sttp.tapir.internal.{Params, ParamsAsAny, RichEndpointIOBody, RichEndpointOutput, SplitParams} import sttp.tapir.{ Codec, CodecFormat, @@ -100,7 +100,9 @@ private[http4s] class EndpointToHttp4sClient(clientOptions: Http4sClientOptions) currentUri.withQueryParam(key, values) } req.withUri(uri) - case EndpointIO.Empty(_, _) => req + case EndpointIO.Empty(_, _) => req + case b @ EndpointIO.Body(_, _, _) if b.isExtracted => + req // decoded server-side only; not part of the request the client sends case EndpointIO.Body(bodyType, codec, _) => setBody(value, bodyType, codec, req) case ob: EndpointIO.OneOfBody[_, _] => ob.headVariantBodyWithAppliedMapping match { diff --git a/client/play-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala b/client/play-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala index 8ed57b8208..cac857793d 100644 --- a/client/play-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala +++ b/client/play-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala @@ -8,7 +8,7 @@ import sttp.capabilities.pekko.PekkoStreams import sttp.model.{Header, Method, ResponseMetadata} import sttp.tapir.Codec.PlainCodec import sttp.tapir.client.ClientOutputParams -import sttp.tapir.internal.{Params, ParamsAsAny, RichEndpointOutput, SplitParams} +import sttp.tapir.internal.{Params, ParamsAsAny, RichEndpointIOBody, RichEndpointOutput, SplitParams} import sttp.tapir.{ Codec, CodecFormat, @@ -115,7 +115,9 @@ private[play] class EndpointToPlayClient(clientOptions: PlayClientOptions, ws: S case EndpointInput.QueryParams(codec, _) => val mqp = codec.encode(value) req.addQueryStringParameters(mqp.toSeq: _*) - case EndpointIO.Empty(_, _) => req + case EndpointIO.Empty(_, _) => req + case b @ EndpointIO.Body(_, _, _) if b.isExtracted => + req // decoded server-side only; not part of the request the client sends case EndpointIO.Body(bodyType, codec, _) => val req2 = setBody(value, bodyType, codec, req) req2 diff --git a/client/play29-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala b/client/play29-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala index 118294a345..ee2dc30d0f 100644 --- a/client/play29-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala +++ b/client/play29-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala @@ -8,7 +8,7 @@ import sttp.capabilities.akka.AkkaStreams import sttp.model.{Header, Method, ResponseMetadata} import sttp.tapir.Codec.PlainCodec import sttp.tapir.client.ClientOutputParams -import sttp.tapir.internal.{Params, ParamsAsAny, RichEndpointOutput, SplitParams} +import sttp.tapir.internal.{Params, ParamsAsAny, RichEndpointIOBody, RichEndpointOutput, SplitParams} import sttp.tapir.{ Codec, CodecFormat, @@ -115,7 +115,9 @@ private[play] class EndpointToPlayClient(clientOptions: PlayClientOptions, ws: S case EndpointInput.QueryParams(codec, _) => val mqp = codec.encode(value) req.addQueryStringParameters(mqp.toSeq: _*) - case EndpointIO.Empty(_, _) => req + case EndpointIO.Empty(_, _) => req + case b @ EndpointIO.Body(_, _, _) if b.isExtracted => + req // decoded server-side only; not part of the request the client sends case EndpointIO.Body(bodyType, codec, _) => val req2 = setBody(value, bodyType, codec, req) req2 diff --git a/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/EndpointToSttpClient.scala b/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/EndpointToSttpClient.scala index 5e37fb09f4..ff1f3df1fa 100644 --- a/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/EndpointToSttpClient.scala +++ b/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/EndpointToSttpClient.scala @@ -91,7 +91,10 @@ private[sttp] class EndpointToSttpClient[R](clientOptions: SttpClientOptions, ws val mqp = codec.encode(value) val uri2 = uri.addParams(mqp.toSeq: _*) (uri2, req) - case EndpointIO.Empty(_, _) => (uri, req) + case EndpointIO.Empty(_, _) => (uri, req) + case b @ EndpointIO.Body(_, _, _) if b.isExtracted => + // decoded server-side only; not part of the request the client sends + (uri, req) case EndpointIO.Body(bodyType, codec, _) => val req2 = setBody(value, bodyType, codec, req) (uri, req2) diff --git a/client/sttp-client4/src/main/scala/sttp/tapir/client/sttp4/EndpointToSttpClientBase.scala b/client/sttp-client4/src/main/scala/sttp/tapir/client/sttp4/EndpointToSttpClientBase.scala index 551c1d90e0..047ab1ad1a 100644 --- a/client/sttp-client4/src/main/scala/sttp/tapir/client/sttp4/EndpointToSttpClientBase.scala +++ b/client/sttp-client4/src/main/scala/sttp/tapir/client/sttp4/EndpointToSttpClientBase.scala @@ -108,7 +108,10 @@ private[sttp4] trait EndpointToSttpClientBase { val mqp = codec.encode(value) val uri2 = uri.addParams(mqp.toSeq: _*) (uri2, req, streamBody) - case EndpointIO.Empty(_, _) => (uri, req, streamBody) + case EndpointIO.Empty(_, _) => (uri, req, streamBody) + case b @ EndpointIO.Body(_, _, _) if b.isExtracted => + // decoded server-side only; not part of the request the client sends + (uri, req, streamBody) case EndpointIO.Body(bodyType, codec, _) => val req2 = setBody(value, bodyType, codec, req) (uri, req2, streamBody) diff --git a/client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/ExtractedBodyClientTest.scala b/client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/ExtractedBodyClientTest.scala new file mode 100644 index 0000000000..c322f6d9c2 --- /dev/null +++ b/client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/ExtractedBodyClientTest.scala @@ -0,0 +1,26 @@ +package sttp.tapir.client.sttp4 + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.client4.Request +import sttp.model.Uri._ +import sttp.tapir._ + +class ExtractedBodyClientTest extends AnyFlatSpec with Matchers { + it should "send only the primary body, ignoring the extracted one" in { + // The extracted body is wrapped on the `in` side (processed *after* `securityIn` by + // EndpointToSttpClientBase#prepareRequestWithInput), so a client that fails to skip it would + // overwrite the primary body's value on the request, which is exactly what this test guards against. + val e = endpoint.post + .in("people") + .securityIn(stringBody) + .in(extractBodyFromRequest(stringBody)) + .out(stringBody) + + val request: Request[_] = + SttpClientInterpreter().toSecureRequestThrowDecodeFailures(e, Some(uri"http://example.com"))("sent")("ignored") + + request.body.show should include("sent") + request.body.show should not include ("ignored") + } +} From a858658c9ff257a46b4cfc92d051de1edf32d65a Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 14:45:56 +0200 Subject: [PATCH 14/32] test: cross-backend regression test for reading the body twice (#4442) 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. --- .../server/tests/ServerSecurityTests.scala | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerSecurityTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerSecurityTests.scala index 8b8cd5d121..c3c92f3dea 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerSecurityTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerSecurityTests.scala @@ -139,6 +139,39 @@ class ServerSecurityTests[F[_], S, OPTIONS, ROUTE](createServerTest: CreateServe bearer.code.code shouldBe 200 without.code.code shouldBe 200 } + }, + testServerLogic( + endpoint.post + .in("extracted") + .securityIn(extractBodyFromRequest(stringBody)) + .in(stringBody) + .out(stringBody) + .serverSecurityLogic((raw: String) => pureResult(s"security:$raw".asRight[Unit])) + .serverLogic(principal => body => pureResult(s"$principal|logic:$body".asRight[Unit])), + "extracted body is decoded for both security and main logic" + ) { (backend, baseUri) => + basicStringRequest + .post(uri"$baseUri/extracted") + .body("payload") + .send(backend) + .map(_.body shouldBe "security:payload|logic:payload") + }, + testServerLogic( + endpoint.post + .in("extracted-denied") + .securityIn(extractBodyFromRequest(stringBody)) + .in(stringBody) + .out(stringBody) + .errorOut(stringBody) + .serverSecurityLogic((_: String) => pureResult("denied".asLeft[Unit])) + .serverLogic(_ => (body: String) => pureResult(body.asRight[String])), + "extracted body short-circuits on security failure" + ) { (backend, baseUri) => + basicStringRequest + .post(uri"$baseUri/extracted-denied") + .body("payload") + .send(backend) + .map(_.body shouldBe "denied") } ) ++ correctAuthTests ++ From de0f8331ffba86d2f6191d1d47b348a947e460bd Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 14:58:34 +0200 Subject: [PATCH 15/32] docs: document extractBodyFromRequest and endpoint verification 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 --- doc/endpoint/security.md | 37 +++++++++++++++++++++++++++++++++++++ doc/server/logic.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/doc/endpoint/security.md b/doc/endpoint/security.md index 319e1b193b..d4ae633384 100644 --- a/doc/endpoint/security.md +++ b/doc/endpoint/security.md @@ -35,6 +35,43 @@ the `oauth2-redirect.html`, see [Generating OpenAPI documentation](../docs/opena supported, as well as optional variants: `authorizationCodeFlow[Optional]`, `clientCredentialsFlow[Optional]`, `implicitFlow[Optional]`. +## Using the request body in security logic + +Security logic sometimes needs the request body itself - for example, to verify a signature computed over the raw +payload. The request body can normally be read only once, so an endpoint which needs it in both +`serverSecurityLogic` and the main logic must mark one of the two declarations with `extractBodyFromRequest`: + +```scala mdoc:compile-only +import sttp.tapir.* +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.* +import io.circe.generic.auto.* + +case class Person(name: String, age: Int) + +val secureEndpoint = endpoint.post + .securityIn(auth.bearer[String]()) + .securityIn(extractBodyFromRequest(stringBody)) + .in("people") + .in(jsonBody[Person]) +``` + +An extracted 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 extracted 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. + +Only bodies which can be re-read from buffered bytes can be extracted: string, byte array, byte buffer, input stream +and input stream range bodies. File and multipart bodies aren't accepted - `extractBodyFromRequest` requires a +`ReplayableRawBody` instance for the body's raw type, and none is provided for `RawBodyType.FileBody` or +`RawBodyType.MultipartBody` - while streaming and `oneOf` bodies aren't `EndpointIO.Body` values at all, so they +can't be passed to `extractBodyFromRequest` in the first place. Either way, the restriction is enforced at compile +time. + +Note that a *single* body input needs no wrapper: an endpoint which reads the body only in `serverSecurityLogic`, +with no body declared in `in`, reads the request exactly once. It works without `extractBodyFromRequest`, and stays +fully documented and visible to clients. + ## Authentication challenges For each `auth` scheme, one can define `WWW-Authenticate` headers that should be returned by the server in case input is diff --git a/doc/server/logic.md b/doc/server/logic.md index 233ed07ce1..e6405adeb3 100644 --- a/doc/server/logic.md +++ b/doc/server/logic.md @@ -190,6 +190,34 @@ an error response is returned. Additional outputs can be then added to the resulting partial endpoint. +## Verifying endpoint descriptions + +When routes are constructed, tapir checks that each endpoint is structurally serveable, and throws an +`IllegalArgumentException` if it isn't - for example, if a request body is declared both in `securityIn` and in `in`, +without one of them being wrapped in +[`extractBodyFromRequest`](../endpoint/security.md#using-the-request-body-in-security-logic). + +Other problems are reported only as warnings, since the endpoint still serves correctly, even though its published +contract is probably not what was intended - for example, an `extractBodyFromRequest` input with no body declared in +`in`, which will never be sent by clients or appear in the documentation. Unlike errors, warnings aren't checked +automatically, and tapir doesn't log them anywhere - `server/core`, where `EndpointBodyVerifier` lives, deliberately +contains no logging. To see them, call `EndpointBodyVerifier` yourself, e.g. asserting on the result in your own tests: + +```scala mdoc:compile-only +import sttp.tapir.* +import sttp.tapir.server.EndpointBodyVerifier + +val endpoints: List[AnyEndpoint] = List( + endpoint.post.in("ingest").securityIn(extractBodyFromRequest(stringBody)) +) + +val problems = EndpointBodyVerifier.verify(endpoints) +assert(problems.warnings.nonEmpty) +``` + +`EndpointBodyVerifier.verify` checks a list of endpoints, while `EndpointBodyVerifier.verifyOne` checks a single one; +both return an `EndpointBodyProblems(errors, warnings)` value. + ## Status codes By default, successful responses are returned with the `200 OK` status code, and errors with `400 Bad Request`. However, From 9205c428f1ee6ac5b0e5055d8c63ec1998684cc5 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 15:36:14 +0200 Subject: [PATCH 16/32] fix: flatten oneOfBody variants in EndpointBodyVerifier, correct tooManyPrimaries 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. --- .../tapir/server/EndpointBodyVerifier.scala | 31 +++++++++++++++---- .../server/EndpointBodyVerifierTest.scala | 22 +++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala b/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala index 18d3206029..b36fae1249 100644 --- a/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala +++ b/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala @@ -24,16 +24,25 @@ object EndpointBodyVerifier { endpoints.map(verifyOne).foldLeft(EndpointBodyProblems.Empty)(_ ++ _) def verifyOne(endpoint: AnyEndpoint): EndpointBodyProblems = { - val inputs = endpoint.securityInput.asVectorOfBasicInputs() ++ endpoint.input.asVectorOfBasicInputs() + val securityInputs = endpoint.securityInput.asVectorOfBasicInputs() + val ordinaryInputs = endpoint.input.asVectorOfBasicInputs() + val inputs = securityInputs ++ ordinaryInputs val extracted = inputs.collect { case b: EndpointIO.Body[?, ?] if b.isExtracted => b } - val primaryBodies: Vector[EndpointInput.Basic[?]] = inputs.collect { + def primaryBodiesOf(basics: Vector[EndpointInput.Basic[?]]): Vector[EndpointInput.Basic[?]] = basics.collect { case b: EndpointIO.Body[?, ?] if !b.isExtracted => b case b: EndpointIO.OneOfBody[?, ?] => b case b: EndpointIO.StreamBodyWrapper[?, ?] => b } - val streamingPrimary = primaryBodies.exists(_.isInstanceOf[EndpointIO.StreamBodyWrapper[?, ?]]) - val nonReplayablePrimary = primaryBodies.exists { + val securityPrimaryBodies = primaryBodiesOf(securityInputs) + val inPrimaryBodies = primaryBodiesOf(ordinaryInputs) + val primaryBodies = securityPrimaryBodies ++ inPrimaryBodies + val primaryBodyAtoms: Vector[EndpointInput.Basic[?]] = primaryBodies.flatMap { + case ob: EndpointIO.OneOfBody[?, ?] => ob.variants.map(_.bodyAsAtom).toVector + case other => Vector(other) + } + val streamingPrimary = primaryBodyAtoms.exists(_.isInstanceOf[EndpointIO.StreamBodyWrapper[?, ?]]) + val nonReplayablePrimary = primaryBodyAtoms.exists { case b: EndpointIO.Body[?, ?] => b.bodyType match { case RawBodyType.FileBody => true @@ -44,13 +53,23 @@ object EndpointBodyVerifier { } val shown = endpoint.showShort - val tooManyPrimaries = - if (primaryBodies.size > 1) + val tooManyPrimaries: List[String] = + if (securityPrimaryBodies.nonEmpty && inPrimaryBodies.nonEmpty) List( s"Endpoint $shown declares a request body in both securityIn and in. Only one may be part of the API " + s"contract. If both should decode the same request body, wrap the securityIn one: " + s"extractBodyFromRequest(...)." ) + else if (securityPrimaryBodies.size > 1) + List( + s"Endpoint $shown declares more than one request body in securityIn. Only one request body may be part " + + s"of the API contract." + ) + else if (inPrimaryBodies.size > 1) + List( + s"Endpoint $shown declares more than one request body in in. Only one request body may be part of the " + + s"API contract." + ) else Nil val streamWithExtracted = diff --git a/server/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala b/server/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala index efe42a101c..da27a7dcb1 100644 --- a/server/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala +++ b/server/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala @@ -46,6 +46,28 @@ class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { problems.errors.head should include("file") } + it should "reject a oneOfBody of streaming variants combined with an extracted body" in { + val e = endpoint.post + .in("people") + .securityIn(extractBodyFromRequest(stringBody)) + .in[Nothing, Unit](oneOfBody[Nothing](streamTextBody(NoStreams)(CodecFormat.TextPlain()).toEndpointIO)) + val problems = EndpointBodyVerifier.verifyOne(e) + + problems.errors should have size 1 + problems.errors.head should include("streaming body") + } + + it should "reject a oneOfBody with a file body variant combined with an extracted body" in { + val e = endpoint.post + .in("people") + .securityIn(extractBodyFromRequest(stringBody)) + .in(oneOfBody(fileBody)) + val problems = EndpointBodyVerifier.verifyOne(e) + + problems.errors should have size 1 + problems.errors.head should include("file") + } + it should "warn about an extracted body with no primary body on POST" in { val e = endpoint.post.in("ingest").securityIn(extractBodyFromRequest(stringBody)) val problems = EndpointBodyVerifier.verifyOne(e) From 378046168f7e4e8eba91fc17fef719d0a56af388 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 15:44:17 +0200 Subject: [PATCH 17/32] fix: Scala 2.12 type inference for primaryBodyAtoms flatMap 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. --- .../main/scala/sttp/tapir/server/EndpointBodyVerifier.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala b/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala index b36fae1249..f1fbc52def 100644 --- a/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala +++ b/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala @@ -37,10 +37,11 @@ object EndpointBodyVerifier { val securityPrimaryBodies = primaryBodiesOf(securityInputs) val inPrimaryBodies = primaryBodiesOf(ordinaryInputs) val primaryBodies = securityPrimaryBodies ++ inPrimaryBodies - val primaryBodyAtoms: Vector[EndpointInput.Basic[?]] = primaryBodies.flatMap { + def asAtoms(body: EndpointInput.Basic[?]): Vector[EndpointInput.Basic[?]] = body match { case ob: EndpointIO.OneOfBody[?, ?] => ob.variants.map(_.bodyAsAtom).toVector case other => Vector(other) } + val primaryBodyAtoms: Vector[EndpointInput.Basic[?]] = primaryBodies.flatMap(asAtoms) val streamingPrimary = primaryBodyAtoms.exists(_.isInstanceOf[EndpointIO.StreamBodyWrapper[?, ?]]) val nonReplayablePrimary = primaryBodyAtoms.exists { case b: EndpointIO.Body[?, ?] => From f610a7560c5e7c2b4852ea1fa3be94a1fb165284 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 15:44:22 +0200 Subject: [PATCH 18/32] chore: fix compiler warnings introduced by extractBodyFromRequest tests 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]). --- .../tapir/server/interpreter/FilterServerEndpointsTest.scala | 3 --- .../interpreter/ServerInterpreterExtractedBodyTest.scala | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala index 5f499d42ba..d3025f53dc 100644 --- a/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala @@ -4,7 +4,6 @@ import sttp.tapir._ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import sttp.model.{Header, Method, QueryParams, Uri} -import sttp.monad.{IdentityMonad, MonadError} import sttp.shared.Identity import sttp.tapir.model.{ConnectionInfo, ServerRequest} import sttp.tapir.server.ServerEndpoint @@ -13,8 +12,6 @@ import scala.concurrent.Future import scala.collection.immutable.Seq class FilterServerEndpointsTest extends AnyFlatSpec with Matchers { - private implicit val idMonad: MonadError[Identity] = IdentityMonad - it should "filter endpoints with a single fixed path component" in { val e1 = endpoint.in("x").noLogic val e2 = endpoint.in("y").noLogic diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala index a9a4b71df3..a0c7049672 100644 --- a/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala @@ -73,7 +73,7 @@ class ServerInterpreterExtractedBodyTest extends AnyFlatSpec with Matchers { _ => () ) - interpreter.apply(createTestRequest(List("test"), _method = Method.POST)) + val _ = interpreter.apply(createTestRequest(List("test"), _method = Method.POST)) requestBody.reads shouldBe 1 } } From c51a4013fc29f044254fbd757a9f49e2f2139710 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 15:44:59 +0200 Subject: [PATCH 19/32] docs: document the two startup errors around extracted bodies 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. --- doc/endpoint/security.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/doc/endpoint/security.md b/doc/endpoint/security.md index d4ae633384..b77e21fc99 100644 --- a/doc/endpoint/security.md +++ b/doc/endpoint/security.md @@ -68,6 +68,21 @@ and input stream range bodies. File and multipart bodies aren't accepted - `extr can't be passed to `extractBodyFromRequest` in the first place. Either way, the restriction is enforced at compile time. +The restriction also applies from the other side: an endpoint whose *ordinary* body (the one declared in `in`) is a +file, multipart, or streaming body can't be combined with an extracted body in `securityIn` either. Reading the +extracted body drains the request, so the file, multipart, or streaming body would then be read from an +already-consumed request. Unlike the compile-time check above, this is a runtime check: it's rejected with an +`IllegalArgumentException` when routes are constructed. + +```{warning} +Declaring two *ordinary* request bodies - one in `securityIn`, one in `in`, neither wrapped in +`extractBodyFromRequest` - is rejected the same way, since only one request body may be part of the API contract. +This is a breaking change: on backends that eagerly buffer the whole request into memory (Vert.x, Armeria, Play), +such an endpoint used to work, with both declarations decoding the same bytes. If you have an endpoint shaped like +`.securityIn(stringBody).in(stringBody)`, migrate it by wrapping the `securityIn` declaration: +`.securityIn(extractBodyFromRequest(stringBody)).in(stringBody)`. +``` + Note that a *single* body input needs no wrapper: an endpoint which reads the body only in `serverSecurityLogic`, with no body declared in `in`, reads the request exactly once. It works without `extractBodyFromRequest`, and stays fully documented and visible to clients. From 374f41a8c3bfe5115caeaf21fbe3b03c31fb3af5 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 15:50:04 +0200 Subject: [PATCH 20/32] fix: minor correctness/clarity fixes for CachingRequestBody and grpc 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. --- .../tapir/grpc/protobuf/EndpointToProtobufService.scala | 8 +++++--- .../tapir/server/interpreter/CachingRequestBody.scala | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufService.scala b/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufService.scala index 98aeb4ef68..83f648dbc5 100644 --- a/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufService.scala +++ b/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufService.scala @@ -1,6 +1,7 @@ package sttp.tapir.grpc.protobuf import sttp.tapir._ +import sttp.tapir.internal._ import sttp.tapir.grpc.protobuf.model._ import sttp.tapir.EndpointIO.Pair import sttp.tapir.EndpointIO.Empty @@ -79,9 +80,10 @@ class EndpointToProtobufService { private def forIO(io: EndpointIO[_]): List[MessageReference] = { io match { - case EndpointIO.Body(_, codec, _) => List(fromCodec(codec)) - case EndpointIO.MappedPair(wrapped, _) => forIO(wrapped) - case _ => List.empty + case b @ EndpointIO.Body(_, _, _) if b.isExtracted => List.empty + case EndpointIO.Body(_, codec, _) => List(fromCodec(codec)) + case EndpointIO.MappedPair(wrapped, _) => forIO(wrapped) + case _ => List.empty } } diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala index 1a139db81f..c798a109d4 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala @@ -20,7 +20,7 @@ private[tapir] class CachingRequestBody[F[_], S](delegate: RequestBody[F, S])(im // A plain var needs no synchronisation here: the interpreter's flatMap chain reads the security-phase body strictly // before the main-phase one, and this instance never outlives a single request. - private var cachedBytes: Option[Array[Byte]] = None + @volatile private var cachedBytes: Option[Array[Byte]] = None override def toRaw[R](serverRequest: ServerRequest, bodyType: RawBodyType[R], maxBytes: Option[Long]): F[RawValue[R]] = bodyType match { @@ -47,6 +47,8 @@ private[tapir] class CachingRequestBody[F[_], S](delegate: RequestBody[F, S])(im override def toStream(serverRequest: ServerRequest, maxBytes: Option[Long]): streams.BinaryStream = delegate.toStream(serverRequest, maxBytes).asInstanceOf[streams.BinaryStream] + // Only the first call's maxBytes actually takes effect; the security and main phases both derive it from + // se.info, which is se.endpoint.info (see ServerEndpoint.info) - the same object - so they always agree. private def bytes(serverRequest: ServerRequest, maxBytes: Option[Long]): F[Array[Byte]] = cachedBytes match { case Some(bs) => bs.unit From 9dc02e866a8ba8f166286ba7deaf8862b7c10b04 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 16:00:30 +0200 Subject: [PATCH 21/32] refactor: move throwOnErrors to EndpointBodyVerifier, fix multi-error 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. --- .../scala/sttp/tapir/server/EndpointBodyVerifier.scala | 6 ++++++ .../tapir/server/interpreter/FilterServerEndpoints.scala | 7 ++----- .../tapir/server/finatra/FinatraServerInterpreter.scala | 4 ++-- .../server/vertx/cats/VertxCatsServerInterpreter.scala | 4 ++-- .../tapir/server/vertx/VertxFutureServerInterpreter.scala | 6 +++--- .../tapir/server/vertx/zio/VertxZioServerInterpreter.scala | 4 ++-- .../sttp/tapir/server/ziohttp/ZioHttpInterpreter.scala | 4 ++-- 7 files changed, 19 insertions(+), 16 deletions(-) diff --git a/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala b/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala index f1fbc52def..499843fa6f 100644 --- a/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala +++ b/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala @@ -23,6 +23,12 @@ object EndpointBodyVerifier { def verify(endpoints: List[AnyEndpoint]): EndpointBodyProblems = endpoints.map(verifyOne).foldLeft(EndpointBodyProblems.Empty)(_ ++ _) + /** Throws an [[IllegalArgumentException]] listing all errors, if any are present. Called by server interpreters when routes are + * constructed. + */ + private[tapir] def throwOnErrors(problems: EndpointBodyProblems): Unit = + if (problems.errors.nonEmpty) throw new IllegalArgumentException(problems.errors.mkString("\n")) + def verifyOne(endpoint: AnyEndpoint): EndpointBodyProblems = { val securityInputs = endpoint.securityInput.asVectorOfBasicInputs() val ordinaryInputs = endpoint.input.asVectorOfBasicInputs() diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/FilterServerEndpoints.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/FilterServerEndpoints.scala index ca3f54913d..e6d6ea4326 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/FilterServerEndpoints.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/FilterServerEndpoints.scala @@ -3,7 +3,7 @@ package sttp.tapir.server.interpreter import sttp.tapir.{AnyEndpoint, EndpointInput} import sttp.tapir.internal.RichEndpointInput import sttp.tapir.model.ServerRequest -import sttp.tapir.server.{EndpointBodyProblems, EndpointBodyVerifier, ServerEndpoint} +import sttp.tapir.server.{EndpointBodyVerifier, ServerEndpoint} class FilterServerEndpoints[R, F[_]](rootLayer: PathLayer[R, F]) extends (ServerRequest => List[ServerEndpoint[R, F]]) { @@ -98,16 +98,13 @@ object FilterServerEndpoints { } def apply[R, F[_]](serverEndpoints: List[ServerEndpoint[R, F]]): FilterServerEndpoints[R, F] = { - throwOnErrors(EndpointBodyVerifier.verify(serverEndpoints.map(_.endpoint))) + EndpointBodyVerifier.throwOnErrors(EndpointBodyVerifier.verify(serverEndpoints.map(_.endpoint))) val segmentsToEndpoints: List[(List[PathSegment], ServerEndpoint[R, F])] = serverEndpoints.map(se => segmentsForEndpoint(se.endpoint) -> se) new FilterServerEndpoints[R, F](createLayer(segmentsToEndpoints)) } - - private[tapir] def throwOnErrors(problems: EndpointBodyProblems): Unit = - if (problems.errors.nonEmpty) throw new IllegalArgumentException(problems.errors.mkString(" ")) } private trait PathLayer[R, F[_]] { diff --git a/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraServerInterpreter.scala b/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraServerInterpreter.scala index 8beff1b402..027670878b 100644 --- a/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraServerInterpreter.scala +++ b/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraServerInterpreter.scala @@ -10,7 +10,7 @@ import sttp.tapir.internal._ import sttp.tapir.server.{EndpointBodyVerifier, ServerEndpoint} import sttp.tapir.server.finatra.FinatraServerInterpreter.FutureMonadError import sttp.tapir.server.interceptor.RequestResult -import sttp.tapir.server.interpreter.{FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.ServerInterpreter import sttp.tapir._ trait FinatraServerInterpreter extends Logging { @@ -18,7 +18,7 @@ trait FinatraServerInterpreter extends Logging { def finatraServerOptions: FinatraServerOptions = FinatraServerOptions.default def toRoute(se: ServerEndpoint[Any, Future]): FinatraRoute = { - FilterServerEndpoints.throwOnErrors(EndpointBodyVerifier.verifyOne(se.endpoint)) + EndpointBodyVerifier.throwOnErrors(EndpointBodyVerifier.verifyOne(se.endpoint)) val serverInterpreter = new ServerInterpreter[Any, Future, FinatraContent, NoStreams]( _ => List(se), diff --git a/server/vertx-server/cats/src/main/scala/sttp/tapir/server/vertx/cats/VertxCatsServerInterpreter.scala b/server/vertx-server/cats/src/main/scala/sttp/tapir/server/vertx/cats/VertxCatsServerInterpreter.scala index c088660fa4..4a8b83303d 100644 --- a/server/vertx-server/cats/src/main/scala/sttp/tapir/server/vertx/cats/VertxCatsServerInterpreter.scala +++ b/server/vertx-server/cats/src/main/scala/sttp/tapir/server/vertx/cats/VertxCatsServerInterpreter.scala @@ -10,7 +10,7 @@ import sttp.capabilities.fs2.Fs2Streams import sttp.monad.MonadError import sttp.tapir.server.{EndpointBodyVerifier, ServerEndpoint} import sttp.tapir.server.interceptor.RequestResult -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, ServerInterpreter} import sttp.tapir.server.vertx.{VertxBodyListener, VertxErrorHandler} import sttp.tapir.server.vertx.cats.VertxCatsServerInterpreter.{CatsFFromVFuture, CatsRunAsync, VertxFutureToCatsF, monadError} import sttp.tapir.server.vertx.decoders.{VertxRequestBody, VertxServerRequest} @@ -35,7 +35,7 @@ trait VertxCatsServerInterpreter[F[_]] extends CommonServerInterpreter with Vert def route( e: ServerEndpoint[Fs2Streams[F] with WebSockets, F] ): Router => Route = { router => - FilterServerEndpoints.throwOnErrors(EndpointBodyVerifier.verifyOne(e.endpoint)) + EndpointBodyVerifier.throwOnErrors(EndpointBodyVerifier.verifyOne(e.endpoint)) val routeDef = extractRouteDefinition(e.endpoint) val readStreamCompatible = fs2ReadStreamCompatible(vertxCatsServerOptions) diff --git a/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/VertxFutureServerInterpreter.scala b/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/VertxFutureServerInterpreter.scala index 6beac08857..cb1c9ed004 100644 --- a/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/VertxFutureServerInterpreter.scala +++ b/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/VertxFutureServerInterpreter.scala @@ -6,7 +6,7 @@ import sttp.capabilities.WebSockets import sttp.monad.FutureMonad import sttp.tapir.server.{EndpointBodyVerifier, ServerEndpoint} import sttp.tapir.server.interceptor.RequestResult -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, ServerInterpreter} import sttp.tapir.server.vertx.VertxFutureServerInterpreter.{FutureFromVFuture, FutureRunAsync, VertxFutureToScalaFuture} import sttp.tapir.server.vertx.decoders.{VertxRequestBody, VertxServerRequest} import sttp.tapir.server.vertx.encoders.{VertxOutputEncoders, VertxToResponseBody} @@ -26,7 +26,7 @@ trait VertxFutureServerInterpreter extends CommonServerInterpreter with VertxErr * A function, that given a router, will attach this endpoint to it */ def route[A, U, I, E, O](e: ServerEndpoint[VertxStreams with WebSockets, Future]): Router => Route = { router => - FilterServerEndpoints.throwOnErrors(EndpointBodyVerifier.verifyOne(e.endpoint)) + EndpointBodyVerifier.throwOnErrors(EndpointBodyVerifier.verifyOne(e.endpoint)) val routeDef = extractRouteDefinition(e.endpoint) optionsRouteIfCORSDefined(e)(router, routeDef, vertxFutureServerOptions) @@ -42,7 +42,7 @@ trait VertxFutureServerInterpreter extends CommonServerInterpreter with VertxErr * A function, that given a router, will attach this endpoint to it */ def blockingRoute(e: ServerEndpoint[VertxStreams with WebSockets, Future]): Router => Route = { router => - FilterServerEndpoints.throwOnErrors(EndpointBodyVerifier.verifyOne(e.endpoint)) + EndpointBodyVerifier.throwOnErrors(EndpointBodyVerifier.verifyOne(e.endpoint)) val routeDef = extractRouteDefinition(e.endpoint) optionsRouteIfCORSDefined(e)(router, routeDef, vertxFutureServerOptions) diff --git a/server/vertx-server/zio/src/main/scala/sttp/tapir/server/vertx/zio/VertxZioServerInterpreter.scala b/server/vertx-server/zio/src/main/scala/sttp/tapir/server/vertx/zio/VertxZioServerInterpreter.scala index e84c95cebb..9d964ebcb2 100644 --- a/server/vertx-server/zio/src/main/scala/sttp/tapir/server/vertx/zio/VertxZioServerInterpreter.scala +++ b/server/vertx-server/zio/src/main/scala/sttp/tapir/server/vertx/zio/VertxZioServerInterpreter.scala @@ -6,7 +6,7 @@ import sttp.capabilities.WebSockets import sttp.capabilities.zio.ZioStreams import sttp.tapir.server.EndpointBodyVerifier import sttp.tapir.server.interceptor.RequestResult -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, ServerInterpreter} import sttp.tapir.server.vertx.VertxBodyListener import sttp.tapir.server.vertx.VertxErrorHandler import sttp.tapir.server.vertx.decoders.{VertxRequestBody, VertxServerRequest} @@ -26,7 +26,7 @@ trait VertxZioServerInterpreter[R] extends CommonServerInterpreter with VertxErr def route[R2](e: ZServerEndpoint[R2, ZioStreams with WebSockets])(implicit runtime: Runtime[R & R2] ): Router => Route = { router => - FilterServerEndpoints.throwOnErrors(EndpointBodyVerifier.verifyOne(e.endpoint)) + EndpointBodyVerifier.throwOnErrors(EndpointBodyVerifier.verifyOne(e.endpoint)) val routeDef = extractRouteDefinition(e.endpoint) optionsRouteIfCORSDefined(e.widen)(router, routeDef, vertxZioServerOptions) diff --git a/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpInterpreter.scala b/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpInterpreter.scala index 09ae0db3b8..c362ac754c 100644 --- a/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpInterpreter.scala +++ b/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpInterpreter.scala @@ -9,7 +9,7 @@ import sttp.tapir.internal.RichEndpointInput import sttp.tapir.server.EndpointBodyVerifier import sttp.tapir.server.interceptor.RequestResult import sttp.tapir.server.interceptor.reject.RejectInterceptor -import sttp.tapir.server.interpreter.{FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.ServerInterpreter import sttp.tapir.server.model.ServerResponse import sttp.tapir.ztapir._ import zio._ @@ -24,7 +24,7 @@ trait ZioHttpInterpreter[R] { toHttp(List(se)) def toHttp[R2](ses: List[ZServerEndpoint[R2, ZioStreams with WebSockets]]): Routes[R & R2, Response] = { - FilterServerEndpoints.throwOnErrors(EndpointBodyVerifier.verify(ses.map(_.endpoint))) + EndpointBodyVerifier.throwOnErrors(EndpointBodyVerifier.verify(ses.map(_.endpoint))) implicit val bodyListener: ZioHttpBodyListener[R & R2] = new ZioHttpBodyListener[R & R2] implicit val monadError: MonadError[RIO[R & R2, *]] = new RIOMonadError[R & R2] From 087e1a8842d5872eb2f05665720f98bd46e985f4 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 16:04:21 +0200 Subject: [PATCH 22/32] refactor: rename decodeBody's requestBody parameter to bodyReader 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. --- .../server/interpreter/ServerInterpreter.scala | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala index 8a0a79e943..656500b88e 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala @@ -184,7 +184,7 @@ class ServerInterpreter[R, F[_], B, S]( result: DecodeBasicInputsResult, endpointInfo: EndpointInfo, addRawValue: RawValue[?] => Unit, - requestBody: RequestBody[F, S] + bodyReader: RequestBody[F, S] ): F[DecodeBasicInputsResult] = { val maxBodyLength = endpointInfo.attribute(AttributeKey[MaxContentLength]).map(_.value) result match { @@ -192,7 +192,7 @@ class ServerInterpreter[R, F[_], B, S]( val primaryDecoded: F[DecodeBasicInputsResult] = values.bodyInputWithIndex match { case Some((Left(oneOfBodyInput), _)) => oneOfBodyInput.chooseBodyToDecode(request.contentTypeParsed) match { - case Some(Left(body)) => decodeBody(request, values, body, maxBodyLength, addRawValue, requestBody) + case Some(Left(body)) => decodeBody(request, values, body, maxBodyLength, addRawValue, bodyReader) case Some(Right(body: EndpointIO.StreamBodyWrapper[Any, Any])) => decodeStreamingBody(request, values, body, maxBodyLength) case None => unsupportedInputMediaTypeResponse(request, oneOfBodyInput) } @@ -202,7 +202,7 @@ class ServerInterpreter[R, F[_], B, S]( } primaryDecoded.flatMap { - case v: DecodeBasicInputsResult.Values => decodeExtractedBodies(request, v, maxBodyLength, addRawValue, requestBody) + case v: DecodeBasicInputsResult.Values => decodeExtractedBodies(request, v, maxBodyLength, addRawValue, bodyReader) case failure => failure.unit } case failure: DecodeBasicInputsResult.Failure => (failure: DecodeBasicInputsResult).unit @@ -214,12 +214,12 @@ class ServerInterpreter[R, F[_], B, S]( values: DecodeBasicInputsResult.Values, maxBodyLength: Option[Long], addRawValue: RawValue[?] => Unit, - requestBody: RequestBody[F, S] + bodyReader: RequestBody[F, S] ): F[DecodeBasicInputsResult] = values.extractedBodyInputsWithIndex.foldLeft((values: DecodeBasicInputsResult).unit) { case (acc, (bodyInput, index)) => acc.flatMap { case v: DecodeBasicInputsResult.Values => - decodeExtractedBody(request, v, bodyInput.asInstanceOf[EndpointIO.Body[Any, Any]], index, maxBodyLength, addRawValue, requestBody) + decodeExtractedBody(request, v, bodyInput.asInstanceOf[EndpointIO.Body[Any, Any]], index, maxBodyLength, addRawValue, bodyReader) case failure => failure.unit } } @@ -231,9 +231,9 @@ class ServerInterpreter[R, F[_], B, S]( index: Int, maxBodyLength: Option[Long], addRawValue: RawValue[?] => Unit, - requestBody: RequestBody[F, S] + bodyReader: RequestBody[F, S] ): F[DecodeBasicInputsResult] = - requestBody + bodyReader .toRaw(request, bodyInput.bodyType, maxBodyLength) .flatMap { v => addRawValue(v) @@ -265,9 +265,9 @@ class ServerInterpreter[R, F[_], B, S]( bodyInput: EndpointIO.Body[RAW, T], maxBodyLength: Option[Long], addRawValue: RawValue[?] => Unit, - requestBody: RequestBody[F, S] + bodyReader: RequestBody[F, S] ): F[DecodeBasicInputsResult] = { - requestBody + bodyReader .toRaw(request, bodyInput.bodyType, maxBodyLength) .flatMap { v => addRawValue(v) From 2bf63a9b214d3ee92c95c44aa12d8c8bfeee15e9 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 16:07:33 +0200 Subject: [PATCH 23/32] test: add missing maxBytes test for CachingRequestBody 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. --- .../interpreter/CachingRequestBodyTest.scala | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/CachingRequestBodyTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/CachingRequestBodyTest.scala index e5c08b796f..86f6568520 100644 --- a/server/core/src/test/scala/sttp/tapir/server/interpreter/CachingRequestBodyTest.scala +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/CachingRequestBodyTest.scala @@ -18,9 +18,11 @@ class CachingRequestBodyTest extends AnyFlatSpec with Matchers { private class CountingRequestBody(content: String) extends RequestBody[Identity, NoStreams] { var reads = 0 + var lastMaxBytes: Option[Long] = None override val streams: Streams[NoStreams] = NoStreams override def toRaw[R](serverRequest: ServerRequest, bodyType: RawBodyType[R], maxBytes: Option[Long]): RawValue[R] = { reads += 1 + lastMaxBytes = maxBytes bodyType match { case RawBodyType.ByteArrayBody => RawValue(content.getBytes(StandardCharsets.UTF_8)).asInstanceOf[RawValue[R]] case other => throw new IllegalStateException(s"unexpected body type: $other") @@ -83,6 +85,18 @@ class CachingRequestBodyTest extends AnyFlatSpec with Matchers { delegate.reads shouldBe 1 } + it should "pass maxBytes through to the delegate on the first read" in { + val delegate = new CountingRequestBody("hello") + val caching = new CachingRequestBody[Identity, NoStreams](delegate) + + caching.toRaw(request, RawBodyType.StringBody(StandardCharsets.UTF_8), Some(1024L)).value shouldBe "hello" + delegate.lastMaxBytes shouldBe Some(1024L) + + caching.toRaw(request, RawBodyType.StringBody(StandardCharsets.UTF_8), Some(2048L)).value shouldBe "hello" + delegate.reads shouldBe 1 + delegate.lastMaxBytes shouldBe Some(1024L) + } + it should "not let mutating a returned byte buffer corrupt the cache" in { val delegate = new CountingRequestBody("hello") val caching = new CachingRequestBody[Identity, NoStreams](delegate) From 2f91e54b489d415aeb8651f44bf7ed0f86f9552a Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 24 Aug 2026 17:20:33 +0200 Subject: [PATCH 24/32] chore: drop redundant casts and tighten comments in CachingRequestBody 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 --- .../sttp4/ExtractedBodyClientTest.scala | 4 +-- .../docs/openapi/ExtractedBodyDocsTest.scala | 3 +- .../interpreter/CachingRequestBody.scala | 28 ++++++++----------- .../interpreter/ServerInterpreter.scala | 3 +- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/ExtractedBodyClientTest.scala b/client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/ExtractedBodyClientTest.scala index c322f6d9c2..e119955958 100644 --- a/client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/ExtractedBodyClientTest.scala +++ b/client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/ExtractedBodyClientTest.scala @@ -8,9 +8,7 @@ import sttp.tapir._ class ExtractedBodyClientTest extends AnyFlatSpec with Matchers { it should "send only the primary body, ignoring the extracted one" in { - // The extracted body is wrapped on the `in` side (processed *after* `securityIn` by - // EndpointToSttpClientBase#prepareRequestWithInput), so a client that fails to skip it would - // overwrite the primary body's value on the request, which is exactly what this test guards against. + // the extracted body is on `in`, processed after `securityIn` - so an unskipped one would overwrite the primary val e = endpoint.post .in("people") .securityIn(stringBody) diff --git a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/ExtractedBodyDocsTest.scala b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/ExtractedBodyDocsTest.scala index 88aa24491f..169fe1c122 100644 --- a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/ExtractedBodyDocsTest.scala +++ b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/ExtractedBodyDocsTest.scala @@ -12,8 +12,7 @@ class ExtractedBodyDocsTest extends AnyFlatSpec with Matchers { .securityIn(extractBodyFromRequest(stringBody)) .in(byteArrayBody) - // suppress the default 400 response, whose body is always documented as text/plain regardless of the - // endpoint's inputs, so the assertion below isolates the request body under test + // the default 400 is always documented as text/plain; suppressing it isolates the request body under test val options = OpenAPIDocsOptions.default.copy(defaultDecodeFailureOutput = _ => None) val yaml = OpenAPIDocsInterpreter(options).toOpenAPI(e, "Test", "1.0").toYaml diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala index c798a109d4..24360daa94 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala @@ -6,7 +6,7 @@ import sttp.monad.syntax._ import sttp.tapir.model.ServerRequest import sttp.tapir.{InputStreamRange, RawBodyType} -import java.io.ByteArrayInputStream +import java.io.{ByteArrayInputStream, InputStream} import java.nio.ByteBuffer /** Reads a bytes-like request body from `delegate` at most once, buffering the bytes so that subsequent reads - e.g. an extracted body @@ -18,37 +18,33 @@ private[tapir] class CachingRequestBody[F[_], S](delegate: RequestBody[F, S])(im override val streams: Streams[S] = delegate.streams - // A plain var needs no synchronisation here: the interpreter's flatMap chain reads the security-phase body strictly - // before the main-phase one, and this instance never outlives a single request. + // 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. @volatile private var cachedBytes: Option[Array[Byte]] = None override def toRaw[R](serverRequest: ServerRequest, bodyType: RawBodyType[R], maxBytes: Option[Long]): F[RawValue[R]] = bodyType match { case RawBodyType.StringBody(charset) => - bytes(serverRequest, maxBytes).map(bs => RawValue(new String(bs, charset)).asInstanceOf[RawValue[R]]) + bytes(serverRequest, maxBytes).map(bs => RawValue(new String(bs, charset))) case RawBodyType.ByteArrayBody => - // clone: byteArrayBody is an identity codec, so the caller receives this array as-is and could mutate it - // in place, corrupting the cache for the next read - bytes(serverRequest, maxBytes).map(bs => RawValue(bs.clone()).asInstanceOf[RawValue[R]]) + // identity codec: without the clone, a caller mutating the array would corrupt the cache + bytes(serverRequest, maxBytes).map(bs => RawValue(bs.clone())) case RawBodyType.ByteBufferBody => - // clone for the same reason as ByteArrayBody above; wrap (not asReadOnlyBuffer) so .array() keeps working - bytes(serverRequest, maxBytes).map(bs => RawValue(ByteBuffer.wrap(bs.clone())).asInstanceOf[RawValue[R]]) + // clone as above; wrap rather than asReadOnlyBuffer so .array() keeps working + bytes(serverRequest, maxBytes).map(bs => RawValue(ByteBuffer.wrap(bs.clone()))) case RawBodyType.InputStreamBody => - bytes(serverRequest, maxBytes).map(bs => RawValue(new ByteArrayInputStream(bs)).asInstanceOf[RawValue[R]]) + bytes(serverRequest, maxBytes).map(bs => RawValue(new ByteArrayInputStream(bs): InputStream)) case RawBodyType.InputStreamRangeBody => bytes(serverRequest, maxBytes) - .map(bs => RawValue(InputStreamRange(() => new ByteArrayInputStream(bs))).asInstanceOf[RawValue[R]]) - // File and multipart bodies are never served from the cache. An endpoint combining one of them with an - // extracted body is rejected by EndpointBodyVerifier at route construction, so this branch only ever sees an - // endpoint whose sole body is the primary one. + .map(bs => RawValue(InputStreamRange(() => new ByteArrayInputStream(bs)))) + // file and multipart are never cached; EndpointBodyVerifier rejects them alongside an extracted body case other => delegate.toRaw(serverRequest, other, maxBytes) } override def toStream(serverRequest: ServerRequest, maxBytes: Option[Long]): streams.BinaryStream = delegate.toStream(serverRequest, maxBytes).asInstanceOf[streams.BinaryStream] - // Only the first call's maxBytes actually takes effect; the security and main phases both derive it from - // se.info, which is se.endpoint.info (see ServerEndpoint.info) - the same object - so they always agree. + // only the first call's maxBytes applies; both phases derive it from the same EndpointInfo, so they agree private def bytes(serverRequest: ServerRequest, maxBytes: Option[Long]): F[Array[Byte]] = cachedBytes match { case Some(bs) => bs.unit diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala index 656500b88e..e4088b131b 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala @@ -252,8 +252,7 @@ class ServerInterpreter[R, F[_], B, S]( bodyInput: EndpointIO.StreamBodyWrapper[Any, Any], maxBodyLength: Option[Long] ): F[DecodeBasicInputsResult] = - // never served from the cache: a body is either buffered for repeated reads or streamed lazily, not both; - // `EndpointBodyVerifier` rejects a streaming body combined with an extracted body at route construction + // deliberately the undecorated requestBody: a body is either buffered or streamed, never both (bodyInput.codec.decode(requestBody.toStream(request, maxBodyLength)) match { case DecodeResult.Value(bodyV) => values.setBodyInputValue(bodyV) case failure: DecodeResult.Failure => DecodeBasicInputsResult.Failure(bodyInput, failure): DecodeBasicInputsResult From 7c31bee672c7aefba558a757859dab311afe2973 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Tue, 25 Aug 2026 09:46:27 +0200 Subject: [PATCH 25/32] chore: trim scaladoc in EndpointBodyVerifier 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 --- .../sttp/tapir/server/EndpointBodyVerifier.scala | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala b/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala index 499843fa6f..f091390756 100644 --- a/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala +++ b/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala @@ -4,9 +4,7 @@ import sttp.model.Method import sttp.tapir.internal._ import sttp.tapir.{AnyEndpoint, EndpointIO, EndpointInput, RawBodyType} -/** Structural problems found in an endpoint description. Errors make the endpoint unserveable and are thrown when routes are constructed; - * warnings describe endpoints which work on the server, but whose published contract is probably not what the author intended. - */ +/** Errors make an endpoint unserveable; warnings describe one that works, but whose published contract probably isn't what was intended. */ case class EndpointBodyProblems(errors: List[String], warnings: List[String]) { def ++(other: EndpointBodyProblems): EndpointBodyProblems = EndpointBodyProblems(errors ++ other.errors, warnings ++ other.warnings) @@ -16,16 +14,13 @@ object EndpointBodyProblems { val Empty: EndpointBodyProblems = EndpointBodyProblems(Nil, Nil) } -/** Verifies that endpoint descriptions are structurally serveable. Called by server interpreters when routes are constructed; can also be - * called directly, e.g. to assert in tests that no warnings are present. +/** Verifies that endpoint descriptions are structurally serveable. Run by server interpreters when routes are constructed; warnings are not + * logged anywhere, so call this directly to assert on them. */ object EndpointBodyVerifier { def verify(endpoints: List[AnyEndpoint]): EndpointBodyProblems = endpoints.map(verifyOne).foldLeft(EndpointBodyProblems.Empty)(_ ++ _) - /** Throws an [[IllegalArgumentException]] listing all errors, if any are present. Called by server interpreters when routes are - * constructed. - */ private[tapir] def throwOnErrors(problems: EndpointBodyProblems): Unit = if (problems.errors.nonEmpty) throw new IllegalArgumentException(problems.errors.mkString("\n")) From d62a81dbade0c7e97f292b22ca5046c28a663bf1 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Tue, 25 Aug 2026 09:58:37 +0200 Subject: [PATCH 26/32] docs: trim the body-restriction explanation in security.md 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 --- doc/endpoint/security.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/doc/endpoint/security.md b/doc/endpoint/security.md index b77e21fc99..0d29447076 100644 --- a/doc/endpoint/security.md +++ b/doc/endpoint/security.md @@ -62,17 +62,13 @@ documentation, and ignored by client interpreters. The unmarked body - `jsonBody documented, and the one clients actually send. Only bodies which can be re-read from buffered bytes can be extracted: string, byte array, byte buffer, input stream -and input stream range bodies. File and multipart bodies aren't accepted - `extractBodyFromRequest` requires a -`ReplayableRawBody` instance for the body's raw type, and none is provided for `RawBodyType.FileBody` or -`RawBodyType.MultipartBody` - while streaming and `oneOf` bodies aren't `EndpointIO.Body` values at all, so they -can't be passed to `extractBodyFromRequest` in the first place. Either way, the restriction is enforced at compile +and input stream range bodies. File and multipart bodies aren't accepted. The restriction is enforced at compile time. The restriction also applies from the other side: an endpoint whose *ordinary* body (the one declared in `in`) is a -file, multipart, or streaming body can't be combined with an extracted body in `securityIn` either. Reading the -extracted body drains the request, so the file, multipart, or streaming body would then be read from an -already-consumed request. Unlike the compile-time check above, this is a runtime check: it's rejected with an -`IllegalArgumentException` when routes are constructed. +file, multipart, or streaming body can't be combined with an extracted body in `securityIn` either. Unlike the +compile-time check above, this is a runtime check: it's rejected with an `IllegalArgumentException` when routes are +constructed. ```{warning} Declaring two *ordinary* request bodies - one in `securityIn`, one in `in`, neither wrapped in From ab1b0819a62533aa12073ed54014bd8e28911bd3 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 31 Aug 2026 16:08:05 +0200 Subject: [PATCH 27/32] docs: drop release-notes framing from the two-bodies warning 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 --- doc/endpoint/security.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/doc/endpoint/security.md b/doc/endpoint/security.md index 0d29447076..f452ab405c 100644 --- a/doc/endpoint/security.md +++ b/doc/endpoint/security.md @@ -73,10 +73,6 @@ constructed. ```{warning} Declaring two *ordinary* request bodies - one in `securityIn`, one in `in`, neither wrapped in `extractBodyFromRequest` - is rejected the same way, since only one request body may be part of the API contract. -This is a breaking change: on backends that eagerly buffer the whole request into memory (Vert.x, Armeria, Play), -such an endpoint used to work, with both declarations decoding the same bytes. If you have an endpoint shaped like -`.securityIn(stringBody).in(stringBody)`, migrate it by wrapping the `securityIn` declaration: -`.securityIn(extractBodyFromRequest(stringBody)).in(stringBody)`. ``` Note that a *single* body input needs no wrapper: an endpoint which reads the body only in `serverSecurityLogic`, From 90a7f136177378297cc9e0a2f20ebee53cee67c2 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 31 Aug 2026 17:22:57 +0200 Subject: [PATCH 28/32] Reuse existing verifier --- .../tapir/server/EndpointBodyVerifier.scala | 6 ++-- .../server/EndpointBodyVerifierTest.scala | 0 doc/endpoint/security.md | 3 ++ doc/server/logic.md | 28 ------------------- doc/testing.md | 25 +++++++++++++++++ .../testing/EndpointVerificationError.scala | 10 +++++++ .../sttp/tapir/testing/EndpointVerifier.scala | 10 ++++++- .../tapir/testing/EndpointVerifierTest.scala | 25 +++++++++++++++++ 8 files changed, 75 insertions(+), 32 deletions(-) rename {server/core => core}/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala (96%) rename {server/core => core}/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala (100%) diff --git a/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala b/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala similarity index 96% rename from server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala rename to core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala index f091390756..e13fe1e101 100644 --- a/server/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala +++ b/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala @@ -5,19 +5,19 @@ import sttp.tapir.internal._ import sttp.tapir.{AnyEndpoint, EndpointIO, EndpointInput, RawBodyType} /** Errors make an endpoint unserveable; warnings describe one that works, but whose published contract probably isn't what was intended. */ -case class EndpointBodyProblems(errors: List[String], warnings: List[String]) { +private[tapir] case class EndpointBodyProblems(errors: List[String], warnings: List[String]) { def ++(other: EndpointBodyProblems): EndpointBodyProblems = EndpointBodyProblems(errors ++ other.errors, warnings ++ other.warnings) } -object EndpointBodyProblems { +private[tapir] object EndpointBodyProblems { val Empty: EndpointBodyProblems = EndpointBodyProblems(Nil, Nil) } /** Verifies that endpoint descriptions are structurally serveable. Run by server interpreters when routes are constructed; warnings are not * logged anywhere, so call this directly to assert on them. */ -object EndpointBodyVerifier { +private[tapir] object EndpointBodyVerifier { def verify(endpoints: List[AnyEndpoint]): EndpointBodyProblems = endpoints.map(verifyOne).foldLeft(EndpointBodyProblems.Empty)(_ ++ _) diff --git a/server/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala b/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala similarity index 100% rename from server/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala rename to core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala diff --git a/doc/endpoint/security.md b/doc/endpoint/security.md index f452ab405c..2e1d635314 100644 --- a/doc/endpoint/security.md +++ b/doc/endpoint/security.md @@ -79,6 +79,9 @@ Note that a *single* body input needs no wrapper: an endpoint which reads the bo with no body declared in `in`, reads the request exactly once. It works without `extractBodyFromRequest`, and stays fully documented and visible to clients. +Both kinds of problem, along with endpoints whose contract is merely suspect, are also reported by +[`EndpointVerifier`](../testing.md#invalid-request-body-definitions). + ## Authentication challenges For each `auth` scheme, one can define `WWW-Authenticate` headers that should be returned by the server in case input is diff --git a/doc/server/logic.md b/doc/server/logic.md index e6405adeb3..233ed07ce1 100644 --- a/doc/server/logic.md +++ b/doc/server/logic.md @@ -190,34 +190,6 @@ an error response is returned. Additional outputs can be then added to the resulting partial endpoint. -## Verifying endpoint descriptions - -When routes are constructed, tapir checks that each endpoint is structurally serveable, and throws an -`IllegalArgumentException` if it isn't - for example, if a request body is declared both in `securityIn` and in `in`, -without one of them being wrapped in -[`extractBodyFromRequest`](../endpoint/security.md#using-the-request-body-in-security-logic). - -Other problems are reported only as warnings, since the endpoint still serves correctly, even though its published -contract is probably not what was intended - for example, an `extractBodyFromRequest` input with no body declared in -`in`, which will never be sent by clients or appear in the documentation. Unlike errors, warnings aren't checked -automatically, and tapir doesn't log them anywhere - `server/core`, where `EndpointBodyVerifier` lives, deliberately -contains no logging. To see them, call `EndpointBodyVerifier` yourself, e.g. asserting on the result in your own tests: - -```scala mdoc:compile-only -import sttp.tapir.* -import sttp.tapir.server.EndpointBodyVerifier - -val endpoints: List[AnyEndpoint] = List( - endpoint.post.in("ingest").securityIn(extractBodyFromRequest(stringBody)) -) - -val problems = EndpointBodyVerifier.verify(endpoints) -assert(problems.warnings.nonEmpty) -``` - -`EndpointBodyVerifier.verify` checks a list of endpoints, while `EndpointBodyVerifier.verifyOne` checks a single one; -both return an `EndpointBodyProblems(errors, warnings)` value. - ## Status codes By default, successful responses are returned with the `200 OK` status code, and errors with `400 Bad Request`. However, diff --git a/doc/testing.md b/doc/testing.md index a102d1d702..1b2e1ac78c 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -398,6 +398,31 @@ Results in: result3.toString ``` +### Invalid request body definitions + +Only one request body may be part of an endpoint's API contract, and a body which can't be re-read can't be combined +with one wrapped in [`extractBodyFromRequest`](endpoint/security.md#using-the-request-body-in-security-logic). Such +endpoints can't be served, and are reported as errors here; they are also thrown when routes are constructed. + +Endpoints whose contract is merely suspect - for example an `extractBodyFromRequest` input with no body declared in +`in`, which clients will never send and which won't appear in the documentation - are reported here as well. These +aren't fatal, and aren't reported anywhere else, so verifying endpoints in a test is the only way to see them. + +Example 1: + +```scala mdoc:silent +import sttp.tapir.testing.EndpointVerifier + +val ep7 = endpoint.post.in("ingest").securityIn(extractBodyFromRequest(stringBody)) +val result4 = EndpointVerifier(List(ep7)) +``` + +Results in: + +```scala mdoc +result4.toString +``` + ## OpenAPI schema compatibility The `OpenAPIVerifier` provides utilities for verifying that client and server endpoints are consistent with an OpenAPI specification. This ensures that endpoints defined in your code correspond to those documented in the OpenAPI schema, and vice versa. diff --git a/testing/src/main/scala/sttp/tapir/testing/EndpointVerificationError.scala b/testing/src/main/scala/sttp/tapir/testing/EndpointVerificationError.scala index 0e25a3c239..c560f3b978 100644 --- a/testing/src/main/scala/sttp/tapir/testing/EndpointVerificationError.scala +++ b/testing/src/main/scala/sttp/tapir/testing/EndpointVerificationError.scala @@ -74,3 +74,13 @@ case class UnexpectedBodyError(e: AnyEndpoint, statusCode: StatusCode) extends E case class DuplicatedNameError(name: String) extends EndpointVerificationError { override def toString: String = s"Duplicate endpoints names found: $name" } + +/** Endpoint `e` declares its request body in a way which can't be served, or which won't be described correctly in the generated + * documentation. For example, declaring an ordinary request body in both `securityIn` and `in`, or combining a body which can't be re-read + * (streaming, file, multipart) with one wrapped in `extractBodyFromRequest`. + * + * Errors of this kind are also thrown when routes are constructed. + */ +case class InvalidBodyDefinitionError(e: AnyEndpoint, message: String) extends EndpointVerificationError { + override def toString: String = message +} diff --git a/testing/src/main/scala/sttp/tapir/testing/EndpointVerifier.scala b/testing/src/main/scala/sttp/tapir/testing/EndpointVerifier.scala index 9dde6ddc30..d98621ef5c 100644 --- a/testing/src/main/scala/sttp/tapir/testing/EndpointVerifier.scala +++ b/testing/src/main/scala/sttp/tapir/testing/EndpointVerifier.scala @@ -3,6 +3,7 @@ package sttp.tapir.testing import sttp.model.Method import sttp.model.StatusCode.{NoContent, NotModified} import sttp.tapir.internal.{RichEndpointInput, RichEndpointOutput, UrlencodedData} +import sttp.tapir.server.EndpointBodyVerifier import sttp.tapir.{AnyEndpoint, EndpointIO, EndpointInput, EndpointOutput, testing} import scala.annotation.tailrec @@ -13,9 +14,16 @@ object EndpointVerifier { findIncorrectPaths(endpoints).toSet ++ findDuplicatedMethodDefinitions(endpoints).toSet ++ findIncorrectStatusWithBody(endpoints).toSet ++ - findDuplicateNames(endpoints).toSet + findDuplicateNames(endpoints).toSet ++ + findInvalidBodyDefinitions(endpoints).toSet } + private def findInvalidBodyDefinitions(endpoints: List[AnyEndpoint]): List[InvalidBodyDefinitionError] = + endpoints.flatMap { e => + val problems = EndpointBodyVerifier.verifyOne(e) + (problems.errors ++ problems.warnings).map(InvalidBodyDefinitionError(e, _)) + } + private def findIncorrectPaths(endpoints: List[AnyEndpoint]): List[IncorrectPathsError] = { endpoints .map(e => { diff --git a/testing/src/test/scala/sttp/tapir/testing/EndpointVerifierTest.scala b/testing/src/test/scala/sttp/tapir/testing/EndpointVerifierTest.scala index 9796ad8b49..ae2749164a 100644 --- a/testing/src/test/scala/sttp/tapir/testing/EndpointVerifierTest.scala +++ b/testing/src/test/scala/sttp/tapir/testing/EndpointVerifierTest.scala @@ -331,6 +331,31 @@ class EndpointVerifierTest extends AnyFlatSpecLike with Matchers { result shouldBe Set(DuplicatedNameError("Z")) } + + it should "detect a request body declared in both securityIn and in" in { + val e = endpoint.post.in("a").securityIn(stringBody).in(stringBody) + + val result = EndpointVerifier(List(e)) + + result should have size 1 + result.head shouldBe a[InvalidBodyDefinitionError] + result.head.toString should include("extractBodyFromRequest") + } + + it should "report an extracted body with no body in the API contract" in { + val e = endpoint.post.in("ingest").securityIn(extractBodyFromRequest(stringBody)) + + val result = EndpointVerifier(List(e)) + + result should have size 1 + result.head shouldBe a[InvalidBodyDefinitionError] + } + + it should "accept an extracted body alongside an ordinary one" in { + val e = endpoint.post.in("a").securityIn(extractBodyFromRequest(stringBody)).in(stringBody) + + EndpointVerifier(List(e)) shouldBe empty + } } sealed trait ErrorInfo From 5ef86349434f3171f3e3354eb26ac8a47502c04e Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 31 Aug 2026 18:12:23 +0200 Subject: [PATCH 29/32] Change naming to "secondary" --- .../http4s/EndpointToHttp4sClient.scala | 4 +- .../client/play/EndpointToPlayClient.scala | 4 +- .../client/play/EndpointToPlayClient.scala | 4 +- .../client/sttp/EndpointToSttpClient.scala | 2 +- .../sttp4/EndpointToSttpClientBase.scala | 2 +- ...st.scala => SecondaryBodyClientTest.scala} | 8 +-- .../main/scala/sttp/tapir/EndpointIO.scala | 16 +++++- ...xtractedBody.scala => SecondaryBody.scala} | 18 +++--- core/src/main/scala/sttp/tapir/Tapir.scala | 11 ---- .../scala/sttp/tapir/internal/package.scala | 8 +-- .../tapir/server/EndpointBodyVerifier.scala | 38 ++++++------- .../tapir/ExtractBodyFromRequestTest.scala | 52 ----------------- .../scala/sttp/tapir/SecondaryBodyTest.scala | 57 +++++++++++++++++++ .../server/EndpointBodyVerifierTest.scala | 32 +++++------ doc/endpoint/security.md | 20 +++---- doc/testing.md | 6 +- .../apispec/schema/SchemasForEndpoints.scala | 2 +- .../EndpointInputToDecodeFailureOutput.scala | 2 +- .../docs/openapi/EndpointToOpenAPIPaths.scala | 2 +- ...Test.scala => SecondaryBodyDocsTest.scala} | 8 +-- .../protobuf/EndpointToProtobufMessage.scala | 2 +- .../protobuf/EndpointToProtobufService.scala | 2 +- .../interpreter/CachingRequestBody.scala | 4 +- .../interpreter/DecodeBasicInputs.scala | 12 ++-- .../interpreter/ServerInterpreter.scala | 12 ++-- .../DecodeBasicInputsValuesTest.scala | 24 ++++---- .../FilterServerEndpointsTest.scala | 6 +- ... ServerInterpreterSecondaryBodyTest.scala} | 6 +- .../server/tests/ServerSecurityTests.scala | 16 +++--- .../testing/EndpointVerificationError.scala | 2 +- .../tapir/testing/EndpointVerifierTest.scala | 10 ++-- 31 files changed, 197 insertions(+), 195 deletions(-) rename client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/{ExtractedBodyClientTest.scala => SecondaryBodyClientTest.scala} (72%) rename core/src/main/scala/sttp/tapir/{ExtractedBody.scala => SecondaryBody.scala} (55%) delete mode 100644 core/src/test/scala/sttp/tapir/ExtractBodyFromRequestTest.scala create mode 100644 core/src/test/scala/sttp/tapir/SecondaryBodyTest.scala rename docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/{ExtractedBodyDocsTest.scala => SecondaryBodyDocsTest.scala} (74%) rename server/core/src/test/scala/sttp/tapir/server/interpreter/{ServerInterpreterExtractedBodyTest.scala => ServerInterpreterSecondaryBodyTest.scala} (94%) diff --git a/client/http4s-client/src/main/scala/sttp/tapir/client/http4s/EndpointToHttp4sClient.scala b/client/http4s-client/src/main/scala/sttp/tapir/client/http4s/EndpointToHttp4sClient.scala index 9e39ebec3b..e7391b3fa8 100644 --- a/client/http4s-client/src/main/scala/sttp/tapir/client/http4s/EndpointToHttp4sClient.scala +++ b/client/http4s-client/src/main/scala/sttp/tapir/client/http4s/EndpointToHttp4sClient.scala @@ -13,7 +13,7 @@ import sttp.capabilities.fs2.Fs2Streams import sttp.model.ResponseMetadata import sttp.tapir.Codec.PlainCodec import sttp.tapir.client.ClientOutputParams -import sttp.tapir.internal.{Params, ParamsAsAny, RichEndpointIOBody, RichEndpointOutput, SplitParams} +import sttp.tapir.internal.{Params, ParamsAsAny, RichEndpointOutput, SplitParams} import sttp.tapir.{ Codec, CodecFormat, @@ -101,7 +101,7 @@ private[http4s] class EndpointToHttp4sClient(clientOptions: Http4sClientOptions) } req.withUri(uri) case EndpointIO.Empty(_, _) => req - case b @ EndpointIO.Body(_, _, _) if b.isExtracted => + case b @ EndpointIO.Body(_, _, _) if b.isSecondary => req // decoded server-side only; not part of the request the client sends case EndpointIO.Body(bodyType, codec, _) => setBody(value, bodyType, codec, req) case ob: EndpointIO.OneOfBody[_, _] => diff --git a/client/play-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala b/client/play-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala index cac857793d..f68248f82c 100644 --- a/client/play-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala +++ b/client/play-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala @@ -8,7 +8,7 @@ import sttp.capabilities.pekko.PekkoStreams import sttp.model.{Header, Method, ResponseMetadata} import sttp.tapir.Codec.PlainCodec import sttp.tapir.client.ClientOutputParams -import sttp.tapir.internal.{Params, ParamsAsAny, RichEndpointIOBody, RichEndpointOutput, SplitParams} +import sttp.tapir.internal.{Params, ParamsAsAny, RichEndpointOutput, SplitParams} import sttp.tapir.{ Codec, CodecFormat, @@ -116,7 +116,7 @@ private[play] class EndpointToPlayClient(clientOptions: PlayClientOptions, ws: S val mqp = codec.encode(value) req.addQueryStringParameters(mqp.toSeq: _*) case EndpointIO.Empty(_, _) => req - case b @ EndpointIO.Body(_, _, _) if b.isExtracted => + case b @ EndpointIO.Body(_, _, _) if b.isSecondary => req // decoded server-side only; not part of the request the client sends case EndpointIO.Body(bodyType, codec, _) => val req2 = setBody(value, bodyType, codec, req) diff --git a/client/play29-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala b/client/play29-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala index ee2dc30d0f..28c66b56a3 100644 --- a/client/play29-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala +++ b/client/play29-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala @@ -8,7 +8,7 @@ import sttp.capabilities.akka.AkkaStreams import sttp.model.{Header, Method, ResponseMetadata} import sttp.tapir.Codec.PlainCodec import sttp.tapir.client.ClientOutputParams -import sttp.tapir.internal.{Params, ParamsAsAny, RichEndpointIOBody, RichEndpointOutput, SplitParams} +import sttp.tapir.internal.{Params, ParamsAsAny, RichEndpointOutput, SplitParams} import sttp.tapir.{ Codec, CodecFormat, @@ -116,7 +116,7 @@ private[play] class EndpointToPlayClient(clientOptions: PlayClientOptions, ws: S val mqp = codec.encode(value) req.addQueryStringParameters(mqp.toSeq: _*) case EndpointIO.Empty(_, _) => req - case b @ EndpointIO.Body(_, _, _) if b.isExtracted => + case b @ EndpointIO.Body(_, _, _) if b.isSecondary => req // decoded server-side only; not part of the request the client sends case EndpointIO.Body(bodyType, codec, _) => val req2 = setBody(value, bodyType, codec, req) diff --git a/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/EndpointToSttpClient.scala b/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/EndpointToSttpClient.scala index ff1f3df1fa..6b21fcdeac 100644 --- a/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/EndpointToSttpClient.scala +++ b/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/EndpointToSttpClient.scala @@ -92,7 +92,7 @@ private[sttp] class EndpointToSttpClient[R](clientOptions: SttpClientOptions, ws val uri2 = uri.addParams(mqp.toSeq: _*) (uri2, req) case EndpointIO.Empty(_, _) => (uri, req) - case b @ EndpointIO.Body(_, _, _) if b.isExtracted => + case b @ EndpointIO.Body(_, _, _) if b.isSecondary => // decoded server-side only; not part of the request the client sends (uri, req) case EndpointIO.Body(bodyType, codec, _) => diff --git a/client/sttp-client4/src/main/scala/sttp/tapir/client/sttp4/EndpointToSttpClientBase.scala b/client/sttp-client4/src/main/scala/sttp/tapir/client/sttp4/EndpointToSttpClientBase.scala index 047ab1ad1a..4d7877d7cb 100644 --- a/client/sttp-client4/src/main/scala/sttp/tapir/client/sttp4/EndpointToSttpClientBase.scala +++ b/client/sttp-client4/src/main/scala/sttp/tapir/client/sttp4/EndpointToSttpClientBase.scala @@ -109,7 +109,7 @@ private[sttp4] trait EndpointToSttpClientBase { val uri2 = uri.addParams(mqp.toSeq: _*) (uri2, req, streamBody) case EndpointIO.Empty(_, _) => (uri, req, streamBody) - case b @ EndpointIO.Body(_, _, _) if b.isExtracted => + case b @ EndpointIO.Body(_, _, _) if b.isSecondary => // decoded server-side only; not part of the request the client sends (uri, req, streamBody) case EndpointIO.Body(bodyType, codec, _) => diff --git a/client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/ExtractedBodyClientTest.scala b/client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/SecondaryBodyClientTest.scala similarity index 72% rename from client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/ExtractedBodyClientTest.scala rename to client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/SecondaryBodyClientTest.scala index e119955958..533246f15e 100644 --- a/client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/ExtractedBodyClientTest.scala +++ b/client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/SecondaryBodyClientTest.scala @@ -6,13 +6,13 @@ import sttp.client4.Request import sttp.model.Uri._ import sttp.tapir._ -class ExtractedBodyClientTest extends AnyFlatSpec with Matchers { - it should "send only the primary body, ignoring the extracted one" in { - // the extracted body is on `in`, processed after `securityIn` - so an unskipped one would overwrite the primary +class SecondaryBodyClientTest extends AnyFlatSpec with Matchers { + it should "send only the primary body, ignoring the secondary one" in { + // the secondary body is on `in`, processed after `securityIn` - so an unskipped one would overwrite the primary val e = endpoint.post .in("people") .securityIn(stringBody) - .in(extractBodyFromRequest(stringBody)) + .in(stringBody.asSecondary) .out(stringBody) val request: Request[_] = diff --git a/core/src/main/scala/sttp/tapir/EndpointIO.scala b/core/src/main/scala/sttp/tapir/EndpointIO.scala index 1ae96f6e7d..1fef154d64 100644 --- a/core/src/main/scala/sttp/tapir/EndpointIO.scala +++ b/core/src/main/scala/sttp/tapir/EndpointIO.scala @@ -486,14 +486,26 @@ object EndpointIO { override private[tapir] type L = R override private[tapir] type CF = CodecFormat override private[tapir] def copyWith[U](c: Codec[R, U, CodecFormat], i: Info[U]): Body[R, U] = copy(codec = c, info = i) + + /** Marks this as a secondary body definition: still decoded on the server, but not part of the API contract - excluded from the + * documentation and ignored by client interpreters. Lets the request body be decoded a second time, e.g. once in `serverSecurityLogic` + * and once in the main logic. Only bodies which can be re-read from buffered bytes may be secondary. + */ + def asSecondary(implicit ev: ReplayableRawBody[R]): Body[R, T] = { + val _ = ev + attribute(SecondaryBody.attributeKey, SecondaryBody()) + } + + def isSecondary: Boolean = info.attribute(SecondaryBody.attributeKey).isDefined + override def show: String = { val charset = bodyType.asInstanceOf[RawBodyType[?]] match { case RawBodyType.StringBody(charset) => s" (${charset.toString})" case _ => "" } val format = codec.format.mediaType - val extracted = if (info.attribute(ExtractedBody.attributeKey).isDefined) "extracted " else "" - s"{${extracted}body as $format$charset}" + val secondary = if (isSecondary) "secondary " else "" + s"{${secondary}body as $format$charset}" } } diff --git a/core/src/main/scala/sttp/tapir/ExtractedBody.scala b/core/src/main/scala/sttp/tapir/SecondaryBody.scala similarity index 55% rename from core/src/main/scala/sttp/tapir/ExtractedBody.scala rename to core/src/main/scala/sttp/tapir/SecondaryBody.scala index 4fe2e6012e..aad623c9d1 100644 --- a/core/src/main/scala/sttp/tapir/ExtractedBody.scala +++ b/core/src/main/scala/sttp/tapir/SecondaryBody.scala @@ -4,21 +4,21 @@ import java.io.InputStream import java.nio.ByteBuffer import scala.annotation.implicitNotFound -/** Attribute value marking a body input as extracted: decoded from the request on the server, but not part of the API contract. Extracted - * bodies are excluded from documentation and ignored by client interpreters, which allows the request body to be decoded more than once - - * e.g. in `serverSecurityLogic` and again in the main logic. +/** Attribute value marking a body input as a secondary definition: decoded from the request on the server, but not part of the API + * contract. Secondary bodies are excluded from documentation and ignored by client interpreters, which allows the request body to be + * decoded more than once - e.g. in `serverSecurityLogic` and again in the main logic. * - * Set using [[Tapir.extractBodyFromRequest]]. + * Set using [[EndpointIO.Body.asSecondary]]. */ -case class ExtractedBody() +case class SecondaryBody() -object ExtractedBody { - val attributeKey: AttributeKey[ExtractedBody] = new AttributeKey[ExtractedBody]("sttp.tapir.ExtractedBody") +object SecondaryBody { + val attributeKey: AttributeKey[SecondaryBody] = new AttributeKey[SecondaryBody]("sttp.tapir.SecondaryBody") } -/** Evidence that a raw body type can be re-read from buffered bytes, and is therefore usable as an extracted body. */ +/** Evidence that a raw body type can be re-read from buffered bytes, and is therefore usable as a secondary body. */ @implicitNotFound( - "Cannot use a body with raw type ${R} as an extracted body. Only bodies which can be re-read from buffered bytes " + + "Cannot use a body with raw type ${R} as a secondary body. Only bodies which can be re-read from buffered bytes " + "are supported: string, byte array, byte buffer, input stream. File, multipart and streaming bodies cannot be " + "read twice." ) diff --git a/core/src/main/scala/sttp/tapir/Tapir.scala b/core/src/main/scala/sttp/tapir/Tapir.scala index bb20e9df78..661cde7a9f 100644 --- a/core/src/main/scala/sttp/tapir/Tapir.scala +++ b/core/src/main/scala/sttp/tapir/Tapir.scala @@ -225,17 +225,6 @@ trait Tapir extends TapirExtensions with TapirComputedInputs with TapirStaticCon def extractFromRequest[T](f: ServerRequest => T): EndpointInput.ExtractFromRequest[T] = EndpointInput.ExtractFromRequest(Codec.idPlain[ServerRequest]().map(f)(_ => null), EndpointIO.Info.empty) - /** Decode the request body a second time, server-side only. The resulting input is excluded from documentation and ignored by client - * interpreters, so an endpoint may declare one body as part of its contract (in `in`) and read the same request body again through this - * input (e.g. in `securityIn`). - * - * Only bodies which can be re-read from buffered bytes are supported; file, multipart and streaming bodies are rejected at compile time. - */ - 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()) - } - /** An output which maps to the status code in the response. */ def statusCode: EndpointOutput.StatusCode[sttp.model.StatusCode] = EndpointOutput.StatusCode(Map.empty, Codec.idPlain(), EndpointIO.Info.empty) diff --git a/core/src/main/scala/sttp/tapir/internal/package.scala b/core/src/main/scala/sttp/tapir/internal/package.scala index decfa706a0..7bcdf4593d 100644 --- a/core/src/main/scala/sttp/tapir/internal/package.scala +++ b/core/src/main/scala/sttp/tapir/internal/package.scala @@ -361,12 +361,8 @@ package object internal { case _ => false } - def isExtractedBodyInput(input: EndpointInput[?]): Boolean = input match { - case b: EndpointIO.Body[?, ?] => b.info.attribute(ExtractedBody.attributeKey).isDefined + def isSecondaryBodyInput(input: EndpointInput[?]): Boolean = input match { + case b: EndpointIO.Body[?, ?] => b.isSecondary case _ => false } - - implicit class RichEndpointIOBody[R, T](body: EndpointIO.Body[R, T]) { - def isExtracted: Boolean = body.info.attribute(ExtractedBody.attributeKey).isDefined - } } diff --git a/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala b/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala index e13fe1e101..2673e286e4 100644 --- a/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala +++ b/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala @@ -29,9 +29,9 @@ private[tapir] object EndpointBodyVerifier { val ordinaryInputs = endpoint.input.asVectorOfBasicInputs() val inputs = securityInputs ++ ordinaryInputs - val extracted = inputs.collect { case b: EndpointIO.Body[?, ?] if b.isExtracted => b } + val secondary = inputs.collect { case b: EndpointIO.Body[?, ?] if b.isSecondary => b } def primaryBodiesOf(basics: Vector[EndpointInput.Basic[?]]): Vector[EndpointInput.Basic[?]] = basics.collect { - case b: EndpointIO.Body[?, ?] if !b.isExtracted => b + case b: EndpointIO.Body[?, ?] if !b.isSecondary => b case b: EndpointIO.OneOfBody[?, ?] => b case b: EndpointIO.StreamBodyWrapper[?, ?] => b } @@ -59,8 +59,8 @@ private[tapir] object EndpointBodyVerifier { if (securityPrimaryBodies.nonEmpty && inPrimaryBodies.nonEmpty) List( s"Endpoint $shown declares a request body in both securityIn and in. Only one may be part of the API " + - s"contract. If both should decode the same request body, wrap the securityIn one: " + - s"extractBodyFromRequest(...)." + s"contract. If both should decode the same request body, mark the securityIn one: " + + s"stringBody.asSecondary." ) else if (securityPrimaryBodies.size > 1) List( @@ -74,41 +74,41 @@ private[tapir] object EndpointBodyVerifier { ) else Nil - val streamWithExtracted = - if (streamingPrimary && extracted.nonEmpty) + val streamWithSecondary = + if (streamingPrimary && secondary.nonEmpty) List( - s"Endpoint $shown combines a streaming body with an extracted body. The request body can either be " + + s"Endpoint $shown combines a streaming body with a secondary body. The request body can either be " + s"streamed lazily or buffered for repeated reads, not both." ) else Nil - val nonReplayableWithExtracted = - if (nonReplayablePrimary && extracted.nonEmpty) + val nonReplayableWithSecondary = + if (nonReplayablePrimary && secondary.nonEmpty) List( - s"Endpoint $shown combines a file or multipart body with an extracted body. Reading the extracted body " + + s"Endpoint $shown combines a file or multipart body with a secondary body. Reading the secondary body " + s"consumes the request; the file or multipart body would then be read from an already-drained request." ) else Nil val bodyCarryingMethod = endpoint.method.exists(m => m == Method.POST || m == Method.PUT || m == Method.PATCH) - val extractedWithoutPrimary = - if (extracted.nonEmpty && primaryBodies.isEmpty && bodyCarryingMethod) + val secondaryWithoutPrimary = + if (secondary.nonEmpty && primaryBodies.isEmpty && bodyCarryingMethod) List( - s"Endpoint $shown reads an extracted request body, but no request body is part of the API contract: it " + + s"Endpoint $shown reads a secondary request body, but no request body is part of the API contract: it " + s"will be absent from the documentation and clients will not send it. Either declare the body in `in` " + - s"as well, or drop extractBodyFromRequest and use the body input directly." + s"as well, or drop asSecondary and use the body input directly." ) else Nil val uselessMetadata = - extracted.filter(b => b.info.description.isDefined || b.info.examples.nonEmpty).map { b => - s"Endpoint $shown sets a description or example on the extracted body ${b.show}, which never reaches the " + - s"documentation, as extracted bodies are excluded from it." + secondary.filter(b => b.info.description.isDefined || b.info.examples.nonEmpty).map { b => + s"Endpoint $shown sets a description or example on the secondary body ${b.show}, which never reaches the " + + s"documentation, as secondary bodies are excluded from it." } EndpointBodyProblems( - errors = tooManyPrimaries ++ streamWithExtracted ++ nonReplayableWithExtracted, - warnings = (extractedWithoutPrimary ++ uselessMetadata).toList + errors = tooManyPrimaries ++ streamWithSecondary ++ nonReplayableWithSecondary, + warnings = (secondaryWithoutPrimary ++ uselessMetadata).toList ) } } diff --git a/core/src/test/scala/sttp/tapir/ExtractBodyFromRequestTest.scala b/core/src/test/scala/sttp/tapir/ExtractBodyFromRequestTest.scala deleted file mode 100644 index 9eba238e6d..0000000000 --- a/core/src/test/scala/sttp/tapir/ExtractBodyFromRequestTest.scala +++ /dev/null @@ -1,52 +0,0 @@ -package sttp.tapir - -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -class ExtractBodyFromRequestTest extends AnyFlatSpec with Matchers { - it should "mark a string body as extracted" in { - extractBodyFromRequest(stringBody).attribute(ExtractedBody.attributeKey) shouldBe Some(ExtractedBody()) - } - - it should "mark a json-style string body as extracted" in { - val body = stringBodyUtf8AnyFormat(Codec.string) - extractBodyFromRequest(body).attribute(ExtractedBody.attributeKey) shouldBe Some(ExtractedBody()) - } - - it should "leave a plain body unmarked" in { - stringBody.attribute(ExtractedBody.attributeKey) shouldBe None - } - - it should "preserve the codec and body type" in { - val extracted = extractBodyFromRequest(byteArrayBody) - extracted.bodyType shouldBe RawBodyType.ByteArrayBody - extracted.codec shouldBe byteArrayBody.codec - } - - it should "not compile for file bodies" in { - assertDoesNotCompile("extractBodyFromRequest(fileBody)") - } - - it should "not compile for multipart bodies" in { - assertDoesNotCompile("extractBodyFromRequest(multipartBody)") - } - - it should "not compile for oneOfBody" in { - assertDoesNotCompile("""extractBodyFromRequest(oneOfBody(stringBody, stringBody))""") - } - - it should "render an extracted body distinctly in show" in { - extractBodyFromRequest(stringBody).show shouldBe "{extracted body as text/plain (UTF-8)}" - } - - it should "render a plain body unchanged in show" in { - stringBody.show shouldBe "{body as text/plain (UTF-8)}" - } - - it should "report extracted bodies through the internal predicate" in { - import sttp.tapir.internal._ - isExtractedBodyInput(extractBodyFromRequest(stringBody)) shouldBe true - isExtractedBodyInput(stringBody) shouldBe false - isExtractedBodyInput(query[String]("q")) shouldBe false - } -} diff --git a/core/src/test/scala/sttp/tapir/SecondaryBodyTest.scala b/core/src/test/scala/sttp/tapir/SecondaryBodyTest.scala new file mode 100644 index 0000000000..6171a1eeca --- /dev/null +++ b/core/src/test/scala/sttp/tapir/SecondaryBodyTest.scala @@ -0,0 +1,57 @@ +package sttp.tapir + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class SecondaryBodyTest extends AnyFlatSpec with Matchers { + it should "mark a string body as secondary" in { + stringBody.asSecondary.attribute(SecondaryBody.attributeKey) shouldBe Some(SecondaryBody()) + } + + it should "mark a json-style string body as secondary" in { + val body = stringBodyUtf8AnyFormat(Codec.string) + body.asSecondary.attribute(SecondaryBody.attributeKey) shouldBe Some(SecondaryBody()) + } + + it should "leave a plain body unmarked" in { + stringBody.attribute(SecondaryBody.attributeKey) shouldBe None + stringBody.isSecondary shouldBe false + } + + it should "report a marked body through isSecondary" in { + stringBody.asSecondary.isSecondary shouldBe true + } + + it should "preserve the codec and body type" in { + val secondary = byteArrayBody.asSecondary + secondary.bodyType shouldBe RawBodyType.ByteArrayBody + secondary.codec shouldBe byteArrayBody.codec + } + + it should "not compile for file bodies" in { + assertDoesNotCompile("fileBody.asSecondary") + } + + it should "not compile for multipart bodies" in { + assertDoesNotCompile("multipartBody.asSecondary") + } + + it should "not compile for oneOfBody" in { + assertDoesNotCompile("""oneOfBody(stringBody, stringBody).asSecondary""") + } + + it should "render a secondary body distinctly in show" in { + stringBody.asSecondary.show shouldBe "{secondary body as text/plain (UTF-8)}" + } + + it should "render a plain body unchanged in show" in { + stringBody.show shouldBe "{body as text/plain (UTF-8)}" + } + + it should "report secondary bodies through the internal predicate" in { + import sttp.tapir.internal._ + isSecondaryBodyInput(stringBody.asSecondary) shouldBe true + isSecondaryBodyInput(stringBody) shouldBe false + isSecondaryBodyInput(query[String]("q")) shouldBe false + } +} diff --git a/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala b/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala index da27a7dcb1..31d2da1d24 100644 --- a/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala +++ b/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala @@ -7,7 +7,7 @@ import sttp.tapir.capabilities.NoStreams class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { it should "accept an endpoint with one extracted and one primary body" in { - val e = endpoint.post.in("people").securityIn(extractBodyFromRequest(stringBody)).in(stringBody) + val e = endpoint.post.in("people").securityIn(stringBody.asSecondary).in(stringBody) EndpointBodyVerifier.verifyOne(e) shouldBe EndpointBodyProblems(Nil, Nil) } @@ -21,13 +21,13 @@ class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { problems.errors should have size 1 problems.errors.head should include("declares a request body in both securityIn and in") - problems.errors.head should include("extractBodyFromRequest") + problems.errors.head should include("asSecondary") } - it should "reject a streaming primary body combined with an extracted body" in { + it should "reject a streaming primary body combined with an secondary body" in { val e = endpoint.post .in("people") - .securityIn(extractBodyFromRequest(stringBody)) + .securityIn(stringBody.asSecondary) .in[Nothing, Nothing, Unit, NoStreams](streamTextBody(NoStreams)(CodecFormat.TextPlain())) val problems = EndpointBodyVerifier.verifyOne(e) @@ -35,10 +35,10 @@ class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { problems.errors.head should include("streaming body") } - it should "reject a file body primary combined with an extracted body" in { + it should "reject a file body primary combined with an secondary body" in { val e = endpoint.post .in("people") - .securityIn(extractBodyFromRequest(stringBody)) + .securityIn(stringBody.asSecondary) .in(fileBody) val problems = EndpointBodyVerifier.verifyOne(e) @@ -46,10 +46,10 @@ class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { problems.errors.head should include("file") } - it should "reject a oneOfBody of streaming variants combined with an extracted body" in { + it should "reject a oneOfBody of streaming variants combined with an secondary body" in { val e = endpoint.post .in("people") - .securityIn(extractBodyFromRequest(stringBody)) + .securityIn(stringBody.asSecondary) .in[Nothing, Unit](oneOfBody[Nothing](streamTextBody(NoStreams)(CodecFormat.TextPlain()).toEndpointIO)) val problems = EndpointBodyVerifier.verifyOne(e) @@ -57,10 +57,10 @@ class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { problems.errors.head should include("streaming body") } - it should "reject a oneOfBody with a file body variant combined with an extracted body" in { + it should "reject a oneOfBody with a file body variant combined with an secondary body" in { val e = endpoint.post .in("people") - .securityIn(extractBodyFromRequest(stringBody)) + .securityIn(stringBody.asSecondary) .in(oneOfBody(fileBody)) val problems = EndpointBodyVerifier.verifyOne(e) @@ -68,8 +68,8 @@ class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { problems.errors.head should include("file") } - it should "warn about an extracted body with no primary body on POST" in { - val e = endpoint.post.in("ingest").securityIn(extractBodyFromRequest(stringBody)) + it should "warn about an secondary body with no primary body on POST" in { + val e = endpoint.post.in("ingest").securityIn(stringBody.asSecondary) val problems = EndpointBodyVerifier.verifyOne(e) problems.errors shouldBe empty @@ -77,15 +77,15 @@ class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { problems.warnings.head should include("no request body is part of the API contract") } - it should "not warn about an extracted body with no primary body on GET" in { - val e = endpoint.get.in("ping").securityIn(extractBodyFromRequest(stringBody)) + it should "not warn about an secondary body with no primary body on GET" in { + val e = endpoint.get.in("ping").securityIn(stringBody.asSecondary) EndpointBodyVerifier.verifyOne(e).warnings shouldBe empty } - it should "warn about metadata on an extracted body" in { + it should "warn about metadata on an secondary body" in { val e = endpoint.post .in("people") - .securityIn(extractBodyFromRequest(stringBody.description("the raw payload"))) + .securityIn(stringBody.description("the raw payload").asSecondary) .in(stringBody) val problems = EndpointBodyVerifier.verifyOne(e) diff --git a/doc/endpoint/security.md b/doc/endpoint/security.md index 2e1d635314..e023a38e31 100644 --- a/doc/endpoint/security.md +++ b/doc/endpoint/security.md @@ -39,7 +39,7 @@ supported, as well as optional variants: `authorizationCodeFlow[Optional]`, `cli Security logic sometimes needs the request body itself - for example, to verify a signature computed over the raw payload. The request body can normally be read only once, so an endpoint which needs it in both -`serverSecurityLogic` and the main logic must mark one of the two declarations with `extractBodyFromRequest`: +`serverSecurityLogic` and the main logic must mark one of the two declarations with `asSecondary`: ```scala mdoc:compile-only import sttp.tapir.* @@ -51,32 +51,32 @@ case class Person(name: String, age: Int) val secureEndpoint = endpoint.post .securityIn(auth.bearer[String]()) - .securityIn(extractBodyFromRequest(stringBody)) + .securityIn(stringBody.asSecondary) .in("people") .in(jsonBody[Person]) ``` -An extracted 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 extracted declaration is excluded from the generated +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. -Only bodies which can be re-read from buffered bytes can be extracted: string, byte array, byte buffer, input stream +Only bodies which can be re-read from buffered bytes can be secondary: string, byte array, byte buffer, input stream and input stream range bodies. File and multipart bodies aren't accepted. The restriction is enforced at compile time. The restriction also applies from the other side: an endpoint whose *ordinary* body (the one declared in `in`) is a -file, multipart, or streaming body can't be combined with an extracted body in `securityIn` either. Unlike the +file, multipart, or streaming body can't be combined with a secondary body in `securityIn` either. Unlike the compile-time check above, this is a runtime check: it's rejected with an `IllegalArgumentException` when routes are constructed. ```{warning} -Declaring two *ordinary* request bodies - one in `securityIn`, one in `in`, neither wrapped in -`extractBodyFromRequest` - is rejected the same way, since only one request body may be part of the API contract. +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. ``` -Note that a *single* body input needs no wrapper: an endpoint which reads the body only in `serverSecurityLogic`, -with no body declared in `in`, reads the request exactly once. It works without `extractBodyFromRequest`, and stays +Note that a *single* body input needs no marking: an endpoint which reads the body only in `serverSecurityLogic`, +with no body declared in `in`, reads the request exactly once. It works without `asSecondary`, and stays fully documented and visible to clients. Both kinds of problem, along with endpoints whose contract is merely suspect, are also reported by diff --git a/doc/testing.md b/doc/testing.md index 1b2e1ac78c..74d30290d1 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -401,10 +401,10 @@ result3.toString ### Invalid request body definitions Only one request body may be part of an endpoint's API contract, and a body which can't be re-read can't be combined -with one wrapped in [`extractBodyFromRequest`](endpoint/security.md#using-the-request-body-in-security-logic). Such +with one marked using [`asSecondary`](endpoint/security.md#using-the-request-body-in-security-logic). Such endpoints can't be served, and are reported as errors here; they are also thrown when routes are constructed. -Endpoints whose contract is merely suspect - for example an `extractBodyFromRequest` input with no body declared in +Endpoints whose contract is merely suspect - for example an `asSecondary` body with no body declared in `in`, which clients will never send and which won't appear in the documentation - are reported here as well. These aren't fatal, and aren't reported anywhere else, so verifying endpoints in a test is the only way to see them. @@ -413,7 +413,7 @@ Example 1: ```scala mdoc:silent import sttp.tapir.testing.EndpointVerifier -val ep7 = endpoint.post.in("ingest").securityIn(extractBodyFromRequest(stringBody)) +val ep7 = endpoint.post.in("ingest").securityIn(stringBody.asSecondary) val result4 = EndpointVerifier(List(ep7)) ``` diff --git a/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/SchemasForEndpoints.scala b/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/SchemasForEndpoints.scala index 3882a1f180..9bb8073d66 100644 --- a/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/SchemasForEndpoints.scala +++ b/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/SchemasForEndpoints.scala @@ -74,7 +74,7 @@ class SchemasForEndpoints( 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.isExtracted => List.empty + case b @ EndpointIO.Body(_, _, _) if b.isSecondary => List.empty case EndpointIO.Body(_, codec, _) => ToKeyedSchemas(codec) case EndpointIO.OneOfBody(variants, _) => variants.flatMap(v => forIO(v.bodyAsAtom)) case EndpointIO.StreamBodyWrapper(StreamBodyIO(_, codec, _, _, _)) => ToKeyedSchemas(codec.schema) diff --git a/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointInputToDecodeFailureOutput.scala b/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointInputToDecodeFailureOutput.scala index d8032b9cbf..7929bf4cf2 100644 --- a/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointInputToDecodeFailureOutput.scala +++ b/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointInputToDecodeFailureOutput.scala @@ -9,7 +9,7 @@ import scala.annotation.tailrec private[openapi] object EndpointInputToDecodeFailureOutput { def defaultBadRequestDescription(input: EndpointInput[_]): Option[String] = { val fallibleBasicInputs = - input.asVectorOfBasicInputs(includeAuth = false).filterNot(isExtractedBodyInput).filter(inputMayFailWithBadRequest) + input.asVectorOfBasicInputs(includeAuth = false).filterNot(isSecondaryBodyInput).filter(inputMayFailWithBadRequest) if (fallibleBasicInputs.nonEmpty) Some(badRequestDescription(fallibleBasicInputs)) else None diff --git a/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToOpenAPIPaths.scala b/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToOpenAPIPaths.scala index 64f12da1fd..29e508c2cc 100644 --- a/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToOpenAPIPaths.scala +++ b/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToOpenAPIPaths.scala @@ -52,7 +52,7 @@ private[openapi] class EndpointToOpenAPIPaths( variants.filterNot(_.codec.schema.hidden), mapping ) - case a: EndpointInput.Atom[_] if !a.codec.schema.hidden && !isExtractedBodyInput(a) => a + case a: EndpointInput.Atom[_] if !a.codec.schema.hidden && !isSecondaryBodyInput(a) => a } private def endpointToOperation(defaultId: String, e: AnyEndpoint, inputs: Vector[EndpointInput.Basic[_]]): Operation = { diff --git a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/ExtractedBodyDocsTest.scala b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/SecondaryBodyDocsTest.scala similarity index 74% rename from docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/ExtractedBodyDocsTest.scala rename to docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/SecondaryBodyDocsTest.scala index 169fe1c122..037fea59b9 100644 --- a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/ExtractedBodyDocsTest.scala +++ b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/SecondaryBodyDocsTest.scala @@ -5,11 +5,11 @@ import org.scalatest.matchers.should.Matchers import sttp.apispec.openapi.circe.yaml._ import sttp.tapir._ -class ExtractedBodyDocsTest extends AnyFlatSpec with Matchers { +class SecondaryBodyDocsTest extends AnyFlatSpec with Matchers { it should "document only the primary body" in { val e = endpoint.post .in("people") - .securityIn(extractBodyFromRequest(stringBody)) + .securityIn(stringBody.asSecondary) .in(byteArrayBody) // the default 400 is always documented as text/plain; suppressing it isolates the request body under test @@ -20,8 +20,8 @@ class ExtractedBodyDocsTest extends AnyFlatSpec with Matchers { yaml should not include ("text/plain") } - it should "document no body when the only body is extracted" in { - val e = endpoint.post.in("ingest").securityIn(extractBodyFromRequest(stringBody)).out(stringBody) + it should "document no body when the only body is secondary" in { + val e = endpoint.post.in("ingest").securityIn(stringBody.asSecondary).out(stringBody) val yaml = OpenAPIDocsInterpreter().toOpenAPI(e, "Test", "1.0").toYaml diff --git a/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufMessage.scala b/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufMessage.scala index 5d58678409..760ba5cc1b 100644 --- a/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufMessage.scala +++ b/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufMessage.scala @@ -56,7 +56,7 @@ class EndpointToProtobufMessage { case EndpointIO.Pair(left, right, _, _) => forIO(left) ++ forIO(right) case EndpointIO.Header(_, codec, _) => ??? case EndpointIO.Headers(_, _) => List.empty - case b @ EndpointIO.Body(_, _, _) if b.isExtracted => List.empty + case b @ EndpointIO.Body(_, _, _) if b.isSecondary => List.empty case EndpointIO.Body(_, codec, _) => fromCodec(codec) case EndpointIO.OneOfBody(variants, _) => variants.flatMap(v => forIO(v.bodyAsAtom)) case EndpointIO.StreamBodyWrapper(StreamBodyIO(_, codec, _, _, _)) => ??? diff --git a/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufService.scala b/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufService.scala index 83f648dbc5..8408ac9814 100644 --- a/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufService.scala +++ b/grpc/protobuf/src/main/scala/sttp/tapir/grpc/protobuf/EndpointToProtobufService.scala @@ -80,7 +80,7 @@ class EndpointToProtobufService { private def forIO(io: EndpointIO[_]): List[MessageReference] = { io match { - case b @ EndpointIO.Body(_, _, _) if b.isExtracted => List.empty + case b @ EndpointIO.Body(_, _, _) if b.isSecondary => List.empty case EndpointIO.Body(_, codec, _) => List(fromCodec(codec)) case EndpointIO.MappedPair(wrapped, _) => forIO(wrapped) case _ => List.empty diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala index 24360daa94..7cc5a5d5c1 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala @@ -9,7 +9,7 @@ import sttp.tapir.{InputStreamRange, RawBodyType} import java.io.{ByteArrayInputStream, InputStream} import java.nio.ByteBuffer -/** Reads a bytes-like request body from `delegate` at most once, buffering the bytes so that subsequent reads - e.g. an extracted body +/** Reads a bytes-like request body from `delegate` at most once, buffering the bytes so that subsequent reads - e.g. a secondary body * decoded during the security phase, followed by the endpoint's own body - are served from memory. * * Must be created per request: it holds that request's bytes. @@ -37,7 +37,7 @@ private[tapir] class CachingRequestBody[F[_], S](delegate: RequestBody[F, S])(im case RawBodyType.InputStreamRangeBody => bytes(serverRequest, maxBytes) .map(bs => RawValue(InputStreamRange(() => new ByteArrayInputStream(bs)))) - // file and multipart are never cached; EndpointBodyVerifier rejects them alongside an extracted body + // file and multipart are never cached; EndpointBodyVerifier rejects them alongside a secondary body case other => delegate.toRaw(serverRequest, other, maxBytes) } diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/DecodeBasicInputs.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/DecodeBasicInputs.scala index b8db108c38..00b41127a4 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/DecodeBasicInputs.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/DecodeBasicInputs.scala @@ -10,9 +10,9 @@ import scala.annotation.tailrec sealed trait DecodeBasicInputsResult { - /** Whether any body input in this result is an extracted body, i.e. one which requires the request body to be readable more than once. + /** Whether any body input in this result is a secondary body, i.e. one which requires the request body to be readable more than once. */ - def hasExtractedBody: Boolean + def hasSecondaryBody: Boolean } object DecodeBasicInputsResult { @@ -20,15 +20,15 @@ object DecodeBasicInputsResult { case class Values( basicInputsValues: Vector[Any], bodyInputWithIndex: Option[(Either[EndpointIO.OneOfBody[?, ?], EndpointIO.StreamBodyWrapper[?, ?]], Int)], - extractedBodyInputsWithIndex: Vector[(EndpointIO.Body[?, ?], Int)] = Vector.empty + secondaryBodyInputsWithIndex: Vector[(EndpointIO.Body[?, ?], Int)] = Vector.empty ) extends DecodeBasicInputsResult { - override def hasExtractedBody: Boolean = extractedBodyInputsWithIndex.nonEmpty + override def hasSecondaryBody: Boolean = secondaryBodyInputsWithIndex.nonEmpty private def verifyNoBody(input: EndpointInput[?]): Unit = if (bodyInputWithIndex.isDefined) { throw new IllegalStateException(s"Double body definition: $input") } def addBodyInput[O](input: EndpointIO.Body[?, O], bodyIndex: Int): Values = - if (input.isExtracted) copy(extractedBodyInputsWithIndex = extractedBodyInputsWithIndex :+ ((input, bodyIndex))) + if (input.isSecondary) copy(secondaryBodyInputsWithIndex = secondaryBodyInputsWithIndex :+ ((input, bodyIndex))) else { verifyNoBody(input) copy(bodyInputWithIndex = Some((Left(oneOfBody(ContentTypeRange.AnyRange -> input)), bodyIndex))) @@ -51,7 +51,7 @@ object DecodeBasicInputsResult { def setBasicInputValue(v: Any, i: Int): Values = copy(basicInputsValues = basicInputsValues.updated(i, v)) } case class Failure(input: EndpointInput.Basic[?], failure: DecodeResult.Failure) extends DecodeBasicInputsResult { - override def hasExtractedBody: Boolean = false + override def hasSecondaryBody: Boolean = false } def higherPriorityFailure(l: DecodeBasicInputsResult, r: DecodeBasicInputsResult): Option[Failure] = (l, r) match { diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala index e4088b131b..64965eedc3 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala @@ -113,7 +113,7 @@ class ServerInterpreter[R, F[_], B, S]( // if the endpoint reads the body more than once, buffer it so that the backend's request is consumed only once val endpointRequestBody: RequestBody[F, S] = - if (securityBasicInputs.hasExtractedBody || regularBasicInputs.hasExtractedBody) new CachingRequestBody(requestBody) + if (securityBasicInputs.hasSecondaryBody || regularBasicInputs.hasSecondaryBody) new CachingRequestBody(requestBody) else requestBody (for { @@ -202,29 +202,29 @@ class ServerInterpreter[R, F[_], B, S]( } primaryDecoded.flatMap { - case v: DecodeBasicInputsResult.Values => decodeExtractedBodies(request, v, maxBodyLength, addRawValue, bodyReader) + case v: DecodeBasicInputsResult.Values => decodeSecondaryBodies(request, v, maxBodyLength, addRawValue, bodyReader) case failure => failure.unit } case failure: DecodeBasicInputsResult.Failure => (failure: DecodeBasicInputsResult).unit } } - private def decodeExtractedBodies( + private def decodeSecondaryBodies( request: ServerRequest, values: DecodeBasicInputsResult.Values, maxBodyLength: Option[Long], addRawValue: RawValue[?] => Unit, bodyReader: RequestBody[F, S] ): F[DecodeBasicInputsResult] = - values.extractedBodyInputsWithIndex.foldLeft((values: DecodeBasicInputsResult).unit) { case (acc, (bodyInput, index)) => + values.secondaryBodyInputsWithIndex.foldLeft((values: DecodeBasicInputsResult).unit) { case (acc, (bodyInput, index)) => acc.flatMap { case v: DecodeBasicInputsResult.Values => - decodeExtractedBody(request, v, bodyInput.asInstanceOf[EndpointIO.Body[Any, Any]], index, maxBodyLength, addRawValue, bodyReader) + decodeSecondaryBody(request, v, bodyInput.asInstanceOf[EndpointIO.Body[Any, Any]], index, maxBodyLength, addRawValue, bodyReader) case failure => failure.unit } } - private def decodeExtractedBody[RAW, T]( + private def decodeSecondaryBody[RAW, T]( request: ServerRequest, values: DecodeBasicInputsResult.Values, bodyInput: EndpointIO.Body[RAW, T], diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/DecodeBasicInputsValuesTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/DecodeBasicInputsValuesTest.scala index a3ee0ced66..b22c9aaa16 100644 --- a/server/core/src/test/scala/sttp/tapir/server/interpreter/DecodeBasicInputsValuesTest.scala +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/DecodeBasicInputsValuesTest.scala @@ -8,30 +8,30 @@ class DecodeBasicInputsValuesTest extends AnyFlatSpec with Matchers { private def emptyValues(size: Int) = DecodeBasicInputsResult.Values(Vector.fill[Any](size)(null), None) - it should "record an extracted body separately from the primary body" in { - val result = emptyValues(1).addBodyInput(extractBodyFromRequest(stringBody), 0) + it should "record an secondary body separately from the primary body" in { + val result = emptyValues(1).addBodyInput(stringBody.asSecondary, 0) result.bodyInputWithIndex shouldBe None - result.extractedBodyInputsWithIndex.map(_._2) shouldBe Vector(0) - result.hasExtractedBody shouldBe true + result.secondaryBodyInputsWithIndex.map(_._2) shouldBe Vector(0) + result.hasSecondaryBody shouldBe true } it should "record a primary body in bodyInputWithIndex" in { val result = emptyValues(1).addBodyInput(stringBody, 0) result.bodyInputWithIndex shouldBe defined - result.extractedBodyInputsWithIndex shouldBe empty - result.hasExtractedBody shouldBe false + result.secondaryBodyInputsWithIndex shouldBe empty + result.hasSecondaryBody shouldBe false } - it should "allow a primary body alongside several extracted bodies" in { + it should "allow a primary body alongside several secondary bodies" in { val result = emptyValues(3) - .addBodyInput(extractBodyFromRequest(stringBody), 0) + .addBodyInput(stringBody.asSecondary, 0) .addBodyInput(stringBody, 1) - .addBodyInput(extractBodyFromRequest(byteArrayBody), 2) + .addBodyInput(byteArrayBody.asSecondary, 2) result.bodyInputWithIndex.map(_._2) shouldBe Some(1) - result.extractedBodyInputsWithIndex.map(_._2) shouldBe Vector(0, 2) + result.secondaryBodyInputsWithIndex.map(_._2) shouldBe Vector(0, 2) } it should "still reject two primary bodies in one pass" in { @@ -40,9 +40,9 @@ class DecodeBasicInputsValuesTest extends AnyFlatSpec with Matchers { } } - it should "report no extracted body for a decode failure" in { + it should "report no secondary body for a decode failure" in { val failure: DecodeBasicInputsResult = DecodeBasicInputsResult.Failure(stringBody, DecodeResult.Missing) - failure.hasExtractedBody shouldBe false + failure.hasSecondaryBody shouldBe false } } diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala index d3025f53dc..5c6f3ba839 100644 --- a/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala @@ -146,13 +146,13 @@ class FilterServerEndpointsTest extends AnyFlatSpec with Matchers { .serverLogic(_ => _ => Right(())) val e = the[IllegalArgumentException] thrownBy FilterServerEndpoints(List(se)) - e.getMessage should include("extractBodyFromRequest") + e.getMessage should include("asSecondary") } - it should "accept an endpoint with an extracted body" in { + it should "accept an endpoint with an secondary body" in { val se = endpoint.post .in("people") - .securityIn(extractBodyFromRequest(stringBody)) + .securityIn(stringBody.asSecondary) .in(stringBody) .serverSecurityLogic[Unit, Identity](_ => Right(())) .serverLogic(_ => _ => Right(())) diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterSecondaryBodyTest.scala similarity index 94% rename from server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala rename to server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterSecondaryBodyTest.scala index a0c7049672..cd8b1ae519 100644 --- a/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterExtractedBodyTest.scala +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterSecondaryBodyTest.scala @@ -14,7 +14,7 @@ import sttp.tapir.server.interceptor.RequestResult import java.nio.charset.StandardCharsets -class ServerInterpreterExtractedBodyTest extends AnyFlatSpec with Matchers { +class ServerInterpreterSecondaryBodyTest extends AnyFlatSpec with Matchers { private implicit val idMonad: MonadError[Identity] = IdentityMonad private class CountingRequestBody(content: String) extends RequestBody[Identity, NoStreams] { @@ -31,7 +31,7 @@ class ServerInterpreterExtractedBodyTest extends AnyFlatSpec with Matchers { it should "decode the same request body for security and main logic, reading it once" in { val se = endpoint.post .in("test") - .securityIn(extractBodyFromRequest(stringBody)) + .securityIn(stringBody.asSecondary) .in(stringBody) .out(stringBody) .serverSecurityLogic[String, Identity](raw => Right(s"security:$raw")) @@ -57,7 +57,7 @@ class ServerInterpreterExtractedBodyTest extends AnyFlatSpec with Matchers { it should "not read the body a second time when security logic fails" in { val se = endpoint.post .in("test") - .securityIn(extractBodyFromRequest(stringBody)) + .securityIn(stringBody.asSecondary) .in(stringBody) .out(stringBody) .errorOut(stringBody) diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerSecurityTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerSecurityTests.scala index c3c92f3dea..3d0ab50847 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerSecurityTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerSecurityTests.scala @@ -142,33 +142,33 @@ class ServerSecurityTests[F[_], S, OPTIONS, ROUTE](createServerTest: CreateServe }, testServerLogic( endpoint.post - .in("extracted") - .securityIn(extractBodyFromRequest(stringBody)) + .in("secondary") + .securityIn(stringBody.asSecondary) .in(stringBody) .out(stringBody) .serverSecurityLogic((raw: String) => pureResult(s"security:$raw".asRight[Unit])) .serverLogic(principal => body => pureResult(s"$principal|logic:$body".asRight[Unit])), - "extracted body is decoded for both security and main logic" + "secondary body is decoded for both security and main logic" ) { (backend, baseUri) => basicStringRequest - .post(uri"$baseUri/extracted") + .post(uri"$baseUri/secondary") .body("payload") .send(backend) .map(_.body shouldBe "security:payload|logic:payload") }, testServerLogic( endpoint.post - .in("extracted-denied") - .securityIn(extractBodyFromRequest(stringBody)) + .in("secondary-denied") + .securityIn(stringBody.asSecondary) .in(stringBody) .out(stringBody) .errorOut(stringBody) .serverSecurityLogic((_: String) => pureResult("denied".asLeft[Unit])) .serverLogic(_ => (body: String) => pureResult(body.asRight[String])), - "extracted body short-circuits on security failure" + "secondary body short-circuits on security failure" ) { (backend, baseUri) => basicStringRequest - .post(uri"$baseUri/extracted-denied") + .post(uri"$baseUri/secondary-denied") .body("payload") .send(backend) .map(_.body shouldBe "denied") diff --git a/testing/src/main/scala/sttp/tapir/testing/EndpointVerificationError.scala b/testing/src/main/scala/sttp/tapir/testing/EndpointVerificationError.scala index c560f3b978..f81a7292cc 100644 --- a/testing/src/main/scala/sttp/tapir/testing/EndpointVerificationError.scala +++ b/testing/src/main/scala/sttp/tapir/testing/EndpointVerificationError.scala @@ -77,7 +77,7 @@ case class DuplicatedNameError(name: String) extends EndpointVerificationError { /** Endpoint `e` declares its request body in a way which can't be served, or which won't be described correctly in the generated * documentation. For example, declaring an ordinary request body in both `securityIn` and `in`, or combining a body which can't be re-read - * (streaming, file, multipart) with one wrapped in `extractBodyFromRequest`. + * (streaming, file, multipart) with one marked with `asSecondary`. * * Errors of this kind are also thrown when routes are constructed. */ diff --git a/testing/src/test/scala/sttp/tapir/testing/EndpointVerifierTest.scala b/testing/src/test/scala/sttp/tapir/testing/EndpointVerifierTest.scala index ae2749164a..fc349fc8f3 100644 --- a/testing/src/test/scala/sttp/tapir/testing/EndpointVerifierTest.scala +++ b/testing/src/test/scala/sttp/tapir/testing/EndpointVerifierTest.scala @@ -339,11 +339,11 @@ class EndpointVerifierTest extends AnyFlatSpecLike with Matchers { result should have size 1 result.head shouldBe a[InvalidBodyDefinitionError] - result.head.toString should include("extractBodyFromRequest") + result.head.toString should include("asSecondary") } - it should "report an extracted body with no body in the API contract" in { - val e = endpoint.post.in("ingest").securityIn(extractBodyFromRequest(stringBody)) + it should "report an secondary body with no body in the API contract" in { + val e = endpoint.post.in("ingest").securityIn(stringBody.asSecondary) val result = EndpointVerifier(List(e)) @@ -351,8 +351,8 @@ class EndpointVerifierTest extends AnyFlatSpecLike with Matchers { result.head shouldBe a[InvalidBodyDefinitionError] } - it should "accept an extracted body alongside an ordinary one" in { - val e = endpoint.post.in("a").securityIn(extractBodyFromRequest(stringBody)).in(stringBody) + it should "accept an secondary body alongside an ordinary one" in { + val e = endpoint.post.in("a").securityIn(stringBody.asSecondary).in(stringBody) EndpointVerifier(List(e)) shouldBe empty } From 9aba4f0a31ad106884efe0599ba1e56ea944ba45 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 31 Aug 2026 18:51:18 +0200 Subject: [PATCH 30/32] refactor: move startup verification out of FilterServerEndpoints 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 --- .../akkahttp/AkkaHttpServerInterpreter.scala | 4 +- .../armeria/cats/TapirCatsService.scala | 4 +- .../server/armeria/TapirFutureService.scala | 4 +- .../server/armeria/zio/TapirZioService.scala | 4 +- .../interpreter/FilterServerEndpoints.scala | 4 +- .../interpreter/PrepareServerEndpoints.scala | 16 +++++++ .../FilterServerEndpointsTest.scala | 23 ---------- .../PrepareServerEndpointsTest.scala | 44 +++++++++++++++++++ .../http4s/Http4sServerInterpreter.scala | 4 +- .../jdkhttp/JdkHttpServerInterpreter.scala | 4 +- .../cats/NettyCatsServerInterpreter.scala | 4 +- .../internal/NettyServerInterpreter.scala | 4 +- .../sync/NettySyncServerInterpreter.scala | 4 +- .../netty/zio/NettyZioServerInterpreter.scala | 4 +- .../server/nima/NimaServerInterpreter.scala | 4 +- .../PekkoHttpServerInterpreter.scala | 4 +- .../server/play/PlayServerInterpreter.scala | 4 +- .../server/play/PlayServerInterpreter.scala | 4 +- .../server/stub/StubServerInterpreter.scala | 2 +- .../server/stub4/StubServerInterpreter.scala | 2 +- .../aws/lambda/AwsServerInterpreter.scala | 4 +- 21 files changed, 93 insertions(+), 58 deletions(-) create mode 100644 server/core/src/main/scala/sttp/tapir/server/interpreter/PrepareServerEndpoints.scala create mode 100644 server/core/src/test/scala/sttp/tapir/server/interpreter/PrepareServerEndpointsTest.scala diff --git a/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaHttpServerInterpreter.scala b/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaHttpServerInterpreter.scala index a95b6506ac..4c4ca844ed 100644 --- a/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaHttpServerInterpreter.scala +++ b/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaHttpServerInterpreter.scala @@ -23,7 +23,7 @@ import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.akkahttp.AkkaModel.parseHeadersOrThrowWithoutContentHeaders import sttp.tapir.server.interceptor.RequestResult import sttp.tapir.server.interceptor.reject.RejectInterceptor -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, RequestBody, ServerInterpreter, ToResponseBody} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, RequestBody, ServerInterpreter, ToResponseBody} import sttp.tapir.server.model.ServerResponse import scala.concurrent.{ExecutionContext, Future} @@ -43,7 +43,7 @@ trait AkkaHttpServerInterpreter { requestBody: (Materializer, ExecutionContext) => RequestBody[Future, AkkaStreams], toResponseBody: (Materializer, ExecutionContext) => ToResponseBody[AkkaResponseBody, AkkaStreams] )(ses: List[ServerEndpoint[AkkaStreams with WebSockets, Future]]): Route = { - val filterServerEndpoints = FilterServerEndpoints(ses) + val filterServerEndpoints = PrepareServerEndpoints(ses) val interceptors = RejectInterceptor.disableWhenSingleEndpoint( akkaHttpServerOptions.appendInterceptor(AkkaStreamSizeExceptionInterceptor).interceptors, ses diff --git a/server/armeria-server/cats/src/main/scala/sttp/tapir/server/armeria/cats/TapirCatsService.scala b/server/armeria-server/cats/src/main/scala/sttp/tapir/server/armeria/cats/TapirCatsService.scala index 486d2bdc74..88e5d14b2c 100644 --- a/server/armeria-server/cats/src/main/scala/sttp/tapir/server/armeria/cats/TapirCatsService.scala +++ b/server/armeria-server/cats/src/main/scala/sttp/tapir/server/armeria/cats/TapirCatsService.scala @@ -19,7 +19,7 @@ import sttp.monad.MonadAsyncError import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.armeria._ import sttp.tapir.server.interceptor.reject.RejectInterceptor -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, ServerInterpreter} private[cats] final case class TapirCatsService[F[_]: Async]( serverEndpoints: List[ServerEndpoint[Fs2Streams[F], F]], @@ -41,7 +41,7 @@ private[cats] final case class TapirCatsService[F[_]: Async]( val interpreter: ServerInterpreter[Fs2Streams[F], F, ArmeriaResponseType, Fs2Streams[F]] = new ServerInterpreter( - FilterServerEndpoints(serverEndpoints), + PrepareServerEndpoints(serverEndpoints), new ArmeriaRequestBody(armeriaServerOptions, fs2StreamCompatible), new ArmeriaToResponseBody(fs2StreamCompatible), RejectInterceptor.disableWhenSingleEndpoint(armeriaServerOptions.interceptors, serverEndpoints), diff --git a/server/armeria-server/src/main/scala/sttp/tapir/server/armeria/TapirFutureService.scala b/server/armeria-server/src/main/scala/sttp/tapir/server/armeria/TapirFutureService.scala index 8331844beb..b6efb7dbda 100644 --- a/server/armeria-server/src/main/scala/sttp/tapir/server/armeria/TapirFutureService.scala +++ b/server/armeria-server/src/main/scala/sttp/tapir/server/armeria/TapirFutureService.scala @@ -12,7 +12,7 @@ import sttp.capabilities.armeria.ArmeriaStreams import sttp.monad.FutureMonad import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.reject.RejectInterceptor -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, ServerInterpreter} private[armeria] final case class TapirFutureService( serverEndpoints: List[ServerEndpoint[ArmeriaStreams, Future]], @@ -29,7 +29,7 @@ private[armeria] final case class TapirFutureService( val serverRequest = new ArmeriaServerRequest(ctx) val future = new CompletableFuture[HttpResponse]() val interpreter: ServerInterpreter[ArmeriaStreams, Future, ArmeriaResponseType, ArmeriaStreams] = new ServerInterpreter( - FilterServerEndpoints(serverEndpoints), + PrepareServerEndpoints(serverEndpoints), new ArmeriaRequestBody(armeriaServerOptions, ArmeriaStreamCompatible), new ArmeriaToResponseBody(ArmeriaStreamCompatible), RejectInterceptor.disableWhenSingleEndpoint(armeriaServerOptions.interceptors, serverEndpoints), diff --git a/server/armeria-server/zio/src/main/scala/sttp/tapir/server/armeria/zio/TapirZioService.scala b/server/armeria-server/zio/src/main/scala/sttp/tapir/server/armeria/zio/TapirZioService.scala index 4667424223..25fc6b1ef4 100644 --- a/server/armeria-server/zio/src/main/scala/sttp/tapir/server/armeria/zio/TapirZioService.scala +++ b/server/armeria-server/zio/src/main/scala/sttp/tapir/server/armeria/zio/TapirZioService.scala @@ -10,7 +10,7 @@ import sttp.capabilities.zio.ZioStreams import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.armeria._ import sttp.tapir.server.interceptor.reject.RejectInterceptor -import sttp.tapir.server.interpreter.{FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{PrepareServerEndpoints, ServerInterpreter} import java.util.concurrent.CompletableFuture import scala.concurrent.{ExecutionContext, Future} @@ -36,7 +36,7 @@ private[zio] final case class TapirZioService[R]( val interpreter: ServerInterpreter[ZioStreams, RIO[R, *], ArmeriaResponseType, ZioStreams] = new ServerInterpreter[ZioStreams, RIO[R, *], ArmeriaResponseType, ZioStreams]( - FilterServerEndpoints(serverEndpoints), + PrepareServerEndpoints(serverEndpoints), new ArmeriaRequestBody(armeriaServerOptions, zioStreamCompatible), new ArmeriaToResponseBody(zioStreamCompatible), RejectInterceptor.disableWhenSingleEndpoint(armeriaServerOptions.interceptors, serverEndpoints), diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/FilterServerEndpoints.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/FilterServerEndpoints.scala index e6d6ea4326..58861fef34 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/FilterServerEndpoints.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/FilterServerEndpoints.scala @@ -3,7 +3,7 @@ package sttp.tapir.server.interpreter import sttp.tapir.{AnyEndpoint, EndpointInput} import sttp.tapir.internal.RichEndpointInput import sttp.tapir.model.ServerRequest -import sttp.tapir.server.{EndpointBodyVerifier, ServerEndpoint} +import sttp.tapir.server.ServerEndpoint class FilterServerEndpoints[R, F[_]](rootLayer: PathLayer[R, F]) extends (ServerRequest => List[ServerEndpoint[R, F]]) { @@ -98,8 +98,6 @@ object FilterServerEndpoints { } def apply[R, F[_]](serverEndpoints: List[ServerEndpoint[R, F]]): FilterServerEndpoints[R, F] = { - EndpointBodyVerifier.throwOnErrors(EndpointBodyVerifier.verify(serverEndpoints.map(_.endpoint))) - val segmentsToEndpoints: List[(List[PathSegment], ServerEndpoint[R, F])] = serverEndpoints.map(se => segmentsForEndpoint(se.endpoint) -> se) diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/PrepareServerEndpoints.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/PrepareServerEndpoints.scala new file mode 100644 index 0000000000..a5fedfe662 --- /dev/null +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/PrepareServerEndpoints.scala @@ -0,0 +1,16 @@ +package sttp.tapir.server.interpreter + +import sttp.tapir.server.{EndpointBodyVerifier, ServerEndpoint} + +/** Verifies that the given endpoints can be served, throwing if any of them can't, and returns the request-to-endpoints function which + * [[ServerInterpreter]] needs. + * + * Server interpreters should call this when constructing routes, rather than [[FilterServerEndpoints]] directly: this is the last point at + * which the endpoints are still a list, and so the only place where they can be checked before serving starts. + */ +object PrepareServerEndpoints { + def apply[R, F[_]](serverEndpoints: List[ServerEndpoint[R, F]]): FilterServerEndpoints[R, F] = { + EndpointBodyVerifier.throwOnErrors(EndpointBodyVerifier.verify(serverEndpoints.map(_.endpoint))) + FilterServerEndpoints(serverEndpoints) + } +} diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala index 5c6f3ba839..986ef21d05 100644 --- a/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/FilterServerEndpointsTest.scala @@ -137,29 +137,6 @@ class FilterServerEndpointsTest extends AnyFlatSpec with Matchers { filter(requestWithPath("y")) shouldBe Nil } - it should "throw when an endpoint declares two primary bodies" in { - val se = endpoint.post - .in("people") - .securityIn(stringBody) - .in(stringBody) - .serverSecurityLogic[Unit, Identity](_ => Right(())) - .serverLogic(_ => _ => Right(())) - - val e = the[IllegalArgumentException] thrownBy FilterServerEndpoints(List(se)) - e.getMessage should include("asSecondary") - } - - it should "accept an endpoint with an secondary body" in { - val se = endpoint.post - .in("people") - .securityIn(stringBody.asSecondary) - .in(stringBody) - .serverSecurityLogic[Unit, Identity](_ => Right(())) - .serverLogic(_ => _ => Right(())) - - noException should be thrownBy FilterServerEndpoints(List(se)) - } - implicit class NoLogic[I, E](e: PublicEndpoint[I, E, Unit, Any]) { def noLogic: ServerEndpoint[Any, Future] = e.serverLogicSuccessPure[Future](_ => ()) } diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/PrepareServerEndpointsTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/PrepareServerEndpointsTest.scala new file mode 100644 index 0000000000..1640da9232 --- /dev/null +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/PrepareServerEndpointsTest.scala @@ -0,0 +1,44 @@ +package sttp.tapir.server.interpreter + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.shared.Identity +import sttp.tapir._ +import sttp.tapir.server.TestUtil + +class PrepareServerEndpointsTest extends AnyFlatSpec with Matchers { + it should "throw when an endpoint declares two primary bodies" in { + val se = endpoint.post + .in("people") + .securityIn(stringBody) + .in(stringBody) + .serverSecurityLogic[Unit, Identity](_ => Right(())) + .serverLogic(_ => _ => Right(())) + + val e = the[IllegalArgumentException] thrownBy PrepareServerEndpoints(List(se)) + e.getMessage should include("asSecondary") + } + + it should "accept an endpoint with a secondary body" in { + val se = endpoint.post + .in("people") + .securityIn(stringBody.asSecondary) + .in(stringBody) + .serverSecurityLogic[Unit, Identity](_ => Right(())) + .serverLogic(_ => _ => Right(())) + + noException should be thrownBy PrepareServerEndpoints(List(se)) + } + + it should "return a filter which matches endpoints by path" in { + val se = endpoint.get + .in("people") + .serverSecurityLogic[Unit, Identity](_ => Right(())) + .serverLogic(_ => _ => Right(())) + + val filter = PrepareServerEndpoints(List(se)) + + filter(TestUtil.createTestRequest(List("people"))) shouldBe List(se) + filter(TestUtil.createTestRequest(List("other"))) shouldBe Nil + } +} diff --git a/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sServerInterpreter.scala b/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sServerInterpreter.scala index 0c87d53a45..d2fc5d9529 100644 --- a/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sServerInterpreter.scala +++ b/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sServerInterpreter.scala @@ -14,7 +14,7 @@ import sttp.tapir.integ.cats.effect.CatsMonadError import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.RequestResult import sttp.tapir.server.interceptor.reject.RejectInterceptor -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, ServerInterpreter} import sttp.tapir.server.model.ServerResponse import scala.reflect.ClassTag @@ -69,7 +69,7 @@ trait Http4sServerInterpreter[F[_]] { implicit val bodyListener: BodyListener[F, Http4sResponseBody[F]] = new Http4sBodyListener[F] new ServerInterpreter( - FilterServerEndpoints(serverEndpoints), + PrepareServerEndpoints(serverEndpoints), new Http4sRequestBody[F](http4sServerOptions), new Http4sToResponseBody[F](http4sServerOptions), RejectInterceptor.disableWhenSingleEndpoint(http4sServerOptions.interceptors, serverEndpoints), diff --git a/server/jdkhttp-server/src/main/scala/sttp/tapir/server/jdkhttp/JdkHttpServerInterpreter.scala b/server/jdkhttp-server/src/main/scala/sttp/tapir/server/jdkhttp/JdkHttpServerInterpreter.scala index fb6e1e9eea..1de80c7cc2 100644 --- a/server/jdkhttp-server/src/main/scala/sttp/tapir/server/jdkhttp/JdkHttpServerInterpreter.scala +++ b/server/jdkhttp-server/src/main/scala/sttp/tapir/server/jdkhttp/JdkHttpServerInterpreter.scala @@ -6,7 +6,7 @@ import sttp.shared.Identity import sttp.tapir.capabilities.NoStreams import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.RequestResult -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, ServerInterpreter} import sttp.tapir.server.jdkhttp.internal._ import scala.jdk.CollectionConverters._ @@ -15,7 +15,7 @@ trait JdkHttpServerInterpreter { def jdkHttpServerOptions: JdkHttpServerOptions def toHandler(ses: List[ServerEndpoint[Any, Identity]]): HttpHandler = { - val filteredEndpoints = FilterServerEndpoints[Any, Identity](ses) + val filteredEndpoints = PrepareServerEndpoints[Any, Identity](ses) val requestBody = new JdkHttpRequestBody( jdkHttpServerOptions.createFile, jdkHttpServerOptions.deleteFile, diff --git a/server/netty-server/cats/src/main/scala/sttp/tapir/server/netty/cats/NettyCatsServerInterpreter.scala b/server/netty-server/cats/src/main/scala/sttp/tapir/server/netty/cats/NettyCatsServerInterpreter.scala index 00376975b9..a88c3f4c23 100644 --- a/server/netty-server/cats/src/main/scala/sttp/tapir/server/netty/cats/NettyCatsServerInterpreter.scala +++ b/server/netty-server/cats/src/main/scala/sttp/tapir/server/netty/cats/NettyCatsServerInterpreter.scala @@ -9,7 +9,7 @@ import sttp.monad.syntax._ import sttp.tapir.integ.cats.effect.CatsMonadError import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.RequestResult -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, ServerInterpreter} import sttp.tapir.server.netty.internal.{NettyBodyListener, RunAsync, _} import sttp.tapir.server.netty.cats.internal.NettyCatsRequestBody import sttp.tapir.server.netty.{NettyResponse, NettyServerRequest, Route} @@ -32,7 +32,7 @@ trait NettyCatsServerInterpreter[F[_]] { val deleteFile = nettyServerOptions.deleteFile val serverInterpreter = new ServerInterpreter[Fs2Streams[F] with WebSockets, F, NettyResponse, Fs2Streams[F]]( - FilterServerEndpoints(ses), + PrepareServerEndpoints(ses), new NettyCatsRequestBody( createFile, deleteFile, diff --git a/server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyServerInterpreter.scala b/server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyServerInterpreter.scala index 3588c83fc9..4eb257acf4 100644 --- a/server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyServerInterpreter.scala +++ b/server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyServerInterpreter.scala @@ -6,7 +6,7 @@ import sttp.tapir.TapirFile import sttp.tapir.capabilities.NoStreams import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.{Interceptor, RequestResult} -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, ServerInterpreter} import sttp.tapir.server.netty.{NettyResponse, NettyServerRequest, Route} import sttp.tapir.server.interpreter.RequestBody import sttp.tapir.server.interpreter.ToResponseBody @@ -22,7 +22,7 @@ object NettyServerInterpreter { ): Route[F] = { implicit val bodyListener: BodyListener[F, NettyResponse] = new NettyBodyListener(runAsync) val serverInterpreter = new ServerInterpreter[Any, F, NettyResponse, NoStreams]( - FilterServerEndpoints(ses), + PrepareServerEndpoints(ses), requestBody, toResponseBody, interceptors, diff --git a/server/netty-server/sync/src/main/scala/sttp/tapir/server/netty/sync/NettySyncServerInterpreter.scala b/server/netty-server/sync/src/main/scala/sttp/tapir/server/netty/sync/NettySyncServerInterpreter.scala index 7a3a81881d..207377bafa 100644 --- a/server/netty-server/sync/src/main/scala/sttp/tapir/server/netty/sync/NettySyncServerInterpreter.scala +++ b/server/netty-server/sync/src/main/scala/sttp/tapir/server/netty/sync/NettySyncServerInterpreter.scala @@ -5,7 +5,7 @@ import sttp.capabilities.WebSockets import sttp.shared.Identity import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.RequestResult -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, ServerInterpreter} import sttp.tapir.server.netty.internal.{NettyBodyListener, RunAsync} import sttp.tapir.server.netty.{NettyResponse, NettyServerRequest, Route} @@ -20,7 +20,7 @@ trait NettySyncServerInterpreter: ): IdRoute = implicit val bodyListener: BodyListener[Identity, NettyResponse] = new NettyBodyListener(RunAsync.Id) val serverInterpreter = new ServerInterpreter[OxStreams with WebSockets, Identity, NettyResponse, OxStreams]( - FilterServerEndpoints(ses), + PrepareServerEndpoints(ses), new NettySyncRequestBody( nettyServerOptions.createFile, nettyServerOptions.deleteFile, diff --git a/server/netty-server/zio/src/main/scala/sttp/tapir/server/netty/zio/NettyZioServerInterpreter.scala b/server/netty-server/zio/src/main/scala/sttp/tapir/server/netty/zio/NettyZioServerInterpreter.scala index affe12fcd5..6c2fd65599 100644 --- a/server/netty-server/zio/src/main/scala/sttp/tapir/server/netty/zio/NettyZioServerInterpreter.scala +++ b/server/netty-server/zio/src/main/scala/sttp/tapir/server/netty/zio/NettyZioServerInterpreter.scala @@ -2,7 +2,7 @@ package sttp.tapir.server.netty.zio import sttp.capabilities.zio.ZioStreams import sttp.tapir.server.interceptor.RequestResult -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, ServerInterpreter} import sttp.tapir.server.netty.internal.{NettyBodyListener, RunAsync, _} import sttp.tapir.server.netty.zio.NettyZioServerInterpreter.ZioRunAsync import sttp.tapir.server.netty.zio.internal.{NettyZioRequestBody, ZioStreamCompatible} @@ -24,7 +24,7 @@ trait NettyZioServerInterpreter[R] { implicit val bodyListener: BodyListener[F, NettyResponse] = new NettyBodyListener(runAsync) val serverInterpreter = new ServerInterpreter[ZioStreams, F, NettyResponse, ZioStreams]( - FilterServerEndpoints(widenedSes), + PrepareServerEndpoints(widenedSes), new NettyZioRequestBody( widenedServerOptions.createFile, widenedServerOptions.deleteFile, diff --git a/server/nima-server/src/main/scala/sttp/tapir/server/nima/NimaServerInterpreter.scala b/server/nima-server/src/main/scala/sttp/tapir/server/nima/NimaServerInterpreter.scala index 73c7a20bea..b4346bab6e 100644 --- a/server/nima-server/src/main/scala/sttp/tapir/server/nima/NimaServerInterpreter.scala +++ b/server/nima-server/src/main/scala/sttp/tapir/server/nima/NimaServerInterpreter.scala @@ -7,7 +7,7 @@ import sttp.tapir.capabilities.NoStreams import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.RequestResult import sttp.tapir.server.interceptor.reject.RejectInterceptor -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, ServerInterpreter} import sttp.tapir.server.nima.internal.{NimaBodyListener, NimaRequestBody, NimaServerRequest, NimaToResponseBody, idMonad} import java.io.InputStream @@ -16,7 +16,7 @@ trait NimaServerInterpreter { def nimaServerOptions: NimaServerOptions def toHandler(ses: List[ServerEndpoint[Any, Identity]]): Handler = { - val filteredEndpoints = FilterServerEndpoints[Any, Identity](ses) + val filteredEndpoints = PrepareServerEndpoints[Any, Identity](ses) val requestBody = new NimaRequestBody(nimaServerOptions.createFile) val responseBody = new NimaToResponseBody val interceptors = nimaServerOptions.interceptors diff --git a/server/pekko-http-server/src/main/scala/sttp/tapir/server/pekkohttp/PekkoHttpServerInterpreter.scala b/server/pekko-http-server/src/main/scala/sttp/tapir/server/pekkohttp/PekkoHttpServerInterpreter.scala index 95c99f1d0e..c799cea625 100644 --- a/server/pekko-http-server/src/main/scala/sttp/tapir/server/pekkohttp/PekkoHttpServerInterpreter.scala +++ b/server/pekko-http-server/src/main/scala/sttp/tapir/server/pekkohttp/PekkoHttpServerInterpreter.scala @@ -22,7 +22,7 @@ import sttp.monad.FutureMonad import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.RequestResult import sttp.tapir.server.interceptor.reject.RejectInterceptor -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, RequestBody, ServerInterpreter, ToResponseBody} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, RequestBody, ServerInterpreter, ToResponseBody} import sttp.tapir.server.model.ServerResponse import sttp.tapir.server.pekkohttp.PekkoModel.parseHeadersOrThrowWithoutContentHeaders @@ -43,7 +43,7 @@ trait PekkoHttpServerInterpreter { requestBody: (Materializer, ExecutionContext) => RequestBody[Future, PekkoStreams], toResponseBody: (Materializer, ExecutionContext) => ToResponseBody[PekkoResponseBody, PekkoStreams] )(ses: List[ServerEndpoint[PekkoStreams with WebSockets, Future]]): Route = { - val filterServerEndpoints = FilterServerEndpoints(ses) + val filterServerEndpoints = PrepareServerEndpoints(ses) val interceptors = RejectInterceptor.disableWhenSingleEndpoint( pekkoHttpServerOptions.appendInterceptor(PekkoStreamSizeExceptionInterceptor).interceptors, ses diff --git a/server/play-server/src/main/scala/sttp/tapir/server/play/PlayServerInterpreter.scala b/server/play-server/src/main/scala/sttp/tapir/server/play/PlayServerInterpreter.scala index dc1d0eefcb..a4968c91c5 100644 --- a/server/play-server/src/main/scala/sttp/tapir/server/play/PlayServerInterpreter.scala +++ b/server/play-server/src/main/scala/sttp/tapir/server/play/PlayServerInterpreter.scala @@ -15,7 +15,7 @@ import sttp.monad.FutureMonad import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.RequestResult import sttp.tapir.server.interceptor.cors.CORSInterceptor -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, ServerInterpreter} import sttp.tapir.server.model.ServerResponse import scala.concurrent.{ExecutionContext, Future} @@ -41,7 +41,7 @@ trait PlayServerInterpreter { ): Routes = { implicit val monad: FutureMonad = new FutureMonad() - val filterServerEndpoints = FilterServerEndpoints(serverEndpoints) + val filterServerEndpoints = PrepareServerEndpoints(serverEndpoints) val singleEndpoint = serverEndpoints.size == 1 implicit val bodyListener: BodyListener[Future, PlayResponseBody] = new PlayBodyListener diff --git a/server/play29-server/src/main/scala/sttp/tapir/server/play/PlayServerInterpreter.scala b/server/play29-server/src/main/scala/sttp/tapir/server/play/PlayServerInterpreter.scala index 36ff87b7e0..b5d1e883a9 100644 --- a/server/play29-server/src/main/scala/sttp/tapir/server/play/PlayServerInterpreter.scala +++ b/server/play29-server/src/main/scala/sttp/tapir/server/play/PlayServerInterpreter.scala @@ -15,7 +15,7 @@ import sttp.monad.FutureMonad import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.RequestResult import sttp.tapir.server.interceptor.cors.CORSInterceptor -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, ServerInterpreter} import sttp.tapir.server.model.ServerResponse import scala.concurrent.{ExecutionContext, Future} @@ -41,7 +41,7 @@ trait PlayServerInterpreter { ): Routes = { implicit val monad: FutureMonad = new FutureMonad() - val filterServerEndpoints = FilterServerEndpoints(serverEndpoints) + val filterServerEndpoints = PrepareServerEndpoints(serverEndpoints) val singleEndpoint = serverEndpoints.size == 1 implicit val bodyListener: BodyListener[Future, PlayResponseBody] = new PlayBodyListener diff --git a/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/StubServerInterpreter.scala b/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/StubServerInterpreter.scala index b1cf6e7ef2..4b2119d8f6 100644 --- a/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/StubServerInterpreter.scala +++ b/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/StubServerInterpreter.scala @@ -25,7 +25,7 @@ private[stub] object StubServerInterpreter { val interpreter = new ServerInterpreter[R, F, Any, AnyStreams]( - FilterServerEndpoints(endpoints), + PrepareServerEndpoints(endpoints), new SttpRequestBody[F], SttpResponseEncoder.toResponseBody, interceptors, diff --git a/server/sttp-stub4-server/src/main/scala/sttp/tapir/server/stub4/StubServerInterpreter.scala b/server/sttp-stub4-server/src/main/scala/sttp/tapir/server/stub4/StubServerInterpreter.scala index 625eaef5d7..d58d28b9d1 100644 --- a/server/sttp-stub4-server/src/main/scala/sttp/tapir/server/stub4/StubServerInterpreter.scala +++ b/server/sttp-stub4-server/src/main/scala/sttp/tapir/server/stub4/StubServerInterpreter.scala @@ -26,7 +26,7 @@ private[stub4] object StubServerInterpreter { val interpreter = new ServerInterpreter[R, F, Any, AnyStreams]( - FilterServerEndpoints(endpoints), + PrepareServerEndpoints(endpoints), new SttpRequestBody[F], SttpResponseEncoder.toResponseBody, interceptors, diff --git a/serverless/aws/lambda-core/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala b/serverless/aws/lambda-core/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala index b003475026..39820f100c 100644 --- a/serverless/aws/lambda-core/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala +++ b/serverless/aws/lambda-core/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala @@ -6,7 +6,7 @@ import sttp.monad.syntax._ import sttp.tapir.capabilities.NoStreams import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.RequestResult -import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.interpreter.{BodyListener, PrepareServerEndpoints, ServerInterpreter} private[aws] abstract class AwsServerInterpreter[F[_]: MonadError] { @@ -19,7 +19,7 @@ private[aws] abstract class AwsServerInterpreter[F[_]: MonadError] { implicit val bodyListener: BodyListener[F, LambdaResponseBody] = new AwsBodyListener[F] val interpreter = new ServerInterpreter[Any, F, LambdaResponseBody, NoStreams]( - FilterServerEndpoints(ses), + PrepareServerEndpoints(ses), new AwsRequestBody[F](), new AwsToResponseBody(awsServerOptions), awsServerOptions.interceptors, From 54fe795ae0520c91a57aff906c219e80371382a5 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Tue, 1 Sep 2026 10:47:01 +0200 Subject: [PATCH 31/32] fix: reject a secondary body marked inside oneOfBody 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 --- .../tapir/server/EndpointBodyVerifier.scala | 17 +++++++++++-- .../server/EndpointBodyVerifierTest.scala | 24 ++++++++++++------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala b/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala index 2673e286e4..9d5f4a1f21 100644 --- a/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala +++ b/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala @@ -55,8 +55,21 @@ private[tapir] object EndpointBodyVerifier { } val shown = endpoint.showShort + // asSecondary can be called on a variant, as oneOfBody takes bodies, but the server interpreters only look for + // the marker on a top-level body input - so accepting it here would silently fall back to reading the body once + val secondaryInsideOneOfBody: List[String] = + inputs + .collect { case ob: EndpointIO.OneOfBody[?, ?] => ob } + .collect { + case ob if ob.variants.map(_.bodyAsAtom).exists { case b: EndpointIO.Body[?, ?] => b.isSecondary; case _ => false } => + s"Endpoint $shown marks a oneOfBody variant as secondary. Only a body input used on its own can be " + + s"secondary; a oneOfBody is always part of the API contract." + } + .toList + val tooManyPrimaries: List[String] = - if (securityPrimaryBodies.nonEmpty && inPrimaryBodies.nonEmpty) + if (secondaryInsideOneOfBody.nonEmpty) Nil + else if (securityPrimaryBodies.nonEmpty && inPrimaryBodies.nonEmpty) List( s"Endpoint $shown declares a request body in both securityIn and in. Only one may be part of the API " + s"contract. If both should decode the same request body, mark the securityIn one: " + @@ -107,7 +120,7 @@ private[tapir] object EndpointBodyVerifier { } EndpointBodyProblems( - errors = tooManyPrimaries ++ streamWithSecondary ++ nonReplayableWithSecondary, + errors = secondaryInsideOneOfBody ++ tooManyPrimaries ++ streamWithSecondary ++ nonReplayableWithSecondary, warnings = (secondaryWithoutPrimary ++ uselessMetadata).toList ) } diff --git a/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala b/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala index 31d2da1d24..6ca5f5e272 100644 --- a/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala +++ b/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala @@ -6,7 +6,7 @@ import sttp.tapir._ import sttp.tapir.capabilities.NoStreams class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { - it should "accept an endpoint with one extracted and one primary body" in { + it should "accept an endpoint with one secondary and one primary body" in { val e = endpoint.post.in("people").securityIn(stringBody.asSecondary).in(stringBody) EndpointBodyVerifier.verifyOne(e) shouldBe EndpointBodyProblems(Nil, Nil) } @@ -24,7 +24,7 @@ class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { problems.errors.head should include("asSecondary") } - it should "reject a streaming primary body combined with an secondary body" in { + it should "reject a streaming primary body combined with a secondary body" in { val e = endpoint.post .in("people") .securityIn(stringBody.asSecondary) @@ -35,7 +35,7 @@ class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { problems.errors.head should include("streaming body") } - it should "reject a file body primary combined with an secondary body" in { + it should "reject a file body primary combined with a secondary body" in { val e = endpoint.post .in("people") .securityIn(stringBody.asSecondary) @@ -46,7 +46,7 @@ class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { problems.errors.head should include("file") } - it should "reject a oneOfBody of streaming variants combined with an secondary body" in { + it should "reject a oneOfBody of streaming variants combined with a secondary body" in { val e = endpoint.post .in("people") .securityIn(stringBody.asSecondary) @@ -57,7 +57,7 @@ class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { problems.errors.head should include("streaming body") } - it should "reject a oneOfBody with a file body variant combined with an secondary body" in { + it should "reject a oneOfBody with a file body variant combined with a secondary body" in { val e = endpoint.post .in("people") .securityIn(stringBody.asSecondary) @@ -68,7 +68,15 @@ class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { problems.errors.head should include("file") } - it should "warn about an secondary body with no primary body on POST" in { + it should "reject a secondary body variant inside a oneOfBody" in { + val e = endpoint.post.in("people").securityIn(oneOfBody(stringBody.asSecondary)).in(byteArrayBody) + val problems = EndpointBodyVerifier.verifyOne(e) + + problems.errors should have size 1 + problems.errors.head should include("marks a oneOfBody variant as secondary") + } + + it should "warn about a secondary body with no primary body on POST" in { val e = endpoint.post.in("ingest").securityIn(stringBody.asSecondary) val problems = EndpointBodyVerifier.verifyOne(e) @@ -77,12 +85,12 @@ class EndpointBodyVerifierTest extends AnyFlatSpec with Matchers { problems.warnings.head should include("no request body is part of the API contract") } - it should "not warn about an secondary body with no primary body on GET" in { + it should "not warn about a secondary body with no primary body on GET" in { val e = endpoint.get.in("ping").securityIn(stringBody.asSecondary) EndpointBodyVerifier.verifyOne(e).warnings shouldBe empty } - it should "warn about metadata on an secondary body" in { + it should "warn about metadata on a secondary body" in { val e = endpoint.post .in("people") .securityIn(stringBody.description("the raw payload").asSecondary) From e0f70cfa71b55a44a4c85e557f900b297a87be08 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Tue, 1 Sep 2026 11:13:14 +0200 Subject: [PATCH 32/32] Shorten the scaladoc --- .../sttp/tapir/server/interpreter/PrepareServerEndpoints.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/core/src/main/scala/sttp/tapir/server/interpreter/PrepareServerEndpoints.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/PrepareServerEndpoints.scala index a5fedfe662..a325689027 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interpreter/PrepareServerEndpoints.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/PrepareServerEndpoints.scala @@ -5,8 +5,7 @@ import sttp.tapir.server.{EndpointBodyVerifier, ServerEndpoint} /** Verifies that the given endpoints can be served, throwing if any of them can't, and returns the request-to-endpoints function which * [[ServerInterpreter]] needs. * - * Server interpreters should call this when constructing routes, rather than [[FilterServerEndpoints]] directly: this is the last point at - * which the endpoints are still a list, and so the only place where they can be checked before serving starts. + * Server interpreters should call this when constructing routes. */ object PrepareServerEndpoints { def apply[R, F[_]](serverEndpoints: List[ServerEndpoint[R, F]]): FilterServerEndpoints[R, F] = {