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

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

The newest version!
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, contentTypeFromName(resolved.getFileName.toString), 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)
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy