Skip to content

Commit

Permalink
added support tethys json (#2129)
Browse files Browse the repository at this point in the history
Co-authored-by: s.chernykh <s.chernykh@tinkoff.ru>
  • Loading branch information
chernykhSG and s.chernykh authored Apr 15, 2024
1 parent e97f73f commit d1ef328
Show file tree
Hide file tree
Showing 5 changed files with 248 additions and 0 deletions.
19 changes: 19 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -212,6 +214,7 @@ lazy val allAggregates = projectsWithOptionalNative ++
sprayJson.projectRefs ++
play29Json.projectRefs ++
playJson.projectRefs ++
tethysJson.projectRefs ++
prometheusBackend.projectRefs ++
openTelemetryMetricsBackend.projectRefs ++
openTelemetryTracingZioBackend.projectRefs ++
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package sttp.client4

package object tethysJson extends SttpTethysApi
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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")
}

}

0 comments on commit d1ef328

Please sign in to comment.