aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOmar Alejandro Mainegra Sarduy <omainegra@gmail.com>2017-07-28 16:37:05 -0400
committerOmar Alejandro Mainegra Sarduy <omainegra@gmail.com>2017-07-28 16:37:05 -0400
commit1f9d0e2c111dc98ec23154b9b54c3693fedac834 (patch)
tree831298610fc51ceb4e99e3de67aa54fd4600661b
parent75916ed78304a8b0c226c5ecd26634b541334f67 (diff)
parent4d23d6fd21317e6bf28cd23b0e149d570b55f5e8 (diff)
downloadsttp-1f9d0e2c111dc98ec23154b9b54c3693fedac834.tar.gz
sttp-1f9d0e2c111dc98ec23154b9b54c3693fedac834.tar.bz2
sttp-1f9d0e2c111dc98ec23154b9b54c3693fedac834.zip
Merge branch 'master' into okhttp3
-rw-r--r--.travis.yml2
-rw-r--r--README.md19
-rw-r--r--akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/AkkaHttpSttpHandler.scala3
-rw-r--r--async-http-client-handler/future/src/main/scala/com/softwaremill/sttp/asynchttpclient/future/FutureAsyncHttpClientHandler.scala6
-rw-r--r--async-http-client-handler/src/main/scala/com/softwaremill/sttp/asynchttpclient/AsyncHttpClientHandler.scala7
-rw-r--r--build.sbt4
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/HttpURLConnectionSttpHandler.scala5
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/RequestT.scala20
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/Uri.scala184
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala166
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/package.scala3
-rw-r--r--core/src/test/scala/com/softwaremill/sttp/UriInterpolatorTests.scala13
-rw-r--r--core/src/test/scala/com/softwaremill/sttp/UriTests.scala82
-rw-r--r--project/plugins.sbt2
-rw-r--r--tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala12
-rw-r--r--tests/src/test/scala/com/softwaremill/sttp/IllTypedTests.scala4
-rw-r--r--tests/src/test/scala/com/softwaremill/sttp/StreamingTests.scala13
17 files changed, 426 insertions, 119 deletions
diff --git a/.travis.yml b/.travis.yml
index 54ec7ae..91737bf 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,7 +3,7 @@ language: scala
jdk:
- oraclejdk8
scala:
-- 2.12.2
+- 2.12.3
- 2.11.8
script:
- sbt ++$TRAVIS_SCALA_VERSION test
diff --git a/README.md b/README.md
index 8da9a5a..8d6dc24 100644
--- a/README.md
+++ b/README.md
@@ -131,12 +131,11 @@ instances, which can then be used to specify request endpoints, for example:
```scala
import com.softwaremill.sttp._
-import java.net.URI
val user = "Mary Smith"
val filter = "programming languages"
-val endpoint: URI = uri"http://example.com/$user/skills?filter=$filter"
+val endpoint: Uri = uri"http://example.com/$user/skills?filter=$filter"
```
Any values embedded in the URI will be URL-encoded, taking into account the
@@ -176,6 +175,16 @@ uri"$scheme://$subdomains.example.com?x=$vx&$params#$jumpTo"
## Supported backends
+### Summary
+
+| Class | Result wrapper | Supported stream type |
+| --- | --- | --- |
+| `HttpURLConnectionSttpHandler` | None (`Id`) | - |
+| `AkkaHttpSttpHandler` | `scala.concurrent.Future` | `akka.stream.scaladsl.Source[ByteString, Any]` |
+| `FutureAsyncHttpClientHandler` | `scala.concurrent.Future` | - |
+| `ScalazAsyncHttpClientHandler` | `scalaz.concurrent.Task` | - |
+| `MonixAsyncHttpClientHandler` | `monix.eval.Task` | `monix.reactive.Observable[ByteBuffer]` |
+
### `HttpURLConnectionSttpHandler`
The default **synchronous** handler. Sending a request returns a response wrapped
@@ -369,7 +378,7 @@ There are two type aliases for the request template that are used:
## TODO
* multi-part uploads
-* scalaz/monix/fs2 streaming
+* scalaz/fs2 streaming
* proxy support
* connection options, SSL
* *your API improvement idea here*
@@ -382,10 +391,12 @@ There are two type aliases for the request template that are used:
* [play ws](https://github.com/playframework/play-ws)
* [fs2-http](https://github.com/Spinoco/fs2-http)
* [http4s](http://http4s.org/v0.17/client/)
+* [Gigahorse](http://eed3si9n.com/gigahorse/)
+* [RösHTTP](https://github.com/hmil/RosHTTP)
## Contributing
-Take a look at our [project board](https://github.com/softwaremill/sttp/projects/1)
+Take a look at the [open issues](https://github.com/softwaremill/sttp/issues)
and pick a task you'd like to work on!
## Credits
diff --git a/akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/AkkaHttpSttpHandler.scala b/akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/AkkaHttpSttpHandler.scala
index e4b2bf6..ee9a88f 100644
--- a/akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/AkkaHttpSttpHandler.scala
+++ b/akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/AkkaHttpSttpHandler.scala
@@ -192,8 +192,7 @@ object AkkaHttpSttpHandler {
* e.g. mapping responses. Defaults to the global execution
* context.
*/
- def apply()(
- implicit ec: ExecutionContext = ExecutionContext.Implicits.global)
+ def apply()(implicit ec: ExecutionContext = ExecutionContext.Implicits.global)
: SttpHandler[Future, Source[ByteString, Any]] =
new AkkaHttpSttpHandler(ActorSystem("sttp"), ec, true)
diff --git a/async-http-client-handler/future/src/main/scala/com/softwaremill/sttp/asynchttpclient/future/FutureAsyncHttpClientHandler.scala b/async-http-client-handler/future/src/main/scala/com/softwaremill/sttp/asynchttpclient/future/FutureAsyncHttpClientHandler.scala
index b0a36f9..5d71511 100644
--- a/async-http-client-handler/future/src/main/scala/com/softwaremill/sttp/asynchttpclient/future/FutureAsyncHttpClientHandler.scala
+++ b/async-http-client-handler/future/src/main/scala/com/softwaremill/sttp/asynchttpclient/future/FutureAsyncHttpClientHandler.scala
@@ -38,8 +38,7 @@ object FutureAsyncHttpClientHandler {
* e.g. mapping responses. Defaults to the global execution
* context.
*/
- def apply()(
- implicit ec: ExecutionContext = ExecutionContext.Implicits.global)
+ def apply()(implicit ec: ExecutionContext = ExecutionContext.Implicits.global)
: SttpHandler[Future, Nothing] =
new FutureAsyncHttpClientHandler(new DefaultAsyncHttpClient(),
closeClient = true)
@@ -72,8 +71,7 @@ private[future] class FutureMonad(implicit ec: ExecutionContext)
override def map[T, T2](fa: Future[T], f: (T) => T2): Future[T2] = fa.map(f)
- override def flatMap[T, T2](fa: Future[T],
- f: (T) => Future[T2]): Future[T2] =
+ override def flatMap[T, T2](fa: Future[T], f: (T) => Future[T2]): Future[T2] =
fa.flatMap(f)
override def async[T](
diff --git a/async-http-client-handler/src/main/scala/com/softwaremill/sttp/asynchttpclient/AsyncHttpClientHandler.scala b/async-http-client-handler/src/main/scala/com/softwaremill/sttp/asynchttpclient/AsyncHttpClientHandler.scala
index d634446..cd10735 100644
--- a/async-http-client-handler/src/main/scala/com/softwaremill/sttp/asynchttpclient/AsyncHttpClientHandler.scala
+++ b/async-http-client-handler/src/main/scala/com/softwaremill/sttp/asynchttpclient/AsyncHttpClientHandler.scala
@@ -28,10 +28,9 @@ import org.reactivestreams.{Publisher, Subscriber, Subscription}
import scala.collection.JavaConverters._
import scala.language.higherKinds
-abstract class AsyncHttpClientHandler[R[_], S](
- asyncHttpClient: AsyncHttpClient,
- rm: MonadAsyncError[R],
- closeClient: Boolean)
+abstract class AsyncHttpClientHandler[R[_], S](asyncHttpClient: AsyncHttpClient,
+ rm: MonadAsyncError[R],
+ closeClient: Boolean)
extends SttpHandler[R, S] {
override def send[T](r: Request[T, S]): R[Response[T]] = {
diff --git a/build.sbt b/build.sbt
index 18e7ce2..6aec268 100644
--- a/build.sbt
+++ b/build.sbt
@@ -1,10 +1,10 @@
val commonSettings = Seq(
organization := "com.softwaremill.sttp",
- scalaVersion := "2.12.2",
+ scalaVersion := "2.12.3",
crossScalaVersions := Seq(scalaVersion.value, "2.11.8"),
scalacOptions ++= Seq("-unchecked", "-deprecation", "-feature", "-Xlint"),
scalafmtOnCompile := true,
- scalafmtVersion := "1.0.0",
+ scalafmtVersion := "1.1.0",
// publishing
publishTo := Some(
if (isSnapshot.value)
diff --git a/core/src/main/scala/com/softwaremill/sttp/HttpURLConnectionSttpHandler.scala b/core/src/main/scala/com/softwaremill/sttp/HttpURLConnectionSttpHandler.scala
index 1ef7fc2..fc9b420 100644
--- a/core/src/main/scala/com/softwaremill/sttp/HttpURLConnectionSttpHandler.scala
+++ b/core/src/main/scala/com/softwaremill/sttp/HttpURLConnectionSttpHandler.scala
@@ -1,7 +1,7 @@
package com.softwaremill.sttp
import java.io._
-import java.net.HttpURLConnection
+import java.net.{HttpURLConnection, URL}
import java.nio.channels.Channels
import java.nio.charset.CharacterCodingException
import java.nio.file.Files
@@ -15,7 +15,8 @@ import scala.collection.JavaConverters._
object HttpURLConnectionSttpHandler extends SttpHandler[Id, Nothing] {
override def send[T](r: Request[T, Nothing]): Response[T] = {
- val c = r.uri.toURL.openConnection().asInstanceOf[HttpURLConnection]
+ val c =
+ new URL(r.uri.toString).openConnection().asInstanceOf[HttpURLConnection]
c.setRequestMethod(r.method.m)
r.headers.foreach { case (k, v) => c.setRequestProperty(k, v) }
c.setDoInput(true)
diff --git a/core/src/main/scala/com/softwaremill/sttp/RequestT.scala b/core/src/main/scala/com/softwaremill/sttp/RequestT.scala
index 1aa8770..ff80dd5 100644
--- a/core/src/main/scala/com/softwaremill/sttp/RequestT.scala
+++ b/core/src/main/scala/com/softwaremill/sttp/RequestT.scala
@@ -1,7 +1,7 @@
package com.softwaremill.sttp
import java.io.{File, InputStream}
-import java.net.{URI, URLEncoder}
+import java.net.URLEncoder
import java.nio.ByteBuffer
import java.nio.file.Path
import java.util.Base64
@@ -25,24 +25,24 @@ import scala.language.higherKinds
*/
case class RequestT[U[_], T, +S](
method: U[Method],
- uri: U[URI],
+ uri: U[Uri],
body: RequestBody[S],
headers: Seq[(String, String)],
responseAs: ResponseAs[T, S]
) {
- def get(uri: URI): Request[T, S] =
+ def get(uri: Uri): Request[T, S] =
this.copy[Id, T, S](uri = uri, method = Method.GET)
- def head(uri: URI): Request[T, S] =
+ def head(uri: Uri): Request[T, S] =
this.copy[Id, T, S](uri = uri, method = Method.HEAD)
- def post(uri: URI): Request[T, S] =
+ def post(uri: Uri): Request[T, S] =
this.copy[Id, T, S](uri = uri, method = Method.POST)
- def put(uri: URI): Request[T, S] =
+ def put(uri: Uri): Request[T, S] =
this.copy[Id, T, S](uri = uri, method = Method.PUT)
- def delete(uri: URI): Request[T, S] =
+ def delete(uri: Uri): Request[T, S] =
this.copy[Id, T, S](uri = uri, method = Method.DELETE)
- def options(uri: URI): Request[T, S] =
+ def options(uri: Uri): Request[T, S] =
this.copy[Id, T, S](uri = uri, method = Method.OPTIONS)
- def patch(uri: URI): Request[T, S] =
+ def patch(uri: Uri): Request[T, S] =
this.copy[Id, T, S](uri = uri, method = Method.PATCH)
def contentType(ct: String): RequestT[U, T, S] =
@@ -63,7 +63,7 @@ case class RequestT[U[_], T, +S](
this.copy(headers = current :+ (k -> v))
}
def headers(hs: Map[String, String]): RequestT[U, T, S] =
- this.copy(headers = headers ++ hs.toSeq)
+ headers(hs.toSeq: _*)
def headers(hs: (String, String)*): RequestT[U, T, S] =
this.copy(headers = headers ++ hs)
def cookie(nv: (String, String)): RequestT[U, T, S] = cookies(nv)
diff --git a/core/src/main/scala/com/softwaremill/sttp/Uri.scala b/core/src/main/scala/com/softwaremill/sttp/Uri.scala
new file mode 100644
index 0000000..1ee4337
--- /dev/null
+++ b/core/src/main/scala/com/softwaremill/sttp/Uri.scala
@@ -0,0 +1,184 @@
+package com.softwaremill.sttp
+
+import java.net.URLEncoder
+
+import com.softwaremill.sttp.QueryFragment.{KeyValue, Plain}
+
+import scala.annotation.tailrec
+import scala.collection.immutable.Seq
+
+/**
+ * @param queryFragments Either key-value pairs, or plain query fragments.
+ * Key value pairs will be serialized as `k=v`, and blocks of key-value
+ * pairs will be combined using `&`. Note that no `&` or other separators
+ * are added around plain query fragments - if required, they need to be
+ * added manually as part of the plain query fragment.
+ */
+case class Uri(scheme: String,
+ host: String,
+ port: Option[Int],
+ path: Seq[String],
+ queryFragments: Seq[QueryFragment],
+ fragment: Option[String]) {
+
+ def this(host: String) =
+ this("http", host, None, Vector.empty, Vector.empty, None)
+ def this(host: String, port: Int) =
+ this("http", host, Some(port), Vector.empty, Vector.empty, None)
+ def this(host: String, port: Int, path: Seq[String]) =
+ this("http", host, Some(port), path, Vector.empty, None)
+ def this(scheme: String, host: String) =
+ this(scheme, host, None, Vector.empty, Vector.empty, None)
+ def this(scheme: String, host: String, port: Int) =
+ this(scheme, host, Some(port), Vector.empty, Vector.empty, None)
+ def this(scheme: String, host: String, port: Int, path: Seq[String]) =
+ this(scheme, host, Some(port), path, Vector.empty, None)
+
+ def scheme(s: String): Uri = this.copy(scheme = s)
+
+ def host(h: String): Uri = this.copy(host = h)
+
+ def port(p: Int): Uri = this.copy(port = Some(p))
+
+ def port(p: Option[Int]): Uri = this.copy(port = p)
+
+ def path(p: String): Uri = {
+ // removing the leading slash, as it is added during serialization anyway
+ val pWithoutLeadingSlash = if (p.startsWith("/")) p.substring(1) else p
+ val ps = pWithoutLeadingSlash.split("/", -1).toList
+ this.copy(path = ps)
+ }
+
+ def path(p1: String, p2: String, ps: String*): Uri =
+ this.copy(path = p1 :: p2 :: ps.toList)
+
+ def path(ps: scala.collection.Seq[String]): Uri = this.copy(path = ps.toList)
+
+ /**
+ * Adds the given parameter to the query.
+ */
+ def param(k: String, v: String): Uri = params(k -> v)
+
+ /**
+ * Adds the given parameters to the query.
+ */
+ def params(ps: Map[String, String]): Uri = params(ps.toSeq: _*)
+
+ /**
+ * Adds the given parameters to the query.
+ */
+ def params(ps: (String, String)*): Uri = {
+ this.copy(queryFragments = queryFragments ++ ps.map {
+ case (k, v) => KeyValue(k, v)
+ })
+ }
+
+ def paramsMap: Map[String, String] = paramsSeq.toMap
+
+ def paramsSeq: Seq[(String, String)] = queryFragments.collect {
+ case KeyValue(k, v, _, _) => k -> v
+ }
+
+ def queryFragment(qf: QueryFragment): Uri =
+ this.copy(queryFragments = queryFragments :+ qf)
+
+ def fragment(f: String): Uri = this.copy(fragment = Some(f))
+
+ def fragment(f: Option[String]): Uri = this.copy(fragment = f)
+
+ override def toString: String = {
+ val schemeS = encode(scheme)
+ val hostS = encode(host)
+ val portS = port.fold("")(":" + _)
+ val pathPrefixS = if (path.isEmpty) "" else "/"
+ val pathS = path.map(encode).mkString("/")
+ val queryPrefixS = if (queryFragments.isEmpty) "" else "?"
+
+ @tailrec
+ def encodeQueryFragments(qfs: List[QueryFragment],
+ previousWasKV: Boolean,
+ sb: StringBuilder): String = qfs match {
+ case Nil => sb.toString()
+
+ case Plain(v, re) :: t =>
+ encodeQueryFragments(t,
+ previousWasKV = false,
+ sb.append(encodeQuery(v, re)))
+
+ case KeyValue(k, v, reK, reV) :: t =>
+ if (previousWasKV) sb.append("&")
+ sb.append(encodeQuery(k, reK)).append("=").append(encodeQuery(v, reV))
+ encodeQueryFragments(t, previousWasKV = true, sb)
+ }
+
+ val queryS = encodeQueryFragments(queryFragments.toList,
+ previousWasKV = false,
+ new StringBuilder())
+ val fragS = fragment.fold("")("#" + _)
+ s"$schemeS://$hostS$portS$pathPrefixS$pathS$queryPrefixS$queryS$fragS"
+ }
+
+ private def encode(s: Any): String = {
+ // 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
+ URLEncoder.encode(String.valueOf(s), "UTF-8").replaceAll("\\+", "%20")
+ }
+
+ private def encodeQuery(s: String, relaxed: Boolean): String =
+ if (relaxed) encodeQueryRelaxed(s)
+ else
+ URLEncoder.encode(String.valueOf(s), "UTF-8")
+
+ private val relaxedQueryAllowedCharacters = {
+ // https://stackoverflow.com/questions/2322764/what-characters-must-be-escaped-in-an-http-query-string
+ val alphanum = (('a' to 'z') ++ ('A' to 'Z') ++ ('0' to '9')).toSet
+ val special = Set('/', '?', ':', '@', '-', '.', '_', '~', '!', '$', '&',
+ '\'', '(', ')', '*', '+', ',', ';', '=')
+ alphanum ++ special
+ }
+
+ private def encodeQueryRelaxed(s: String): String = {
+ val sb = new StringBuilder()
+ // based on https://gist.github.com/teigen/5865923
+ for (c <- s) {
+ if (relaxedQueryAllowedCharacters(c)) sb.append(c)
+ else {
+ for (b <- c.toString.getBytes("UTF-8")) {
+ sb.append("%")
+ sb.append("%02X".format(b))
+ }
+ }
+ }
+ sb.toString
+ }
+}
+
+sealed trait QueryFragment
+object QueryFragment {
+
+ /**
+ * @param keyRelaxedEncoding See [[Plain.relaxedEncoding]]
+ * @param valueRelaxedEncoding See [[Plain.relaxedEncoding]]
+ */
+ case class KeyValue(k: String,
+ v: String,
+ keyRelaxedEncoding: Boolean = false,
+ valueRelaxedEncoding: Boolean = false)
+ extends QueryFragment
+
+ /**
+ * A query fragment which will be inserted into the query, without and
+ * preceding or following separators. Allows constructing query strings
+ * which are not (only) &-separated key-value pairs.
+ *
+ * @param relaxedEncoding Should characters, which are allowed in the query
+ * string, but normally escaped be left unchanged. These characters are:
+ * {{{
+ * /?:@-._~!$&()*+,;=
+ * }}}
+ * See: [[https://stackoverflow.com/questions/2322764/what-characters-must-be-escaped-in-an-http-query-string]]
+ */
+ case class Plain(v: String, relaxedEncoding: Boolean = false)
+ extends QueryFragment
+}
diff --git a/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala b/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala
index 3e2ebcf..26b9827 100644
--- a/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala
+++ b/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala
@@ -1,10 +1,10 @@
package com.softwaremill.sttp
-import java.net.{URI, URLEncoder}
+import scala.annotation.tailrec
object UriInterpolator {
- def interpolate(sc: StringContext, args: Any*): URI = {
+ def interpolate(sc: StringContext, args: Any*): Uri = {
val strings = sc.parts.iterator
val expressions = args.iterator
var ub = UriBuilderStart.parseS(strings.next())
@@ -14,20 +14,20 @@ object UriInterpolator {
ub = ub.parseS(strings.next())
}
- new URI(ub.build)
+ ub.build
}
sealed trait UriBuilder {
def parseS(s: String): UriBuilder
def parseE(e: Any): UriBuilder
- def build: String
+ def build: Uri
- protected def parseE_asEncodedS_skipNone(e: Any): UriBuilder = e match {
- case s: String => parseS(encode(s))
+ protected def parseE_skipNone(e: Any): UriBuilder = e match {
+ case s: String => parseS(s)
case None => this
case null => this
case Some(x) => parseE(x)
- case x => parseS(encode(x.toString))
+ case x => parseS(x.toString)
}
}
@@ -48,30 +48,16 @@ object UriInterpolator {
// 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)
+ Authority(Scheme("http"), v).parseS(x)
} else append(x)
}
}
- override def parseE(e: Any): UriBuilder = {
- def encodeIfNotInitialEndpoint(s: String) = {
- // special case: when this is the first expression, contains a complete
- // schema with :// and nothing is yet parsed not escaping the contents
- if (v.isEmpty && s.contains("://")) s else encode(s)
- }
-
- e match {
- case s: String => parseS(encodeIfNotInitialEndpoint(s))
- case None => this
- case null => this
- case Some(x) => parseE(x)
- case x => parseS(encodeIfNotInitialEndpoint(x.toString))
- }
- }
+ override def parseE(e: Any): UriBuilder = parseE_skipNone(e)
private def append(x: String): Scheme = Scheme(v + x)
- override def build: String = if (v.isEmpty) "" else v + "://"
+ override def build: Uri = Uri(v, "", None, Nil, Nil, None)
}
case class Authority(s: Scheme, v: String = "") extends UriBuilder {
@@ -100,22 +86,27 @@ object UriInterpolator {
override def parseE(e: Any): UriBuilder = e match {
case s: Seq[_] =>
- val newAuthority = s.map(_.toString).map(encode(_)).mkString(".")
+ val newAuthority = s.map(_.toString).mkString(".")
copy(v = v + newAuthority)
- case x => parseE_asEncodedS_skipNone(x)
+ case x => parseE_skipNone(x)
}
- override def build: String = {
+ override def build: Uri = {
var vv = v
// remove dangling "." which might occur due to optional authority
// fragments
while (vv.startsWith(".")) vv = vv.substring(1)
- while (vv.endsWith(".")) vv = vv.substring(0, vv.length - 1)
- s.build + vv
+ val builtS = s.build
+ vv.split(":", 2) match {
+ case Array(host, port) if port.matches("\\d+") =>
+ builtS.copy(host = host, port = Some(port.toInt))
+ case Array(x) => builtS.copy(host = x)
+ }
}
- private def append(x: String): Authority = copy(v = v + x)
+ private def append(x: String): Authority =
+ copy(v = v + x)
}
case class Path(a: Authority, fs: Vector[String] = Vector.empty)
@@ -141,20 +132,16 @@ object UriInterpolator {
override def parseE(e: Any): UriBuilder = e match {
case s: Seq[_] =>
- val newFragments = s.map(_.toString).map(encode(_)).map(Some(_))
+ val newFragments = s.map(_.toString).map(Some(_))
newFragments.foldLeft(this)(_.appendE(_))
- case s: String => appendE(Some(encode(s)))
+ case s: String => appendE(Some(s))
case None => appendE(None)
case null => appendE(None)
case Some(x) => parseE(x)
- case x => appendE(Some(encode(x.toString)))
+ case x => appendE(Some(x.toString))
}
- override def build: String = {
- // if there is a trailing /, the last path fragment will be empty
- val v = if (fs.isEmpty) "" else "/" + fs.mkString("/")
- a.build + v
- }
+ override def build: Uri = a.build.copy(path = fs)
private def appendS(fragments: String): Path = {
if (fragments.isEmpty) this
@@ -200,16 +187,16 @@ object UriInterpolator {
}
override def parseE(e: Any): UriBuilder = e match {
- case m: Map[_, _] => parseSeq(m.toSeq)
- case s: Seq[_] => parseSeq(s)
- case s: String => appendE(Some(encodeQuery(s)))
+ case m: Map[_, _] => parseSeqE(m.toSeq)
+ case s: Seq[_] => parseSeqE(s)
+ case s: String => appendE(Some(s))
case None => appendE(None)
case null => appendE(None)
case Some(x) => parseE(x)
- case x => appendE(Some(encodeQuery(x.toString)))
+ case x => appendE(Some(x.toString))
}
- private def parseSeq(s: Seq[_]): UriBuilder = {
+ private def parseSeqE(s: Seq[_]): UriBuilder = {
val flattenedS = s.flatMap {
case (_, None) => None
case (k, Some(v)) => Some((k, v))
@@ -219,32 +206,67 @@ object UriInterpolator {
}
val newFragments = flattenedS.map {
case ("", "") => Eq
- case (k, "") => K_Eq(encodeQuery(k))
- case ("", v) => Eq_V(encodeQuery(v))
- case (k, v) => K_Eq_V(encodeQuery(k), encodeQuery(v))
- case x => K(encodeQuery(x))
+ case (k, "") => K_Eq(k.toString)
+ case ("", v) => Eq_V(v.toString)
+ case (k, v) => K_Eq_V(k.toString, v.toString)
+ case x => K(x.toString)
}
copy(fs = fs ++ newFragments)
}
- override def build: String = {
- val fragments = fs.flatMap {
+ override def build: Uri = {
+ import com.softwaremill.sttp.{QueryFragment => QF}
+
+ val plainSeparator = QF.Plain("&", relaxedEncoding = true)
+ var fragments: Vector[QF] = fs.flatMap {
case Empty => None
- case K_Eq_V(k, v) => Some(s"$k=$v")
- case K_Eq(k) => Some(s"$k=")
- case K(k) => Some(s"$k")
- case Eq => Some("=")
- case Eq_V(v) => Some(s"=$v")
+ case K_Eq_V(k, v) => Some(QF.KeyValue(k, v))
+ case K_Eq(k) => Some(QF.KeyValue(k, ""))
+ case K(k) =>
+ // if we have a key-only entry, we encode it as a plain query
+ // fragment
+ Some(QF.Plain(k))
+ case Eq => Some(QF.KeyValue("", ""))
+ case Eq_V(v) => Some(QF.KeyValue("", v))
}
- val query = if (fragments.isEmpty) "" else "?" + fragments.mkString("&")
+ // when serialized, plain query fragments don't have & separators
+ // prepended/appended - hence, if we have parsed them here, they
+ // need to be added by hand. Adding an & separator between each pair
+ // of fragments where one of them is plain. For example:
+ // KV P P KV KV P KV
+ // becomes:
+ // KV S P S P S KV KV S P S KV
+ @tailrec
+ def addPlainSeparators(qfs: Vector[QF],
+ previousWasPlain: Boolean,
+ acc: Vector[QF],
+ isFirst: Boolean = false): Vector[QF] = qfs match {
+ case Vector() => acc
+ case (p: QF.Plain) +: tail if !isFirst =>
+ addPlainSeparators(tail,
+ previousWasPlain = true,
+ acc :+ plainSeparator :+ p)
+ case (p: QF.Plain) +: tail =>
+ addPlainSeparators(tail, previousWasPlain = true, acc :+ p)
+ case (kv: QF.KeyValue) +: tail if previousWasPlain =>
+ addPlainSeparators(tail,
+ previousWasPlain = false,
+ acc :+ plainSeparator :+ kv)
+ case (kv: QF.KeyValue) +: tail =>
+ addPlainSeparators(tail, previousWasPlain = false, acc :+ kv)
+ }
+
+ fragments = addPlainSeparators(fragments,
+ previousWasPlain = false,
+ Vector(),
+ isFirst = true)
- p.build + query
+ p.build.copy(queryFragments = fragments)
}
private def appendS(queryFragment: String): Query = {
-
- val newVs = queryFragment.split("&").map { nv =>
+ val newVs = queryFragment.split("&", -1).map { nv =>
if (nv.isEmpty) Empty
else if (nv == "=") Eq
else if (nv.startsWith("=")) Eq_V(nv.substring(1))
@@ -285,9 +307,10 @@ object UriInterpolator {
the form k=$v). Here we have to handle: $k=$v and $k=v.
*/
(last, first) match {
- case (K(k), Eq) => Vector(K_Eq(k)) // k + = => k=
- case (K(k), Eq_V(v)) => Vector(K_Eq_V(k, v)) // k + =v => k=v
- case (x, y) => Vector(x, y)
+ case (K(k), Eq) => Vector(K_Eq(k)) // k + = => k=
+ case (K(k), Eq_V(v)) =>
+ Vector(K_Eq_V(k, v)) // k + =v => k=v
+ case (x, y) => Vector(x, y)
}
}
@@ -300,29 +323,22 @@ object UriInterpolator {
case Some("") => this
case Some(v) => copy(fs = fs.init :+ K_Eq_V(k, v))
}
- case _ => copy(fs = fs :+ vo.fold[QueryFragment](Empty)(K))
+ case _ =>
+ copy(fs = fs :+ vo.fold[QueryFragment](Empty)(K))
}
}
}
case class Fragment(q: Query, v: String = "") extends UriBuilder {
- override def parseS(s: String): UriBuilder = copy(v = v + s)
+ override def parseS(s: String): UriBuilder =
+ copy(v = v + s)
- override def parseE(e: Any): UriBuilder = parseE_asEncodedS_skipNone(e)
+ override def parseE(e: Any): UriBuilder = parseE_skipNone(e)
- override def build: String = q.build + (if (v.isEmpty) "" else s"#$v")
+ override def build: Uri =
+ q.build.copy(fragment = if (v.isEmpty) None else Some(v))
}
- private def encode(s: Any): String = {
- // 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
- URLEncoder.encode(String.valueOf(s), "UTF-8").replaceAll("\\+", "%20")
- }
-
- private def encodeQuery(s: Any): String =
- URLEncoder.encode(String.valueOf(s), "UTF-8")
-
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 5e7b13c..c786720 100644
--- a/core/src/main/scala/com/softwaremill/sttp/package.scala
+++ b/core/src/main/scala/com/softwaremill/sttp/package.scala
@@ -1,7 +1,6 @@
package com.softwaremill
import java.io.{File, InputStream}
-import java.net.URI
import java.nio.ByteBuffer
import java.nio.file.Path
@@ -163,6 +162,6 @@ package object sttp {
// uri interpolator
implicit class UriContext(val sc: StringContext) extends AnyVal {
- def uri(args: Any*): URI = UriInterpolator.interpolate(sc, args: _*)
+ def uri(args: Any*): Uri = UriInterpolator.interpolate(sc, args: _*)
}
}
diff --git a/core/src/test/scala/com/softwaremill/sttp/UriInterpolatorTests.scala b/core/src/test/scala/com/softwaremill/sttp/UriInterpolatorTests.scala
index 85348e6..9f0f081 100644
--- a/core/src/test/scala/com/softwaremill/sttp/UriInterpolatorTests.scala
+++ b/core/src/test/scala/com/softwaremill/sttp/UriInterpolatorTests.scala
@@ -1,7 +1,5 @@
package com.softwaremill.sttp
-import java.net.URI
-
import org.scalatest.{FunSuite, Matchers}
class UriInterpolatorTests extends FunSuite with Matchers {
@@ -15,7 +13,7 @@ class UriInterpolatorTests extends FunSuite with Matchers {
val v4encoded = "f%2Fg"
val secure = true
- val testData: List[(String, List[(URI, String)])] = List(
+ val testData: List[(String, List[(Uri, String)])] = List(
"basic" -> List(
(uri"http://example.com", "http://example.com"),
(uri"http://example.com/", "http://example.com/"),
@@ -30,7 +28,7 @@ class UriInterpolatorTests extends FunSuite with Matchers {
s"https://example.com"),
(uri"${if (secure) "https" else "http"}://example.com",
s"https://example.com"),
- (uri"example.com?a=$v2", s"example.com?a=$v2queryEncoded")
+ (uri"example.com?a=$v2", s"http://example.com?a=$v2queryEncoded")
),
"authority" -> List(
(uri"http://$v1.com", s"http://$v1.com"),
@@ -74,6 +72,11 @@ class UriInterpolatorTests extends FunSuite with Matchers {
(uri"http://example.com?x=$v2", s"http://example.com?x=$v2queryEncoded"),
(uri"http://example.com?x=$v3", s"http://example.com?x=$v3encoded")
),
+ "query parameter without value" -> List(
+ (uri"http://example.com?$v1", s"http://example.com?$v1"),
+ (uri"http://example.com?$v1&$v2",
+ s"http://example.com?$v1&$v2queryEncoded")
+ ),
"optional query parameters" -> List(
(uri"http://example.com?a=$None", s"http://example.com"),
(uri"http://example.com?a=b&c=$None", s"http://example.com?a=b"),
@@ -115,7 +118,7 @@ class UriInterpolatorTests extends FunSuite with Matchers {
((interpolated, expected), i) <- testCases.zipWithIndex
} {
test(s"[$groupName] interpolate to $expected (${i + 1})") {
- interpolated should be(new URI(expected))
+ interpolated.toString should be(expected)
}
}
}
diff --git a/core/src/test/scala/com/softwaremill/sttp/UriTests.scala b/core/src/test/scala/com/softwaremill/sttp/UriTests.scala
new file mode 100644
index 0000000..933afff
--- /dev/null
+++ b/core/src/test/scala/com/softwaremill/sttp/UriTests.scala
@@ -0,0 +1,82 @@
+package com.softwaremill.sttp
+
+import org.scalatest.{FunSuite, Matchers}
+
+class UriTests extends FunSuite with Matchers {
+
+ val QF = QueryFragment
+
+ val wholeUriTestData = List(
+ Uri("http", "example.com", None, Nil, Nil, None) -> "http://example.com",
+ Uri("https",
+ "sub.example.com",
+ Some(8080),
+ List("a", "b", "xyz"),
+ List(QF.KeyValue("p1", "v1"), QF.KeyValue("p2", "v2")),
+ Some("f")) ->
+ "https://sub.example.com:8080/a/b/xyz?p1=v1&p2=v2#f",
+ Uri("http",
+ "example.com",
+ None,
+ List(""),
+ List(QF.KeyValue("p", "v"), QF.KeyValue("p", "v")),
+ None) -> "http://example.com/?p=v&p=v",
+ Uri("http",
+ "exa mple.com",
+ None,
+ List("a b", "z", "ą:ę"),
+ List(QF.KeyValue("p:1", "v&v"), QF.KeyValue("p2", "v v")),
+ None) ->
+ "http://exa%20mple.com/a%20b/z/%C4%85%3A%C4%99?p%3A1=v%26v&p2=v+v"
+ )
+
+ for {
+ (uri, expected) <- wholeUriTestData
+ } {
+ test(s"$uri should serialize to $expected") {
+ uri.toString should be(expected)
+ }
+ }
+
+ val testUri = Uri("http", "example.com", None, Nil, Nil, None)
+
+ val pathTestData = List(
+ "a/b/c" -> List("a", "b", "c"),
+ "/a/b/c" -> List("a", "b", "c"),
+ "/" -> List(""),
+ "" -> List("")
+ )
+
+ for {
+ (path, expected) <- pathTestData
+ } {
+ test(s"$path should parse as $expected") {
+ testUri.path(path).path.toList should be(expected)
+ }
+ }
+
+ val queryFragmentsTestData = List(
+ List(QF.KeyValue("k1", "v1"),
+ QF.KeyValue("k2", "v2"),
+ QF.KeyValue("k3", "v3"),
+ QF.KeyValue("k4", "v4")) -> "k1=v1&k2=v2&k3=v3&k4=v4",
+ List(QF.KeyValue("k1", "v1"),
+ QF.KeyValue("k2", "v2"),
+ QF.Plain("-abc-"),
+ QF.KeyValue("k3", "v3"),
+ QF.KeyValue("k4", "v4")) -> "k1=v1&k2=v2-abc-k3=v3&k4=v4",
+ List(QF.KeyValue("k1", "v1"), QF.Plain("&abc&"), QF.KeyValue("k2", "v2")) -> "k1=v1%26abc%26k2=v2",
+ List(QF.KeyValue("k1", "v1"), QF.Plain("&abc&", relaxedEncoding = true)) -> "k1=v1&abc&",
+ List(QF.KeyValue("k1?", "v1?", keyRelaxedEncoding = true)) -> "k1?=v1%3F",
+ List(QF.KeyValue("k1?", "v1?", valueRelaxedEncoding = true)) -> "k1%3F=v1?",
+ List(QF.Plain("ą/ę&+;?", relaxedEncoding = true)) -> "%C4%85/%C4%99&+;?"
+ )
+
+ for {
+ (fragments, expected) <- queryFragmentsTestData
+ } {
+ test(s"$fragments should serialize to$expected") {
+ testUri.copy(queryFragments = fragments).toString should endWith(expected)
+ }
+ }
+}
diff --git a/project/plugins.sbt b/project/plugins.sbt
index d734f0e..a6c8f63 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -1,4 +1,4 @@
-addSbtPlugin("com.lucidchart" % "sbt-scalafmt" % "1.8")
+addSbtPlugin("com.lucidchart" % "sbt-scalafmt" % "1.9")
addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "1.1")
diff --git a/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala b/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala
index 45f6430..5f17a7a 100644
--- a/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala
+++ b/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala
@@ -104,8 +104,7 @@ class BasicTests
}
} ~ path("secure_basic") {
authenticateBasic("test realm", {
- case c @ Credentials.Provided(un)
- if un == "adam" && c.verify("1234") =>
+ case c @ Credentials.Provided(un) if un == "adam" && c.verify("1234") =>
Some(un)
case _ => None
}) { userName =>
@@ -119,6 +118,8 @@ class BasicTests
override def port = 51823
+ var closeHandlers: List[() => Unit] = Nil
+
runTests("HttpURLConnection")(HttpURLConnectionSttpHandler,
ForceWrappedValue.id)
runTests("Akka HTTP")(AkkaHttpSttpHandler.usingActorSystem(actorSystem),
@@ -138,6 +139,8 @@ class BasicTests
implicit handler: SttpHandler[R, Nothing],
forceResponse: ForceWrappedValue[R]): Unit = {
+ closeHandlers = handler.close _ :: closeHandlers
+
val postEcho = sttp.post(uri"$endpoint/echo")
val testBody = "this is the body"
val testBodyBytes = testBody.getBytes("UTF-8")
@@ -396,4 +399,9 @@ class BasicTests
}
}
}
+
+ override protected def afterAll(): Unit = {
+ closeHandlers.foreach(_())
+ super.afterAll()
+ }
}
diff --git a/tests/src/test/scala/com/softwaremill/sttp/IllTypedTests.scala b/tests/src/test/scala/com/softwaremill/sttp/IllTypedTests.scala
index 057ba9e..6137ed2 100644
--- a/tests/src/test/scala/com/softwaremill/sttp/IllTypedTests.scala
+++ b/tests/src/test/scala/com/softwaremill/sttp/IllTypedTests.scala
@@ -12,9 +12,8 @@ class IllTypedTests extends FlatSpec with Matchers {
import com.softwaremill.sttp._
import akka.stream.scaladsl.Source
import akka.util.ByteString
- import java.net.URI
implicit val sttpHandler = HttpURLConnectionSttpHandler
- sttp.get(new URI("http://example.com")).response(asStream[Source[ByteString, Any]]).send()
+ sttp.get(uri"http://example.com").response(asStream[Source[ByteString, Any]]).send()
""")
}
@@ -26,7 +25,6 @@ class IllTypedTests extends FlatSpec with Matchers {
val thrown = intercept[ToolBoxError] {
EvalScala("""
import com.softwaremill.sttp._
- import java.net.URI
implicit val sttpHandler = HttpURLConnectionSttpHandler
sttp.send()
""")
diff --git a/tests/src/test/scala/com/softwaremill/sttp/StreamingTests.scala b/tests/src/test/scala/com/softwaremill/sttp/StreamingTests.scala
index 7bc842c..5f04126 100644
--- a/tests/src/test/scala/com/softwaremill/sttp/StreamingTests.scala
+++ b/tests/src/test/scala/com/softwaremill/sttp/StreamingTests.scala
@@ -35,13 +35,16 @@ class StreamingTests
override def port = 51824
+ val akkaHandler = AkkaHttpSttpHandler.usingActorSystem(actorSystem)
+ val monixHandler = MonixAsyncHttpClientHandler()
+
akkaStreamingTests()
monixStreamingTests()
val body = "streaming test"
def akkaStreamingTests(): Unit = {
- implicit val handler = AkkaHttpSttpHandler.usingActorSystem(actorSystem)
+ implicit val handler = akkaHandler
"Akka HTTP" should "stream request body" in {
val response = sttp
@@ -68,7 +71,7 @@ class StreamingTests
}
def monixStreamingTests(): Unit = {
- implicit val handler = MonixAsyncHttpClientHandler()
+ implicit val handler = monixHandler
import monix.execution.Scheduler.Implicits.global
val body = "streaming test"
@@ -127,4 +130,10 @@ class StreamingTests
new String(bytes, "utf-8") should include("</div>")
}
}
+
+ override protected def afterAll(): Unit = {
+ akkaHandler.close()
+ monixHandler.close()
+ super.afterAll()
+ }
}