diff options
author | Adam Warski <adam@warski.org> | 2017-08-10 10:17:39 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-08-10 10:17:39 +0200 |
commit | d97fb63953986e26657861469e6c8e8aba70f5d9 (patch) | |
tree | f656e434375c0515051f30eab6f09406d1c759b5 | |
parent | ff76a1737bb88c2664927db309d196e677ba3e98 (diff) | |
parent | 85f93f2ccd282cdfceda8ae76cdf3cbb36d20883 (diff) | |
download | sttp-d97fb63953986e26657861469e6c8e8aba70f5d9.tar.gz sttp-d97fb63953986e26657861469e6c8e8aba70f5d9.tar.bz2 sttp-d97fb63953986e26657861469e6c8e8aba70f5d9.zip |
Merge pull request #25 from aeons/feature/circe
Add circe support module
12 files changed, 248 insertions, 64 deletions
@@ -374,6 +374,38 @@ with a monad for the response wrapper. This brings the possibility to `map` and `flatMap` over responses. That way you could implement e.g. a logging or metric-capturing wrapper. +## JSON + +JSON encoding of bodies and decoding of responses can be handled using +[Circe](https://circe.github.io/circe/) by the `circe` module. To use +add the following dependency to your project: + +```scala +"com.softwaremill.sttp" %% "circe" % "0.0.5" +``` + +This module adds a method to the request and a function that can be given to +a request to decode the response to a specific object. + +```scala +import com.softwaremill.sttp._ +import com.softwaremill.sttp.circe._ + +implicit val handler = HttpURLConnectionSttpHandler + +// Assume that there is an implicit circe encoder in scope +// for the request Payload, and a decoder for the Response +val requestPayload: Payload = ??? + +val response: Either[io.circe.Error, Response] = + sttp + .post(uri"...") + .body(requestPayload) + .response(asJson[Response]) + .send() +``` + + ## Request type All request descriptions have type `RequestT[U, T, S]` (T as in Template). 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 0a2a467..d1dcc22 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 @@ -132,20 +132,19 @@ class AkkaHttpSttpHandler private (actorSystem: ActorSystem, getContentTypeOrOctetStream(r).map { ct => def doSet(body: RequestBody[S]): HttpRequest = body match { case NoBody => ar - case StringBody(b, encoding) => + case StringBody(b, encoding, _) => val ctWithEncoding = HttpCharsets .getForKey(encoding) .map(hc => ContentType.apply(ct.mediaType, () => hc)) .getOrElse(ct) ar.withEntity(ctWithEncoding, b.getBytes(encoding)) - case ByteArrayBody(b) => ar.withEntity(b) - case ByteBufferBody(b) => ar.withEntity(ByteString(b)) - case InputStreamBody(b) => + case ByteArrayBody(b, _) => ar.withEntity(b) + case ByteBufferBody(b, _) => ar.withEntity(ByteString(b)) + case InputStreamBody(b, _) => ar.withEntity( HttpEntity(ct, StreamConverters.fromInputStream(() => b))) - case PathBody(b) => ar.withEntity(ct, b) - case StreamBody(s) => ar.withEntity(HttpEntity(ct, s)) - case SerializableBody(f, t) => doSet(f(t)) + case PathBody(b, _) => ar.withEntity(ct, b) + case StreamBody(s) => ar.withEntity(HttpEntity(ct, s)) } doSet(body) diff --git a/async-http-client-handler/monix/src/main/scala/com/softwaremill/sttp/asynchttpclient/monix/MonixAsyncHttpClientHandler.scala b/async-http-client-handler/monix/src/main/scala/com/softwaremill/sttp/asynchttpclient/monix/MonixAsyncHttpClientHandler.scala index c782519..5b6426f 100644 --- a/async-http-client-handler/monix/src/main/scala/com/softwaremill/sttp/asynchttpclient/monix/MonixAsyncHttpClientHandler.scala +++ b/async-http-client-handler/monix/src/main/scala/com/softwaremill/sttp/asynchttpclient/monix/MonixAsyncHttpClientHandler.scala @@ -25,9 +25,8 @@ class MonixAsyncHttpClientHandler private ( closeClient) { override protected def streamBodyToPublisher( - s: Observable[ByteBuffer]): Publisher[ByteBuffer] = { + s: Observable[ByteBuffer]): Publisher[ByteBuffer] = s.toReactivePublisher - } override protected def publisherToStreamBody( p: Publisher[ByteBuffer]): Observable[ByteBuffer] = 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 8f75030..7b74c29 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 @@ -159,24 +159,21 @@ abstract class AsyncHttpClientHandler[R[_], S](asyncHttpClient: AsyncHttpClient, body match { case NoBody => // skip - case StringBody(b, encoding) => + case StringBody(b, encoding, _) => rb.setBody(b.getBytes(encoding)) - case ByteArrayBody(b) => + case ByteArrayBody(b, _) => rb.setBody(b) - case ByteBufferBody(b) => + case ByteBufferBody(b, _) => rb.setBody(b) - case InputStreamBody(b) => + case InputStreamBody(b, _) => rb.setBody(b) - case PathBody(b) => + case PathBody(b, _) => rb.setBody(b.toFile) - case SerializableBody(f, t) => - setBody(r, f(t), rb) - case StreamBody(s) => val cl = r.headers .find(_._1.equalsIgnoreCase(ContentLengthHeader)) @@ -32,7 +32,7 @@ val commonSettings = Seq( val akkaHttpVersion = "10.0.9" val akkaHttp = "com.typesafe.akka" %% "akka-http" % akkaHttpVersion - +val circeVersion = "0.8.0" val scalaTest = "org.scalatest" %% "scalatest" % "3.0.3" lazy val rootProject = (project in file(".")) @@ -47,6 +47,7 @@ lazy val rootProject = (project in file(".")) monixAsyncHttpClientHandler, catsAsyncHttpClientHandler, okhttpClientHandler, + circe, tests ) @@ -126,6 +127,17 @@ lazy val okhttpClientHandler: Project = (project in file( ) ) dependsOn core +lazy val circe: Project = (project in file("circe")) + .settings(commonSettings: _*) + .settings( + name := "circe", + libraryDependencies ++= Seq( + "io.circe" %% "circe-core" % circeVersion, + "io.circe" %% "circe-parser" % circeVersion, + scalaTest % "test" + ) + ) dependsOn core + lazy val tests: Project = (project in file("tests")) .settings(commonSettings: _*) .settings( diff --git a/circe/src/main/scala/com/softwaremill/sttp/circe.scala b/circe/src/main/scala/com/softwaremill/sttp/circe.scala new file mode 100644 index 0000000..95a72e1 --- /dev/null +++ b/circe/src/main/scala/com/softwaremill/sttp/circe.scala @@ -0,0 +1,17 @@ +package com.softwaremill.sttp + +import com.softwaremill.sttp.model._ +import io.circe.parser._ +import io.circe.{Decoder, Encoder} + +package object circe { + private[sttp] val ApplicationJsonContentType = "application/json" + + implicit def circeBodySerializer[B]( + implicit encoder: Encoder[B]): BodySerializer[B] = + b => StringBody(encoder(b).noSpaces, Utf8, Some(ApplicationJsonContentType)) + + def asJson[B: Decoder]: ResponseAs[Either[io.circe.Error, B], Nothing] = + asString(Utf8).map(decode[B]) + +} diff --git a/circe/src/test/scala/com/softwaremill/sttp/CirceTests.scala b/circe/src/test/scala/com/softwaremill/sttp/CirceTests.scala new file mode 100644 index 0000000..19a317e --- /dev/null +++ b/circe/src/test/scala/com/softwaremill/sttp/CirceTests.scala @@ -0,0 +1,105 @@ +package com.softwaremill.sttp + +import com.softwaremill.sttp.model._ +import io.circe._ +import org.scalatest._ + +import scala.language.higherKinds + +class CirceTests extends FlatSpec with Matchers with EitherValues { + import circe._ + + "The circe module" should "encode arbitrary bodies given an encoder" in { + val body = Outer(Inner(42, true, "horses"), "cats") + val expected = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + + val req = sttp.body(body) + + extractBody(req) shouldBe expected + } + + it should "decode arbitrary bodies given a decoder" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + val responseAs = asJson[Outer] + + runJsonResponseAs(responseAs)(body).right.value shouldBe expected + } + + it should "fail to decode invalid json" in { + val body = """not valid json""" + + val responseAs = asJson[Outer] + + runJsonResponseAs(responseAs)(body).left.value shouldBe an[io.circe.Error] + } + + it should "encode and decode back to the same thing" in { + val outer = Outer(Inner(42, true, "horses"), "cats") + + val encoded = extractBody(sttp.body(outer)) + val decoded = runJsonResponseAs(asJson[Outer])(encoded) + + decoded.right.value shouldBe outer + } + + it should "set the content type" in { + val body = Outer(Inner(42, true, "horses"), "cats") + val req = sttp.body(body) + + val ct = req.headers.toMap.get("Content-Type") + + ct shouldBe Some(contentTypeWithEncoding(ApplicationJsonContentType, Utf8)) + } + + it should "only set the content type if it was not set earlier" in { + val body = Outer(Inner(42, true, "horses"), "cats") + val req = sttp.contentType("horses/cats").body(body) + + val ct = req.headers.toMap.get("Content-Type") + + ct shouldBe Some("horses/cats") + } + + case class Inner(a: Int, b: Boolean, c: String) + + object Inner { + implicit val encoder: Encoder[Inner] = + Encoder.forProduct3("a", "b", "c")(i => (i.a, i.b, i.c)) + implicit val decoder: Decoder[Inner] = + Decoder.forProduct3("a", "b", "c")(Inner.apply) + } + + case class Outer(foo: Inner, bar: String) + + object Outer { + implicit val encoder: Encoder[Outer] = + Encoder.forProduct2("foo", "bar")(o => (o.foo, o.bar)) + implicit val decoder: Decoder[Outer] = + Decoder.forProduct2("foo", "bar")(Outer.apply) + } + + def extractBody[A[_], B, C](request: RequestT[A, B, C]): String = + request.body match { + case StringBody(body, "utf-8", Some(ApplicationJsonContentType)) => + body + case wrongBody => + fail(s"Request body does not serialize to correct StringBody: $wrongBody") + } + + def runJsonResponseAs[A](responseAs: ResponseAs[A, Nothing]): String => A = + responseAs match { + case responseAs: MappedResponseAs[_, A, Nothing] => + responseAs.raw match { + case ResponseAsString("utf-8") => + responseAs.g + case ResponseAsString(encoding) => + fail( + s"MappedResponseAs wraps a ResponseAsString with wrong encoding: $encoding") + case _ => + fail("MappedResponseAs does not wrap a ResponseAsString") + } + case _ => fail("ResponseAs is not a MappedResponseAs") + } +} diff --git a/core/src/main/scala/com/softwaremill/sttp/HttpURLConnectionSttpHandler.scala b/core/src/main/scala/com/softwaremill/sttp/HttpURLConnectionSttpHandler.scala index 29da886..dd208f4 100644 --- a/core/src/main/scala/com/softwaremill/sttp/HttpURLConnectionSttpHandler.scala +++ b/core/src/main/scala/com/softwaremill/sttp/HttpURLConnectionSttpHandler.scala @@ -56,28 +56,25 @@ object HttpURLConnectionSttpHandler extends SttpHandler[Id, Nothing] { body match { case NoBody => // skip - case StringBody(b, encoding) => + case StringBody(b, encoding, _) => val writer = new OutputStreamWriter(c.getOutputStream, encoding) try writer.write(b) finally writer.close() - case ByteArrayBody(b) => + case ByteArrayBody(b, _) => c.getOutputStream.write(b) - case ByteBufferBody(b) => + case ByteBufferBody(b, _) => val channel = Channels.newChannel(c.getOutputStream) try channel.write(b) finally channel.close() - case InputStreamBody(b) => + case InputStreamBody(b, _) => copyStream(b, c.getOutputStream) - case PathBody(b) => + case PathBody(b, _) => Files.copy(b, c.getOutputStream) - case SerializableBody(f, t) => - setBody(f(t), c) - case StreamBody(s) => // we have an instance of nothing - everything's possible! s diff --git a/core/src/main/scala/com/softwaremill/sttp/RequestT.scala b/core/src/main/scala/com/softwaremill/sttp/RequestT.scala index ff80dd5..6b1bf0c 100644 --- a/core/src/main/scala/com/softwaremill/sttp/RequestT.scala +++ b/core/src/main/scala/com/softwaremill/sttp/RequestT.scala @@ -100,10 +100,8 @@ case class RequestT[U[_], T, +S]( * bytes in the string using the given encoding. */ def body(b: String, encoding: String): RequestT[U, T, S] = - setContentTypeIfMissing( - contentTypeWithEncoding(TextPlainContentType, encoding)) + withBasicBody(StringBody(b, encoding)) .setContentLengthIfMissing(b.getBytes(encoding).length) - .copy(body = StringBody(b, encoding)) /** * If content type is not yet specified, will be set to @@ -113,25 +111,22 @@ case class RequestT[U[_], T, +S]( * of the given array. */ def body(b: Array[Byte]): RequestT[U, T, S] = - setContentTypeIfMissing(ApplicationOctetStreamContentType) + withBasicBody(ByteArrayBody(b)) .setContentLengthIfMissing(b.length) - .copy(body = ByteArrayBody(b)) /** * If content type is not yet specified, will be set to * `application/octet-stream`. */ def body(b: ByteBuffer): RequestT[U, T, S] = - setContentTypeIfMissing(ApplicationOctetStreamContentType).copy( - body = ByteBufferBody(b)) + withBasicBody(ByteBufferBody(b)) /** * If content type is not yet specified, will be set to * `application/octet-stream`. */ def body(b: InputStream): RequestT[U, T, S] = - setContentTypeIfMissing(ApplicationOctetStreamContentType).copy( - body = InputStreamBody(b)) + withBasicBody(InputStreamBody(b)) /** * If content type is not yet specified, will be set to @@ -151,9 +146,8 @@ case class RequestT[U[_], T, +S]( * of the given file. */ def body(b: Path): RequestT[U, T, S] = - setContentTypeIfMissing(ApplicationOctetStreamContentType) + withBasicBody(PathBody(b)) .setContentLengthIfMissing(b.toFile.length()) - .copy(body = PathBody(b)) /** * Encodes the given parameters as form data using `utf-8`. @@ -204,13 +198,10 @@ case class RequestT[U[_], T, +S]( * `application/octet-stream`. */ def body[B: BodySerializer](b: B): RequestT[U, T, S] = - setContentTypeIfMissing(ApplicationOctetStreamContentType).copy( - body = SerializableBody(implicitly[BodySerializer[B]], b)) - - //def multipartData(parts: MultiPart*): RequestTemplate[U] = ??? + withBasicBody(implicitly[BodySerializer[B]].apply(b)) def streamBody[S2 >: S](b: S2): RequestT[U, T, S2] = - this.copy[U, T, S2](body = StreamBody(b)) + copy[U, T, S2](body = StreamBody(b)) /** * What's the target type to which the response body should be read. @@ -239,6 +230,20 @@ case class RequestT[U[_], T, +S]( private def setContentTypeIfMissing(ct: String): RequestT[U, T, S] = if (hasContentType) this else contentType(ct) + private def withBasicBody(body: BasicRequestBody) = { + if (hasContentType) this + else + body match { + case StringBody(_, encoding, Some(ct)) => + contentType(ct, encoding) + case body => + body.defaultContentType match { + case Some(ct) => contentType(ct) + case None => this + } + } + }.copy(body = body) + private def hasContentLength: Boolean = headers.exists(_._1.equalsIgnoreCase(ContentLengthHeader)) private def setContentLengthIfMissing(l: => Long): RequestT[U, T, S] = @@ -247,10 +252,11 @@ case class RequestT[U[_], T, +S]( private def formDataBody(fs: Seq[(String, String)], encoding: String): RequestT[U, T, S] = { val b = fs - .map( - p => - URLEncoder.encode(p._1, encoding) + "=" + URLEncoder - .encode(p._2, encoding)) + .map { + case (key, value) => + URLEncoder.encode(key, encoding) + "=" + + URLEncoder.encode(value, encoding) + } .mkString("&") setContentTypeIfMissing(ApplicationFormContentType) .setContentLengthIfMissing(b.getBytes(encoding).length) diff --git a/core/src/main/scala/com/softwaremill/sttp/model/RequestBody.scala b/core/src/main/scala/com/softwaremill/sttp/model/RequestBody.scala index 0c737f0..7499048 100644 --- a/core/src/main/scala/com/softwaremill/sttp/model/RequestBody.scala +++ b/core/src/main/scala/com/softwaremill/sttp/model/RequestBody.scala @@ -4,18 +4,39 @@ import java.io.InputStream import java.nio.ByteBuffer import java.nio.file.Path -import com.softwaremill.sttp.BodySerializer +import com.softwaremill.sttp._ sealed trait RequestBody[+S] case object NoBody extends RequestBody[Nothing] -case class SerializableBody[T](f: BodySerializer[T], t: T) - extends RequestBody[Nothing] - -sealed trait BasicRequestBody extends RequestBody[Nothing] -case class StringBody(s: String, encoding: String) extends BasicRequestBody -case class ByteArrayBody(b: Array[Byte]) extends BasicRequestBody -case class ByteBufferBody(b: ByteBuffer) extends BasicRequestBody -case class InputStreamBody(b: InputStream) extends BasicRequestBody -case class PathBody(f: Path) extends BasicRequestBody + +sealed trait BasicRequestBody extends RequestBody[Nothing] { + def defaultContentType: Option[String] +} + +case class StringBody( + s: String, + encoding: String, + defaultContentType: Option[String] = Some(TextPlainContentType) +) extends BasicRequestBody + +case class ByteArrayBody( + b: Array[Byte], + defaultContentType: Option[String] = Some(ApplicationOctetStreamContentType) +) extends BasicRequestBody + +case class ByteBufferBody( + b: ByteBuffer, + defaultContentType: Option[String] = Some(ApplicationOctetStreamContentType) +) extends BasicRequestBody + +case class InputStreamBody( + b: InputStream, + defaultContentType: Option[String] = Some(ApplicationOctetStreamContentType) +) extends BasicRequestBody + +case class PathBody( + f: Path, + defaultContentType: Option[String] = Some(ApplicationOctetStreamContentType) +) extends BasicRequestBody case class StreamBody[S](s: S) extends RequestBody[S] diff --git a/core/src/main/scala/com/softwaremill/sttp/model/ResponseAs.scala b/core/src/main/scala/com/softwaremill/sttp/model/ResponseAs.scala index 24b9b2b..5a008ed 100644 --- a/core/src/main/scala/com/softwaremill/sttp/model/ResponseAs.scala +++ b/core/src/main/scala/com/softwaremill/sttp/model/ResponseAs.scala @@ -2,7 +2,6 @@ package com.softwaremill.sttp.model import java.io.{File, FileOutputStream, IOException, InputStream} import java.net.URLDecoder -import java.nio.file.Path import com.softwaremill.sttp.{MonadError, transfer} diff --git a/okhttp-client-handler/src/main/scala/com/softwaremill/sttp/okhttp/OkHttpClientHandler.scala b/okhttp-client-handler/src/main/scala/com/softwaremill/sttp/okhttp/OkHttpClientHandler.scala index 79ca98b..f7c4466 100644 --- a/okhttp-client-handler/src/main/scala/com/softwaremill/sttp/okhttp/OkHttpClientHandler.scala +++ b/okhttp-client-handler/src/main/scala/com/softwaremill/sttp/okhttp/OkHttpClientHandler.scala @@ -49,19 +49,19 @@ abstract class OkHttpClientHandler[R[_], S](client: OkHttpClient) private def setBody(requestBody: RequestBody[S]): Option[OkHttpRequestBody] = { requestBody match { case NoBody => None - case StringBody(b, encoding) => + case StringBody(b, encoding, _) => Some(OkHttpRequestBody.create(MediaType.parse(encoding), b)) - case ByteArrayBody(b) => Some(OkHttpRequestBody.create(null, b)) - case ByteBufferBody(b) => Some(OkHttpRequestBody.create(null, b.array())) - case InputStreamBody(b) => + case ByteArrayBody(b, _) => Some(OkHttpRequestBody.create(null, b)) + case ByteBufferBody(b, _) => + Some(OkHttpRequestBody.create(null, b.array())) + case InputStreamBody(b, _) => Some(new OkHttpRequestBody() { override def writeTo(sink: BufferedSink): Unit = sink.writeAll(Okio.source(b)) override def contentType(): MediaType = null }) - case PathBody(b) => Some(OkHttpRequestBody.create(null, b.toFile)) - case SerializableBody(f, t) => setBody(f(t)) - case StreamBody(s) => None + case PathBody(b, _) => Some(OkHttpRequestBody.create(null, b.toFile)) + case StreamBody(s) => None } } |