From d1ef3286af62e10d535eaa20f3bd16ef788c0867 Mon Sep 17 00:00:00 2001 From: chernykhSG <42139447+chernykhSG@users.noreply.github.com> Date: Mon, 15 Apr 2024 19:19:27 +0500 Subject: [PATCH] added support tethys json (#2129) Co-authored-by: s.chernykh --- build.sbt | 19 +++ .../client4/tethysJson/SttpTethysApi.scala | 43 +++++ .../sttp/client4/tethysJson/package.scala | 3 + .../tethysJson/BackendStubTethysTests.scala | 27 +++ .../sttp/client4/tethysJson/TethysTests.scala | 156 ++++++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 json/tethys-json/src/main/scala/sttp/client4/tethysJson/SttpTethysApi.scala create mode 100644 json/tethys-json/src/main/scala/sttp/client4/tethysJson/package.scala create mode 100644 json/tethys-json/src/test/scala/sttp/client4/tethysJson/BackendStubTethysTests.scala create mode 100644 json/tethys-json/src/test/scala/sttp/client4/tethysJson/TethysTests.scala diff --git a/build.sbt b/build.sbt index 693f850832..ba6666b266 100644 --- a/build.sbt +++ b/build.sbt @@ -163,6 +163,8 @@ val resilience4jVersion = "2.2.0" val http4s_ce2_version = "0.22.15" val http4s_ce3_version = "0.23.26" +val tethysVersion = "0.28.3" + val openTelemetryVersion = "1.37.0" val compileAndTest = "compile->compile;test->test" @@ -212,6 +214,7 @@ lazy val allAggregates = projectsWithOptionalNative ++ sprayJson.projectRefs ++ play29Json.projectRefs ++ playJson.projectRefs ++ + tethysJson.projectRefs ++ prometheusBackend.projectRefs ++ openTelemetryMetricsBackend.projectRefs ++ openTelemetryTracingZioBackend.projectRefs ++ @@ -843,6 +846,22 @@ lazy val zio1Json = (projectMatrix in file("json/zio1-json")) .jsPlatform(scalaVersions = scala2 ++ scala3, settings = commonJsSettings) .dependsOn(core, jsonCommon) +lazy val tethysJson = (projectMatrix in file("json/tethys-json")) + .settings( + name := "tethys-json", + libraryDependencies ++= Seq( + "com.tethys-json" %% "tethys-core" % tethysVersion, + "com.tethys-json" %% "tethys-jackson213" % tethysVersion, + "com.tethys-json" %% "tethys-derivation" % tethysVersion + ), + scalaTest + ) + .jvmPlatform( + scalaVersions = scala2 ++ scala3, + settings = commonJvmSettings + ) + .dependsOn(core, jsonCommon) + lazy val upickle = (projectMatrix in file("json/upickle")) .settings( name := "upickle", diff --git a/json/tethys-json/src/main/scala/sttp/client4/tethysJson/SttpTethysApi.scala b/json/tethys-json/src/main/scala/sttp/client4/tethysJson/SttpTethysApi.scala new file mode 100644 index 0000000000..a646b56fc6 --- /dev/null +++ b/json/tethys-json/src/main/scala/sttp/client4/tethysJson/SttpTethysApi.scala @@ -0,0 +1,43 @@ +package sttp.client4.tethysJson + +import sttp.client4._ +import sttp.client4.internal.Utf8 +import sttp.client4.json.RichResponseAs +import sttp.model.MediaType +import tethys._ +import tethys.readers.ReaderError +import tethys.readers.tokens.TokenIteratorProducer +import tethys.writers.tokens.TokenWriterProducer + +trait SttpTethysApi { + + implicit def tethysBodySerializer[B](implicit + jsonWriter: JsonWriter[B], + tokenWriterProducer: TokenWriterProducer + ): BodySerializer[B] = + b => StringBody(b.asJson, 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: JsonReader: IsOption](implicit + producer: TokenIteratorProducer + ): ResponseAs[Either[ResponseException[String, ReaderError], 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: JsonReader: IsOption](implicit + producer: TokenIteratorProducer + ): ResponseAs[Either[DeserializationException[ReaderError], B]] = + asStringAlways.map(ResponseAs.deserializeWithError(deserializeJson)).showAsJsonAlways + + private def deserializeJson[B: JsonReader: IsOption](implicit + producer: TokenIteratorProducer + ): String => Either[ReaderError, B] = + JsonInput.sanitize[B].andThen(_.jsonAs[B]) +} diff --git a/json/tethys-json/src/main/scala/sttp/client4/tethysJson/package.scala b/json/tethys-json/src/main/scala/sttp/client4/tethysJson/package.scala new file mode 100644 index 0000000000..890682f5d5 --- /dev/null +++ b/json/tethys-json/src/main/scala/sttp/client4/tethysJson/package.scala @@ -0,0 +1,3 @@ +package sttp.client4 + +package object tethysJson extends SttpTethysApi diff --git a/json/tethys-json/src/test/scala/sttp/client4/tethysJson/BackendStubTethysTests.scala b/json/tethys-json/src/test/scala/sttp/client4/tethysJson/BackendStubTethysTests.scala new file mode 100644 index 0000000000..8c4099c701 --- /dev/null +++ b/json/tethys-json/src/test/scala/sttp/client4/tethysJson/BackendStubTethysTests.scala @@ -0,0 +1,27 @@ +package sttp.client4.tethysJson + +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.client4._ +import sttp.client4.testing.SyncBackendStub +import tethys.derivation.semiauto.{jsonReader, jsonWriter} +import tethys.jackson.jacksonTokenIteratorProducer +import tethys.{JsonReader, JsonWriter} + +class BackendStubTethysTests extends AnyFlatSpec with Matchers with ScalaFutures { + + it should "deserialize to json using a string stub" in { + val backend = SyncBackendStub.whenAnyRequest.thenRespond("""{"name": "John"}""") + val r = basicRequest.get(uri"http://example.org").response(asJson[Person]).send(backend) + r.is200 should be(true) + r.body should be(Right(Person("John"))) + } + + case class Person(name: String) + + object Person { + implicit val encoder: JsonWriter[Person] = jsonWriter + implicit val decoder: JsonReader[Person] = jsonReader + } +} diff --git a/json/tethys-json/src/test/scala/sttp/client4/tethysJson/TethysTests.scala b/json/tethys-json/src/test/scala/sttp/client4/tethysJson/TethysTests.scala new file mode 100644 index 0000000000..adf34a56a8 --- /dev/null +++ b/json/tethys-json/src/test/scala/sttp/client4/tethysJson/TethysTests.scala @@ -0,0 +1,156 @@ +package sttp.client4.tethysJson + +import org.scalatest.EitherValues +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.client4._ +import sttp.client4.internal._ +import sttp.model._ +import tethys.derivation.semiauto.{jsonReader, jsonWriter} +import tethys.jackson.{jacksonTokenIteratorProducer, jacksonTokenWriterProducer} +import tethys.readers.tokens.TokenIterator +import tethys.readers.{FieldName, ReaderError} +import tethys.{JsonReader, JsonWriter} + +import scala.util.{Failure, Success, Try} + +class TethysTests extends AnyFlatSpec with Matchers with EitherValues { + + "The tethys 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 = basicRequest.body(body) + + extractBody(req, MediaType.ApplicationJson) 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 "decode None from empty body" in { + val responseAs = asJson[Option[Inner]] + + runJsonResponseAs(responseAs)("").right.value shouldBe None + } + + it should "decode Left(None) from empty body" in { + import EitherDecoders._ + val responseAs = asJson[Either[Option[Inner], Outer]] + + runJsonResponseAs(responseAs)("").right.value shouldBe Left(None) + } + + it should "decode Right(None) from empty body" in { + import EitherDecoders._ + val responseAs = asJson[Either[Outer, Option[Inner]]] + + runJsonResponseAs(responseAs)("").right.value shouldBe Right(None) + } + + it should "fail to decode from empty input" in { + val responseAs = asJson[Inner] + + runJsonResponseAs(responseAs)("") should matchPattern { case Left(DeserializationException("", _: ReaderError)) => + } + } + + it should "fail to decode invalid json" in { + val body = """not valid json""" + + val responseAs = asJson[Outer] + + val Left(DeserializationException(original, _)) = runJsonResponseAs(responseAs)(body) + original shouldBe body + } + + it should "encode and decode back to the same thing" in { + val outer = Outer(Inner(42, true, "horses"), "cats") + + val encoded = extractBody(basicRequest.body(outer), MediaType.ApplicationJson) + 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 = basicRequest.body(body) + + val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") + + ct shouldBe Some(MediaType.ApplicationJson.copy(charset = Some(Utf8)).toString) + } + + it should "only set the content type if it was not set earlier" in { + val body = Outer(Inner(42, true, "horses"), "cats") + val req = basicRequest.contentType("horses/cats").body(body) + + val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") + + ct shouldBe Some("horses/cats") + } + + case class Inner(a: Int, b: Boolean, c: String) + + object Inner { + implicit val encoder: JsonWriter[Inner] = jsonWriter + implicit val decoder: JsonReader[Inner] = jsonReader + } + + case class Outer(foo: Inner, bar: String) + + object Outer { + implicit val encoder: JsonWriter[Outer] = jsonWriter + implicit val decoder: JsonReader[Outer] = jsonReader + } + + object EitherDecoders { + implicit def decoder[L: JsonReader, R: JsonReader]: JsonReader[Either[L, R]] = new JsonReader[Either[L, R]] { + + override def read(it: TokenIterator)(implicit fieldName: FieldName): Either[L, R] = { + val newIt = it.collectExpression() + ( + Try(implicitly[JsonReader[L]].read(newIt.copy())), + Try(implicitly[JsonReader[R]].read(newIt)) + ) match { + case (Success(value), Failure(_)) => Left(value) + case (Failure(_), Success(value)) => Right(value) + case (Success(_), Success(_)) => + ReaderError.wrongJson("Both succeeded.") + case (Failure(exceptionLeft), Failure(exceptionRight)) => + ReaderError.wrongJson( + s"Either parse exception. Both parsers failed: ${exceptionLeft.getMessage} and ${exceptionRight.getMessage}" + ) + } + } + } + } + + def extractBody[T](request: PartialRequest[T], mediaType: MediaType): String = + request.body match { + case StringBody(body, "utf-8", `mediaType`) => + body + case wrongBody => + fail(s"Request body does not serialize to correct StringBody: $wrongBody") + } + + def runJsonResponseAs[A](responseAs: ResponseAs[A]): String => A = + responseAs.delegate match { + case responseAs: MappedResponseAs[_, A, Nothing] => + responseAs.raw match { + case ResponseAsByteArray => + s => responseAs.g(s.getBytes(Utf8), ResponseMetadata(StatusCode.Ok, "", Nil)) + case _ => + fail("MappedResponseAs does not wrap a ResponseAsByteArray") + } + case _ => fail("ResponseAs is not a MappedResponseAs") + } + +}