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
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
|
|
}
|
|
}
|