From 39564570f3364bdea30237de4b42706682f02b6f Mon Sep 17 00:00:00 2001 From: adamw Date: Tue, 10 Dec 2024 15:28:57 +0100 Subject: [PATCH] Move .getRight, .mapLeft etc. to ResponseAs for better discoverability --- .../zio/AsyncHttpClientZioHttpTest.scala | 5 +- .../main/scala/sttp/client4/ResponseAs.scala | 92 ++++++++++++------- .../client4/testing/BackendStubTests.scala | 8 +- .../scala/sttp/client4/testing/HttpTest.scala | 4 +- .../sttp/client4/testing/SyncHttpTest.scala | 6 +- docs/json.md | 2 +- docs/responses/body.md | 2 +- .../sttp/client4/circe/SttpCirceApi.scala | 8 +- .../sttp/client4/json4s/SttpJson4sApi.scala | 10 +- .../jsoniter/SttpJsoniterJsonApi.scala | 8 +- .../client4/playJson/SttpPlayJsonApi.scala | 10 +- .../client4/sprayJson/SttpSprayJsonApi.scala | 10 +- .../client4/upicklejson/SttpUpickleApi.scala | 8 +- .../sttp/client4/ziojson/SttpZioJsonApi.scala | 8 +- .../sttp/client4/ziojson/SttpZioJsonApi.scala | 8 +- 15 files changed, 115 insertions(+), 74 deletions(-) diff --git a/async-http-client-backend/zio/src/test/scala/sttp/client4/asynchttpclient/zio/AsyncHttpClientZioHttpTest.scala b/async-http-client-backend/zio/src/test/scala/sttp/client4/asynchttpclient/zio/AsyncHttpClientZioHttpTest.scala index 2377124037..3102225a33 100644 --- a/async-http-client-backend/zio/src/test/scala/sttp/client4/asynchttpclient/zio/AsyncHttpClientZioHttpTest.scala +++ b/async-http-client-backend/zio/src/test/scala/sttp/client4/asynchttpclient/zio/AsyncHttpClientZioHttpTest.scala @@ -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 { diff --git a/core/src/main/scala/sttp/client4/ResponseAs.scala b/core/src/main/scala/sttp/client4/ResponseAs.scala index fa3a6bbfbc..6275fe608a 100644 --- a/core/src/main/scala/sttp/client4/ResponseAs.scala +++ b/core/src/main/scala/sttp/client4/ResponseAs.scala @@ -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. diff --git a/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala b/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala index 53a5a87acb..86b71f0be0 100644 --- a/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala +++ b/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala @@ -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)) } @@ -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")) } @@ -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(""))) ) diff --git a/core/src/test/scala/sttp/client4/testing/HttpTest.scala b/core/src/test/scala/sttp/client4/testing/HttpTest.scala index 90c2c62732..41744a8470 100644 --- a/core/src/test/scala/sttp/client4/testing/HttpTest.scala +++ b/core/src/test/scala/sttp/client4/testing/HttpTest.scala @@ -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))) @@ -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 => diff --git a/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala b/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala index 26f900263c..2d9fb61d58 100644 --- a/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala +++ b/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala @@ -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)) } @@ -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))) @@ -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)) } diff --git a/docs/json.md b/docs/json.md index dd83ee4aed..af7eb750f8 100644 --- a/docs/json.md +++ b/docs/json.md @@ -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 diff --git a/docs/responses/body.md b/docs/responses/body.md index 9a9c35192a..e29823299c 100644 --- a/docs/responses/body.md +++ b/docs/responses/body.md @@ -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") diff --git a/json/circe/src/main/scala/sttp/client4/circe/SttpCirceApi.scala b/json/circe/src/main/scala/sttp/client4/circe/SttpCirceApi.scala index a43016505a..f665814d88 100644 --- a/json/circe/src/main/scala/sttp/client4/circe/SttpCirceApi.scala +++ b/json/circe/src/main/scala/sttp/client4/circe/SttpCirceApi.scala @@ -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] = diff --git a/json/json4s/src/main/scala/sttp/client4/json4s/SttpJson4sApi.scala b/json/json4s/src/main/scala/sttp/client4/json4s/SttpJson4sApi.scala index 4e037e4b11..c9781781aa 100644 --- a/json/json4s/src/main/scala/sttp/client4/json4s/SttpJson4sApi.scala +++ b/json/json4s/src/main/scala/sttp/client4/json4s/SttpJson4sApi.scala @@ -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 diff --git a/json/jsoniter/src/main/scala/sttp/client4/jsoniter/SttpJsoniterJsonApi.scala b/json/jsoniter/src/main/scala/sttp/client4/jsoniter/SttpJsoniterJsonApi.scala index b2038472f2..31e88c897f 100644 --- a/json/jsoniter/src/main/scala/sttp/client4/jsoniter/SttpJsoniterJsonApi.scala +++ b/json/jsoniter/src/main/scala/sttp/client4/jsoniter/SttpJsoniterJsonApi.scala @@ -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) => diff --git a/json/play-json/src/main/scala/sttp/client4/playJson/SttpPlayJsonApi.scala b/json/play-json/src/main/scala/sttp/client4/playJson/SttpPlayJsonApi.scala index 38071b3072..e1d948d8cc 100644 --- a/json/play-json/src/main/scala/sttp/client4/playJson/SttpPlayJsonApi.scala +++ b/json/play-json/src/main/scala/sttp/client4/playJson/SttpPlayJsonApi.scala @@ -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 diff --git a/json/spray-json/src/main/scala/sttp/client4/sprayJson/SttpSprayJsonApi.scala b/json/spray-json/src/main/scala/sttp/client4/sprayJson/SttpSprayJsonApi.scala index 49e02d4ae2..fd226c0333 100644 --- a/json/spray-json/src/main/scala/sttp/client4/sprayJson/SttpSprayJsonApi.scala +++ b/json/spray-json/src/main/scala/sttp/client4/sprayJson/SttpSprayJsonApi.scala @@ -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 = 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 8984e5cfde..8059ee95c1 100644 --- a/json/upickle/src/main/scala/sttp/client4/upicklejson/SttpUpickleApi.scala +++ b/json/upickle/src/main/scala/sttp/client4/upicklejson/SttpUpickleApi.scala @@ -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) => diff --git a/json/zio-json/src/main/scala/sttp/client4/ziojson/SttpZioJsonApi.scala b/json/zio-json/src/main/scala/sttp/client4/ziojson/SttpZioJsonApi.scala index daff87e65d..151ad08dbc 100644 --- a/json/zio-json/src/main/scala/sttp/client4/ziojson/SttpZioJsonApi.scala +++ b/json/zio-json/src/main/scala/sttp/client4/ziojson/SttpZioJsonApi.scala @@ -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] = diff --git a/json/zio1-json/src/main/scala/sttp/client4/ziojson/SttpZioJsonApi.scala b/json/zio1-json/src/main/scala/sttp/client4/ziojson/SttpZioJsonApi.scala index daff87e65d..151ad08dbc 100644 --- a/json/zio1-json/src/main/scala/sttp/client4/ziojson/SttpZioJsonApi.scala +++ b/json/zio1-json/src/main/scala/sttp/client4/ziojson/SttpZioJsonApi.scala @@ -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] =