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

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

/*
 * 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 zio.{Chunk, ChunkBuilder}

/**
 * Path is an immutable representation of the path of a URL. Internally it
 * stores each element of a path in a sequence of text, together with flags on
 * whether there are leading and trailing slashes in the path. This allows for a
 * combination of precision and performance and supports a rich API.
 */
final case class Path private[http] (flags: Path.Flags, segments: Chunk[String]) { self =>
  import Path.{Flag, Flags}

  /**
   * Appends a segment at the end of the path.
   *
   * If there is already a trailing slash when you append a segment, then the
   * trailing slash will be removed (more precisely, it becomes the slash that
   * separates the existing path from the new segment).
   */
  def /(name: String): Path =
    if (name == "") addTrailingSlash
    else if (isRoot) Path(Flags(Flag.LeadingSlash), Chunk(name))
    else Path(Flag.TrailingSlash.remove(flags), segments = segments :+ name)

  /**
   * Prepends the path with the provided segment.
   *
   * If there is already a leading slash when you prepend a segment, then the
   * leading slash will be removed (more precisely, it becomes the slash that
   * separates the new segment from the existing path).
   */
  def /:(name: String): Path =
    if (name == "") addLeadingSlash
    else if (isRoot) Path(Flags(Flag.TrailingSlash), Chunk(name))
    else Path(Flag.LeadingSlash.remove(flags), segments = name +: segments)

  /**
   * Combines two paths together to create a new one, having a leading slash if
   * only the left path has a leading slash, and a trailing slash if only the
   * right path has a trailing slash. Note that if you concat the empty path to
   * any other path, either on the left or right hand side, you get back that
   * same path unmodified.
   */
  def ++(that: Path): Path =
    if (self.isEmpty) that
    else if (that.isEmpty) self
    else Path(Flags.concat(self.normalize.flags, that.normalize.flags), self.segments ++ that.segments)

  /**
   * Prepends a leading slash to the path.
   */
  def addLeadingSlash: Path =
    if (hasLeadingSlash) self
    else if (segments.isEmpty) Path(Flags(Flag.LeadingSlash), Chunk.empty)
    else Path(Flag.LeadingSlash.add(flags), segments)

  /**
   * Appends a trailing slash to the path.
   */
  def addTrailingSlash: Path =
    if (hasTrailingSlash) self
    else if (segments.isEmpty) Path(Flags(Flag.TrailingSlash), Chunk.empty)
    else Path(Flag.TrailingSlash.add(flags), segments)

  /**
   * Named alias to `++` operator.
   */
  def concat(other: Path): Path = self ++ other

  /**
   * Drops the first n segments from the path, treating both leading and
   * trailing slashes as segments.
   */
  def drop(n: Int): Path =
    if (n <= 0) self
    else {
      if (isRoot) Path.empty
      else if (hasLeadingSlash) dropLeadingSlash.drop(n - 1)
      else copy(segments = segments.drop(n))
    }

  /**
   * Drops the last n segments from the path, treating both leading and trailing
   * slashes as segments.
   */
  def dropRight(n: Int): Path = take(size - n)

  /**
   * Drops the leading slash if available.
   */
  def dropLeadingSlash: Path =
    if (isRoot) Path.empty
    else if (!Flag.LeadingSlash.check(flags)) self
    else copy(Flag.LeadingSlash.remove(flags))

  /**
   * Drops the trailing slash if available.
   */
  def dropTrailingSlash: Path =
    if (isRoot) Path.empty
    else if (!Flag.TrailingSlash.check(flags)) self
    else copy(flags = Flag.TrailingSlash.remove(flags))

  /**
   * Encodes the current path into a valid string.
   */
  def encode: String =
    if (self == Path.empty) ""
    else if (self == Path.root) "/"
    else segments.mkString(if (hasLeadingSlash) "/" else "", "/", if (hasTrailingSlash) "/" else "")

  override def equals(that: Any): Boolean =
    that match {
      case that: Path =>
        val normalLeft  = self.normalize
        val normalRight = that.normalize

        Flags.equivalent(normalLeft.flags, normalRight.flags) && normalLeft.segments == normalRight.segments

      case _ => false
    }

  /**
   * Checks if the path contains a leading slash.
   */
  def hasLeadingSlash: Boolean = Flag.LeadingSlash.check(flags)

  /**
   * Checks if the path contains a trailing slash.
   */
  def hasTrailingSlash: Boolean = Flag.TrailingSlash.check(flags)

  override def hashCode: Int = {
    val normalized = normalize

    var result = 17
    result = 31 * result + Flags.essential(normalized.flags)
    result = 31 * result + normalized.segments.hashCode
    result
  }

  /**
   * Checks if the path is equal to "".
   */
  def isEmpty: Boolean = segments.isEmpty && (flags == Flags.none)

  /**
   * Checks if the path is equal to "/".
   * @return
   */
  def isRoot: Boolean = segments.isEmpty && (hasLeadingSlash || hasTrailingSlash)

  /**
   * Checks if the path is not equal to "".
   */
  def nonEmpty: Boolean = !isEmpty

  /**
   * Normalizes the path for proper equals/hashCode treatment.
   */
  def normalize: Path =
    if (segments.isEmpty) {
      if (hasLeadingSlash) Path.root
      else if (hasTrailingSlash) Path.root
      else Path.empty
    } else self

  /**
   * RFC 3986 § 5.2.4 Remove Dot Segments
   * @return
   *   the Path with `.` and `..` resolved and removed
   */
  def removeDotSegments: Path = {
    // See https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4
    val segments     = new Array[String](self.segments.length)
    var segmentCount = 0
    // leading/trailing slashes may change but is unlikely
    var flags        = self.flags

    var i   = 0
    val max = self.segments.length

    if (!Flag.LeadingSlash.check(flags)) {
      // § 5.2.4.2.A/D no leading slash, so skip all initial `./` and `../`
      while (i < max && (self.segments(i) == "." | self.segments(i) == "..")) {
        i += 1
      }
      // if the entire input was consumed, there is no more trailing slash
      if (i == max) flags = Flag.TrailingSlash.remove(flags)
    }

    var loop = i < max
    while (loop) {
      val segment = self.segments(i)

      i += 1
      loop = i < max

      if (segment == "..") {
        segmentCount = (segmentCount - 1).max(0)
        // § 5.2.4.2.C resolving `/..` and `/../` removes preceding slashes and is itself replaced by a slash
        // so if we popped the first one we definitely have a leading slash
        if (segmentCount == 0) flags = Flag.LeadingSlash.add(flags)
        // § 5.2.4.2.C resolving `/..` and `/../` are both as-if replaced by a `/`
        // so if this is the last segment, then we have a trailing slash
        if (i == max) flags = Flag.TrailingSlash.add(flags)
      } else if (segment == ".") {
        // § 5.2.4.2.B resolving `/.` and `/./` are both as-if replaced by a `/`
        // so if this is the last segment, then we have a trailing slash
        if (i == max) flags = Flag.TrailingSlash.add(flags)
      } else {
        segments(segmentCount) = segment
        segmentCount += 1
      }
    }

    Path(flags, Chunk.fromArray(segments.take(segmentCount)))
  }

  /**
   * Creates a new path from this one with it's segments reversed.
   */
  def reverse: Path = Path(Flags.reverse(flags), segments.reverse)

  /**
   * Returns the "size" of a path, which counts leading and trailing slash, if
   * present.
   */
  def size: Int =
    if (isEmpty) 0
    else if (isRoot) 1
    else segments.length + (if (hasLeadingSlash) 1 else 0) + (if (hasTrailingSlash) 1 else 0)

  /**
   * Checks if the path starts with the provided path
   */
  def startsWith(other: Path): Boolean =
    (self.hasLeadingSlash == other.hasLeadingSlash) && segments.startsWith(other.segments)

  /**
   * Returns a new path containing the first n segments of the path, treating
   * both leading and trailing slashes as segments.
   */
  def take(n: Int): Path =
    if (n <= 0) Path.empty
    else {
      if (n >= size) self
      else Path(Flag.TrailingSlash.remove(flags), segments = segments.take(n - (if (hasLeadingSlash) 1 else 0)))
    }

  override def toString: String = encode

  lazy val unapply: Option[(String, Path)] =
    if (hasLeadingSlash) Some(("", drop(1)))
    else if (segments.nonEmpty) Some((segments.head, copy(segments = segments.drop(1))))
    else if (hasTrailingSlash) Some(("", Path.empty))
    else None

  lazy val unapplyRight: Option[(Path, String)] =
    if (hasTrailingSlash) Some((dropRight(1), ""))
    else if (segments.nonEmpty) Some((copy(segments = segments.dropRight(1)), segments.last))
    else if (hasLeadingSlash) Some((Path.empty, ""))
    else None

  /**
   * Unnests a path that has been nested at the specified prefix. If the path is
   * not nested at the specified prefix, the same path is returned.
   */
  def unnest(prefix: Path): Path =
    if (startsWith(prefix)) drop(prefix.size) else self
}

