Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve usability of response specifications #2365

Merged
merged 11 commits into from
Dec 12, 2024
Prev Previous commit
Next Next commit
Add asJsonOrFail methods
  • Loading branch information
adamw committed Dec 11, 2024
commit e4d8d95be0a8d3aadb8039065fdcf9a5729da1ad
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
Loading