All Downloads are FREE. Search and download functionalities are using the official Maven repository.

sttp.tapir.files.TapirStaticContentEndpoints.scala Maven / Gradle / Ivy

The newest version!
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.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 val acceptEncodingHeader: EndpointIO[List[String]] =
    header[Option[String]](HeaderNames.AcceptEncoding).mapDecode[List[String]] {
      case None      => DecodeResult.Value(List.empty)
      case Some(str) => DecodeResult.Value(str.split(",").map(_.trim).toList)
    }(es => Option(es.mkString(",")).filter(_.nonEmpty))

  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 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)
    )
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy