Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support ranges request handling #1527

Merged
merged 18 commits into from
Oct 15, 2021
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions core/src/main/scalajvm/sttp/tapir/static/Files.scala
Original file line number Diff line number Diff line change
@@ -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._
Expand All @@ -12,7 +13,6 @@ import scala.util.{Failure, Success, Try}

object Files {
// inspired by org.http4s.server.staticcontent.FileService

def apply[F[_]: MonadError](systemPath: String): StaticInput => F[Either[StaticErrorOutput, StaticOutput[FileRange]]] =
apply(systemPath, defaultEtag[F])

Expand Down Expand Up @@ -72,7 +72,7 @@ object Files {
Some(range.contentLength),
Some(contentTypeFromName(file.toFile.getName)),
etag,
Some("bytes"),
Some(ContentRangeUnits.Bytes),
Some(range.toContentRange.toString())
)
)
Expand Down
31 changes: 31 additions & 0 deletions core/src/main/scalajvm/sttp/tapir/static/FilesUtil.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package sttp.tapir.static

import sttp.model.ContentRangeUnits
import sttp.monad.MonadError

import java.nio.file.Paths
import scala.util.{Failure, Success, Try}

object FilesUtil {

def apply[F[_]: MonadError](
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this can be simply part of files, where we have method .head and .get?

systemPath: String
): HeadInput => F[Either[StaticErrorOutput, HeadOutput]] = {
Try(Paths.get(systemPath).toRealPath()) match {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't .toRealPath blocking as well? We might need to expand the scope of MonadError[F].blocking

case Success(realSystemPath) =>
_ =>
val file = realSystemPath.toFile
MonadError[F].blocking(
Right(
HeadOutput.SupportRanges(
Some(ContentRangeUnits.Bytes),
Some(file.length()),
Some(contentTypeFromName(file.getName))
)
)
)
case Failure(e) => _ => MonadError[F].error(e)
}
}

}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -53,8 +52,8 @@ trait TapirStaticContentEndpoints {
}(_.map(_.toString))

private def staticEndpoint[T](
prefix: EndpointInput[Unit],
body: EndpointOutput[T]
prefix: EndpointInput[Unit],
body: EndpointOutput[T]
): Endpoint[StaticInput, StaticErrorOutput, StaticOutput[T], Any] = {
endpoint.get
.in(prefix)
Expand All @@ -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(
Expand All @@ -78,8 +77,7 @@ trait TapirStaticContentEndpoints {
StatusCode.BadRequest,
emptyOutputAs(StaticErrorOutput.BadRequest),
StaticErrorOutput.BadRequest.getClass
)
,
),
oneOfMappingClassMatcher(
StatusCode.RangeNotSatisfiable,
emptyOutputAs(StaticErrorOutput.RangeNotSatisfiable),
Expand All @@ -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]]
),
Expand All @@ -120,6 +119,37 @@ trait TapirStaticContentEndpoints {
)
}

private def headEndpoint(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mayber similarly - staticGetEndpoint and 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
)
)
)
.out(
oneOf[HeadOutput](
oneOfMappingClassMatcher(
StatusCode.Ok,
header[Option[String]](HeaderNames.AcceptRanges)
.and(header[Option[Long]](HeaderNames.ContentLength))
.and(contentTypeHeader)
.map[HeadOutput.SupportRanges]((t: (Option[String], Option[Long], Option[MediaType])) =>
HeadOutput.SupportRanges(t._1, t._2, t._3)
)(fo => (fo.acceptRanges, fo.contentLength, fo.contentType)),
classOf[HeadOutput.SupportRanges]
)
)
)
}

def filesEndpoint(prefix: EndpointInput[Unit]): Endpoint[StaticInput, StaticErrorOutput, StaticOutput[FileRange], Any] =
staticEndpoint(prefix, fileRangeBody)

Expand All @@ -136,10 +166,16 @@ 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])(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and here -> .filesGetServerEndpoint

systemPath: String
systemPath: String
): ServerEndpoint[StaticInput, StaticErrorOutput, StaticOutput[FileRange], Any, F] =
ServerEndpoint(filesEndpoint(prefix), (m: MonadError[F]) => Files(systemPath)(m))

/** A server endpoint, used to verify if sever supports range requests for file under particular path */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not only, it also checks if the file exists and what it's length

def headServerEndpoint[F[_]](prefix: EndpointInput[Unit])(
systemPath: String
): ServerEndpoint[HeadInput, StaticErrorOutput, HeadOutput, Any, F] =
ServerEndpoint(headEndpoint(prefix), (m: MonadError[F]) => FilesUtil(systemPath)(m))

/** A server endpoint, which exposes a single file from local storage found at `systemPath`, using the given `path`.
*
* {{{
Expand Down
29 changes: 19 additions & 10 deletions core/src/main/scalajvm/sttp/tapir/static/model.scala
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -13,24 +12,34 @@ case class StaticInput(
range: Option[Range]
)

case class HeadInput(
path: List[String]
)

trait StaticErrorOutput
object StaticErrorOutput {
case object NotFound extends StaticErrorOutput
case object BadRequest extends StaticErrorOutput
case object RangeNotSatisfiable extends StaticErrorOutput
}

trait HeadOutput
object HeadOutput {
case class SupportRanges(acceptRanges: Option[String], contentLength: Option[Long], contentType: Option[MediaType]) extends HeadOutput
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe simply Found? ranges are optional anyway

case class NotSupportRanges() extends HeadOutput
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this used?

}

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,
Expand All @@ -39,4 +48,4 @@ object StaticOutput {
contentType: Option[MediaType],
etag: Option[ETag]
) extends StaticOutput[T]
}
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ class ServerStaticContentTests[F[_], ROUTE](
.unsafeToFuture()
}
},
Test("Should return acceptRanges for head request") {
withTestFilesDirectory { testDir =>
val file = testDir.toPath.resolve("f1").toFile
serveRoute(headServerEndpoint("test")(file.getAbsolutePath))
.use { port =>
basicRequest
.head(uri"http://localhost:$port/test")
.response(asStringAlways)
.send(backend)
.map(r => {
r.code shouldBe StatusCode(200)
r.headers contains Header(HeaderNames.AcceptRanges, "bytes") shouldBe true
r.headers contains Header(HeaderNames.ContentLength, file.length().toString) shouldBe true
})
}
.unsafeToFuture()
}
},
Test("return 404 when files are not found") {
withTestFilesDirectory { testDir =>
serveRoute(filesServerEndpoint[F]("test")(testDir.toPath.resolve("f1").toFile.getAbsolutePath))
Expand Down