diff --git a/build.sbt b/build.sbt index 28847b5161..102f6765ef 100644 --- a/build.sbt +++ b/build.sbt @@ -162,6 +162,7 @@ lazy val rawAllAggregates = core.projectRefs ++ monixNewtype.projectRefs ++ zioPrelude.projectRefs ++ circeJson.projectRefs ++ + files.projectRefs ++ jsoniterScala.projectRefs ++ prometheusMetrics.projectRefs ++ opentelemetryMetrics.projectRefs ++ @@ -422,6 +423,19 @@ lazy val core: ProjectMatrix = (projectMatrix in file("core")) ) //.enablePlugins(spray.boilerplate.BoilerplatePlugin) +lazy val files: ProjectMatrix = (projectMatrix in file("files")) + .settings(commonJvmSettings) + .settings( + name := "tapir-files", + libraryDependencies ++= Seq( + scalaTest.value % Test + ) + ) + .jvmPlatform(scalaVersions = scala2And3Versions) + .jsPlatform(scalaVersions = scala2And3Versions) + .nativePlatform(scalaVersions = scala2And3Versions) + .dependsOn(core) + lazy val testing: ProjectMatrix = (projectMatrix in file("testing")) .settings(commonSettings) .settings( @@ -453,7 +467,7 @@ lazy val tests: ProjectMatrix = (projectMatrix in file("tests")) scalaVersions = scala2And3Versions, settings = commonNativeSettings ) - .dependsOn(core, circeJson, cats) + .dependsOn(core, files, circeJson, cats) val akkaHttpVanilla = taskKey[Unit]("akka-http-vanilla") val akkaHttpTapir = taskKey[Unit]("akka-http-tapir") @@ -1029,7 +1043,7 @@ lazy val swaggerUi: ProjectMatrix = (projectMatrix in file("docs/swagger-ui")) libraryDependencies ++= Seq("org.webjars" % "swagger-ui" % Versions.swaggerUi) ) .jvmPlatform(scalaVersions = scala2And3Versions) - .dependsOn(core) + .dependsOn(core, files) lazy val swaggerUiBundle: ProjectMatrix = (projectMatrix in file("docs/swagger-ui-bundle")) .settings(commonJvmSettings) 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 bff5986d99..7230ea8a01 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 @@ -1,7 +1,7 @@ package sttp.tapir.client.http4s import cats.Applicative -import cats.effect.Async +import cats.effect.{Async, Sync} import cats.implicits._ import fs2.Chunk import fs2.io.file.Files @@ -23,14 +23,16 @@ import sttp.tapir.{ EndpointInput, EndpointOutput, FileRange, + InputStreamRange, Mapping, RawBodyType, StreamBodyIO, WebSocketBodyOutput } -import java.io.{ByteArrayInputStream, InputStream} +import java.io.{InputStream} import java.nio.ByteBuffer +import java.io.ByteArrayInputStream private[http4s] class EndpointToHttp4sClient(clientOptions: Http4sClientOptions) { @@ -147,6 +149,9 @@ private[http4s] class EndpointToHttp4sClient(clientOptions: Http4sClientOptions) case RawBodyType.InputStreamBody => val entityEncoder = EntityEncoder.inputStreamEncoder[F, InputStream] req.withEntity(Applicative[F].pure(encoded.asInstanceOf[InputStream]))(entityEncoder) + case RawBodyType.InputStreamRangeBody => + val entityEncoder = EntityEncoder.inputStreamEncoder[F, InputStream] + req.withEntity(Sync[F].blocking(encoded.asInstanceOf[InputStreamRange].inputStream()))(entityEncoder) case RawBodyType.FileBody => val entityEncoder = EntityEncoder.fileEncoder[F] req.withEntity(encoded.asInstanceOf[FileRange].file)(entityEncoder) @@ -226,6 +231,12 @@ private[http4s] class EndpointToHttp4sClient(clientOptions: Http4sClientOptions) response.body.compile.toVector.map(_.toArray).map(java.nio.ByteBuffer.wrap).map(_.asInstanceOf[Any]) case RawBodyType.InputStreamBody => response.body.compile.toVector.map(_.toArray).map(new ByteArrayInputStream(_)).map(_.asInstanceOf[Any]) + case RawBodyType.InputStreamRangeBody => + response.body.compile.toVector + .map(_.toArray) + .map(new ByteArrayInputStream(_)) + .map(stream => InputStreamRange(() => stream)) + .map(_.asInstanceOf[Any]) case RawBodyType.FileBody => val file = clientOptions.createFile() response.body.through(Files[F].writeAll(file.toPath)).compile.drain.map(_ => FileRange(file)) 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 7f3392a7d9..b3e9f044de 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 @@ -18,6 +18,7 @@ import sttp.tapir.{ EndpointInput, EndpointOutput, FileRange, + InputStreamRange, Mapping, RawBodyType, StreamBodyIO, @@ -189,6 +190,9 @@ private[play] class EndpointToPlayClient(clientOptions: PlayClientOptions, ws: S // For some reason, Play comes with a Writeable for Supplier[InputStream] but not InputStream directly val inputStreamSupplier: Supplier[InputStream] = () => encoded.asInstanceOf[InputStream] req.withBody(inputStreamSupplier) + case RawBodyType.InputStreamRangeBody => + val inputStreamSupplier: Supplier[InputStream] = new Supplier[InputStream] { override def get(): InputStream = encoded.inputStream() } + req.withBody(inputStreamSupplier) case RawBodyType.FileBody => req.withBody(encoded.asInstanceOf[FileRange].file) case _: RawBodyType.MultipartBody => throw new IllegalArgumentException("Multipart body aren't supported") } @@ -224,6 +228,8 @@ private[play] class EndpointToPlayClient(clientOptions: PlayClientOptions, ws: S case RawBodyType.ByteArrayBody => response.body[Array[Byte]] case RawBodyType.ByteBufferBody => response.body[ByteBuffer] case RawBodyType.InputStreamBody => new ByteArrayInputStream(response.body[Array[Byte]]) + case RawBodyType.InputStreamRangeBody => + InputStreamRange(() => new ByteArrayInputStream(response.body[Array[Byte]])) case RawBodyType.FileBody => // TODO Consider using bodyAsSource to not load the whole content in memory val f = clientOptions.createFile() 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 779fb927dd..d4aa3e4079 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 @@ -162,11 +162,12 @@ private[sttp] class EndpointToSttpClient[R](clientOptions: SttpClientOptions, ws ): PartialAnyRequest = { val encoded = codec.encode(v) val req2 = bodyType match { - case RawBodyType.StringBody(charset) => req.body(encoded, charset.name()) - case RawBodyType.ByteArrayBody => req.body(encoded) - case RawBodyType.ByteBufferBody => req.body(encoded) - case RawBodyType.InputStreamBody => req.body(encoded) - case RawBodyType.FileBody => req.body(encoded.asInstanceOf[FileRange].file) + case RawBodyType.StringBody(charset) => req.body(encoded, charset.name()) + case RawBodyType.ByteArrayBody => req.body(encoded) + case RawBodyType.ByteBufferBody => req.body(encoded) + case RawBodyType.InputStreamBody => req.body(encoded) + case RawBodyType.FileBody => req.body(encoded.file) + case RawBodyType.InputStreamRangeBody => req.body(encoded.inputStream()) case m: RawBodyType.MultipartBody => val parts: Seq[Part[RequestBody[Any]]] = (encoded: Seq[RawPart]).flatMap { p => m.partType(p.name).map { partType => @@ -189,12 +190,13 @@ private[sttp] class EndpointToSttpClient[R](clientOptions: SttpClientOptions, ws private def partToSttpPart[T](p: Part[T], bodyType: RawBodyType[T]): Part[RequestBody[Any]] = bodyType match { - case RawBodyType.StringBody(charset) => multipart(p.name, p.body, charset.toString) - case RawBodyType.ByteArrayBody => multipart(p.name, p.body) - case RawBodyType.ByteBufferBody => multipart(p.name, p.body) - case RawBodyType.InputStreamBody => multipart(p.name, p.body) - case RawBodyType.FileBody => multipartFile(p.name, p.body.asInstanceOf[FileRange].file) - case RawBodyType.MultipartBody(_, _) => throw new IllegalArgumentException("Nested multipart bodies aren't supported") + case RawBodyType.StringBody(charset) => multipart(p.name, p.body, charset.toString) + case RawBodyType.ByteArrayBody => multipart(p.name, p.body) + case RawBodyType.ByteBufferBody => multipart(p.name, p.body) + case RawBodyType.InputStreamBody => multipart(p.name, p.body) + case RawBodyType.FileBody => multipartFile(p.name, p.body.asInstanceOf[FileRange].file) + case RawBodyType.InputStreamRangeBody => multipart(p.name, p.body.inputStream()) + case RawBodyType.MultipartBody(_, _) => throw new IllegalArgumentException("Nested multipart bodies aren't supported") } private def responseAsFromOutputs(out: EndpointOutput[_], isWebSocket: Boolean): ResponseAs[Any, Any] = { @@ -204,12 +206,13 @@ private[sttp] class EndpointToSttpClient[R](clientOptions: SttpClientOptions, ws case (None, false) => out.bodyType .map { - case RawBodyType.StringBody(charset) => asStringAlways(charset.name()) - case RawBodyType.ByteArrayBody => asByteArrayAlways - case RawBodyType.ByteBufferBody => asByteArrayAlways.map(ByteBuffer.wrap) - case RawBodyType.InputStreamBody => asByteArrayAlways.map(new ByteArrayInputStream(_)) - case RawBodyType.FileBody => asFileAlways(clientOptions.createFile()).map(d => FileRange(d)) - case RawBodyType.MultipartBody(_, _) => throw new IllegalArgumentException("Multipart bodies aren't supported in responses") + case RawBodyType.StringBody(charset) => asStringAlways(charset.name()) + case RawBodyType.ByteArrayBody => asByteArrayAlways + case RawBodyType.ByteBufferBody => asByteArrayAlways.map(ByteBuffer.wrap) + case RawBodyType.InputStreamBody => asByteArrayAlways.map(new ByteArrayInputStream(_)) + case RawBodyType.FileBody => asFileAlways(clientOptions.createFile()).map(d => FileRange(d)) + case RawBodyType.InputStreamRangeBody => asByteArrayAlways.map(b => InputStreamRange(() => new ByteArrayInputStream(b))) + case RawBodyType.MultipartBody(_, _) => throw new IllegalArgumentException("Multipart bodies aren't supported in responses") } .getOrElse(ignore) }).asInstanceOf[ResponseAs[Any, Any]] diff --git a/core/src/main/scala/sttp/tapir/Codec.scala b/core/src/main/scala/sttp/tapir/Codec.scala index 26a597ffdf..4926a163a3 100644 --- a/core/src/main/scala/sttp/tapir/Codec.scala +++ b/core/src/main/scala/sttp/tapir/Codec.scala @@ -232,6 +232,8 @@ object Codec extends CodecExtensions with CodecExtensions2 with FormCodecMacros id[Array[Byte], OctetStream](OctetStream(), Schema.schemaForByteArray) implicit val inputStream: Codec[InputStream, InputStream, OctetStream] = id[InputStream, OctetStream](OctetStream(), Schema.schemaForInputStream) + implicit val inputStreamRange: Codec[InputStreamRange, InputStreamRange, OctetStream] = + id[InputStreamRange, OctetStream](OctetStream(), Schema.schemaForInputStreamRange) implicit val byteBuffer: Codec[ByteBuffer, ByteBuffer, OctetStream] = id[ByteBuffer, OctetStream](OctetStream(), Schema.schemaForByteBuffer) implicit val fileRange: Codec[FileRange, FileRange, OctetStream] = @@ -735,6 +737,7 @@ object RawBodyType { case object ByteBufferBody extends Binary[ByteBuffer] case object InputStreamBody extends Binary[InputStream] case object FileBody extends Binary[FileRange] + case object InputStreamRangeBody extends Binary[InputStreamRange] case class MultipartBody(partTypes: Map[String, RawBodyType[_]], defaultType: Option[RawBodyType[_]]) extends RawBodyType[Seq[RawPart]] { def partType(name: String): Option[RawBodyType[_]] = partTypes.get(name).orElse(defaultType) diff --git a/core/src/main/scala/sttp/tapir/InputStreamRange.scala b/core/src/main/scala/sttp/tapir/InputStreamRange.scala new file mode 100644 index 0000000000..df3a30098e --- /dev/null +++ b/core/src/main/scala/sttp/tapir/InputStreamRange.scala @@ -0,0 +1,17 @@ +package sttp.tapir + +import java.io.InputStream + +case class InputStreamRange(inputStream: () => InputStream, range: Option[RangeValue] = None) { + def inputStreamFromRangeStart: () => InputStream = range.flatMap(_.start) match { + case Some(start) if start > 0 => + () => + val openedStream = inputStream() + val skipped = openedStream.skip(start) + if (skipped == start) + openedStream + else + throw new IllegalArgumentException(s"Illegal range start: $start, could skip only $skipped bytes") + case _ => inputStream + } +} diff --git a/core/src/main/scala/sttp/tapir/Schema.scala b/core/src/main/scala/sttp/tapir/Schema.scala index 3c440ac78b..0f77217421 100644 --- a/core/src/main/scala/sttp/tapir/Schema.scala +++ b/core/src/main/scala/sttp/tapir/Schema.scala @@ -276,6 +276,7 @@ object Schema extends LowPrioritySchema with SchemaCompanionMacros { implicit val schemaForByteArray: Schema[Array[Byte]] = Schema(SBinary()) implicit val schemaForByteBuffer: Schema[ByteBuffer] = Schema(SBinary()) implicit val schemaForInputStream: Schema[InputStream] = Schema(SBinary()) + implicit val schemaForInputStreamRange: Schema[InputStreamRange] = Schema(SchemaType.SBinary()) implicit val schemaForInstant: Schema[Instant] = Schema(SDateTime()) implicit val schemaForZonedDateTime: Schema[ZonedDateTime] = Schema(SDateTime()) implicit val schemaForOffsetDateTime: Schema[OffsetDateTime] = Schema(SDateTime()) diff --git a/core/src/main/scala/sttp/tapir/Tapir.scala b/core/src/main/scala/sttp/tapir/Tapir.scala index 6478963b99..3b4439c3ed 100644 --- a/core/src/main/scala/sttp/tapir/Tapir.scala +++ b/core/src/main/scala/sttp/tapir/Tapir.scala @@ -113,6 +113,7 @@ trait Tapir extends TapirExtensions with TapirComputedInputs with TapirStaticCon def byteArrayBody: EndpointIO.Body[Array[Byte], Array[Byte]] = rawBinaryBody(RawBodyType.ByteArrayBody) def byteBufferBody: EndpointIO.Body[ByteBuffer, ByteBuffer] = rawBinaryBody(RawBodyType.ByteBufferBody) def inputStreamBody: EndpointIO.Body[InputStream, InputStream] = rawBinaryBody(RawBodyType.InputStreamBody) + def inputStreamRangeBody: EndpointIO.Body[InputStreamRange, InputStreamRange] = rawBinaryBody(RawBodyType.InputStreamRangeBody) def fileRangeBody: EndpointIO.Body[FileRange, FileRange] = rawBinaryBody(RawBodyType.FileBody) def fileBody: EndpointIO.Body[FileRange, TapirFile] = rawBinaryBody(RawBodyType.FileBody).map(_.file)(d => FileRange(d)) diff --git a/core/src/main/scalajvm/sttp/tapir/static/Files.scala b/core/src/main/scalajvm/sttp/tapir/static/Files.scala index 4aee4391fb..d9caa4936c 100644 --- a/core/src/main/scalajvm/sttp/tapir/static/Files.scala +++ b/core/src/main/scalajvm/sttp/tapir/static/Files.scala @@ -11,6 +11,7 @@ import java.nio.file.{LinkOption, Path, Paths} import java.time.Instant import scala.annotation.tailrec +@deprecated("Use sttp.tapir.files.Files", since = "1.3.0") object Files { // inspired by org.http4s.server.staticcontent.FileService @@ -157,6 +158,7 @@ object Files { * path segments (relative to the system path from which files are read) of the file to return in case the one requested by the user * isn't found. This is useful for SPA apps, where the same main application file needs to be returned regardless of the path. */ +@deprecated("Use sttp.tapir.files.FilesOptions", since = "1.3.0") case class FilesOptions[F[_]]( calculateETag: MonadError[F] => File => F[Option[ETag]], fileFilter: List[String] => Boolean, diff --git a/core/src/main/scalajvm/sttp/tapir/static/Resources.scala b/core/src/main/scalajvm/sttp/tapir/static/Resources.scala index 4c537397db..45c8cebecc 100644 --- a/core/src/main/scalajvm/sttp/tapir/static/Resources.scala +++ b/core/src/main/scalajvm/sttp/tapir/static/Resources.scala @@ -8,6 +8,7 @@ import java.io.{File, FileNotFoundException, InputStream} import java.net.URL import java.time.Instant +@deprecated("Use sttp.tapir.files.Resources", since = "1.3.0") object Resources { def apply[F[_]: MonadError]( classLoader: ClassLoader, @@ -100,6 +101,7 @@ object Resources { * the user isn't found. This is useful for SPA apps, where the same main application resource needs to be returned regardless of the * path. */ +@deprecated("Use new files API and FileOptions, see sttp.tapir.files.Resources", since = "1.3.0") case class ResourcesOptions[F[_]]( useETags: Boolean, useGzippedIfAvailable: Boolean, diff --git a/core/src/main/scalajvm/sttp/tapir/static/TapirStaticContentEndpoints.scala b/core/src/main/scalajvm/sttp/tapir/static/TapirStaticContentEndpoints.scala index 05a5a8fdd2..fd079269ce 100644 --- a/core/src/main/scalajvm/sttp/tapir/static/TapirStaticContentEndpoints.scala +++ b/core/src/main/scalajvm/sttp/tapir/static/TapirStaticContentEndpoints.scala @@ -144,11 +144,18 @@ trait TapirStaticContentEndpoints { ) } + @deprecated("Use sttp.tapir.files.staticFilesGetEndpoint", since = "1.3.0") lazy val filesGetEndpoint: PublicEndpoint[StaticInput, StaticErrorOutput, StaticOutput[FileRange], Any] = staticGetEndpoint(fileRangeBody) + + @deprecated("Use sttp.tapir.files.staticResourcesGetEndpoint", since = "1.3.0") lazy val resourcesGetEndpoint: PublicEndpoint[StaticInput, StaticErrorOutput, StaticOutput[InputStream], Any] = staticGetEndpoint(inputStreamBody) + + @deprecated("Use sttp.tapir.files.staticFilesGetEndpoint", since = "1.3.0") def filesGetEndpoint(prefix: EndpointInput[Unit]): PublicEndpoint[StaticInput, StaticErrorOutput, StaticOutput[FileRange], Any] = filesGetEndpoint.prependIn(prefix) + + @deprecated("Use sttp.tapir.files.staticResourcesGetEndpoint", since = "1.3.0") def resourcesGetEndpoint(prefix: EndpointInput[Unit]): PublicEndpoint[StaticInput, StaticErrorOutput, StaticOutput[InputStream], Any] = resourcesGetEndpoint.prependIn(prefix) @@ -161,6 +168,7 @@ trait TapirStaticContentEndpoints { * * A request to `/static/files/css/styles.css` will try to read the `/home/app/static/css/styles.css` file. */ + @deprecated("Use sttp.tapir.files.staticFilesGetServerEndpoint", since = "1.3.0") def filesGetServerEndpoint[F[_]]( prefix: EndpointInput[Unit] )(systemPath: String, options: FilesOptions[F] = FilesOptions.default[F]): ServerEndpoint[Any, F] = @@ -169,6 +177,7 @@ trait TapirStaticContentEndpoints { /** A server endpoint, used to verify if sever supports range requests for file under particular path Additionally it verify file * existence and returns its size */ + @deprecated("Use sttp.tapir.files.staticFilesHeadServerEndpoint", since = "1.3.0") def filesHeadServerEndpoint[F[_]]( prefix: EndpointInput[Unit] )(systemPath: String, options: FilesOptions[F] = FilesOptions.default[F]): ServerEndpoint[Any, F] = @@ -183,6 +192,7 @@ trait TapirStaticContentEndpoints { * * A request to `/static/files/css/styles.css` will try to read the `/home/app/static/css/styles.css` file. */ + @deprecated("Use sttp.tapir.files.staticFilesServerEndpoints", since = "1.3.0") def filesServerEndpoints[F[_]]( prefix: EndpointInput[Unit] )(systemPath: String, options: FilesOptions[F] = FilesOptions.default[F]): List[ServerEndpoint[Any, F]] = @@ -194,6 +204,7 @@ trait TapirStaticContentEndpoints { * fileGetServerEndpoint("static" / "hello.html")("/home/app/static/data.html") * }}} */ + @deprecated("Use sttp.tapir.files.staticFileGetServerEndpoint", since = "1.3.0") def fileGetServerEndpoint[F[_]](prefix: EndpointInput[Unit])(systemPath: String): ServerEndpoint[Any, F] = ServerEndpoint.public(removePath(filesGetEndpoint(prefix)), (m: MonadError[F]) => Files.get(systemPath)(m)) @@ -206,6 +217,7 @@ trait TapirStaticContentEndpoints { * * A request to `/static/files/css/styles.css` will try to read the `/app/css/styles.css` resource. */ + @deprecated("Use sttp.tapir.files.staticResourcesGetServerEndpoint", since = "1.3.0") def resourcesGetServerEndpoint[F[_]](prefix: EndpointInput[Unit])( classLoader: ClassLoader, resourcePrefix: String, @@ -222,6 +234,7 @@ trait TapirStaticContentEndpoints { * resourceGetServerEndpoint("static" / "hello.html")(classOf[App].getClassLoader, "app/data.html") * }}} */ + @deprecated("Use sttp.tapir.files.staticResourceGetServerEndpoint", since = "1.3.0") def resourceGetServerEndpoint[F[_]](prefix: EndpointInput[Unit])( classLoader: ClassLoader, resourcePath: String, diff --git a/doc/endpoint/static.md b/doc/endpoint/static.md index da74f5930f..4fad5d3c9e 100644 --- a/doc/endpoint/static.md +++ b/doc/endpoint/static.md @@ -1,11 +1,23 @@ # Serving static content Tapir contains predefined endpoints, server logic and server endpoints which allow serving static content, originating -from local files or application resources. These endpoints respect etags as well as if-modified-since headers. +from local files or application resources. These endpoints respect etags, byte ranges as well as if-modified-since headers. + +```eval_rst +.. note:: + Since Tapir 1.3.0, static content is supported via the new `tapir-files` module. If you're looking for + the API documentation of the old static content API, switch documentation to an older version. +``` + +In order to use static content endpoints, add the module to your dependencies: + +```scala +"com.softwaremill.sttp.tapir" %% "tapir-files" % "@VERSION@" +``` ## Files -The easiest way to expose static content from the local filesystem is to use the `filesServerEndpoint`. This method +The easiest way to expose static content from the local filesystem is to use the `staticFilesServerEndpoint`. This method is parametrised with the path, at which the content should be exposed, as well as the local system path, from which to read the data. @@ -15,17 +27,18 @@ Such an endpoint has to be interpreted using your server interpreter. For exampl import akka.http.scaladsl.server.Route import sttp.tapir._ +import sttp.tapir.files._ import sttp.tapir.server.akkahttp.AkkaHttpServerInterpreter import scala.concurrent.Future import scala.concurrent.ExecutionContext.Implicits.global val filesRoute: Route = AkkaHttpServerInterpreter().toRoute( - filesGetServerEndpoint[Future]("site" / "static")("/home/static/data") + staticFilesGetServerEndpoint[Future]("site" / "static")("/home/static/data") ) ``` -Using the above endpoint, a request to `/site/static/css/styles.css` will try to read the +Using the above endpoint, a request to `/site/static/css/styles.css` will try to read the `/home/static/data/css/styles.css` file. To expose files without a prefix, use `emptyInput`. For example, using the [netty](../server/netty.md) interpreter, the @@ -33,52 +46,85 @@ below exposes the content of `/var/www` at `http://localhost:8080`: ```scala mdoc:compile-only import sttp.tapir.server.netty.NettyFutureServer -import sttp.tapir.{emptyInput, filesServerEndpoints} +import sttp.tapir.emptyInput +import sttp.tapir._ +import sttp.tapir.files._ import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future NettyFutureServer() .port(8080) - .addEndpoints(filesServerEndpoints[Future](emptyInput)("/var/www")) + .addEndpoint(staticFilesGetServerEndpoint[Future](emptyInput)("/var/www")) .start() .flatMap(_ => Future.never) ``` - -A single file can be exposed using `fileGetServerEndpoint`. +A single file can be exposed using `staticFileGetServerEndpoint`. +Similarly, you can expose HEAD endpoints with `staticFileHeadServerEndpoint` and `staticFilesHeadServerEndpoint`. +If you want to serve both GET and HEAD, use `staticFilesServerEndpoints`. The file server endpoints can be secured using `ServerLogic.prependSecurity`, see [server logic](../server/logic.md) for details. ## Resources -Similarly, the `resourcesGetServerEndpoint` can be used to expose the application's resources at the given prefix. +Similarly, the `staticResourcesGetServerEndpoint` can be used to expose the application's resources at the given prefix. + +A single resource can be exposed using `staticResourceGetServerEndpoint`. + +## FileOptions + +Endpoint constructor methods for files and resources can receive optional `FileOptions`, which allow to configure additional settings: -A single resource can be exposed using `resourceGetServerEndpoint`. +```scala mdoc:compile-only +import sttp.model.headers.ETag +import sttp.tapir.emptyInput +import sttp.tapir._ +import sttp.tapir.files._ + +import scala.concurrent.Future + +import java.net.URL + +val customETag: Option[RangeValue] => URL => Future[Option[ETag]] = ??? +val customFileFilter: List[String] => Boolean = ??? + +val options: FilesOptions[Future] = + FilesOptions + .default + // serves file.txt.gz instead of file.txt if available and Accept-Encoding contains "gzip" + .withUseGzippedIfAvailable + .calculateETag(customETag) + .fileFilter(customFileFilter) + .defaultFile(List("default.md")) + +val endpoint = staticFilesGetServerEndpoint(emptyInput)("/var/www", options) +``` ## Endpoint description and server logic -The descriptions of endpoints which should serve static data, and the server logic which implements the actual +The descriptions of endpoints which should serve static data, and the server logic which implements the actual file/resource reading are also available separately for further customisation. -The `filesGetEndpoint` and `resourcesGetEndpoint` are descriptions which contain the metadata (including caching headers) -required to serve a file or resource, and possible error outcomes. This is captured using the `StaticInput`, +The `staticFilesGetEndpoint` and `staticResourcesGetEndpoint` are descriptions which contain the metadata (including caching headers) +required to serve a file or resource, and possible error outcomes. This is captured using the `StaticInput`, `StaticErrorOuput` and `StaticOutput[T]` classes. -The `sttp.tapir.static.Files` and `sttp.tapir.static.Resources` objects contain the logic implementing server-side +The `sttp.tapir.files.Files` and `sttp.tapir.files.Resources` objects contain the logic implementing server-side reading of files or resources, with etag/last modification support. ## WebJars -The content of [WebJars](https://www.webjars.org) that are available on the classpath can be exposed using the +The content of [WebJars](https://www.webjars.org) that are available on the classpath can be exposed using the following routes (here using the `/resources` context path): ```scala mdoc:compile-only import sttp.tapir._ +import sttp.tapir.files._ import scala.concurrent.Future -val webJarRoutes = resourcesGetServerEndpoint[Future]("resources")( +val webJarRoutes = staticResourcesGetServerEndpoint[Future]("resources")( this.getClass.getClassLoader, "META-INF/resources/webjars") -``` \ No newline at end of file +``` diff --git a/doc/migrating.md b/doc/migrating.md index 8adb7c75fd..60a00ce13b 100644 --- a/doc/migrating.md +++ b/doc/migrating.md @@ -1,33 +1,38 @@ # Migrating +## From 1.2 to 1.3 + +- Static content endpoints from `sttp.tapir.static._` are deprecated in favor of the new `tapir-files` module. New methods are in `sttp.tapir.files._`: `staticFilesGetServerEndpoint`, `staticFilesHeadServerEndpoint`, `staticFilesServerEndpoints`, `staticResourcesGetServerEndpoint`, `staticResourcesHeadServerEndpoint`, `staticResourcesServerEndpoints`, etc. See the [updated documentation](endpoint/static.md). +- Respectively, use `sttp.tapir.files.FilesOptions` instead of `sttp.tapir.static.FilesOptions` + ## From 0.20 to 1.0 -* `EndpointVerifier` is moved to a separate `tapir-testing` module -* `customJsonBody` is renamed to `customCodecJsonBody` -* `anyFromStringBody` is renamed to `stringBodyAnyFormat` -* `anyFromUtf8StringBody` is renamed to `stringBodyUtf8AnyFormat` -* `CustomInterceptors` is renamed to `CustomiseInterceptors` as this better reflects the functionality of the class -* `CustomiseInterceptors.errorOutput` is renamed to `.defaultHandlers`, with additional options added. -* in custom server interpreters, the `RejectInterecptor` must be now disabled explicitly using `RejectInterceptor.disableWhenSingleEndpoint` when a single endpoint is being interpreted; the `ServerInterpreter` no longer knows about all endpoints, as it is now parametrised with a function which gives the potentially matching endpoints, given a `ServerRequest` -* the names of Prometheus and OpenTelemetry metrics have changed; there are now three metrics (requests active, total and duration), instead of the previous 4 (requests active, total, response total and duration). Moreover, the request duration metric includes an additional label - phase (either headers or body), measuring how long it takes to create the headers or the body. -* `CustomiseInterceptors.appendInterceptor` is replaced with `.addInterceptor`; `.prependInterceptor` and `.appendInterceptor` methods are also added -* `RequestHandler`, returned by `RequestInterceptor`, now also accepts a list of server endpoints. This allows to dynamically filter the endpoints. Moreover, there's a new type parameter in `RequestInterceptor` and `RequestHandler`, `R`, specifying the capabilities required by the given server endpoints. -* the http4s server interpreters have only one effect parameter, instead of two (`F` for the general effect and `G` for the body effect). This separation stopped making sense with the introduction of `BodyListener` some time ago and keeping `ServerInterpreter` using a single effect. -* the Swagger and Redoc UIs by default use relative paths for yaml/json documentation references and for redirects. This can be changed by passing appropriate options. -* The `streamBinaryBody` method now has a mandatory `format` parameter, which previously was fixed to be `CodecFormat.OctetStream()` +- `EndpointVerifier` is moved to a separate `tapir-testing` module +- `customJsonBody` is renamed to `customCodecJsonBody` +- `anyFromStringBody` is renamed to `stringBodyAnyFormat` +- `anyFromUtf8StringBody` is renamed to `stringBodyUtf8AnyFormat` +- `CustomInterceptors` is renamed to `CustomiseInterceptors` as this better reflects the functionality of the class +- `CustomiseInterceptors.errorOutput` is renamed to `.defaultHandlers`, with additional options added. +- in custom server interpreters, the `RejectInterecptor` must be now disabled explicitly using `RejectInterceptor.disableWhenSingleEndpoint` when a single endpoint is being interpreted; the `ServerInterpreter` no longer knows about all endpoints, as it is now parametrised with a function which gives the potentially matching endpoints, given a `ServerRequest` +- the names of Prometheus and OpenTelemetry metrics have changed; there are now three metrics (requests active, total and duration), instead of the previous 4 (requests active, total, response total and duration). Moreover, the request duration metric includes an additional label - phase (either headers or body), measuring how long it takes to create the headers or the body. +- `CustomiseInterceptors.appendInterceptor` is replaced with `.addInterceptor`; `.prependInterceptor` and `.appendInterceptor` methods are also added +- `RequestHandler`, returned by `RequestInterceptor`, now also accepts a list of server endpoints. This allows to dynamically filter the endpoints. Moreover, there's a new type parameter in `RequestInterceptor` and `RequestHandler`, `R`, specifying the capabilities required by the given server endpoints. +- the http4s server interpreters have only one effect parameter, instead of two (`F` for the general effect and `G` for the body effect). This separation stopped making sense with the introduction of `BodyListener` some time ago and keeping `ServerInterpreter` using a single effect. +- the Swagger and Redoc UIs by default use relative paths for yaml/json documentation references and for redirects. This can be changed by passing appropriate options. +- The `streamBinaryBody` method now has a mandatory `format` parameter, which previously was fixed to be `CodecFormat.OctetStream()` ### Moved traits, classes, objects -* server interpreters & interceptors have moved from `core` into the `server/core` module -* `ServerResponse` and `ValuedEndpointOutput` are moved to `sttp.tapir.server.model` -* metrics classes and interceptors have moved to the `sttp.tapir.server.metrics` package -* `Endpoint.renderPathTemplate` is renamed to `Endpoint.showPathTemplate` -* web socket exceptions `UnsupportedWebSocketFrameException` and `WebSocketFrameDecodeFailure` are now in the `sttp.tapir.model` package -* OpenAPI and AsyncAPI models are now part of a separate sttp-apispec project, hence the packages of these objects changed as well, from `sttp.tapir.apispec` / `sttp.tapir.openapi` / `sttp.tapir.asyncapi` to `sttp.tapir.apispec.(...)` -* server interpreters sources are now grouped based on the underlying server implementation (e.g. http4s, vertx), and then sub-directories contain effect integrations (e.g. cats, zio). Name templates: - * for artifacts: `tapir--server-`. E.g. `tapir-zio-http4s-server` became `tapir-http4s-server-zio1` - * for package names: `sttp.tapir.server..` - * for interpreters: `ServerInterpreter` +- server interpreters & interceptors have moved from `core` into the `server/core` module +- `ServerResponse` and `ValuedEndpointOutput` are moved to `sttp.tapir.server.model` +- metrics classes and interceptors have moved to the `sttp.tapir.server.metrics` package +- `Endpoint.renderPathTemplate` is renamed to `Endpoint.showPathTemplate` +- web socket exceptions `UnsupportedWebSocketFrameException` and `WebSocketFrameDecodeFailure` are now in the `sttp.tapir.model` package +- OpenAPI and AsyncAPI models are now part of a separate sttp-apispec project, hence the packages of these objects changed as well, from `sttp.tapir.apispec` / `sttp.tapir.openapi` / `sttp.tapir.asyncapi` to `sttp.tapir.apispec.(...)` +- server interpreters sources are now grouped based on the underlying server implementation (e.g. http4s, vertx), and then sub-directories contain effect integrations (e.g. cats, zio). Name templates: + - for artifacts: `tapir--server-`. E.g. `tapir-zio-http4s-server` became `tapir-http4s-server-zio1` + - for package names: `sttp.tapir.server..` + - for interpreters: `ServerInterpreter` ## From 0.19 to 0.20 @@ -39,4 +44,4 @@ See the [release notes](https://github.com/softwaremill/tapir/releases/tag/v0.19 ## From 0.17 to 0.18 -See the [release notes](https://github.com/softwaremill/tapir/releases/tag/v0.18.0) \ No newline at end of file +See the [release notes](https://github.com/softwaremill/tapir/releases/tag/v0.18.0) diff --git a/doc/stability.md b/doc/stability.md index 09d1408774..375be027bb 100644 --- a/doc/stability.md +++ b/doc/stability.md @@ -14,6 +14,7 @@ The modules are categorised using the following levels: | core (Scala 3) | stabilising | | server-core | stabilising | | client-core | stabilising | +| files | stabilising | ## Server interpreters diff --git a/files/src/main/scalajs/sttp/tapir/files/TapirStaticContentEndpoints.scala b/files/src/main/scalajs/sttp/tapir/files/TapirStaticContentEndpoints.scala new file mode 100644 index 0000000000..62279380aa --- /dev/null +++ b/files/src/main/scalajs/sttp/tapir/files/TapirStaticContentEndpoints.scala @@ -0,0 +1,3 @@ +package sttp.tapir.files + +trait TapirStaticContentEndpoints \ No newline at end of file diff --git a/files/src/main/scalajvm/sttp/tapir/files/Files.scala b/files/src/main/scalajvm/sttp/tapir/files/Files.scala new file mode 100644 index 0000000000..a5eab55c89 --- /dev/null +++ b/files/src/main/scalajvm/sttp/tapir/files/Files.scala @@ -0,0 +1,243 @@ +package sttp.tapir.files + +import sttp.model.ContentRangeUnits +import sttp.model.MediaType +import sttp.model.headers.ETag +import sttp.monad.MonadError +import sttp.monad.syntax._ +import sttp.tapir.FileRange +import sttp.tapir.RangeValue +import sttp.tapir.files.StaticInput + +import java.io.File +import java.net.URL +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.{Files => JFiles} +import java.time.Instant +import scala.annotation.tailrec + +object Files { + + def head[F[_]]( + systemPath: String, + options: FilesOptions[F] = FilesOptions.default[F] + ): MonadError[F] => StaticInput => F[Either[StaticErrorOutput, StaticOutput[Unit]]] = { implicit monad => filesInput => + get(systemPath, options)(monad)(filesInput) + .map(_.map(_.withoutBody)) + } + + def get[F[_]]( + systemPath: String, + options: FilesOptions[F] = FilesOptions.default[F] + ): MonadError[F] => StaticInput => F[Either[StaticErrorOutput, StaticOutput[FileRange]]] = { implicit monad => filesInput => + MonadError[F] + .blocking(Paths.get(systemPath).toRealPath()) + .flatMap(path => { + val resolveUrlFn: ResolveUrlFn = resolveSystemPathUrl(filesInput, options, path) + files(filesInput, options, resolveUrlFn, fileRangeFromUrl) + }) + } + + def defaultEtag[F[_]]: MonadError[F] => Option[RangeValue] => URL => F[Option[ETag]] = monad => { range => url => + monad.blocking { + val connection = url.openConnection() + val lastModified = connection.getLastModified + val length = connection.getContentLengthLong + Some(defaultETag(lastModified, range, length)) + } + } + + private def fileRangeFromUrl( + url: URL, + range: Option[RangeValue] + ): FileRange = FileRange( + new File(url.toURI), + range + ) + + /** Creates a function of type ResolveUrlFn, which is capable of taking a relative path as a list of string segments, and finding the + * actual full system path, considering additional parameters. For example, with a root system path of /home/user/files/ it can create a + * function which takies List("dir1", "dir2", "file.txt") and tries to resolve /home/user/files/dir1/dir2/file.txt into a Url. The final + * resolved file may also be resolved to a pre-gzipped sibling, an index.html file, or a default file given as a fallback, all depending + * on additional parameters. See also Resources.resolveResourceUrl for an equivalent of this function but for resources under a + * classloader. + * + * @param input + * request input parameters like path and headers, used together with options to apply filtering and look for possible pre-gzipped + * files if they are accepted + * @param options + * additional options of the endpoint, defining filtering rules and pre-gzipped file support + * @param systemPath + * the root system path where file resolution should happen + * @return + * a function which can be used in general file resolution logic. This function takes path segments and an optional default fallback + * path segments and tries to resolve the file, then returns its full Url. + */ + private def resolveSystemPathUrl[F[_]](input: StaticInput, options: FilesOptions[F], systemPath: Path): ResolveUrlFn = { + + @tailrec + def resolveRec(path: List[String], default: Option[List[String]]): Either[StaticErrorOutput, ResolvedUrl] = { + val resolved = path.foldLeft(systemPath)(_.resolve(_)) + val resolvedGzipped = resolveGzipSibling(resolved) + if (useGzippedIfAvailable(input, options) && JFiles.exists(resolvedGzipped, LinkOption.NOFOLLOW_LINKS)) { + val realRequestedPath = resolvedGzipped.toRealPath(LinkOption.NOFOLLOW_LINKS) + if (!realRequestedPath.startsWith(resolvedGzipped)) + LeftUrlNotFound + else + Right(ResolvedUrl(realRequestedPath.toUri.toURL, MediaType.ApplicationGzip, Some("gzip"))) + } else { + if (!JFiles.exists(resolved, LinkOption.NOFOLLOW_LINKS)) { + default match { + case Some(defaultPath) => resolveRec(defaultPath, None) + case None => LeftUrlNotFound + } + } else { + val realRequestedPath = resolved.toRealPath(LinkOption.NOFOLLOW_LINKS) + + if (!realRequestedPath.startsWith(systemPath)) + LeftUrlNotFound + else if (realRequestedPath.toFile.isDirectory) { + resolveRec(path :+ "index.html", default) + } else { + Right(ResolvedUrl(realRequestedPath.toUri.toURL, contentTypeFromName(realRequestedPath.getFileName.toString), None)) + } + } + } + } + + if (!options.fileFilter(input.path)) + (_, _) => LeftUrlNotFound + else + resolveRec _ + } + + private[files] def files[F[_], R]( + input: StaticInput, + options: FilesOptions[F], + resolveUrlFn: ResolveUrlFn, + urlToResultFn: (URL, Option[RangeValue]) => R + )(implicit + m: MonadError[F] + ): F[Either[StaticErrorOutput, StaticOutput[R]]] = { + m.flatten(m.blocking { + resolveUrlFn(input.path, options.defaultFile) match { + case Left(error) => + (Left(error): Either[StaticErrorOutput, StaticOutput[R]]).unit + case Right(ResolvedUrl(url, contentType, contentEncoding)) => + input.range match { + case Some(range) => + val fileSize = url.openConnection().getContentLengthLong() + if (range.isValid(fileSize)) { + val rangeValue = RangeValue(range.start, range.end, fileSize) + rangeFileOutput(input, url, options.calculateETag(m)(Some(rangeValue)), rangeValue, contentType, urlToResultFn) + .map(Right(_)) + } else (Left(StaticErrorOutput.RangeNotSatisfiable): Either[StaticErrorOutput, StaticOutput[R]]).unit + case None => + wholeFileOutput(input, url, options.calculateETag(m)(None), contentType, contentEncoding, urlToResultFn).map(Right(_)) + } + } + }) + } + + private def resolveGzipSibling(path: Path): Path = + path.resolveSibling(path.getFileName.toString + ".gz") + + private def rangeFileOutput[F[_], R]( + filesInput: StaticInput, + url: URL, + calculateETag: URL => F[Option[ETag]], + range: RangeValue, + contentType: MediaType, + urlToResult: (URL, Option[RangeValue]) => R + )(implicit + m: MonadError[F] + ): F[StaticOutput[R]] = + fileOutput( + filesInput, + url, + calculateETag, + (lastModified, _, etag) => + StaticOutput.FoundPartial( + urlToResult(url, Some(range)), + Some(Instant.ofEpochMilli(lastModified)), + Some(range.contentLength), + Some(contentType), + etag, + Some(ContentRangeUnits.Bytes), + Some(range.toContentRange.toString()) + ) + ) + + private def wholeFileOutput[F[_], R]( + filesInput: StaticInput, + url: URL, + calculateETag: URL => F[Option[ETag]], + contentType: MediaType, + contentEncoding: Option[String], + urlToResult: (URL, Option[RangeValue]) => R + )(implicit + m: MonadError[F] + ): F[StaticOutput[R]] = fileOutput( + filesInput, + url, + calculateETag, + (lastModified, fileLength, etag) => + StaticOutput.Found( + urlToResult(url, None), + Some(Instant.ofEpochMilli(lastModified)), + Some(fileLength), + Some(contentType), + etag, + Some(ContentRangeUnits.Bytes), + contentEncoding + ) + ) + + private def fileOutput[F[_], R]( + filesInput: StaticInput, + url: URL, + calculateETag: URL => F[Option[ETag]], + result: (Long, Long, Option[ETag]) => StaticOutput[R] + )(implicit + m: MonadError[F] + ): F[StaticOutput[R]] = + for { + etagOpt <- calculateETag(url) + urlConnection <- m.blocking(url.openConnection()) + lastModified <- m.blocking(urlConnection.getLastModified()) + resourceResult <- + if (isModified(filesInput, etagOpt, lastModified)) + m.blocking(urlConnection.getContentLengthLong).map(fileLength => result(lastModified, fileLength, etagOpt)) + else StaticOutput.NotModified.unit + } yield resourceResult +} + +/** @param fileFilter + * A file will be exposed only if this function returns `true`. + * @param defaultFile + * path segments (relative to the system path from which files are read) of the file to return in case the one requested by the user + * isn't found. This is useful for SPA apps, where the same main application file needs to be returned regardless of the path. + */ +case class FilesOptions[F[_]]( + calculateETag: MonadError[F] => Option[RangeValue] => URL => F[Option[ETag]], + fileFilter: List[String] => Boolean, + useGzippedIfAvailable: Boolean = false, + defaultFile: Option[List[String]] +) { + def withUseGzippedIfAvailable: FilesOptions[F] = copy(useGzippedIfAvailable = true) + + def calculateETag(f: Option[RangeValue] => URL => F[Option[ETag]]): FilesOptions[F] = copy(calculateETag = _ => f) + + /** A file will be exposed only if this function returns `true`. */ + def fileFilter(f: List[String] => Boolean): FilesOptions[F] = copy(fileFilter = f) + + /** Path segments (relative to the system path from which files are read) of the file to return in case the one requested by the user + * isn't found. This is useful for SPA apps, where the same main application file needs to be returned regardless of the path. + */ + def defaultFile(d: List[String]): FilesOptions[F] = copy(defaultFile = Some(d)) +} +object FilesOptions { + def default[F[_]]: FilesOptions[F] = FilesOptions(Files.defaultEtag, _ => true, useGzippedIfAvailable = false, None) +} diff --git a/files/src/main/scalajvm/sttp/tapir/files/Resources.scala b/files/src/main/scalajvm/sttp/tapir/files/Resources.scala new file mode 100644 index 0000000000..fb5de1b891 --- /dev/null +++ b/files/src/main/scalajvm/sttp/tapir/files/Resources.scala @@ -0,0 +1,103 @@ +package sttp.tapir.files + +import sttp.model.MediaType +import sttp.monad.MonadError +import sttp.monad.syntax._ + +import java.io.File +import sttp.tapir.InputStreamRange +import sttp.tapir.RangeValue +import java.net.URL + +import sttp.tapir.files.StaticInput +import Files._ + +object Resources { + + def head[F[_]]( + classLoader: ClassLoader, + resourcePrefix: String, + options: FilesOptions[F] = FilesOptions.default[F] + ): MonadError[F] => StaticInput => F[Either[StaticErrorOutput, StaticOutput[Unit]]] = { implicit monad => filesInput => + get(classLoader, resourcePrefix, options)(monad)(filesInput) + .map(_.map(_.withoutBody)) + } + + def get[F[_]]( + classLoader: ClassLoader, + resourcePrefix: String, + options: FilesOptions[F] = FilesOptions.default[F] + ): MonadError[F] => StaticInput => F[Either[StaticErrorOutput, StaticOutput[InputStreamRange]]] = { implicit monad => filesInput => + val resolveUrlFn: ResolveUrlFn = resolveResourceUrl(classLoader, resourcePrefix.split("/").toList, filesInput, options) + files(filesInput, options, resolveUrlFn, resourceRangeFromUrl) + } + + private def resourceRangeFromUrl( + url: URL, + range: Option[RangeValue] + ): InputStreamRange = InputStreamRange( + () => url.openStream(), + range + ) + + /** Creates a function of type ResolveUrlFn, which is capable of taking a relative path as a list of string segments, and finding the + * actual full Url of a file available as a resource under a classLoader, considering additional parameters. For example, with a root + * resource prefix of /config/files/ it can create a function which takies List("dir1", "dir2", "file.txt") and tries to resolve + * /config/files/dir1/dir2/file.txt into a resource Url. The final resolved file may also be resolved to a pre-gzipped sibling, an + * index.html file, or a default file given as a fallback, all depending on additional parameters. See also Files.resolveSystemPathUrl + * for an equivalent of this function but for files from the filesystem. + * + * @param classLoader + * the class loader that will be used to call .getResource + * @param resourcePrefix + * root resource path prefix represented as string segments + * @param input + * request input parameters like path and headers, used together with options to apply filtering and look for possible pre-gzipped + * files if they are accepted + * @param options + * additional options of the endpoint, defining filtering rules and pre-gzipped file support + * @return + * a function which can be used in general file resolution logic. This function takes path segments and an optional default fallback + * path segments and tries to resolve the file, then returns its full Url. + */ + private def resolveResourceUrl[F[_]]( + classLoader: ClassLoader, + resourcePrefix: List[String], + input: StaticInput, + options: FilesOptions[F] + ): ResolveUrlFn = { + + val nameComponents = resourcePrefix ++ input.path + + def resolveRec(path: List[String], default: Option[List[String]]): Option[ResolvedUrl] = { + val name = (resourcePrefix ++ path).mkString("/") + val resultOpt = (if (useGzippedIfAvailable(input, options)) + Option(classLoader.getResource(name + ".gz")).map(ResolvedUrl(_, MediaType.ApplicationGzip, Some("gzip"))) + else None) + .orElse(Option(classLoader.getResource(name)).map(ResolvedUrl(_, contentTypeFromName(name), None))) + .orElse(default match { + case None => None + case Some(defaultPath) => + resolveRec(path = defaultPath, default = None) + }) + // making sure that the resulting path contains the original requested path + .filter(_.url.toURI.toString.contains(resourcePrefix.mkString("/"))) + + if (resultOpt.exists(r => isDirectory(classLoader, name, r.url))) + resolveRec(path :+ "index.html", default) + else resultOpt + } + + if (!options.fileFilter(nameComponents)) + (_, _) => LeftUrlNotFound + else + Function.untupled((resolveRec _).tupled.andThen(_.map(Right(_)).getOrElse(LeftUrlNotFound))) + } + + private def isDirectory(classLoader: ClassLoader, name: String, nameResource: URL): Boolean = { + // https://stackoverflow.com/questions/20105554/is-there-a-way-to-tell-if-a-classpath-resource-is-a-file-or-a-directory + if (nameResource.getProtocol == "file") new File(nameResource.getPath).isDirectory + else classLoader.getResource(name + "/") != null + } + +} diff --git a/files/src/main/scalajvm/sttp/tapir/files/TapirStaticContentEndpoints.scala b/files/src/main/scalajvm/sttp/tapir/files/TapirStaticContentEndpoints.scala new file mode 100644 index 0000000000..5697ae6418 --- /dev/null +++ b/files/src/main/scalajvm/sttp/tapir/files/TapirStaticContentEndpoints.scala @@ -0,0 +1,247 @@ +package sttp.tapir.files + +import sttp.model.Header +import sttp.model.HeaderNames +import sttp.model.MediaType +import sttp.model.StatusCode +import sttp.model.headers.ETag +import sttp.model.headers.Range +import sttp.monad.MonadError +import sttp.tapir.CodecFormat.OctetStream +import sttp.tapir.FileRange +import sttp.tapir._ +import sttp.tapir.files.FilesOptions +import sttp.tapir.server.ServerEndpoint + +import java.time.Instant +import sttp.tapir.files.StaticInput + +/** Static content endpoints, including files and resources. */ +trait TapirStaticContentEndpoints { + // we can't use oneOfVariant and mapTo, since mapTo doesn't work with body fields of type T + + private val pathsWithoutDots: EndpointInput[List[String]] = + paths.mapDecode(ps => + // a single path segment might contain / as well + if (ps.exists(p => p == "" || p == "." || p == ".." || p.startsWith("../") || p.endsWith("/..") || p.contains("/../"))) + DecodeResult.Error(ps.mkString("/"), new RuntimeException(s"Incorrect path: ${ps.mkString("/")}")) + else DecodeResult.Value(ps) + )(identity) + + private val ifNoneMatchHeader: EndpointIO[Option[List[ETag]]] = + header[Option[String]](HeaderNames.IfNoneMatch).mapDecode[Option[List[ETag]]] { + case None => DecodeResult.Value(None) + case Some(h) => DecodeResult.fromEitherString(h, ETag.parseList(h)).map(Some(_)) + }(_.map(es => ETag.toString(es))) + + private def optionalHttpDateHeader(headerName: String): EndpointIO[Option[Instant]] = + header[Option[String]](headerName).mapDecode[Option[Instant]] { + case None => DecodeResult.Value(None) + case Some(v) => DecodeResult.fromEitherString(v, Header.parseHttpDate(v)).map(Some(_)) + }(_.map(Header.toHttpDateString)) + + private val ifModifiedSinceHeader: EndpointIO[Option[Instant]] = optionalHttpDateHeader(HeaderNames.IfModifiedSince) + private val lastModifiedHeader: EndpointIO[Option[Instant]] = optionalHttpDateHeader(HeaderNames.LastModified) + private val contentTypeHeader: EndpointIO[Option[MediaType]] = header[Option[MediaType]](HeaderNames.ContentType) + private def contentLengthHeader: EndpointIO[Option[Long]] = header[Option[Long]](HeaderNames.ContentLength) + private val etagHeader: EndpointIO[Option[ETag]] = header[Option[ETag]](HeaderNames.Etag) + private val rangeHeader: EndpointIO[Option[Range]] = header[Option[Range]](HeaderNames.Range) + private def acceptRangesHeader: EndpointIO[Option[String]] = header[Option[String]](HeaderNames.AcceptRanges) + private val acceptEncodingHeader: EndpointIO[Option[String]] = header[Option[String]](HeaderNames.AcceptEncoding) + private val contentEncodingHeader: EndpointIO[Option[String]] = header[Option[String]](HeaderNames.ContentEncoding) + + private def staticEndpoint[T]( + method: Endpoint[Unit, Unit, Unit, Unit, Any], + body: EndpointOutput[T] + ): PublicEndpoint[StaticInput, StaticErrorOutput, StaticOutput[T], Any] = { + method + .in( + pathsWithoutDots + .and(ifNoneMatchHeader) + .and(ifModifiedSinceHeader) + .and(rangeHeader) + .and(acceptEncodingHeader) + .mapTo[StaticInput] + ) + .errorOut( + oneOf[StaticErrorOutput]( + oneOfVariantClassMatcher( + StatusCode.NotFound, + emptyOutputAs(StaticErrorOutput.NotFound), + StaticErrorOutput.NotFound.getClass + ), + oneOfVariantClassMatcher( + StatusCode.BadRequest, + emptyOutputAs(StaticErrorOutput.BadRequest), + StaticErrorOutput.BadRequest.getClass + ), + oneOfVariantClassMatcher( + StatusCode.RangeNotSatisfiable, + emptyOutputAs(StaticErrorOutput.RangeNotSatisfiable), + StaticErrorOutput.RangeNotSatisfiable.getClass + ) + ) + ) + .out( + oneOf[StaticOutput[T]]( + oneOfVariantClassMatcher(StatusCode.NotModified, emptyOutputAs(StaticOutput.NotModified), StaticOutput.NotModified.getClass), + oneOfVariantClassMatcher( + StatusCode.PartialContent, + body + .and(lastModifiedHeader) + .and(contentLengthHeader) + .and(contentTypeHeader) + .and(etagHeader) + .and(acceptRangesHeader) + .and(header[Option[String]](HeaderNames.ContentRange)) + .map[StaticOutput.FoundPartial[T]]( + (t: (T, Option[Instant], Option[Long], Option[MediaType], Option[ETag], Option[String], Option[String])) => + StaticOutput.FoundPartial(t._1, t._2, t._3, t._4, t._5, t._6, t._7) + )(fo => (fo.body, fo.lastModified, fo.contentLength, fo.contentType, fo.etag, fo.acceptRanges, fo.contentRange)), + classOf[StaticOutput.FoundPartial[T]] + ), + oneOfVariantClassMatcher( + StatusCode.Ok, + body + .and(lastModifiedHeader) + .and(contentLengthHeader) + .and(contentTypeHeader) + .and(etagHeader) + .and(acceptRangesHeader) + .and(contentEncodingHeader) + .map[StaticOutput.Found[T]]( + (t: (T, Option[Instant], Option[Long], Option[MediaType], Option[ETag], Option[String], Option[String])) => + StaticOutput.Found(t._1, t._2, t._3, t._4, t._5, t._6, t._7) + )(fo => (fo.body, fo.lastModified, fo.contentLength, fo.contentType, fo.etag, fo.acceptRanges, fo.contentEncoding)), + classOf[StaticOutput.Found[T]] + ) + ) + ) + } + + private lazy val staticHeadEndpoint: PublicEndpoint[StaticInput, StaticErrorOutput, StaticOutput[Unit], Any] = + staticEndpoint(endpoint.head, emptyOutput) + + lazy val staticFilesGetEndpoint: PublicEndpoint[StaticInput, StaticErrorOutput, StaticOutput[FileRange], Any] = staticEndpoint( + endpoint.get, + fileRangeBody + ) + + lazy val staticResourcesGetEndpoint: PublicEndpoint[StaticInput, StaticErrorOutput, StaticOutput[InputStreamRange], Any] = + staticEndpoint(endpoint.get, inputStreamRangeBody) + + def staticFilesGetEndpoint(prefix: EndpointInput[Unit]): PublicEndpoint[StaticInput, StaticErrorOutput, StaticOutput[FileRange], Any] = + staticFilesGetEndpoint.prependIn(prefix) + + def staticResourcesGetEndpoint( + prefix: EndpointInput[Unit] + ): PublicEndpoint[StaticInput, StaticErrorOutput, StaticOutput[InputStreamRange], Any] = + staticResourcesGetEndpoint.prependIn(prefix) + + /** A server endpoint, which exposes files from local storage found at `systemPath`, using the given `prefix`. Typically, the prefix is a + * path, but it can also contain other inputs. For example: + * + * {{{ + * staticFilesGetServerEndpoint("static" / "files")("/home/app/static") + * }}} + * + * A request to `/static/files/css/styles.css` will try to read the `/home/app/static/css/styles.css` file. + */ + def staticFilesGetServerEndpoint[F[_]]( + prefix: EndpointInput[Unit] + )(systemPath: String, options: FilesOptions[F] = FilesOptions.default[F]): ServerEndpoint[Any, F] = + ServerEndpoint.public(staticFilesGetEndpoint.prependIn(prefix), Files.get(systemPath, options)) + + /** A server endpoint, which exposes a single file from local storage found at `systemPath`, using the given `path`. + * + * {{{ + * staticFileGetServerEndpoint("static" / "hello.html")("/home/app/static/data.html") + * }}} + */ + def staticFileGetServerEndpoint[F[_]](prefix: EndpointInput[Unit])(systemPath: String): ServerEndpoint[Any, F] = + ServerEndpoint.public(removePath(staticFilesGetEndpoint(prefix)), (m: MonadError[F]) => Files.get(systemPath)(m)) + + /** A server endpoint, used to verify if sever supports range requests for file under particular path Additionally it verify file + * existence and returns its size + */ + def staticFilesHeadServerEndpoint[F[_]]( + prefix: EndpointInput[Unit] + )(systemPath: String, options: FilesOptions[F] = FilesOptions.default[F]): ServerEndpoint[Any, F] = + ServerEndpoint.public(staticHeadEndpoint.prependIn(prefix), Files.head(systemPath, options)) + + /** Create a pair of endpoints (head, get) for exposing files from local storage found at `systemPath`, using the given `prefix`. + * Typically, the prefix is a path, but it can also contain other inputs. For example: + * + * {{{ + * staticFilesServerEndpoints("static" / "files")("/home/app/static") + * }}} + * + * A request to `/static/files/css/styles.css` will try to read the `/home/app/static/css/styles.css` file. + */ + def staticFilesServerEndpoints[F[_]]( + prefix: EndpointInput[Unit] + )(systemPath: String, options: FilesOptions[F] = FilesOptions.default[F]): List[ServerEndpoint[Any, F]] = + List(staticFilesHeadServerEndpoint(prefix)(systemPath, options), staticFilesGetServerEndpoint(prefix)(systemPath, options)) + + /** A server endpoint, which exposes resources available from the given `classLoader`, using the given `prefix`. Typically, the prefix is + * a path, but it can also contain other inputs. For example: + * + * {{{ + * staticResourcesGetServerEndpoint("static" / "files")(classOf[App].getClassLoader, "app") + * }}} + * + * A request to `/static/files/css/styles.css` will try to read the `/app/css/styles.css` resource. + */ + def staticResourcesGetServerEndpoint[F[_]](prefix: EndpointInput[Unit])( + classLoader: ClassLoader, + resourcePrefix: String, + options: FilesOptions[F] = FilesOptions.default[F] + ): ServerEndpoint[Any, F] = + ServerEndpoint.public[StaticInput, StaticErrorOutput, StaticOutput[InputStreamRange], Any, F]( + staticResourcesGetEndpoint(prefix), + (m: MonadError[F]) => Resources.get(classLoader, resourcePrefix, options)(m) + ) + + /** A server endpoint, which exposes a single resource available from the given `classLoader` at `resourcePath`, using the given `path`. + * + * {{{ + * staticResourceGetServerEndpoint("static" / "hello.html")(classOf[App].getClassLoader, "app/data.html") + * }}} + */ + def staticResourceGetServerEndpoint[F[_]](prefix: EndpointInput[Unit])( + classLoader: ClassLoader, + resourcePath: String, + options: FilesOptions[F] = FilesOptions.default[F] + ): ServerEndpoint[Any, F] = + ServerEndpoint.public( + removePath(staticResourcesGetEndpoint(prefix)), + (m: MonadError[F]) => Resources.get(classLoader, resourcePath, options)(m) + ) + + /** A server endpoint, which can be used to verify the existence of a resource under given path. + */ + def staticResourcesHeadServerEndpoint[F[_]]( + prefix: EndpointInput[Unit] + )(classLoader: ClassLoader, resourcePath: String, options: FilesOptions[F] = FilesOptions.default[F]): ServerEndpoint[Any, F] = + ServerEndpoint.public(staticHeadEndpoint.prependIn(prefix), Resources.head(classLoader, resourcePath, options)) + + private def removePath[T](e: Endpoint[Unit, StaticInput, StaticErrorOutput, StaticOutput[T], Any]) = + e.mapIn(i => i.copy(path = Nil))(i => i.copy(path = Nil)) + + /** Create a pair of endpoints (head, get) for exposing resources available from the given `classLoader`, using the given `prefix`. + * Typically, the prefix is a path, but it can also contain other inputs. For example: + * + * {{{ + * resourcesServerEndpoints("static" / "files")(classOf[App].getClassLoader, "app") + * }}} + * + * A request to `/static/files/css/styles.css` will try to read the `/app/css/styles.css` resource. + */ + def staticResourcesServerEndpoints[F[_]]( + prefix: EndpointInput[Unit] + )(classLoader: ClassLoader, resourcePath: String, options: FilesOptions[F] = FilesOptions.default[F]): List[ServerEndpoint[Any, F]] = + List( + staticResourcesHeadServerEndpoint(prefix)(classLoader, resourcePath, options), + staticResourcesGetServerEndpoint(prefix)(classLoader, resourcePath, options) + ) +} diff --git a/files/src/main/scalajvm/sttp/tapir/files/model.scala b/files/src/main/scalajvm/sttp/tapir/files/model.scala new file mode 100644 index 0000000000..c9bbaf5428 --- /dev/null +++ b/files/src/main/scalajvm/sttp/tapir/files/model.scala @@ -0,0 +1,60 @@ +package sttp.tapir.files + +import sttp.model.MediaType +import sttp.model.headers.{ETag, Range} + +import java.net.URL +import java.time.Instant + +private[tapir] case class ResolvedUrl(url: URL, mediaType: MediaType, contentEncoding: Option[String]) + +case class StaticInput( + path: List[String], + ifNoneMatch: Option[List[ETag]], + ifModifiedSince: Option[Instant], + range: Option[Range], + acceptEncoding: Option[String] +) { + + def acceptGzip: Boolean = acceptEncoding.contains("gzip") +} + +trait StaticErrorOutput +object StaticErrorOutput { + case object NotFound extends StaticErrorOutput + case object BadRequest extends StaticErrorOutput + case object RangeNotSatisfiable extends StaticErrorOutput +} + +sealed trait StaticOutput[+T] { + def withoutBody: StaticOutput[Unit] = + this match { + case StaticOutput.NotModified => StaticOutput.NotModified + case o: StaticOutput.FoundPartial[T] => + o.copy(body = ()) + case o: StaticOutput.Found[T] => + o.copy(body = ()) + } +} + +object StaticOutput { + case object NotModified extends StaticOutput[Nothing] + case class FoundPartial[T]( + body: T, + lastModified: Option[Instant], + contentLength: Option[Long], + contentType: Option[MediaType], + etag: Option[ETag], + acceptRanges: Option[String], + contentRange: Option[String] + ) extends StaticOutput[T] + case class Found[T]( + body: T, + lastModified: Option[Instant], + contentLength: Option[Long], + contentType: Option[MediaType], + etag: Option[ETag], + acceptRanges: Option[String], + contentEncoding: Option[String] + ) extends StaticOutput[T] +} diff --git a/files/src/main/scalajvm/sttp/tapir/files/package.scala b/files/src/main/scalajvm/sttp/tapir/files/package.scala new file mode 100644 index 0000000000..49a2a89af1 --- /dev/null +++ b/files/src/main/scalajvm/sttp/tapir/files/package.scala @@ -0,0 +1,44 @@ +package sttp.tapir + +import sttp.model.MediaType +import sttp.model.headers.ETag +import sttp.tapir.internal.MimeByExtensionDB + +package object files extends TapirStaticContentEndpoints { + def defaultETag(lastModified: Long, range: Option[RangeValue], length: Long): ETag = { + val rangeSuffix = range.flatMap(_.startAndEnd).map { case (start, end) => s"-${start.toHexString}-${end.toHexString}" }.getOrElse("") + ETag(s"${lastModified.toHexString}-${length.toHexString}$rangeSuffix") + } + + private[tapir] def isModified(staticInput: StaticInput, etag: Option[ETag], lastModified: Long): Boolean = { + etag match { + case None => isModifiedByModifiedSince(staticInput, lastModified) + case Some(et) => + val ifNoneMatch = staticInput.ifNoneMatch.getOrElse(Nil) + if (ifNoneMatch.nonEmpty) ifNoneMatch.forall(e => e.tag != et.tag) + else true + } + } + + private[tapir] def isModifiedByModifiedSince(staticInput: StaticInput, lastModified: Long): Boolean = staticInput.ifModifiedSince match { + case Some(i) => lastModified > i.toEpochMilli + case None => true + } + + private[tapir] def contentTypeFromName(name: String): MediaType = { + val ext = name.substring(name.lastIndexOf(".") + 1) + MimeByExtensionDB(ext).getOrElse(MediaType.ApplicationOctetStream) + } + + // Asking for range implies Transfer-Encoding instead of Content-Encoding, because the byte range has to be compressed individually + // Therefore we cannot take the preGzipped file in this case + private[tapir] def useGzippedIfAvailable[F[_]]( + input: StaticInput, + options: FilesOptions[F] + ): Boolean = + input.range.isEmpty && options.useGzippedIfAvailable && input.acceptGzip + + private[tapir] def LeftUrlNotFound = Left(StaticErrorOutput.NotFound): Either[StaticErrorOutput, ResolvedUrl] + + private[tapir] type ResolveUrlFn = (List[String], Option[List[String]]) => Either[StaticErrorOutput, ResolvedUrl] +} diff --git a/files/src/main/scalanative/sttp/tapir/files/TapirStaticContentEndpoints.scala b/files/src/main/scalanative/sttp/tapir/files/TapirStaticContentEndpoints.scala new file mode 100644 index 0000000000..62279380aa --- /dev/null +++ b/files/src/main/scalanative/sttp/tapir/files/TapirStaticContentEndpoints.scala @@ -0,0 +1,3 @@ +package sttp.tapir.files + +trait TapirStaticContentEndpoints \ No newline at end of file diff --git a/files/src/test/scalajvm/sttp/tapir/FilesSpec.scala b/files/src/test/scalajvm/sttp/tapir/FilesSpec.scala new file mode 100644 index 0000000000..87c30fa6b4 --- /dev/null +++ b/files/src/test/scalajvm/sttp/tapir/FilesSpec.scala @@ -0,0 +1,16 @@ +package sttp.tapir + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.model.headers.ETag + +class FilesSpec extends AnyFlatSpec with Matchers { + + "Default Etag" should "be calulated with lastModified and length and RangeValue" in { + files.defaultETag(lastModified = 1682079650000L, length = 3489500L, range = None) shouldBe ETag("187a3c290d0-353edc", weak = false) + files.defaultETag(lastModified = 769523317000L, length = 2L, range = None) shouldBe ETag("b32b29f908-2", weak = false) + files.defaultETag(lastModified = 769523317000L, length = 2L, range = Some(RangeValue(start = None, end = None, fileSize = 2L))) shouldBe ETag("b32b29f908-2", weak = false) + files.defaultETag(lastModified = 769523317000L, length = 2000L, range = Some(RangeValue(start = Some(100L), end = None, fileSize = 2000L))) shouldBe ETag("b32b29f908-7d0-64-7d0", weak = false) + files.defaultETag(lastModified = 769590203000L, length = 10485760L, range = Some(RangeValue(start = None, end = Some(3178474L), fileSize = 3178474L))) shouldBe ETag("b32f269278-a00000-0-307fea", weak = false) + } +} diff --git a/server/akka-grpc-server/src/main/scala/sttp/tapir/server/akkagrpc/AkkaGrpcRequestBody.scala b/server/akka-grpc-server/src/main/scala/sttp/tapir/server/akkagrpc/AkkaGrpcRequestBody.scala index bdc3b30a32..e4cf0a1468 100644 --- a/server/akka-grpc-server/src/main/scala/sttp/tapir/server/akkagrpc/AkkaGrpcRequestBody.scala +++ b/server/akka-grpc-server/src/main/scala/sttp/tapir/server/akkagrpc/AkkaGrpcRequestBody.scala @@ -6,7 +6,7 @@ import akka.http.scaladsl.server.RequestContext import akka.stream.Materializer import akka.util.ByteString import sttp.capabilities.akka.AkkaStreams -import sttp.tapir.RawBodyType +import sttp.tapir.{InputStreamRange, RawBodyType} import sttp.tapir.model.ServerRequest import sttp.tapir.server.akkahttp.AkkaHttpServerOptions import sttp.tapir.server.interpreter.{RawValue, RequestBody} @@ -39,12 +39,13 @@ private[akkagrpc] class AkkaGrpcRequestBody(serverOptions: AkkaHttpServerOptions private def toExpectedBodyType[R](byteString: ByteString, bodyType: RawBodyType[R]): RawValue[R] = { bodyType match { - case RawBodyType.ByteArrayBody => RawValue(byteString.toArray) - case RawBodyType.ByteBufferBody => RawValue(byteString.asByteBuffer) - case RawBodyType.InputStreamBody => RawValue(new ByteArrayInputStream(byteString.toArray)) - case RawBodyType.FileBody => ??? - case m: RawBodyType.MultipartBody => ??? - case _ => ??? + case RawBodyType.ByteArrayBody => RawValue(byteString.toArray) + case RawBodyType.ByteBufferBody => RawValue(byteString.asByteBuffer) + case RawBodyType.InputStreamBody => RawValue(new ByteArrayInputStream(byteString.toArray)) + case RawBodyType.InputStreamRangeBody => RawValue(InputStreamRange(() => new ByteArrayInputStream(byteString.toArray))) + case RawBodyType.FileBody => ??? + case m: RawBodyType.MultipartBody => ??? + case _ => ??? } } diff --git a/server/akka-grpc-server/src/main/scala/sttp/tapir/server/akkagrpc/AkkaGrpcToResponseBody.scala b/server/akka-grpc-server/src/main/scala/sttp/tapir/server/akkagrpc/AkkaGrpcToResponseBody.scala index 57ca0e67e2..864b95b964 100644 --- a/server/akka-grpc-server/src/main/scala/sttp/tapir/server/akkagrpc/AkkaGrpcToResponseBody.scala +++ b/server/akka-grpc-server/src/main/scala/sttp/tapir/server/akkagrpc/AkkaGrpcToResponseBody.scala @@ -45,12 +45,13 @@ private[akkagrpc] class AkkaGrpcToResponseBody(implicit m: Materializer, ec: Exe r: R ): ResponseEntity = { bodyType match { - case RawBodyType.StringBody(charset) => ??? - case RawBodyType.ByteArrayBody => HttpEntity(ct, encodeDataToFrameBytes(ByteString(r))) - case RawBodyType.ByteBufferBody => HttpEntity(ct, encodeDataToFrameBytes(ByteString(r))) - case RawBodyType.InputStreamBody => ??? - case RawBodyType.FileBody => ??? - case m: RawBodyType.MultipartBody => ??? + case RawBodyType.StringBody(charset) => ??? + case RawBodyType.ByteArrayBody => HttpEntity(ct, encodeDataToFrameBytes(ByteString(r))) + case RawBodyType.ByteBufferBody => HttpEntity(ct, encodeDataToFrameBytes(ByteString(r))) + case RawBodyType.InputStreamBody => ??? + case RawBodyType.InputStreamRangeBody => ??? + case RawBodyType.FileBody => ??? + case m: RawBodyType.MultipartBody => ??? } } diff --git a/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaRequestBody.scala b/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaRequestBody.scala index 61d39ea59b..4ca5e8b517 100644 --- a/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaRequestBody.scala +++ b/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaRequestBody.scala @@ -10,9 +10,10 @@ import sttp.capabilities.akka.AkkaStreams import sttp.model.{Header, Part} import sttp.tapir.model.ServerRequest import sttp.tapir.server.interpreter.{RawValue, RequestBody} -import sttp.tapir.{FileRange, RawBodyType, RawPart} +import sttp.tapir.{FileRange, RawBodyType, RawPart, InputStreamRange} + +import java.io.{ByteArrayInputStream, InputStream} -import java.io.ByteArrayInputStream import scala.concurrent.{ExecutionContext, Future} private[akkahttp] class AkkaRequestBody(serverOptions: AkkaHttpServerOptions)(implicit @@ -37,6 +38,10 @@ private[akkahttp] class AkkaRequestBody(serverOptions: AkkaHttpServerOptions)(im serverOptions .createFile(request) .flatMap(file => body.dataBytes.runWith(FileIO.toPath(file.toPath)).map(_ => FileRange(file)).map(f => RawValue(f, Seq(f)))) + case RawBodyType.InputStreamRangeBody => + implicitly[FromEntityUnmarshaller[Array[Byte]]] + .apply(body) + .map(b => RawValue(InputStreamRange(() => new ByteArrayInputStream(b)))) case m: RawBodyType.MultipartBody => implicitly[FromEntityUnmarshaller[Multipart.FormData]].apply(body).flatMap { fd => fd.parts diff --git a/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaToResponseBody.scala b/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaToResponseBody.scala index efc5b74d33..edc10f0323 100644 --- a/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaToResponseBody.scala +++ b/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaToResponseBody.scala @@ -1,8 +1,8 @@ package sttp.tapir.server.akkahttp import akka.http.scaladsl.model._ -import akka.stream.scaladsl.{FileIO, Source, StreamConverters} -import akka.stream.{IOResult, Materializer} +import akka.stream.scaladsl.{FileIO, StreamConverters} +import akka.stream.Materializer import akka.util.ByteString import sttp.capabilities.akka.AkkaStreams import sttp.model.{HasHeaders, HeaderNames, Part} @@ -10,14 +10,14 @@ import sttp.tapir.internal.charset import sttp.tapir.server.akkahttp.AkkaModel.parseHeadersOrThrowWithoutContentHeaders import sttp.tapir.server.interpreter.ToResponseBody import sttp.tapir.{CodecFormat, FileRange, RawBodyType, RawPart, WebSocketBodyOutput} - import java.nio.charset.{Charset, StandardCharsets} -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.ExecutionContext import scala.util.Try private[akkahttp] class AkkaToResponseBody(implicit m: Materializer, ec: ExecutionContext) extends ToResponseBody[AkkaResponseBody, AkkaStreams] { override val streams: AkkaStreams = AkkaStreams + private val ChunkSize = 8192 override def fromRawValue[R](v: R, headers: HasHeaders, format: CodecFormat, bodyType: RawBodyType[R]): AkkaResponseBody = Right( @@ -52,13 +52,19 @@ private[akkahttp] class AkkaToResponseBody(implicit m: Materializer, ec: Executi case nb: ContentType.NonBinary => HttpEntity(nb, r) case _ => HttpEntity(ct, r.getBytes(charset)) } - case RawBodyType.ByteArrayBody => HttpEntity(ct, r) - case RawBodyType.ByteBufferBody => HttpEntity(ct, ByteString(r)) - case RawBodyType.InputStreamBody => streamToEntity(ct, contentLength, StreamConverters.fromInputStream(() => r)) + case RawBodyType.ByteArrayBody => HttpEntity(ct, r) + case RawBodyType.ByteBufferBody => HttpEntity(ct, ByteString(r)) + case RawBodyType.InputStreamBody => streamToEntity(ct, contentLength, StreamConverters.fromInputStream(() => r, ChunkSize)) + case RawBodyType.InputStreamRangeBody => + val resource = r + val initialStream = StreamConverters.fromInputStream(resource.inputStreamFromRangeStart, ChunkSize) + resource.range + .map(r => streamToEntity(ct, contentLength, toRangedStream(initialStream, bytesTotal = r.contentLength))) + .getOrElse(streamToEntity(ct, contentLength, initialStream)) case RawBodyType.FileBody => - val tapirFile = r.asInstanceOf[FileRange] + val tapirFile = r tapirFile.range - .flatMap(r => r.startAndEnd.map(s => HttpEntity(ct, createSource(tapirFile, s._1, r.contentLength)))) + .flatMap(r => r.startAndEnd.map(s => HttpEntity(ct, createFileSource(tapirFile, s._1, r.contentLength)))) .getOrElse(HttpEntity.fromPath(ct, tapirFile.file.toPath)) case m: RawBodyType.MultipartBody => val parts = (r: Seq[RawPart]).flatMap(rawPartToBodyPart(m, _)) @@ -67,13 +73,15 @@ private[akkahttp] class AkkaToResponseBody(implicit m: Materializer, ec: Executi } } - private def createSource[R, CF <: CodecFormat]( + private def createFileSource( tapirFile: FileRange, start: Long, bytesTotal: Long - ): Source[ByteString, Future[IOResult]] = - FileIO - .fromPath(tapirFile.file.toPath, chunkSize = 8192, startPosition = start) + ): AkkaStreams.BinaryStream = + toRangedStream(FileIO.fromPath(tapirFile.file.toPath, ChunkSize, startPosition = start), bytesTotal) + + private def toRangedStream(initialStream: AkkaStreams.BinaryStream, bytesTotal: Long): AkkaStreams.BinaryStream = + initialStream .scan((0L, ByteString.empty)) { case ((bytesConsumed, _), next) => val bytesInNext = next.length val bytesFromNext = Math.max(0, Math.min(bytesTotal - bytesConsumed, bytesInNext.toLong)) diff --git a/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala b/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala index df898cf587..43f2ca9e7e 100644 --- a/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala +++ b/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala @@ -100,7 +100,6 @@ class AkkaHttpServerTest extends TestSuite with EitherValues { .unsafeToFuture() } ) - new AllServerTests(createServerTest, interpreter, backend).tests() ++ new ServerStreamingTests(createServerTest, AkkaStreams).tests() ++ new ServerWebSocketTests(createServerTest, AkkaStreams) { diff --git a/server/armeria-server/src/main/scala/sttp/tapir/server/armeria/ArmeriaRequestBody.scala b/server/armeria-server/src/main/scala/sttp/tapir/server/armeria/ArmeriaRequestBody.scala index 5be22d14a5..a0db02886b 100644 --- a/server/armeria-server/src/main/scala/sttp/tapir/server/armeria/ArmeriaRequestBody.scala +++ b/server/armeria-server/src/main/scala/sttp/tapir/server/armeria/ArmeriaRequestBody.scala @@ -8,7 +8,7 @@ import sttp.capabilities.Streams import sttp.model.Part import sttp.tapir.model.ServerRequest import sttp.tapir.server.interpreter.{RawValue, RequestBody} -import sttp.tapir.{FileRange, RawBodyType} +import sttp.tapir.{FileRange, InputStreamRange, RawBodyType} import java.io.ByteArrayInputStream import scala.collection.JavaConverters._ @@ -45,6 +45,11 @@ private[armeria] final class ArmeriaRequestBody[F[_], S <: Streams[S]]( .aggregate() .thenApply[RawValue[R]](agg => RawValue(new ByteArrayInputStream(agg.content().array()))) .toScala + case RawBodyType.InputStreamRangeBody => + request + .aggregate() + .thenApply[RawValue[R]](agg => RawValue(InputStreamRange(() => new ByteArrayInputStream(agg.content().array())))) + .toScala case RawBodyType.FileBody => val bodyStream = request.filter(x => x.isInstanceOf[HttpData]).asInstanceOf[StreamMessage[HttpData]] for { @@ -74,10 +79,11 @@ private[armeria] final class ArmeriaRequestBody[F[_], S <: Streams[S]]( private def toRawFromHttpData[R](ctx: ServiceRequestContext, body: HttpData, bodyType: RawBodyType[R]): Future[RawValue[R]] = { bodyType match { - case RawBodyType.StringBody(_) => Future.successful(RawValue(body.toStringUtf8)) - case RawBodyType.ByteArrayBody => Future.successful(RawValue(body.array())) - case RawBodyType.ByteBufferBody => Future.successful(RawValue(body.byteBuf().nioBuffer())) - case RawBodyType.InputStreamBody => Future.successful(RawValue(new ByteArrayInputStream(body.array()))) + case RawBodyType.StringBody(_) => Future.successful(RawValue(body.toStringUtf8)) + case RawBodyType.ByteArrayBody => Future.successful(RawValue(body.array())) + case RawBodyType.ByteBufferBody => Future.successful(RawValue(body.byteBuf().nioBuffer())) + case RawBodyType.InputStreamBody => Future.successful(RawValue(new ByteArrayInputStream(body.array()))) + case RawBodyType.InputStreamRangeBody => Future.successful(RawValue(InputStreamRange(() => new ByteArrayInputStream(body.array())))) case RawBodyType.FileBody => for { file <- futureConversion.to(serverOptions.createFile()) diff --git a/server/armeria-server/src/main/scala/sttp/tapir/server/armeria/ArmeriaToResponseBody.scala b/server/armeria-server/src/main/scala/sttp/tapir/server/armeria/ArmeriaToResponseBody.scala index c5e13f23aa..fce8d89ea9 100644 --- a/server/armeria-server/src/main/scala/sttp/tapir/server/armeria/ArmeriaToResponseBody.scala +++ b/server/armeria-server/src/main/scala/sttp/tapir/server/armeria/ArmeriaToResponseBody.scala @@ -5,7 +5,6 @@ import com.linecorp.armeria.common.stream.StreamMessage import com.linecorp.armeria.common.{ContentDisposition, HttpData, HttpHeaders} import com.linecorp.armeria.internal.shaded.guava.io.ByteStreams import io.netty.buffer.Unpooled -import java.io.InputStream import java.nio.ByteBuffer import java.nio.charset.Charset import sttp.capabilities.Streams @@ -48,9 +47,18 @@ private[armeria] final class ArmeriaToResponseBody[S <: Streams[S]](streamCompat Right(HttpData.wrap(Unpooled.wrappedBuffer(byteBuffer))) case RawBodyType.InputStreamBody => - val is = v.asInstanceOf[InputStream] // TODO(ikhoon): Add StreamMessage.of(InputStream) - Right(HttpData.wrap(ByteStreams.toByteArray(is))) + Right(HttpData.wrap(ByteStreams.toByteArray(v))) + + case RawBodyType.InputStreamRangeBody => + val bytes = v.range // TODO Add StreamMessage.of(InputStream) + .map { r => + val array = new Array[Byte](r.contentLength.toInt) + ByteStreams.read(v.inputStreamFromRangeStart(), array, 0, r.contentLength.toInt) + array + } + .getOrElse(ByteStreams.toByteArray(v.inputStream())) + Right(HttpData.wrap(bytes)) case RawBodyType.FileBody => val tapirFile = v.asInstanceOf[FileRange] diff --git a/server/core/src/main/scala/sttp/tapir/server/interceptor/decodefailure/DecodeFailureHandler.scala b/server/core/src/main/scala/sttp/tapir/server/interceptor/decodefailure/DecodeFailureHandler.scala index c118e5c3ba..003d4d56d8 100644 --- a/server/core/src/main/scala/sttp/tapir/server/interceptor/decodefailure/DecodeFailureHandler.scala +++ b/server/core/src/main/scala/sttp/tapir/server/interceptor/decodefailure/DecodeFailureHandler.scala @@ -131,6 +131,7 @@ object DefaultDecodeFailureHandler { if (badRequestOnPathErrorIfPathShapeMatches && ctx.failure.isInstanceOf[DecodeResult.Error]) || (badRequestOnPathInvalidIfPathShapeMatches && ctx.failure.isInstanceOf[DecodeResult.InvalidValue]) => respondBadRequest + case _: EndpointInput.PathsCapture[_] => respondBadRequest // if the failing input contains an authentication input (potentially nested), sending its challenge case FirstAuth(a) => Some((StatusCode.Unauthorized, Header.wwwAuthenticate(a.challenge))) // other basic endpoints - the request doesn't match, but not returning a response (trying other endpoints) diff --git a/server/finatra-server/cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala b/server/finatra-server/cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala index 43cdf0ca12..2a2e6b3d8e 100644 --- a/server/finatra-server/cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala +++ b/server/finatra-server/cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala @@ -2,7 +2,7 @@ package sttp.tapir.server.finatra.cats import cats.effect.{IO, Resource} import sttp.client3.impl.cats.CatsMonadAsyncError -import sttp.tapir.server.tests.{AllServerTests, DefaultCreateServerTest, ServerStaticContentTests, backendResource} +import sttp.tapir.server.tests._ import sttp.tapir.tests.{Test, TestSuite} class FinatraServerCatsTests extends TestSuite { @@ -13,6 +13,6 @@ class FinatraServerCatsTests extends TestSuite { val createServerTest = new DefaultCreateServerTest(backend, interpreter) new AllServerTests(createServerTest, interpreter, backend, staticContent = false, reject = false, metrics = false).tests() ++ - new ServerStaticContentTests(interpreter, backend, supportSettingContentLength = false).tests() + new ServerFilesTests(interpreter, backend, supportSettingContentLength = false).tests() } } diff --git a/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraRequestBody.scala b/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraRequestBody.scala index f395e52373..7e7220ceec 100644 --- a/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraRequestBody.scala +++ b/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraRequestBody.scala @@ -9,7 +9,7 @@ import sttp.model.{Header, Part} import sttp.tapir.capabilities.NoStreams import sttp.tapir.model.ServerRequest import sttp.tapir.server.interpreter.{RawValue, RequestBody} -import sttp.tapir.{FileRange, RawBodyType, RawPart} +import sttp.tapir.{FileRange, InputStreamRange, RawBodyType, RawPart} import java.io.ByteArrayInputStream import java.nio.ByteBuffer @@ -45,6 +45,9 @@ class FinatraRequestBody(serverOptions: FinatraServerOptions) extends RequestBod case RawBodyType.ByteArrayBody => Future.value[R](asByteArray).map(RawValue(_)) case RawBodyType.ByteBufferBody => Future.value[R](asByteBuffer).map(RawValue(_)) case RawBodyType.InputStreamBody => Future.value[R](new ByteArrayInputStream(asByteArray)).map(RawValue(_)) + case RawBodyType.InputStreamRangeBody => + Future.value[R](InputStreamRange(() => new ByteArrayInputStream(asByteArray))).map(RawValue(_)) + case RawBodyType.FileBody => serverOptions.createFile(asByteArray).map(f => FileRange(f)).map(file => RawValue(file, Seq(file))) case m: RawBodyType.MultipartBody => multiPartRequestToRawBody(request, m).map(RawValue.fromParts) } diff --git a/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraToResponseBody.scala b/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraToResponseBody.scala index 62e40c7816..fb4ee45cce 100644 --- a/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraToResponseBody.scala +++ b/server/finatra-server/src/main/scala/sttp/tapir/server/finatra/FinatraToResponseBody.scala @@ -7,7 +7,7 @@ import org.apache.http.entity.mime.{FormBodyPart, FormBodyPartBuilder, Multipart import sttp.model.{HasHeaders, Part} import sttp.tapir.capabilities.NoStreams import sttp.tapir.server.interpreter.ToResponseBody -import sttp.tapir.{CodecFormat, FileRange, RawBodyType, WebSocketBodyOutput} +import sttp.tapir.{CodecFormat, RawBodyType, WebSocketBodyOutput} import java.io.InputStream import java.nio.charset.Charset @@ -22,12 +22,17 @@ class FinatraToResponseBody extends ToResponseBody[FinatraContent, NoStreams] { case RawBodyType.ByteArrayBody => FinatraContentBuf(Buf.ByteArray.Owned(v)) case RawBodyType.ByteBufferBody => FinatraContentBuf(Buf.ByteBuffer.Owned(v)) case RawBodyType.InputStreamBody => FinatraContentReader(Reader.fromStream(v)) + case RawBodyType.InputStreamRangeBody => + val stream = + v.range + .flatMap(_.startAndEnd.map(s => RangeInputStream(v.inputStream(), s._1, s._2))) + .getOrElse(v.inputStream()) + FinatraContentReader(Reader.fromStream(stream)) case RawBodyType.FileBody => - val tapirFile = v.asInstanceOf[FileRange] FileChunk - .prepare(tapirFile) + .prepare(v) .map(s => FinatraContentReader(Reader.fromStream(s))) - .getOrElse(FinatraContentReader(Reader.fromFile(tapirFile.file))) + .getOrElse(FinatraContentReader(Reader.fromFile(v.file))) case m: RawBodyType.MultipartBody => val entity = MultipartEntityBuilder.create() v.flatMap(rawPartToFormBodyPart(m, _)).foreach { formBodyPart: FormBodyPart => entity.addPart(formBodyPart) } @@ -53,9 +58,11 @@ class FinatraToResponseBody extends ToResponseBody[FinatraContent, NoStreams] { new ByteArrayBody(array, ContentType.create(contentType), part.fileName.get) case RawBodyType.FileBody => part.fileName match { - case Some(filename) => new FileBody(r.asInstanceOf[FileRange].file, ContentType.create(contentType), filename) - case None => new FileBody(r.asInstanceOf[FileRange].file, ContentType.create(contentType)) + case Some(filename) => new FileBody(r.file, ContentType.create(contentType), filename) + case None => new FileBody(r.file, ContentType.create(contentType)) } + case RawBodyType.InputStreamRangeBody => + new InputStreamBody(r.inputStream(), ContentType.create(contentType), part.fileName.get) case RawBodyType.InputStreamBody => new InputStreamBody(r, ContentType.create(contentType), part.fileName.get) case _: RawBodyType.MultipartBody => diff --git a/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala b/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala index 7286a938e9..6b1d4d7d7f 100644 --- a/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala +++ b/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala @@ -2,7 +2,7 @@ package sttp.tapir.server.finatra import cats.effect.{IO, Resource} import sttp.tapir.server.finatra.FinatraServerInterpreter.FutureMonadError -import sttp.tapir.server.tests.{AllServerTests, DefaultCreateServerTest, ServerStaticContentTests, backendResource} +import sttp.tapir.server.tests._ import sttp.tapir.tests.{Test, TestSuite} class FinatraServerTest extends TestSuite { @@ -12,6 +12,6 @@ class FinatraServerTest extends TestSuite { val createServerTest = new DefaultCreateServerTest(backend, interpreter) new AllServerTests(createServerTest, interpreter, backend, staticContent = false, reject = false).tests() ++ - new ServerStaticContentTests(interpreter, backend, supportSettingContentLength = false).tests() + new ServerFilesTests(interpreter, backend, supportSettingContentLength = false).tests() } } diff --git a/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sRequestBody.scala b/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sRequestBody.scala index 0d9a036202..cbd7e1fadc 100644 --- a/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sRequestBody.scala +++ b/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sRequestBody.scala @@ -10,7 +10,7 @@ import sttp.capabilities.fs2.Fs2Streams import sttp.model.{Header, Part} import sttp.tapir.model.ServerRequest import sttp.tapir.server.interpreter.{RawValue, RequestBody} -import sttp.tapir.{FileRange, RawBodyType, RawPart} +import sttp.tapir.{FileRange, InputStreamRange, RawBodyType, RawPart} import java.io.ByteArrayInputStream @@ -38,9 +38,11 @@ private[http4s] class Http4sRequestBody[F[_]: Async]( bodyType match { case RawBodyType.StringBody(defaultCharset) => asByteArray.map(new String(_, charset.map(_.nioCharset).getOrElse(defaultCharset))).map(RawValue(_)) - case RawBodyType.ByteArrayBody => asByteArray.map(RawValue(_)) - case RawBodyType.ByteBufferBody => asChunk.map(c => RawValue(c.toByteBuffer)) - case RawBodyType.InputStreamBody => asByteArray.map(b => RawValue(new ByteArrayInputStream(b))) + case RawBodyType.ByteArrayBody => asByteArray.map(RawValue(_)) + case RawBodyType.ByteBufferBody => asChunk.map(c => RawValue(c.toByteBuffer)) + case RawBodyType.InputStreamBody => asByteArray.map(b => RawValue(new ByteArrayInputStream(b))) + case RawBodyType.InputStreamRangeBody => asByteArray.map(b => RawValue(InputStreamRange(() => new ByteArrayInputStream(b)))) + case RawBodyType.FileBody => serverOptions.createFile(serverRequest).flatMap { file => val fileSink = Files[F].writeAll(file.toPath) diff --git a/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sToResponseBody.scala b/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sToResponseBody.scala index 99879b5526..6ffe5d5193 100644 --- a/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sToResponseBody.scala +++ b/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sToResponseBody.scala @@ -1,6 +1,6 @@ package sttp.tapir.server.http4s -import cats.effect.Async +import cats.effect.{Async, Sync} import cats.syntax.all._ import fs2.io.file.Files import fs2.{Chunk, Stream} @@ -12,8 +12,9 @@ import org.typelevel.ci.CIString import sttp.capabilities.fs2.Fs2Streams import sttp.model.{HasHeaders, HeaderNames, Part} import sttp.tapir.server.interpreter.ToResponseBody -import sttp.tapir.{CodecFormat, FileRange, RawBodyType, RawPart, WebSocketBodyOutput} +import sttp.tapir.{CodecFormat, RawBodyType, RawPart, WebSocketBodyOutput} +import java.io.InputStream import java.nio.charset.Charset private[http4s] class Http4sToResponseBody[F[_]: Async]( @@ -42,18 +43,16 @@ private[http4s] class Http4sToResponseBody[F[_]: Async]( case RawBodyType.StringBody(charset) => val bytes = r.toString.getBytes(charset) (fs2.Stream.chunk(Chunk.array(bytes)), Some(bytes.length)) - case RawBodyType.ByteArrayBody => (fs2.Stream.chunk(Chunk.array(r)), Some((r: Array[Byte]).length)) - case RawBodyType.ByteBufferBody => (fs2.Stream.chunk(Chunk.byteBuffer(r)), None) - case RawBodyType.InputStreamBody => - ( - fs2.io.readInputStream( - r.pure[F], - serverOptions.ioChunkSize - ), - None - ) + case RawBodyType.ByteArrayBody => (fs2.Stream.chunk(Chunk.array(r)), Some((r: Array[Byte]).length)) + case RawBodyType.ByteBufferBody => (fs2.Stream.chunk(Chunk.byteBuffer(r)), None) + case RawBodyType.InputStreamBody => (inputStreamToFs2(() => r), None) + case RawBodyType.InputStreamRangeBody => + val fs2Stream = r.range + .map(range => inputStreamToFs2(r.inputStreamFromRangeStart).take(range.contentLength)) + .getOrElse(inputStreamToFs2(r.inputStream)) + (fs2Stream, None) case RawBodyType.FileBody => - val tapirFile = r.asInstanceOf[FileRange] + val tapirFile = r val stream = tapirFile.range .flatMap(r => r.startAndEnd.map(s => Files[F].readRange(tapirFile.file.toPath, r.contentLength.toInt, s._1, s._2))) .getOrElse(Files[F].readAll(tapirFile.file.toPath, serverOptions.ioChunkSize)) @@ -65,6 +64,12 @@ private[http4s] class Http4sToResponseBody[F[_]: Async]( } } + private def inputStreamToFs2(inputStream: () => InputStream) = + fs2.io.readInputStream( + Sync[F].blocking(inputStream()), + serverOptions.ioChunkSize + ) + private def rawPartToBodyPart[T](m: RawBodyType.MultipartBody, part: Part[T]): Option[multipart.Part[F]] = { m.partType(part.name).map { partType => val headers: List[Header.ToRaw] = part.headers.map { header => diff --git a/server/netty-server/cats/src/test/scala/sttp/tapir/server/netty/cats/NettyCatsServerTest.scala b/server/netty-server/cats/src/test/scala/sttp/tapir/server/netty/cats/NettyCatsServerTest.scala index d917ba4dc5..7087458d48 100644 --- a/server/netty-server/cats/src/test/scala/sttp/tapir/server/netty/cats/NettyCatsServerTest.scala +++ b/server/netty-server/cats/src/test/scala/sttp/tapir/server/netty/cats/NettyCatsServerTest.scala @@ -20,7 +20,7 @@ class NettyCatsServerTest extends TestSuite with EitherValues { val interpreter = new NettyCatsTestServerInterpreter(eventLoopGroup, dispatcher) val createServerTest = new DefaultCreateServerTest(backend, interpreter) - val tests = new AllServerTests(createServerTest, interpreter, backend, staticContent = false, multipart = false).tests() + val tests = new AllServerTests(createServerTest, interpreter, backend, multipart = false).tests() IO.pure((tests, eventLoopGroup)) } { case (_, eventLoopGroup) => diff --git a/server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyRequestBody.scala b/server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyRequestBody.scala index 063865a6f4..e32c06fc46 100644 --- a/server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyRequestBody.scala +++ b/server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyRequestBody.scala @@ -4,7 +4,7 @@ import io.netty.buffer.{ByteBufInputStream, ByteBufUtil} import io.netty.handler.codec.http.FullHttpRequest import sttp.capabilities import sttp.monad.MonadError -import sttp.tapir.{FileRange, RawBodyType, TapirFile} +import sttp.tapir.{FileRange, InputStreamRange, RawBodyType, TapirFile} import sttp.tapir.model.ServerRequest import sttp.monad.syntax._ import sttp.tapir.capabilities.NoStreams @@ -30,6 +30,8 @@ class NettyRequestBody[F[_]](createFile: ServerRequest => F[TapirFile])(implicit case RawBodyType.ByteArrayBody => monadError.unit(RawValue(requestContentAsByteArray)) case RawBodyType.ByteBufferBody => monadError.unit(RawValue(ByteBuffer.wrap(requestContentAsByteArray))) case RawBodyType.InputStreamBody => monadError.unit(RawValue(new ByteBufInputStream(nettyRequest(serverRequest).content()))) + case RawBodyType.InputStreamRangeBody => + monadError.unit(RawValue(InputStreamRange(() => new ByteBufInputStream(nettyRequest(serverRequest).content())))) case RawBodyType.FileBody => createFile(serverRequest) .map(file => { diff --git a/server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyToResponseBody.scala b/server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyToResponseBody.scala index 5a1fad15b9..8a060c56cc 100644 --- a/server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyToResponseBody.scala +++ b/server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyToResponseBody.scala @@ -13,12 +13,18 @@ import sttp.tapir.server.netty.NettyResponseContent.{ ChunkedFileNettyResponseContent, ChunkedStreamNettyResponseContent } -import sttp.tapir.{CodecFormat, FileRange, RawBodyType, WebSocketBodyOutput} +import sttp.tapir.{CodecFormat, FileRange, InputStreamRange, RawBodyType, WebSocketBodyOutput} import java.io.{InputStream, RandomAccessFile} import java.nio.ByteBuffer import java.nio.charset.Charset +private[internal] class RangedChunkedStream(raw: InputStream, length: Long) extends ChunkedStream(raw) { + + override def isEndOfInput(): Boolean = + super.isEndOfInput || transferredBytes == length +} + class NettyToResponseBody extends ToResponseBody[NettyResponse, NoStreams] { override val streams: capabilities.Streams[NoStreams] = NoStreams @@ -37,17 +43,24 @@ class NettyToResponseBody extends ToResponseBody[NettyResponse, NoStreams] { (ctx: ChannelHandlerContext) => ByteBufNettyResponseContent(ctx.newPromise(), Unpooled.wrappedBuffer(byteBuffer)) case RawBodyType.InputStreamBody => - val stream = v.asInstanceOf[InputStream] - (ctx: ChannelHandlerContext) => ChunkedStreamNettyResponseContent(ctx.newPromise(), wrap(stream)) + (ctx: ChannelHandlerContext) => ChunkedStreamNettyResponseContent(ctx.newPromise(), wrap(v)) + + case RawBodyType.InputStreamRangeBody => + (ctx: ChannelHandlerContext) => ChunkedStreamNettyResponseContent(ctx.newPromise(), wrap(v)) case RawBodyType.FileBody => - val fileRange = v.asInstanceOf[FileRange] - (ctx: ChannelHandlerContext) => ChunkedFileNettyResponseContent(ctx.newPromise(), wrap(fileRange)) + (ctx: ChannelHandlerContext) => ChunkedFileNettyResponseContent(ctx.newPromise(), wrap(v)) case _: RawBodyType.MultipartBody => throw new UnsupportedOperationException } } + private def wrap(streamRange: InputStreamRange): ChunkedStream = { + streamRange.range + .map(r => new RangedChunkedStream(streamRange.inputStreamFromRangeStart(), r.contentLength)) + .getOrElse(new ChunkedStream(streamRange.inputStream())) + } + private def wrap(content: InputStream): ChunkedStream = { new ChunkedStream(content) } diff --git a/server/play-server/src/main/scala/sttp/tapir/server/play/PlayRequestBody.scala b/server/play-server/src/main/scala/sttp/tapir/server/play/PlayRequestBody.scala index c02489a847..5bff1c3d57 100644 --- a/server/play-server/src/main/scala/sttp/tapir/server/play/PlayRequestBody.scala +++ b/server/play-server/src/main/scala/sttp/tapir/server/play/PlayRequestBody.scala @@ -10,7 +10,7 @@ import sttp.model.{Header, MediaType, Part} import sttp.tapir.internal._ import sttp.tapir.model.ServerRequest import sttp.tapir.server.interpreter.{RawValue, RequestBody} -import sttp.tapir.{FileRange, RawBodyType, RawPart} +import sttp.tapir.{FileRange, InputStreamRange, RawBodyType, RawPart} import java.io.{ByteArrayInputStream, File} import java.nio.charset.Charset @@ -55,6 +55,8 @@ private[play] class PlayRequestBody(serverOptions: PlayServerOptions)(implicit case RawBodyType.ByteArrayBody => bodyAsByteString().map(b => RawValue(b.toArray)) case RawBodyType.ByteBufferBody => bodyAsByteString().map(b => RawValue(b.toByteBuffer)) case RawBodyType.InputStreamBody => bodyAsByteString().map(b => RawValue(new ByteArrayInputStream(b.toArray))) + case RawBodyType.InputStreamRangeBody => + bodyAsByteString().map(b => RawValue(new InputStreamRange(() => new ByteArrayInputStream(b.toArray)))) case RawBodyType.FileBody => bodyAsFile match { case Some(file) => diff --git a/server/play-server/src/main/scala/sttp/tapir/server/play/PlayToResponseBody.scala b/server/play-server/src/main/scala/sttp/tapir/server/play/PlayToResponseBody.scala index 9f79de6c80..dbcb5abb03 100644 --- a/server/play-server/src/main/scala/sttp/tapir/server/play/PlayToResponseBody.scala +++ b/server/play-server/src/main/scala/sttp/tapir/server/play/PlayToResponseBody.scala @@ -1,7 +1,6 @@ package sttp.tapir.server.play import akka.NotUsed -import akka.stream.IOResult import akka.stream.scaladsl.{FileIO, Source, StreamConverters} import akka.util.ByteString import play.api.http.{HeaderNames, HttpChunk, HttpEntity} @@ -12,10 +11,8 @@ import sttp.model.{HasHeaders, Part} import sttp.tapir.server.interpreter.ToResponseBody import sttp.tapir.{CodecFormat, FileRange, RawBodyType, RawPart, WebSocketBodyOutput} -import java.io.InputStream import java.nio.ByteBuffer import java.nio.charset.Charset -import scala.concurrent.Future class PlayToResponseBody extends ToResponseBody[PlayResponseBody, AkkaStreams] { @@ -25,6 +22,8 @@ class PlayToResponseBody extends ToResponseBody[PlayResponseBody, AkkaStreams] { Right(fromRawValue(v, headers, bodyType)) } + private val ChunkSize = 8192 + private def fromRawValue[R](v: R, headers: HasHeaders, bodyType: RawBodyType[R]): HttpEntity = { val contentType = headers.contentType bodyType match { @@ -41,17 +40,21 @@ class PlayToResponseBody extends ToResponseBody[PlayResponseBody, AkkaStreams] { HttpEntity.Strict(ByteString(byteBuffer), contentType) case RawBodyType.InputStreamBody => - val stream = v.asInstanceOf[InputStream] - streamOrChunk(StreamConverters.fromInputStream(() => stream), headers.contentLength, contentType) + streamOrChunk(StreamConverters.fromInputStream(() => v), headers.contentLength, contentType) + + case RawBodyType.InputStreamRangeBody => + val initialStream = StreamConverters.fromInputStream(v.inputStreamFromRangeStart, ChunkSize) + v.range + .map(r => streamOrChunk(toRangedStream(initialStream, bytesTotal = r.contentLength), Some(r.contentLength), contentType)) + .getOrElse(streamOrChunk(initialStream, headers.contentLength, contentType)) case RawBodyType.FileBody => - val tapirFile = v.asInstanceOf[FileRange] - tapirFile.range + v.range .flatMap(r => r.startAndEnd - .map(s => streamOrChunk(createSource(tapirFile, s._1, r.contentLength), Some(r.contentLength), contentType)) + .map(s => streamOrChunk(createFileSource(v, s._1, r.contentLength), Some(r.contentLength), contentType)) ) - .getOrElse(streamOrChunk(FileIO.fromPath(tapirFile.file.toPath), Some(tapirFile.file.length()), contentType)) + .getOrElse(streamOrChunk(FileIO.fromPath(v.file.toPath), Some(v.file.length()), contentType)) case m: RawBodyType.MultipartBody => val rawParts = v.asInstanceOf[Seq[RawPart]] @@ -81,13 +84,15 @@ class PlayToResponseBody extends ToResponseBody[PlayResponseBody, AkkaStreams] { } } - private def createSource[R, CF <: CodecFormat]( + private def createFileSource( tapirFile: FileRange, start: Long, bytesTotal: Long - ): Source[ByteString, Future[IOResult]] = - FileIO - .fromPath(tapirFile.file.toPath, chunkSize = 8192, startPosition = start) + ): AkkaStreams.BinaryStream = + toRangedStream(FileIO.fromPath(tapirFile.file.toPath, ChunkSize, startPosition = start), bytesTotal) + + private def toRangedStream(initialStream: AkkaStreams.BinaryStream, bytesTotal: Long): AkkaStreams.BinaryStream = + initialStream .scan((0L, ByteString.empty)) { case ((bytesConsumed, _), next) => val bytesInNext = next.length val bytesFromNext = Math.max(0, Math.min(bytesTotal - bytesConsumed, bytesInNext.toLong)) diff --git a/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpRequestBody.scala b/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpRequestBody.scala index a7c9f0ca6c..b6bfbecc4f 100644 --- a/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpRequestBody.scala +++ b/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpRequestBody.scala @@ -2,6 +2,7 @@ package sttp.tapir.server.stub import sttp.client3.{ByteArrayBody, ByteBufferBody, FileBody, InputStreamBody, MultipartBody, NoBody, Request, StreamBody, StringBody} import sttp.monad.MonadError +import sttp.tapir.InputStreamRange import sttp.tapir.RawBodyType import sttp.tapir.model.ServerRequest import sttp.tapir.server.interpreter.{RawValue, RequestBody} @@ -17,12 +18,13 @@ class SttpRequestBody[F[_]](implicit ME: MonadError[F]) extends RequestBody[F, A body(serverRequest) match { case Left(bytes) => bodyType match { - case RawBodyType.StringBody(charset) => ME.unit(RawValue(new String(bytes, charset))) - case RawBodyType.ByteArrayBody => ME.unit(RawValue(bytes)) - case RawBodyType.ByteBufferBody => ME.unit(RawValue(ByteBuffer.wrap(bytes))) - case RawBodyType.InputStreamBody => ME.unit(RawValue(new ByteArrayInputStream(bytes))) - case RawBodyType.FileBody => ME.error(new UnsupportedOperationException) - case _: RawBodyType.MultipartBody => ME.error(new UnsupportedOperationException) + case RawBodyType.StringBody(charset) => ME.unit(RawValue(new String(bytes, charset))) + case RawBodyType.ByteArrayBody => ME.unit(RawValue(bytes)) + case RawBodyType.ByteBufferBody => ME.unit(RawValue(ByteBuffer.wrap(bytes))) + case RawBodyType.InputStreamBody => ME.unit(RawValue(new ByteArrayInputStream(bytes))) + case RawBodyType.FileBody => ME.error(new UnsupportedOperationException) + case RawBodyType.InputStreamRangeBody => ME.unit(RawValue(InputStreamRange(() => new ByteArrayInputStream(bytes)))) + case _: RawBodyType.MultipartBody => ME.error(new UnsupportedOperationException) } case _ => throw new IllegalArgumentException("Stream body provided while endpoint accepts raw body type") } diff --git a/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpRequestDecoder.scala b/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpRequestDecoder.scala index 76af95c6c3..a0ca32e481 100644 --- a/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpRequestDecoder.scala +++ b/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpRequestDecoder.scala @@ -4,6 +4,7 @@ import sttp.client3.testing._ import sttp.client3.{Request, StreamBody} import sttp.model._ import sttp.tapir.internal.RichOneOfBody +import sttp.tapir.InputStreamRange import sttp.tapir.server.interpreter.{DecodeBasicInputs, DecodeBasicInputsResult, DecodeInputsContext, RawValue} import sttp.tapir.{DecodeResult, EndpointIO, EndpointInput, RawBodyType} @@ -55,12 +56,13 @@ private[stub] object SttpRequestDecoder { private def rawBody[RAW](request: Request[_, _], body: EndpointIO.Body[RAW, _]): RAW = { val asByteArray = request.forceBodyAsByteArray body.bodyType match { - case RawBodyType.StringBody(charset) => new String(asByteArray, charset) - case RawBodyType.ByteArrayBody => asByteArray - case RawBodyType.ByteBufferBody => ByteBuffer.wrap(asByteArray) - case RawBodyType.InputStreamBody => new ByteArrayInputStream(asByteArray) - case RawBodyType.FileBody => throw new UnsupportedOperationException - case _: RawBodyType.MultipartBody => throw new UnsupportedOperationException + case RawBodyType.StringBody(charset) => new String(asByteArray, charset) + case RawBodyType.ByteArrayBody => asByteArray + case RawBodyType.ByteBufferBody => ByteBuffer.wrap(asByteArray) + case RawBodyType.InputStreamBody => new ByteArrayInputStream(asByteArray) + case RawBodyType.FileBody => throw new UnsupportedOperationException + case RawBodyType.InputStreamRangeBody => new InputStreamRange(() => new ByteArrayInputStream(asByteArray)) + case _: RawBodyType.MultipartBody => throw new UnsupportedOperationException } } } diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/AllServerTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/AllServerTests.scala index 01ac39c529..96eea0ee72 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/AllServerTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/AllServerTests.scala @@ -41,7 +41,7 @@ class AllServerTests[F[_], OPTIONS, ROUTE]( (if (multipart) new ServerMultipartTests(createServerTest).tests() else Nil) ++ (if (oneOf) new ServerOneOfTests(createServerTest).tests() else Nil) ++ (if (reject) new ServerRejectTests(createServerTest, serverInterpreter).tests() else Nil) ++ - (if (staticContent) new ServerStaticContentTests(serverInterpreter, backend).tests() else Nil) ++ + (if (staticContent) new ServerFilesTests(serverInterpreter, backend).tests() else Nil) ++ (if (validation) new ServerValidationTests(createServerTest).tests() else Nil) ++ (if (oneOfBody) new ServerOneOfBodyTests(createServerTest).tests() else Nil) ++ (if (cors) new ServerCORSTests(createServerTest).tests() else Nil) ++ diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStaticContentTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerFilesTests.scala similarity index 61% rename from server/tests/src/main/scala/sttp/tapir/server/tests/ServerStaticContentTests.scala rename to server/tests/src/main/scala/sttp/tapir/server/tests/ServerFilesTests.scala index 45cff1c455..0af3bcdd6f 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStaticContentTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerFilesTests.scala @@ -9,8 +9,9 @@ import sttp.capabilities.fs2.Fs2Streams import sttp.client3._ import sttp.model._ import sttp.tapir._ +import sttp.tapir.files._ import sttp.tapir.server.ServerEndpoint -import sttp.tapir.static.{FilesOptions, ResourcesOptions} +import sttp.tapir.files.FilesOptions import sttp.tapir.server.interceptor.CustomiseInterceptors import sttp.tapir.server.interceptor.decodefailure.DefaultDecodeFailureHandler import sttp.tapir.tests._ @@ -19,8 +20,10 @@ import java.io.File import java.nio.file.{Files, StandardOpenOption} import java.util.Comparator import scala.concurrent.Future +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPOutputStream -class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( +class ServerFilesTests[F[_], OPTIONS, ROUTE]( serverInterpreter: TestServerInterpreter[F, Any, OPTIONS, ROUTE], backend: SttpBackend[IO, Fs2Streams[IO] with WebSockets], supportSettingContentLength: Boolean = true @@ -36,13 +39,13 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( .response(asStringAlways) .send(backend) - private val classLoader = classOf[ServerStaticContentTests[F, OPTIONS, ROUTE]].getClassLoader + private val classLoader = classOf[ServerFilesTests[F, OPTIONS, ROUTE]].getClassLoader def tests(): List[Test] = { val baseTests = List( Test("should serve files from the given system path") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) .use { port => get(port, "f1" :: Nil).map(_.body shouldBe "f1 content") >> get(port, "f2" :: Nil).map(_.body shouldBe "f2 content") >> @@ -54,7 +57,7 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( }, Test("should serve files from the given system path with a prefix") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F]("static" / "content")(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F]("static" / "content")(testDir.getAbsolutePath)) .use { port => get(port, "static" :: "content" :: "f1" :: Nil).map(_.body shouldBe "f1 content") >> get(port, "static" :: "content" :: "d1" :: "f3" :: Nil).map(_.body shouldBe "f3 content") @@ -64,7 +67,7 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( }, Test("should serve index.html when a directory is requested") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) .use { port => get(port, List("d1")).map(_.body shouldBe "index content") } @@ -74,7 +77,7 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( Test("should serve files from the given system path with filter") { withTestFilesDirectory { testDir => serveRoute( - filesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath, FilesOptions.default.fileFilter(_.exists(_.contains("2")))) + staticFilesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath, FilesOptions.default.fileFilter(_.exists(_.contains("2")))) ) .use { port => get(port, "f1" :: Nil).map(_.code shouldBe StatusCode.NotFound) >> @@ -85,10 +88,10 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( .unsafeToFuture() } }, - Test("should return acceptRanges for head request") { + Test("should return acceptRanges for file head request") { withTestFilesDirectory { testDir => val file = testDir.toPath.resolve("f1").toFile - serveRoute(filesHeadServerEndpoint("test")(testDir.getAbsolutePath)) + serveRoute(staticFilesHeadServerEndpoint("test")(testDir.getAbsolutePath)) .use { port => head(port, List("test", "f1")) .map { r => @@ -100,44 +103,43 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( .unsafeToFuture() } }, - Test("should create head and get endpoints") { + Test("should handle ranged HEAD request like a GET request") { withTestFilesDirectory { testDir => - val file = testDir.toPath.resolve("f2").toFile - val headAndGetEndpoint = filesServerEndpoints[F]("test")(testDir.getAbsolutePath) - serverInterpreter - .server(NonEmptyList.of(serverInterpreter.route(headAndGetEndpoint))) + val file = testDir.toPath.resolve("f1").toFile + serveRoute(staticFilesHeadServerEndpoint("test")(testDir.getAbsolutePath)) .use { port => - head(port, List("test", "f2")) + head(port, List("test", "f1")) .map { r => r.code shouldBe StatusCode.Ok r.headers contains Header(HeaderNames.AcceptRanges, ContentRangeUnits.Bytes) shouldBe true r.headers contains Header(HeaderNames.ContentLength, file.length().toString) shouldBe true - } >> - basicRequest - .headers(Header(HeaderNames.Range, "bytes=3-6")) - .get(uri"http://localhost:$port/test/f2") - .response(asStringAlways) - .send(backend) - .map(r => { - r.body shouldBe "cont" - r.code shouldBe StatusCode.PartialContent - r.body.length shouldBe 4 - r.headers contains Header(HeaderNames.ContentRange, "bytes 3-6/10") shouldBe true - }) + } } .unsafeToFuture() } }, + Test("should return acceptRanges for resource head request") { + serveRoute(staticResourcesHeadServerEndpoint(emptyInput)(classLoader, "test/r3.txt")) + .use { port => + head(port, List("")) + .map { r => + r.code shouldBe StatusCode.Ok + r.headers contains Header(HeaderNames.AcceptRanges, ContentRangeUnits.Bytes) shouldBe true + r.headers contains Header(HeaderNames.ContentLength, "10") shouldBe true + } + } + .unsafeToFuture() + }, Test("should return 404 when files are not found") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => get(port, List("test")).map(_.code shouldBe StatusCode.NotFound) } .unsafeToFuture() } }, Test("should return 404 for HEAD request and not existing file ") { withTestFilesDirectory { testDir => - serveRoute(filesHeadServerEndpoint(emptyInput)(testDir.getAbsolutePath)) + serveRoute(staticFilesHeadServerEndpoint(emptyInput)(testDir.getAbsolutePath)) .use { port => head(port, List("test")).map(_.code shouldBe StatusCode.NotFound) } .unsafeToFuture() } @@ -145,7 +147,7 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( Test("should return default file when file is not found") { withTestFilesDirectory { testDir => serveRoute( - filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath, FilesOptions.default.defaultFile(List("d1", "index.html"))) + staticFilesGetServerEndpoint[F]("test")(testDir.getAbsolutePath, FilesOptions.default.defaultFile(List("d1", "index.html"))) ) .use { port => get(port, List("test", "f10")).map(_.body shouldBe "index content") >> @@ -154,23 +156,44 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( .unsafeToFuture() } }, + Test("should return pre-gzipped files") { + withTestFilesDirectory { testDir => + serveRoute( + staticFilesGetServerEndpoint[F]("test")(testDir.getAbsolutePath, FilesOptions.default.withUseGzippedIfAvailable) + ) + .use { port => + emptyRequest + .acceptEncoding("gzip") + .get(uri"http://localhost:$port/test/img.gif") + .response(asStringAlways) + .send(backend) + .map(r => { + r.code shouldBe StatusCode.Ok + r.body shouldBe "img gzipped content" + r.headers contains Header(HeaderNames.ContentEncoding, "gzip") shouldBe true + r.headers contains Header(HeaderNames.ContentType, MediaType.ApplicationGzip.toString()) shouldBe true + }) + } + .unsafeToFuture() + } + }, Test("should return whole while file if range header not present") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => get(port, List("test", "f2")).map(_.body shouldBe "f2 content") } .unsafeToFuture() } }, Test("should return 200 status code for whole file") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => get(port, List("test", "f2")).map(_.code shouldBe StatusCode.Ok) } .unsafeToFuture() } }, Test("should return 416 if over range") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=0-11")) @@ -184,7 +207,7 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( }, Test("should return content range header with matching bytes") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=1-3")) @@ -198,7 +221,7 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( }, Test("should return 206 status code for partial content") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=1-3")) @@ -212,7 +235,7 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( }, Test("should return bytes 4-7 from file") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=4-7")) @@ -226,7 +249,7 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( }, Test("should return bytes 100000-200000 from file") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=100000-200000")) @@ -243,7 +266,7 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( }, Test("should return bytes from 100000 from file") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=100000-")) @@ -260,7 +283,7 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( }, Test("should return last 100000 bytes from file") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=-100000")) @@ -277,7 +300,7 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( }, Test("should fail for incorrect range") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=-")) @@ -289,33 +312,9 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( .unsafeToFuture() } }, - Test("if an etag is present, should only return the file if it doesn't match the etag") { - withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) - .use { port => - def get(etag: Option[String]) = basicRequest - .get(uri"http://localhost:$port/f1") - .header(HeaderNames.IfNoneMatch, etag) - .response(asStringAlways) - .send(backend) - - get(None).flatMap { r1 => - r1.code shouldBe StatusCode.Ok - val etag = r1.header(HeaderNames.Etag).get - - get(Some(etag)).map { r2 => - r2.code shouldBe StatusCode.NotModified - } >> get(Some(etag.replace("-", "-x"))).map { r2 => - r2.code shouldBe StatusCode.Ok - } - } - } - .unsafeToFuture() - } - }, Test("should return file metadata") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) + serveRoute(staticFilesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) .use { port => get(port, List("img.gif")) map { r => r.contentLength shouldBe Some(11) @@ -332,25 +331,39 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( }, Test("should not return a file outside of the system path") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath + "/d1")) + serveRoute(staticFilesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath + "/d1")) .use { port => get(port, List("..", "f1")).map(_.body should not be "f1 content") } .unsafeToFuture() } }, Test("should not return a file outside of the system path, when the path is given as a single segment") { withTestFilesDirectory { testDir => - serveRoute(filesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath + "/d1")) + serveRoute(staticFilesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath + "/d1")) .use { port => get(port, List("../f1")).map(_.body should not be "f1 content") } .unsafeToFuture() } }, Test("should serve a single resource") { - serveRoute(resourceGetServerEndpoint[F](emptyInput)(classLoader, "test/r1.txt")) + serveRoute(staticResourceGetServerEndpoint[F](emptyInput)(classLoader, "test/r1.txt")) .use { port => get(port, Nil).map(_.body shouldBe "Resource 1") } .unsafeToFuture() }, - Test("should serve single gzipped resource") { - serveRoute(resourceGetServerEndpoint[F](emptyInput)(classLoader, "test/r3.txt", ResourcesOptions.default.withUseGzippedIfAvailable)) + Test("should serve a single resource range") { + serveRoute(staticResourceGetServerEndpoint[F](emptyInput)(classLoader, "test/r1.txt")) + .use { port => + basicRequest + .get(uri"http://localhost:$port") + .headers(Header(HeaderNames.Range, "bytes=6-8")) + .response(asStringAlways) + .send(backend) + .map(_.body shouldBe "ce ") + } + .unsafeToFuture() + }, + Test("should serve single pre-gzipped resource") { + serveRoute( + staticResourceGetServerEndpoint[F](emptyInput)(classLoader, "test/r3.txt", FilesOptions.default.withUseGzippedIfAvailable) + ) .use { port => emptyRequest .acceptEncoding("gzip") @@ -367,7 +380,7 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( .unsafeToFuture() }, Test("should return 404 for resources without extension") { - serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) + serveRoute(staticResourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) .use { port => emptyRequest .get(uri"http://localhost:$port/r3") @@ -378,7 +391,9 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( .unsafeToFuture() }, Test("should serve resource at path for preGzipped endpoint without correct header") { - serveRoute(resourceGetServerEndpoint[F](emptyInput)(classLoader, "test/r3.txt", ResourcesOptions.default.withUseGzippedIfAvailable)) + serveRoute( + staticResourceGetServerEndpoint[F](emptyInput)(classLoader, "test/r3.txt", FilesOptions.default.withUseGzippedIfAvailable) + ) .use { port => emptyRequest .get(uri"http://localhost:$port") @@ -389,17 +404,17 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( .unsafeToFuture() }, Test("should not return a resource outside of the resource prefix directory") { - serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) + serveRoute(staticResourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) .use { port => get(port, List("..", "test", "r5.txy")).map(_.body should not be "Resource 5") } .unsafeToFuture() }, Test("should not return a resource outside of the resource prefix directory, when the path is given as a single segment") { - serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) + serveRoute(staticResourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) .use { port => get(port, List("../test2/r5.txt")).map(_.body should not be "Resource 5") } .unsafeToFuture() }, Test("should serve resources") { - serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) + serveRoute(staticResourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) .use { port => get(port, "r1.txt" :: Nil).map(_.body shouldBe "Resource 1") >> get(port, "r2.txt" :: Nil).map(_.body shouldBe "Resource 2") >> @@ -410,10 +425,10 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( }, Test("should serve resources with filter") { serveRoute( - resourcesGetServerEndpoint[F](emptyInput)( + staticResourcesGetServerEndpoint[F](emptyInput)( classLoader, "test", - ResourcesOptions.default.resourceFilter(_.exists(_.contains("2"))) + FilesOptions.default.fileFilter(_.exists(_.contains("2"))) ) ) .use { port => @@ -425,29 +440,32 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( .unsafeToFuture() }, Test("should return 404 when a resource is not found") { - serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) + serveRoute(staticResourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) .use { port => get(port, List("r3")).map(_.code shouldBe StatusCode.NotFound) } .unsafeToFuture() }, - Test("should return default resource when resource is not found") { + Test("should return default resource and its etag, when resource is not found") { serveRoute( - resourcesGetServerEndpoint[F]("test")(classLoader, "test", ResourcesOptions.default.defaultResource(List("d1", "r3.txt"))) + staticResourcesGetServerEndpoint[F]("test")(classLoader, "test", FilesOptions.default.defaultFile(List("d1", "r3.txt"))) ) .use { port => - get(port, List("test", "r10.txt")).map(_.body shouldBe "Resource 3") >> + get(port, List("test", "r10.txt")).map { resp => + resp.body shouldBe "Resource 3" + resp.header(HeaderNames.Etag).get should endWith("-a\"") + } >> get(port, List("test", "r1.txt")).map(_.body shouldBe "Resource 1") } .unsafeToFuture() }, Test("should serve a single file from the given system path") { withTestFilesDirectory { testDir => - serveRoute(fileGetServerEndpoint[F]("test")(testDir.toPath.resolve("f1").toFile.getAbsolutePath)) + serveRoute(staticFileGetServerEndpoint[F]("test")(testDir.toPath.resolve("f1").toFile.getAbsolutePath)) .use { port => get(port, List("test")).map(_.body shouldBe "f1 content") } .unsafeToFuture() } }, Test("if an etag is present, should only return the resource if it doesn't match the etag") { - serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) + serveRoute(staticResourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) .use { port => def get(etag: Option[String]) = basicRequest .get(uri"http://localhost:$port/r1.txt") @@ -469,43 +487,150 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( .unsafeToFuture() }, Test("should serve index.html when a resource directory is requested (from file)") { - serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) + serveRoute(staticResourcesGetServerEndpoint[F](emptyInput)(classLoader, "test")) .use { port => get(port, List("d1")).map(_.body shouldBe "Index resource") } .unsafeToFuture() }, Test("should serve a resource from a jar") { - serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classLoader, "META-INF/maven/org.slf4j/slf4j-api")) + serveRoute(staticResourcesGetServerEndpoint[F](emptyInput)(classLoader, "META-INF/maven/org.slf4j/slf4j-api")) .use { port => get(port, List("pom.properties")).map(_.body should include("groupId=org.slf4j")) } .unsafeToFuture() }, Test("should return 404 when a resource directory is requested from jar and index.html does not exist") { - serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classLoader, "META-INF/maven/org.slf4j/slf4j-api")) + serveRoute(staticResourcesGetServerEndpoint[F](emptyInput)(classLoader, "META-INF/maven/org.slf4j/slf4j-api")) .use { port => get(port, Nil).map(_.code shouldBe StatusCode.NotFound) } .unsafeToFuture() } ) - val resourceMetadataTest = Test("should return resource metadata") { - serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classOf[ServerStaticContentTests[F, OPTIONS, ROUTE]].getClassLoader, "test")) - .use { port => - get(port, List("r1.txt")).map { r => - r.contentLength shouldBe Some(10) - r.contentType shouldBe Some(MediaType.TextPlain.toString()) - r.header(HeaderNames.LastModified) - .flatMap(t => Header.parseHttpDate(t).toOption) - .map(_.toEpochMilli) - .get should be > (1629180000000L) // 8:00 17 Aug 2021 when the test was written - r.header(HeaderNames.Etag).isDefined shouldBe true + val resourceMetadataTests = List( + Test("should return resource metadata") { + serveRoute( + staticResourcesGetServerEndpoint[F](emptyInput)(classLoader, "test") + ) + .use { port => + get(port, List("r1.txt")).map { r => + r.contentLength shouldBe Some(10) + r.contentType shouldBe Some(MediaType.TextPlain.toString()) + r.header(HeaderNames.LastModified) + .flatMap(t => Header.parseHttpDate(t).toOption) + .map(_.toEpochMilli) + .get should be > (1629180000000L) // 8:00 17 Aug 2021 when the test was written + r.header(HeaderNames.Etag).isDefined shouldBe true + } + } + .unsafeToFuture() + }, + Test("should create both head and get endpoints for files") { + withTestFilesDirectory { testDir => + val headAndGetEndpoint = staticFilesServerEndpoints[F]("test")(testDir.getAbsolutePath) + serverInterpreter + .server(NonEmptyList.of(serverInterpreter.route(headAndGetEndpoint))) + .use { port => + basicRequest + .headers(Header(HeaderNames.Range, "bytes=3-6")) + .head(uri"http://localhost:$port/test/f2") + .response(asStringAlways) + .send(backend) + .map(r => { + r.body shouldBe "" + r.code shouldBe StatusCode.PartialContent + r.body.length shouldBe 0 + r.headers contains Header(HeaderNames.ContentRange, "bytes 3-6/10") shouldBe true + }) >> + basicRequest + .headers(Header(HeaderNames.Range, "bytes=3-6")) + .get(uri"http://localhost:$port/test/f2") + .response(asStringAlways) + .send(backend) + .map(r => { + r.body shouldBe "cont" + r.code shouldBe StatusCode.PartialContent + r.body.length shouldBe 4 + r.headers contains Header(HeaderNames.ContentRange, "bytes 3-6/10") shouldBe true + }) + } + .unsafeToFuture() + } + }, + Test("should create both head and get endpoints for resources") { + val headAndGetEndpoint = staticResourcesServerEndpoints[F](emptyInput)(classLoader, "test") + serverInterpreter + .server(NonEmptyList.of(serverInterpreter.route(headAndGetEndpoint))) + .use { port => + basicRequest + .headers(Header(HeaderNames.Range, "bytes=6-9")) + .head(uri"http://localhost:$port/r1.txt") + .response(asStringAlways) + .send(backend) + .map(r => { + r.body shouldBe "" + r.code shouldBe StatusCode.PartialContent + r.body.length shouldBe 0 + r.headers contains Header(HeaderNames.ContentRange, "bytes 6-9/10") shouldBe true + }) >> + basicRequest + .headers(Header(HeaderNames.Range, "bytes=6-9")) + .get(uri"http://localhost:$port/r1.txt") + .response(asStringAlways) + .send(backend) + .map(r => { + r.body shouldBe "ce 1" + r.code shouldBe StatusCode.PartialContent + r.body.length shouldBe 4 + r.headers contains Header(HeaderNames.ContentRange, "bytes 6-9/10") shouldBe true + }) } + .unsafeToFuture() + }, + Test("if an etag is present, should only return the file if it doesn't match the etag") { + withTestFilesDirectory { testDir => + + val headAndGetEndpoint = staticFilesServerEndpoints[F](emptyInput)(testDir.getAbsolutePath) + serverInterpreter + .server(NonEmptyList.of(serverInterpreter.route(headAndGetEndpoint))) + .use { port => + def testHttpMethod(method: Request[Either[String, String], Any]) = { + def send(etag: Option[String]) = + method + .header(HeaderNames.IfNoneMatch, etag) + .response(asStringAlways) + .send(backend) + send(etag = None).flatMap { r1 => + r1.code shouldBe StatusCode.Ok + val etag = r1.header(HeaderNames.Etag).get + + send(etag = Some(etag)).map { r2 => + r2.code shouldBe StatusCode.NotModified + } >> send(Some(etag.replace("-", "-x"))).map { r2 => + r2.code shouldBe StatusCode.Ok + } + } + } + testHttpMethod( + basicRequest + .get(uri"http://localhost:$port/f1") + ) >> + testHttpMethod( + basicRequest + .head(uri"http://localhost:$port/f1") + ) + } + .unsafeToFuture() } - .unsafeToFuture() - } - if (supportSettingContentLength) baseTests :+ resourceMetadataTest + }, + Test("should not return resource metadata outside of the resource prefix directory") { + serveRoute(staticResourcesHeadServerEndpoint[F](emptyInput)(classLoader, "test")) + .use { port => head(port, List("..", "test", "r5.txy")).map(_.code.isClientError shouldBe true) } + .unsafeToFuture() + } + ) + if (supportSettingContentLength) baseTests ++ resourceMetadataTests else baseTests } @@ -516,6 +641,14 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( ) ) + def gzipCompress(input: String): Array[Byte] = { + val outputStream = new ByteArrayOutputStream() + val gzipOutputStream = new GZIPOutputStream(outputStream) + gzipOutputStream.write(input.getBytes("UTF-8")) + gzipOutputStream.close() + outputStream.toByteArray + } + def withTestFilesDirectory[T](t: File => Future[T]): Future[T] = { val parent = Files.createTempDirectory("tapir-tests") @@ -524,6 +657,7 @@ class ServerStaticContentTests[F[_], OPTIONS, ROUTE]( Files.write(parent.resolve("f1"), "f1 content".getBytes, StandardOpenOption.CREATE_NEW) Files.write(parent.resolve("f2"), "f2 content".getBytes, StandardOpenOption.CREATE_NEW) Files.write(parent.resolve("img.gif"), "img content".getBytes, StandardOpenOption.CREATE_NEW) + Files.write(parent.resolve("img.gif.gz"), gzipCompress("img gzipped content"), StandardOpenOption.CREATE_NEW) Files.write(parent.resolve("d1/f3"), "f3 content".getBytes, StandardOpenOption.CREATE_NEW) Files.write(parent.resolve("d1/index.html"), "index content".getBytes, StandardOpenOption.CREATE_NEW) Files.write(parent.resolve("d1/d2/f4"), "f4 content".getBytes, StandardOpenOption.CREATE_NEW) diff --git a/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/decoders/VertxRequestBody.scala b/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/decoders/VertxRequestBody.scala index 1af5defc50..b6f2238476 100644 --- a/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/decoders/VertxRequestBody.scala +++ b/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/decoders/VertxRequestBody.scala @@ -1,13 +1,11 @@ package sttp.tapir.server.vertx.decoders import io.vertx.core.Future -import io.vertx.core.buffer.Buffer -import io.vertx.core.streams.ReadStream import io.vertx.ext.web.{FileUpload, RoutingContext} import sttp.capabilities.Streams import sttp.model.Part import sttp.tapir.model.ServerRequest -import sttp.tapir.{FileRange, RawBodyType} +import sttp.tapir.{FileRange, InputStreamRange, RawBodyType} import sttp.tapir.server.interpreter.{RawValue, RequestBody} import sttp.tapir.server.vertx.VertxServerOptions import sttp.tapir.server.vertx.interpreters.FromVFuture @@ -40,6 +38,9 @@ class VertxRequestBody[F[_], S <: Streams[S]]( case RawBodyType.InputStreamBody => val bytes = Option(rc.getBody).fold(Array.emptyByteArray)(_.getBytes) Future.succeededFuture(RawValue(new ByteArrayInputStream(bytes))) + case RawBodyType.InputStreamRangeBody => + val bytes = Option(rc.getBody).fold(Array.emptyByteArray)(_.getBytes) + Future.succeededFuture(RawValue(InputStreamRange(() => new ByteArrayInputStream(bytes)))) case RawBodyType.FileBody => rc.fileUploads().asScala.headOption match { case Some(upload) => @@ -101,23 +102,25 @@ class VertxRequestBody[F[_], S <: Streams[S]]( private def extractStringPart[B](part: String, bodyType: RawBodyType[B]): Option[Any] = { bodyType match { - case RawBodyType.StringBody(charset) => Some(new String(part.getBytes(Charset.defaultCharset()), charset)) - case RawBodyType.ByteArrayBody => Some(part.getBytes(Charset.defaultCharset())) - case RawBodyType.ByteBufferBody => Some(ByteBuffer.wrap(part.getBytes(Charset.defaultCharset()))) - case RawBodyType.InputStreamBody => throw new IllegalArgumentException("Cannot create a multipart as an InputStream") - case RawBodyType.FileBody => None - case RawBodyType.MultipartBody(_, _) => None + case RawBodyType.StringBody(charset) => Some(new String(part.getBytes(Charset.defaultCharset()), charset)) + case RawBodyType.ByteArrayBody => Some(part.getBytes(Charset.defaultCharset())) + case RawBodyType.ByteBufferBody => Some(ByteBuffer.wrap(part.getBytes(Charset.defaultCharset()))) + case RawBodyType.InputStreamBody => throw new IllegalArgumentException("Cannot create a multipart as an InputStream") + case RawBodyType.InputStreamRangeBody => throw new IllegalArgumentException("Cannot create a multipart as an InputStream") + case RawBodyType.FileBody => None + case RawBodyType.MultipartBody(_, _) => None } } private def extractFilePart[B](fu: FileUpload, bodyType: RawBodyType[B]): Option[Any] = { bodyType match { - case RawBodyType.StringBody(charset) => Some(new String(readFileBytes(fu), charset)) - case RawBodyType.ByteArrayBody => Some(readFileBytes(fu)) - case RawBodyType.ByteBufferBody => Some(ByteBuffer.wrap(readFileBytes(fu))) - case RawBodyType.InputStreamBody => throw new IllegalArgumentException("Cannot create a multipart as an InputStream") - case RawBodyType.FileBody => Some(FileRange(new File(fu.uploadedFileName()))) - case RawBodyType.MultipartBody(_, _) => None + case RawBodyType.StringBody(charset) => Some(new String(readFileBytes(fu), charset)) + case RawBodyType.ByteArrayBody => Some(readFileBytes(fu)) + case RawBodyType.ByteBufferBody => Some(ByteBuffer.wrap(readFileBytes(fu))) + case RawBodyType.InputStreamBody => throw new IllegalArgumentException("Cannot create a multipart as an InputStream") + case RawBodyType.InputStreamRangeBody => throw new IllegalArgumentException("Cannot create a multipart as an InputStream") + case RawBodyType.FileBody => Some(FileRange(new File(fu.uploadedFileName()))) + case RawBodyType.MultipartBody(_, _) => None } } diff --git a/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/encoders/VertxToResponseBody.scala b/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/encoders/VertxToResponseBody.scala index 4ddb243da9..3272ed7551 100644 --- a/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/encoders/VertxToResponseBody.scala +++ b/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/encoders/VertxToResponseBody.scala @@ -14,6 +14,7 @@ import sttp.tapir.server.vertx.streams.{Pipe, ReadStreamCompatible} import java.io.InputStream import java.nio.ByteBuffer import java.nio.charset.Charset +import sttp.tapir.InputStreamRange class VertxToResponseBody[F[_], S <: Streams[S]](serverOptions: VertxServerOptions[F])(implicit val readStreamCompatible: ReadStreamCompatible[S] @@ -28,12 +29,16 @@ class VertxToResponseBody[F[_], S <: Streams[S]](serverOptions: VertxServerOptio case RawBodyType.ByteArrayBody => resp.end(Buffer.buffer(v.asInstanceOf[Array[Byte]])) case RawBodyType.ByteBufferBody => resp.end(Buffer.buffer().setBytes(0, v.asInstanceOf[ByteBuffer])) case RawBodyType.InputStreamBody => - inputStreamToBuffer(v.asInstanceOf[InputStream], rc.vertx).flatMap(resp.end) + inputStreamToBuffer(v, rc.vertx, byteLimit = None).flatMap(resp.end) + case RawBodyType.InputStreamRangeBody => + v.range + .map(r => inputStreamToBuffer(v.inputStreamFromRangeStart(), rc.vertx, Some(r.contentLength))) + .getOrElse(inputStreamToBuffer(v.inputStream(), rc.vertx, byteLimit = None)) + .flatMap(resp.end) case RawBodyType.FileBody => - val tapirFile = v.asInstanceOf[FileRange] - tapirFile.range - .flatMap(r => r.startAndEnd.map(s => resp.sendFile(tapirFile.file.toPath.toString, s._1, r.contentLength))) - .getOrElse(resp.sendFile(tapirFile.file.toString)) + v.range + .flatMap(r => r.startAndEnd.map(s => resp.sendFile(v.file.toPath.toString, s._1, r.contentLength))) + .getOrElse(resp.sendFile(v.file.toString)) case m: RawBodyType.MultipartBody => handleMultipleBodyParts(m, v)(serverOptions)(rc) } } @@ -129,7 +134,11 @@ class VertxToResponseBody[F[_], S <: Streams[S]](serverOptions: VertxServerOptio case RawBodyType.ByteBufferBody => resp.write(Buffer.buffer.setBytes(0, r.asInstanceOf[ByteBuffer])) case RawBodyType.InputStreamBody => - inputStreamToBuffer(r.asInstanceOf[InputStream], rc.vertx).flatMap(resp.write) + inputStreamToBuffer(r.asInstanceOf[InputStream], rc.vertx, byteLimit = None).flatMap(resp.write) + case RawBodyType.InputStreamRangeBody => + val resource = r.asInstanceOf[InputStreamRange] + val byteLimit = resource.range.map(_.contentLength) + inputStreamToBuffer(resource.inputStream(), rc.vertx, byteLimit).flatMap(resp.end) case RawBodyType.FileBody => val file = r.asInstanceOf[FileRange].file rc.vertx.fileSystem diff --git a/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/encoders/package.scala b/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/encoders/package.scala index 7ee4b5a0e6..3bad95fa27 100644 --- a/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/encoders/package.scala +++ b/server/vertx-server/src/main/scala/sttp/tapir/server/vertx/encoders/package.scala @@ -9,6 +9,7 @@ import io.vertx.core.buffer.Buffer import io.vertx.core.http.{ServerWebSocket, WebSocketFrameType} import io.vertx.core.streams.ReadStream import sttp.ws.WebSocketFrame +import scala.annotation.tailrec package object encoders { @@ -17,23 +18,30 @@ package object encoders { /** README: Tests are using a ByteArrayInputStream, which is totally fine, but other blocking implementations like FileInputStream etc. * must maybe be wrapped in executeBlocking */ - private[vertx] def inputStreamToBuffer(is: InputStream, vertx: Vertx): Future[Buffer] = { + private[vertx] def inputStreamToBuffer(is: InputStream, vertx: Vertx, byteLimit: Option[Long]): Future[Buffer] = { is match { case _: ByteArrayInputStream => - Future.succeededFuture(inputStreamToBufferUnsafe(is)) + Future.succeededFuture(inputStreamToBufferUnsafe(is, byteLimit)) case _ => - vertx.executeBlocking { promise => promise.complete(inputStreamToBufferUnsafe(is)) } + vertx.executeBlocking { promise => promise.complete(inputStreamToBufferUnsafe(is, byteLimit)) } } } - private def inputStreamToBufferUnsafe(is: InputStream): Buffer = { + private def inputStreamToBufferUnsafe(is: InputStream, byteLimit: Option[Long]): Buffer = { val buffer = Buffer.buffer() - val buf = new Array[Byte](bufferSize) - while (is.available() > 0) { - val read = is.read(buf) - buffer.appendBytes(buf, 0, read) - } - buffer + + @tailrec + def readRec(buffer: Buffer, readSoFar: Long): Buffer = + if (byteLimit.exists(_ <= readSoFar) || is.available() <= 0) + buffer + else { + val bytes = is.readNBytes(bufferSize) + val length = bytes.length.toLong + val lengthToWrite: Int = byteLimit.map(limit => Math.min(limit - readSoFar, length)).getOrElse(length).toInt + readRec(buffer.appendBytes(bytes, 0, lengthToWrite), readSoFar = readSoFar + lengthToWrite) + } + + readRec(buffer, readSoFar = 0L) } def wrapWebSocket(websocket: ServerWebSocket): ReadStream[WebSocketFrame] = diff --git a/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpRequestBody.scala b/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpRequestBody.scala index 077cabf836..5e324cfbfb 100644 --- a/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpRequestBody.scala +++ b/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpRequestBody.scala @@ -2,7 +2,7 @@ package sttp.tapir.server.ziohttp import sttp.capabilities import sttp.capabilities.zio.ZioStreams -import sttp.tapir.FileRange +import sttp.tapir.{FileRange, InputStreamRange} import sttp.tapir.RawBodyType import sttp.tapir.model.ServerRequest import sttp.tapir.server.interpreter.RawValue @@ -22,6 +22,8 @@ class ZioHttpRequestBody[R](serverOptions: ZioHttpServerOptions[R]) extends Requ case RawBodyType.ByteArrayBody => asByteArray(serverRequest).map(RawValue(_)) case RawBodyType.ByteBufferBody => asByteArray(serverRequest).map(bytes => ByteBuffer.wrap(bytes)).map(RawValue(_)) case RawBodyType.InputStreamBody => asByteArray(serverRequest).map(new ByteArrayInputStream(_)).map(RawValue(_)) + case RawBodyType.InputStreamRangeBody => + asByteArray(serverRequest).map(bytes => new InputStreamRange(() => new ByteArrayInputStream(bytes))).map(RawValue(_)) case RawBodyType.FileBody => serverOptions.createFile(serverRequest).map(d => FileRange(d)).flatMap(file => ZIO.succeed(RawValue(file, Seq(file)))) case RawBodyType.MultipartBody(_, _) => ZIO.fail(new UnsupportedOperationException("Multipart is not supported")) diff --git a/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpToResponseBody.scala b/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpToResponseBody.scala index 244c7629eb..9cd5d00e1e 100644 --- a/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpToResponseBody.scala +++ b/server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpToResponseBody.scala @@ -3,7 +3,7 @@ package sttp.tapir.server.ziohttp import sttp.capabilities.zio.ZioStreams import sttp.model.HasHeaders import sttp.tapir.server.interpreter.ToResponseBody -import sttp.tapir.{CodecFormat, FileRange, RawBodyType, WebSocketBodyOutput} +import sttp.tapir.{CodecFormat, RawBodyType, WebSocketBodyOutput} import zio.Chunk import zio.stream.ZStream @@ -36,23 +36,26 @@ class ZioHttpToResponseBody extends ToResponseBody[ZioHttpResponseBody, ZioStrea case RawBodyType.ByteArrayBody => (ZStream.fromChunk(Chunk.fromArray(r)), Some((r: Array[Byte]).length.toLong)) case RawBodyType.ByteBufferBody => (ZStream.fromChunk(Chunk.fromByteBuffer(r)), None) case RawBodyType.InputStreamBody => (ZStream.fromInputStream(r), None) + case RawBodyType.InputStreamRangeBody => + r.range + .map(range => (ZStream.fromInputStream(r.inputStreamFromRangeStart()).take(range.contentLength), Some(range.contentLength))) + .getOrElse((ZStream.fromInputStream(r.inputStream()), None)) case RawBodyType.FileBody => - val tapirFile = r.asInstanceOf[FileRange] - val stream = tapirFile.range + val tapirFile = r + tapirFile.range .flatMap { r => r.startAndEnd.map { s => var count = 0L - ZStream + (ZStream .fromPath(tapirFile.file.toPath) .dropWhile(_ => if (count < s._1) { count += 1; true } else false ) - .take(r.contentLength) + .take(r.contentLength), Some(r.contentLength)) } } - .getOrElse(ZStream.fromPath(tapirFile.file.toPath)) - (stream, Some(tapirFile.file.length)) + .getOrElse((ZStream.fromPath(tapirFile.file.toPath), Some(tapirFile.file.length))) case RawBodyType.MultipartBody(_, _) => throw new UnsupportedOperationException("Multipart is not supported") } } diff --git a/server/zio1-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpRequestBody.scala b/server/zio1-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpRequestBody.scala index 5d943f86d0..1234bab7e8 100644 --- a/server/zio1-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpRequestBody.scala +++ b/server/zio1-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpRequestBody.scala @@ -2,7 +2,7 @@ package sttp.tapir.server.ziohttp import sttp.capabilities import sttp.capabilities.zio.ZioStreams -import sttp.tapir.FileRange +import sttp.tapir.{FileRange, InputStreamRange} import sttp.tapir.RawBodyType import sttp.tapir.model.ServerRequest import sttp.tapir.server.interpreter.RawValue @@ -23,6 +23,8 @@ class ZioHttpRequestBody[R](serverOptions: ZioHttpServerOptions[R]) extends Requ case RawBodyType.ByteArrayBody => asByteArray(serverRequest).map(RawValue(_)) case RawBodyType.ByteBufferBody => asByteArray(serverRequest).map(bytes => ByteBuffer.wrap(bytes)).map(RawValue(_)) case RawBodyType.InputStreamBody => asByteArray(serverRequest).map(new ByteArrayInputStream(_)).map(RawValue(_)) + case RawBodyType.InputStreamRangeBody => + asByteArray(serverRequest).map(bytes => new InputStreamRange(() => new ByteArrayInputStream(bytes))).map(RawValue(_)) case RawBodyType.FileBody => for { tmpFile <- serverOptions.createFile(serverRequest) diff --git a/server/zio1-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpToResponseBody.scala b/server/zio1-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpToResponseBody.scala index dd27f977c4..aa42fdd01a 100644 --- a/server/zio1-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpToResponseBody.scala +++ b/server/zio1-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpToResponseBody.scala @@ -37,14 +37,24 @@ class ZioHttpToResponseBody extends ToResponseBody[ZioHttpResponseBody, ZioStrea case RawBodyType.ByteArrayBody => (Stream.fromChunk(Chunk.fromArray(r)), Some((r: Array[Byte]).length.toLong)) case RawBodyType.ByteBufferBody => (Stream.fromChunk(Chunk.fromByteBuffer(r)), None) case RawBodyType.InputStreamBody => (ZStream.fromInputStream(r).provideLayer(Blocking.live), None) + case RawBodyType.InputStreamRangeBody => + r.range + .map(range => + ( + ZStream.fromInputStream(r.inputStreamFromRangeStart()).take(range.contentLength).provideLayer(Blocking.live), + Some(range.contentLength) + ) + ) + .getOrElse((ZStream.fromInputStream(r.inputStream()).provideLayer(Blocking.live), None)) case RawBodyType.FileBody => - val tapirFile = r.asInstanceOf[FileRange] - val stream = tapirFile.range + val tapirFile = r: FileRange + tapirFile.range .flatMap(r => - r.startAndEnd.map(s => ZStream.fromFile(tapirFile.file.toPath).drop(s._1).take(r.contentLength).provideLayer(Blocking.live)) + r.startAndEnd.map(s => + (ZStream.fromFile(tapirFile.file.toPath).drop(s._1).take(r.contentLength).provideLayer(Blocking.live), Some(r.contentLength)) + ) ) - .getOrElse(ZStream.fromFile(tapirFile.file.toPath).provideLayer(Blocking.live)) - (stream, Some(tapirFile.file.length)) + .getOrElse((ZStream.fromFile(tapirFile.file.toPath).provideLayer(Blocking.live), Some(tapirFile.file.length))) case RawBodyType.MultipartBody(_, _) => throw new UnsupportedOperationException("Multipart is not supported") } } diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsRequestBody.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsRequestBody.scala index 7b1fda99cd..c8675ed552 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsRequestBody.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsRequestBody.scala @@ -3,7 +3,7 @@ package sttp.tapir.serverless.aws.lambda import sttp.capabilities import sttp.monad.MonadError import sttp.monad.syntax._ -import sttp.tapir.RawBodyType +import sttp.tapir.{InputStreamRange, RawBodyType} import sttp.tapir.capabilities.NoStreams import sttp.tapir.model.ServerRequest import sttp.tapir.server.interpreter.{RawValue, RequestBody} @@ -23,12 +23,13 @@ private[lambda] class AwsRequestBody[F[_]: MonadError]() extends RequestBody[F, def asByteArray: Array[Byte] = decoded.fold(identity[Array[Byte]], _.getBytes()) RawValue(bodyType match { - case RawBodyType.StringBody(charset) => decoded.fold(new String(_, charset), identity[String]) - case RawBodyType.ByteArrayBody => asByteArray - case RawBodyType.ByteBufferBody => ByteBuffer.wrap(asByteArray) - case RawBodyType.InputStreamBody => new ByteArrayInputStream(asByteArray) - case RawBodyType.FileBody => throw new UnsupportedOperationException - case _: RawBodyType.MultipartBody => throw new UnsupportedOperationException + case RawBodyType.StringBody(charset) => decoded.fold(new String(_, charset), identity[String]) + case RawBodyType.ByteArrayBody => asByteArray + case RawBodyType.ByteBufferBody => ByteBuffer.wrap(asByteArray) + case RawBodyType.InputStreamBody => new ByteArrayInputStream(asByteArray) + case RawBodyType.InputStreamRangeBody => InputStreamRange(() => new ByteArrayInputStream(asByteArray)) + case RawBodyType.FileBody => throw new UnsupportedOperationException + case _: RawBodyType.MultipartBody => throw new UnsupportedOperationException }).asInstanceOf[RawValue[R]].unit } diff --git a/serverless/aws/lambda/src/main/scalajs/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala b/serverless/aws/lambda/src/main/scalajs/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala index bcf7405335..adefe67bde 100644 --- a/serverless/aws/lambda/src/main/scalajs/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala +++ b/serverless/aws/lambda/src/main/scalajs/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala @@ -24,10 +24,11 @@ private[lambda] class AwsToResponseBody[F[_]](options: AwsServerOptions[F]) exte val r = if (options.encodeResponseBody) Base64.getEncoder.encodeToString(bytes) else new String(bytes) (r, Some(bytes.length.toLong)) - case RawBodyType.ByteBufferBody => throw new UnsupportedOperationException - case RawBodyType.InputStreamBody => throw new UnsupportedOperationException - case RawBodyType.FileBody => throw new UnsupportedOperationException - case _: RawBodyType.MultipartBody => throw new UnsupportedOperationException + case RawBodyType.ByteBufferBody => throw new UnsupportedOperationException + case RawBodyType.InputStreamBody => throw new UnsupportedOperationException + case RawBodyType.InputStreamRangeBody => throw new UnsupportedOperationException + case RawBodyType.FileBody => throw new UnsupportedOperationException + case _: RawBodyType.MultipartBody => throw new UnsupportedOperationException } override def fromStreamValue( diff --git a/serverless/aws/lambda/src/main/scalajvm/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala b/serverless/aws/lambda/src/main/scalajvm/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala index 7f213c26b1..b70debd682 100644 --- a/serverless/aws/lambda/src/main/scalajvm/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala +++ b/serverless/aws/lambda/src/main/scalajvm/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala @@ -36,6 +36,13 @@ private[lambda] class AwsToResponseBody[F[_]](options: AwsServerOptions[F]) exte val r = if (options.encodeResponseBody) Base64.getEncoder.encodeToString(stream.readAllBytes()) else new String(stream.readAllBytes()) (r, None) + case RawBodyType.InputStreamRangeBody => + val bytes: Array[Byte] = v.range + .map(r => v.inputStreamFromRangeStart().readNBytes(r.contentLength.toInt)) + .getOrElse(v.inputStream().readAllBytes()) + val body = + if (options.encodeResponseBody) Base64.getEncoder.encodeToString(bytes) else new String(bytes) + (body, Some(bytes.length.toLong)) case RawBodyType.FileBody => throw new UnsupportedOperationException case _: RawBodyType.MultipartBody => throw new UnsupportedOperationException