You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

246 lines
7.4 KiB

// Completely copied from org.http4s.dsl.impl
package outwatch.router
import cats.implicits._
import java.nio.{ByteBuffer, CharBuffer}
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets.UTF_8
/** Base class for path extractors. */
trait Path {
def /(child: String) = new /(this, child)
def toList: List[String]
def parent: Path
def lastOption: Option[String]
def startsWith(other: Path): Boolean
def pathString: String
def toUrlString: String = if (pathString.isEmpty) "/" else pathString
}
object Path {
/** Constructs a path from a single string by splitting on the `'/'`
* character.
*
* Leading slashes do not create an empty path segment. This is to
* reflect that there is no distinction between a request to
* `http://www.example.com` from `http://www.example.com/`.
*
* Trailing slashes result in a path with an empty final segment,
* unless the path is `"/"`, which is `Root`.
*
* Segments are URL decoded.
*
* {{{
* scala> Path("").toList
* res0: List[String] = List()
* scala> Path("/").toList
* res1: List[String] = List()
* scala> Path("a").toList
* res2: List[String] = List(a)
* scala> Path("/a").toList
* res3: List[String] = List(a)
* scala> Path("/a/").toList
* res4: List[String] = List(a, "")
* scala> Path("//a").toList
* res5: List[String] = List("", a)
* scala> Path("/%2F").toList
* res0: List[String] = List(/)
* }}}
*/
def apply(str: String): Path =
if (str == "" || str == "/")
Root
else {
val segments = str.split("/", -1)
// .head is safe because split always returns non-empty array
val segments0 = if (segments.head == "") segments.drop(1) else segments
segments0.foldLeft(Root: Path)((path, seg) => path / UrlCodingUtils.urlDecode(seg))
}
def apply(first: String, rest: String*): Path =
rest.foldLeft(Root / first)(_ / _)
def apply(list: List[String]): Path =
list.foldLeft(Root: Path)(_ / _)
def apply(left: Path, right: Path): Path =
right.toList.foldLeft(left)(_ / _)
def unapplySeq(path: Path): Some[List[String]] =
Some(path.toList)
//
// def unapplySeq[F[_]](request: Request[F]): Some[List[String]] =
// Some(Path(request.pathInfo).toList)
}
final case class /(parent: Path, child: String) extends Path {
lazy val toList: List[String] = parent.toList ++ List(child)
def lastOption: Some[String] = Some(child)
lazy val asString: String = s"${parent.pathString}/${UrlCodingUtils.pathEncode(child)}"
override def toString: String = asString
def pathString: String = asString
def startsWith(other: Path): Boolean = {
val components = other.toList
toList.take(components.length) === components
}
}
/**
* Path separator extractor:
* {{{
* Path("/1/2/3/test.json") match {
* case "1" /: "2" /: _ => ...
* }}}
*/
object /: {
def unapply(path: Path): Option[(String, Path)] =
path.toList match {
case head :: tail => Some(head -> Path(tail))
case Nil => None
}
}
/**
* Root extractor:
* {{{
* Path("/") match {
* case Root => ...
* }
* }}}
*/
case object Root extends Path {
def toList: List[String] = Nil
def parent: Path = this
def lastOption: None.type = None
override def toString = "Root"
def pathString: String = ""
def startsWith(other: Path): Boolean = other == Root
}
private[router] object UrlCodingUtils {
private val lower = ('a' to 'z').toSet
private val upper = ('A' to 'Z').toSet
private val num = ('0' to '9').toSet
val Unreserved: Set[Char] = lower ++ upper ++ num ++ "-_.~"
private val toSkip : Set[Char] = Unreserved ++ "!$&'()*+,;=:/?@"
private val HexUpperCaseChars: Array[Char] = ('A' to 'F').toArray
/**
* Percent-encodes a string. Depending on the parameters, this method is
* appropriate for URI or URL form encoding. Any resulting percent-encodings
* are normalized to uppercase.
*
* @param toEncode the string to encode
* @param charset the charset to use for characters that are percent encoded
* @param spaceIsPlus if space is not skipped, determines whether it will be
* rendreed as a `"+"` or a percent-encoding according to `charset`.
* @param toSkip a predicate of characters exempt from encoding. In typical
* use, this is composed of all Unreserved URI characters and sometimes a
* subset of Reserved URI characters.
*/
def urlEncode(
toEncode: String,
charset: Charset = UTF_8,
spaceIsPlus: Boolean = false,
toSkip: Char => Boolean = toSkip): String = {
val in = charset.encode(toEncode)
val out = CharBuffer.allocate((in.remaining() * 3).toInt)
while (in.hasRemaining) {
val c = in.get().toChar
if (toSkip(c)) {
out.put(c)
} else if (c == ' ' && spaceIsPlus) {
out.put('+')
} else {
out.put('%')
out.put(HexUpperCaseChars((c >> 4) & 0xF))
out.put(HexUpperCaseChars(c & 0xF))
}
}
out.flip()
out.toString
}
private val SkipEncodeInPath =
Unreserved ++ ":@!$&'()*+,;="
def pathEncode(s: String, charset: Charset = UTF_8): String =
UrlCodingUtils.urlEncode(s, charset, false, SkipEncodeInPath)
/**
* Percent-decodes a string.
*
* @param toDecode the string to decode
* @param charset the charset of percent-encoded characters
* @param plusIsSpace true if `'+'` is to be interpreted as a `' '`
* @param toSkip a predicate of characters whose percent-encoded form
* is left percent-encoded. Almost certainly should be left empty.
*/
def urlDecode(
toDecode: String,
charset: Charset = UTF_8,
plusIsSpace: Boolean = false,
toSkip: Char => Boolean = Function.const(false)): String = {
val in = CharBuffer.wrap(toDecode)
// reserve enough space for 3-byte UTF-8 characters. 4-byte characters are represented
// as surrogate pairs of characters, and will get a luxurious 6 bytes of space.
val out = ByteBuffer.allocate(in.remaining() * 3)
while (in.hasRemaining) {
val mark = in.position()
val c = in.get()
if (c == '%') {
if (in.remaining() >= 2) {
val xc = in.get()
val yc = in.get()
// scalastyle:off magic.number
val x = Character.digit(xc, 0x10)
val y = Character.digit(yc, 0x10)
// scalastyle:on magic.number
if (x != -1 && y != -1) {
val oo = (x << 4) + y
if (!toSkip(oo.toChar)) {
out.put(oo.toByte)
} else {
out.put('%'.toByte)
out.put(xc.toByte)
out.put(yc.toByte)
}
} else {
out.put('%'.toByte)
in.position(mark + 1)
}
} else {
// This is an invalid encoding. Fail gracefully by treating the '%' as
// a literal.
out.put(c.toByte)
while (in.hasRemaining) out.put(in.get().toByte)
}
} else if (c == '+' && plusIsSpace) {
out.put(' '.toByte)
} else {
// normally `out.put(c.toByte)` would be enough since the url is %-encoded,
// however there are cases where a string can be partially decoded
// so we have to make sure the non us-ascii chars get preserved properly.
if (this.toSkip(c)) {
out.put(c.toByte)
} else {
out.put(charset.encode(String.valueOf(c)))
}
}
}
out.flip()
charset.decode(out).toString
}
}