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..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 @@ -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.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[_, _] => 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..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 @@ -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.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) 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..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 @@ -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.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) 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..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 @@ -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.isSecondary => + // 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..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 @@ -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.isSecondary => + // 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/SecondaryBodyClientTest.scala b/client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/SecondaryBodyClientTest.scala new file mode 100644 index 0000000000..533246f15e --- /dev/null +++ b/client/sttp-client4/src/test/scala/sttp/tapir/client/sttp4/SecondaryBodyClientTest.scala @@ -0,0 +1,24 @@ +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 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(stringBody.asSecondary) + .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") + } +} diff --git a/core/src/main/scala/sttp/tapir/EndpointIO.scala b/core/src/main/scala/sttp/tapir/EndpointIO.scala index 846d5bebfa..1fef154d64 100644 --- a/core/src/main/scala/sttp/tapir/EndpointIO.scala +++ b/core/src/main/scala/sttp/tapir/EndpointIO.scala @@ -486,13 +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 - s"{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/SecondaryBody.scala b/core/src/main/scala/sttp/tapir/SecondaryBody.scala new file mode 100644 index 0000000000..aad623c9d1 --- /dev/null +++ b/core/src/main/scala/sttp/tapir/SecondaryBody.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 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 [[EndpointIO.Body.asSecondary]]. + */ +case class SecondaryBody() + +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 a secondary body. */ +@implicitNotFound( + "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." +) +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/internal/package.scala b/core/src/main/scala/sttp/tapir/internal/package.scala index ab7e91d5e9..7bcdf4593d 100644 --- a/core/src/main/scala/sttp/tapir/internal/package.scala +++ b/core/src/main/scala/sttp/tapir/internal/package.scala @@ -360,4 +360,9 @@ package object internal { case null => true case _ => false } + + def isSecondaryBodyInput(input: EndpointInput[?]): Boolean = input match { + case b: EndpointIO.Body[?, ?] => b.isSecondary + case _ => false + } } diff --git a/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala b/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala new file mode 100644 index 0000000000..9d5f4a1f21 --- /dev/null +++ b/core/src/main/scala/sttp/tapir/server/EndpointBodyVerifier.scala @@ -0,0 +1,127 @@ +package sttp.tapir.server + +import sttp.model.Method +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. */ +private[tapir] case class EndpointBodyProblems(errors: List[String], warnings: List[String]) { + def ++(other: EndpointBodyProblems): EndpointBodyProblems = + EndpointBodyProblems(errors ++ other.errors, warnings ++ other.warnings) +} + +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. + */ +private[tapir] object EndpointBodyVerifier { + def verify(endpoints: List[AnyEndpoint]): EndpointBodyProblems = + endpoints.map(verifyOne).foldLeft(EndpointBodyProblems.Empty)(_ ++ _) + + 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() + val inputs = securityInputs ++ ordinaryInputs + + 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.isSecondary => b + case b: EndpointIO.OneOfBody[?, ?] => b + case b: EndpointIO.StreamBodyWrapper[?, ?] => b + } + val securityPrimaryBodies = primaryBodiesOf(securityInputs) + val inPrimaryBodies = primaryBodiesOf(ordinaryInputs) + val primaryBodies = securityPrimaryBodies ++ inPrimaryBodies + 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[?, ?] => + b.bodyType match { + case RawBodyType.FileBody => true + case _: RawBodyType.MultipartBody => true + case _ => false + } + case _ => false + } + 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 (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: " + + s"stringBody.asSecondary." + ) + 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 streamWithSecondary = + if (streamingPrimary && secondary.nonEmpty) + List( + 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 nonReplayableWithSecondary = + if (nonReplayablePrimary && secondary.nonEmpty) + List( + 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 secondaryWithoutPrimary = + if (secondary.nonEmpty && primaryBodies.isEmpty && bodyCarryingMethod) + List( + 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 asSecondary and use the body input directly." + ) + else Nil + + val uselessMetadata = + 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 = secondaryInsideOneOfBody ++ tooManyPrimaries ++ streamWithSecondary ++ nonReplayableWithSecondary, + warnings = (secondaryWithoutPrimary ++ uselessMetadata).toList + ) + } +} 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 new file mode 100644 index 0000000000..6ca5f5e272 --- /dev/null +++ b/core/src/test/scala/sttp/tapir/server/EndpointBodyVerifierTest.scala @@ -0,0 +1,109 @@ +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 secondary and one primary body" in { + val e = endpoint.post.in("people").securityIn(stringBody.asSecondary).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("asSecondary") + } + + it should "reject a streaming primary body combined with a secondary body" in { + val e = endpoint.post + .in("people") + .securityIn(stringBody.asSecondary) + .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 a secondary body" in { + val e = endpoint.post + .in("people") + .securityIn(stringBody.asSecondary) + .in(fileBody) + val problems = EndpointBodyVerifier.verifyOne(e) + + problems.errors should have size 1 + problems.errors.head should include("file") + } + + it should "reject a oneOfBody of streaming variants combined with a secondary body" in { + val e = endpoint.post + .in("people") + .securityIn(stringBody.asSecondary) + .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 a secondary body" in { + val e = endpoint.post + .in("people") + .securityIn(stringBody.asSecondary) + .in(oneOfBody(fileBody)) + val problems = EndpointBodyVerifier.verifyOne(e) + + problems.errors should have size 1 + problems.errors.head should include("file") + } + + 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) + + 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 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 a secondary body" in { + val e = endpoint.post + .in("people") + .securityIn(stringBody.description("the raw payload").asSecondary) + .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 + } +} diff --git a/doc/endpoint/security.md b/doc/endpoint/security.md index 319e1b193b..e023a38e31 100644 --- a/doc/endpoint/security.md +++ b/doc/endpoint/security.md @@ -35,6 +35,53 @@ 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 `asSecondary`: + +```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(stringBody.asSecondary) + .in("people") + .in(jsonBody[Person]) +``` + +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 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 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 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 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 +[`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/testing.md b/doc/testing.md index a102d1d702..74d30290d1 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 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 `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. + +Example 1: + +```scala mdoc:silent +import sttp.tapir.testing.EndpointVerifier + +val ep7 = endpoint.post.in("ingest").securityIn(stringBody.asSecondary) +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/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..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 @@ -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.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 5aa66aa253..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 @@ -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(isSecondaryBodyInput).filter(inputMayFailWithBadRequest) if (fallibleBasicInputs.nonEmpty) Some(badRequestDescription(fallibleBasicInputs)) else None diff --git a/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToParameters.scala b/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToParameters.scala index 8a700a2340..0083d917af 100644 --- a/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToParameters.scala +++ b/docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToParameters.scala @@ -5,6 +5,7 @@ import sttp.apispec.openapi.Parameter import sttp.tapir._ import sttp.tapir.EndpointIO.OneOfBody import sttp.tapir.docs.apispec.schema.TSchemaToASchema +import sttp.tapir.internal._ private[openapi] class EndpointToParameters(tschemaToASchema: TSchemaToASchema) { // stateless, and derived from the same TSchemaToASchema as every other caller's instance @@ -17,7 +18,7 @@ private[openapi] class EndpointToParameters(tschemaToASchema: TSchemaToASchema) 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 && !isSecondaryBodyInput(a) => a } def withSourceAtoms( diff --git a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/SecondaryBodyDocsTest.scala b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/SecondaryBodyDocsTest.scala new file mode 100644 index 0000000000..037fea59b9 --- /dev/null +++ b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/SecondaryBodyDocsTest.scala @@ -0,0 +1,30 @@ +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 SecondaryBodyDocsTest extends AnyFlatSpec with Matchers { + it should "document only the primary body" in { + val e = endpoint.post + .in("people") + .securityIn(stringBody.asSecondary) + .in(byteArrayBody) + + // 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 + + yaml should include("application/octet-stream") + yaml should not include ("text/plain") + } + + 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 + + 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..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 @@ -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.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 98aeb4ef68..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 @@ -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.isSecondary => List.empty + case EndpointIO.Body(_, codec, _) => List(fromCodec(codec)) + case EndpointIO.MappedPair(wrapped, _) => forIO(wrapped) + case _ => List.empty } } 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/CachingRequestBody.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala new file mode 100644 index 0000000000..7cc5a5d5c1 --- /dev/null +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/CachingRequestBody.scala @@ -0,0 +1,57 @@ +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, 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. 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. + */ +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 + + // 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))) + case RawBodyType.ByteArrayBody => + // 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 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): InputStream)) + case RawBodyType.InputStreamRangeBody => + bytes(serverRequest, maxBytes) + .map(bs => RawValue(InputStreamRange(() => new ByteArrayInputStream(bs)))) + // file and multipart are never cached; EndpointBodyVerifier rejects them alongside a secondary 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 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 + 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 ed48caf8ec..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 @@ -8,21 +8,31 @@ 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 a secondary body, i.e. one which requires the request body to be readable more than once. + */ + def hasSecondaryBody: 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)], + secondaryBodyInputsWithIndex: Vector[(EndpointIO.Body[?, ?], Int)] = Vector.empty ) extends DecodeBasicInputsResult { + 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 = { - verifyNoBody(input) - copy(bodyInputWithIndex = Some((Left(oneOfBody(ContentTypeRange.AnyRange -> input)), bodyIndex))) - } + def addBodyInput[O](input: EndpointIO.Body[?, O], bodyIndex: Int): Values = + if (input.isSecondary) copy(secondaryBodyInputsWithIndex = secondaryBodyInputsWithIndex :+ ((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 +50,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 hasSecondaryBody: 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/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..a325689027 --- /dev/null +++ b/server/core/src/main/scala/sttp/tapir/server/interpreter/PrepareServerEndpoints.scala @@ -0,0 +1,15 @@ +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. + */ +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/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala b/server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala index 9e29a0a7d0..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 @@ -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.hasSecondaryBody || regularBasicInputs.hasSecondaryBody) 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, + bodyReader: 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, bodyReader) 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 => decodeSecondaryBodies(request, v, maxBodyLength, addRawValue, bodyReader) + case failure => failure.unit + } case failure: DecodeBasicInputsResult.Failure => (failure: DecodeBasicInputsResult).unit } + } + + private def decodeSecondaryBodies( + request: ServerRequest, + values: DecodeBasicInputsResult.Values, + maxBodyLength: Option[Long], + addRawValue: RawValue[?] => Unit, + bodyReader: RequestBody[F, S] + ): F[DecodeBasicInputsResult] = + values.secondaryBodyInputsWithIndex.foldLeft((values: DecodeBasicInputsResult).unit) { case (acc, (bodyInput, index)) => + acc.flatMap { + case v: DecodeBasicInputsResult.Values => + decodeSecondaryBody(request, v, bodyInput.asInstanceOf[EndpointIO.Body[Any, Any]], index, maxBodyLength, addRawValue, bodyReader) + case failure => failure.unit + } + } + + private def decodeSecondaryBody[RAW, T]( + request: ServerRequest, + values: DecodeBasicInputsResult.Values, + bodyInput: EndpointIO.Body[RAW, T], + index: Int, + maxBodyLength: Option[Long], + addRawValue: RawValue[?] => Unit, + bodyReader: RequestBody[F, S] + ): F[DecodeBasicInputsResult] = + bodyReader + .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, @@ -203,6 +252,7 @@ class ServerInterpreter[R, F[_], B, S]( bodyInput: EndpointIO.StreamBodyWrapper[Any, Any], maxBodyLength: Option[Long] ): F[DecodeBasicInputsResult] = + // 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 @@ -213,9 +263,10 @@ class ServerInterpreter[R, F[_], B, S]( values: DecodeBasicInputsResult.Values, bodyInput: EndpointIO.Body[RAW, T], maxBodyLength: Option[Long], - addRawValue: RawValue[?] => Unit + addRawValue: RawValue[?] => Unit, + bodyReader: RequestBody[F, S] ): F[DecodeBasicInputsResult] = { - requestBody + bodyReader .toRaw(request, bodyInput.bodyType, maxBodyLength) .flatMap { v => addRawValue(v) 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..86f6568520 --- /dev/null +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/CachingRequestBodyTest.scala @@ -0,0 +1,112 @@ +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 + 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") + } + } + 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" + } + + 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 "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) + + 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 + } +} 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..b22c9aaa16 --- /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 secondary body separately from the primary body" in { + val result = emptyValues(1).addBodyInput(stringBody.asSecondary, 0) + + result.bodyInputWithIndex shouldBe None + 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.secondaryBodyInputsWithIndex shouldBe empty + result.hasSecondaryBody shouldBe false + } + + it should "allow a primary body alongside several secondary bodies" in { + val result = emptyValues(3) + .addBodyInput(stringBody.asSecondary, 0) + .addBodyInput(stringBody, 1) + .addBodyInput(byteArrayBody.asSecondary, 2) + + result.bodyInputWithIndex.map(_._2) shouldBe Some(1) + result.secondaryBodyInputsWithIndex.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 secondary body for a decode failure" in { + val failure: DecodeBasicInputsResult = + DecodeBasicInputsResult.Failure(stringBody, DecodeResult.Missing) + 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 3b2b449d4e..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 @@ -4,6 +4,7 @@ import sttp.tapir._ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import sttp.model.{Header, Method, QueryParams, Uri} +import sttp.shared.Identity import sttp.tapir.model.{ConnectionInfo, ServerRequest} import sttp.tapir.server.ServerEndpoint 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/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterSecondaryBodyTest.scala b/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterSecondaryBodyTest.scala new file mode 100644 index 0000000000..cd8b1ae519 --- /dev/null +++ b/server/core/src/test/scala/sttp/tapir/server/interpreter/ServerInterpreterSecondaryBodyTest.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 ServerInterpreterSecondaryBodyTest 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(stringBody.asSecondary) + .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(stringBody.asSecondary) + .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, + _ => () + ) + + val _ = interpreter.apply(createTestRequest(List("test"), _method = Method.POST)) + requestBody.reads shouldBe 1 + } +} 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..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 @@ -7,7 +7,7 @@ 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 @@ -18,6 +18,8 @@ trait FinatraServerInterpreter extends Logging { def finatraServerOptions: FinatraServerOptions = FinatraServerOptions.default def toRoute(se: ServerEndpoint[Any, Future]): FinatraRoute = { + EndpointBodyVerifier.throwOnErrors(EndpointBodyVerifier.verifyOne(se.endpoint)) + val serverInterpreter = new ServerInterpreter[Any, Future, FinatraContent, NoStreams]( _ => List(se), new FinatraRequestBody(finatraServerOptions), 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/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..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 @@ -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("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])), + "secondary body is decoded for both security and main logic" + ) { (backend, baseUri) => + basicStringRequest + .post(uri"$baseUri/secondary") + .body("payload") + .send(backend) + .map(_.body shouldBe "security:payload|logic:payload") + }, + testServerLogic( + endpoint.post + .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])), + "secondary body short-circuits on security failure" + ) { (backend, baseUri) => + basicStringRequest + .post(uri"$baseUri/secondary-denied") + .body("payload") + .send(backend) + .map(_.body shouldBe "denied") } ) ++ correctAuthTests ++ 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..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 @@ -8,7 +8,7 @@ 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.vertx.{VertxBodyListener, VertxErrorHandler} @@ -35,6 +35,8 @@ trait VertxCatsServerInterpreter[F[_]] extends CommonServerInterpreter with Vert def route( e: ServerEndpoint[Fs2Streams[F] with WebSockets, F] ): Router => Route = { router => + EndpointBodyVerifier.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..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 @@ -4,7 +4,7 @@ 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.vertx.VertxFutureServerInterpreter.{FutureFromVFuture, FutureRunAsync, VertxFutureToScalaFuture} @@ -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 => + EndpointBodyVerifier.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 => + EndpointBodyVerifier.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..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 @@ -4,6 +4,7 @@ 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.vertx.VertxBodyListener @@ -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 => + EndpointBodyVerifier.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..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 @@ -6,6 +6,7 @@ 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 @@ -23,6 +24,8 @@ trait ZioHttpInterpreter[R] { toHttp(List(se)) def toHttp[R2](ses: List[ZServerEndpoint[R2, ZioStreams with WebSockets]]): Routes[R & R2, Response] = { + 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] val widenedSes = ses.map(_.widen[R & R2]) 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, diff --git a/testing/src/main/scala/sttp/tapir/testing/EndpointVerificationError.scala b/testing/src/main/scala/sttp/tapir/testing/EndpointVerificationError.scala index 0e25a3c239..f81a7292cc 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 marked with `asSecondary`. + * + * 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..fc349fc8f3 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("asSecondary") + } + + 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)) + + result should have size 1 + result.head shouldBe a[InvalidBodyDefinitionError] + } + + 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 + } } sealed trait ErrorInfo