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 14 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
28 changes: 17 additions & 11 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 @@ -8,23 +9,28 @@ 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]] = { _ =>
MonadError[F]
.blocking {
val file = Paths.get(systemPath).toRealPath().toFile
Copy link
Member

Choose a reason for hiding this comment

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

what if the file doesn't exist?

Copy link
Member

Choose a reason for hiding this comment

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

besides ... you are ignoring the input path :D and always returning the length of the root file/direcotry

Right(HeadOutput.Found(Some(ContentRangeUnits.Bytes), Some(file.length()), Some(contentTypeFromName(file.getName))))
}
}

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()))
Expand Down Expand Up @@ -72,7 +78,7 @@ object Files {
Some(range.contentLength),
Some(contentTypeFromName(file.toFile.getName)),
etag,
Some("bytes"),
Some(ContentRangeUnits.Bytes),
Some(range.toContentRange.toString())
)
)
Expand Down
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 @@ -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)
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,11 +119,42 @@ 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
)
)
)
.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:
Expand All @@ -135,21 +165,35 @@ 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`.
*
* {{{
* 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:
Expand All @@ -160,23 +204,23 @@ 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`.
*
* {{{
* 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))
Expand Down
28 changes: 18 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,33 @@ 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 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,
Expand All @@ -39,4 +47,4 @@ object StaticOutput {
contentType: Option[MediaType],
etag: Option[ETag]
) extends StaticOutput[T]
}
}
2 changes: 1 addition & 1 deletion doc/endpoint/static.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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")

parent.resolve("d1/d2").toFile.mkdirs()
Copy link
Member

Choose a reason for hiding this comment

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

not needed


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))
Copy link
Member

Choose a reason for hiding this comment

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

different syntax


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)
}
2 changes: 1 addition & 1 deletion generated-doc/out/endpoint/static.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
```

Expand Down
Loading