Skip to content

Commit

Permalink
Add asJsonOrFail methods
Browse files Browse the repository at this point in the history
  • Loading branch information
adamw committed Dec 11, 2024
1 parent 62ead1f commit e4d8d95
Show file tree
Hide file tree
Showing 27 changed files with 591 additions and 203 deletions.
23 changes: 12 additions & 11 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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"

Expand All @@ -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)
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions core/src/main/scala/sttp/client4/ResponseAs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 8 additions & 8 deletions core/src/main/scala/sttp/client4/SttpApi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions core/src/main/scala/sttp/client4/SttpWebSocketAsyncApi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions core/src/main/scala/sttp/client4/SttpWebSocketStreamApi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions core/src/main/scala/sttp/client4/SttpWebSocketSyncApi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions docs/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
15 changes: 15 additions & 0 deletions json/circe/src/main/scala/sttp/client4/circe/SttpCirceApi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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
Expand All @@ -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])
}
Loading

0 comments on commit e4d8d95

Please sign in to comment.