Skip to content

Commit

Permalink
Move .getRight, .mapLeft etc. to ResponseAs for better discoverability
Browse files Browse the repository at this point in the history
  • Loading branch information
adamw committed Dec 10, 2024
1 parent 492bba7 commit 3956457
Show file tree
Hide file tree
Showing 15 changed files with 115 additions and 74 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ package sttp.client4.asynchttpclient.zio
import sttp.client4._
import sttp.client4.asynchttpclient.AsyncHttpClientHttpTest
import sttp.client4.impl.zio.ZioTestBase
import sttp.client4.testing.{ConvertToFuture, HttpTest}
import zio.{Task, ZIO}
import sttp.client4.testing.ConvertToFuture
import zio.Task
import zio.ZIO

class AsyncHttpClientZioHttpTest extends AsyncHttpClientHttpTest[Task] with ZioTestBase {

Expand Down
92 changes: 58 additions & 34 deletions core/src/main/scala/sttp/client4/ResponseAs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -41,47 +41,71 @@ trait ResponseAsDelegate[+T, -R] {
* Target type as which the response will be read.
*/
case class ResponseAs[+T](delegate: GenericResponseAs[T, Any]) extends ResponseAsDelegate[T, Any] {

/** Applies the given function `f` to the deserialized value `T`. */
def map[T2](f: T => T2): ResponseAs[T2] = ResponseAs(delegate.mapWithMetadata { case (t, _) => f(t) })

/** Applies the given function `f` to the deserialized value `T`, and the response's metadata. */
def mapWithMetadata[T2](f: (T, ResponseMetadata) => T2): ResponseAs[T2] = ResponseAs(delegate.mapWithMetadata(f))

/** If the type to which the response body should be deserialized is an `Either[A, B]`, applies the given function `f`
* to `Left` values.
*
* Because of type inference, the type of `f` must be fully provided, e.g.
*
* ```
* asString.mapLeft((s: String) => new CustomHttpError(s))`
* ```
*/
def mapLeft[A, B, A2](f: A => A2)(implicit tIsEither: T <:< Either[A, B]): ResponseAs[Either[A2, B]] = map(
_.left.map(f)
)

/** If the type to which the response body should be deserialized is an `Either[A, B]`, applies the given function `f`
* to `Right` values.
*
* Because of type inference, the type of `f` must be fully provided, e.g.
*
* ```
* asString.mapRight((s: String) => parse(s))`
* ```
*/
def mapRight[A, B, B2](f: B => B2)(implicit tIsEither: T <:< Either[A, B]): ResponseAs[Either[A, B2]] = map(
_.right.map(f)
)

/** If the type to which the response body should be deserialized is an `Either[A, B]`:
* - in case of `A`, throws as an exception / returns a failed effect (wrapped with an [[HttpError]] if `A` is not
* yet an exception)
* - in case of `B`, returns the value directly
*/
def getRight[A, B](implicit tIsEither: T <:< Either[A, B]): ResponseAs[B] =
mapWithMetadata { case (t, meta) =>
(t: Either[A, B]) match {
case Left(a: Exception) => throw a
case Left(a) => throw HttpError(a, meta.code)
case Right(b) => b
}
}

/** If the type to which the response body should be deserialized is an `Either[ResponseException[HE, DE], B]`, either
* throws the [[DeserializationException]], returns the deserialized body from the [[HttpError]], or the deserialized
* successful body `B`.
*/
def getEither[HE, DE, B](implicit
tIsEither: T <:< Either[ResponseException[HE, DE], B]
): ResponseAs[Either[HE, B]] = map { t =>
(t: Either[ResponseException[HE, DE], B]) match {
case Left(HttpError(he, _)) => Left(he)
case Left(d: DeserializationException[_]) => throw d
case Right(b) => Right(b)
}
}

def showAs(s: String): ResponseAs[T] = ResponseAs(delegate.showAs(s))
}

object ResponseAs {
implicit class RichResponseAsEither[A, B](ra: ResponseAs[Either[A, B]]) {
def mapLeft[L2](f: A => L2): ResponseAs[Either[L2, B]] = ra.map(_.left.map(f))
def mapRight[R2](f: B => R2): ResponseAs[Either[A, R2]] = ra.map(_.right.map(f))

/** If the type to which the response body should be deserialized is an `Either[A, B]`:
* - in case of `A`, throws as an exception / returns a failed effect (wrapped with an [[HttpError]] if `A` is
* not yet an exception)
* - in case of `B`, returns the value directly
*/
def getRight: ResponseAs[B] =
ra.mapWithMetadata { case (t, meta) =>
t match {
case Left(a: Exception) => throw a
case Left(a) => throw HttpError(a, meta.code)
case Right(b) => b
}
}
}

implicit class RichResponseAsEitherResponseException[HE, DE, B](
ra: ResponseAs[Either[ResponseException[HE, DE], B]]
) {

/** If the type to which the response body should be deserialized is an `Either[ResponseException[HE, DE], B]`,
* either throws the [[DeserializationException]], returns the deserialized body from the [[HttpError]], or the
* deserialized successful body `B`.
*/
def getEither: ResponseAs[Either[HE, B]] =
ra.map {
case Left(HttpError(he, _)) => Left(he)
case Left(d: DeserializationException[_]) => throw d
case Right(b) => Right(b)
}
}

/** Returns a function, which maps `Left` values to [[HttpError]] s, and attempts to deserialize `Right` values using
* the given function, catching any exceptions and representing them as [[DeserializationException]] s.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class BackendStubTests extends AnyFlatSpec with Matchers with ScalaFutures {
val backend = testingStub
val r = basicRequest
.get(uri"http://example.org/d?p=v")
.response(asString.mapRight(_.toInt))
.response(asString.mapRight((_: String).toInt))
.send(backend)
r.body should be(Right(10))
}
Expand Down Expand Up @@ -253,7 +253,7 @@ class BackendStubTests extends AnyFlatSpec with Matchers with ScalaFutures {
val backend = BackendStub.synchronous.whenAnyRequest.thenRespond("1234")
basicRequest
.get(uri"http://example.org")
.response(asBoth(asString.mapRight(_.toInt), asStringAlways))
.response(asBoth(asString.mapRight((_: String).toInt), asStringAlways))
.send(backend)
.body shouldBe ((Right(1234), "1234"))
}
Expand Down Expand Up @@ -452,8 +452,8 @@ class BackendStubTests extends AnyFlatSpec with Matchers with ScalaFutures {
(s.getBytes(Utf8), asString(Utf8), Some(Right(s))),
(new ByteArrayInputStream(s.getBytes(Utf8)), asString(Utf8), Some(Right(s))),
(10, asString(Utf8), None),
("10", asString(Utf8).mapRight(_.toInt), Some(Right(10))),
(11, asString(Utf8).mapRight(_.toInt), None),
("10", asString(Utf8).mapRight((_: String).toInt), Some(Right(10))),
(11, asString(Utf8).mapRight((_: String).toInt), None),
((), asString(Utf8), Some(Right("")))
)

Expand Down
4 changes: 2 additions & 2 deletions core/src/test/scala/sttp/client4/testing/HttpTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ trait HttpTest[F[_]]
"as string with mapping using map" in {
postEcho
.body(testBody)
.response(asString.mapRight(_.length))
.response(asString.mapRight((_: String).length))
.send(backend)
.toFuture()
.map(response => response.body should be(Right(expectedPostEchoResponse.length)))
Expand Down Expand Up @@ -172,7 +172,7 @@ trait HttpTest[F[_]]
"as both string and mapped string" in {
postEcho
.body(testBody)
.response(asBoth(asStringAlways, asByteArray.mapRight(_.length)))
.response(asBoth(asStringAlways, asByteArray.mapRight((_: Array[Byte]).length)))
.send(backend)
.toFuture()
.map { response =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ trait SyncHttpTest
"as string with mapping using map" in {
val response = postEcho
.body(testBody)
.response(asString.mapRight(_.length))
.response(asString.mapRight((_: String).length))
.send(backend)
response.body should be(Right(expectedPostEchoResponse.length))
}
Expand Down Expand Up @@ -119,7 +119,7 @@ trait SyncHttpTest
"as both string and mapped string" in {
val response = postEcho
.body(testBody)
.response(asBoth(asStringAlways, asByteArray.mapRight(_.length)))
.response(asBoth(asStringAlways, asByteArray.mapRight((_: Array[Byte]).length)))
.send(backend)

response.body shouldBe ((expectedPostEchoResponse, Right(expectedPostEchoResponse.getBytes.length)))
Expand Down Expand Up @@ -367,7 +367,7 @@ trait SyncHttpTest
}

"redirect when redirects should be followed, and the response is parsed" in {
val resp = r2.response(asString.mapRight(_.toInt)).send(backend)
val resp = r2.response(asString.mapRight((_: String).toInt)).send(backend)
resp.code shouldBe StatusCode.Ok
resp.body should be(Right(r4response.toInt))
}
Expand Down
2 changes: 1 addition & 1 deletion docs/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Each integration is available as an import, which brings `asJson` methods into s
The following variants of `asJson` methods are available:

* `asJson(b: B)` - serializes the body so that it can be used as a request's body, e.g. using `basicRequest.body(asJson(myValue))`
* `asJson[B]` - specifies that the body should be deserialized to json, but only if the response is successful (2xx); shoud be used to specify how a response should be handled, e.g. `basicRequest.response(asJson[T])`
* `asJson[B]` - specifies that the body should be deserialized to json, but only if the response is successful (2xx); should be used to specify how a response should be handled, e.g. `basicRequest.response(asJson[T])`
* `asJsonAlways[B]` - specifies that the body should be deserialized to json, regardless of the status code
* `asJsonEither[E, B]` - specifies that the body should be deserialized to json, using different deserializers for error and successful (2xx) responses

Expand Down
2 changes: 1 addition & 1 deletion docs/responses/body.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ As an example, to read the response body as an int, the following response descr
```scala mdoc:compile-only
import sttp.client4._

val asInt: ResponseAs[Either[String, Int]] = asString.mapRight(_.toInt)
val asInt: ResponseAs[Either[String, Int]] = asString.mapRight((_: String).toInt)

basicRequest
.get(uri"http://example.com")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ trait SttpCirceApi {
*/
def asJsonEither[E: Decoder: IsOption, B: Decoder: IsOption]
: ResponseAs[Either[ResponseException[E, io.circe.Error], B]] =
asJson[B].mapLeft {
case HttpError(e, code) => deserializeJson[E].apply(e).fold(DeserializationException(e, _), HttpError(_, code))
case de @ DeserializationException(_, _) => de
asJson[B].mapLeft { (l: ResponseException[String, io.circe.Error]) =>
l match {
case HttpError(e, code) => deserializeJson[E].apply(e).fold(DeserializationException(e, _), HttpError(_, code))
case de @ DeserializationException(_, _) => de
}
}.showAsJsonEither

def deserializeJson[B: Decoder: IsOption]: String => Either[io.circe.Error, B] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ trait SttpJson4sApi {
formats: Formats,
serialization: Serialization
): ResponseAs[Either[ResponseException[E, Exception], B]] =
asJson[B].mapLeft {
case HttpError(e, code) =>
ResponseAs.deserializeCatchingExceptions(deserializeJson[E])(e).fold(identity, HttpError(_, code))
case de @ DeserializationException(_, _) => de
asJson[B].mapLeft { (l: ResponseException[String, Exception]) =>
l match {
case HttpError(e, code) =>
ResponseAs.deserializeCatchingExceptions(deserializeJson[E])(e).fold(identity, HttpError(_, code))
case de @ DeserializationException(_, _) => de
}
}.showAsJsonEither

def deserializeJson[B: Manifest](implicit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,11 @@ trait SttpJsoniterJsonApi {
E: JsonValueCodec: IsOption,
B: JsonValueCodec: IsOption
]: ResponseAs[Either[ResponseException[E, Exception], B]] =
asJson[B].mapLeft {
case de @ DeserializationException(_, _) => de
case HttpError(e, code) => deserializeJson[E].apply(e).fold(DeserializationException(e, _), HttpError(_, code))
asJson[B].mapLeft { (l: ResponseException[String, Exception]) =>
l match {
case de @ DeserializationException(_, _) => de
case HttpError(e, code) => deserializeJson[E].apply(e).fold(DeserializationException(e, _), HttpError(_, code))
}
}.showAsJsonEither

def deserializeJson[B: JsonValueCodec: IsOption]: String => Either[Exception, B] = { (s: String) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ trait SttpPlayJsonApi {
* - `Left(DeserializationException)` if there's an error during deserialization
*/
def asJsonEither[E: Reads: IsOption, B: Reads: IsOption]: ResponseAs[Either[ResponseException[E, JsError], B]] =
asJson[B].mapLeft {
case HttpError(e, code) =>
deserializeJson[E].apply(e).fold(DeserializationException(e, _), HttpError(_, code))
case de @ DeserializationException(_, _) => de
asJson[B].mapLeft { (l: ResponseException[String, JsError]) =>
l match {
case HttpError(e, code) =>
deserializeJson[E].apply(e).fold(DeserializationException(e, _), HttpError(_, code))
case de @ DeserializationException(_, _) => de
}
}.showAsJsonEither

// Note: None of the play-json utilities attempt to catch invalid
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ trait SttpSprayJsonApi {
*/
def asJsonEither[E: JsonReader: IsOption, B: JsonReader: IsOption]
: ResponseAs[Either[ResponseException[E, Exception], B]] =
asJson[B].mapLeft {
case HttpError(e, code) =>
ResponseAs.deserializeCatchingExceptions(deserializeJson[E])(e).fold(identity, HttpError(_, code))
case de @ DeserializationException(_, _) => de
asJson[B].mapLeft { (l: ResponseException[String, Exception]) =>
l match {
case HttpError(e, code) =>
ResponseAs.deserializeCatchingExceptions(deserializeJson[E])(e).fold(identity, HttpError(_, code))
case de @ DeserializationException(_, _) => de
}
}.showAsJsonEither

def deserializeJson[B: JsonReader: IsOption]: String => B =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ trait SttpUpickleApi {
*/
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
asJson[B].mapLeft { (l: ResponseException[String, Exception]) =>
l match {
case HttpError(e, code) => deserializeJson[E].apply(e).fold(DeserializationException(e, _), HttpError(_, code))
case de @ DeserializationException(_, _) => de
}
}.showAsJsonEither

def deserializeJson[B: upickleApi.Reader: IsOption]: String => Either[Exception, B] = { (s: String) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@ trait SttpZioJsonApi extends SttpZioJsonApiExtensions {
*/
def asJsonEither[E: JsonDecoder: IsOption, B: JsonDecoder: IsOption]
: ResponseAs[Either[ResponseException[E, String], B]] =
asJson[B].mapLeft {
case HttpError(e, code) => deserializeJson[E].apply(e).fold(DeserializationException(e, _), HttpError(_, code))
case de @ DeserializationException(_, _) => de
asJson[B].mapLeft { (l: ResponseException[String, String]) =>
l match {
case HttpError(e, code) => deserializeJson[E].apply(e).fold(DeserializationException(e, _), HttpError(_, code))
case de @ DeserializationException(_, _) => de
}
}.showAsJsonEither

def deserializeJson[B: JsonDecoder: IsOption]: String => Either[String, B] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@ trait SttpZioJsonApi extends SttpZioJsonApiExtensions {
*/
def asJsonEither[E: JsonDecoder: IsOption, B: JsonDecoder: IsOption]
: ResponseAs[Either[ResponseException[E, String], B]] =
asJson[B].mapLeft {
case HttpError(e, code) => deserializeJson[E].apply(e).fold(DeserializationException(e, _), HttpError(_, code))
case de @ DeserializationException(_, _) => de
asJson[B].mapLeft { (l: ResponseException[String, String]) =>
l match {
case HttpError(e, code) => deserializeJson[E].apply(e).fold(DeserializationException(e, _), HttpError(_, code))
case de @ DeserializationException(_, _) => de
}
}.showAsJsonEither

def deserializeJson[B: JsonDecoder: IsOption]: String => Either[String, B] =
Expand Down

0 comments on commit 3956457

Please sign in to comment.