object Path {
  def apply(path: String): Path = decode(path)

  /**
   * Decodes a path string into a Path.
   */
  def decode(path: String): Path =
    if (path.isEmpty) Path.empty
    else {
      val chunkBuilder = ChunkBuilder.make[String]()

      var flags: Path.Flags = Path.Flags.none

      val max       = path.length - 1
      var lastSlash = -1

      var i    = 0
      var loop = true
      while (loop) {
        val char = path.charAt(i)
        if (char == '/') {
          if (i == 0) {
            flags = Path.Flag.LeadingSlash.add(flags)
          }
          if (i == max) {
            if (i != 0) flags = Path.Flag.TrailingSlash.add(flags)
            loop = false
          }
          val segmentLen = (i - 1) - lastSlash

          if (segmentLen > 0) {
            chunkBuilder += path.substring(lastSlash + 1, i)
          }

          lastSlash = i
        } else if (i == max) {
          loop = false

          val segmentLen = i - lastSlash
          if (segmentLen > 0) {
            chunkBuilder += path.substring(lastSlash + 1, i + 1)
          }
        }
        i = i + 1
      }

      Path(flags, chunkBuilder.result())
    }

  /**
   * Represents a empty path which is rendered as "".
   */
  val empty: Path = Path(Flags.none, Chunk.empty)

  /**
   * Represents a slash or a root path which is equivalent to "/".
   */
  val root: Path = Path(Flags(Flag.LeadingSlash, Flag.TrailingSlash), Chunk.empty)

  type Flags = Int
  object Flags {
    def apply(flag: Flag, flags: Flag*): Flags =
      flags.foldLeft(flag.mask)((acc, flag) => acc | flag.mask)

    def concat(first: Flags, last: Flags): Int = {
      var result = 0
      if (Flag.LeadingSlash.check(first)) result = result | Flag.LeadingSlash.mask
      if (Flag.TrailingSlash.check(last)) result = result | Flag.TrailingSlash.mask
      result
    }

    def equivalent(left: Flags, right: Flags): Boolean =
      essential(left) == essential(right)

    def essential(flags: Flags): Flags = flags

    val none: Flags = 0

    def reverse(flags: Flags): Int = {
      var result = 0
      if (Flag.LeadingSlash.check(flags)) result |= Flag.TrailingSlash.mask
      if (Flag.TrailingSlash.check(flags)) result |= Flag.LeadingSlash.mask
      result
    }

  }

  sealed trait Flag {
    private[http] val shift: Int
    private[http] val mask: Int
    private[http] val invertMask: Int

    private[http] def check(flags: Int): Boolean = (flags & mask) != 0

    private[http] def add(flags: Flags): Flags = flags | mask

    private[http] def remove(flags: Flags): Flags = flags & invertMask
  }
  object Flag       {
    case object LeadingSlash  extends Flag {
      private[http] final val shift      = 0
      private[http] final val mask       = 1 << shift
      private[http] final val invertMask = ~mask

      def apply(path: Path): Path = path.addLeadingSlash

      def unapply(path: Path): Option[Path] =
        if (path.hasLeadingSlash) Some(path.dropLeadingSlash) else None
    }
    case object TrailingSlash extends Flag {
      private[http] final val shift      = 1
      private[http] final val mask       = 1 << shift
      private[http] final val invertMask = ~mask

      def apply(path: Path): Path = path.addTrailingSlash

      def unapply(path: Path): Option[Path] =
        if (path.hasTrailingSlash) Some(path.dropTrailingSlash) else None
    }
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy