diff --git a/docs/json.md b/docs/json.md index b4bd244cbb..f790a77c0a 100644 --- a/docs/json.md +++ b/docs/json.md @@ -232,12 +232,12 @@ or for ScalaJS (cross build) projects: "com.softwaremill.sttp.client4" %%% "upickle" % "@VERSION@" ``` -To use, add an import: `import sttp.client4.upicklejson._` (or extend `SttpUpickleApi`) and define an implicit `ReadWriter` (or separately `Reader` and `Writer`) for your datatype. +To use, add an import: `import sttp.client4.upicklejson.default._` and define an implicit `ReadWriter` (or separately `Reader` and `Writer`) for your datatype. Usage example: ```scala mdoc:compile-only import sttp.client4._ -import sttp.client4.upicklejson._ +import sttp.client4.upicklejson.default._ import upickle.default._ val backend: SyncBackend = DefaultSyncBackend() @@ -254,3 +254,20 @@ basicRequest .response(asJson[ResponsePayload]) .send(backend) ``` + +If you have a customised version of upickle, with [custom configuration](https://com-lihaoyi.github.io/upickle/#CustomConfiguration), you'll need to create a dedicated object, which provides the upickle <-> sttp integration. There, you'll need to provide the implementation of `upickle.Api` that you are using. Moreover, the type of the overridden `upickleApi` needs to be the singleton type of the value (as in the example below). + +That's needed as the `upickle.Api` contains the `read`/`write` methods to serialize/deserialize the JSON; moreover, `ReadWriter` isn't a top-level type, but path-dependent one. + +For example, if you want to use the `legacy` upickle configuration, the integration might look as follows: + +```scala mdoc:compile-only +import upickle.legacy._ // get access to ReadWriter type, macroRW derivation, etc. + +object legacyUpickle extends sttp.client4.upicklejson.SttpUpickleApi { + override val upickleApi: upickle.legacy.type = upickle.legacy +} +import legacyUpickle._ + +// use upickle as in the above examples +``` \ No newline at end of file diff --git a/docs/quickstart.md b/docs/quickstart.md index 4027fe796c..ca413c8faf 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -79,7 +79,7 @@ Your code might then look as follows: ```scala mdoc:compile-only import sttp.client4._ -import sttp.client4.upicklejson._ +import sttp.client4.upicklejson.default._ import upickle.default._ val backend = DefaultSyncBackend() diff --git a/json/upickle/src/main/scala/sttp/client4/upicklejson/SttpUpickleApi.scala b/json/upickle/src/main/scala/sttp/client4/upicklejson/SttpUpickleApi.scala index a7b1a8ce00..a91273092f 100644 --- a/json/upickle/src/main/scala/sttp/client4/upicklejson/SttpUpickleApi.scala +++ b/json/upickle/src/main/scala/sttp/client4/upicklejson/SttpUpickleApi.scala @@ -1,29 +1,29 @@ package sttp.client4.upicklejson -import upickle.default.{read, write, Reader, Writer} import sttp.client4._ import sttp.client4.internal.Utf8 import sttp.model.MediaType import sttp.client4.json._ trait SttpUpickleApi { + val upickleApi: upickle.Api - implicit def upickleBodySerializer[B](implicit encoder: Writer[B]): BodySerializer[B] = - b => StringBody(write(b), Utf8, MediaType.ApplicationJson) + implicit def upickleBodySerializer[B](implicit encoder: upickleApi.Writer[B]): BodySerializer[B] = + b => StringBody(upickleApi.write(b), Utf8, MediaType.ApplicationJson) /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Returns: * - `Right(b)` if the parsing was successful * - `Left(HttpError(String))` if the response code was other than 2xx (deserialization is not attempted) * - `Left(DeserializationException)` if there's an error during deserialization */ - def asJson[B: Reader: IsOption]: ResponseAs[Either[ResponseException[String, Exception], B]] = + def asJson[B: upickleApi.Reader: IsOption]: ResponseAs[Either[ResponseException[String, Exception], B]] = asString.mapWithMetadata(ResponseAs.deserializeRightWithError(deserializeJson)).showAsJson /** Tries to deserialize the body from a string into JSON, regardless of the response code. Returns: * - `Right(b)` if the parsing was successful * - `Left(DeserializationException)` if there's an error during deserialization */ - def asJsonAlways[B: Reader: IsOption]: ResponseAs[Either[DeserializationException[Exception], B]] = + def asJsonAlways[B: upickleApi.Reader: IsOption]: ResponseAs[Either[DeserializationException[Exception], B]] = asStringAlways.map(ResponseAs.deserializeWithError(deserializeJson)).showAsJsonAlways /** Tries to deserialize the body from a string into JSON, using different deserializers depending on the status code. @@ -32,15 +32,16 @@ trait SttpUpickleApi { * - `Left(HttpError(E))` if the response was other than 2xx and parsing was successful * - `Left(DeserializationException)` if there's an error during deserialization */ - def asJsonEither[E: Reader: IsOption, B: Reader: IsOption]: ResponseAs[Either[ResponseException[E, Exception], B]] = + def asJsonEither[E: upickleApi.Reader: IsOption, B: upickleApi.Reader: IsOption] + : ResponseAs[Either[ResponseException[E, Exception], B]] = asJson[B].mapLeft { case HttpError(e, code) => deserializeJson[E].apply(e).fold(DeserializationException(e, _), HttpError(_, code)) case de @ DeserializationException(_, _) => de }.showAsJsonEither - def deserializeJson[B: Reader: IsOption]: String => Either[Exception, B] = { (s: String) => + def deserializeJson[B: upickleApi.Reader: IsOption]: String => Either[Exception, B] = { (s: String) => try - Right(read[B](JsonInput.sanitize[B].apply(s))) + Right(upickleApi.read[B](JsonInput.sanitize[B].apply(s))) catch { case e: Exception => Left(e) case t: Throwable => diff --git a/json/upickle/src/main/scala/sttp/client4/upicklejson/package.scala b/json/upickle/src/main/scala/sttp/client4/upicklejson/package.scala index 3ec678a8de..a0922d4d82 100644 --- a/json/upickle/src/main/scala/sttp/client4/upicklejson/package.scala +++ b/json/upickle/src/main/scala/sttp/client4/upicklejson/package.scala @@ -1,3 +1,7 @@ package sttp.client4 -package object upicklejson extends SttpUpickleApi +package object upicklejson { + object default extends SttpUpickleApi { + override val upickleApi: upickle.default.type = upickle.default + } +} diff --git a/json/upickle/src/test/scala/sttp/client4/upicklejson/BackendStubUpickleTests.scala b/json/upickle/src/test/scala/sttp/client4/upicklejson/BackendStubUpickleTests.scala index 87e705f448..9d00f6bc52 100644 --- a/json/upickle/src/test/scala/sttp/client4/upicklejson/BackendStubUpickleTests.scala +++ b/json/upickle/src/test/scala/sttp/client4/upicklejson/BackendStubUpickleTests.scala @@ -1,6 +1,7 @@ package sttp.client4.upicklejson import upickle.default._ +import sttp.client4.upicklejson.default._ import org.scalatest.concurrent.ScalaFutures import sttp.client4.basicRequest import sttp.client4.testing.SyncBackendStub diff --git a/json/upickle/src/test/scala/sttp/client4/upicklejson/UpickleTests.scala b/json/upickle/src/test/scala/sttp/client4/upicklejson/UpickleTests.scala index c635207ca9..cd26902f49 100644 --- a/json/upickle/src/test/scala/sttp/client4/upicklejson/UpickleTests.scala +++ b/json/upickle/src/test/scala/sttp/client4/upicklejson/UpickleTests.scala @@ -1,6 +1,5 @@ package sttp.client4.upicklejson -import upickle.default._ import org.scalatest._ import sttp.client4.internal._ import sttp.client4._ @@ -11,6 +10,9 @@ import ujson.Obj class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { "The upickle module" should "encode arbitrary bodies given an encoder" in { + import UsingDefaultReaderWriters._ + import sttp.client4.upicklejson.default._ + val body = Outer(Inner(42, true, "horses"), "cats") val expected = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" @@ -20,6 +22,9 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { } it should "decode arbitrary bodies given a decoder" in { + import UsingDefaultReaderWriters._ + import sttp.client4.upicklejson.default._ + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" val expected = Outer(Inner(42, true, "horses"), "cats") @@ -29,30 +34,45 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { } it should "decode None from empty array body" in { + import UsingDefaultReaderWriters._ + import sttp.client4.upicklejson.default._ + val responseAs = asJson[Option[Inner]] runJsonResponseAs(responseAs)("[]").right.value shouldBe None } it should "decode Left(None) from upickle notation" in { + import UsingDefaultReaderWriters._ + import sttp.client4.upicklejson.default._ + val responseAs = asJson[Either[Option[Inner], Outer]] runJsonResponseAs(responseAs)("[0,[]]").right.value shouldBe Left(None) } it should "decode Right(None) from upickle notation" in { + import UsingDefaultReaderWriters._ + import sttp.client4.upicklejson.default._ + val responseAs = asJson[Either[Outer, Option[Inner]]] runJsonResponseAs(responseAs)("[1,[]]").right.value shouldBe Right(None) } it should "fail to decode from empty input" in { + import UsingDefaultReaderWriters._ + import sttp.client4.upicklejson.default._ + val responseAs = asJson[Inner] runJsonResponseAs(responseAs)("").left.value should matchPattern { case DeserializationException(_, _) => } } it should "fail to decode invalid json" in { + import UsingDefaultReaderWriters._ + import sttp.client4.upicklejson.default._ + val body = """not valid json""" val responseAs = asJson[Outer] @@ -62,6 +82,9 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { } it should "encode and decode back to the same thing" in { + import UsingDefaultReaderWriters._ + import sttp.client4.upicklejson.default._ + val outer = Outer(Inner(42, true, "horses"), "cats") val encoded = extractBody(basicRequest.body(outer)) @@ -71,6 +94,9 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { } it should "set the content type" in { + import UsingDefaultReaderWriters._ + import sttp.client4.upicklejson.default._ + val body = Outer(Inner(42, true, "horses"), "cats") val req = basicRequest.body(body) @@ -80,6 +106,9 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { } it should "only set the content type if it was not set earlier" in { + import UsingDefaultReaderWriters._ + import sttp.client4.upicklejson.default._ + val body = Outer(Inner(42, true, "horses"), "cats") val req = basicRequest.contentType("horses/cats").body(body) @@ -89,6 +118,9 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { } it should "serialize ujson.Obj using implicit upickleBodySerializer" in { + import UsingDefaultReaderWriters._ + import sttp.client4.upicklejson.default._ + val json: Obj = ujson.Obj( "location" -> "hometown", "bio" -> "Scala programmer" @@ -105,15 +137,33 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { actualContentType should be(expectedContentType) } - case class Inner(a: Int, b: Boolean, c: String) + it should "encode using a non-default reader/writer" in { + import UsingLegacyReaderWriters._ + object legacyUpickle extends SttpUpickleApi { + override val upickleApi: upickle.legacy.type = upickle.legacy + } + import legacyUpickle._ - object Inner { - implicit val reader: ReadWriter[Inner] = macroRW[Inner] + val body = Outer(Inner(42, true, "horses"), "cats") + val expected = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + + val req = basicRequest.body(body) + + extractBody(req) shouldBe expected } + case class Inner(a: Int, b: Boolean, c: String) case class Outer(foo: Inner, bar: String) - object Outer { + object UsingDefaultReaderWriters { + import upickle.default._ + implicit val reader: ReadWriter[Inner] = macroRW[Inner] + implicit val readWriter: ReadWriter[Outer] = macroRW[Outer] + } + + object UsingLegacyReaderWriters { + import upickle.legacy._ + implicit val reader: ReadWriter[Inner] = macroRW[Inner] implicit val readWriter: ReadWriter[Outer] = macroRW[Outer] }