From 39564570f3364bdea30237de4b42706682f02b6f Mon Sep 17 00:00:00 2001 From: adamw Date: Tue, 10 Dec 2024 15:28:57 +0100 Subject: [PATCH 01/11] 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] = From 9ab3a2981c47c4a1f5afb5a7545a54099a323ec8 Mon Sep 17 00:00:00 2001 From: adamw Date: Tue, 10 Dec 2024 15:40:07 +0100 Subject: [PATCH 02/11] Comment --- core/src/main/scala/sttp/client4/ResponseException.scala | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/core/src/main/scala/sttp/client4/ResponseException.scala b/core/src/main/scala/sttp/client4/ResponseException.scala index cfb2329a8a..9282389674 100644 --- a/core/src/main/scala/sttp/client4/ResponseException.scala +++ b/core/src/main/scala/sttp/client4/ResponseException.scala @@ -4,6 +4,15 @@ import sttp.model.StatusCode import scala.annotation.tailrec +/** Used to represent errors, that might occur when handling the response body. Either: + * - a [[HttpError]], when the response code is different than the expected one; desrialization is not attempted + * - a [[DeserializationException]], when there's an error during deserialization + * + * @tparam HE + * The type of the body to which the response is read, when the resposne code is different than the expected one + * @tparam DE + * A deserialization-library-specific error type, describing the deserialization error in more detail + */ sealed abstract class ResponseException[+HE, +DE](error: String) extends Exception(error) case class HttpError[+HE](body: HE, statusCode: StatusCode) extends ResponseException[HE, Nothing](s"statusCode: $statusCode, response: $body") From 7d4ff31f2e2fb1db4a613b6b624bf50b39637da1 Mon Sep 17 00:00:00 2001 From: adamw Date: Tue, 10 Dec 2024 16:02:50 +0100 Subject: [PATCH 03/11] Rename .getRight to .orFail --- core/src/main/scala/sttp/client4/ResponseAs.scala | 8 ++++---- core/src/main/scala/sttp/client4/request.scala | 4 ++-- docs/json.md | 2 +- docs/responses/body.md | 6 +++--- docs/responses/exceptions.md | 2 +- ...xCirce.scala => GetAndParseJsonOrFailMonixCirce.scala} | 4 ++-- generated-docs/out/examples.md | 2 +- .../opentelemetry/OpenTelemetryMetricsBackendTest.scala | 4 ++-- .../sttp/client4/prometheus/PrometheusBackendTest.scala | 4 ++-- 9 files changed, 18 insertions(+), 18 deletions(-) rename examples-ce2/src/main/scala/sttp/client4/examples/{GetAndParseJsonGetRightMonixCirce.scala => GetAndParseJsonOrFailMonixCirce.scala} (86%) diff --git a/core/src/main/scala/sttp/client4/ResponseAs.scala b/core/src/main/scala/sttp/client4/ResponseAs.scala index 6275fe608a..e8ccf35542 100644 --- a/core/src/main/scala/sttp/client4/ResponseAs.scala +++ b/core/src/main/scala/sttp/client4/ResponseAs.scala @@ -79,7 +79,7 @@ case class ResponseAs[+T](delegate: GenericResponseAs[T, Any]) extends ResponseA * yet an exception) * - in case of `B`, returns the value directly */ - def getRight[A, B](implicit tIsEither: T <:< Either[A, B]): ResponseAs[B] = + def orFail[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 @@ -89,10 +89,10 @@ case class ResponseAs[+T](delegate: GenericResponseAs[T, Any]) extends ResponseA } /** 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`. + * throws /returns a failed effect with the [[DeserializationException]], returns the deserialized body from the + * [[HttpError]], or the deserialized successful body `B`. */ - def getEither[HE, DE, B](implicit + def orFailDeserialization[HE, DE, B](implicit tIsEither: T <:< Either[ResponseException[HE, DE], B] ): ResponseAs[Either[HE, B]] = map { t => (t: Either[ResponseException[HE, DE], B]) match { diff --git a/core/src/main/scala/sttp/client4/request.scala b/core/src/main/scala/sttp/client4/request.scala index 1cd627d8b1..7129cca216 100644 --- a/core/src/main/scala/sttp/client4/request.scala +++ b/core/src/main/scala/sttp/client4/request.scala @@ -171,13 +171,13 @@ case class Request[T]( object Request { implicit class RichRequestTEither[A, B](r: Request[Either[A, B]]) { def mapResponseRight[B2](f: B => B2): Request[Either[A, B2]] = r.copy(response = r.response.mapRight(f)) - def responseGetRight: Request[B] = r.copy(response = r.response.getRight) + def responseGetRight: Request[B] = r.copy(response = r.response.orFail) } implicit class RichRequestTEitherResponseException[HE, DE, B]( r: Request[Either[ResponseException[HE, DE], B]] ) { - def responseGetEither: Request[Either[HE, B]] = r.copy(response = r.response.getEither) + def responseGetEither: Request[Either[HE, B]] = r.copy(response = r.response.orFailDeserialization) } } diff --git a/docs/json.md b/docs/json.md index af7eb750f8..e1004b1954 100644 --- a/docs/json.md +++ b/docs/json.md @@ -22,7 +22,7 @@ def asJsonAlways[B]: ResponseAs[Either[DeserializationException[Exception], B]] def asJsonEither[E, B]: ResponseAs[Either[ResponseException[E, Exception], B]] = ??? ``` -The response specifications can be further refined using `.getRight` and `.getEither`, see [response body specifications](responses/body.md). +The response specifications can be further refined using `.orFail` and `.orFailDeserialization`, see [response body specifications](responses/body.md). Following data class will be used through the next few examples: diff --git a/docs/responses/body.md b/docs/responses/body.md index e29823299c..b14a14eff7 100644 --- a/docs/responses/body.md +++ b/docs/responses/body.md @@ -79,12 +79,12 @@ basicRequest.response(asFile(someFile)) ## Failing when the response code is not 2xx -Sometimes it's convenient to get a failed effect (or an exception thrown) when the response status code is not successful. In such cases, the response specification can be modified using the `.getRight` combinator: +Sometimes it's convenient to get a failed effect (or an exception thrown) when the response status code is not successful. In such cases, the response specification can be modified using the `.orFail` combinator: ```scala mdoc:compile-only import sttp.client4._ -basicRequest.response(asString.getRight): PartialRequest[String] +basicRequest.response(asString.orFail): PartialRequest[String] ``` The combinator works in all cases where the response body is specified to be deserialized as an `Either`. If the left is already an exception, it will be thrown unchanged. Otherwise, the left-value will be wrapped in an `HttpError`. @@ -92,7 +92,7 @@ The combinator works in all cases where the response body is specified to be des ```eval_rst .. note:: - While both ``asStringAlways`` and ``asString.getRight`` have the type ``ResponseAs[String, Any]``, they are different. The first will return the response body as a string always, regardless of the responses' status code. The second will return a failed effect / throw a ``HttpError`` exception for non-2xx status codes, and the string as body only for 2xx status codes. + While both ``asStringAlways`` and ``asString.orFail`` have the type ``ResponseAs[String, Any]``, they are different. The first will return the response body as a string always, regardless of the responses' status code. The second will return a failed effect / throw a ``HttpError`` exception for non-2xx status codes, and the string as body only for 2xx status codes. ``` There's also a variant of the combinator, `.getEither`, which can be used to extract typed errors and fail the effect if there's a deserialization error. diff --git a/docs/responses/exceptions.md b/docs/responses/exceptions.md index ef10a8700b..03a21b0957 100644 --- a/docs/responses/exceptions.md +++ b/docs/responses/exceptions.md @@ -28,7 +28,7 @@ import sttp.client4._ def asJson[T]: ResponseAs[Either[ResponseException[String, Exception], T]] = ??? ``` -There are also the `.getRight` and `.getEither` methods on eligible response specifications, which convert http errors or deserialization exceptions as failed effects. +There are also the `.orFail` and `.orFailDeserialization` methods on eligible response specifications, which convert http errors or deserialization exceptions as failed effects. ## Possible outcomes diff --git a/examples-ce2/src/main/scala/sttp/client4/examples/GetAndParseJsonGetRightMonixCirce.scala b/examples-ce2/src/main/scala/sttp/client4/examples/GetAndParseJsonOrFailMonixCirce.scala similarity index 86% rename from examples-ce2/src/main/scala/sttp/client4/examples/GetAndParseJsonGetRightMonixCirce.scala rename to examples-ce2/src/main/scala/sttp/client4/examples/GetAndParseJsonOrFailMonixCirce.scala index 2f373d1bf3..fedbe735e4 100644 --- a/examples-ce2/src/main/scala/sttp/client4/examples/GetAndParseJsonGetRightMonixCirce.scala +++ b/examples-ce2/src/main/scala/sttp/client4/examples/GetAndParseJsonOrFailMonixCirce.scala @@ -5,14 +5,14 @@ import sttp.client4._ import sttp.client4.httpclient.monix.HttpClientMonixBackend import sttp.client4.circe._ -object GetAndParseJsonGetRightMonixCirce extends App { +object GetAndParseJsonOrFailMonixCirce extends App { import monix.execution.Scheduler.Implicits.global case class HttpBinResponse(origin: String, headers: Map[String, String]) val request: Request[HttpBinResponse] = basicRequest .get(uri"https://httpbin.org/get") - .response(asJson[HttpBinResponse].getRight) + .response(asJson[HttpBinResponse].orFail) HttpClientMonixBackend .resource() diff --git a/generated-docs/out/examples.md b/generated-docs/out/examples.md index 563d198141..23b92e046f 100644 --- a/generated-docs/out/examples.md +++ b/generated-docs/out/examples.md @@ -85,7 +85,7 @@ libraryDependencies ++= List( Example code: ```eval_rst -.. literalinclude:: ../../examples-ce2/src/main/scala/sttp/client4/examples/GetAndParseJsonGetRightMonixCirce.scala +.. literalinclude:: ../../examples-ce2/src/main/scala/sttp/client4/examples/GetAndParseJsonOrFailMonixCirce.scala :language: scala ``` diff --git a/observability/opentelemetry-metrics-backend/src/test/scala/sttp/client4/opentelemetry/OpenTelemetryMetricsBackendTest.scala b/observability/opentelemetry-metrics-backend/src/test/scala/sttp/client4/opentelemetry/OpenTelemetryMetricsBackendTest.scala index e7a83c531f..fa4cf385f5 100644 --- a/observability/opentelemetry-metrics-backend/src/test/scala/sttp/client4/opentelemetry/OpenTelemetryMetricsBackendTest.scala +++ b/observability/opentelemetry-metrics-backend/src/test/scala/sttp/client4/opentelemetry/OpenTelemetryMetricsBackendTest.scala @@ -192,7 +192,7 @@ class OpenTelemetryMetricsBackendTest extends AnyFlatSpec with Matchers with Opt assertThrows[SttpClientException] { basicRequest .get(uri"http://127.0.0.1/foo") - .response(asString.getRight) + .response(asString.orFail) .send(backend) } @@ -231,7 +231,7 @@ class OpenTelemetryMetricsBackendTest extends AnyFlatSpec with Matchers with Opt // when basicRequest .get(uri"http://127.0.0.1/foo") - .response(asString.getRight) + .response(asString.orFail) .send(backend) // then diff --git a/observability/prometheus-backend/src/test/scala/sttp/client4/prometheus/PrometheusBackendTest.scala b/observability/prometheus-backend/src/test/scala/sttp/client4/prometheus/PrometheusBackendTest.scala index 81cf153cc7..df451b319f 100644 --- a/observability/prometheus-backend/src/test/scala/sttp/client4/prometheus/PrometheusBackendTest.scala +++ b/observability/prometheus-backend/src/test/scala/sttp/client4/prometheus/PrometheusBackendTest.scala @@ -350,7 +350,7 @@ class PrometheusBackendTest assertThrows[SttpClientException] { basicRequest .get(uri"http://127.0.0.1/foo") - .response(asString.getRight) + .response(asString.orFail) .send(backend) } @@ -401,7 +401,7 @@ class PrometheusBackendTest // when basicRequest .get(uri"http://127.0.0.1/foo") - .response(asString.getRight) + .response(asString.orFail) .send(backend) // then From f28dcea427ac86dd9ddedfea5850eb1244be8644 Mon Sep 17 00:00:00 2001 From: adamw Date: Tue, 10 Dec 2024 16:03:05 +0100 Subject: [PATCH 04/11] Lefover --- .../src/main/scala/sttp/client4/examples/LogRequestsSlf4j.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/src/main/scala/sttp/client4/examples/LogRequestsSlf4j.scala b/examples/src/main/scala/sttp/client4/examples/LogRequestsSlf4j.scala index 9c5d73f937..34d57d5412 100644 --- a/examples/src/main/scala/sttp/client4/examples/LogRequestsSlf4j.scala +++ b/examples/src/main/scala/sttp/client4/examples/LogRequestsSlf4j.scala @@ -12,7 +12,7 @@ object LogRequestsSlf4j extends App { val request = basicRequest .get(uri"https://httpbin.org/get") - .response(asJson[HttpBinResponse].getRight) + .response(asJson[HttpBinResponse].orFail) val backend: SyncBackend = Slf4jLoggingBackend( From 1557c5f6adb3da96517ab22f22d1e1761c85ed9a Mon Sep 17 00:00:00 2001 From: adamw Date: Wed, 11 Dec 2024 09:10:39 +0100 Subject: [PATCH 05/11] Remove specialize response-mapping request functions --- core/src/main/scala/sttp/client4/request.scala | 14 +------------- .../sttp/client4/testing/BackendStubTests.scala | 3 +-- .../test/scala/sttp/client4/testing/HttpTest.scala | 4 ++-- .../sttp/client4/testing/SyncHttpTest.scala | 2 +- 4 files changed, 5 insertions(+), 18 deletions(-) diff --git a/core/src/main/scala/sttp/client4/request.scala b/core/src/main/scala/sttp/client4/request.scala index 7129cca216..76620308fe 100644 --- a/core/src/main/scala/sttp/client4/request.scala +++ b/core/src/main/scala/sttp/client4/request.scala @@ -28,6 +28,7 @@ import sttp.attributes.AttributeMap trait GenericRequest[+T, -R] extends RequestBuilder[GenericRequest[T, R]] with RequestMetadata { def body: GenericRequestBody[R] def response: ResponseAsDelegate[T, R] + def mapResponse[T2](f: T => T2): GenericRequest[T2, R] def toCurl: String = ToCurlConverter(this) @@ -168,19 +169,6 @@ case class Request[T]( def send(backend: SyncBackend): Response[T] = backend.send(this) } -object Request { - implicit class RichRequestTEither[A, B](r: Request[Either[A, B]]) { - def mapResponseRight[B2](f: B => B2): Request[Either[A, B2]] = r.copy(response = r.response.mapRight(f)) - def responseGetRight: Request[B] = r.copy(response = r.response.orFail) - } - - implicit class RichRequestTEitherResponseException[HE, DE, B]( - r: Request[Either[ResponseException[HE, DE], B]] - ) { - def responseGetEither: Request[Either[HE, B]] = r.copy(response = r.response.orFailDeserialization) - } -} - // /** Describes an HTTP request, along with a description of how the response body should be handled. Either the request diff --git a/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala b/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala index 86b71f0be0..c93111ad84 100644 --- a/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala +++ b/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala @@ -144,8 +144,7 @@ class BackendStubTests extends AnyFlatSpec with Matchers with ScalaFutures { val result = basicRequest .get(uri"http://example.org") - .mapResponseRight(_.toInt) - .mapResponseRight(_ * 2) + .response(asString.mapRight((_: String).toInt).mapRight((_: Int) * 2)) .send(backend) result.body should be(Right(20)) diff --git a/core/src/test/scala/sttp/client4/testing/HttpTest.scala b/core/src/test/scala/sttp/client4/testing/HttpTest.scala index 41744a8470..09b297e7cf 100644 --- a/core/src/test/scala/sttp/client4/testing/HttpTest.scala +++ b/core/src/test/scala/sttp/client4/testing/HttpTest.scala @@ -92,7 +92,7 @@ trait HttpTest[F[_]] "as string with mapping using mapResponse" in { postEcho .body(testBody) - .mapResponseRight(_.length) + .response(asString.mapRight((_: String).length)) .send(backend) .toFuture() .map(response => response.body should be(Right(expectedPostEchoResponse.length))) @@ -572,7 +572,7 @@ trait HttpTest[F[_]] } "redirect when redirects should be followed, and the response is parsed" in { - r2.response(asString).mapResponseRight(_.toInt).send(backend).toFuture().map { resp => + r2.response(asString.mapRight((_: String).toInt)).send(backend).toFuture().map { resp => resp.code shouldBe StatusCode.Ok resp.body shouldBe Right(r4response.toInt) } diff --git a/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala b/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala index 2d9fb61d58..612066d212 100644 --- a/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala +++ b/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala @@ -61,7 +61,7 @@ trait SyncHttpTest "as string with mapping using mapResponse" in { val response = postEcho .body(testBody) - .mapResponseRight(_.length) + .response(asString.mapRight((_: String).length)) .send(backend) response.body should be(Right(expectedPostEchoResponse.length)) } From bdb8d005e90ea66f19d91eeb4a7a368586a5a3f0 Mon Sep 17 00:00:00 2001 From: adamw Date: Wed, 11 Dec 2024 10:38:35 +0100 Subject: [PATCH 06/11] Docs --- .../main/scala/sttp/client4/ResponseAs.scala | 30 +++-- .../sttp/client4/ResponseException.scala | 29 ++++- .../src/main/scala/sttp/client4/SttpApi.scala | 109 +++++++++++++++--- docs/responses/basics.md | 5 +- docs/responses/body.md | 28 ++--- 5 files changed, 149 insertions(+), 52 deletions(-) diff --git a/core/src/main/scala/sttp/client4/ResponseAs.scala b/core/src/main/scala/sttp/client4/ResponseAs.scala index e8ccf35542..aad0b84843 100644 --- a/core/src/main/scala/sttp/client4/ResponseAs.scala +++ b/core/src/main/scala/sttp/client4/ResponseAs.scala @@ -13,15 +13,17 @@ import scala.util.{Failure, Success, Try} /** Describes how the response body of a request should be handled. A number of `as` helper methods are available * as part of [[SttpApi]] and when importing `sttp.client4._`. These methods yield specific implementations of this * trait, which can then be set on a [[Request]], [[StreamRequest]], [[WebSocketRequest]] or - * [[WebSocketStreamRequest]], depending on the response type. + * [[WebSocketStreamRequest]]. * * @tparam T - * Target type as which the response will be read. + * Target type as which the response will be deserialized. * @tparam R * The backend capabilities required by the response description. This might be `Any` (no requirements), * [[sttp.capabilities.Effect]] (the backend must support the given effect type), [[sttp.capabilities.Streams]] (the * ability to send and receive streaming bodies) or [[sttp.capabilities.WebSockets]] (the ability to handle websocket * requests). + * @see + * [[ResponseAs]] */ trait ResponseAsDelegate[+T, -R] { def delegate: GenericResponseAs[T, R] @@ -35,10 +37,10 @@ trait ResponseAsDelegate[+T, -R] { * status code. Responses can also be handled depending on the response metadata. Finally, two response body * descriptions can be combined (with some restrictions). * - * A number of `as` helper methods are available as part of [[SttpApi]] and when importing `sttp.client4._`. + * A number of `as` helper methods are available as part of [[SttpApi]] and when importing `sttp.client4.*`. * * @tparam T - * Target type as which the response will be read. + * Target type as which the response will be read/deserialized. */ case class ResponseAs[+T](delegate: GenericResponseAs[T, Any]) extends ResponseAsDelegate[T, Any] { @@ -89,7 +91,7 @@ case class ResponseAs[+T](delegate: GenericResponseAs[T, Any]) extends ResponseA } /** If the type to which the response body should be deserialized is an `Either[ResponseException[HE, DE], B]`, either - * throws /returns a failed effect with the [[DeserializationException]], returns the deserialized body from the + * throws / returns a failed effect with the [[DeserializationException]], returns the deserialized body from the * [[HttpError]], or the deserialized successful body `B`. */ def orFailDeserialization[HE, DE, B](implicit @@ -180,12 +182,14 @@ object ResponseAs { * [[ResponseMetadata]], that is the headers and status code. * * A number of `asStream[Type]` helper methods are available as part of [[SttpApi]] and when importing - * `sttp.client4._`. + * `sttp.client4.*`. * * @tparam T - * Target type as which the response will be read. + * Target type as which the response will be read/deserialized. * @tparam S * The type of stream, used to receive the response body bodies. + * @see + * [[ResponseAs]] */ case class StreamResponseAs[+T, S](delegate: GenericResponseAs[T, S]) extends ResponseAsDelegate[T, S] { def map[T2](f: T => T2): StreamResponseAs[T2, S] = @@ -202,10 +206,12 @@ case class StreamResponseAs[+T, S](delegate: GenericResponseAs[T, S]) extends Re * [[ResponseMetadata]], that is the headers and status code. Responses can also be handled depending on the response * metadata. * - * A number of `asWebSocket` helper methods are available as part of [[SttpApi]] and when importing `sttp.client4._`. + * A number of `asWebSocket` helper methods are available as part of [[SttpApi]] and when importing `sttp.client4.*`. * * @tparam T - * Target type as which the response will be read. + * Target type as which the response will be read/deserialized. + * @see + * [[ResponseAs]] */ case class WebSocketResponseAs[F[_], +T](delegate: GenericResponseAs[T, Effect[F] with WebSockets]) extends ResponseAsDelegate[T, Effect[F] with WebSockets] { @@ -223,10 +229,12 @@ case class WebSocketResponseAs[F[_], +T](delegate: GenericResponseAs[T, Effect[F * [[ResponseMetadata]], that is the headers and status code. Responses can also be handled depending on the response * metadata. * - * A number of `asWebSocket` helper methods are available as part of [[SttpApi]] and when importing `sttp.client4._`. + * A number of `asWebSocket` helper methods are available as part of [[SttpApi]] and when importing `sttp.client4.*`. * * @tparam T - * Target type as which the response will be read. + * Target type as which the response will be read/deserialized. + * @see + * [[ResponseAs]] */ case class WebSocketStreamResponseAs[+T, S](delegate: GenericResponseAs[T, S with WebSockets]) extends ResponseAsDelegate[T, S with WebSockets] { diff --git a/core/src/main/scala/sttp/client4/ResponseException.scala b/core/src/main/scala/sttp/client4/ResponseException.scala index 9282389674..b8153ae052 100644 --- a/core/src/main/scala/sttp/client4/ResponseException.scala +++ b/core/src/main/scala/sttp/client4/ResponseException.scala @@ -4,18 +4,37 @@ import sttp.model.StatusCode import scala.annotation.tailrec -/** Used to represent errors, that might occur when handling the response body. Either: - * - a [[HttpError]], when the response code is different than the expected one; desrialization is not attempted - * - a [[DeserializationException]], when there's an error during deserialization +/** Used to represent errors, that might occur when handling the response body. Typically, this type is used as the + * left-side of a top-level either (where the right-side represents a successfull request and deserialization). + * + * A response exception can itself either be one of two cases: + * - a [[HttpError]], when the response code is other than 2xx (or whatever is considered "success" by the response + * handling description); the body is deserialized to `HE` + * - a [[DeserializationException]], when there's an error during deserialization (this might include both + * deserialization exceptions of the success and error branches) * * @tparam HE - * The type of the body to which the response is read, when the resposne code is different than the expected one + * The type of the body to which the response is deserialized, when the response code is different than success + * (typically 2xx status code). * @tparam DE - * A deserialization-library-specific error type, describing the deserialization error in more detail + * A deserialization-library-specific error type, describing the deserialization error in more detail. */ sealed abstract class ResponseException[+HE, +DE](error: String) extends Exception(error) + +/** Represents an http error, where the response was received successfully, but the status code is other than the + * expected one (typically other than 2xx). + * + * @tparam HE + * The type of the body to which the error response is deserialized. + */ case class HttpError[+HE](body: HE, statusCode: StatusCode) extends ResponseException[HE, Nothing](s"statusCode: $statusCode, response: $body") + +/** Represents an error that occured during deserialization of `body`. + * + * @tparam DE + * A deserialization-library-specific error type, describing the deserialization error in more detail. + */ case class DeserializationException[+DE: ShowError](body: String, error: DE) extends ResponseException[Nothing, DE](implicitly[ShowError[DE]].show(error)) diff --git a/core/src/main/scala/sttp/client4/SttpApi.scala b/core/src/main/scala/sttp/client4/SttpApi.scala index 35df0200f9..b19cecc05f 100644 --- a/core/src/main/scala/sttp/client4/SttpApi.scala +++ b/core/src/main/scala/sttp/client4/SttpApi.scala @@ -38,11 +38,17 @@ trait SttpApi extends SttpExtensions with UriInterpolator { AttributeMap.Empty ) - /** A starting request, with the following modification comparing to [[emptyRequest]]: `Accept-Encoding` is set to - * `gzip, deflate` (compression/decompression is handled automatically by the library). + /** A starting request, with the `Accept-Encoding` header set to `gzip, deflate` (compression/decompression is handled + * automatically by the library). * * Reads the response body as an `Either[String, String]`, where `Left` is used if the status code is non-2xx, and * `Right` otherwise. + * + * @see + * [[emptyRequest]] for a starting request which has no headers set + * @see + * [[quickRequest]] for a starting request which always reads the response body as a [[String]], without the + * [[Either]] wrapper */ val basicRequest: PartialRequest[Either[String, String]] = emptyRequest.acceptEncoding("gzip, deflate") @@ -50,17 +56,24 @@ trait SttpApi extends SttpExtensions with UriInterpolator { /** A starting request which always reads the response body as a string, regardless of the status code. */ val quickRequest: PartialRequest[String] = basicRequest.response(asStringAlways) - // response specifications + // response descriptions + /** Ignores (discards) the response. */ def ignore: ResponseAs[Unit] = ResponseAs(IgnoreResponse) - /** Use the `utf-8` charset by default, unless specified otherwise in the response headers. */ + /** Reads the response as an `Either[String, String]`, where `Left` is used if the status code is non-2xx, and `Right` + * otherwise. Uses the `utf-8` charset by default, unless specified otherwise in the response headers. + */ def asString: ResponseAs[Either[String, String]] = asString(Utf8) - /** Use the `utf-8` charset by default, unless specified otherwise in the response headers. */ + /** Reads the response as a `String`, regardless of the status code. Use the `utf-8` charset by default, unless + * specified otherwise in the response headers. + */ def asStringAlways: ResponseAs[String] = asStringAlways(Utf8) - /** Use the given charset by default, unless specified otherwise in the response headers. */ + /** Reads the response as an `Either[String, String]`, where `Left` is used if the status code is non-2xx, and `Right` + * otherwise. Uses the given charset by default, unless specified otherwise in the response headers. + */ def asString(charset: String): ResponseAs[Either[String, String]] = asStringAlways(charset) .mapWithMetadata { (s, m) => @@ -68,6 +81,9 @@ trait SttpApi extends SttpExtensions with UriInterpolator { } .showAs("either(as string, as string)") + /** Reads the response as a `String`, regardless of the status code. Uses the given charset by default, unless + * specified otherwise in the response headers. + */ def asStringAlways(charset: String): ResponseAs[String] = asByteArrayAlways .mapWithMetadata { (bytes, metadata) => @@ -77,21 +93,35 @@ trait SttpApi extends SttpExtensions with UriInterpolator { } .showAs("as string") + /** Reads the response as either a string (for non-2xx responses), or othweise as an array of bytes (without any + * processing). The entire response is loaded into memory. + */ def asByteArray: ResponseAs[Either[String, Array[Byte]]] = asEither(asStringAlways, asByteArrayAlways) + /** Reads the response as an array of bytes, without any processing, regardless of the status code. The entire + * response is loaded into memory. + */ def asByteArrayAlways: ResponseAs[Array[Byte]] = ResponseAs(ResponseAsByteArray) - /** Use the `utf-8` charset by default, unless specified otherwise in the response headers. */ + /** Deserializes the response as either a string (for non-2xx responses), or otherwise as form parameters. Uses the + * `utf-8` charset by default, unless specified otherwise in the response headers. + */ def asParams: ResponseAs[Either[String, Seq[(String, String)]]] = asParams(Utf8) - /** Use the `utf-8` charset by default, unless specified otherwise in the response headers. */ + /** Deserializes the response as form parameters, regardless of the status code. Uses the `utf-8` charset by default, + * unless specified otherwise in the response headers. + */ def asParamsAlways: ResponseAs[Seq[(String, String)]] = asParamsAlways(Utf8) - /** Use the given charset by default, unless specified otherwise in the response headers. */ + /** Deserializes the response as either a string (for non-2xx responses), or otherwise as form parameters. Uses the + * given charset by default, unless specified otherwise in the response headers. + */ def asParams(charset: String): ResponseAs[Either[String, Seq[(String, String)]]] = asEither(asStringAlways, asParamsAlways(charset)).showAs("either(as string, as params)") - /** Use the given charset by default, unless specified otherwise in the response headers. */ + /** Deserializes the response as form parameters, regardless of the status code. Uses the given charset by default, + * unless specified otherwise in the response headers. + */ def asParamsAlways(charset: String): ResponseAs[Seq[(String, String)]] = { val charset2 = sanitizeCharset(charset) asStringAlways(charset2).map(GenericResponseAs.parseParams(_, charset2)).showAs("as params") @@ -99,17 +129,21 @@ trait SttpApi extends SttpExtensions with UriInterpolator { private[client4] def asSttpFile(file: SttpFile): ResponseAs[SttpFile] = ResponseAs(ResponseAsFile(file)) + /** Uses the [[ResponseAs]] description that matches the condition (using the response's metadata). + * + * This allows using different response description basing on the status code, for example. If none of the conditions + * match, the default response handling description is used. + */ def fromMetadata[T](default: ResponseAs[T], conditions: ConditionalResponseAs[ResponseAs[T]]*): ResponseAs[T] = ResponseAs(ResponseAsFromMetadata(conditions.map(_.map(_.delegate)).toList, default.delegate)) - /** Uses the `onSuccess` response specification for successful responses (2xx), and the `onError` specification - * otherwise. + /** Uses the `onSuccess` response description for successful responses (2xx), and the `onError` description otherwise. */ def asEither[A, B](onError: ResponseAs[A], onSuccess: ResponseAs[B]): ResponseAs[Either[A, B]] = fromMetadata(onError.map(Left(_)), ConditionalResponseAs(_.isSuccess, onSuccess.map(Right(_)))) .showAs(s"either(${onError.show}, ${onSuccess.show})") - /** Use both `l` and `r` to read the response body. Neither response specifications may use streaming or web sockets. + /** Uses both `l` and `r` to handle the response body. Neither response descriptions may use streaming or web sockets. */ def asBoth[A, B](l: ResponseAs[A], r: ResponseAs[B]): ResponseAs[(A, B)] = asBothOption(l, r) @@ -119,8 +153,8 @@ trait SttpApi extends SttpExtensions with UriInterpolator { } .showAs(s"(${l.show}, ${r.show})") - /** Use `l` to read the response body. If the raw body value which is used by `l` is replayable (a file or byte - * array), also use `r` to read the response body. Otherwise ignore `r` (if the raw body is a stream). + /** Uses `l` to handle the response body. If the raw body value which is used by `l` is replayable (a file or byte + * array), also uses `r` to read the response body. Otherwise ignores `r` (if the raw body is a stream). */ def asBothOption[A, B](l: ResponseAs[A], r: ResponseAs[B]): ResponseAs[(A, Option[B])] = ResponseAs(ResponseAsBoth(l.delegate, r.delegate)) @@ -208,44 +242,81 @@ trait SttpApi extends SttpExtensions with UriInterpolator { // stream response specifications + /** Handles the response body by either reading a string (for non-2xx responses), or otherwise providing a stream with + * the response's data to `f`. The stream is always closed after `f` completes. + * + * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. + */ def asStream[F[_], T, S](s: Streams[S])( f: s.BinaryStream => F[T] ): StreamResponseAs[Either[String, T], S with Effect[F]] = asEither(asStringAlways, asStreamAlways(s)(f)) + /** Handles the response body by either reading a string (for non-2xx responses), or otherwise providing a stream with + * the response's data, along with the response metadata, to `f`. The stream is always closed after `f` completes. + * + * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. + */ def asStreamWithMetadata[F[_], T, S](s: Streams[S])( f: (s.BinaryStream, ResponseMetadata) => F[T] ): StreamResponseAs[Either[String, T], S with Effect[F]] = asEither(asStringAlways, asStreamAlwaysWithMetadata(s)(f)) + /** Handles the response body by providing a stream with the response's data to `f`, regardless of the status code. + * The stream is always closed after `f` completes. + * + * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. + */ def asStreamAlways[F[_], T, S](s: Streams[S])(f: s.BinaryStream => F[T]): StreamResponseAs[T, S with Effect[F]] = asStreamAlwaysWithMetadata(s)((s, _) => f(s)) + /** Handles the response body by providing a stream with the response's data, along with the response metadata, to + * `f`, regardless of the status code. The stream is always closed after `f` completes. + * + * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. + */ def asStreamAlwaysWithMetadata[F[_], T, S](s: Streams[S])( f: (s.BinaryStream, ResponseMetadata) => F[T] ): StreamResponseAs[T, S with Effect[F]] = StreamResponseAs(ResponseAsStream(s)(f)) + /** Handles the response body by either reading a string (for non-2xx responses), or otherwise returning a stream with + * the response's data. It's the responsibility of the caller to consume & close the stream. + * + * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. + */ def asStreamUnsafe[S](s: Streams[S]): StreamResponseAs[Either[String, s.BinaryStream], S] = asEither(asStringAlways, asStreamAlwaysUnsafe(s)) + /** Handles the response body by returning a stream with the response's data, regardless of the status code. It's the + * responsibility of the caller to consume & close the stream. + * + * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. + */ def asStreamAlwaysUnsafe[S](s: Streams[S]): StreamResponseAs[s.BinaryStream, S] = StreamResponseAs(ResponseAsStreamUnsafe(s)) + /** Uses the [[StreamResponseAs]] description that matches the condition (using the response's metadata). The + * conditional response descriptions might include handling the response as a non-blocking, asynchronous stream. + * + * This allows using different response description basing on the status code, for example. If none of the conditions + * match, the default response handling description is used. + */ def fromMetadata[T, S]( default: ResponseAs[T], conditions: ConditionalResponseAs[StreamResponseAs[T, S]]* ): StreamResponseAs[T, S] = StreamResponseAs[T, S](ResponseAsFromMetadata(conditions.map(_.map(_.delegate)).toList, default.delegate)) - /** Uses the `onSuccess` response specification for successful responses (2xx), and the `onError` specification - * otherwise. + /** Uses the `onSuccess` response description for successful responses (2xx), and the `onError` description otherwise. + * + * The sucessful response description might include handling the response as a non-blocking, asynchronous stream. */ def asEither[A, B, S](onError: ResponseAs[A], onSuccess: StreamResponseAs[B, S]): StreamResponseAs[Either[A, B], S] = fromMetadata[Either[A, B], S](onError.map(Left(_)), ConditionalResponseAs(_.isSuccess, onSuccess.map(Right(_)))) .showAs(s"either(${onError.show}, ${onSuccess.show})") - /** Use `l` to read the response body. If the raw body value which is used by `l` is replayable (a file or byte - * array), also use `r` to read the response body. Otherwise ignore `r` (if the raw body is a stream). + /** Uses `l` to handle the response body. If the raw body value which is used by `l` is replayable (a file or byte + * array), also uses `r` to read the response body. Otherwise ignores `r` (if the raw body is a stream). */ def asBothOption[A, B, S](l: StreamResponseAs[A, S], r: ResponseAs[B]): StreamResponseAs[(A, Option[B]), S] = StreamResponseAs[(A, Option[B]), S](ResponseAsBoth(l.delegate, r.delegate)) diff --git a/docs/responses/basics.md b/docs/responses/basics.md index 2d88fdbf50..7bed1da569 100644 --- a/docs/responses/basics.md +++ b/docs/responses/basics.md @@ -1,6 +1,6 @@ # Responses -Responses are represented as instances of the case class `Response[T]`, where `T` is the type of the response body. When sending a request, an effect containing the response will be returned. For example, for asynchronous backends, we can get a `Future[Response[T]]`, while for the default synchronous backend, the wrapper will be a no-op, `Identity`, which is the same as no wrapper at all. +Responses are represented as instances of the case class `Response[T]`, where `T` is the type of the response body. When sending a request, the response might be return directly, or wrapped with an effect containing the response. For example, for asynchronous backends, we can get a `Future[Response[T]]`, while for the default synchronous backend, there's no wrapper at all. If sending the request fails, either due to client or connection errors, an exception will be thrown (synchronous backends), or a failed effect will be returned (e.g. a failed future). @@ -50,5 +50,4 @@ If the cookies from a response should be set without changes on the request, thi ## Obtaining the response body -The response body can be obtained through the `.body: T` property. `T` is the body deserialized as specified in the request description - see -the next section on [response body specifications](body.md). +The response body can be obtained through the `.body: T` property. `T` is the type of the body to which it's deserialized, as specified in the request description - see the next section on [response body specifications](body.md). diff --git a/docs/responses/body.md b/docs/responses/body.md index b14a14eff7..5d860cfd53 100644 --- a/docs/responses/body.md +++ b/docs/responses/body.md @@ -1,4 +1,4 @@ -# Response body specification +# Response body descriptions By default, the received response body will be read as a `Either[String, String]`, using the encoding specified in the `Content-Type` response header (and if none is specified, using `UTF-8`). This is of course configurable: response bodies can be ignored, deserialized into custom types, received as a stream or saved to a file. @@ -7,9 +7,9 @@ The default `response.body` will be a: * `Left(errorMessage)` if the request is successful, but response code is not 2xx. * `Right(body)` if the request is successful, and the response code is 2xx. -How the response body will be read is part of the request description, as already when sending the request, the backend needs to know what to do with the response. The type to which the response body should be deserialized is the second type parameter of `RequestT`, and stored in the request definition as the `request.response: ResponseAs[T, R]` property. +How the response body will be read is part of the request description, as already when sending the request, the backend needs to know what to do with the response. The type to which the response body should be deserialized is a type parameter of `Request`. It's used in request definition in the `request.response: ResponseAs[T]` property. -## Basic response specifications +## Basic response descriptions To conveniently specify how to deserialize the response body, a number of `as(...Type...)` methods are available. They can be used to provide a value for the request description's `response` property: @@ -21,7 +21,7 @@ basicRequest.response(asByteArray) When the above request is completely described and sent, it will result in a `Response[Either[String, Array[Byte]]]` (where the left and right correspond to non-2xx and 2xx status codes, as above). -Other possible response descriptions include (the first type parameter of `ResponseAs` specifies the type returned as the response body, the second - the capabilities that the backend is required to support to send the request; `Any` means no special requirements): +Other possible response descriptions include: ```scala mdoc:compile-only import sttp.client4._ @@ -45,9 +45,9 @@ def asPath(path: Path): ResponseAs[Either[String, Path]] = ??? def asPathAlways(path: Path): ResponseAs[Path] = ??? def asEither[A, B](onError: ResponseAs[A], - onSuccess: ResponseAs[B]): ResponseAs[Either[A, B]] = ??? + onSuccess: ResponseAs[B]): ResponseAs[Either[A, B]] = ??? def fromMetadata[T](default: ResponseAs[T], - conditions: ConditionalResponseAs[T]*): ResponseAs[T] = ??? + conditions: ConditionalResponseAs[T]*): ResponseAs[T] = ??? def asBoth[A, B](l: ResponseAs[A], r: ResponseAs[B]): ResponseAs[(A, B)] = ??? def asBothOption[A, B](l: ResponseAs[A], r: ResponseAs[B]): ResponseAs[(A, Option[B])] = ??? @@ -79,7 +79,7 @@ basicRequest.response(asFile(someFile)) ## Failing when the response code is not 2xx -Sometimes it's convenient to get a failed effect (or an exception thrown) when the response status code is not successful. In such cases, the response specification can be modified using the `.orFail` combinator: +Sometimes it's convenient to get a failed effect (or an exception thrown) when the response status code is not successful. In such cases, the response description can be modified using the `.orFail` combinator: ```scala mdoc:compile-only import sttp.client4._ @@ -117,7 +117,7 @@ basicRequest .response(asInt) ``` -To integrate with a third-party JSON library, and always parse the response as a json (regardless of the status code): +To integrate with a third-party JSON library, and always parse the response as JSON (regardless of the status code): ```scala mdoc:compile-only import sttp.client4._ @@ -170,6 +170,7 @@ import io.circe._ import io.circe.generic.auto._ case class MyModel(p1: Int) + sealed trait MyErrorModel case class Conflict(message: String) extends MyErrorModel case class BadRequest(message: String) extends MyErrorModel @@ -184,13 +185,13 @@ basicRequest ### Blocking streaming (InputStream) -Some backends on the JVM support receiving the response body as a `java.io.InputStream`. This is possible either using the safe `asInputStream(f)` specification, where the entire stream has to be consumed by the provided `f` function, and is then closed by sttp client. Alternatively, there's `asInputStreamUnsafe`, which returns the stream directly to the user, who is then responsible for closing it. +Some backends on the JVM support receiving the response body as a `java.io.InputStream`. This is possible either using the safe `asInputStream(f)` description, where the entire stream has to be consumed by the provided `f` function, and is then closed by sttp client. Alternatively, there's `asInputStreamUnsafe`, which returns the stream directly to the user, who is then responsible for closing it. -`InputStream`s have two major limitations. First, they operate on the relatively low `byte`-level. The consumer is responsible for any decoding, chunking etc. Moreover, all `InputStream` operations are blocking, hence using them in a non-virtual-threads environment may severely limit performance. If you're using a functional effect system, see below on how to use non-blocking streams instead. +`InputStream`s have two limitations. First, they operate on the relatively low `byte`-level. The consumer is responsible for any decoding, chunking etc. Moreover, all `InputStream` operations are blocking, hence using them in a non-virtual-threads environment may severely limit performance. If you're using a functional effect system, see below on how to use non-blocking streams instead. ### Non-blocking streaming -If the backend used supports non-blocking streaming (see "Supported stream type" in the [backends summary](../backends/summary.md)), it's possible to receive responses as a stream. This can be described using the following methods: +If the backend used supports non-blocking, asynchronous streaming (see "Supported stream type" in the [backends summary](../backends/summary.md)), it's possible to receive responses as a stream. This can be described using the following methods: ```scala mdoc:compile-only import sttp.capabilities.{Effect, Streams} @@ -218,7 +219,7 @@ def asStreamUnsafeAlways[S](s: Streams[S]): StreamResponseAs[s.BinaryStream, S] = ??? ``` -All of these specifications require the streaming capability to be passed as a parameter, an implementation of `Streams[S]`. This is used to determine the type of binary streams that are supported, and to require that the backend used to send the request supports the given type of streams. These implementations are provided by the backend implementations, e.g. `AkkaStreams` or `Fs2Streams[F]`. +All of these descriptions require the streaming capability to be passed as a parameter, an implementation of `Streams[S]`. This is used to determine the type of binary streams that are supported, and to require that the backend used to send the request supports the given type of streams. These implementations are provided by the backend implementations, e.g. `AkkaStreams` or `Fs2Streams[F]`. The first two "safe" variants pass the response stream to the user-provided function, which should consume the stream entirely. Once the effect returned by the function is complete, the backend will try to close the stream (if the streaming implementation allows it). @@ -243,5 +244,4 @@ val response: Future[Response[Either[String, Source[ByteString, Any]]]] = .send(backend) ``` -It's also possible to parse the received stream as server-sent events (SSE), using an implementation-specific mapping -function. Refer to the documentation for particular backends for more details. +It's also possible to parse the received stream as server-sent events (SSE), using an implementation-specific mapping function. Refer to the documentation for particular backends for more details. From 45647848031dd83b97c30d864b87d4cd9549a771 Mon Sep 17 00:00:00 2001 From: adamw Date: Wed, 11 Dec 2024 10:44:23 +0100 Subject: [PATCH 07/11] Revert "Remove specialize response-mapping request functions" This reverts commit 1557c5f6adb3da96517ab22f22d1e1761c85ed9a. --- core/src/main/scala/sttp/client4/request.scala | 14 +++++++++++++- .../sttp/client4/testing/BackendStubTests.scala | 3 ++- .../test/scala/sttp/client4/testing/HttpTest.scala | 4 ++-- .../sttp/client4/testing/SyncHttpTest.scala | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/core/src/main/scala/sttp/client4/request.scala b/core/src/main/scala/sttp/client4/request.scala index 76620308fe..7129cca216 100644 --- a/core/src/main/scala/sttp/client4/request.scala +++ b/core/src/main/scala/sttp/client4/request.scala @@ -28,7 +28,6 @@ import sttp.attributes.AttributeMap trait GenericRequest[+T, -R] extends RequestBuilder[GenericRequest[T, R]] with RequestMetadata { def body: GenericRequestBody[R] def response: ResponseAsDelegate[T, R] - def mapResponse[T2](f: T => T2): GenericRequest[T2, R] def toCurl: String = ToCurlConverter(this) @@ -169,6 +168,19 @@ case class Request[T]( def send(backend: SyncBackend): Response[T] = backend.send(this) } +object Request { + implicit class RichRequestTEither[A, B](r: Request[Either[A, B]]) { + def mapResponseRight[B2](f: B => B2): Request[Either[A, B2]] = r.copy(response = r.response.mapRight(f)) + def responseGetRight: Request[B] = r.copy(response = r.response.orFail) + } + + implicit class RichRequestTEitherResponseException[HE, DE, B]( + r: Request[Either[ResponseException[HE, DE], B]] + ) { + def responseGetEither: Request[Either[HE, B]] = r.copy(response = r.response.orFailDeserialization) + } +} + // /** Describes an HTTP request, along with a description of how the response body should be handled. Either the request diff --git a/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala b/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala index c93111ad84..86b71f0be0 100644 --- a/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala +++ b/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala @@ -144,7 +144,8 @@ class BackendStubTests extends AnyFlatSpec with Matchers with ScalaFutures { val result = basicRequest .get(uri"http://example.org") - .response(asString.mapRight((_: String).toInt).mapRight((_: Int) * 2)) + .mapResponseRight(_.toInt) + .mapResponseRight(_ * 2) .send(backend) result.body should be(Right(20)) diff --git a/core/src/test/scala/sttp/client4/testing/HttpTest.scala b/core/src/test/scala/sttp/client4/testing/HttpTest.scala index 09b297e7cf..41744a8470 100644 --- a/core/src/test/scala/sttp/client4/testing/HttpTest.scala +++ b/core/src/test/scala/sttp/client4/testing/HttpTest.scala @@ -92,7 +92,7 @@ trait HttpTest[F[_]] "as string with mapping using mapResponse" in { postEcho .body(testBody) - .response(asString.mapRight((_: String).length)) + .mapResponseRight(_.length) .send(backend) .toFuture() .map(response => response.body should be(Right(expectedPostEchoResponse.length))) @@ -572,7 +572,7 @@ trait HttpTest[F[_]] } "redirect when redirects should be followed, and the response is parsed" in { - r2.response(asString.mapRight((_: String).toInt)).send(backend).toFuture().map { resp => + r2.response(asString).mapResponseRight(_.toInt).send(backend).toFuture().map { resp => resp.code shouldBe StatusCode.Ok resp.body shouldBe Right(r4response.toInt) } diff --git a/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala b/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala index 612066d212..2d9fb61d58 100644 --- a/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala +++ b/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala @@ -61,7 +61,7 @@ trait SyncHttpTest "as string with mapping using mapResponse" in { val response = postEcho .body(testBody) - .response(asString.mapRight((_: String).length)) + .mapResponseRight(_.length) .send(backend) response.body should be(Right(expectedPostEchoResponse.length)) } From ae08044da3202f26282f6f1bbf85fb1de1ec16a7 Mon Sep 17 00:00:00 2001 From: adamw Date: Wed, 11 Dec 2024 10:54:17 +0100 Subject: [PATCH 08/11] Keep .mapResponseRight in Request only --- .../src/main/scala/sttp/client4/request.scala | 28 ++++++++++--------- .../client4/testing/BackendStubTests.scala | 4 +-- .../scala/sttp/client4/testing/HttpTest.scala | 4 +-- .../sttp/client4/testing/SyncHttpTest.scala | 2 +- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/core/src/main/scala/sttp/client4/request.scala b/core/src/main/scala/sttp/client4/request.scala index 7129cca216..df7730ca43 100644 --- a/core/src/main/scala/sttp/client4/request.scala +++ b/core/src/main/scala/sttp/client4/request.scala @@ -28,6 +28,8 @@ import sttp.attributes.AttributeMap trait GenericRequest[+T, -R] extends RequestBuilder[GenericRequest[T, R]] with RequestMetadata { def body: GenericRequestBody[R] def response: ResponseAsDelegate[T, R] + + /** Applies the given function `f` to the deserialized value `T`. */ def mapResponse[T2](f: T => T2): GenericRequest[T2, R] def toCurl: String = ToCurlConverter(this) @@ -124,6 +126,19 @@ case class Request[T]( def mapResponse[T2](f: T => T2): Request[T2] = response(response.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 mapResponseRight[A, B, B2](f: B => B2)(implicit tIsEither: T <:< Either[A, B]): Request[Either[A, B2]] = response( + response.mapRight(f) + ) + /** Specifies that this is a WebSocket request. A [[WebSocketBackend]] will be required to send this request. */ def response[F[_], T2](ra: WebSocketResponseAs[F, T2]): WebSocketRequest[F, T2] = WebSocketRequest(method, uri, body, headers, ra, options, attributes) @@ -168,19 +183,6 @@ case class Request[T]( def send(backend: SyncBackend): Response[T] = backend.send(this) } -object Request { - implicit class RichRequestTEither[A, B](r: Request[Either[A, B]]) { - def mapResponseRight[B2](f: B => B2): Request[Either[A, B2]] = r.copy(response = r.response.mapRight(f)) - def responseGetRight: Request[B] = r.copy(response = r.response.orFail) - } - - implicit class RichRequestTEitherResponseException[HE, DE, B]( - r: Request[Either[ResponseException[HE, DE], B]] - ) { - def responseGetEither: Request[Either[HE, B]] = r.copy(response = r.response.orFailDeserialization) - } -} - // /** Describes an HTTP request, along with a description of how the response body should be handled. Either the request diff --git a/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala b/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala index 86b71f0be0..7f91e7badc 100644 --- a/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala +++ b/core/src/test/scala/sttp/client4/testing/BackendStubTests.scala @@ -144,8 +144,8 @@ class BackendStubTests extends AnyFlatSpec with Matchers with ScalaFutures { val result = basicRequest .get(uri"http://example.org") - .mapResponseRight(_.toInt) - .mapResponseRight(_ * 2) + .mapResponseRight((_: String).toInt) + .mapResponseRight((_: Int) * 2) .send(backend) result.body should be(Right(20)) diff --git a/core/src/test/scala/sttp/client4/testing/HttpTest.scala b/core/src/test/scala/sttp/client4/testing/HttpTest.scala index 41744a8470..cb0b771c40 100644 --- a/core/src/test/scala/sttp/client4/testing/HttpTest.scala +++ b/core/src/test/scala/sttp/client4/testing/HttpTest.scala @@ -92,7 +92,7 @@ trait HttpTest[F[_]] "as string with mapping using mapResponse" in { postEcho .body(testBody) - .mapResponseRight(_.length) + .mapResponseRight((_: String).length) .send(backend) .toFuture() .map(response => response.body should be(Right(expectedPostEchoResponse.length))) @@ -572,7 +572,7 @@ trait HttpTest[F[_]] } "redirect when redirects should be followed, and the response is parsed" in { - r2.response(asString).mapResponseRight(_.toInt).send(backend).toFuture().map { resp => + r2.response(asString).mapResponseRight((_: String).toInt).send(backend).toFuture().map { resp => resp.code shouldBe StatusCode.Ok resp.body shouldBe Right(r4response.toInt) } diff --git a/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala b/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala index 2d9fb61d58..7742345639 100644 --- a/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala +++ b/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala @@ -61,7 +61,7 @@ trait SyncHttpTest "as string with mapping using mapResponse" in { val response = postEcho .body(testBody) - .mapResponseRight(_.length) + .mapResponseRight((_: String).length) .send(backend) response.body should be(Right(expectedPostEchoResponse.length)) } From 62ead1fe0f195154b96740c642854fd03f61850b Mon Sep 17 00:00:00 2001 From: adamw Date: Wed, 11 Dec 2024 13:51:24 +0100 Subject: [PATCH 09/11] Add as...OrFail response-as variants --- .../main/scala/sttp/client4/ResponseAs.scala | 54 +++++++++++++++++ .../src/main/scala/sttp/client4/SttpApi.scala | 55 +++++++++++++++-- .../sttp/client4/SttpWebSocketAsyncApi.scala | 60 ++++++++++++++++++- .../sttp/client4/SttpWebSocketStreamApi.scala | 42 ++++++++++++- .../sttp/client4/SttpWebSocketSyncApi.scala | 47 ++++++++++++++- .../scala/sttp/client4/testing/HttpTest.scala | 29 ++++++++- .../testing/streaming/StreamingTest.scala | 29 +++++++++ 7 files changed, 304 insertions(+), 12 deletions(-) diff --git a/core/src/main/scala/sttp/client4/ResponseAs.scala b/core/src/main/scala/sttp/client4/ResponseAs.scala index aad0b84843..8ab6ae842e 100644 --- a/core/src/main/scala/sttp/client4/ResponseAs.scala +++ b/core/src/main/scala/sttp/client4/ResponseAs.scala @@ -192,11 +192,29 @@ object ResponseAs { * [[ResponseAs]] */ case class StreamResponseAs[+T, S](delegate: GenericResponseAs[T, S]) extends ResponseAsDelegate[T, S] { + + /** Applies the given function `f` to the deserialized value `T`. */ def map[T2](f: T => T2): StreamResponseAs[T2, S] = StreamResponseAs(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): StreamResponseAs[T2, S] = StreamResponseAs(delegate.mapWithMetadata(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 orFail[A, B](implicit tIsEither: T <:< Either[A, B]): StreamResponseAs[B, S] = + 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 + } + } + def showAs(s: String): StreamResponseAs[T, S] = new StreamResponseAs(delegate.showAs(s)) } @@ -215,11 +233,29 @@ case class StreamResponseAs[+T, S](delegate: GenericResponseAs[T, S]) extends Re */ case class WebSocketResponseAs[F[_], +T](delegate: GenericResponseAs[T, Effect[F] with WebSockets]) extends ResponseAsDelegate[T, Effect[F] with WebSockets] { + + /** Applies the given function `f` to the deserialized value `T`. */ def map[T2](f: T => T2): WebSocketResponseAs[F, T2] = WebSocketResponseAs(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): WebSocketResponseAs[F, T2] = WebSocketResponseAs(delegate.mapWithMetadata(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 orFail[A, B](implicit tIsEither: T <:< Either[A, B]): WebSocketResponseAs[F, 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 + } + } + def showAs(s: String): WebSocketResponseAs[F, T] = new WebSocketResponseAs(delegate.showAs(s)) } @@ -238,11 +274,29 @@ case class WebSocketResponseAs[F[_], +T](delegate: GenericResponseAs[T, Effect[F */ case class WebSocketStreamResponseAs[+T, S](delegate: GenericResponseAs[T, S with WebSockets]) extends ResponseAsDelegate[T, S with WebSockets] { + + /** Applies the given function `f` to the deserialized value `T`. */ def map[T2](f: T => T2): WebSocketStreamResponseAs[T2, S] = WebSocketStreamResponseAs[T2, S](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): WebSocketStreamResponseAs[T2, S] = WebSocketStreamResponseAs[T2, S](delegate.mapWithMetadata(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 orFail[A, B](implicit tIsEither: T <:< Either[A, B]): WebSocketStreamResponseAs[B, S] = + 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 + } + } + def showAs(s: String): WebSocketStreamResponseAs[T, S] = new WebSocketStreamResponseAs[T, S](delegate.showAs(s)) } diff --git a/core/src/main/scala/sttp/client4/SttpApi.scala b/core/src/main/scala/sttp/client4/SttpApi.scala index b19cecc05f..bfee6f08b3 100644 --- a/core/src/main/scala/sttp/client4/SttpApi.scala +++ b/core/src/main/scala/sttp/client4/SttpApi.scala @@ -93,7 +93,16 @@ trait SttpApi extends SttpExtensions with UriInterpolator { } .showAs("as string") - /** Reads the response as either a string (for non-2xx responses), or othweise as an array of bytes (without any + /** Reads the response as a `String`, if the status code is 2xx. Otherwise, throws an [[HttpError]] / returns a failed + * effect. Use the `utf-8` charset by default, unless specified otherwise in the response headers. + * + * @see + * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * an exception-throwing variant. + */ + def asStringOrFail: ResponseAs[String] = asString.orFail + + /** Reads the response as either a string (for non-2xx responses), or otherwise as an array of bytes (without any * processing). The entire response is loaded into memory. */ def asByteArray: ResponseAs[Either[String, Array[Byte]]] = asEither(asStringAlways, asByteArrayAlways) @@ -103,6 +112,15 @@ trait SttpApi extends SttpExtensions with UriInterpolator { */ def asByteArrayAlways: ResponseAs[Array[Byte]] = ResponseAs(ResponseAsByteArray) + /** Reads the response as an array of bytes, without any processing, if the status code is 2xx. Otherwise, throws an + * [[HttpError]] / returns a failed effect. + * + * @see + * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * an exception-throwing variant. + */ + def asByteArrayOrFail: ResponseAs[Array[Byte]] = asByteArray.orFail + /** Deserializes the response as either a string (for non-2xx responses), or otherwise as form parameters. Uses the * `utf-8` charset by default, unless specified otherwise in the response headers. */ @@ -127,6 +145,15 @@ trait SttpApi extends SttpExtensions with UriInterpolator { asStringAlways(charset2).map(GenericResponseAs.parseParams(_, charset2)).showAs("as params") } + /** Deserializes the response as form parameters, if the status code is 2xx. Otherwise, throws an [[HttpError]] / + * returns a failed effect. Uses the `utf-8` charset by default, unless specified otherwise in the response headers. + * + * @see + * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * an exception-throwing variant. + */ + def asParamsOrFail: ResponseAs[String] = asString.orFail + private[client4] def asSttpFile(file: SttpFile): ResponseAs[SttpFile] = ResponseAs(ResponseAsFile(file)) /** Uses the [[ResponseAs]] description that matches the condition (using the response's metadata). @@ -243,7 +270,8 @@ trait SttpApi extends SttpExtensions with UriInterpolator { // stream response specifications /** Handles the response body by either reading a string (for non-2xx responses), or otherwise providing a stream with - * the response's data to `f`. The stream is always closed after `f` completes. + * the response's data to `f`. The effect type used by `f` must be compatible with the effect type of the backend. + * The stream is always closed after `f` completes. * * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. */ @@ -252,8 +280,23 @@ trait SttpApi extends SttpExtensions with UriInterpolator { ): StreamResponseAs[Either[String, T], S with Effect[F]] = asEither(asStringAlways, asStreamAlways(s)(f)) + /** Handles the response body by providing a stream with the response's data to `f`, if the status code is 2xx. + * Otherwise, returns a failed effect (with [[HttpError]]). The effect type used by `f` must be compatible with the + * effect type of the backend. The stream is always closed after `f` completes. + * + * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. + * + * @see + * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * an exception-throwing variant. + */ + def asStreamOrFail[F[_], T, S](s: Streams[S])( + f: s.BinaryStream => F[T] + ): StreamResponseAs[T, S with Effect[F]] = asStream(s)(f).orFail + /** Handles the response body by either reading a string (for non-2xx responses), or otherwise providing a stream with - * the response's data, along with the response metadata, to `f`. The stream is always closed after `f` completes. + * the response's data, along with the response metadata, to `f`. The effect type used by `f` must be compatible with + * the effect type of the backend. The stream is always closed after `f` completes. * * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. */ @@ -263,7 +306,8 @@ trait SttpApi extends SttpExtensions with UriInterpolator { asEither(asStringAlways, asStreamAlwaysWithMetadata(s)(f)) /** Handles the response body by providing a stream with the response's data to `f`, regardless of the status code. - * The stream is always closed after `f` completes. + * The effect type used by `f` must be compatible with the effect type of the backend. The stream is always closed + * after `f` completes. * * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. */ @@ -271,7 +315,8 @@ trait SttpApi extends SttpExtensions with UriInterpolator { asStreamAlwaysWithMetadata(s)((s, _) => f(s)) /** Handles the response body by providing a stream with the response's data, along with the response metadata, to - * `f`, regardless of the status code. The stream is always closed after `f` completes. + * `f`, regardless of the status code. The effect type used by `f` must be compatible with the effect type of the + * backend. The stream is always closed after `f` completes. * * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. */ diff --git a/core/src/main/scala/sttp/client4/SttpWebSocketAsyncApi.scala b/core/src/main/scala/sttp/client4/SttpWebSocketAsyncApi.scala index bc8ff93f09..ab42df12c7 100644 --- a/core/src/main/scala/sttp/client4/SttpWebSocketAsyncApi.scala +++ b/core/src/main/scala/sttp/client4/SttpWebSocketAsyncApi.scala @@ -4,34 +4,90 @@ import sttp.model.ResponseMetadata import sttp.ws.WebSocket trait SttpWebSocketAsyncApi { + + /** Handles the response body by either reading a string (for non-2xx responses), or otherwise providing an open + * [[WebSocket]] instance to the `f` function. + * + * The effect type used by `f` must be compatible with the effect type of the backend. The web socket is always + * closed after `f` completes. + */ def asWebSocket[F[_], T](f: WebSocket[F] => F[T]): WebSocketResponseAs[F, Either[String, T]] = asWebSocketEither(asStringAlways, asWebSocketAlways(f)) + /** Handles the response as a web socket, providing an open [[WebSocket]] instance to the `f` function, if the status + * code is 2xx. Otherwise, returns a failed effect (with [[HttpError]]). + * + * The effect type used by `f` must be compatible with the effect type of the backend. The web socket is always + * closed after `f` completes. + * + * @see + * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * an exception-throwing variant. + */ + def asWebSocketOrFail[F[_], T](f: WebSocket[F] => F[T]): WebSocketResponseAs[F, T] = asWebSocket(f).orFail + + /** Handles the response body by either reading a string (for non-2xx responses), or otherwise providing an open + * [[WebSocket]] instance, along with the response metadata, to the `f` function. + * + * The effect type used by `f` must be compatible with the effect type of the backend. The web socket is always + * closed after `f` completes. + */ def asWebSocketWithMetadata[F[_], T]( f: (WebSocket[F], ResponseMetadata) => F[T] ): WebSocketResponseAs[F, Either[String, T]] = asWebSocketEither(asStringAlways, asWebSocketAlwaysWithMetadata(f)) + /** Handles the response body by providing an open [[WebSocket]] instance to the `f` function, regardless of the + * status code. + * + * The effect type used by `f` must be compatible with the effect type of the backend. The web socket is always + * closed after `f` completes. + */ def asWebSocketAlways[F[_], T](f: WebSocket[F] => F[T]): WebSocketResponseAs[F, T] = asWebSocketAlwaysWithMetadata((w, _) => f(w)) + /** Handles the response body by providing an open [[WebSocket]] instance to the `f` function, along with the response + * metadata, regardless of the status code. + * + * The effect type used by `f` must be compatible with the effect type of the backend. The web socket is always + * closed after `f` completes. + */ def asWebSocketAlwaysWithMetadata[F[_], T](f: (WebSocket[F], ResponseMetadata) => F[T]): WebSocketResponseAs[F, T] = WebSocketResponseAs(ResponseAsWebSocket(f)) + /** Handles the response body by either reading a string (for non-2xx responses), or otherwise returning an open + * [[WebSocket]] instance. It is the responsibility of the caller to consume & close the web socket. + * + * The effect type `F` must be compatible with the effect type of the backend. + */ def asWebSocketUnsafe[F[_]]: WebSocketResponseAs[F, Either[String, WebSocket[F]]] = asWebSocketEither(asStringAlways, asWebSocketAlwaysUnsafe) + /** Handles the response body by returning an open [[WebSocket]] instance, regardless of the status code. It is the + * responsibility of the caller to consume & close the web socket. + * + * The effect type `F` must be compatible with the effect type of the backend. + */ def asWebSocketAlwaysUnsafe[F[_]]: WebSocketResponseAs[F, WebSocket[F]] = WebSocketResponseAs(ResponseAsWebSocketUnsafe()) + /** Uses the [[ResponseAs]] description that matches the condition (using the response's metadata). + * + * This allows using different response description basing on the status code, for example. If none of the conditions + * match, the default response handling description is used. + * + * The effect type `F` must be compatible with the effect type of the backend. + */ def fromMetadata[F[_], T]( default: ResponseAs[T], conditions: ConditionalResponseAs[WebSocketResponseAs[F, T]]* ): WebSocketResponseAs[F, T] = WebSocketResponseAs(ResponseAsFromMetadata(conditions.map(_.map(_.delegate)).toList, default.delegate)) - /** Uses the `onSuccess` response specification for 101 responses (switching protocols) on JVM/Native, 200 responses - * on JS. Otherwise, use the `onError` specification. + /** Uses the `onSuccess` response description for 101 responses (switching protocols) on JVM/Native, 200 responses on + * JS. Otherwise, use the `onError` description. + * + * The effect type `F` must be compatible with the effect type of the backend. */ def asWebSocketEither[F[_], A, B]( onError: ResponseAs[A], diff --git a/core/src/main/scala/sttp/client4/SttpWebSocketStreamApi.scala b/core/src/main/scala/sttp/client4/SttpWebSocketStreamApi.scala index ec9ea08f26..a14029f509 100644 --- a/core/src/main/scala/sttp/client4/SttpWebSocketStreamApi.scala +++ b/core/src/main/scala/sttp/client4/SttpWebSocketStreamApi.scala @@ -5,15 +5,53 @@ import sttp.model.StatusCode import sttp.ws.WebSocketFrame trait SttpWebSocketStreamApi { + + /** Handles the response body by either reading a string (for non-2xx responses), or otherwise using the given `p` + * stream processing pipe to handle the incoming & produce the outgoing web socket frames. + * + * The web socket is always closed after `p` completes. + * + * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. + */ def asWebSocketStream[S]( s: Streams[S] )(p: s.Pipe[WebSocketFrame.Data[_], WebSocketFrame]): WebSocketStreamResponseAs[Either[String, Unit], S] = asWebSocketEither(asStringAlways, asWebSocketStreamAlways(s)(p)) + /** Handles the response as a web socket, using the given `p` stream processing pipe to handle the incoming & produce + * the outgoing web socket frames, if the status code is 2xx. Otherwise, returns a failed effect (with + * [[HttpError]]). + * + * The effect type used by `f` must be compatible with the effect type of the backend. The web socket is always + * closed after `p` completes. + * + * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. + * + * @see + * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * an exception-throwing variant. + */ + def asWebSocketStreamOrFail[S]( + s: Streams[S] + )(p: s.Pipe[WebSocketFrame.Data[_], WebSocketFrame]): WebSocketStreamResponseAs[Unit, S] = + asWebSocketStream(s)(p).orFail + + /** Handles the response body by using the given `p` stream processing pipe to handle the incoming & produce the + * outgoing web socket frames, regardless of the status code. + * + * The web socket is always closed after `p` completes. + * + * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. + */ def asWebSocketStreamAlways[S](s: Streams[S])( p: s.Pipe[WebSocketFrame.Data[_], WebSocketFrame] ): WebSocketStreamResponseAs[Unit, S] = WebSocketStreamResponseAs[Unit, S](ResponseAsWebSocketStream(s, p)) + /** Uses the [[ResponseAs]] description that matches the condition (using the response's metadata). + * + * This allows using different response description basing on the status code, for example. If none of the conditions + * match, the default response handling description is used. + */ def fromMetadata[T, S]( default: ResponseAs[T], conditions: ConditionalResponseAs[WebSocketStreamResponseAs[T, S]]* @@ -22,8 +60,8 @@ trait SttpWebSocketStreamApi { ResponseAsFromMetadata(conditions.map(_.map(_.delegate)).toList, default.delegate) ) - /** Uses the `onSuccess` response specification for 101 responses (switching protocols), and the `onError` - * specification otherwise. + /** Uses the `onSuccess` response description for 101 responses (switching protocols) on JVM/Native, 200 responses on + * JS. Otherwise, use the `onError` description. */ def asWebSocketEither[A, B, S]( onError: ResponseAs[A], diff --git a/core/src/main/scala/sttp/client4/SttpWebSocketSyncApi.scala b/core/src/main/scala/sttp/client4/SttpWebSocketSyncApi.scala index 9226c8e77c..d6218d91a8 100644 --- a/core/src/main/scala/sttp/client4/SttpWebSocketSyncApi.scala +++ b/core/src/main/scala/sttp/client4/SttpWebSocketSyncApi.scala @@ -6,36 +6,79 @@ import sttp.shared.Identity import sttp.ws.WebSocket trait SttpWebSocketSyncApi { + + /** Handles the response body by either reading a string (for non-2xx responses), or otherwise providing an open + * [[WebSocket]] instance to the `f` function. + * + * The web socket is always closed after `f` completes. + */ def asWebSocket[T](f: SyncWebSocket => T): WebSocketResponseAs[Identity, Either[String, T]] = asWebSocketEither(asStringAlways, asWebSocketAlways(f)) + /** Handles the response as a web socket, providing an open [[WebSocket]] instance to the `f` function, if the status + * code is 2xx. Otherwise, throws an [[HttpError]]. + * + * The web socket is always closed after `f` completes. + * + * @see + * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * an exception-throwing variant. + */ + def asWebSocketOrFail[T](f: SyncWebSocket => T): WebSocketResponseAs[Identity, T] = asWebSocket(f).orFail + + /** Handles the response body by either reading a string (for non-2xx responses), or otherwise providing an open + * [[WebSocket]] instance, along with the response metadata, to the `f` function. + * + * The web socket is always closed after `f` completes. + */ def asWebSocketWithMetadata[T]( f: (SyncWebSocket, ResponseMetadata) => T ): WebSocketResponseAs[Identity, Either[String, T]] = asWebSocketEither(asStringAlways, asWebSocketAlwaysWithMetadata(f)) + /** Handles the response body by providing an open [[WebSocket]] instance to the `f` function, regardless of the + * status code. + * + * The web socket is always closed after `f` completes. + */ def asWebSocketAlways[T](f: SyncWebSocket => T): WebSocketResponseAs[Identity, T] = asWebSocketAlwaysWithMetadata((w, _) => f(w)) + /** Handles the response body by providing an open [[WebSocket]] instance to the `f` function, along with the response + * metadata, regardless of the status code. + * + * The web socket is always closed after `f` completes. + */ def asWebSocketAlwaysWithMetadata[T]( f: (SyncWebSocket, ResponseMetadata) => T ): WebSocketResponseAs[Identity, T] = WebSocketResponseAs[Identity, T](ResponseAsWebSocket[Identity, T]((ws, m) => f(new SyncWebSocket(ws), m))) + /** Handles the response body by either reading a string (for non-2xx responses), or otherwise returning an open + * [[WebSocket]] instance. It is the responsibility of the caller to consume & close the web socket. + */ def asWebSocketUnsafe: WebSocketResponseAs[Identity, Either[String, SyncWebSocket]] = asWebSocketEither(asStringAlways, asWebSocketAlwaysUnsafe) + /** Handles the response body by returning an open [[WebSocket]] instance, regardless of the status code. It is the + * responsibility of the caller to consume & close the web socket. + */ def asWebSocketAlwaysUnsafe: WebSocketResponseAs[Identity, SyncWebSocket] = WebSocketResponseAs[Identity, WebSocket[Identity]](ResponseAsWebSocketUnsafe()).map(new SyncWebSocket(_)) + /** Uses the [[ResponseAs]] description that matches the condition (using the response's metadata). + * + * This allows using different response description basing on the status code, for example. If none of the conditions + * match, the default response handling description is used. + */ def fromMetadata[T]( default: ResponseAs[T], conditions: ConditionalResponseAs[WebSocketResponseAs[Identity, T]]* ): WebSocketResponseAs[Identity, T] = WebSocketResponseAs(ResponseAsFromMetadata(conditions.map(_.map(_.delegate)).toList, default.delegate)) - /** Uses the `onSuccess` response specification for 101 responses (switching protocols) on JVM/Native, 200 responses - * on JS. Otherwise, use the `onError` specification. + /** Uses the `onSuccess` response description for 101 responses (switching protocols) on JVM/Native, 200 responses on + * JS. Otherwise, use the `onError` description. */ def asWebSocketEither[A, B]( onError: ResponseAs[A], diff --git a/core/src/test/scala/sttp/client4/testing/HttpTest.scala b/core/src/test/scala/sttp/client4/testing/HttpTest.scala index cb0b771c40..ad368c06db 100644 --- a/core/src/test/scala/sttp/client4/testing/HttpTest.scala +++ b/core/src/test/scala/sttp/client4/testing/HttpTest.scala @@ -98,7 +98,7 @@ trait HttpTest[F[_]] .map(response => response.body should be(Right(expectedPostEchoResponse.length))) } - "as string with mapping using mapWithHeaders" in { + "as string with mapping using mapWithMetadata" in { postEcho .body(testBody) .response(asStringAlways.mapWithMetadata((b, h) => b + " " + h.contentType.getOrElse(""))) @@ -160,6 +160,33 @@ trait HttpTest[F[_]] } } + "as failure, when the request is successfull" in { + basicRequest + .post(uri"$endpoint/echo/custom_status/200") + .body(testBody) + .response(asStringOrFail) + .send(backend) + .toFuture() + .map(_.body shouldBe s"POST /echo/custom_status/200 $testBody") + } + + "as failure, when the request is not successfull" in { + implicit val monadError: MonadError[F] = backend.monad + basicRequest + .post(uri"$endpoint/echo/custom_status/400") + .body(testBody) + .response(asStringOrFail) + .send(backend) + .map(_.body) + .handleError { case e: SttpClientException.ReadException => + monadError.unit(e.getCause().toString()) + } + .toFuture() + .map( + _ shouldBe "sttp.client4.HttpError: statusCode: 400, response: POST /echo/custom_status/400 this is the body" + ) + } + "as string, when the content type encoding is in quotes" in { basicRequest .post(uri"$endpoint/set_content_type_header_with_encoding_in_quotes") diff --git a/core/src/test/scala/sttp/client4/testing/streaming/StreamingTest.scala b/core/src/test/scala/sttp/client4/testing/streaming/StreamingTest.scala index 3826dd0ee6..824307a202 100644 --- a/core/src/test/scala/sttp/client4/testing/streaming/StreamingTest.scala +++ b/core/src/test/scala/sttp/client4/testing/streaming/StreamingTest.scala @@ -261,6 +261,35 @@ abstract class StreamingTest[F[_], S] } } + "when mapping using asStreamOrFail, receive a stream" in { + basicRequest + .post(uri"$endpoint/streaming/echo") + .body(Body) + .response(asStreamOrFail(streams)(bodyConsumer(_))) + .send(backend) + .toFuture() + .map { response => + response.body shouldBe Body + } + } + + "when mapping using asStreamOrFail, receive an error" in { + implicit val monadError: MonadError[F] = backend.monad + basicRequest + .post(uri"$endpoint/echo/custom_status/400") + .body(Body) + .response(asStreamOrFail(streams)(bodyConsumer(_))) + .send(backend) + .map(_.body) + .handleError { case e: SttpClientException.ReadException => + monadError.unit(e.getCause().toString()) + } + .toFuture() + .map( + _ shouldBe "sttp.client4.HttpError: statusCode: 400, response: POST /echo/custom_status/400 streaming test" + ) + } + override protected def afterAll(): Unit = { backend.close().toFuture() super.afterAll() From e4d8d95be0a8d3aadb8039065fdcf9a5729da1ad Mon Sep 17 00:00:00 2001 From: adamw Date: Wed, 11 Dec 2024 17:45:21 +0100 Subject: [PATCH 10/11] Add asJsonOrFail methods --- build.sbt | 23 +++--- .../main/scala/sttp/client4/ResponseAs.scala | 24 +++++++ .../src/main/scala/sttp/client4/SttpApi.scala | 16 ++--- .../sttp/client4/SttpWebSocketAsyncApi.scala | 5 +- .../sttp/client4/SttpWebSocketStreamApi.scala | 4 +- .../sttp/client4/SttpWebSocketSyncApi.scala | 5 +- docs/json.md | 11 ++- .../sttp/client4/circe/SttpCirceApi.scala | 15 ++++ .../scala/sttp/client4/circe/CirceTests.scala | 58 ++++++++++----- .../scala/sttp/client4/json/package.scala | 2 + .../sttp/client4/json/RunResponseAs.scala | 26 +++++++ .../sttp/client4/json4s/SttpJson4sApi.scala | 21 ++++++ .../test/scala/sttp/client4/Json4sTests.scala | 58 +++++++++------ .../jsoniter/SttpJsoniterJsonApi.scala | 14 ++++ .../client4/jsoniter/JsoniterJsonTests.scala | 57 ++++++++++----- .../client4/playJson/SttpPlayJsonApi.scala | 15 ++++ .../scala/sttp/client4/PlayJsonTests.scala | 58 ++++++++++----- .../client4/sprayJson/SttpSprayJsonApi.scala | 15 ++++ .../client4/sprayJson/SprayJsonTests.scala | 61 ++++++++++------ .../client4/tethysJson/SttpTethysApi.scala | 19 +++++ .../sttp/client4/tethysJson/TethysTests.scala | 58 ++++++++++----- .../client4/upicklejson/SttpUpickleApi.scala | 15 ++++ .../client4/upicklejson/UpickleTests.scala | 70 ++++++++++++++----- .../sttp/client4/ziojson/SttpZioJsonApi.scala | 14 ++++ .../sttp/client4/ziojson/ZioJsonTests.scala | 58 ++++++++++----- .../sttp/client4/ziojson/SttpZioJsonApi.scala | 14 ++++ .../sttp/client4/ziojson/ZioJsonTests.scala | 58 ++++++++++----- 27 files changed, 591 insertions(+), 203 deletions(-) create mode 100644 json/common/src/test/scala/sttp/client4/json/RunResponseAs.scala diff --git a/build.sbt b/build.sbt index 6b60d14f6a..5f14da1d6b 100644 --- a/build.sbt +++ b/build.sbt @@ -800,7 +800,8 @@ lazy val armeriaZioBackend = //----- json lazy val jsonCommon = (projectMatrix in (file("json/common"))) .settings( - name := "json-common" + name := "json-common", + scalaTest ) .jvmPlatform( scalaVersions = scala2 ++ scala3, @@ -826,7 +827,7 @@ lazy val circe = (projectMatrix in file("json/circe")) ) .jsPlatform(scalaVersions = scala2 ++ scala3, settings = commonJsSettings) .nativePlatform(scalaVersions = scala2 ++ scala3, settings = commonNativeSettings) - .dependsOn(core, jsonCommon) + .dependsOn(core, jsonCommon % compileAndTest) lazy val jsoniter = (projectMatrix in file("json/jsoniter")) .settings( @@ -842,7 +843,7 @@ lazy val jsoniter = (projectMatrix in file("json/jsoniter")) settings = commonJvmSettings ) .jsPlatform(scalaVersions = scala2 ++ scala3, settings = commonJsSettings) - .dependsOn(core, jsonCommon) + .dependsOn(core, jsonCommon % compileAndTest) lazy val zioJson = (projectMatrix in file("json/zio-json")) .settings( @@ -858,7 +859,7 @@ lazy val zioJson = (projectMatrix in file("json/zio-json")) settings = commonJvmSettings ) .jsPlatform(scalaVersions = scala2 ++ scala3, settings = commonJsSettings) - .dependsOn(core, jsonCommon) + .dependsOn(core, jsonCommon % compileAndTest) lazy val zio1Json = (projectMatrix in file("json/zio1-json")) .settings( @@ -874,7 +875,7 @@ lazy val zio1Json = (projectMatrix in file("json/zio1-json")) settings = commonJvmSettings ) .jsPlatform(scalaVersions = scala2 ++ scala3, settings = commonJsSettings) - .dependsOn(core, jsonCommon) + .dependsOn(core, jsonCommon % compileAndTest) lazy val tethysJson = (projectMatrix in file("json/tethys-json")) .settings( @@ -890,7 +891,7 @@ lazy val tethysJson = (projectMatrix in file("json/tethys-json")) scalaVersions = scala2 ++ scala3, settings = commonJvmSettings ) - .dependsOn(core, jsonCommon) + .dependsOn(core, jsonCommon % compileAndTest) lazy val upickle = (projectMatrix in file("json/upickle")) .settings( @@ -908,7 +909,7 @@ lazy val upickle = (projectMatrix in file("json/upickle")) ) .jsPlatform(scalaVersions = scala2 ++ scala3, settings = commonJsSettings) .nativePlatform(scalaVersions = scala2 ++ scala3, settings = commonNativeSettings) - .dependsOn(core, jsonCommon) + .dependsOn(core, jsonCommon % compileAndTest) lazy val json4sVersion = "4.0.7" @@ -923,7 +924,7 @@ lazy val json4s = (projectMatrix in file("json/json4s")) scalaTest ) .jvmPlatform(scalaVersions = scala2 ++ scala3) - .dependsOn(core, jsonCommon) + .dependsOn(core, jsonCommon % compileAndTest) lazy val sprayJson = (projectMatrix in file("json/spray-json")) .settings(commonJvmSettings) @@ -935,7 +936,7 @@ lazy val sprayJson = (projectMatrix in file("json/spray-json")) scalaTest ) .jvmPlatform(scalaVersions = scala2 ++ scala3) - .dependsOn(core, jsonCommon) + .dependsOn(core, jsonCommon % compileAndTest) lazy val play29Json = (projectMatrix in file("json/play29-json")) .settings( @@ -952,7 +953,7 @@ lazy val play29Json = (projectMatrix in file("json/play29-json")) settings = commonJvmSettings ) .jsPlatform(scalaVersions = scala2, settings = commonJsSettings) - .dependsOn(core, jsonCommon) + .dependsOn(core, jsonCommon % compileAndTest) lazy val playJson = (projectMatrix in file("json/play-json")) .settings( @@ -967,7 +968,7 @@ lazy val playJson = (projectMatrix in file("json/play-json")) settings = commonJvmSettings ) .jsPlatform(scalaVersions = scala2 ++ scala3, settings = commonJsSettings) - .dependsOn(core, jsonCommon) + .dependsOn(core, jsonCommon % compileAndTest) lazy val prometheusBackend = (projectMatrix in file("observability/prometheus-backend")) .settings(commonJvmSettings) diff --git a/core/src/main/scala/sttp/client4/ResponseAs.scala b/core/src/main/scala/sttp/client4/ResponseAs.scala index 8ab6ae842e..a695b54d15 100644 --- a/core/src/main/scala/sttp/client4/ResponseAs.scala +++ b/core/src/main/scala/sttp/client4/ResponseAs.scala @@ -174,6 +174,30 @@ object ResponseAs { case Left(e) => throw DeserializationException(s, e) case Right(b) => b } + + /** Converts deserialization functions, which both return errors of type `E`, into a function where errors are thrown + * as exceptions, and results are parsed using either of the functions, depending if the response was successfull, or + * not. + */ + def deserializeEitherWithErrorOrThrow[E: ShowError, T, T2]( + doDeserializeHttpError: String => Either[E, T], + doDeserializeHttpSuccess: String => Either[E, T2] + ): (String, ResponseMetadata) => Either[T, T2] = + (s, m) => + if (m.isSuccess) Right(deserializeOrThrow(doDeserializeHttpSuccess).apply(s)) + else Left(deserializeOrThrow(doDeserializeHttpError).apply(s)) + + /** Converts deserialization functions, which both throw exceptions upon errors, into a function where errors still + * thrown as exceptions, and results are parsed using either of the functions, depending if the response was + * successfull, or not. + */ + def deserializeEitherOrThrow[T, T2]( + doDeserializeHttpError: String => T, + doDeserializeHttpSuccess: String => T2 + ): (String, ResponseMetadata) => Either[T, T2] = + (s, m) => + if (m.isSuccess) Right(doDeserializeHttpSuccess(s)) + else Left(doDeserializeHttpError(s)) } /** Describes how the response body of a [[StreamRequest]] should be handled. diff --git a/core/src/main/scala/sttp/client4/SttpApi.scala b/core/src/main/scala/sttp/client4/SttpApi.scala index bfee6f08b3..7fda9af0c5 100644 --- a/core/src/main/scala/sttp/client4/SttpApi.scala +++ b/core/src/main/scala/sttp/client4/SttpApi.scala @@ -97,10 +97,10 @@ trait SttpApi extends SttpExtensions with UriInterpolator { * effect. Use the `utf-8` charset by default, unless specified otherwise in the response headers. * * @see - * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * the [[ResponseAs#orFail]] method can be used to convert any response description which returns an `Either` into * an exception-throwing variant. */ - def asStringOrFail: ResponseAs[String] = asString.orFail + def asStringOrFail: ResponseAs[String] = asString.orFail.showAs("as string or fail") /** Reads the response as either a string (for non-2xx responses), or otherwise as an array of bytes (without any * processing). The entire response is loaded into memory. @@ -116,10 +116,10 @@ trait SttpApi extends SttpExtensions with UriInterpolator { * [[HttpError]] / returns a failed effect. * * @see - * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * the [[ResponseAs#orFail]] method can be used to convert any response description which returns an `Either` into * an exception-throwing variant. */ - def asByteArrayOrFail: ResponseAs[Array[Byte]] = asByteArray.orFail + def asByteArrayOrFail: ResponseAs[Array[Byte]] = asByteArray.orFail.showAs("as byte array or fail") /** Deserializes the response as either a string (for non-2xx responses), or otherwise as form parameters. Uses the * `utf-8` charset by default, unless specified otherwise in the response headers. @@ -149,10 +149,10 @@ trait SttpApi extends SttpExtensions with UriInterpolator { * returns a failed effect. Uses the `utf-8` charset by default, unless specified otherwise in the response headers. * * @see - * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * the [[ResponseAs#orFail]] method can be used to convert any response description which returns an `Either` into * an exception-throwing variant. */ - def asParamsOrFail: ResponseAs[String] = asString.orFail + def asParamsOrFail: ResponseAs[String] = asString.orFail.showAs("as params or fail") private[client4] def asSttpFile(file: SttpFile): ResponseAs[SttpFile] = ResponseAs(ResponseAsFile(file)) @@ -287,12 +287,12 @@ trait SttpApi extends SttpExtensions with UriInterpolator { * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. * * @see - * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * the [[ResponseAs#orFail]] method can be used to convert any response description which returns an `Either` into * an exception-throwing variant. */ def asStreamOrFail[F[_], T, S](s: Streams[S])( f: s.BinaryStream => F[T] - ): StreamResponseAs[T, S with Effect[F]] = asStream(s)(f).orFail + ): StreamResponseAs[T, S with Effect[F]] = asStream(s)(f).orFail.showAs("as stream or fail") /** Handles the response body by either reading a string (for non-2xx responses), or otherwise providing a stream with * the response's data, along with the response metadata, to `f`. The effect type used by `f` must be compatible with diff --git a/core/src/main/scala/sttp/client4/SttpWebSocketAsyncApi.scala b/core/src/main/scala/sttp/client4/SttpWebSocketAsyncApi.scala index ab42df12c7..2176905a8a 100644 --- a/core/src/main/scala/sttp/client4/SttpWebSocketAsyncApi.scala +++ b/core/src/main/scala/sttp/client4/SttpWebSocketAsyncApi.scala @@ -21,10 +21,11 @@ trait SttpWebSocketAsyncApi { * closed after `f` completes. * * @see - * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * the [[ResponseAs#orFail]] method can be used to convert any response description which returns an `Either` into * an exception-throwing variant. */ - def asWebSocketOrFail[F[_], T](f: WebSocket[F] => F[T]): WebSocketResponseAs[F, T] = asWebSocket(f).orFail + def asWebSocketOrFail[F[_], T](f: WebSocket[F] => F[T]): WebSocketResponseAs[F, T] = + asWebSocket(f).orFail.showAs("as web socket or fail") /** Handles the response body by either reading a string (for non-2xx responses), or otherwise providing an open * [[WebSocket]] instance, along with the response metadata, to the `f` function. diff --git a/core/src/main/scala/sttp/client4/SttpWebSocketStreamApi.scala b/core/src/main/scala/sttp/client4/SttpWebSocketStreamApi.scala index a14029f509..cdb0640768 100644 --- a/core/src/main/scala/sttp/client4/SttpWebSocketStreamApi.scala +++ b/core/src/main/scala/sttp/client4/SttpWebSocketStreamApi.scala @@ -28,13 +28,13 @@ trait SttpWebSocketStreamApi { * A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter. * * @see - * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * the [[ResponseAs#orFail]] method can be used to convert any response description which returns an `Either` into * an exception-throwing variant. */ def asWebSocketStreamOrFail[S]( s: Streams[S] )(p: s.Pipe[WebSocketFrame.Data[_], WebSocketFrame]): WebSocketStreamResponseAs[Unit, S] = - asWebSocketStream(s)(p).orFail + asWebSocketStream(s)(p).orFail.showAs("as web socket stream or fail") /** Handles the response body by using the given `p` stream processing pipe to handle the incoming & produce the * outgoing web socket frames, regardless of the status code. diff --git a/core/src/main/scala/sttp/client4/SttpWebSocketSyncApi.scala b/core/src/main/scala/sttp/client4/SttpWebSocketSyncApi.scala index d6218d91a8..09f504f69f 100644 --- a/core/src/main/scala/sttp/client4/SttpWebSocketSyncApi.scala +++ b/core/src/main/scala/sttp/client4/SttpWebSocketSyncApi.scala @@ -21,10 +21,11 @@ trait SttpWebSocketSyncApi { * The web socket is always closed after `f` completes. * * @see - * the [[ResponseAs.orFail]] method can be used to convert any response description which returns an `Either` into + * the [[ResponseAs#orFail]] method can be used to convert any response description which returns an `Either` into * an exception-throwing variant. */ - def asWebSocketOrFail[T](f: SyncWebSocket => T): WebSocketResponseAs[Identity, T] = asWebSocket(f).orFail + def asWebSocketOrFail[T](f: SyncWebSocket => T): WebSocketResponseAs[Identity, T] = + asWebSocket(f).orFail.showAs("as web socket or fail") /** Handles the response body by either reading a string (for non-2xx responses), or otherwise providing an open * [[WebSocket]] instance, along with the response metadata, to the `f` function. diff --git a/docs/json.md b/docs/json.md index e1004b1954..fc439da2e4 100644 --- a/docs/json.md +++ b/docs/json.md @@ -6,20 +6,27 @@ 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); should be used to specify how a response should be handled, e.g. `basicRequest.response(asJson[T])` +* `asJson(b: B)` - to be used when specifying the body of a request: serializes the body so that it can be used as a request's body, e.g. using `basicRequest.body(asJson(myValue))` +* `asJson[B]` - to be used when specifying how the response body should be handled: specifies that the body should be deserialized to json, but only if the response is successful (2xx); otherwise, a `Left` is returned, with body as a string +* `asJsonOrFail[B]` - specifies that the body should be deserialized to json, if the response is successful (2xx); throws an exception/returns a failed effect if the response code is other than 2xx, or if deserialization fails * `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 +* `asJsonEitherOrFail[E, B]` - specifies that the body should be deserialized to json, using different deserializers for error and successful (2xx) responses; throws an exception/returns a failed effect, if deserialization fails The type signatures vary depending on the underlying library (required implicits and error representation differs), but they obey the following pattern: ```scala mdoc:compile-only import sttp.client4._ +// request bodies def asJson[B](b: B): StringBody = ??? + +// response handling description def asJson[B]: ResponseAs[Either[ResponseException[String, Exception], B]] = ??? +def asJsonOrFail[B]: ResponseAs[B] = ??? def asJsonAlways[B]: ResponseAs[Either[DeserializationException[Exception], B]] = ??? def asJsonEither[E, B]: ResponseAs[Either[ResponseException[E, Exception], B]] = ??? +def asJsonEitherOrFail[E, B]: ResponseAs[Either[E, B]] = ??? ``` The response specifications can be further refined using `.orFail` and `.orFailDeserialization`, see [response body specifications](responses/body.md). 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 f665814d88..0a4c34e923 100644 --- a/json/circe/src/main/scala/sttp/client4/circe/SttpCirceApi.scala +++ b/json/circe/src/main/scala/sttp/client4/circe/SttpCirceApi.scala @@ -6,6 +6,7 @@ import io.circe.{Decoder, Encoder, Printer} import sttp.client4.internal.Utf8 import sttp.model.MediaType import sttp.client4.json._ +import sttp.client4.ResponseAs.deserializeEitherWithErrorOrThrow trait SttpCirceApi { @@ -24,6 +25,12 @@ trait SttpCirceApi { def asJson[B: Decoder: IsOption]: ResponseAs[Either[ResponseException[String, io.circe.Error], B]] = asString.mapWithMetadata(ResponseAs.deserializeRightWithError(deserializeJson)).showAsJson + /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Otherwise, if the + * response code is other than 2xx, or a deserialization error occurs, throws an [[ResponseException]] / returns a + * failed effect. + */ + def asJsonOrFail[B: Decoder: IsOption]: ResponseAs[B] = asJson[B].orFail.showAsJsonOrFail + /** 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 @@ -46,6 +53,14 @@ trait SttpCirceApi { } }.showAsJsonEither + /** Deserializes the body from a string into JSON, using different deserializers depending on the status code. If a + * deserialization error occurs, throws a [[DeserializationException]] / returns a failed effect. + */ + def asJsonEitherOrFail[E: Decoder: IsOption, B: Decoder: IsOption]: ResponseAs[Either[E, B]] = + asStringAlways + .mapWithMetadata(deserializeEitherWithErrorOrThrow(deserializeJson[E], deserializeJson[B])) + .showAsJsonEitherOrFail + def deserializeJson[B: Decoder: IsOption]: String => Either[io.circe.Error, B] = JsonInput.sanitize[B].andThen(decode[B]) } diff --git a/json/circe/src/test/scala/sttp/client4/circe/CirceTests.scala b/json/circe/src/test/scala/sttp/client4/circe/CirceTests.scala index 8ad141755a..6326593b01 100644 --- a/json/circe/src/test/scala/sttp/client4/circe/CirceTests.scala +++ b/json/circe/src/test/scala/sttp/client4/circe/CirceTests.scala @@ -7,6 +7,7 @@ import sttp.client4._ import sttp.model._ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import sttp.client4.json.RunResponseAs class CirceTests extends AnyFlatSpec with Matchers with EitherValues { @@ -34,33 +35,33 @@ class CirceTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - runJsonResponseAs(responseAs)(body).right.value shouldBe expected + RunResponseAs(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 + RunResponseAs(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) + RunResponseAs(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) + RunResponseAs(responseAs)("").right.value shouldBe Right(None) } it should "fail to decode from empty input" in { val responseAs = asJson[Inner] - runJsonResponseAs(responseAs)("").left.value should matchPattern { + RunResponseAs(responseAs)("").left.value should matchPattern { case DeserializationException("", _: io.circe.ParsingFailure) => } } @@ -70,7 +71,7 @@ class CirceTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - val Left(DeserializationException(original, _)) = runJsonResponseAs(responseAs)(body) + val Left(DeserializationException(original, _)) = RunResponseAs(responseAs)(body) original shouldBe body } @@ -78,7 +79,7 @@ class CirceTests extends AnyFlatSpec with Matchers with EitherValues { val outer = Outer(Inner(42, true, "horses"), "cats") val encoded = extractBody(basicRequest.body(asJson(outer))) - val decoded = runJsonResponseAs(asJson[Outer])(encoded) + val decoded = RunResponseAs(asJson[Outer])(encoded) decoded.right.value shouldBe outer } @@ -119,6 +120,37 @@ class CirceTests extends AnyFlatSpec with Matchers with EitherValues { actualContentType should be(expectedContentType) } + it should "decode when using asJsonOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonOrFail[Outer])(body) shouldBe expected + } + + it should "fail when using asJsonOrFail for incorrect JSON" in { + val body = """invalid json""" + + assertThrows[DeserializationException[io.circe.Error]] { + RunResponseAs(asJsonOrFail[Outer])(body) + } + } + + it should "decode success when using asJsonEitherOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer])(body) shouldBe Right(expected) + } + + it should "decode failure when using asJsonEitherOrFail" in { + val body = """{"a":21,"b":false,"c":"hippos"}""" + val expected = Inner(21, false, "hippos") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer], ResponseMetadata(StatusCode.BadRequest, "", Nil))( + body + ) shouldBe Left(expected) + } + case class Inner(a: Int, b: Boolean, c: String) object Inner { @@ -149,16 +181,4 @@ class CirceTests extends AnyFlatSpec with Matchers with EitherValues { 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") - } } diff --git a/json/common/src/main/scala/sttp/client4/json/package.scala b/json/common/src/main/scala/sttp/client4/json/package.scala index 4899e1e5d6..b423cad04f 100644 --- a/json/common/src/main/scala/sttp/client4/json/package.scala +++ b/json/common/src/main/scala/sttp/client4/json/package.scala @@ -4,6 +4,8 @@ package object json { implicit class RichResponseAs[T](ra: ResponseAs[T]) { def showAsJson: ResponseAs[T] = ra.showAs("either(as string, as json)") def showAsJsonAlways: ResponseAs[T] = ra.showAs("as json") + def showAsJsonOrFail: ResponseAs[T] = ra.showAs("as json or fail") def showAsJsonEither: ResponseAs[T] = ra.showAs("either(as json, as json)") + def showAsJsonEitherOrFail: ResponseAs[T] = ra.showAs("either(as json, as json) or fail") } } diff --git a/json/common/src/test/scala/sttp/client4/json/RunResponseAs.scala b/json/common/src/test/scala/sttp/client4/json/RunResponseAs.scala new file mode 100644 index 0000000000..f87ba8ad83 --- /dev/null +++ b/json/common/src/test/scala/sttp/client4/json/RunResponseAs.scala @@ -0,0 +1,26 @@ +package sttp.client4.json + +import sttp.client4.ResponseAs +import sttp.client4.MappedResponseAs +import sttp.client4.ResponseAsByteArray +import sttp.client4.internal.Utf8 +import sttp.model.ResponseMetadata +import sttp.model.StatusCode +import org.scalatest.Assertions.fail + +object RunResponseAs { + def apply[A]( + responseAs: ResponseAs[A], + responseMetadata: ResponseMetadata = ResponseMetadata(StatusCode.Ok, "", Nil) + ): String => A = + responseAs.delegate match { + case responseAs: MappedResponseAs[_, A, Nothing] @unchecked => + responseAs.raw match { + case ResponseAsByteArray => + s => responseAs.g(s.getBytes(Utf8), responseMetadata) + case _ => + fail("MappedResponseAs does not wrap a ResponseAsByteArray") + } + case _ => fail("ResponseAs is not a MappedResponseAs") + } +} 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 c9781781aa..bef89f9809 100644 --- a/json/json4s/src/main/scala/sttp/client4/json4s/SttpJson4sApi.scala +++ b/json/json4s/src/main/scala/sttp/client4/json4s/SttpJson4sApi.scala @@ -5,6 +5,7 @@ import sttp.client4._ import sttp.client4.internal.Utf8 import sttp.client4.json._ import sttp.model._ +import sttp.client4.ResponseAs.deserializeEitherOrThrow trait SttpJson4sApi { @@ -26,6 +27,15 @@ trait SttpJson4sApi { ): ResponseAs[Either[ResponseException[String, Exception], B]] = asString.mapWithMetadata(ResponseAs.deserializeRightCatchingExceptions(deserializeJson[B])).showAsJson + /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Otherwise, if the + * response code is other than 2xx, or a deserialization error occurs, throws an [[ResponseException]] / returns a + * failed effect. + */ + def asJsonOrFail[B: Manifest](implicit + formats: Formats, + serialization: Serialization + ): ResponseAs[B] = asJson[B].orFail.showAsJsonOrFail + /** 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 @@ -54,6 +64,17 @@ trait SttpJson4sApi { } }.showAsJsonEither + /** Deserializes the body from a string into JSON, using different deserializers depending on the status code. If a + * deserialization error occurs, throws a [[DeserializationException]] / returns a failed effect. + */ + def asJsonEitherOrFail[E: Manifest, B: Manifest](implicit + formats: Formats, + serialization: Serialization + ): ResponseAs[Either[E, B]] = + asStringAlways + .mapWithMetadata(deserializeEitherOrThrow(deserializeJson[E], deserializeJson[B])) + .showAsJsonEitherOrFail + def deserializeJson[B: Manifest](implicit formats: Formats, serialization: Serialization diff --git a/json/json4s/src/test/scala/sttp/client4/Json4sTests.scala b/json/json4s/src/test/scala/sttp/client4/Json4sTests.scala index 08cc91f3fc..d255f06dfe 100644 --- a/json/json4s/src/test/scala/sttp/client4/Json4sTests.scala +++ b/json/json4s/src/test/scala/sttp/client4/Json4sTests.scala @@ -11,6 +11,7 @@ import sttp.model._ import scala.language.higherKinds import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import sttp.client4.json.RunResponseAs class Json4sTests extends AnyFlatSpec with Matchers with EitherValues { implicit val serialization: Serialization.type = native.Serialization @@ -34,32 +35,31 @@ class Json4sTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - runJsonResponseAs(responseAs)(body) shouldBe Right(expected) + RunResponseAs(responseAs)(body) shouldBe Right(expected) } it should "decode None from empty body" in { val responseAs = asJson[Option[Inner]] - runJsonResponseAs(responseAs)("") shouldBe Right(None) + RunResponseAs(responseAs)("") shouldBe Right(None) } it should "decode Left(None) from empty body" in { val responseAs = asJson[Either[Option[Inner], Outer]] - runJsonResponseAs(responseAs)("") shouldBe Right(Left(None)) + RunResponseAs(responseAs)("") shouldBe Right(Left(None)) } it should "decode Right(None) from empty body" in { val responseAs = asJson[Either[Outer, Option[Inner]]] - runJsonResponseAs(responseAs)("") shouldBe Right(Right(None)) + RunResponseAs(responseAs)("") shouldBe Right(Right(None)) } it should "fail to decode from empty input" in { val responseAs = asJson[Inner] - runJsonResponseAs(responseAs)("") should matchPattern { - case Left(DeserializationException(_, _: MappingException)) => + RunResponseAs(responseAs)("") should matchPattern { case Left(DeserializationException(_, _: MappingException)) => } } @@ -68,8 +68,7 @@ class Json4sTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - runJsonResponseAs(responseAs)(body) should matchPattern { - case Left(DeserializationException(_, _: ParseException)) => + RunResponseAs(responseAs)(body) should matchPattern { case Left(DeserializationException(_, _: ParseException)) => } } @@ -96,6 +95,37 @@ class Json4sTests extends AnyFlatSpec with Matchers with EitherValues { actualContentType should be(expectedContentType) } + it should "decode when using asJsonOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonOrFail[Outer])(body) shouldBe expected + } + + it should "fail when using asJsonOrFail for incorrect JSON" in { + val body = """invalid json""" + + assertThrows[Exception] { + RunResponseAs(asJsonOrFail[Outer])(body) + } + } + + it should "decode success when using asJsonEitherOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer])(body) shouldBe Right(expected) + } + + it should "decode failure when using asJsonEitherOrFail" in { + val body = """{"a":21,"b":false,"c":"hippos"}""" + val expected = Inner(21, false, "hippos") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer], ResponseMetadata(StatusCode.BadRequest, "", Nil))( + body + ) shouldBe Left(expected) + } + def extractBody[T](request: PartialRequest[T]): String = request.body match { case StringBody(body, "utf-8", MediaType.ApplicationJson) => @@ -103,18 +133,6 @@ class Json4sTests extends AnyFlatSpec with Matchers with EitherValues { 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") - } } object Json4sTests { 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 31e88c897f..d6347824be 100644 --- a/json/jsoniter/src/main/scala/sttp/client4/jsoniter/SttpJsoniterJsonApi.scala +++ b/json/jsoniter/src/main/scala/sttp/client4/jsoniter/SttpJsoniterJsonApi.scala @@ -30,6 +30,12 @@ trait SttpJsoniterJsonApi { def asJson[B: JsonValueCodec: IsOption]: ResponseAs[Either[ResponseException[String, Exception], B]] = asString.mapWithMetadata(ResponseAs.deserializeRightWithError(deserializeJson[B])).showAsJson + /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Otherwise, if the + * response code is other than 2xx, or a deserialization error occurs, throws an [[ResponseException]] / returns a + * failed effect. + */ + def asJsonOrFail[B: JsonValueCodec: IsOption]: ResponseAs[B] = asJson[B].orFail.showAsJsonOrFail + /** 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 @@ -54,6 +60,14 @@ trait SttpJsoniterJsonApi { } }.showAsJsonEither + /** Deserializes the body from a string into JSON, using different deserializers depending on the status code. If a + * deserialization error occurs, throws a [[DeserializationException]] / returns a failed effect. + */ + def asJsonEitherOrFail[E: JsonValueCodec: IsOption, B: JsonValueCodec: IsOption]: ResponseAs[Either[E, B]] = + asStringAlways + .mapWithMetadata(ResponseAs.deserializeEitherWithErrorOrThrow(deserializeJson[E], deserializeJson[B])) + .showAsJsonEitherOrFail + def deserializeJson[B: JsonValueCodec: IsOption]: String => Either[Exception, B] = { (s: String) => try Right(readFromString[B](JsonInput.sanitize[B].apply(s))) catch { diff --git a/json/jsoniter/src/test/scala/sttp/client4/jsoniter/JsoniterJsonTests.scala b/json/jsoniter/src/test/scala/sttp/client4/jsoniter/JsoniterJsonTests.scala index d1cfa2ea43..c1e0fd44a5 100644 --- a/json/jsoniter/src/test/scala/sttp/client4/jsoniter/JsoniterJsonTests.scala +++ b/json/jsoniter/src/test/scala/sttp/client4/jsoniter/JsoniterJsonTests.scala @@ -9,6 +9,7 @@ import sttp.client4.internal.Utf8 import sttp.model._ import com.github.plokhotnyuk.jsoniter_scala.core._ import com.github.plokhotnyuk.jsoniter_scala.macros._ +import sttp.client4.json.RunResponseAs class JsoniterJsonTests extends AnyFlatSpec with Matchers with EitherValues { @@ -25,17 +26,17 @@ class JsoniterJsonTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - runJsonResponseAs(responseAs)(body).value shouldBe expected + RunResponseAs(responseAs)(body).value shouldBe expected } it should "decode None from empty body" in { val responseAs = asJson[Option[Inner]] - runJsonResponseAs(responseAs)("").value shouldBe None + RunResponseAs(responseAs)("").value shouldBe None } it should "decode Right(None) from empty body" in { val responseAs = asJsonEither[Inner, Option[Outer]] - runJsonResponseAs(responseAs)("").value shouldBe None + RunResponseAs(responseAs)("").value shouldBe None } it should "fail to decode invalid json" in { @@ -43,14 +44,13 @@ class JsoniterJsonTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - val Left(DeserializationException(original, _)) = runJsonResponseAs(responseAs)(body) + val Left(DeserializationException(original, _)) = RunResponseAs(responseAs)(body) original shouldBe body } it should "fail to decode from empty input" in { val responseAs = asJson[Inner] - runJsonResponseAs(responseAs)("").left.value should matchPattern { - case DeserializationException("", _: Exception) => + RunResponseAs(responseAs)("").left.value should matchPattern { case DeserializationException("", _: Exception) => } } @@ -58,7 +58,7 @@ class JsoniterJsonTests extends AnyFlatSpec with Matchers with EitherValues { val outer = Outer(Inner(42, true, "horses"), "cats") val encoded = extractBody(basicRequest.body(asJson(outer))) - val decoded = runJsonResponseAs(asJson[Outer])(encoded) + val decoded = RunResponseAs(asJson[Outer])(encoded) decoded.right.value shouldBe outer } @@ -95,6 +95,37 @@ class JsoniterJsonTests extends AnyFlatSpec with Matchers with EitherValues { actualContentType should be(expectedContentType) } + it should "decode when using asJsonOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonOrFail[Outer])(body) shouldBe expected + } + + it should "fail when using asJsonOrFail for incorrect JSON" in { + val body = """invalid json""" + + assertThrows[DeserializationException[Exception]] { + RunResponseAs(asJsonOrFail[Outer])(body) + } + } + + it should "decode success when using asJsonEitherOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer])(body) shouldBe Right(expected) + } + + it should "decode failure when using asJsonEitherOrFail" in { + val body = """{"a":21,"b":false,"c":"hippos"}""" + val expected = Inner(21, false, "hippos") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer], ResponseMetadata(StatusCode.BadRequest, "", Nil))( + body + ) shouldBe Left(expected) + } + def extractBody[T](request: PartialRequest[T]): String = request.body match { case StringBody(body, "utf-8", MediaType.ApplicationJson) => @@ -102,18 +133,6 @@ class JsoniterJsonTests extends AnyFlatSpec with Matchers with EitherValues { 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") - } } case class Inner(a: Int, b: Boolean, c: 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 e1d948d8cc..4bb475e425 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 @@ -7,6 +7,7 @@ import sttp.client4._ import sttp.model.MediaType import scala.util.{Failure, Success, Try} +import sttp.client4.ResponseAs.deserializeEitherWithErrorOrThrow trait SttpPlayJsonApi { implicit val errorMessageForPlayError: ShowError[JsError] = new ShowError[JsError] { @@ -25,6 +26,12 @@ trait SttpPlayJsonApi { def asJson[B: Reads: IsOption]: ResponseAs[Either[ResponseException[String, JsError], B]] = asString.mapWithMetadata(ResponseAs.deserializeRightWithError(deserializeJson[B])).showAsJson + /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Otherwise, if the + * response code is other than 2xx, or a deserialization error occurs, throws an [[ResponseException]] / returns a + * failed effect. + */ + def asJsonOrFail[B: Reads: IsOption]: ResponseAs[B] = asJson[B].orFail.showAsJsonOrFail + /** 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 @@ -47,6 +54,14 @@ trait SttpPlayJsonApi { } }.showAsJsonEither + /** Deserializes the body from a string into JSON, using different deserializers depending on the status code. If a + * deserialization error occurs, throws a [[DeserializationException]] / returns a failed effect. + */ + def asJsonEitherOrFail[E: Reads: IsOption, B: Reads: IsOption]: ResponseAs[Either[E, B]] = + asStringAlways + .mapWithMetadata(deserializeEitherWithErrorOrThrow(deserializeJson[E], deserializeJson[B])) + .showAsJsonEitherOrFail + // Note: None of the play-json utilities attempt to catch invalid // json, so Json.parse needs to be wrapped in Try def deserializeJson[B: Reads: IsOption]: String => Either[JsError, B] = diff --git a/json/play-json/src/test/scala/sttp/client4/PlayJsonTests.scala b/json/play-json/src/test/scala/sttp/client4/PlayJsonTests.scala index 957b5ee27b..decc616317 100644 --- a/json/play-json/src/test/scala/sttp/client4/PlayJsonTests.scala +++ b/json/play-json/src/test/scala/sttp/client4/PlayJsonTests.scala @@ -8,6 +8,7 @@ import org.scalatest._ import sttp.model.StatusCode import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import sttp.client4.json.RunResponseAs class PlayJsonTests extends AnyFlatSpec with Matchers with EitherValues { @@ -28,7 +29,7 @@ class PlayJsonTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - runJsonResponseAs(responseAs)(body) shouldBe Right(expected) + RunResponseAs(responseAs)(body) shouldBe Right(expected) } it should "decode None from empty body" in { @@ -36,7 +37,7 @@ class PlayJsonTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Option[Inner]] - runJsonResponseAs(responseAs)("") shouldBe Right(None) + RunResponseAs(responseAs)("") shouldBe Right(None) } it should "decode Left(None) from empty body" in { @@ -45,7 +46,7 @@ class PlayJsonTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Either[Option[Inner], Outer]] - runJsonResponseAs(responseAs)("") shouldBe Right(Left(None)) + RunResponseAs(responseAs)("") shouldBe Right(Left(None)) } it should "decode Right(None) from empty body" in { @@ -54,13 +55,13 @@ class PlayJsonTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Either[Outer, Option[Inner]]] - runJsonResponseAs(responseAs)("") shouldBe Right(Right(None)) + RunResponseAs(responseAs)("") shouldBe Right(Right(None)) } it should "fail to decode from empty input" in { val responseAs = asJson[Inner] - runJsonResponseAs(responseAs)("") should matchPattern { case Left(DeserializationException("", _)) => + RunResponseAs(responseAs)("") should matchPattern { case Left(DeserializationException("", _)) => } } @@ -69,7 +70,7 @@ class PlayJsonTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - runJsonResponseAs(responseAs)(body) should matchPattern { case Left(DeserializationException(`body`, _)) => + RunResponseAs(responseAs)(body) should matchPattern { case Left(DeserializationException(`body`, _)) => } } @@ -77,7 +78,7 @@ class PlayJsonTests extends AnyFlatSpec with Matchers with EitherValues { val outer = Outer(Inner(42, true, "horses"), "cats") val encoded = extractBody(basicRequest.body(asJson(outer))) - val decoded = runJsonResponseAs(asJson[Outer])(encoded) + val decoded = RunResponseAs(asJson[Outer])(encoded) decoded.value shouldBe outer } @@ -116,6 +117,37 @@ class PlayJsonTests extends AnyFlatSpec with Matchers with EitherValues { actualContentType should be(expectedContentType) } + it should "decode when using asJsonOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonOrFail[Outer])(body) shouldBe expected + } + + it should "fail when using asJsonOrFail for incorrect JSON" in { + val body = """invalid json""" + + assertThrows[DeserializationException[JsError]] { + RunResponseAs(asJsonOrFail[Outer])(body) + } + } + + it should "decode success when using asJsonEitherOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer])(body) shouldBe Right(expected) + } + + it should "decode failure when using asJsonEitherOrFail" in { + val body = """{"a":21,"b":false,"c":"hippos"}""" + val expected = Inner(21, false, "hippos") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer], ResponseMetadata(StatusCode.BadRequest, "", Nil))( + body + ) shouldBe Left(expected) + } + case class Inner(a: Int, b: Boolean, c: String) object Inner { @@ -149,16 +181,4 @@ class PlayJsonTests extends AnyFlatSpec with Matchers with EitherValues { 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[_, _, _] => - 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") - } } 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 fd226c0333..021fe4577b 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 @@ -5,6 +5,7 @@ import sttp.client4.internal.Utf8 import sttp.client4._ import sttp.client4.json._ import sttp.model._ +import sttp.client4.ResponseAs.deserializeEitherOrThrow trait SttpSprayJsonApi { @@ -20,6 +21,12 @@ trait SttpSprayJsonApi { def asJson[B: JsonReader: IsOption]: ResponseAs[Either[ResponseException[String, Exception], B]] = asString.mapWithMetadata(ResponseAs.deserializeRightCatchingExceptions(deserializeJson[B])).showAsJson + /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Otherwise, if the + * response code is other than 2xx, or a deserialization error occurs, throws an [[ResponseException]] / returns a + * failed effect. + */ + def asJsonOrFail[B: JsonReader: IsOption]: ResponseAs[B] = asJson[B].orFail.showAsJsonOrFail + /** 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 @@ -43,6 +50,14 @@ trait SttpSprayJsonApi { } }.showAsJsonEither + /** Deserializes the body from a string into JSON, using different deserializers depending on the status code. If a + * deserialization error occurs, throws a [[DeserializationException]] / returns a failed effect. + */ + def asJsonEitherOrFail[E: JsonReader: IsOption, B: JsonReader: IsOption]: ResponseAs[Either[E, B]] = + asStringAlways + .mapWithMetadata(deserializeEitherOrThrow(deserializeJson[E], deserializeJson[B])) + .showAsJsonEitherOrFail + def deserializeJson[B: JsonReader: IsOption]: String => B = JsonInput.sanitize[B].andThen((_: String).parseJson.convertTo[B]) } diff --git a/json/spray-json/src/test/scala/sttp/client4/sprayJson/SprayJsonTests.scala b/json/spray-json/src/test/scala/sttp/client4/sprayJson/SprayJsonTests.scala index a1cf3843c4..cbdba27b35 100644 --- a/json/spray-json/src/test/scala/sttp/client4/sprayJson/SprayJsonTests.scala +++ b/json/spray-json/src/test/scala/sttp/client4/sprayJson/SprayJsonTests.scala @@ -8,14 +8,12 @@ import sttp.model._ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import sttp.client4.basicRequest -import sttp.client4.ResponseAs import sttp.client4.PartialRequest import sttp.client4.StringBody -import sttp.client4.MappedResponseAs -import sttp.client4.ResponseAsByteArray import sttp.client4.Request import sttp.client4.DeserializationException import spray.json.JsonParser.ParsingException +import sttp.client4.json.RunResponseAs class SprayJsonTests extends AnyFlatSpec with Matchers with EitherValues { import SprayJsonTests._ @@ -39,32 +37,31 @@ class SprayJsonTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - runJsonResponseAs(responseAs)(body) shouldBe Right(expected) + RunResponseAs(responseAs)(body) shouldBe Right(expected) } it should "decode None from empty body" in { val responseAs = asJson[Option[Inner]] - runJsonResponseAs(responseAs)("") shouldBe Right(None) + RunResponseAs(responseAs)("") shouldBe Right(None) } it should "decode Left(None) from empty body" in { val responseAs = asJson[Either[Option[Inner], Outer]] - runJsonResponseAs(responseAs)("") shouldBe Right(Left(None)) + RunResponseAs(responseAs)("") shouldBe Right(Left(None)) } it should "decode Right(None) from empty body" in { val responseAs = asJson[Either[Outer, Option[Inner]]] - runJsonResponseAs(responseAs)("") shouldBe Right(Right(None)) + RunResponseAs(responseAs)("") shouldBe Right(Right(None)) } it should "fail to decode from empty input" in { val responseAs = asJson[Inner] - runJsonResponseAs(responseAs)("") should matchPattern { - case Left(DeserializationException(_, _: ParsingException)) => + RunResponseAs(responseAs)("") should matchPattern { case Left(DeserializationException(_, _: ParsingException)) => } } @@ -73,8 +70,7 @@ class SprayJsonTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - runJsonResponseAs(responseAs)(body) should matchPattern { - case Left(DeserializationException(_, _: ParsingException)) => + RunResponseAs(responseAs)(body) should matchPattern { case Left(DeserializationException(_, _: ParsingException)) => } } @@ -104,6 +100,37 @@ class SprayJsonTests extends AnyFlatSpec with Matchers with EitherValues { actualContentType should be(expectedContentType) } + it should "decode when using asJsonOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonOrFail[Outer])(body) shouldBe expected + } + + it should "fail when using asJsonOrFail for incorrect JSON" in { + val body = """invalid json""" + + assertThrows[DeserializationException[Exception]] { + RunResponseAs(asJsonOrFail[Outer])(body) + } + } + + it should "decode success when using asJsonEitherOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer])(body) shouldBe Right(expected) + } + + it should "decode failure when using asJsonEitherOrFail" in { + val body = """{"a":21,"b":false,"c":"hippos"}""" + val expected = Inner(21, false, "hippos") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer], ResponseMetadata(StatusCode.BadRequest, "", Nil))( + body + ) shouldBe Left(expected) + } + def extractBody[T](request: PartialRequest[T]): String = request.body match { case StringBody(body, "utf-8", MediaType.ApplicationJson) => @@ -111,18 +138,6 @@ class SprayJsonTests extends AnyFlatSpec with Matchers with EitherValues { 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] @unchecked => - 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") - } } object SprayJsonTests { 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 index 607917815c..cfad65863e 100644 --- a/json/tethys-json/src/main/scala/sttp/client4/tethysJson/SttpTethysApi.scala +++ b/json/tethys-json/src/main/scala/sttp/client4/tethysJson/SttpTethysApi.scala @@ -8,6 +8,7 @@ import tethys._ import tethys.readers.ReaderError import tethys.readers.tokens.TokenIteratorProducer import tethys.writers.tokens.TokenWriterProducer +import sttp.client4.ResponseAs.deserializeEitherWithErrorOrThrow trait SttpTethysApi { @@ -27,6 +28,14 @@ trait SttpTethysApi { ): ResponseAs[Either[ResponseException[String, ReaderError], B]] = asString.mapWithMetadata(ResponseAs.deserializeRightWithError(deserializeJson)).showAsJson + /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Otherwise, if the + * response code is other than 2xx, or a deserialization error occurs, throws an [[ResponseException]] / returns a + * failed effect. + */ + def asJsonOrFail[B: JsonReader: IsOption](implicit + producer: TokenIteratorProducer + ): ResponseAs[B] = asJson[B].orFail.showAsJsonOrFail + /** 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 @@ -36,6 +45,16 @@ trait SttpTethysApi { ): ResponseAs[Either[DeserializationException[ReaderError], B]] = asStringAlways.map(ResponseAs.deserializeWithError(deserializeJson)).showAsJsonAlways + /** Deserializes the body from a string into JSON, using different deserializers depending on the status code. If a + * deserialization error occurs, throws a [[DeserializationException]] / returns a failed effect. + */ + def asJsonEitherOrFail[E: JsonReader: IsOption, B: JsonReader: IsOption](implicit + producer: TokenIteratorProducer + ): ResponseAs[Either[E, B]] = + asStringAlways + .mapWithMetadata(deserializeEitherWithErrorOrThrow(deserializeJson[E], deserializeJson[B])) + .showAsJsonEitherOrFail + private def deserializeJson[B: JsonReader: IsOption](implicit producer: TokenIteratorProducer ): String => Either[ReaderError, B] = 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 index e7312ec4e0..fbfcc49100 100644 --- a/json/tethys-json/src/test/scala/sttp/client4/tethysJson/TethysTests.scala +++ b/json/tethys-json/src/test/scala/sttp/client4/tethysJson/TethysTests.scala @@ -11,6 +11,7 @@ import tethys.jackson.{jacksonTokenIteratorProducer, jacksonTokenWriterProducer} import tethys.readers.tokens.TokenIterator import tethys.readers.{FieldName, ReaderError} import tethys.{JsonReader, JsonWriter} +import sttp.client4.json.RunResponseAs import scala.util.{Failure, Success, Try} @@ -31,33 +32,33 @@ class TethysTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - runJsonResponseAs(responseAs)(body).right.value shouldBe expected + RunResponseAs(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 + RunResponseAs(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) + RunResponseAs(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) + RunResponseAs(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)) => + RunResponseAs(responseAs)("") should matchPattern { case Left(DeserializationException("", _: ReaderError)) => } } @@ -66,7 +67,7 @@ class TethysTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - val Left(DeserializationException(original, _)) = runJsonResponseAs(responseAs)(body) + val Left(DeserializationException(original, _)) = RunResponseAs(responseAs)(body) original shouldBe body } @@ -74,7 +75,7 @@ class TethysTests extends AnyFlatSpec with Matchers with EitherValues { val outer = Outer(Inner(42, true, "horses"), "cats") val encoded = extractBody(basicRequest.body(asJson(outer)), MediaType.ApplicationJson) - val decoded = runJsonResponseAs(asJson[Outer])(encoded) + val decoded = RunResponseAs(asJson[Outer])(encoded) decoded.right.value shouldBe outer } @@ -97,6 +98,37 @@ class TethysTests extends AnyFlatSpec with Matchers with EitherValues { ct shouldBe Some("horses/cats") } + it should "decode when using asJsonOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonOrFail[Outer])(body) shouldBe expected + } + + it should "fail when using asJsonOrFail for incorrect JSON" in { + val body = """invalid json""" + + assertThrows[DeserializationException[ReaderError]] { + RunResponseAs(asJsonOrFail[Outer])(body) + } + } + + it should "decode success when using asJsonEitherOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer])(body) shouldBe Right(expected) + } + + it should "decode failure when using asJsonEitherOrFail" in { + val body = """{"a":21,"b":false,"c":"hippos"}""" + val expected = Inner(21, false, "hippos") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer], ResponseMetadata(StatusCode.BadRequest, "", Nil))( + body + ) shouldBe Left(expected) + } + case class Inner(a: Int, b: Boolean, c: String) object Inner { @@ -141,16 +173,4 @@ class TethysTests extends AnyFlatSpec with Matchers with EitherValues { 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") - } - } 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 8059ee95c1..338411029a 100644 --- a/json/upickle/src/main/scala/sttp/client4/upicklejson/SttpUpickleApi.scala +++ b/json/upickle/src/main/scala/sttp/client4/upicklejson/SttpUpickleApi.scala @@ -4,6 +4,7 @@ import sttp.client4._ import sttp.client4.internal.Utf8 import sttp.model.MediaType import sttp.client4.json._ +import sttp.client4.ResponseAs.deserializeEitherWithErrorOrThrow trait SttpUpickleApi { val upickleApi: upickle.Api @@ -20,6 +21,12 @@ trait SttpUpickleApi { def asJson[B: upickleApi.Reader: IsOption]: ResponseAs[Either[ResponseException[String, Exception], B]] = asString.mapWithMetadata(ResponseAs.deserializeRightWithError(deserializeJson)).showAsJson + /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Otherwise, if the + * response code is other than 2xx, or a deserialization error occurs, throws an [[ResponseException]] / returns a + * failed effect. + */ + def asJsonOrFail[B: upickleApi.Reader: IsOption]: ResponseAs[B] = asJson[B].orFail.showAsJsonOrFail + /** 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 @@ -42,6 +49,14 @@ trait SttpUpickleApi { } }.showAsJsonEither + /** Deserializes the body from a string into JSON, using different deserializers depending on the status code. If a + * deserialization error occurs, throws a [[DeserializationException]] / returns a failed effect. + */ + def asJsonEitherOrFail[E: upickleApi.Reader: IsOption, B: upickleApi.Reader: IsOption]: ResponseAs[Either[E, B]] = + asStringAlways + .mapWithMetadata(deserializeEitherWithErrorOrThrow(deserializeJson[E], deserializeJson[B])) + .showAsJsonEitherOrFail + def deserializeJson[B: upickleApi.Reader: IsOption]: String => Either[Exception, B] = { (s: String) => try Right(upickleApi.read[B](JsonInput.sanitize[B].apply(s))) 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 1247cd9990..5681dd4459 100644 --- a/json/upickle/src/test/scala/sttp/client4/upicklejson/UpickleTests.scala +++ b/json/upickle/src/test/scala/sttp/client4/upicklejson/UpickleTests.scala @@ -7,6 +7,7 @@ import sttp.model._ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import ujson.Obj +import sttp.client4.json.RunResponseAs class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { "The upickle module" should "encode arbitrary bodies given an encoder" in { @@ -30,7 +31,7 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - runJsonResponseAs(responseAs)(body).right.value shouldBe expected + RunResponseAs(responseAs)(body).right.value shouldBe expected } it should "decode None from empty array body" in { @@ -39,7 +40,7 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Option[Inner]] - runJsonResponseAs(responseAs)("[]").right.value shouldBe None + RunResponseAs(responseAs)("[]").right.value shouldBe None } it should "decode Left(None) from upickle notation" in { @@ -48,7 +49,7 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Either[Option[Inner], Outer]] - runJsonResponseAs(responseAs)("[0,[]]").right.value shouldBe Left(None) + RunResponseAs(responseAs)("[0,[]]").right.value shouldBe Left(None) } it should "decode Right(None) from upickle notation" in { @@ -57,7 +58,7 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Either[Outer, Option[Inner]]] - runJsonResponseAs(responseAs)("[1,[]]").right.value shouldBe Right(None) + RunResponseAs(responseAs)("[1,[]]").right.value shouldBe Right(None) } it should "fail to decode from empty input" in { @@ -66,7 +67,7 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Inner] - runJsonResponseAs(responseAs)("").left.value should matchPattern { case DeserializationException(_, _) => } + RunResponseAs(responseAs)("").left.value should matchPattern { case DeserializationException(_, _) => } } it should "fail to decode invalid json" in { @@ -77,7 +78,7 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - val Left(DeserializationException(original, _)) = runJsonResponseAs(responseAs)(body) + val Left(DeserializationException(original, _)) = RunResponseAs(responseAs)(body) original shouldBe body } @@ -88,7 +89,7 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { val outer = Outer(Inner(42, true, "horses"), "cats") val encoded = extractBody(basicRequest.body(asJson(outer))) - val decoded = runJsonResponseAs(asJson[Outer])(encoded) + val decoded = RunResponseAs(asJson[Outer])(encoded) decoded.right.value shouldBe outer } @@ -152,6 +153,49 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { extractBody(req) shouldBe expected } + it should "decode when using asJsonOrFail" 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") + + RunResponseAs(asJsonOrFail[Outer])(body) shouldBe expected + } + + it should "fail when using asJsonOrFail for incorrect JSON" in { + import UsingDefaultReaderWriters._ + import sttp.client4.upicklejson.default._ + + val body = """invalid json""" + + assertThrows[DeserializationException[Exception]] { + RunResponseAs(asJsonOrFail[Outer])(body) + } + } + + it should "decode success when using asJsonEitherOrFail" 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") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer])(body) shouldBe Right(expected) + } + + it should "decode failure when using asJsonEitherOrFail" in { + import UsingDefaultReaderWriters._ + import sttp.client4.upicklejson.default._ + + val body = """{"a":21,"b":false,"c":"hippos"}""" + val expected = Inner(21, false, "hippos") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer], ResponseMetadata(StatusCode.BadRequest, "", Nil))( + body + ) shouldBe Left(expected) + } + case class Inner(a: Int, b: Boolean, c: String) case class Outer(foo: Inner, bar: String) @@ -174,16 +218,4 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { 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] @unchecked => - 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") - } } 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 151ad08dbc..21c72048ba 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 @@ -29,6 +29,12 @@ trait SttpZioJsonApi extends SttpZioJsonApiExtensions { def asJson[B: JsonDecoder: IsOption]: ResponseAs[Either[ResponseException[String, String], B]] = asString.mapWithMetadata(ResponseAs.deserializeRightWithError(deserializeJson)).showAsJson + /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Otherwise, if the + * response code is other than 2xx, or a deserialization error occurs, throws an [[ResponseException]] / returns a + * failed effect. + */ + def asJsonOrFail[B: JsonDecoder: IsOption]: ResponseAs[B] = asJson[B].orFail.showAsJsonOrFail + /** 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 @@ -51,6 +57,14 @@ trait SttpZioJsonApi extends SttpZioJsonApiExtensions { } }.showAsJsonEither + /** Deserializes the body from a string into JSON, using different deserializers depending on the status code. If a + * deserialization error occurs, throws a [[DeserializationException]] / returns a failed effect. + */ + def asJsonEitherOrFail[E: JsonDecoder: IsOption, B: JsonDecoder: IsOption]: ResponseAs[Either[E, B]] = + asStringAlways + .mapWithMetadata(ResponseAs.deserializeEitherWithErrorOrThrow(deserializeJson[E], deserializeJson[B])) + .showAsJsonEitherOrFail + def deserializeJson[B: JsonDecoder: IsOption]: String => Either[String, B] = JsonInput.sanitize[B].andThen(_.fromJson[B]) } diff --git a/json/zio-json/src/test/scala/sttp/client4/ziojson/ZioJsonTests.scala b/json/zio-json/src/test/scala/sttp/client4/ziojson/ZioJsonTests.scala index 31153d1e4d..8777f7683f 100644 --- a/json/zio-json/src/test/scala/sttp/client4/ziojson/ZioJsonTests.scala +++ b/json/zio-json/src/test/scala/sttp/client4/ziojson/ZioJsonTests.scala @@ -9,6 +9,7 @@ import sttp.client4.internal.Utf8 import sttp.model._ import zio.Chunk import zio.json.ast.Json +import sttp.client4.json.RunResponseAs class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { @@ -25,27 +26,27 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - runJsonResponseAs(responseAs)(body).right.value shouldBe expected + RunResponseAs(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 + RunResponseAs(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) + RunResponseAs(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) + RunResponseAs(responseAs)("").right.value shouldBe Right(None) } it should "fail to decode invalid json" in { @@ -53,14 +54,14 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - val Left(DeserializationException(original, _)) = runJsonResponseAs(responseAs)(body) + val Left(DeserializationException(original, _)) = RunResponseAs(responseAs)(body) original shouldBe body } it should "fail to decode from empty input" in { val responseAs = asJson[Inner] - runJsonResponseAs(responseAs)("").left.value should matchPattern { case DeserializationException("", _: String) => + RunResponseAs(responseAs)("").left.value should matchPattern { case DeserializationException("", _: String) => } } @@ -68,7 +69,7 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { val outer = Outer(Inner(42, true, "horses"), "cats") val encoded = extractBody(basicRequest.body(asJson(outer))) - val decoded = runJsonResponseAs(asJson[Outer])(encoded) + val decoded = RunResponseAs(asJson[Outer])(encoded) decoded.right.value shouldBe outer } @@ -106,6 +107,37 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { actualContentType should be(expectedContentType) } + it should "decode when using asJsonOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonOrFail[Outer])(body) shouldBe expected + } + + it should "fail when using asJsonOrFail for incorrect JSON" in { + val body = """invalid json""" + + assertThrows[DeserializationException[Exception]] { + RunResponseAs(asJsonOrFail[Outer])(body) + } + } + + it should "decode success when using asJsonEitherOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer])(body) shouldBe Right(expected) + } + + it should "decode failure when using asJsonEitherOrFail" in { + val body = """{"a":21,"b":false,"c":"hippos"}""" + val expected = Inner(21, false, "hippos") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer], ResponseMetadata(StatusCode.BadRequest, "", Nil))( + body + ) shouldBe Left(expected) + } + def extractBody[T](request: PartialRequest[T]): String = request.body match { case StringBody(body, "utf-8", MediaType.ApplicationJson) => @@ -114,18 +146,6 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { 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] @unchecked => - 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") - } - object EitherDecoders { implicit def decoder[L: JsonDecoder, R: JsonDecoder]: JsonDecoder[Either[L, R]] = implicitly[JsonDecoder[L]] <+> implicitly[JsonDecoder[R]] 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 151ad08dbc..21c72048ba 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 @@ -29,6 +29,12 @@ trait SttpZioJsonApi extends SttpZioJsonApiExtensions { def asJson[B: JsonDecoder: IsOption]: ResponseAs[Either[ResponseException[String, String], B]] = asString.mapWithMetadata(ResponseAs.deserializeRightWithError(deserializeJson)).showAsJson + /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Otherwise, if the + * response code is other than 2xx, or a deserialization error occurs, throws an [[ResponseException]] / returns a + * failed effect. + */ + def asJsonOrFail[B: JsonDecoder: IsOption]: ResponseAs[B] = asJson[B].orFail.showAsJsonOrFail + /** 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 @@ -51,6 +57,14 @@ trait SttpZioJsonApi extends SttpZioJsonApiExtensions { } }.showAsJsonEither + /** Deserializes the body from a string into JSON, using different deserializers depending on the status code. If a + * deserialization error occurs, throws a [[DeserializationException]] / returns a failed effect. + */ + def asJsonEitherOrFail[E: JsonDecoder: IsOption, B: JsonDecoder: IsOption]: ResponseAs[Either[E, B]] = + asStringAlways + .mapWithMetadata(ResponseAs.deserializeEitherWithErrorOrThrow(deserializeJson[E], deserializeJson[B])) + .showAsJsonEitherOrFail + def deserializeJson[B: JsonDecoder: IsOption]: String => Either[String, B] = JsonInput.sanitize[B].andThen(_.fromJson[B]) } diff --git a/json/zio1-json/src/test/scala/sttp/client4/ziojson/ZioJsonTests.scala b/json/zio1-json/src/test/scala/sttp/client4/ziojson/ZioJsonTests.scala index 4067062487..8777f7683f 100644 --- a/json/zio1-json/src/test/scala/sttp/client4/ziojson/ZioJsonTests.scala +++ b/json/zio1-json/src/test/scala/sttp/client4/ziojson/ZioJsonTests.scala @@ -9,6 +9,7 @@ import sttp.client4.internal.Utf8 import sttp.model._ import zio.Chunk import zio.json.ast.Json +import sttp.client4.json.RunResponseAs class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { @@ -25,27 +26,27 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - runJsonResponseAs(responseAs)(body).right.value shouldBe expected + RunResponseAs(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 + RunResponseAs(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) + RunResponseAs(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) + RunResponseAs(responseAs)("").right.value shouldBe Right(None) } it should "fail to decode invalid json" in { @@ -53,14 +54,14 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { val responseAs = asJson[Outer] - val Left(DeserializationException(original, _)) = runJsonResponseAs(responseAs)(body) + val Left(DeserializationException(original, _)) = RunResponseAs(responseAs)(body) original shouldBe body } it should "fail to decode from empty input" in { val responseAs = asJson[Inner] - runJsonResponseAs(responseAs)("").left.value should matchPattern { case DeserializationException("", _: String) => + RunResponseAs(responseAs)("").left.value should matchPattern { case DeserializationException("", _: String) => } } @@ -68,7 +69,7 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { val outer = Outer(Inner(42, true, "horses"), "cats") val encoded = extractBody(basicRequest.body(asJson(outer))) - val decoded = runJsonResponseAs(asJson[Outer])(encoded) + val decoded = RunResponseAs(asJson[Outer])(encoded) decoded.right.value shouldBe outer } @@ -106,6 +107,37 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { actualContentType should be(expectedContentType) } + it should "decode when using asJsonOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonOrFail[Outer])(body) shouldBe expected + } + + it should "fail when using asJsonOrFail for incorrect JSON" in { + val body = """invalid json""" + + assertThrows[DeserializationException[Exception]] { + RunResponseAs(asJsonOrFail[Outer])(body) + } + } + + it should "decode success when using asJsonEitherOrFail" in { + val body = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" + val expected = Outer(Inner(42, true, "horses"), "cats") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer])(body) shouldBe Right(expected) + } + + it should "decode failure when using asJsonEitherOrFail" in { + val body = """{"a":21,"b":false,"c":"hippos"}""" + val expected = Inner(21, false, "hippos") + + RunResponseAs(asJsonEitherOrFail[Inner, Outer], ResponseMetadata(StatusCode.BadRequest, "", Nil))( + body + ) shouldBe Left(expected) + } + def extractBody[T](request: PartialRequest[T]): String = request.body match { case StringBody(body, "utf-8", MediaType.ApplicationJson) => @@ -114,18 +146,6 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { 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") - } - object EitherDecoders { implicit def decoder[L: JsonDecoder, R: JsonDecoder]: JsonDecoder[Either[L, R]] = implicitly[JsonDecoder[L]] <+> implicitly[JsonDecoder[R]] From 29df36321bd856d5203b5562dcd0b9d42ac69b04 Mon Sep 17 00:00:00 2001 From: adamw Date: Wed, 11 Dec 2024 17:53:51 +0100 Subject: [PATCH 11/11] quickRequest now uses asStringOrFail --- core/src/main/scala/sttp/client4/SttpApi.scala | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/src/main/scala/sttp/client4/SttpApi.scala b/core/src/main/scala/sttp/client4/SttpApi.scala index 7fda9af0c5..368242e0a3 100644 --- a/core/src/main/scala/sttp/client4/SttpApi.scala +++ b/core/src/main/scala/sttp/client4/SttpApi.scala @@ -53,8 +53,10 @@ trait SttpApi extends SttpExtensions with UriInterpolator { val basicRequest: PartialRequest[Either[String, String]] = emptyRequest.acceptEncoding("gzip, deflate") - /** A starting request which always reads the response body as a string, regardless of the status code. */ - val quickRequest: PartialRequest[String] = basicRequest.response(asStringAlways) + /** A starting request which always reads the response body as a string, if the response code is successfull (2xx), + * and fails (throws an exception, or returns a failed effect) otherwise. + */ + val quickRequest: PartialRequest[String] = basicRequest.response(asStringOrFail) // response descriptions