From 4343ced01debdf81132626129d5d4248a3ef8f65 Mon Sep 17 00:00:00 2001 From: adamw Date: Thu, 21 Apr 2022 09:26:37 +0200 Subject: [PATCH] Allow using streaming bodies in oneOf --- .../main/scala/sttp/tapir/EndpointIO.scala | 13 +++++++---- doc/endpoint/oneof.md | 15 +++++++++++++ .../server/tests/ServerStreamingTests.scala | 22 ++++++++++++++++++- .../scala/sttp/tapir/tests/Streaming.scala | 16 ++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/core/src/main/scala/sttp/tapir/EndpointIO.scala b/core/src/main/scala/sttp/tapir/EndpointIO.scala index 5f2ddac0a3..f4a2339d60 100644 --- a/core/src/main/scala/sttp/tapir/EndpointIO.scala +++ b/core/src/main/scala/sttp/tapir/EndpointIO.scala @@ -591,7 +591,7 @@ object EndpointIO { } /* -Streaming body is a special kind of input, as it influences the 4th type parameter of `Endpoint`. Other inputs +Streaming body is a special kind of input/output, as it influences the 4th type parameter of `Endpoint`. Other inputs (`EndpointInput`s and `EndpointIO`s) aren't parametrised with the type of streams that they use (to make them simpler), so we need to pass the streaming information directly between the streaming body input and the endpoint. @@ -600,8 +600,8 @@ other inputs, and the `Endpoint.in(EndpointInput)` method can't be used to add a overloaded variant `Endpoint.in(StreamBody)`, which takes into account the streaming type. Internally, the streaming body is converted into a wrapper `EndpointIO`, which "forgets" about the streaming -information. The `EndpointIO.StreamBodyWrapper` should only be used internally, not by the end user: there's no -factory method in `Tapir` which would directly create an instance of it. +information. This can also be done by the end user with `.toEndpointIO`, if the body should be used e.g. in `oneOf`. +However, this decreases type safety, as the streaming requirement is lost. BS == streams.BinaryStream, but we can't express this using dependent types here. */ @@ -617,7 +617,12 @@ case class StreamBodyIO[BS, T, S]( override private[tapir] type CF = CodecFormat override private[tapir] def copyWith[U](c: Codec[BS, U, CodecFormat], i: Info[U]) = copy(codec = c, info = i) - private[tapir] def toEndpointIO: EndpointIO.StreamBodyWrapper[BS, T] = EndpointIO.StreamBodyWrapper(this) + /** Lift this streaming body into an [[EndpointIO]], so that it can be used as a regular endpoint input/output, "forgetting" the streaming + * requirement. This is useful when using the streaming body in [[Tapir.oneOf]] or [[Tapir.oneOfBody]], however at the expense of type + * safety: the fact that the endpoint can only be interpreted by an interpreter supporting the given stream type is lost; in case of a + * mismatch, a run-time error will occur. + */ + def toEndpointIO: EndpointIO.StreamBodyWrapper[BS, T] = EndpointIO.StreamBodyWrapper(this) /** Add an example of a "deserialized" stream value. This should be given in an encoded form, e.g. in case of json - as a [[String]], as * the stream body doesn't have access to the codec that will be later used for deserialization. diff --git a/doc/endpoint/oneof.md b/doc/endpoint/oneof.md index 1b98581d81..2bcd557fca 100644 --- a/doc/endpoint/oneof.md +++ b/doc/endpoint/oneof.md @@ -169,6 +169,21 @@ oneOfBody( ) ``` +## oneOf and non-blocking streaming + +[Streaming bodies](streaming.md) can't be used as normal inputs/outputs, as the streaming requirement needs to be +propagated to the `Endpoint` type. This way, we assure at compile-time that only interpreters supporting the given +streaming type can be used to interpret an endpoint. + +However, this makes it impossible to use streaming bodies in `oneOf` (via `oneOfVariant`) and `oneOfBody`, as both +require normal input/outputs as parameters. To bypass this limitation, a `.toEndpointIO` method is available +on streaming bodies, which "lifts" them to an `EndpointIO` type, forgetting the streaming requirement. This decreases +type safety, as a run-time error might occur if an incompatible interpreter is used, however allows describing +endpoints, which require including streaming bodies in output variants. + +Note that if the same streaming body description is used in all branches of a `oneOf`, this can be refactored into +a regular streaming body output + a varying set of output headers, expressed using `oneOf`. + ## Next Read on about [codecs](codecs.md). diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStreamingTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStreamingTests.scala index 4906922a5d..f51dce74b6 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStreamingTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStreamingTests.scala @@ -7,7 +7,12 @@ import sttp.client3._ import sttp.model.{Header, HeaderNames, MediaType} import sttp.monad.MonadError import sttp.tapir.tests.Test -import sttp.tapir.tests.Streaming.{in_stream_out_stream, in_stream_out_stream_with_content_length, out_custom_content_type_stream_body} +import sttp.tapir.tests.Streaming.{ + in_stream_out_stream, + in_stream_out_stream_with_content_length, + in_string_stream_out_either_stream_string, + out_custom_content_type_stream_body +} class ServerStreamingTests[F[_], S, OPTIONS, ROUTE](createServerTest: CreateServerTest[F, S, OPTIONS, ROUTE], streams: Streams[S])(implicit m: MonadError[F] @@ -60,6 +65,21 @@ class ServerStreamingTests[F[_], S, OPTIONS, ROUTE](createServerTest: CreateServ r.body shouldBe Right(penPineapple) r.contentType shouldBe Some(MediaType.ApplicationXml.toString()) } + }, + testServer(in_string_stream_out_either_stream_string(streams)) { + case ("left", s) => pureResult((Left(s): Either[streams.BinaryStream, String]).asRight[Unit]) + case _ => pureResult((Right("was not left"): Either[streams.BinaryStream, String]).asRight[Unit]) + } { (backend, baseUri) => + basicRequest + .post(uri"$baseUri?which=left") + .body(penPineapple) + .send(backend) + .map(_.body shouldBe Right(penPineapple)) >> + basicRequest + .post(uri"$baseUri?which=right") + .body(penPineapple) + .send(backend) + .map(_.body shouldBe Right("was not left")) } ) } diff --git a/tests/src/main/scala/sttp/tapir/tests/Streaming.scala b/tests/src/main/scala/sttp/tapir/tests/Streaming.scala index 9e16c0f308..86b36a9e6a 100644 --- a/tests/src/main/scala/sttp/tapir/tests/Streaming.scala +++ b/tests/src/main/scala/sttp/tapir/tests/Streaming.scala @@ -27,4 +27,20 @@ object Streaming { .out(header[String](HeaderNames.ContentType)) .out(sb) } + + def in_string_stream_out_either_stream_string[S]( + s: Streams[S] + ): PublicEndpoint[(String, s.BinaryStream), Unit, Either[s.BinaryStream, String], S] = { + val sb = streamTextBody(s)(CodecFormat.TextPlain(), Some(StandardCharsets.UTF_8)) + + endpoint.post + .in(query[String]("which")) + .in(sb) + .out( + oneOf( + oneOfVariantClassMatcher(sb.toEndpointIO.map(Left(_))(_.value), classOf[Left[s.BinaryStream, String]]), + oneOfVariantClassMatcher(stringBody.map(Right(_))(_.value), classOf[Right[s.BinaryStream, String]]) + ) + ) + } }