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

spinoco.protocol.http.Uri.scala Maven / Gradle / Ivy

The newest version!
package spinoco.protocol.http

import java.net.{URLDecoder, URLEncoder}

import scodec.{Attempt, Codec, Err, codecs}
import codec.helper._
import scodec.bits.BitVector
import spinoco.protocol.common.util._
import spinoco.protocol.common.codec._
import spinoco.protocol.common.Terminator
import spinoco.protocol.http.codec.RFC3986

import scala.annotation.tailrec

/**
  * Internet Uri, as defined in http://tools.ietf.org/html/rfc3986
  * All values are decoded (no % escaping)
  */
sealed case class Uri(
  scheme: Scheme
  , host: HostPort
  , path: Uri.Path
  , query: Uri.Query
) { self =>
  /** replaces query with one specified **/
  def withQuery(query: Uri.Query): Uri =
    self.copy(query = query)

  /** appends supplied param to uri **/
  def withParam(k: String, v: String): Uri =
    self.copy(query = self.query :+ (k, v))

  /** if this is valid Uri, yields to string representation of this Uri **/
  lazy val stringify: Attempt[String] = {
    Uri.codec.encode(self) flatMap { bytes =>
      Attempt.fromEither(bytes.decodeUtf8.left.map(rsn => Err(s"Failed to decode UTF8: $rsn")))
    }
  }

  /** throws if this cannot be encoded to Uri **/
  def stringifyUnsafe: String =
    stringify.fold(err => throw new Throwable(s"Failed to stringify: ${err.message}"), identity)


}


object Uri {

  def http(host: String, path: String): Uri =
    Uri(HttpScheme.HTTP, HostPort(host, None), Uri.Path.fromUtf8String(path), Query.empty)

  def http(host: String, port: Int, path: String): Uri =
    Uri(HttpScheme.HTTP, HostPort(host, Some(port)), Uri.Path.fromUtf8String(path), Query.empty)

  def https(host: String, path: String): Uri =
    Uri(HttpScheme.HTTPS, HostPort(host, None), Uri.Path.fromUtf8String(path), Query.empty)

  def https(host: String, port: Int, path: String): Uri =
    Uri(HttpScheme.HTTPS, HostPort(host, Some(port)), Uri.Path.fromUtf8String(path), Query.empty)

  def ws(host: String, path: String): Uri =
    Uri(HttpScheme.WS, HostPort(host, None), Uri.Path.fromUtf8String(path), Query.empty)

  def ws(host: String, port: Int, path: String): Uri =
    Uri(HttpScheme.WS, HostPort(host, Some(port)), Uri.Path.fromUtf8String(path), Query.empty)

  def wss(host: String, path: String): Uri =
    Uri(HttpScheme.WSS, HostPort(host, None), Uri.Path.fromUtf8String(path), Query.empty)

  def wss(host: String, port: Int, path: String): Uri =
    Uri(HttpScheme.WSS, HostPort(host, Some(port)), Uri.Path.fromUtf8String(path), Query.empty)

  /** parse supplied string and receive Uri, if supplied string is valid **/
  def parse(uriString: String): Attempt[Uri] =
    codec.decodeValue(BitVector.view(uriString.getBytes))

  val pathQueryCodec:Codec[(Uri.Path, Uri.Query)] = {
    val queryCodec: Codec[Query] = {
      val d = (codecs.byte.unit('?') ~> Query.codec)
      Codec(q => if (q.params.isEmpty) attempt(BitVector.empty) else d.encode(q), d.decode _)
    }

    bytesUntil(_ != '?').codedAs(Path.codec) ~
    codecs.optional(codecs.bitsRemaining, queryCodec).xmap(_.getOrElse(Query.empty), Some(_))
  }

  val hostPortCodec: Codec[HostPort] = bytesUntil(_ != '/').codedAs(HostPort.codec)

  val codec: Codec[Uri] = {

    val hostPathQueryCodec:Codec[(HostPort, Uri.Path, Uri.Query)] = {
      (bytesUntil(b => b != '/' && b != '?').codedAs(HostPort.codec) ~ pathQueryCodec).xmap(
        { case (hp, (path, query)) => (hp, path, query) }
        , { case (hp, path, query)  =>  (hp, (path, query)) }
      )
    }

    (terminated(Scheme.codec, Terminator.constantString1("://")) ~ hostPathQueryCodec)
    .xmap(
      { case (scheme, (host, path, query)) => Uri(scheme, host, path, query) }
      , uri => (uri.scheme, (uri.host, uri.path, uri.query))
    )
  }


  sealed case class Path(initialSlash: Boolean, trailingSlash:Boolean, segments: Seq[String]) { self =>

    def / (s: String) =
      self.copy(trailingSlash = false, segments = self.segments :+ s)

    def / =
      self.copy(trailingSlash = true)


    def stringify:String = {
      val sb = new StringBuilder()
      if (self.initialSlash) sb.append("/")
      sb.append(self.segments.map(RFC3986.encodePathSegment).mkString("/"))
      if (self.trailingSlash) sb.append("/")
      sb.toString()
    }

  }

  object Path {

    private val PlusRegex = "\\+".r

    /** constructs relative path without initial slash (`/`) **/
    def relative(s: String) : Path =
      Path(initialSlash = false, trailingSlash = false, segments = Seq(s))

    /** constructs absolute path with initial slash (`/`) **/
    def absolute(s: String) : Path =
      Path(initialSlash = true, trailingSlash = false, segments = Seq(s))

    def  / (s: String) : Path = absolute(s)

    def fromUtf8String(path: String):Uri.Path = {
      val trimmed = path.trim
      val segments = trimmed.split("/").filter(_.nonEmpty).map { s =>
        // avoid URLDecoder turning a + into a space
        val segment = PlusRegex.replaceAllIn(s, "%2B")
        URLDecoder.decode(segment, "UTF-8")
      }
      Path(
        initialSlash =  trimmed.startsWith("/")
        , segments = segments.toIndexedSeq
        , trailingSlash = trimmed.endsWith("/") && segments.nonEmpty
      )
    }


    val codec : Codec[Uri.Path] = {
      trimmedUtf8String.xmap(fromUtf8String, _.stringify)
    }

    val Root: Path = Path(initialSlash = true, segments = Nil, trailingSlash = false)
    val Empty: Path = Path(initialSlash = false, segments = Nil, trailingSlash = false)
  }


  sealed case class Query(params: List[QueryParameter]) { self =>

    def append(param: QueryParameter): Query = self.copy(params = self.params :+ param)
    def append(k: String, v: String): Query = append(QueryParameter.single(k, v))
    def append(flag: String): Query = append(QueryParameter.flag(flag))

    def :+(param: QueryParameter): Query = append(param)
    def :+(k: String, v: String): Query = append(QueryParameter.single(k, v))
    def :+(flag: String): Query = append(QueryParameter.flag(flag))

    def hasFlag(flag: String): Boolean =
      collectFirst { case QueryParameter.Flag(`flag`) => true }.getOrElse(false)

    def valueOf(k: String): Option[String] =
      collectFirst { case QueryParameter.Single(`k`, v) => v }

    def collectFirst[A](pf: PartialFunction[SingleOrFlagParameter, A]): Option[A] = {
      val lifted = pf.lift
      @tailrec
      def go(rem: List[QueryParameter]): Option[A] = {
        rem.headOption match {
          case Some(p) => p match {
            case QueryParameter.Multi(p1, p2, tail) => go(p1 +: p2 +: (tail ++ rem))
            case param: SingleOrFlagParameter => lifted(param) match {
              case None => go(rem.tail)
              case Some(a) => Some(a)
            }
          }

          case None => None
        }
      }
      go(self.params)
    }

    def collect[A](pf: PartialFunction[SingleOrFlagParameter, A]): List[A] = {
      val lifted = pf.lift
      @tailrec
      def go(rem: List[QueryParameter], acc: Vector[A]): List[A] = {
        rem.headOption match {
          case Some(p) => p match {
            case QueryParameter.Multi(p1, p2, tail) => go(p1 +: p2 +: (tail ++ rem), acc)
            case param: SingleOrFlagParameter =>  go(rem.tail, acc ++ lifted(param))
          }

          case None => acc.toList
        }
      }
      go(self.params, Vector.empty)
    }

  }

  object Query {
    import scodec.codecs._

    val empty = Query(List.empty)

    def apply(k:String, v:String): Query = empty :+ (k,v)
    def apply(flag: String): Query = empty :+ flag

    private def urlDecode(s: String) = attempt(URLDecoder.decode(s.trim, "UTF-8"))
    private def urlEncode(s: String) = attempt(URLEncoder.encode(s, "UTF-8"))

    val codec: Codec[Query] = {
      val `=` = BitVector.view("=".getBytes)
      val `;` = BitVector.view(";".getBytes)

      val urlEncodedString:Codec[String] =
        utf8.exmap(urlDecode, urlEncode)

      val param: Codec[QueryParameter] = {
        import QueryParameter._
        val flag: Codec[Flag] = {
          listDelimited(`=`, urlEncodedString).narrow(
            {
              case a :: Nil =>  Attempt.successful(Flag(a))
              case a :: b :: Nil if b.trim.isEmpty => Attempt.successful(Flag(a))
              case other => Attempt.failure(Err(s"Failed to decode flag, must be flag alone or followed by `=` : $other"))
            }
            , flag => List(flag.name)
          )
        }


        val single: Codec[Single] = {
          listDelimited(`=`, urlEncodedString).narrow(
            {
              case a :: b :: Nil if b.trim.nonEmpty => Attempt.successful(Single(a,b))
              case other => Attempt.failure(Err(s"Single paramter must be from key and value, both separated by `=` character: $other"))
            }
            , single => List(single.name, single.value)
          )
        }


        val multi: Codec[Multi] = {
          val multiParam: Codec[SingleOrFlagParameter] = {
            choice(
              single.upcast[SingleOrFlagParameter]
              , flag.upcast[SingleOrFlagParameter]
            )
          }

          listDelimited(`;`, multiParam).narrow(
            {
              case p1 :: p2 :: tail => Attempt.successful(Multi(p1, p2, tail))
              case other => Attempt.failure(Err(s"Uri Query multi-parameter must be seprated by `;` and must have at least two parameters: $other"))
            }
            , multi => List(multi.p1, multi.p2) ++ multi.tail
          )
        }

        choice(
          multi.upcast[QueryParameter]
          , single.upcast[QueryParameter]
          , flag.upcast[QueryParameter]
        )
      }

      spinoco.protocol.http.codec.helper.delimitedBy(amp,amp, param).xmap(Query(_), _.params)
    }



  }


  sealed trait QueryParameter {
    import QueryParameter._
    def append(param: SingleOrFlagParameter): Multi
    def append(k: String, v: String): Multi = append(Single(k, v))
    def append(flag: String): Multi = append(Flag(flag))

    def :+ (param: SingleOrFlagParameter): Multi = append(param)
    def :+ (k: String, v: String): Multi = append(Single(k, v))
    def :+ (flag: String): Multi = append(Flag(flag))
  }

  sealed trait SingleOrFlagParameter extends QueryParameter {
  }

  object QueryParameter {

    def single(k: String, v: String): Single = Single(k, v)
    def flag(flg: String): Flag = Flag(flg)
    def multi(p1: SingleOrFlagParameter, p2: SingleOrFlagParameter): Multi = Multi(p1, p2, Nil)

    final case class Single(name: String, value: String) extends SingleOrFlagParameter {
      def append(param: SingleOrFlagParameter): Multi = Multi(this, param, Nil)
    }
    final case class Flag(name: String) extends  SingleOrFlagParameter {
      def append(param: SingleOrFlagParameter): Multi = Multi(this, param, Nil)
    }
    final case class Multi(p1: SingleOrFlagParameter, p2: SingleOrFlagParameter, tail: List[SingleOrFlagParameter]) extends QueryParameter {
      def append(param: SingleOrFlagParameter): Multi = copy(tail = tail :+ param)

    }
  }



}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy