diff options
7 files changed, 205 insertions, 73 deletions
diff --git a/core/src/main/scala/com/softwaremill/sttp/HttpConnectionSttpHandler.scala b/core/src/main/scala/com/softwaremill/sttp/HttpConnectionSttpHandler.scala index dc6f8a2..50f6d76 100644 --- a/core/src/main/scala/com/softwaremill/sttp/HttpConnectionSttpHandler.scala +++ b/core/src/main/scala/com/softwaremill/sttp/HttpConnectionSttpHandler.scala @@ -120,7 +120,8 @@ object HttpConnectionSttpHandler extends SttpHandler[Id, Nothing] { r.parse(asString(enc)) case ResponseAsStream() => - // only possible when the user requests the response as a stream of Nothing. Oh well ... + // only possible when the user requests the response as a stream of + // Nothing. Oh well ... throw new IllegalStateException() } } diff --git a/core/src/main/scala/com/softwaremill/sttp/Response.scala b/core/src/main/scala/com/softwaremill/sttp/Response.scala index a4d60a5..44c39c3 100644 --- a/core/src/main/scala/com/softwaremill/sttp/Response.scala +++ b/core/src/main/scala/com/softwaremill/sttp/Response.scala @@ -47,9 +47,9 @@ case class Cookie(name: String, object Cookie { def apply(hc: HttpCookie, h: String): Cookie = { - // HttpCookie.parse has special handling for the expires attribute and turns it into max-age - // if the cookie contains an expires header; hand-parsing in such case to preserve the - // values from the cookie + // HttpCookie.parse has special handling for the expires attribute and + // turns it into max-age if the cookie contains an expires header; + // hand-parsing in such case to preserve the values from the cookie val lch = h.toLowerCase val (expires, maxAge) = if (lch.contains("expires=")) { val tokenizer = new StringTokenizer(h, ";") @@ -85,7 +85,8 @@ object Cookie { } /** - * Modified version of `HttpCookie.expiryDate2DeltaSeconds` to return a `ZonedDateTime`, not a second-delta. + * Modified version of `HttpCookie.expiryDate2DeltaSeconds` to return a + * `ZonedDateTime`, not a second-delta. */ private def expiryDate2ZonedDateTime( dateString: String): Option[ZonedDateTime] = { @@ -98,7 +99,8 @@ object Cookie { df.set2DigitYearStart(cal.getTime) try { cal.setTime(df.parse(dateString)) - if (!format.contains("yyyy")) { // 2-digit years following the standard set + if (!format.contains("yyyy")) { + // 2-digit years following the standard set // out it rfc 6265 var year = cal.get(Calendar.YEAR) year %= 100 diff --git a/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala b/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala index 1216a1e..a8099a4 100644 --- a/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala +++ b/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala @@ -44,9 +44,10 @@ object UriInterpolator { 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 + // 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) } else append(x) } @@ -54,8 +55,8 @@ object UriInterpolator { 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 + // 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) } @@ -314,8 +315,8 @@ object UriInterpolator { 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 + // 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") } diff --git a/core/src/main/scala/com/softwaremill/sttp/package.scala b/core/src/main/scala/com/softwaremill/sttp/package.scala index baf4061..763b6c3 100644 --- a/core/src/main/scala/com/softwaremill/sttp/package.scala +++ b/core/src/main/scala/com/softwaremill/sttp/package.scala @@ -26,6 +26,10 @@ package object sttp { ResponseAsString(encoding) def responseAsByteArray: ResponseAs[Array[Byte], Nothing] = ResponseAsByteArray + + /** + * Uses `utf-8` encoding. + */ def responseAsParams: ResponseAs[Seq[(String, String)], Nothing] = responseAsParams(Utf8) def responseAsParams( @@ -35,8 +39,9 @@ package object sttp { def responseAsStream[S]: ResponseAs[S, S] = ResponseAsStream[S, S]() /** - * Use the factory methods `multiPart` to conveniently create instances of this class. A part can be then - * further customised using `fileName`, `contentType` and `header` methods. + * Use the factory methods `multiPart` to conveniently create instances of + * this class. A part can be then further customised using `fileName`, + * `contentType` and `header` methods. */ case class MultiPart(name: String, data: BasicRequestBody, @@ -50,7 +55,8 @@ package object sttp { } /** - * Content type will be set to `text/plain` with `utf-8` encoding, can be overridden later using the `contentType` method. + * Content type will be set to `text/plain` with `utf-8` encoding, can be + * overridden later using the `contentType` method. */ def multiPart(name: String, data: String): MultiPart = MultiPart(name, @@ -59,7 +65,8 @@ package object sttp { Some(contentTypeWithEncoding(TextPlainContentType, Utf8))) /** - * Content type will be set to `text/plain` with `utf-8` encoding, can be overridden later using the `contentType` method. + * Content type will be set to `text/plain` with `utf-8` encoding, can be + * overridden later using the `contentType` method. */ def multiPart(name: String, data: String, encoding: String): MultiPart = MultiPart(name, @@ -68,7 +75,8 @@ package object sttp { Some(contentTypeWithEncoding(TextPlainContentType, Utf8))) /** - * Content type will be set to `application/octet-stream`, can be overridden later using the `contentType` method. + * Content type will be set to `application/octet-stream`, can be overridden + * later using the `contentType` method. */ def multiPart(name: String, data: Array[Byte]): MultiPart = MultiPart(name, @@ -76,7 +84,8 @@ package object sttp { contentType = Some(ApplicationOctetStreamContentType)) /** - * Content type will be set to `application/octet-stream`, can be overridden later using the `contentType` method. + * Content type will be set to `application/octet-stream`, can be overridden + * later using the `contentType` method. */ def multiPart(name: String, data: ByteBuffer): MultiPart = MultiPart(name, @@ -84,7 +93,8 @@ package object sttp { contentType = Some(ApplicationOctetStreamContentType)) /** - * Content type will be set to `application/octet-stream`, can be overridden later using the `contentType` method. + * Content type will be set to `application/octet-stream`, can be overridden + * later using the `contentType` method. */ def multiPart(name: String, data: InputStream): MultiPart = MultiPart(name, @@ -92,13 +102,15 @@ package object sttp { contentType = Some(ApplicationOctetStreamContentType)) /** - * Content type will be set to `application/octet-stream`, can be overridden later using the `contentType` method. + * Content type will be set to `application/octet-stream`, can be overridden + * later using the `contentType` method. */ def multiPart(name: String, data: File): MultiPart = multiPart(name, data.toPath) /** - * Content type will be set to `application/octet-stream`, can be overridden later using the `contentType` method. + * Content type will be set to `application/octet-stream`, can be overridden + * later using the `contentType` method. */ def multiPart(name: String, data: Path): MultiPart = MultiPart(name, @@ -106,6 +118,17 @@ package object sttp { fileName = Some(data.getFileName.toString), contentType = Some(ApplicationOctetStreamContentType)) + /** + * @tparam U Specifies if the method & uri are specified. By default can be + * either: + * * `Empty`, which is a type constructor which always resolves to + * `None`. This type of request is aliased to `PartialRequest`: + * there's no method and uri specified, and the request cannot be + * sent. + * * `Id`, which is an identity type constructor. This type of + * request is aliased to `Request`: the method and uri are + * specified, and the request can be sent. + */ case class RequestTemplate[U[_]]( method: U[Method], uri: U[URI], @@ -159,12 +182,14 @@ package object sttp { /** * Uses the `utf-8` encoding. - * If content type is not yet specified, will be set to `text/plain` with `utf-8` encoding. + * If content type is not yet specified, will be set to `text/plain` + * with `utf-8` encoding. */ def body(b: String): RequestTemplate[U] = body(b, Utf8) /** - * If content type is not yet specified, will be set to `text/plain` with the given encoding. + * If content type is not yet specified, will be set to `text/plain` + * with the given encoding. */ def body(b: String, encoding: String): RequestTemplate[U] = setContentTypeIfMissing( @@ -172,33 +197,38 @@ package object sttp { .copy(body = StringBody(b, encoding)) /** - * If content type is not yet specified, will be set to `application/octet-stream`. + * If content type is not yet specified, will be set to + * `application/octet-stream`. */ def body(b: Array[Byte]): RequestTemplate[U] = setContentTypeIfMissing(ApplicationOctetStreamContentType).copy( body = ByteArrayBody(b)) /** - * If content type is not yet specified, will be set to `application/octet-stream`. + * If content type is not yet specified, will be set to + * `application/octet-stream`. */ def body(b: ByteBuffer): RequestTemplate[U] = setContentTypeIfMissing(ApplicationOctetStreamContentType).copy( body = ByteBufferBody(b)) /** - * If content type is not yet specified, will be set to `application/octet-stream`. + * If content type is not yet specified, will be set to + * `application/octet-stream`. */ def body(b: InputStream): RequestTemplate[U] = setContentTypeIfMissing(ApplicationOctetStreamContentType).copy( body = InputStreamBody(b)) /** - * If content type is not yet specified, will be set to `application/octet-stream`. + * If content type is not yet specified, will be set to + * `application/octet-stream`. */ def body(b: File): RequestTemplate[U] = body(b.toPath) /** - * If content type is not yet specified, will be set to `application/octet-stream`. + * If content type is not yet specified, will be set to + * `application/octet-stream`. */ def body(b: Path): RequestTemplate[U] = setContentTypeIfMissing(ApplicationOctetStreamContentType).copy( @@ -206,34 +236,39 @@ package object sttp { /** * Encodes the given parameters as form data using `utf-8`. - * If content type is not yet specified, will be set to `application/x-www-form-urlencoded`. + * If content type is not yet specified, will be set to + * `application/x-www-form-urlencoded`. */ def body(fs: Map[String, String]): RequestTemplate[U] = formDataBody(fs.toList, Utf8) /** * Encodes the given parameters as form data. - * If content type is not yet specified, will be set to `application/x-www-form-urlencoded`. + * If content type is not yet specified, will be set to + * `application/x-www-form-urlencoded`. */ def body(fs: Map[String, String], encoding: String): RequestTemplate[U] = formDataBody(fs.toList, encoding) /** * Encodes the given parameters as form data using `utf-8`. - * If content type is not yet specified, will be set to `application/x-www-form-urlencoded`. + * If content type is not yet specified, will be set to + * `application/x-www-form-urlencoded`. */ def body(fs: (String, String)*): RequestTemplate[U] = formDataBody(fs.toList, Utf8) /** * Encodes the given parameters as form data. - * If content type is not yet specified, will be set to `application/x-www-form-urlencoded`. + * If content type is not yet specified, will be set to + * `application/x-www-form-urlencoded`. */ def body(fs: Seq[(String, String)], encoding: String): RequestTemplate[U] = formDataBody(fs, encoding) /** - * If content type is not yet specified, will be set to `application/octet-stream`. + * If content type is not yet specified, will be set to + * `application/octet-stream`. */ def body[T: BodySerializer](b: T): RequestTemplate[U] = setContentTypeIfMissing(ApplicationOctetStreamContentType).copy( @@ -242,10 +277,13 @@ package object sttp { //def multipartData(parts: MultiPart*): RequestTemplate[U] = ??? /** - * @param responseAs What's the target type to which the response should be read. Needs to be specified upfront - * so that the response is always consumed and hence there are no requirements on client code - * to consume it. An exception to this are streaming responses, which need to fully consumed - * by the client if such a response type is requested. + * @param responseAs What's the target type to which the response body + * should be read. Needs to be specified upfront + * so that the response is always consumed and hence + * there are no requirements on client code to consume + * it. An exception to this are streaming responses, + * which need to fully consumed by the client if such + * a response type is requested. */ def send[R[_], S, T](responseAs: ResponseAs[T, S])( implicit handler: SttpHandler[R, S], @@ -293,7 +331,8 @@ package object sttp { type Request = RequestTemplate[Id] @implicitNotFound( - "This is a partial request, the method & url are not specified. Use .get(...), .post(...) etc. to obtain a non-partial request.") + "This is a partial request, the method & url are not specified. Use " + + ".get(...), .post(...) etc. to obtain a non-partial request.") private type IsRequest[U[_]] = RequestTemplate[U] =:= Request val sttp: RequestTemplate[Empty] = RequestTemplate.empty diff --git a/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala b/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala index def843a..ff6b113 100644 --- a/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala +++ b/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala @@ -4,13 +4,11 @@ import java.io.ByteArrayInputStream import java.nio.ByteBuffer import java.time.{ZoneId, ZonedDateTime} -import akka.stream.ActorMaterializer -import akka.actor.ActorSystem -import akka.http.scaladsl.Http import akka.http.scaladsl.model.{DateTime, FormData} import akka.http.scaladsl.model.headers._ import akka.http.scaladsl.model.headers.CacheDirectives._ import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.Route import akka.http.scaladsl.server.directives.Credentials import com.softwaremill.sttp.akkahttp.AkkaHttpSttpHandler import com.typesafe.scalalogging.StrictLogging @@ -18,7 +16,6 @@ import org.scalatest.concurrent.{IntegrationPatience, ScalaFutures} import org.scalatest.{BeforeAndAfterAll, FlatSpec, Matchers} import better.files._ -import scala.concurrent.Future import scala.language.higherKinds class BasicTests @@ -27,11 +24,14 @@ class BasicTests with BeforeAndAfterAll with ScalaFutures with StrictLogging - with IntegrationPatience { + with IntegrationPatience + with TestHttpServer + with ForceWrapped { + private def paramsToString(m: Map[String, String]): String = m.toList.sortBy(_._1).map(p => s"${p._1}=${p._2}").mkString(" ") - private val serverRoutes = + override val serverRoutes: Route = pathPrefix("echo") { pathPrefix("form_params") { formFieldMap { params => @@ -105,37 +105,12 @@ class BasicTests } } - private implicit val actorSystem: ActorSystem = ActorSystem("sttp-test") - import actorSystem.dispatcher - - private implicit val materializer = ActorMaterializer() - private val endpoint = "http://localhost:51823" - - override protected def beforeAll(): Unit = { - Http().bindAndHandle(serverRoutes, "localhost", 51823).futureValue - } - - override protected def afterAll(): Unit = { - actorSystem.terminate().futureValue - } - - trait ForceWrappedValue[R[_]] { - def force[T](wrapped: R[T]): T - } - implicit class ForceDecorator[R[_], T](wrapped: R[T]) { - def force()(implicit fwv: ForceWrappedValue[R]): T = fwv.force(wrapped) - } + override def port = 51823 runTests("HttpURLConnection")(HttpConnectionSttpHandler, - new ForceWrappedValue[Id] { - override def force[T](wrapped: Id[T]): T = - wrapped - }) + ForceWrappedValue.id) runTests("Akka HTTP")(new AkkaHttpSttpHandler(actorSystem), - new ForceWrappedValue[Future] { - override def force[T](wrapped: Future[T]): T = - wrapped.futureValue - }) + ForceWrappedValue.future) def runTests[R[_]](name: String)( implicit handler: SttpHandler[R, Nothing], diff --git a/tests/src/test/scala/com/softwaremill/sttp/StreamingTests.scala b/tests/src/test/scala/com/softwaremill/sttp/StreamingTests.scala new file mode 100644 index 0000000..ab77753 --- /dev/null +++ b/tests/src/test/scala/com/softwaremill/sttp/StreamingTests.scala @@ -0,0 +1,64 @@ +package com.softwaremill.sttp + +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.Route +import akka.stream.scaladsl.Source +import akka.util.ByteString +import com.softwaremill.sttp.akkahttp.AkkaHttpSttpHandler +import com.typesafe.scalalogging.StrictLogging +import org.scalatest.{BeforeAndAfterAll, FlatSpec, Matchers} +import org.scalatest.concurrent.{IntegrationPatience, ScalaFutures} +import com.softwaremill.sttp.akkahttp._ + +class StreamingTests + extends FlatSpec + with Matchers + with BeforeAndAfterAll + with ScalaFutures + with StrictLogging + with IntegrationPatience + with TestHttpServer { + + override val serverRoutes: Route = + path("echo") { + post { + parameterMap { params => + entity(as[String]) { body: String => + complete(body) + } + } + } + } + + override def port = 51824 + + akkaStreamingTests() + + def akkaStreamingTests(): Unit = { + implicit val handler = new AkkaHttpSttpHandler(actorSystem) + + val body = "streaming test" + + "Akka HTTP" should "stream request body" in { + val response = sttp + .post(uri"$endpoint/echo") + .body(Source.single(ByteString(body))) + .send(responseAsString) + .futureValue + + response.body should be(body) + } + + it should "receive a stream" in { + val response = sttp + .post(uri"$endpoint/echo") + .body(body) + .send(responseAsStream[Source[ByteString, Any]]) + .futureValue + + val responseBody = response.body.runReduce(_ ++ _).futureValue.utf8String + + responseBody should be(body) + } + } +} diff --git a/tests/src/test/scala/com/softwaremill/sttp/testHelpers.scala b/tests/src/test/scala/com/softwaremill/sttp/testHelpers.scala new file mode 100644 index 0000000..bc7eccd --- /dev/null +++ b/tests/src/test/scala/com/softwaremill/sttp/testHelpers.scala @@ -0,0 +1,50 @@ +package com.softwaremill.sttp + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.server.Route +import akka.stream.ActorMaterializer +import org.scalatest.{BeforeAndAfterAll, Suite} +import org.scalatest.concurrent.ScalaFutures + +import scala.concurrent.Future +import scala.language.higherKinds + +trait TestHttpServer extends BeforeAndAfterAll with ScalaFutures { + this: Suite => + protected implicit val actorSystem: ActorSystem = ActorSystem("sttp-test") + import actorSystem.dispatcher + + protected implicit val materializer = ActorMaterializer() + protected val endpoint = uri"http://localhost:$port" + + override protected def beforeAll(): Unit = { + Http().bindAndHandle(serverRoutes, "localhost", port).futureValue + } + + override protected def afterAll(): Unit = { + actorSystem.terminate().futureValue + } + + def serverRoutes: Route + def port: Int +} + +trait ForceWrapped extends ScalaFutures { this: Suite => + trait ForceWrappedValue[R[_]] { + def force[T](wrapped: R[T]): T + } + object ForceWrappedValue { + val id = new ForceWrappedValue[Id] { + override def force[T](wrapped: Id[T]): T = + wrapped + } + val future = new ForceWrappedValue[Future] { + override def force[T](wrapped: Future[T]): T = + wrapped.futureValue + } + } + implicit class ForceDecorator[R[_], T](wrapped: R[T]) { + def force()(implicit fwv: ForceWrappedValue[R]): T = fwv.force(wrapped) + } +} |