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

  1. // Completely copied from org.http4s.dsl.impl
  2. package outwatch.router
  3. import cats.implicits._
  4. import java.nio.{ByteBuffer, CharBuffer}
  5. import java.nio.charset.Charset
  6. import java.nio.charset.StandardCharsets.UTF_8
  7. /** Base class for path extractors. */
  8. trait Path {
  9. def /(child: String) = new /(this, child)
  10. def toList: List[String]
  11. def parent: Path
  12. def lastOption: Option[String]
  13. def startsWith(other: Path): Boolean
  14. def pathString: String
  15. def toUrlString: String = if (pathString.isEmpty) "/" else pathString
  16. }
  17. object Path {
  18. /** Constructs a path from a single string by splitting on the `'/'`
  19. * character.
  20. *
  21. * Leading slashes do not create an empty path segment. This is to
  22. * reflect that there is no distinction between a request to
  23. * `http://www.example.com` from `http://www.example.com/`.
  24. *
  25. * Trailing slashes result in a path with an empty final segment,
  26. * unless the path is `"/"`, which is `Root`.
  27. *
  28. * Segments are URL decoded.
  29. *
  30. * {{{
  31. * scala> Path("").toList
  32. * res0: List[String] = List()
  33. * scala> Path("/").toList
  34. * res1: List[String] = List()
  35. * scala> Path("a").toList
  36. * res2: List[String] = List(a)
  37. * scala> Path("/a").toList
  38. * res3: List[String] = List(a)
  39. * scala> Path("/a/").toList
  40. * res4: List[String] = List(a, "")
  41. * scala> Path("//a").toList
  42. * res5: List[String] = List("", a)
  43. * scala> Path("/%2F").toList
  44. * res0: List[String] = List(/)
  45. * }}}
  46. */
  47. def apply(str: String): Path =
  48. if (str == "" || str == "/")
  49. Root
  50. else {
  51. val segments = str.split("/", -1)
  52. // .head is safe because split always returns non-empty array
  53. val segments0 = if (segments.head == "") segments.drop(1) else segments
  54. segments0.foldLeft(Root: Path)((path, seg) => path / UrlCodingUtils.urlDecode(seg))
  55. }
  56. def apply(first: String, rest: String*): Path =
  57. rest.foldLeft(Root / first)(_ / _)
  58. def apply(list: List[String]): Path =
  59. list.foldLeft(Root: Path)(_ / _)
  60. def apply(left: Path, right: Path): Path =
  61. right.toList.foldLeft(left)(_ / _)
  62. def unapplySeq(path: Path): Some[List[String]] =
  63. Some(path.toList)
  64. //
  65. // def unapplySeq[F[_]](request: Request[F]): Some[List[String]] =
  66. // Some(Path(request.pathInfo).toList)
  67. }
  68. final case class /(parent: Path, child: String) extends Path {
  69. lazy val toList: List[String] = parent.toList ++ List(child)
  70. def lastOption: Some[String] = Some(child)
  71. lazy val asString: String = s"${parent.pathString}/${UrlCodingUtils.pathEncode(child)}"
  72. override def toString: String = asString
  73. def pathString: String = asString
  74. def startsWith(other: Path): Boolean = {
  75. val components = other.toList
  76. toList.take(components.length) === components
  77. }
  78. }
  79. /**
  80. * Path separator extractor:
  81. * {{{
  82. * Path("/1/2/3/test.json") match {
  83. * case "1" /: "2" /: _ => ...
  84. * }}}
  85. */
  86. object /: {
  87. def unapply(path: Path): Option[(String, Path)] =
  88. path.toList match {
  89. case head :: tail => Some(head -> Path(tail))
  90. case Nil => None
  91. }
  92. }
  93. /**
  94. * Root extractor:
  95. * {{{
  96. * Path("/") match {
  97. * case Root => ...
  98. * }
  99. * }}}
  100. */
  101. case object Root extends Path {
  102. def toList: List[String] = Nil
  103. def parent: Path = this
  104. def lastOption: None.type = None
  105. override def toString = "Root"
  106. def pathString: String = ""
  107. def startsWith(other: Path): Boolean = other == Root
  108. }
  109. private[router] object UrlCodingUtils {
  110. private val lower = ('a' to 'z').toSet
  111. private val upper = ('A' to 'Z').toSet
  112. private val num = ('0' to '9').toSet
  113. val Unreserved: Set[Char] = lower ++ upper ++ num ++ "-_.~"
  114. private val toSkip : Set[Char] = Unreserved ++ "!$&'()*+,;=:/?@"
  115. private val HexUpperCaseChars: Array[Char] = ('A' to 'F').toArray
  116. /**
  117. * Percent-encodes a string. Depending on the parameters, this method is
  118. * appropriate for URI or URL form encoding. Any resulting percent-encodings
  119. * are normalized to uppercase.
  120. *
  121. * @param toEncode the string to encode
  122. * @param charset the charset to use for characters that are percent encoded
  123. * @param spaceIsPlus if space is not skipped, determines whether it will be
  124. * rendreed as a `"+"` or a percent-encoding according to `charset`.
  125. * @param toSkip a predicate of characters exempt from encoding. In typical
  126. * use, this is composed of all Unreserved URI characters and sometimes a
  127. * subset of Reserved URI characters.
  128. */
  129. def urlEncode(
  130. toEncode: String,
  131. charset: Charset = UTF_8,
  132. spaceIsPlus: Boolean = false,
  133. toSkip: Char => Boolean = toSkip): String = {
  134. val in = charset.encode(toEncode)
  135. val out = CharBuffer.allocate((in.remaining() * 3).toInt)
  136. while (in.hasRemaining) {
  137. val c = in.get().toChar
  138. if (toSkip(c)) {
  139. out.put(c)
  140. } else if (c == ' ' && spaceIsPlus) {
  141. out.put('+')
  142. } else {
  143. out.put('%')
  144. out.put(HexUpperCaseChars((c >> 4) & 0xF))
  145. out.put(HexUpperCaseChars(c & 0xF))
  146. }
  147. }
  148. out.flip()
  149. out.toString
  150. }
  151. private val SkipEncodeInPath =
  152. Unreserved ++ ":@!$&'()*+,;="
  153. def pathEncode(s: String, charset: Charset = UTF_8): String =
  154. UrlCodingUtils.urlEncode(s, charset, false, SkipEncodeInPath)
  155. /**
  156. * Percent-decodes a string.
  157. *
  158. * @param toDecode the string to decode
  159. * @param charset the charset of percent-encoded characters
  160. * @param plusIsSpace true if `'+'` is to be interpreted as a `' '`
  161. * @param toSkip a predicate of characters whose percent-encoded form
  162. * is left percent-encoded. Almost certainly should be left empty.
  163. */
  164. def urlDecode(
  165. toDecode: String,
  166. charset: Charset = UTF_8,
  167. plusIsSpace: Boolean = false,
  168. toSkip: Char => Boolean = Function.const(false)): String = {
  169. val in = CharBuffer.wrap(toDecode)
  170. // reserve enough space for 3-byte UTF-8 characters. 4-byte characters are represented
  171. // as surrogate pairs of characters, and will get a luxurious 6 bytes of space.
  172. val out = ByteBuffer.allocate(in.remaining() * 3)
  173. while (in.hasRemaining) {
  174. val mark = in.position()
  175. val c = in.get()
  176. if (c == '%') {
  177. if (in.remaining() >= 2) {
  178. val xc = in.get()
  179. val yc = in.get()
  180. // scalastyle:off magic.number
  181. val x = Character.digit(xc, 0x10)
  182. val y = Character.digit(yc, 0x10)
  183. // scalastyle:on magic.number
  184. if (x != -1 && y != -1) {
  185. val oo = (x << 4) + y
  186. if (!toSkip(oo.toChar)) {
  187. out.put(oo.toByte)
  188. } else {
  189. out.put('%'.toByte)
  190. out.put(xc.toByte)
  191. out.put(yc.toByte)
  192. }
  193. } else {
  194. out.put('%'.toByte)
  195. in.position(mark + 1)
  196. }
  197. } else {
  198. // This is an invalid encoding. Fail gracefully by treating the '%' as
  199. // a literal.
  200. out.put(c.toByte)
  201. while (in.hasRemaining) out.put(in.get().toByte)
  202. }
  203. } else if (c == '+' && plusIsSpace) {
  204. out.put(' '.toByte)
  205. } else {
  206. // normally `out.put(c.toByte)` would be enough since the url is %-encoded,
  207. // however there are cases where a string can be partially decoded
  208. // so we have to make sure the non us-ascii chars get preserved properly.
  209. if (this.toSkip(c)) {
  210. out.put(c.toByte)
  211. } else {
  212. out.put(charset.encode(String.valueOf(c)))
  213. }
  214. }
  215. }
  216. out.flip()
  217. charset.decode(out).toString
  218. }
  219. }