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

blobstore.url.Url.scala Maven / Gradle / Ivy

There is a newer version: 0.9.15
Show newest version
package blobstore.url

import blobstore.url.exception.{AuthorityParseError, MultipleUrlValidationException, UrlParseError}
import blobstore.url.Path.AbsolutePath
import blobstore.url.exception.AuthorityParseError.{InvalidFileUrl, InvalidHost, MissingHost}
import blobstore.url.exception.UrlParseError.{CouldntParseUrl, MissingScheme}
import cats.{ApplicativeThrow, Order, Show}
import cats.data.Validated.{Invalid, Valid}
import cats.data.{NonEmptyChain, OptionT, ValidatedNec}
import cats.syntax.all.*

import scala.util.Try

case class Url[+A](scheme: String, authority: Authority, path: Path[A]) {
  lazy val representation: A = path.representation

  def plain: Url.Plain = copy(path = path.plain)

  def withPath[AA](p: Path[AA]): Url[AA]     = copy(path = p)
  def withAuthority(a: Authority): Url.Plain = copy(authority = a, path = path.plain)

  def toS3(bucketName: Hostname): Url.Plain    = copy(scheme = "s3", authority = Authority(bucketName), path.plain)
  def toGcs(bucketName: Hostname): Url.Plain   = copy(scheme = "gs", authority = Authority(bucketName), path.plain)
  def toAzure(bucketName: Hostname): Url.Plain = copy(scheme = "https", authority = Authority(bucketName), path.plain)
  def toSftp(authority: Authority): Url.Plain  = copy(scheme = "sftp", authority = authority, path.plain)

  def /[AA](path: Path[AA]): Url.Plain = copy(path = this.path./(path.show))
  def /(segment: String): Url.Plain    = copy(path = path./(segment))
  def /(segment: Option[String]): Url.Plain = segment match {
    case Some(s) => /(s)
    case None    => copy(path = path.plain)
  }

  /** Ensure that path always is suffixed with '/'
    */
  def `//`(segment: String): Url.Plain = copy(path = path.`//`(segment))
  def `//`(segment: Option[String]): Url.Plain = segment match {
    case Some(s) => `//`(s)
    case None    => copy(path = path.plain)
  }

  /** Safe toString implementation.
    *
    * @return
    *   Outputs user segment if any, will not print passwords
    */
  override val toString: String = {
    val sep = "://"

    authority match {
      case Authority(h, Some(u), Some(p)) =>
        show"${scheme.stripSuffix(sep)}$sep${u.user}@$h:$p/${path.show.stripPrefix("/")}"
      case Authority(h, Some(u), None) => show"${scheme.stripSuffix(sep)}$sep${u.user}@$h/${path.show.stripPrefix("/")}"
      case Authority(h, None, Some(p)) => show"${scheme.stripSuffix(sep)}$sep$h:$p/${path.show.stripPrefix("/")}"
      case Authority(h, None, None)    => show"${scheme.stripSuffix(sep)}$sep$h/${path.show.stripPrefix("/")}"
    }
  }

  /** Safe toString implementation
    *
    * @return
    *   Outputs masked passwords
    */
  val toStringMasked: String = {
    val sep = "://"

    authority match {
      case Authority(h, Some(u), Some(p)) => show"${scheme.stripSuffix(sep)}$sep$u@$h:$p/${path.show.stripPrefix("/")}"
      case Authority(h, Some(u), None)    => show"${scheme.stripSuffix(sep)}$sep$u@$h/${path.show.stripPrefix("/")}"
      case Authority(h, None, Some(p))    => show"${scheme.stripSuffix(sep)}$sep$h:$p/${path.show.stripPrefix("/")}"
      case Authority(h, None, None)       => show"${scheme.stripSuffix(sep)}$sep$h/${path.show.stripPrefix("/")}"
    }
  }

  def toStringWithPassword: String = {
    val sep = "://"

    authority match {
      case Authority(h, Some(u), Some(p)) =>
        show"${scheme.stripSuffix(sep)}$sep${u.toStringWithPassword}@$h:$p/${path.show.stripPrefix("/")}"
      case Authority(h, Some(u), None) =>
        show"${scheme.stripSuffix(sep)}$sep${u.toStringWithPassword}@$h/${path.show.stripPrefix("/")}"
      case Authority(h, None, Some(p)) => show"${scheme.stripSuffix(sep)}$sep$h:$p/${path.show.stripPrefix("/")}"
      case Authority(h, None, None)    => show"${scheme.stripSuffix(sep)}$sep$h/${path.show.stripPrefix("/")}"
    }
  }

}

object Url {

  type Plain = Url[String]

  def parseF[F[_]: ApplicativeThrow](c: String): F[Url.Plain] =
    parse(c).leftMap(MultipleUrlValidationException.apply).liftTo[F]

  def unsafe(c: String): Url.Plain = parse(c) match {
    case Valid(u)   => u
    case Invalid(e) => throw MultipleUrlValidationException(e) // scalafix:ok
  }

  def parse(c: String): ValidatedNec[UrlParseError, Url.Plain] = {
    val regex = "^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?".r

    // Treat `m.group` as unsafe, since it really is
    def tryOpt[A](a: => A): Try[Option[A]] = Try(a).map(Option.apply)

    def parseFileUrl(u: String): ValidatedNec[UrlParseError, Url.Plain] = {
      val fileRegex = "file:/([^:]+)".r
      fileRegex.findFirstMatchIn(u).map { m =>
        val matchRegex = tryOpt(m.group(1)).toEither.leftMap(_ => InvalidFileUrl(show"Not a valid file uri: $u"))
        OptionT(matchRegex.leftWiden[UrlParseError])
          .getOrElseF(InvalidFileUrl(show"File uri didn't match regex: ${fileRegex.pattern.toString}").asLeft[String])
          .map { pathPart =>
            if (!pathPart.startsWith("/"))
              Url("file", Authority.localhost, Path("/" + pathPart))
            else Url("file", Authority.localhost, Path(pathPart.stripPrefix("/")))
          }
          .toValidatedNec
      }.getOrElse(InvalidFileUrl(show"File uri didn't match regex: ${fileRegex.pattern.toString}").invalidNec)
    }

    lazy val parseNonFile = regex.findFirstMatchIn(c).map { m =>
      val authority: Either[AuthorityParseError, String] = OptionT(
        tryOpt(m.group(4)).toEither.leftMap(InvalidHost.apply).leftWiden[AuthorityParseError]
      ).getOrElseF(MissingHost(c).asLeft)

      val typedAuthority: ValidatedNec[AuthorityParseError, Authority] =
        authority.leftMap(NonEmptyChain(_)).flatMap(Authority.parse(_).toEither).toValidated

      // Default to rootless paths when parsing URLs, these tends to be more useful.
      // S3 fails if you pass it an absolute path, the same will GCS. Rootless paths will work for SFTP servers that
      // don't use chroot for user logins, while absolute paths will not.
      val pathGroup        = OptionT(tryOpt(m.group(5))).map(_.stripPrefix("/"))
      val path: Path.Plain = pathGroup.map(Path.apply).getOrElse(Path.empty).getOrElse(Path.empty)
      val scheme =
        OptionT(
          tryOpt(m.group(2)).toEither.leftMap(t => MissingScheme(c, Some(t))).leftWiden[UrlParseError]
        ).getOrElseF(MissingScheme(c, None).asLeft[String]).toValidatedNec

      (scheme, typedAuthority).mapN((s, a) => Url(s, a, path))
    }.getOrElse(CouldntParseUrl(c).invalidNec)

    if (c.startsWith("file")) parseFileUrl(c) else parseNonFile
  }

  implicit def ordering[A]: Ordering[Url[A]] = (x: Url[A], y: Url[A]) => x.show.compare(y.show)
  implicit def order[A]: Order[Url[A]]       = Order.fromOrdering
  implicit def show[A]: Show[Url[A]] = u => {
    val pathString = u.path match {
      case a @ AbsolutePath(_, _) => a.show.stripPrefix("/")
      case a                      => a.show
    }

    if (u.scheme === "file") show"${u.scheme}:///$pathString" else show"${u.scheme}://${u.authority}/$pathString"
  }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy