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

zio.http.URL.scala Maven / Gradle / Ivy

There is a newer version: 3.0.1
Show newest version
/*
 * Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP contributors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package zio.http

import java.net.{MalformedURLException, URI}

import scala.util.control.NonFatal

import zio.Config

import zio.http.URL.{Fragment, Location}
import zio.http.internal._

final case class URL(
  path: Path,
  kind: URL.Location = URL.Location.Relative,
  queryParams: QueryParams = QueryParams.empty,
  fragment: Option[Fragment] = None,
) extends URLPlatformSpecific
    with QueryOps[URL] { self =>

  /**
   * A right-biased way of combining two URLs. Where it makes sense, information
   * will be merged, but in cases where this does not make sense (e.g. two
   * non-empty fragments), the information from the right URL will be used.
   */
  def ++(that: URL): URL =
    URL(
      self.path ++ that.path,
      self.kind ++ that.kind,
      self.queryParams ++ that.queryParams,
      that.fragment.orElse(this.fragment),
    )

  def /(segment: String): URL = self.copy(path = self.path / segment)

  def absolute(host: String): URL =
    self.copy(kind = URL.Location.Absolute(Scheme.HTTP, host, None))

  def absolute(scheme: Scheme, host: String, port: Int): URL =
    self.copy(kind = URL.Location.Absolute(scheme, host, Some(port)))

  def addLeadingSlash: URL = self.copy(path = path.addLeadingSlash)

  def addPath(path: Path): URL = self.copy(path = self.path ++ path)

  def addPath(path: String): URL = self.copy(path = self.path ++ Path.decode(path))

  def addTrailingSlash: URL = self.copy(path = path.addTrailingSlash)

  def addQueryParams(queryParams: QueryParams): URL =
    copy(queryParams = self.queryParams ++ queryParams)

  def dropLeadingSlash: URL = self.copy(path = path.dropLeadingSlash)

  def dropTrailingSlash: URL = self.copy(path = path.dropTrailingSlash)

  def encode: String = URL.encode(self)

  override def equals(that: Any): Boolean = that match {
    case that: URL =>
      val left  = self.normalize
      val right = that.normalize

      left.kind == right.kind &&
      left.path == right.path &&
      left.queryParams == right.queryParams &&
      left.fragment == right.fragment

    case _ => false
  }

  override def hashCode(): Int = {
    val normalized = self.normalize

    var hash = 17
    hash = hash * 31 + normalized.kind.hashCode
    hash = hash * 31 + normalized.path.hashCode
    hash = hash * 31 + normalized.queryParams.hashCode
    hash = hash * 31 + normalized.fragment.hashCode
    hash
  }

  override def toString: String = encode

  def host: Option[String] = kind match {
    case URL.Location.Relative      => None
    case abs: URL.Location.Absolute => Option(abs.host)
  }

  def host(host: String): URL = {
    val location = kind match {
      case URL.Location.Relative      => URL.Location.Absolute(Scheme.HTTP, host, None)
      case abs: URL.Location.Absolute => abs.copy(host = host)
    }
    copy(kind = location)
  }

  /**
   * @return
   *   the location, the host name and the port. The port part is omitted if is
   *   the default port for the protocol.
   */
  def hostPort: Option[String] =
    kind match {
      case URL.Location.Relative      => None
      case abs: URL.Location.Absolute =>
        abs.portIfNotDefault match {
          case None             => Some(abs.host)
          case Some(customPort) => Some(s"${abs.host}:$customPort")
        }
    }

  def isAbsolute: Boolean = self.kind match {
    case Location.Absolute(_, _, _) => true
    case Location.Relative          => false
  }

  def isRelative: Boolean = !isAbsolute

  def normalize: URL = {
    def normalizePath(path: Path): Path =
      if (path.isEmpty || path.isRoot) Path.empty
      else path.addLeadingSlash

    self.copy(path = normalizePath(path), queryParams = queryParams.normalize)
  }

  def path(path: Path): URL =
    copy(path = path)

  def path(path: String): URL =
    copy(path = Path.decode(path))

  def port(port: Int): URL = {
    val location = kind match {
      case URL.Location.Relative      => URL.Location.Absolute(Scheme.HTTP, "", Some(port))
      case abs: URL.Location.Absolute => abs.copy(originalPort = Some(port))
    }

    copy(kind = location)
  }

  def port: Option[Int] = kind match {
    case URL.Location.Relative      => None
    case abs: URL.Location.Absolute => abs.originalPort
  }

  def portOrDefault: Option[Int] = kind match {
    case URL.Location.Relative      => None
    case abs: URL.Location.Absolute => abs.portOrDefault
  }

  def portIfNotDefault: Option[Int] = kind match {
    case URL.Location.Relative      => None
    case abs: URL.Location.Absolute => abs.portIfNotDefault
  }

  override def queryParameters: QueryParams =
    queryParams

  def relative: URL = self.kind match {
    case URL.Location.Relative => self
    case _                     => self.copy(kind = URL.Location.Relative)
  }

  /**
   * RFC 3986 § 5.2 Relative Resolution
   * @param reference
   *   the URL to resolve relative to ``this`` base URL
   * @return
   *   the target URL
   */
  def resolve(reference: URL): Either[String, URL] = {
    // See https://www.rfc-editor.org/rfc/rfc3986#section-5.2
    // § 5.2.1 - `self` is the base and already pre-parsed into components
    // § 5.2.2 - strict parsing does not ignore the reference URL scheme, so we use it directly, instead of un-setting it

    if (reference.kind.isRelative) {
      // § 5.2.2 - reference scheme is undefined, i.e. it is relative
      self.kind match {
        // § 5.2.1 - `self` is the base and is required to have a scheme, therefore it must be absolute
        case Location.Relative => Left("cannot resolve against relative url")

        case location: Location.Absolute =>
          var path: Path         = null
          var query: QueryParams = null

          if (reference.path.isEmpty) {
            // § 5.2.2 - empty reference path keeps base path unmodified
            path = self.path
            // § 5.2.2 - given an empty reference path, use non-empty reference query params,
            //           while empty reference query params keeps base query params
            // NOTE: strictly, if the reference defines a query it should be used, even if that query is empty
            //       but currently no-query is not differentiated from empty-query
            if (reference.queryParams.isEmpty) {
              query = self.queryParams
            } else {
              query = reference.queryParams
            }
          } else {
            // § 5.2.2 - non-empty reference path always keeps reference query params
            query = reference.queryParams

            if (reference.path.hasLeadingSlash) {
              // § 5.2.2 - reference path starts from root, keep reference path without dot segments
              path = reference.path.removeDotSegments
            } else {
              // § 5.2.2 - merge base and reference paths, then collapse dot segments
              // § 5.2.3 - if base has an authority AND an empty path, use the reference path, ensuring a leading slash
              //           the authority is the [user]@host[:port], which is always present on `self`,
              //           so we only need to check for an empty path
              if (self.path.isEmpty) {
                path = reference.path.addLeadingSlash
              } else {
                // § 5.2.3 - otherwise (base has no authority OR a non-empty path), drop the very last portion of the base path,
                //           and append all the reference path components
                path = Path(
                  Path.Flags.concat(self.path.flags, reference.path.flags),
                  self.path.segments.dropRight(1) ++ reference.path.segments,
                )
              }

              path = path.removeDotSegments
            }
          }

          val url = URL(path, location, query, reference.fragment)

          Right(url)

      }
    } else {
      // § 5.2.2 - if the reference scheme is defined, i.e. the reference is absolute,
      //           the target components are the reference components but with dot segments removed

      // § 5.2.2 - if the reference scheme is undefined and authority is defined, keep the base scheme
      //           and take everything else from the reference, removing dot segments from the path
      // NOTE: URL currently does not track authority separate from scheme to implement this
      //       so having an authority is the same as having a scheme and they are treated the same
      Right(reference.copy(path = reference.path.removeDotSegments))
    }
  }

  def scheme: Option[Scheme] = kind match {
    case Location.Absolute(scheme, _, _) => Some(scheme)
    case Location.Relative               => None
  }

  def scheme(scheme: Scheme): URL = {
    val location = kind match {
      case URL.Location.Relative      => URL.Location.Absolute(scheme, "", None)
      case abs: URL.Location.Absolute => abs.copy(scheme = scheme)
    }

    copy(kind = location)
  }

  /**
   * Returns a new java.net.URI representing this URL.
   */
  def toJavaURI: java.net.URI = new URI(encode)

  override def updateQueryParams(f: QueryParams => QueryParams): URL =
    copy(queryParams = f(queryParams))

}

object URL {
  val empty: URL = URL(path = Path.empty)

  /**
   * To better understand this implementation, read discussion:
   * https://github.com/zio/zio-http/pull/3017/files#r1716489733
   */
  private final class Err(rawUrl: String, cause: Throwable) extends MalformedURLException {
    override def getMessage: String  = s"""Invalid URL: "$rawUrl""""
    override def getCause: Throwable = cause
  }

  def decode(rawUrl: String): Either[MalformedURLException, URL] = {
    def invalidURL(e: Throwable = null): Either[MalformedURLException, URL] = Left(new Err(rawUrl = rawUrl, cause = e))

    try {
      val uri = new URI(rawUrl)
      val url = if (uri.isAbsolute) fromAbsoluteURI(uri) else fromRelativeURI(uri)

      url match {
        case Some(value) => Right(value)
        case None        => invalidURL()
      }
    } catch {
      case NonFatal(e) => invalidURL(e)
    }
  }

  def config: Config[URL] = Config.string.mapAttempt(decode(_).fold(throw _, identity))

  def fromURI(uri: URI): Option[URL] = if (uri.isAbsolute) fromAbsoluteURI(uri) else fromRelativeURI(uri)

  def root: URL = URL(Path.root)

  sealed trait Location { self =>
    def ++(that: Location): Location =
      if (that.isRelative) self
      else that

    def isAbsolute: Boolean = !isRelative

    def isRelative: Boolean = self match { case Location.Relative => true; case _ => false }
  }

  object Location {
    final case class Absolute(scheme: Scheme, host: String, originalPort: Option[Int]) extends Location {
      def portOrDefault: Option[Int]    = originalPort.orElse(scheme.defaultPort)
      def portIfNotDefault: Option[Int] = originalPort.filter(p => scheme.defaultPort.exists(_ != p))
      def port: Int                     = originalPort.orElse(scheme.defaultPort).getOrElse(Scheme.defaultPortForHTTP)
    }

    case object Relative extends Location
  }

  final case class Fragment private (raw: String, decoded: String)

  object Fragment {
    def fromURI(uri: URI): Option[Fragment] = for {
      raw     <- Option(uri.getRawFragment)
      decoded <- Option(uri.getFragment)
    } yield Fragment(raw, decoded)
  }

  private def encode(url: URL): String = {
    def path(relative: Boolean) =
      QueryParamEncoding.default.encode(
        if (relative || url.path.isEmpty) url.path.encode else url.path.addLeadingSlash.encode,
        url.queryParams.normalize,
        Charsets.Http,
      ) + url.fragment.fold("")(f => "#" + f.raw)

    url.kind match {
      case Location.Relative      => path(true)
      case abs: Location.Absolute =>
        val path2 = path(false)
        abs.portIfNotDefault match {
          case None             => s"${abs.scheme.encode}://${abs.host}$path2"
          case Some(customPort) => s"${abs.scheme.encode}://${abs.host}:$customPort$path2"
        }
    }
  }

  private[http] def encodeHttpPath(url: URL): String = {
    // As per the spec, the path should contain only the relative part and start with a slash.
    // Host and port information should be in the headers.
    // Query params are included while fragments are excluded.

    val pathBuf = new StringBuilder(256)

    val path = url.path

    path.segments.foreach { segment =>
      pathBuf.append('/')
      pathBuf.append(segment)
    }

    if (pathBuf.isEmpty | path.hasTrailingSlash) {
      pathBuf.append('/')
    }

    val qparams = url.queryParams

    if (qparams.isEmpty) {
      pathBuf.result()
    } else {
      // this branch could be more efficient with something like QueryParamEncoding.appendNonEmpty(pathBuf, qparams, Charsets.Http)
      // that directly filtered the keys/values and appended to the buffer
      // but for now the underlying Netty encoder requires the base url as a String anyway
      QueryParamEncoding.default.encode(pathBuf.result(), qparams.normalize, Charsets.Http)
    }
  }

  private[http] def fromAbsoluteURI(uri: URI): Option[URL] = {
    for {
      scheme <- Scheme.decode(uri.getScheme)
      host   <- Option(uri.getHost)
      path   <- Option(uri.getRawPath)
      port       = Option(uri.getPort).filter(_ != -1).orElse(scheme.defaultPort) // FIXME REMOVE defaultPort
      connection = URL.Location.Absolute(scheme, host, port)
      path2      = Path.decode(path)
      path3      = if (path.nonEmpty) path2.addLeadingSlash else path2
    } yield URL(path3, connection, QueryParams.decode(uri.getRawQuery), Fragment.fromURI(uri))
  }

  private[http] def fromRelativeURI(uri: URI): Option[URL] = for {
    path <- Option(uri.getRawPath)
  } yield URL(Path.decode(path), Location.Relative, QueryParams.decode(uri.getRawQuery), Fragment.fromURI(uri))

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy