aboutsummaryrefslogtreecommitdiff
path: root/core
diff options
context:
space:
mode:
authoradamw <adam@warski.org>2017-07-09 16:39:29 +0200
committeradamw <adam@warski.org>2017-07-09 16:39:29 +0200
commit18ed991eefd4ff541e722808f80a48d47df58a57 (patch)
treeb4a1a72825f6d6d7a3adbb2811cef98bdd514ba5 /core
parent820095216e671888cfa607b329d749ba099a7bc1 (diff)
downloadsttp-18ed991eefd4ff541e722808f80a48d47df58a57.tar.gz
sttp-18ed991eefd4ff541e722808f80a48d47df58a57.tar.bz2
sttp-18ed991eefd4ff541e722808f80a48d47df58a57.zip
Initial version of the URI interpolator
Diffstat (limited to 'core')
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala284
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/package.scala2
2 files changed, 267 insertions, 19 deletions
diff --git a/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala b/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala
index 825985a..b967870 100644
--- a/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala
+++ b/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala
@@ -1,33 +1,281 @@
package com.softwaremill.sttp
-import java.net.URI
+import java.net.{URI, URLEncoder}
-// based on https://gist.github.com/teigen/5865923
object UriInterpolator {
- private val unreserved = {
- val alphanum = (('a' to 'z') ++ ('A' to 'Z') ++ ('0' to '9')).toSet
- val mark = Set('-', '_', '.', '!', '~', '*', '\'', '(', ')')
- alphanum ++ mark
- }
-
- def interpolate(sc: StringContext, args: String*): URI = {
+ def interpolate(sc: StringContext, args: Any*): URI = {
val strings = sc.parts.iterator
val expressions = args.iterator
- val sb = new StringBuffer(strings.next())
+ var ub = UriBuilderStart.parseS(strings.next(), identity)
while (strings.hasNext) {
- for (c <- expressions.next()) {
- if (unreserved(c))
- sb.append(c)
+ ub = ub.parseE(expressions.next())
+ ub = ub.parseS(strings.next(), identity)
+ }
+
+ new URI(ub.build)
+ }
+
+ sealed trait UriBuilder {
+
+ /**
+ * @param doEncode Only values from expressions should be URI-encoded. Strings should be preserved as-is.
+ */
+ def parseS(s: String, doEncode: String => String): UriBuilder
+ def parseE(e: Any): UriBuilder = e match {
+ case s: String => parseS(s, encode(_))
+ case None => this
+ case null => this
+ case Some(x) => parseE(x)
+ case x => parseS(x.toString, encode(_))
+ }
+ def build: String
+ }
+
+ val UriBuilderStart = Scheme("")
+
+ case class Scheme(v: String) extends UriBuilder {
+
+ override def parseS(s: String, doEncode: String => String): UriBuilder = {
+ val splitAtSchemeEnd = s.split("://", 2)
+ splitAtSchemeEnd match {
+ case Array(schemeFragment, rest) =>
+ Authority(append(schemeFragment, doEncode))
+ .parseS(rest, doEncode)
+
+ case Array(x) =>
+ if (!x.matches("[a-zA-Z0-9+\\.\\-]*")) {
+ // anything else than the allowed characters in scheme suggest that there is no scheme
+ // assuming whatever we parsed so far is part of authority, and parsing the rest
+ // see https://stackoverflow.com/questions/3641722/valid-characters-for-uri-schemes
+ Authority(Scheme(""), v).parseS(x, doEncode)
+ } else append(x, doEncode)
+ }
+ }
+
+ private def append(x: String, doEncode: String => String): Scheme =
+ Scheme(v + doEncode(x))
+
+ override def build: String = if (v.isEmpty) "" else v + "://"
+ }
+
+ case class Authority(s: Scheme, v: String = "") extends UriBuilder {
+
+ override def parseS(s: String, doEncode: (String) => String): UriBuilder = {
+ // authority is terminated by /, ?, # or end of string (there might be other /, ?, # later on e.g. in the query)
+ // see https://tools.ietf.org/html/rfc3986#section-3.2
+ s.split("[/\\?#]", 2) match {
+ case Array(authorityFragment, rest) =>
+ val splitOn = charAfterPrefix(authorityFragment, s)
+ append(authorityFragment, doEncode).next(splitOn, rest, doEncode)
+ case Array(x) => append(x, doEncode)
+ }
+ }
+
+ private def next(splitOn: Char,
+ rest: String,
+ doEncode: (String) => String): UriBuilder =
+ splitOn match {
+ case '/' => Path(this).parseS(rest, doEncode)
+ case '?' => Query(Path(this)).parseS(rest, doEncode)
+ case '#' => Fragment(Query(Path(this))).parseS(rest, doEncode)
+ }
+
+ override def parseE(e: Any): UriBuilder = e match {
+ case s: Seq[_] =>
+ val newAuthority = s.map(_.toString).map(encode(_)).mkString(".")
+ copy(v = v + newAuthority)
+ case x => super.parseE(x)
+ }
+
+ override def build: String = {
+ // remove dangling "." which might occur due to optional authority fragments
+ val v2 = if (v.startsWith(".")) v.substring(1) else v
+ val v3 = if (v.endsWith(".")) v2.substring(0, v2.length - 1) else v2
+
+ s.build + v3
+ }
+
+ private def append(x: String, doEncode: String => String): Authority =
+ copy(v = v + doEncode(x))
+ }
+
+ case class Path(a: Authority, vs: Vector[String] = Vector.empty)
+ extends UriBuilder {
+
+ override def parseS(s: String, doEncode: (String) => String): UriBuilder = {
+ // path is terminated by ?, # or end of string (there might be other ?, # later on e.g. in the query)
+ // see https://tools.ietf.org/html/rfc3986#section-3.3
+ s.split("[\\?#]", 2) match {
+ case Array(pathFragments, rest) =>
+ val splitOn = charAfterPrefix(pathFragments, s)
+ append(pathFragments, doEncode).next(splitOn, rest, doEncode)
+ case Array(x) => append(x, doEncode)
+ }
+ }
+
+ private def next(splitOn: Char,
+ rest: String,
+ doEncode: (String) => String): UriBuilder =
+ splitOn match {
+ case '?' => Query(this).parseS(rest, doEncode)
+ case '#' => Fragment(Query(this)).parseS(rest, doEncode)
+ }
+
+ override def parseE(e: Any): UriBuilder = e match {
+ case s: Seq[_] =>
+ val newFragments = s.map(_.toString).map(encode(_))
+ copy(vs = vs ++ newFragments)
+
+ case x => super.parseE(x)
+ }
+
+ override def build: String = {
+ val v = if (vs.isEmpty) "" else "/" + vs.mkString("/")
+ a.build + v
+ }
+
+ private def append(fragments: String, doEncode: String => String): Path = {
+ copy(vs = vs ++ fragments.split("/").map(doEncode))
+ }
+ }
+
+ type QueryFragment = (Option[String], Option[String])
+
+ case class Query(p: Path, fs: Vector[QueryFragment] = Vector.empty)
+ extends UriBuilder {
+
+ override def parseS(s: String, doEncode: (String) => String): UriBuilder = {
+ s.split("#", 2) match {
+ case Array(queryFragment, rest) =>
+ Fragment(appendS(queryFragment)).parseS(rest, doEncode)
+
+ case Array(x) => appendS(x)
+ }
+ }
+
+ override def parseE(e: Any): UriBuilder = e match {
+ case m: Map[_, _] =>
+ val newFragments = m.map {
+ case (k, v) =>
+ (Some(encode(k, query = true)), Some(encode(v, query = true)))
+ }
+ copy(fs = fs ++ newFragments)
+
+ case s: Seq[_] =>
+ val newFragments = s.map {
+ case (k, v) =>
+ (Some(encode(k, query = true)), Some(encode(v, query = true)))
+ case x => (Some(encode(x, query = true)), None)
+ }
+ copy(fs = fs ++ newFragments)
+
+ case s: String => appendE(Some(encode(s, query = true)))
+ case None => appendE(None)
+ case null => appendE(None)
+ case Some(x) => parseE(x)
+ case x => appendE(Some(encode(x.toString, query = true)))
+ }
+
+ override def build: String = {
+ val fragments = fs.flatMap {
+ case (None, None) => None
+ case (Some(k), None) => Some(k)
+ case (k, v) => Some(k.getOrElse("") + "=" + v.getOrElse(""))
+ }
+
+ val query = if (fragments.isEmpty) "" else "?" + fragments.mkString("&")
+
+ p.build + query
+ }
+
+ private def appendS(queryFragment: String): Query = {
+
+ val newVs = queryFragment.split("&").map { nv =>
+ /*
+ - empty -> (None, None)
+ - k=v -> (Some(k), Some(v))
+ - k= -> (Some(k), Some(""))
+ - k -> (Some(k), None)
+ - = -> (None, Some(""))
+ - =v -> (None, Some(v))
+ */
+ if (nv.isEmpty) (None, None)
+ else if (nv.startsWith("=")) (None, Some(nv.substring(1)))
else
- for (b <- c.toString.getBytes("UTF-8")) {
- sb.append("%")
- sb.append("%02X".format(b))
+ nv.split("=", 2) match {
+ case Array(n, v) => (Some(n), Some(v))
+ case Array(n) => (Some(n), None)
}
}
- sb.append(strings.next())
+
+ // it's possible that the current-last and new-first query fragments
+ // are indeed two parts of a single fragment. Attempting to merge,
+ // if possible
+ val (currentInit, currentLastV) = fs.splitAt(fs.length - 1)
+ val (newHeadV, newTail) = newVs.splitAt(1)
+
+ val mergedOpt = for {
+ currentLast <- currentLastV.headOption
+ newHead <- newHeadV.headOption
+ } yield {
+ currentInit ++ merge(currentLast, newHead) ++ newTail
+ }
+
+ val combinedVs = mergedOpt match {
+ case None => fs ++ newVs // either current or new fragments are empty
+ case Some(merged) => merged
+ }
+
+ copy(fs = combinedVs)
+ }
+
+ private def merge(last: QueryFragment,
+ first: QueryFragment): Vector[QueryFragment] = {
+ /*
+ Only some combinations of fragments are possible. Part of them is
+ already handled in `appendE` (specifically, any expressions of
+ the form k=$v). Here we have to handle: $k=$v and $k=v.
+ */
+ (last, first) match {
+ case ((Some(k), None), (None, Some(""))) =>
+ Vector((Some(k), Some(""))) // k + = => k=
+ case ((Some(k), None), (None, Some(v))) =>
+ Vector((Some(k), Some(v))) // k + =v => k=v
+ case (x, y) => Vector(x, y)
+ }
}
- new URI(sb.toString)
+
+ private def appendE(vo: Option[String]): Query = fs.lastOption match {
+ case Some((Some(k), Some(""))) =>
+ // k= + Some(v) -> k=v; k= + None -> remove parameter
+ vo match {
+ case None => copy(fs = fs.init)
+ case Some(v) => copy(fs = fs.init :+ (Some(k), Some(v)))
+ }
+ case _ => copy(fs = fs :+ (vo, None))
+ }
+ }
+
+ case class Fragment(q: Query, v: String = "") extends UriBuilder {
+ override def parseS(s: String, doEncode: (String) => String): UriBuilder = {
+ copy(v = v + doEncode(s))
+ }
+
+ override def build: String = q.build + (if (v.isEmpty) "" else s"#$v")
+ }
+
+ private def encode(s: Any, query: Boolean = false): String = {
+ val encoded = URLEncoder.encode(String.valueOf(s), "UTF-8")
+ // space is encoded as a +, which is only valid in the query;
+ // in other contexts, it must be percent-encoded
+ // see https://stackoverflow.com/questions/2678551/when-to-encode-space-to-plus-or-20
+ if (!query) encoded.replaceAll("\\+", "%20") else encoded
+ }
+
+ private def charAfterPrefix(prefix: String, whole: String): Char = {
+ val pl = prefix.length
+ whole.substring(pl, pl + 1).charAt(0)
}
}
diff --git a/core/src/main/scala/com/softwaremill/sttp/package.scala b/core/src/main/scala/com/softwaremill/sttp/package.scala
index d757bc0..79d9a17 100644
--- a/core/src/main/scala/com/softwaremill/sttp/package.scala
+++ b/core/src/main/scala/com/softwaremill/sttp/package.scala
@@ -244,6 +244,6 @@ package object sttp {
s"$ct; charset=$enc"
implicit class UriContext(val sc: StringContext) extends AnyVal {
- def uri(args: String*): URI = UriInterpolator.interpolate(sc, args: _*)
+ def uri(args: Any*): URI = UriInterpolator.interpolate(sc, args: _*)
}
}