diff --git a/core/src/main/scalajvm/sttp/tapir/static/Files.scala b/core/src/main/scalajvm/sttp/tapir/static/Files.scala index f13d736c05..aa346e1fd4 100644 --- a/core/src/main/scalajvm/sttp/tapir/static/Files.scala +++ b/core/src/main/scalajvm/sttp/tapir/static/Files.scala @@ -1,5 +1,6 @@ package sttp.tapir.static +import sttp.model.ContentRangeUnits import sttp.model.headers.ETag import sttp.monad.MonadError import sttp.monad.syntax._ @@ -8,23 +9,31 @@ import sttp.tapir.{FileRange, RangeValue} import java.io.File import java.nio.file.{LinkOption, Path, Paths} import java.time.Instant -import scala.util.{Failure, Success, Try} object Files { + // inspired by org.http4s.server.staticcontent.FileService + def head[F[_]: MonadError]( + systemPath: String + ): HeadInput => F[Either[StaticErrorOutput, HeadOutput]] = { input => + MonadError[F] + .blocking { + val resolved = input.path.foldLeft(Paths.get(systemPath).toRealPath())(_.resolve(_)) + if (java.nio.file.Files.exists(resolved, LinkOption.NOFOLLOW_LINKS)) { + val file = resolved.toFile + Right(HeadOutput.Found(Some(ContentRangeUnits.Bytes), Some(file.length()), Some(contentTypeFromName(file.getName)))) + } else Left(StaticErrorOutput.NotFound) + } + } - def apply[F[_]: MonadError](systemPath: String): StaticInput => F[Either[StaticErrorOutput, StaticOutput[FileRange]]] = - apply(systemPath, defaultEtag[F]) + def get[F[_]: MonadError](systemPath: String): StaticInput => F[Either[StaticErrorOutput, StaticOutput[FileRange]]] = + get(systemPath, defaultEtag[F]) - def apply[F[_]: MonadError]( + def get[F[_]: MonadError]( systemPath: String, calculateETag: File => F[Option[ETag]] - ): StaticInput => F[Either[StaticErrorOutput, StaticOutput[FileRange]]] = { - Try(Paths.get(systemPath).toRealPath()) match { - case Success(realSystemPath) => (filesInput: StaticInput) => files(realSystemPath, calculateETag)(filesInput) - case Failure(e) => _ => MonadError[F].error(e) - } - } + ): StaticInput => F[Either[StaticErrorOutput, StaticOutput[FileRange]]] = + filesInput => MonadError[F].blocking(Paths.get(systemPath).toRealPath()).flatMap(path => files(path, calculateETag)(filesInput)) def defaultEtag[F[_]: MonadError](file: File): F[Option[ETag]] = MonadError[F].blocking { if (file.isFile) Some(defaultETag(file.lastModified(), file.length())) @@ -72,7 +81,7 @@ object Files { Some(range.contentLength), Some(contentTypeFromName(file.toFile.getName)), etag, - Some("bytes"), + Some(ContentRangeUnits.Bytes), Some(range.toContentRange.toString()) ) ) diff --git a/core/src/main/scalajvm/sttp/tapir/static/TapirStaticContentEndpoints.scala b/core/src/main/scalajvm/sttp/tapir/static/TapirStaticContentEndpoints.scala index 8f0ae4f04a..6551158c85 100644 --- a/core/src/main/scalajvm/sttp/tapir/static/TapirStaticContentEndpoints.scala +++ b/core/src/main/scalajvm/sttp/tapir/static/TapirStaticContentEndpoints.scala @@ -1,11 +1,10 @@ package sttp.tapir.static -import sttp.model.headers.ETag +import sttp.model.headers.{ETag, Range} import sttp.model.{Header, HeaderNames, MediaType, StatusCode} import sttp.monad.MonadError -import sttp.tapir.{FileRange, _} import sttp.tapir.server.ServerEndpoint -import sttp.model.headers.Range +import sttp.tapir.{FileRange, _} import java.io.InputStream import java.time.Instant @@ -52,9 +51,9 @@ trait TapirStaticContentEndpoints { case Some(v) => DecodeResult.fromEitherString(v, Range.parse(v).map(_.headOption)) }(_.map(_.toString)) - private def staticEndpoint[T]( - prefix: EndpointInput[Unit], - body: EndpointOutput[T] + private def staticGetEndpoint[T]( + prefix: EndpointInput[Unit], + body: EndpointOutput[T] ): Endpoint[StaticInput, StaticErrorOutput, StaticOutput[T], Any] = { endpoint.get .in(prefix) @@ -63,8 +62,8 @@ trait TapirStaticContentEndpoints { .and(ifNoneMatchHeader) .and(ifModifiedSinceHeader) .and(rangeHeader) - .map[StaticInput]((t: (List[String], Option[List[ETag]], Option[Instant], Option[Range])) => StaticInput(t._1, t._2, t._3, t._4))(fi => - (fi.path, fi.ifNoneMatch, fi.ifModifiedSince, fi.range) + .map[StaticInput]((t: (List[String], Option[List[ETag]], Option[Instant], Option[Range])) => StaticInput(t._1, t._2, t._3, t._4))( + fi => (fi.path, fi.ifNoneMatch, fi.ifModifiedSince, fi.range) ) ) .errorOut( @@ -78,8 +77,7 @@ trait TapirStaticContentEndpoints { StatusCode.BadRequest, emptyOutputAs(StaticErrorOutput.BadRequest), StaticErrorOutput.BadRequest.getClass - ) - , + ), oneOfMappingClassMatcher( StatusCode.RangeNotSatisfiable, emptyOutputAs(StaticErrorOutput.RangeNotSatisfiable), @@ -99,8 +97,9 @@ trait TapirStaticContentEndpoints { .and(etagHeader) .and(header[Option[String]](HeaderNames.AcceptRanges)) .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) + .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]] ), @@ -120,11 +119,47 @@ trait TapirStaticContentEndpoints { ) } - def filesEndpoint(prefix: EndpointInput[Unit]): Endpoint[StaticInput, StaticErrorOutput, StaticOutput[FileRange], Any] = - staticEndpoint(prefix, fileRangeBody) + private def staticHeadEndpoint( + prefix: EndpointInput[Unit] + ): Endpoint[HeadInput, StaticErrorOutput, HeadOutput, Any] = { + endpoint.head + .in(prefix) + .in(pathsWithoutDots.map[HeadInput](t => HeadInput(t))(_.path)) + .errorOut( + oneOf[StaticErrorOutput]( + oneOfMappingClassMatcher( + StatusCode.BadRequest, + emptyOutputAs(StaticErrorOutput.BadRequest), + StaticErrorOutput.BadRequest.getClass + ), + oneOfMappingClassMatcher( + StatusCode.NotFound, + emptyOutputAs(StaticErrorOutput.NotFound), + StaticErrorOutput.NotFound.getClass + ) + ) + ) + .out( + oneOf[HeadOutput]( + oneOfMappingClassMatcher( + StatusCode.Ok, + header[Option[String]](HeaderNames.AcceptRanges) + .and(header[Option[Long]](HeaderNames.ContentLength)) + .and(contentTypeHeader) + .map[HeadOutput.Found]((t: (Option[String], Option[Long], Option[MediaType])) => HeadOutput.Found(t._1, t._2, t._3))(fo => + (fo.acceptRanges, fo.contentLength, fo.contentType) + ), + classOf[HeadOutput.Found] + ) + ) + ) + } + + def filesGetEndpoint(prefix: EndpointInput[Unit]): Endpoint[StaticInput, StaticErrorOutput, StaticOutput[FileRange], Any] = + staticGetEndpoint(prefix, fileRangeBody) - def resourcesEndpoint(prefix: EndpointInput[Unit]): Endpoint[StaticInput, StaticErrorOutput, StaticOutput[InputStream], Any] = - staticEndpoint(prefix, inputStreamBody) + def resourcesGetEndpoint(prefix: EndpointInput[Unit]): Endpoint[StaticInput, StaticErrorOutput, StaticOutput[InputStream], Any] = + staticGetEndpoint(prefix, inputStreamBody) /** 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: @@ -135,10 +170,24 @@ trait TapirStaticContentEndpoints { * * A request to `/static/files/css/styles.css` will try to read the `/home/app/static/css/styles.css` file. */ - def filesServerEndpoint[F[_]](prefix: EndpointInput[Unit])( - systemPath: String + def filesGetServerEndpoint[F[_]](prefix: EndpointInput[Unit])( + systemPath: String ): ServerEndpoint[StaticInput, StaticErrorOutput, StaticOutput[FileRange], Any, F] = - ServerEndpoint(filesEndpoint(prefix), (m: MonadError[F]) => Files(systemPath)(m)) + ServerEndpoint(filesGetEndpoint(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 filesHeadServerEndpoint[F[_]](prefix: EndpointInput[Unit])( + systemPath: String + ): ServerEndpoint[HeadInput, StaticErrorOutput, HeadOutput, Any, F] = + ServerEndpoint(staticHeadEndpoint(prefix), (m: MonadError[F]) => Files.head(systemPath)(m)) + + /** Create pair of endpoints (head, get) for particular file */ + def fileServerEndpoints[F[_]](prefix: EndpointInput[Unit])( + systemPath: String + ): List[ServerEndpoint[_, StaticErrorOutput, _, Any, F]] = + List(filesHeadServerEndpoint(prefix)(systemPath), filesGetServerEndpoint(prefix)(systemPath)) /** A server endpoint, which exposes a single file from local storage found at `systemPath`, using the given `path`. * @@ -146,10 +195,10 @@ trait TapirStaticContentEndpoints { * fileServerEndpoint("static" / "hello.html")("/home/app/static/data.html") * }}} */ - def fileServerEndpoint[F[_]](path: EndpointInput[Unit])( + def fileGetServerEndpoint[F[_]](path: EndpointInput[Unit])( systemPath: String ): ServerEndpoint[StaticInput, StaticErrorOutput, StaticOutput[FileRange], Any, F] = - ServerEndpoint(removePath(filesEndpoint(path)), (m: MonadError[F]) => Files(systemPath)(m)) + ServerEndpoint(removePath(filesGetEndpoint(path)), (m: MonadError[F]) => Files.get(systemPath)(m)) /** 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: @@ -160,11 +209,11 @@ trait TapirStaticContentEndpoints { * * A request to `/static/files/css/styles.css` will try to read the `/app/css/styles.css` resource. */ - def resourcesServerEndpoint[F[_]](prefix: EndpointInput[Unit])( + def resourcesGetServerEndpoint[F[_]](prefix: EndpointInput[Unit])( classLoader: ClassLoader, resourcePrefix: String ): ServerEndpoint[StaticInput, StaticErrorOutput, StaticOutput[InputStream], Any, F] = - ServerEndpoint(resourcesEndpoint(prefix), (m: MonadError[F]) => Resources(classLoader, resourcePrefix)(m)) + ServerEndpoint(resourcesGetEndpoint(prefix), (m: MonadError[F]) => Resources(classLoader, resourcePrefix)(m)) /** A server endpoint, which exposes a single resource available from the given `classLoader` at `resourcePath`, using the given `path`. * @@ -172,11 +221,11 @@ trait TapirStaticContentEndpoints { * resourceServerEndpoint("static" / "hello.html")(classOf[App].getClassLoader, "app/data.html") * }}} */ - def resourceServerEndpoint[F[_]](prefix: EndpointInput[Unit])( + def resourceGetServerEndpoint[F[_]](prefix: EndpointInput[Unit])( classLoader: ClassLoader, resourcePath: String ): ServerEndpoint[StaticInput, StaticErrorOutput, StaticOutput[InputStream], Any, F] = - ServerEndpoint(removePath(resourcesEndpoint(prefix)), (m: MonadError[F]) => Resources(classLoader, resourcePath)(m)) + ServerEndpoint(removePath(resourcesGetEndpoint(prefix)), (m: MonadError[F]) => Resources(classLoader, resourcePath)(m)) private def removePath[T](e: Endpoint[StaticInput, StaticErrorOutput, StaticOutput[T], Any]) = e.mapIn(i => i.copy(path = Nil))(i => i.copy(path = Nil)) diff --git a/core/src/main/scalajvm/sttp/tapir/static/model.scala b/core/src/main/scalajvm/sttp/tapir/static/model.scala index 4326245bb8..7326056b36 100644 --- a/core/src/main/scalajvm/sttp/tapir/static/model.scala +++ b/core/src/main/scalajvm/sttp/tapir/static/model.scala @@ -1,8 +1,7 @@ package sttp.tapir.static import sttp.model.MediaType -import sttp.model.headers.ETag -import sttp.model.headers.Range +import sttp.model.headers.{ETag, Range} import java.time.Instant @@ -13,6 +12,10 @@ case class StaticInput( range: Option[Range] ) +case class HeadInput( + path: List[String] +) + trait StaticErrorOutput object StaticErrorOutput { case object NotFound extends StaticErrorOutput @@ -20,17 +23,22 @@ object StaticErrorOutput { case object RangeNotSatisfiable extends StaticErrorOutput } +trait HeadOutput +object HeadOutput { + case class Found(acceptRanges: Option[String], contentLength: Option[Long], contentType: Option[MediaType]) extends HeadOutput +} + trait StaticOutput[+T] 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] + 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, @@ -39,4 +47,4 @@ object StaticOutput { contentType: Option[MediaType], etag: Option[ETag] ) extends StaticOutput[T] -} \ No newline at end of file +} diff --git a/doc/endpoint/static.md b/doc/endpoint/static.md index 17a2f4c80c..d5fae7fb89 100644 --- a/doc/endpoint/static.md +++ b/doc/endpoint/static.md @@ -20,7 +20,7 @@ import sttp.tapir.server.akkahttp.AkkaHttpServerInterpreter import scala.concurrent.Future val filesRoute: Route = AkkaHttpServerInterpreter().toRoute( - filesServerEndpoint[Future]("site" / "static")("/home/static/data") + filesGetServerEndpoint[Future]("site" / "static")("/home/static/data") ) ``` diff --git a/docs/swagger-ui/src/main/scala/sttp/tapir/swagger/SwaggerUI.scala b/docs/swagger-ui/src/main/scala/sttp/tapir/swagger/SwaggerUI.scala index 4479475fed..c657a522a2 100644 --- a/docs/swagger-ui/src/main/scala/sttp/tapir/swagger/SwaggerUI.scala +++ b/docs/swagger-ui/src/main/scala/sttp/tapir/swagger/SwaggerUI.scala @@ -58,7 +58,7 @@ object SwaggerUI { Right(s"/$prefixAsPath/oauth2-redirect.html$queryString") } - val resourcesEndpoint = resourcesServerEndpoint[F](prefixInput)( + val resourcesEndpoint = resourcesGetServerEndpoint[F](prefixInput)( SwaggerUI.getClass.getClassLoader, s"META-INF/resources/webjars/swagger-ui/$swaggerVersion/" ) diff --git a/examples/src/main/scala/sttp/tapir/examples/StaticContentAkkaServer.scala b/examples/src/main/scala/sttp/tapir/examples/StaticContentAkkaServer.scala new file mode 100644 index 0000000000..9475244dab --- /dev/null +++ b/examples/src/main/scala/sttp/tapir/examples/StaticContentAkkaServer.scala @@ -0,0 +1,55 @@ +package sttp.tapir.examples + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.server.Route +import sttp.client3._ +import sttp.model.{ContentRangeUnits, Header, HeaderNames, StatusCode} +import sttp.tapir._ +import sttp.tapir.server.akkahttp.AkkaHttpServerInterpreter + +import java.nio.file.{Files, Path, StandardOpenOption} +import scala.concurrent.duration.DurationInt +import scala.concurrent.{Await, Future} + +object StaticContentAkkaServer extends App { + private val parent: Path = Files.createTempDirectory("akka-static-example") + Files.write(parent.resolve("f1"), "f1 content".getBytes, StandardOpenOption.CREATE_NEW) + + private val exampleFile = parent.resolve("f1").toFile + private val exampleFilePath = exampleFile.getAbsolutePath + + private val fileEndpoints = fileServerEndpoints[Future]("range-example")(exampleFilePath) + private val route: Route = AkkaHttpServerInterpreter().toRoute(fileEndpoints) + + // starting the server + private implicit val actorSystem: ActorSystem = ActorSystem() + import actorSystem.dispatcher + + private val bindAndCheck: Future[Unit] = Http().newServerAt("localhost", 8080).bindFlow(route).map { _ => + // testing + val backend: SttpBackend[Identity, Any] = HttpURLConnectionBackend() + val headResponse = basicRequest + .head(uri"http://localhost:8080/range-example") + .response(asStringAlways) + .send(backend) + + assert(headResponse.code == StatusCode.Ok) + assert(headResponse.headers.contains(Header(HeaderNames.AcceptRanges, ContentRangeUnits.Bytes))) + assert(headResponse.headers.contains(Header(HeaderNames.ContentLength, exampleFile.length.toString))) + + val getResponse = basicRequest + .headers(Header(HeaderNames.Range, "bytes=3-6")) + .get(uri"http://localhost:8080/range-example") + .response(asStringAlways) + .send(backend) + + assert(getResponse.body == "cont") + assert(getResponse.code == StatusCode.PartialContent) + assert(getResponse.body.length == 4) + assert(getResponse.headers.contains(Header(HeaderNames.ContentRange, "bytes 3-6/10"))) + + } + + Await.result(bindAndCheck.transformWith { r => actorSystem.terminate().transform(_ => r) }, 1.minute) +} diff --git a/generated-doc/out/endpoint/static.md b/generated-doc/out/endpoint/static.md index 39b730ce19..565da17116 100644 --- a/generated-doc/out/endpoint/static.md +++ b/generated-doc/out/endpoint/static.md @@ -20,7 +20,7 @@ import sttp.tapir.server.akkahttp.AkkaHttpServerInterpreter import scala.concurrent.Future val filesRoute: Route = AkkaHttpServerInterpreter().toRoute( - filesServerEndpoint[Future]("site" / "static")("/home/static/data") + filesGetServerEndpoint[Future]("site" / "static")("/home/static/data") ) ``` diff --git a/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaHttpServerInterpreter.scala b/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaHttpServerInterpreter.scala index 7a7908849e..75ee805f9e 100644 --- a/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaHttpServerInterpreter.scala +++ b/server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaHttpServerInterpreter.scala @@ -1,6 +1,6 @@ package sttp.tapir.server.akkahttp -import akka.http.scaladsl.model.{HttpResponse, StatusCodes} +import akka.http.scaladsl.model._ import akka.http.scaladsl.server.Directives.{ complete, extractExecutionContext, @@ -12,8 +12,10 @@ import akka.http.scaladsl.server.Directives.{ respondWithHeaders } import akka.http.scaladsl.server.Route +import akka.stream.scaladsl.Source import sttp.capabilities.WebSockets import sttp.capabilities.akka.AkkaStreams +import sttp.model.{HeaderNames, Method} import sttp.monad.FutureMonad import sttp.tapir.Endpoint import sttp.tapir.model.ServerResponse @@ -55,14 +57,14 @@ trait AkkaHttpServerInterpreter { onSuccess(interpreter(serverRequest, ses)) { case RequestResult.Failure(_) => reject - case RequestResult.Response(response) => serverResponseToAkka(response) + case RequestResult.Response(response) => serverResponseToAkka(response, serverRequest.method) } } } } } - private def serverResponseToAkka(response: ServerResponse[AkkaResponseBody]): Route = { + private def serverResponseToAkka(response: ServerResponse[AkkaResponseBody], requestMethod: Method): Route = { val statusCode = StatusCodes.getForKey(response.code.code).getOrElse(StatusCodes.custom(response.code.code, "")) val akkaHeaders = parseHeadersOrThrowWithoutContentHeaders(response) @@ -73,7 +75,21 @@ trait AkkaHttpServerInterpreter { } case Some(Right(entity)) => complete(HttpResponse(entity = entity, status = statusCode, headers = akkaHeaders)) - case None => complete(HttpResponse(statusCode, headers = akkaHeaders)) + case None => + if (requestMethod.is(Method.HEAD) && response.contentLength.isDefined) { + val contentLength: Long = response.contentLength.getOrElse(0) + val contentType: ContentType = response.contentType match { + case Some(t) => ContentType.parse(t).getOrElse(ContentTypes.NoContentType) + case None => ContentTypes.NoContentType + } + complete( + HttpResponse( + status = statusCode, + headers = akkaHeaders, + entity = HttpEntity.Default(contentType, contentLength, Source.empty) + ) + ) + } else complete(HttpResponse(statusCode, headers = akkaHeaders)) } } } diff --git a/server/play-server/src/main/scala/sttp/tapir/server/play/PlayServerInterpreter.scala b/server/play-server/src/main/scala/sttp/tapir/server/play/PlayServerInterpreter.scala index 1286518b20..3ecff29a3e 100644 --- a/server/play-server/src/main/scala/sttp/tapir/server/play/PlayServerInterpreter.scala +++ b/server/play-server/src/main/scala/sttp/tapir/server/play/PlayServerInterpreter.scala @@ -1,13 +1,15 @@ package sttp.tapir.server.play +import akka.http.scaladsl.model.{ContentType, ContentTypes} import akka.stream.Materializer +import akka.stream.scaladsl.Source import akka.util.ByteString import play.api.http.{HeaderNames, HttpEntity} import play.api.libs.streams.Accumulator import play.api.mvc._ import play.api.routing.Router.Routes import sttp.capabilities.akka.AkkaStreams -import sttp.model.StatusCode +import sttp.model.{Method, StatusCode} import sttp.monad.FutureMonad import sttp.tapir.Endpoint import sttp.tapir.model.ServerResponse @@ -93,7 +95,10 @@ trait PlayServerInterpreter { val status = response.code.code response.body match { case Some(entity) => Result(ResponseHeader(status, headers), entity) - case None => Result(ResponseHeader(status, headers), HttpEntity.NoEntity) + case None => + if (serverRequest.method.is(Method.HEAD) && response.contentLength.isDefined) + Result(ResponseHeader(status, headers), HttpEntity.Streamed(Source.empty, response.contentLength, response.contentType)) + else Result(ResponseHeader(status, headers), HttpEntity.NoEntity) } } } diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStaticContentTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStaticContentTests.scala index 105e11cdbb..6b5aac05b9 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStaticContentTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStaticContentTests.scala @@ -7,7 +7,7 @@ import org.scalatest.matchers.should.Matchers._ import sttp.capabilities.WebSockets import sttp.capabilities.fs2.Fs2Streams import sttp.client3._ -import sttp.model.{Header, HeaderNames, MediaType, StatusCode} +import sttp.model._ import sttp.tapir._ import sttp.tapir.server.ServerEndpoint import sttp.tapir.tests._ @@ -27,7 +27,7 @@ class ServerStaticContentTests[F[_], ROUTE]( val baseTests = List( Test("serve files from the given system path") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) .use { port => def get(path: List[String]) = basicRequest .get(uri"http://localhost:$port/$path") @@ -44,7 +44,7 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("serve files from the given system path with a prefix") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F]("static" / "content")(testDir.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F]("static" / "content")(testDir.getAbsolutePath)) .use { port => def get(path: List[String]) = basicRequest .get(uri"http://localhost:$port/$path") @@ -59,7 +59,7 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("serve index.html when a directory is requested") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) .use { port => basicRequest .get(uri"http://localhost:$port/d1") @@ -70,29 +70,95 @@ class ServerStaticContentTests[F[_], ROUTE]( .unsafeToFuture() } }, + Test("Should return acceptRanges for head request") { + withTestFilesDirectory { testDir => + val file = testDir.toPath.resolve("f1").toFile + serveRoute(filesHeadServerEndpoint("test")(testDir.getAbsolutePath)) + .use { port => + basicRequest + .head(uri"http://localhost:$port/test/f1") + .response(asStringAlways) + .send(backend) + .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 + }) + } + .unsafeToFuture() + } + }, + Test("Should return 404 for HEAD request and not existing file ") { + withTestFilesDirectory { testDir => + serveRoute(filesHeadServerEndpoint(emptyInput)(testDir.getAbsolutePath)) + .use { port => + basicRequest + .head(uri"http://localhost:$port/test") + .response(asStringAlways) + .send(backend) + .map(_.code shouldBe StatusCode.NotFound) + } + .unsafeToFuture() + } + }, + Test("Should create head and get endpoints") { + withTestFilesDirectory { testDir => + val file = testDir.toPath.resolve("f2").toFile + val headAndGetEndpoint = fileServerEndpoints[F]("test")(testDir.getAbsolutePath) + serveRoute(headAndGetEndpoint.head) + .use { port => + basicRequest + .head(uri"http://localhost:$port/test/f2") + .response(asStringAlways) + .send(backend) + .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 + }) + } + .flatMap(_ => { + serveRoute(headAndGetEndpoint.last) + .use { port => + 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("return 404 when files are not found") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F]("test")(testDir.toPath.resolve("f1").toFile.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F]("test")(testDir.toPath.resolve("f1").toFile.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=0-1")) .get(uri"http://localhost:$port/test") .response(asStringAlways) .send(backend) - .map(_.headers contains Header(HeaderNames.AcceptRanges, "bytes") shouldBe true) + .map(_.headers contains Header(HeaderNames.AcceptRanges, ContentRangeUnits.Bytes) shouldBe true) } .unsafeToFuture() } }, Test("should return whole while file if header not present ") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F]("test")(testDir.toPath.resolve("f1").toFile.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest - .get(uri"http://localhost:$port/test") + .get(uri"http://localhost:$port/test/f2") .response(asStringAlways) .send(backend) - .map(_.body shouldBe "f1 content") + .map(_.body shouldBe "f2 content") } .unsafeToFuture() @@ -100,10 +166,10 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("returns 200 status code for whole file") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F]("test")(testDir.toPath.resolve("f1").toFile.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest - .get(uri"http://localhost:$port/test") + .get(uri"http://localhost:$port/test/f2") .response(asStringAlways) .send(backend) .map(_.code shouldBe StatusCode.Ok) @@ -114,11 +180,11 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("should return 416 if over range") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F]("test")(testDir.toPath.resolve("f1").toFile.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=0-11")) - .get(uri"http://localhost:$port/test") + .get(uri"http://localhost:$port/test/f2") .response(asStringAlways) .send(backend) .map(_.code shouldBe StatusCode.RangeNotSatisfiable) @@ -128,11 +194,11 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("returns content range header with matching bytes") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F]("test")(testDir.toPath.resolve("f1").toFile.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=1-3")) - .get(uri"http://localhost:$port/test") + .get(uri"http://localhost:$port/test/f1") .response(asStringAlways) .send(backend) .map(_.headers contains Header(HeaderNames.ContentRange, "bytes 1-3/10") shouldBe true) @@ -142,11 +208,11 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("returns 206 status code for partial content") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F]("test")(testDir.toPath.resolve("f1").toFile.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=1-3")) - .get(uri"http://localhost:$port/test") + .get(uri"http://localhost:$port/test/f1") .response(asStringAlways) .send(backend) .map(_.code shouldBe StatusCode.PartialContent) @@ -156,11 +222,11 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("should return bytes 4-7 from file") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F]("test")(testDir.toPath.resolve("f1").toFile.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=4-7")) - .get(uri"http://localhost:$port/test") + .get(uri"http://localhost:$port/test/f1") .response(asStringAlways) .send(backend) .map(_.body shouldBe "onte") @@ -170,11 +236,11 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("should return bytes 100000-200000 from file") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F]("test")(testDir.toPath.resolve("f5").toFile.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=100000-200000")) - .get(uri"http://localhost:$port/test") + .get(uri"http://localhost:$port/test/f5") .response(asStringAlways) .send(backend) .map { r => @@ -187,11 +253,11 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("should return last 200000 bytes from file") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F]("test")(testDir.toPath.resolve("f5").toFile.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=100000-")) - .get(uri"http://localhost:$port/test") + .get(uri"http://localhost:$port/test/f5") .response(asStringAlways) .send(backend) .map { r => @@ -204,11 +270,11 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("should return last 100000 bytes from file") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F]("test")(testDir.toPath.resolve("f5").toFile.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=-100000")) - .get(uri"http://localhost:$port/test") + .get(uri"http://localhost:$port/test/f5") .response(asStringAlways) .send(backend) .map { r => @@ -221,21 +287,21 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("should fail for incorrect range") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F]("test")(testDir.toPath.resolve("f5").toFile.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F]("test")(testDir.getAbsolutePath)) .use { port => basicRequest .headers(Header(HeaderNames.Range, "bytes=-")) - .get(uri"http://localhost:$port/test") + .get(uri"http://localhost:$port/test/f5") .response(asStringAlways) .send(backend) - .map { _.code shouldBe StatusCode(400) } + .map { _.code shouldBe StatusCode.BadRequest } } .unsafeToFuture() } }, Test("if an etag is present, only return the file if it doesn't match the etag") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) .use { port => def get(etag: Option[String]) = basicRequest .get(uri"http://localhost:$port/f1") @@ -259,7 +325,7 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("return file metadata") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) + serveRoute(filesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath)) .use { port => basicRequest .get(uri"http://localhost:$port/img.gif") @@ -279,7 +345,7 @@ class ServerStaticContentTests[F[_], ROUTE]( } }, Test("serve a single resource") { - serveRoute(resourceServerEndpoint[F](emptyInput)(classOf[ServerStaticContentTests[F, ROUTE]].getClassLoader, "test/r1.txt")) + serveRoute(resourceGetServerEndpoint[F](emptyInput)(classOf[ServerStaticContentTests[F, ROUTE]].getClassLoader, "test/r1.txt")) .use { port => basicRequest .get(uri"http://localhost:$port/$path") @@ -290,7 +356,7 @@ class ServerStaticContentTests[F[_], ROUTE]( .unsafeToFuture() }, Test("not return a resource outside of the resource prefix directory") { - serveRoute(resourcesServerEndpoint[F](emptyInput)(classOf[ServerStaticContentTests[F, ROUTE]].getClassLoader, "test")) + serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classOf[ServerStaticContentTests[F, ROUTE]].getClassLoader, "test")) .use { port => basicRequest .get(uri"http://localhost:$port/../test2/r5.txt") @@ -301,7 +367,7 @@ class ServerStaticContentTests[F[_], ROUTE]( .unsafeToFuture() }, Test("serve resources") { - serveRoute(resourcesServerEndpoint[F](emptyInput)(classOf[ServerStaticContentTests[F, ROUTE]].getClassLoader, "test")) + serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classOf[ServerStaticContentTests[F, ROUTE]].getClassLoader, "test")) .use { port => def get(path: List[String]) = basicRequest .get(uri"http://localhost:$port/$path") @@ -317,7 +383,7 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("not return a file outside of the system path") { withTestFilesDirectory { testDir => - serveRoute(filesServerEndpoint[F](emptyInput)(testDir.getAbsolutePath + "/d1")) + serveRoute(filesGetServerEndpoint[F](emptyInput)(testDir.getAbsolutePath + "/d1")) .use { port => basicRequest .get(uri"http://localhost:$port/../f1") @@ -329,7 +395,7 @@ class ServerStaticContentTests[F[_], ROUTE]( } }, Test("return 404 when a resource is not found") { - serveRoute(resourcesServerEndpoint[F](emptyInput)(classOf[ServerStaticContentTests[F, ROUTE]].getClassLoader, "test")) + serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classOf[ServerStaticContentTests[F, ROUTE]].getClassLoader, "test")) .use { port => basicRequest .get(uri"http://localhost:$port/r3") @@ -341,7 +407,7 @@ class ServerStaticContentTests[F[_], ROUTE]( }, Test("serve a single file from the given system path") { withTestFilesDirectory { testDir => - serveRoute(fileServerEndpoint[F]("test")(testDir.toPath.resolve("f1").toFile.getAbsolutePath)) + serveRoute(fileGetServerEndpoint[F]("test")(testDir.toPath.resolve("f1").toFile.getAbsolutePath)) .use { port => basicRequest .get(uri"http://localhost:$port/test") @@ -353,7 +419,7 @@ class ServerStaticContentTests[F[_], ROUTE]( } }, Test("if an etag is present, only return the resource if it doesn't match the etag") { - serveRoute(resourcesServerEndpoint[F](emptyInput)(classOf[ServerStaticContentTests[F, ROUTE]].getClassLoader, "test")) + serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classOf[ServerStaticContentTests[F, ROUTE]].getClassLoader, "test")) .use { port => def get(etag: Option[String]) = basicRequest .get(uri"http://localhost:$port/r1.txt") @@ -376,7 +442,7 @@ class ServerStaticContentTests[F[_], ROUTE]( } ) val resourceMetadataTest = Test("return resource metadata") { - serveRoute(resourcesServerEndpoint[F](emptyInput)(classOf[ServerStaticContentTests[F, ROUTE]].getClassLoader, "test")) + serveRoute(resourcesGetServerEndpoint[F](emptyInput)(classOf[ServerStaticContentTests[F, ROUTE]].getClassLoader, "test")) .use { port => basicRequest .get(uri"http://localhost:$port/r1.txt")