From 2659559acb95b546c45a66a08126d0d4cae3f27e Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Wed, 4 Dec 2024 01:43:21 +0100 Subject: [PATCH 01/14] Update armeria to 1.31.2 (#2359) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index ebb13ae52..6593fd984 100644 --- a/build.sbt +++ b/build.sbt @@ -734,7 +734,7 @@ lazy val armeriaBackend = (projectMatrix in file("armeria-backend")) .settings(testServerSettings) .settings( name := "armeria-backend", - libraryDependencies += "com.linecorp.armeria" % "armeria" % "1.31.1" + libraryDependencies += "com.linecorp.armeria" % "armeria" % "1.31.2" ) .jvmPlatform(scalaVersions = scala2 ++ scala3) .dependsOn(core % compileAndTest) From 9bfaf7cc8db6ca7f1035e769b3eed280d74e5156 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Thu, 5 Dec 2024 01:45:39 +0100 Subject: [PATCH 02/14] Update http4s-client, http4s-ember-client to 0.23.30 (#2360) --- build.sbt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.sbt b/build.sbt index 6593fd984..9777d4437 100644 --- a/build.sbt +++ b/build.sbt @@ -161,7 +161,7 @@ val braveOpentracingVersion = "1.0.1" val zipkinSenderOkHttpVersion = "3.4.2" val resilience4jVersion = "2.2.0" val http4s_ce2_version = "0.22.15" -val http4s_ce3_version = "0.23.29" +val http4s_ce3_version = "0.23.30" val tethysVersion = "0.29.3" @@ -708,7 +708,7 @@ lazy val http4sBackend = (projectMatrix in file("http4s-backend")) name := "http4s-backend", libraryDependencies ++= Seq( "org.http4s" %% "http4s-client" % http4s_ce3_version, - "org.http4s" %% "http4s-ember-client" % "0.23.29" % Optional, + "org.http4s" %% "http4s-ember-client" % "0.23.30" % Optional, "org.http4s" %% "http4s-blaze-client" % "0.23.17" % Optional ), evictionErrorLevel := Level.Info From ac13e232531b0de10eaa4f917f7e9777caefce7b Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Sat, 7 Dec 2024 01:43:32 +0100 Subject: [PATCH 03/14] Update opentelemetry-api, ... to 1.45.0 (#2361) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 9777d4437..5d046e2e1 100644 --- a/build.sbt +++ b/build.sbt @@ -165,7 +165,7 @@ val http4s_ce3_version = "0.23.30" val tethysVersion = "0.29.3" -val openTelemetryVersion = "1.44.1" +val openTelemetryVersion = "1.45.0" val compileAndTest = "compile->compile;test->test" From b8ddedd8463b31a67a6e84ae39abb261ceb422d8 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Tue, 10 Dec 2024 01:44:22 +0100 Subject: [PATCH 04/14] Update zipkin-sender-okhttp3 to 3.4.3 (#2363) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 5d046e2e1..faf321986 100644 --- a/build.sbt +++ b/build.sbt @@ -158,7 +158,7 @@ val logback = "ch.qos.logback" % "logback-classic" % "1.5.12" val jaegerClientVersion = "1.8.1" val braveOpentracingVersion = "1.0.1" -val zipkinSenderOkHttpVersion = "3.4.2" +val zipkinSenderOkHttpVersion = "3.4.3" val resilience4jVersion = "2.2.0" val http4s_ce2_version = "0.22.15" val http4s_ce3_version = "0.23.30" From 3ce24c168b81b468a27f4390ea867693b4f94618 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Tue, 10 Dec 2024 08:58:29 +0100 Subject: [PATCH 05/14] Remove BodySerializer (#2362) --- .../src/main/scala/sttp/client4/SttpApi.scala | 4 +- .../scala/sttp/client4/requestBuilder.scala | 81 ++++++++++++------- .../wrappers/FollowRedirectsBackend.scala | 2 +- .../client4/PartialRequestExtensions.scala | 6 -- .../client4/PartialRequestExtensions.scala | 9 +-- .../client4/PartialRequestExtensions.scala | 6 -- docs/json.md | 22 ++--- docs/quickstart.md | 2 +- docs/requests/body.md | 18 ++--- docs/xml.md | 13 +-- ...ostSerializeJsonMonixHttpClientCirce.scala | 2 +- .../sttp/client4/circe/SttpCirceApi.scala | 7 +- .../scala/sttp/client4/circe/CirceTests.scala | 14 ++-- .../sttp/client4/json4s/SttpJson4sApi.scala | 8 +- .../test/scala/sttp/client4/Json4sTests.scala | 8 +- .../jsoniter/SttpJsoniterJsonApi.scala | 29 ++++--- .../client4/jsoniter/JsoniterJsonTests.scala | 12 +-- .../client4/playJson/SttpPlayJsonApi.scala | 5 +- .../scala/sttp/client4/PlayJsonTests.scala | 12 +-- .../client4/sprayJson/SttpSprayJsonApi.scala | 6 +- .../BackendStubSprayJsonTests.scala | 4 +- .../{ => sprayJson}/SprayJsonTests.scala | 27 ++++--- .../client4/tethysJson/SttpTethysApi.scala | 6 +- .../sttp/client4/tethysJson/TethysTests.scala | 8 +- .../client4/upicklejson/SttpUpickleApi.scala | 5 +- .../client4/upicklejson/UpickleTests.scala | 16 ++-- .../sttp/client4/ziojson/SttpZioJsonApi.scala | 27 +++---- .../ziojson/SttpZioJsonApiExtensions.scala | 11 ++- .../sttp/client4/ziojson/ZioJsonTests.scala | 14 ++-- .../sttp/client4/ziojson/SttpZioJsonApi.scala | 27 +++---- .../ziojson/SttpZioJsonApiExtensions.scala | 11 ++- .../sttp/client4/ziojson/ZioJsonTests.scala | 12 +-- 32 files changed, 226 insertions(+), 208 deletions(-) rename json/spray-json/src/test/scala/sttp/client4/{ => sprayJson}/BackendStubSprayJsonTests.scala (92%) rename json/spray-json/src/test/scala/sttp/client4/{ => sprayJson}/SprayJsonTests.scala (85%) diff --git a/core/src/main/scala/sttp/client4/SttpApi.scala b/core/src/main/scala/sttp/client4/SttpApi.scala index 8e06ce907..b6f588e1a 100644 --- a/core/src/main/scala/sttp/client4/SttpApi.scala +++ b/core/src/main/scala/sttp/client4/SttpApi.scala @@ -200,8 +200,8 @@ trait SttpApi extends SttpExtensions with UriInterpolator { ) /** Content type will be set to `application/octet-stream`, can be overridden later using the `contentType` method. */ - def multipart[B: BodySerializer](name: String, b: B): Part[BasicBodyPart] = - Part(name, implicitly[BodySerializer[B]].apply(b), contentType = Some(MediaType.ApplicationXWwwFormUrlencoded)) + def multipart(name: String, b: BasicBodyPart): Part[BasicBodyPart] = + Part(name, b, contentType = Some(MediaType.ApplicationXWwwFormUrlencoded)) // stream response specifications diff --git a/core/src/main/scala/sttp/client4/requestBuilder.scala b/core/src/main/scala/sttp/client4/requestBuilder.scala index 36d7f0d22..143f70e51 100644 --- a/core/src/main/scala/sttp/client4/requestBuilder.scala +++ b/core/src/main/scala/sttp/client4/requestBuilder.scala @@ -155,79 +155,87 @@ trait PartialRequestBuilder[+PR <: PartialRequestBuilder[PR, R], +R] private[client4] def setContentLengthIfMissing(l: => Long): PR = if (hasContentLength) this else contentLength(l) - /** Uses the `utf-8` encoding. + /** Sets the body of this request to the given string, using the UTF-8 encoding. * - * If content type is not yet specified, will be set to `text/plain` with `utf-8` encoding. + * If content type is not yet specified, will be set to `text/plain` with UTF-8 encoding. * - * If content length is not yet specified, will be set to the number of bytes in the string using the `utf-8` - * encoding. + * If content length is not yet specified, will be set to the number of bytes in the string using the UTF-8 encoding. */ def body(b: String): PR = body(b, Utf8) - /** If content type is not yet specified, will be set to `text/plain` with the given encoding. + /** Sets the body of this request to the given string, using the given encoding. + * + * If content type is not yet specified, will be set to `text/plain` with the given encoding. * * If content length is not yet specified, will be set to the number of bytes in the string using the given encoding. */ def body(b: String, encoding: String): PR = - withBody(StringBody(b, encoding)).setContentLengthIfMissing(b.getBytes(encoding).length.toLong) + body(StringBody(b, encoding)).setContentLengthIfMissing(b.getBytes(encoding).length.toLong) - /** If content type is not yet specified, will be set to `application/octet-stream`. + /** Sets the body of this request to the given byte array. + * + * If content type is not yet specified, will be set to `application/octet-stream`. * * If content length is not yet specified, will be set to the length of the given array. */ - def body(b: Array[Byte]): PR = withBody(ByteArrayBody(b)).setContentLengthIfMissing(b.length.toLong) + def body(b: Array[Byte]): PR = body(ByteArrayBody(b)).setContentLengthIfMissing(b.length.toLong) - /** If content type is not yet specified, will be set to `application/octet-stream`. */ - def body(b: ByteBuffer): PR = withBody(ByteBufferBody(b)) + /** Sets the body of this request to the given byte buffer. + * + * If content type is not yet specified, will be set to `application/octet-stream`. + */ + def body(b: ByteBuffer): PR = body(ByteBufferBody(b)) - /** If content type is not yet specified, will be set to `application/octet-stream`. + /** Sets the body of this request to the given input stream. + * + * If content type is not yet specified, will be set to `application/octet-stream`. */ - def body(b: InputStream): PR = withBody(InputStreamBody(b)) + def body(b: InputStream): PR = body(InputStreamBody(b)) /** If content type is not yet specified, will be set to `application/octet-stream`. * * If content length is not yet specified, will be set to the length of the given file. */ - private[client4] def body(f: SttpFile): PR = withBody(FileBody(f)).setContentLengthIfMissing(f.size) + private[client4] def body(f: SttpFile): PR = body(FileBody(f)).setContentLengthIfMissing(f.size) - /** Encodes the given parameters as form data using `utf-8`. If content type is not yet specified, will be set to - * `application/x-www-form-urlencoded`. + /** Sets the body of this request to the given form-data parameters. The parameters are encoded using UTF-8. + * + * If content type is not yet specified, will be set to `application/x-www-form-urlencoded`. * * If content length is not yet specified, will be set to the length of the number of bytes in the url-encoded * parameter string. */ def body(fs: Map[String, String]): PR = formDataBody(fs.toList, Utf8) - /** Encodes the given parameters as form data. If content type is not yet specified, will be set to - * `application/x-www-form-urlencoded`. + /** Sets the body of this request to the given form-data parameters. The parameters are encoded using the given + * encoding. + * + * If content type is not yet specified, will be set to `application/x-www-form-urlencoded`. * * If content length is not yet specified, will be set to the length of the number of bytes in the url-encoded * parameter string. */ def body(fs: Map[String, String], encoding: String): PR = formDataBody(fs.toList, encoding) - /** Encodes the given parameters as form data using `utf-8`. If content type is not yet specified, will be set to - * `application/x-www-form-urlencoded`. + /** Sets the body of this request to the given form-data parameters. The parameters are encoded using UTF-8. + * + * If content type is not yet specified, will be set to `application/x-www-form-urlencoded`. * * If content length is not yet specified, will be set to the length of the number of bytes in the url-encoded * parameter string. */ def body(fs: (String, String)*): PR = formDataBody(fs.toList, Utf8) - /** Encodes the given parameters as form data. If content type is not yet specified, will be set to - * `application/x-www-form-urlencoded`. + /** Sets the body of this request to the given form-data parameters. The parameters are encoded using the given + * encoding. + * + * If content type is not yet specified, will be set to `application/x-www-form-urlencoded`. * * If content length is not yet specified, will be set to the length of the number of bytes in the url-encoded * parameter string. */ def body(fs: Seq[(String, String)], encoding: String): PR = formDataBody(fs, encoding) - def multipartBody(ps: Seq[Part[BasicBodyPart]]): PR = copyWithBody(BasicMultipartBody(ps)) - - def multipartBody(p1: Part[BasicBodyPart], ps: Part[BasicBodyPart]*): PR = copyWithBody( - BasicMultipartBody(p1 :: ps.toList) - ) - private def formDataBody(fs: Seq[(String, String)], encoding: String): PR = { val b = BasicBody.paramsToStringBody(fs, encoding) copyWithBody(b) @@ -235,7 +243,20 @@ trait PartialRequestBuilder[+PR <: PartialRequestBuilder[PR, R], +R] .setContentLengthIfMissing(b.s.getBytes(encoding).length.toLong) } - def withBody(body: BasicBody): PR = { + /** Sets the body of this request to the given multipart form parts. */ + def multipartBody(ps: Seq[Part[BasicBodyPart]]): PR = copyWithBody(BasicMultipartBody(ps)) + + /** Sets the body of this request to the given multipart form parts. */ + def multipartBody(p1: Part[BasicBodyPart], ps: Part[BasicBodyPart]*): PR = copyWithBody( + BasicMultipartBody(p1 :: ps.toList) + ) + + /** Sets the body of this request to the given [[BasicBody]] implementation. + * + * If content type is not yet specified, it will be set to the default content type of the body, including the + * encoding in case of a string body. + */ + def body(body: BasicBody): PR = { val defaultCt = body match { case StringBody(_, encoding, ct) => ct.copy(charset = Some(encoding)) @@ -254,8 +275,8 @@ trait PartialRequestBuilder[+PR <: PartialRequestBuilder[PR, R], +R] def followRedirects(fr: Boolean): PR = withOptions(options.copy(followRedirects = fr)) def maxRedirects(n: Int): PR = - if (n <= 0) withOptions(options.copy(followRedirects = false)) - else withOptions(options.copy(followRedirects = true, maxRedirects = n)) + if (n <= 0) withOptions(options.copy(followRedirects = false)) + else withOptions(options.copy(followRedirects = true, maxRedirects = n)) /** When a POST or PUT request is redirected, should the redirect be a POST/PUT as well (with the original body), or * should the request be converted to a GET without a body. diff --git a/core/src/main/scala/sttp/client4/wrappers/FollowRedirectsBackend.scala b/core/src/main/scala/sttp/client4/wrappers/FollowRedirectsBackend.scala index 69215a4d1..b99bac11a 100644 --- a/core/src/main/scala/sttp/client4/wrappers/FollowRedirectsBackend.scala +++ b/core/src/main/scala/sttp/client4/wrappers/FollowRedirectsBackend.scala @@ -77,7 +77,7 @@ abstract class FollowRedirectsBackend[F[_], P] private ( if (applicable && (r.options.redirectToGet || alwaysChanged) && !neverChanged) { // when transforming POST or PUT into a get, content is dropped, also filter out content-related request headers r.method(Method.GET, r.uri) - .withBody(NoBody) + .body(NoBody) .withHeaders(r.headers.filterNot(header => config.contentHeaders.contains(header.name.toLowerCase()))) } else r } diff --git a/core/src/main/scalajs/sttp/client4/PartialRequestExtensions.scala b/core/src/main/scalajs/sttp/client4/PartialRequestExtensions.scala index c8167fce8..0c74e8093 100644 --- a/core/src/main/scalajs/sttp/client4/PartialRequestExtensions.scala +++ b/core/src/main/scalajs/sttp/client4/PartialRequestExtensions.scala @@ -10,10 +10,4 @@ trait PartialRequestExtensions[+R <: PartialRequestBuilder[R, _]] { self: R => * If content length is not yet specified, will be set to the length of the given file. */ def body(file: File): R = body(SttpFile.fromDomFile(file)) - - // this method needs to be in the extensions, so that it has lowest priority when considering overloading options - /** If content type is not yet specified, will be set to `application/octet-stream`. - */ - def body[B: BodySerializer](b: B): R = - withBody(implicitly[BodySerializer[B]].apply(b)) } diff --git a/core/src/main/scalajvm/sttp/client4/PartialRequestExtensions.scala b/core/src/main/scalajvm/sttp/client4/PartialRequestExtensions.scala index dc3777e72..1281ef357 100644 --- a/core/src/main/scalajvm/sttp/client4/PartialRequestExtensions.scala +++ b/core/src/main/scalajvm/sttp/client4/PartialRequestExtensions.scala @@ -4,13 +4,12 @@ import java.io.File import java.nio.file.Path import sttp.client4.internal.SttpFile -import sttp.client4.BodySerializer trait PartialRequestExtensions[+R <: PartialRequestBuilder[R, _]] { self: R => /** If content type is not yet specified, will be set to `application/octet-stream`. * - * If content length is noBodySerializert yet specified, will be set to the length of the given file. + * If content length is not yet specified, will be set to the length of the given file. */ def body(file: File): R = body(SttpFile.fromFile(file)) @@ -19,10 +18,4 @@ trait PartialRequestExtensions[+R <: PartialRequestBuilder[R, _]] { self: R => * If content length is not yet specified, will be set to the length of the given file. */ def body(path: Path): R = body(SttpFile.fromPath(path)) - - // this method needs to be in the extensions, so that it has lowest priority when considering overloading options - /** If content type is not yet specified, will be set to `application/octet-stream`. - */ - def body[B: BodySerializer](b: B): R = - withBody(implicitly[BodySerializer[B]].apply(b)) } diff --git a/core/src/main/scalanative/sttp/client4/PartialRequestExtensions.scala b/core/src/main/scalanative/sttp/client4/PartialRequestExtensions.scala index b0df1ea12..93f1b21b9 100644 --- a/core/src/main/scalanative/sttp/client4/PartialRequestExtensions.scala +++ b/core/src/main/scalanative/sttp/client4/PartialRequestExtensions.scala @@ -18,10 +18,4 @@ trait PartialRequestExtensions[+R <: PartialRequestBuilder[R, _]] { self: R => * If content length is not yet specified, will be set to the length of the given file. */ def body(path: Path): R = body(SttpFile.fromPath(path)) - - // this method needs to be in the extensions, so that it has lowest priority when considering overloading options - /** If content type is not yet specified, will be set to `application/octet-stream`. - */ - def body[B: BodySerializer](b: B): R = - withBody(implicitly[BodySerializer[B]].apply(b)) } diff --git a/docs/json.md b/docs/json.md index 6e297dd42..dd83ee4ae 100644 --- a/docs/json.md +++ b/docs/json.md @@ -2,19 +2,21 @@ Adding support for JSON (or other format) bodies in requests/responses is a matter of providing a [body serializer](requests/body.md) and/or a [response body specification](responses/body.md). Both are quite straightforward to implement, so integrating with your favorite JSON library shouldn't be a problem. However, there are some integrations available out-of-the-box. -Each integration is available as an import, which brings the implicit `BodySerializer`s and `asJson` methods into scope. Alternatively, these values are grouped intro traits (e.g. `sttp.client4.circe.SttpCirceApi`), which can be extended to group multiple integrations in one object, and thus reduce the number of necessary imports. +Each integration is available as an import, which brings `asJson` methods into scope. Alternatively, these values are grouped intro traits (e.g. `sttp.client4.circe.SttpCirceApi`), which can be extended to group multiple integrations in one object, and thus reduce the number of necessary imports. The following variants of `asJson` methods are available: -* regular - deserializes the body to json, only if the response is successful (2xx) -* `always` - deserializes the body to json regardless of the status code -* `either` - uses different deserializers for error and successful (2xx) responses +* `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])` +* `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 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._ +def asJson[B](b: B): StringBody = ??? def asJson[B]: ResponseAs[Either[ResponseException[String, Exception], B]] = ??? def asJsonAlways[B]: ResponseAs[Either[DeserializationException[Exception], B]] = ??? def asJsonEither[E, B]: ResponseAs[Either[ResponseException[E, Exception], B]] = ??? @@ -54,7 +56,7 @@ val requestPayload = RequestPayload("some data") val response: Response[Either[ResponseException[String, io.circe.Error], ResponsePayload]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asJson(requestPayload)) .response(asJson[ResponsePayload]) .send(backend) ``` @@ -90,7 +92,7 @@ implicit val formats = org.json4s.DefaultFormats val response: Response[Either[ResponseException[String, Exception], ResponsePayload]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asJson(requestPayload)) .response(asJson[ResponsePayload]) .send(backend) ``` @@ -122,7 +124,7 @@ val requestPayload = RequestPayload("some data") val response: Response[Either[ResponseException[String, Exception], ResponsePayload]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asJson(requestPayload)) .response(asJson[ResponsePayload]) .send(backend) ``` @@ -179,7 +181,7 @@ val requestPayload = RequestPayload("some data") val response: Response[Either[ResponseException[String, String], ResponsePayload]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asJson(requestPayload)) .response(asJson[ResponsePayload]) .send(backend) ``` @@ -219,7 +221,7 @@ val requestPayload = RequestPayload("some data") val response: Response[Either[ResponseException[String, Exception], ResponsePayload]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asJson(requestPayload)) .response(asJson[ResponsePayload]) .send(backend) ``` @@ -256,7 +258,7 @@ val requestPayload = RequestPayload("some data") val response: Response[Either[ResponseException[String, Exception], ResponsePayload]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asJson(requestPayload)) .response(asJson[ResponsePayload]) .send(backend) ``` diff --git a/docs/quickstart.md b/docs/quickstart.md index 29c3a6c1e..47e371d5d 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -93,7 +93,7 @@ implicit val responseRW: ReadWriter[HttpBinResponse] = macroRW[HttpBinResponse] val request = basicRequest .post(uri"https://httpbin.org/post") - .body(MyRequest("test", 42)) + .body(asJson(MyRequest("test", 42))) .response(asJson[HttpBinResponse]) val response = request.send(backend) diff --git a/docs/requests/body.md b/docs/requests/body.md index bbfe39569..364be5749 100644 --- a/docs/requests/body.md +++ b/docs/requests/body.md @@ -83,15 +83,11 @@ basicRequest.body("k1" -> "v1", "k2" -> "v2") basicRequest.body(Seq("k1" -> "v1", "k2" -> "v2"), "utf-8") ``` -## Custom body serializers +## Custom serializers -It is also possible to set custom types as request bodies, as long as there's an implicit `BodySerializer[B]` value in scope, which is simply an alias for a function: - -```scala -type BodySerializer[B] = B => BasicRequestBody -``` - -A `BasicRequestBody` is a wrapper for one of the supported request body types: a `String`/byte array or an input stream. +It is also possible to write custom serializers, which return arbitrary body representations. These should be +methods/functions which return instances of `BasicBody`, which is a wrapper for one of the supported request body +types: a `String`, byte array, an input stream, etc. For example, here's how to write a custom serializer for a case class, with serializer-specific default content type: @@ -101,12 +97,12 @@ import sttp.model.MediaType case class Person(name: String, surname: String, age: Int) // for this example, assuming names/surnames can't contain commas -implicit val personSerializer: BodySerializer[Person] = { p: Person => +def serializePerson(p: Person): BasicBody = { val serialized = s"${p.name},${p.surname},${p.age}" StringBody(serialized, "UTF-8", MediaType.TextCsv) } -basicRequest.body(Person("mary", "smith", 67)) +basicRequest.body(serializePerson(Person("mary", "smith", 67))) ``` -See the implementations of the `BasicRequestBody` trait for more options. +See the implementations of the `BasicBody` trait for more options. diff --git a/docs/xml.md b/docs/xml.md index c5286377b..96bea7e2e 100644 --- a/docs/xml.md +++ b/docs/xml.md @@ -12,7 +12,7 @@ After code generation, create the `SttpScalaxbApi` trait (or trait with another import generated.defaultScope // import may differ depending on location of generated code import scalaxb.`package`.{fromXML, toXML} // import may differ depending on location of generated code import scalaxb.{CanWriteXML, XMLFormat} // import may differ depending on location of generated code -import sttp.client4.{BodySerializer, ResponseAs, ResponseException, StringBody, asString} +import sttp.client4.{ResponseAs, ResponseException, StringBody, asString} import sttp.model.MediaType import scala.xml.{NodeSeq, XML} @@ -20,7 +20,7 @@ import scala.xml.{NodeSeq, XML} trait SttpScalaxbApi { case class XmlElementLabel(label: String) - implicit def scalaxbBodySerializer[B](implicit format: CanWriteXML[B], label: XmlElementLabel): BodySerializer[B] = { (b: B) => + def asXml[B](b: B)(implicit format: CanWriteXML[B], label: XmlElementLabel): StringBody = { val nodeSeq: NodeSeq = toXML[B](obj = b, elementLabel = label.label, scope = defaultScope) StringBody(nodeSeq.toString(), "utf-8", MediaType.ApplicationXml) } @@ -38,11 +38,11 @@ trait SttpScalaxbApi { .showAs("either(as string, as xml)") } ``` -This would add `BodySerializer` needed for serialization and `asXml` method needed for deserialization. Please notice, that `fromXML`, `toXML`, `CanWriteXML`, `XMLFormat` and `defaultScope` are members of code generated by scalaxb. +This would add `asXml` methods needed for serialization and deserialization. Please notice, that `fromXML`, `toXML`, `CanWriteXML`, `XMLFormat` and `defaultScope` are members of code generated by scalaxb. + +Next to this trait, you might want to introduce `sttpScalaxb` package object to simplify imports. -Next to this trait, you might want to introduce `sttpScalaxb` -package object to simplify imports. ```scala package object sttpScalaxb extends SttpScalaxbApi ``` @@ -50,6 +50,7 @@ package object sttpScalaxb extends SttpScalaxbApi From now on, XML serialization/deserialization would work for all classes generated from `.xsd` file as long as `XMLFormat` for the type in the question and `XmlElementLabel` for the top XML node would be implicitly provided in the scope. Usage example: + ```scala val backend: SyncBackend = DefaultSyncBackend() val requestPayload = Outer(Inner(42, b = true, "horses"), "cats") // `Outer` and `Inner` classes are generated by scalaxb from xsd file @@ -61,7 +62,7 @@ import generated.Generated_OuterFormat // imports member of code generated by sc val response: Response[Either[ResponseException[String, Exception], Outer]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asXml(requestPayload)) .response(asXml[Outer]) .send(backend) ``` \ No newline at end of file diff --git a/examples-ce2/src/main/scala/sttp/client4/examples/PostSerializeJsonMonixHttpClientCirce.scala b/examples-ce2/src/main/scala/sttp/client4/examples/PostSerializeJsonMonixHttpClientCirce.scala index e747d0f9a..2444118a2 100644 --- a/examples-ce2/src/main/scala/sttp/client4/examples/PostSerializeJsonMonixHttpClientCirce.scala +++ b/examples-ce2/src/main/scala/sttp/client4/examples/PostSerializeJsonMonixHttpClientCirce.scala @@ -11,7 +11,7 @@ object PostSerializeJsonMonixHttpClientCirce extends App { val postTask = HttpClientMonixBackend().flatMap { backend => val r = basicRequest - .body(Info(91, "abc")) + .body(asJson(Info(91, "abc"))) .post(uri"https://httpbin.org/post") r.send(backend) 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 ca2cdae63..a43016505 100644 --- a/json/circe/src/main/scala/sttp/client4/circe/SttpCirceApi.scala +++ b/json/circe/src/main/scala/sttp/client4/circe/SttpCirceApi.scala @@ -9,11 +9,12 @@ import sttp.client4.json._ trait SttpCirceApi { - implicit def circeBodySerializer[B](implicit + /** Serialize the given value as JSON, to be used as a request's body using [[sttp.client4.Request.body]]. */ + def asJson[B](b: B)(implicit encoder: Encoder[B], printer: Printer = Printer.noSpaces - ): BodySerializer[B] = - b => StringBody(encoder(b).printWith(printer), Utf8, MediaType.ApplicationJson) + ): StringBody = + StringBody(encoder(b).printWith(printer), Utf8, MediaType.ApplicationJson) /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Returns: * - `Right(b)` if the parsing was successful 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 3b61a690d..8ad141755 100644 --- a/json/circe/src/test/scala/sttp/client4/circe/CirceTests.scala +++ b/json/circe/src/test/scala/sttp/client4/circe/CirceTests.scala @@ -14,7 +14,7 @@ class CirceTests extends AnyFlatSpec with Matchers with EitherValues { val body = Outer(Inner(42, true, "horses"), "cats") val expected = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) extractBody(req) shouldBe expected } @@ -23,7 +23,7 @@ class CirceTests extends AnyFlatSpec with Matchers with EitherValues { val body = Outer(Inner(42, true, "horses"), "cats") implicit val printer = Printer.spaces4 - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) extractBody(req) should include("\n \"foo") } @@ -77,7 +77,7 @@ class CirceTests extends AnyFlatSpec with Matchers with EitherValues { it should "encode and decode back to the same thing" in { val outer = Outer(Inner(42, true, "horses"), "cats") - val encoded = extractBody(basicRequest.body(outer)) + val encoded = extractBody(basicRequest.body(asJson(outer))) val decoded = runJsonResponseAs(asJson[Outer])(encoded) decoded.right.value shouldBe outer @@ -85,7 +85,7 @@ class CirceTests extends AnyFlatSpec with Matchers with EitherValues { it should "set the content type" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") @@ -94,20 +94,20 @@ class CirceTests extends AnyFlatSpec with Matchers with EitherValues { it should "only set the content type if it was not set earlier" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.contentType("horses/cats").body(body) + val req = basicRequest.contentType("horses/cats").body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") ct shouldBe Some("horses/cats") } - it should "serialize from JsonObject using implicit circeBodySerializer" in { + it should "serialize from JsonObject" in { import io.circe.syntax.EncoderOps import io.circe.JsonObject import sttp.model.Uri val jObject: JsonObject = JsonObject(("location", "hometown".asJson), ("bio", "Scala programmer".asJson)) - val request: Request[Either[String, String]] = basicRequest.get(Uri("http://example.org")).body(jObject) + val request: Request[Either[String, String]] = basicRequest.get(Uri("http://example.org")).body(asJson(jObject)) val actualBody: String = request.body.show val actualContentType: Option[String] = request.contentType 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 4c289cd79..4e037e4b1 100644 --- a/json/json4s/src/main/scala/sttp/client4/json4s/SttpJson4sApi.scala +++ b/json/json4s/src/main/scala/sttp/client4/json4s/SttpJson4sApi.scala @@ -7,11 +7,13 @@ import sttp.client4.json._ import sttp.model._ trait SttpJson4sApi { - implicit def json4sBodySerializer[B <: AnyRef](implicit + + /** Serialize the given value as JSON, to be used as a request's body using [[sttp.client4.Request.body]]. */ + def asJson[B <: AnyRef](b: B)(implicit formats: Formats, serialization: Serialization - ): BodySerializer[B] = - b => StringBody(serialization.write(b), Utf8, MediaType.ApplicationJson) + ): StringBody = + StringBody(serialization.write(b), Utf8, MediaType.ApplicationJson) /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Returns: * - `Right(b)` if the parsing was successful diff --git a/json/json4s/src/test/scala/sttp/client4/Json4sTests.scala b/json/json4s/src/test/scala/sttp/client4/Json4sTests.scala index cf8d34c85..08cc91f3f 100644 --- a/json/json4s/src/test/scala/sttp/client4/Json4sTests.scala +++ b/json/json4s/src/test/scala/sttp/client4/Json4sTests.scala @@ -23,7 +23,7 @@ class Json4sTests extends AnyFlatSpec with Matchers with EitherValues { val body = Outer(Inner(42, true, "horses"), "cats") val expected = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) extractBody(req) shouldBe expected } @@ -75,16 +75,16 @@ class Json4sTests extends AnyFlatSpec with Matchers with EitherValues { it should "set the content type" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") ct shouldBe Some(MediaType.ApplicationJson.copy(charset = Some(Utf8)).toString) } - it should "serialize from JObject using implicit json4sBodySerializer" in { + it should "serialize from JObject using" in { val jObject: JObject = JObject(JField("location", JString("hometown")), JField("bio", JString("Scala programmer"))) - val request: Request[Either[String, String]] = basicRequest.get(Uri("http://example.org")).body(jObject) + val request: Request[Either[String, String]] = basicRequest.get(Uri("http://example.org")).body(asJson(jObject)) val actualBody: String = request.body.show val actualContentType: Option[String] = request.contentType 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 6d3db0206..b2038472f 100644 --- a/json/jsoniter/src/main/scala/sttp/client4/jsoniter/SttpJsoniterJsonApi.scala +++ b/json/jsoniter/src/main/scala/sttp/client4/jsoniter/SttpJsoniterJsonApi.scala @@ -1,27 +1,26 @@ package sttp.client4.jsoniter +import sttp.client4.DeserializationException +import sttp.client4.HttpError +import sttp.client4.IsOption +import sttp.client4.JsonInput +import sttp.client4.ResponseAs +import sttp.client4.ResponseException +import sttp.client4.ShowError +import sttp.client4.StringBody +import sttp.client4.asString +import sttp.client4.asStringAlways import sttp.client4.internal.Utf8 import sttp.client4.json.RichResponseAs -import sttp.client4.{ - asString, - asStringAlways, - BodySerializer, - DeserializationException, - HttpError, - IsOption, - JsonInput, - ResponseAs, - ResponseException, - ShowError, - StringBody -} import sttp.model.MediaType trait SttpJsoniterJsonApi { import com.github.plokhotnyuk.jsoniter_scala.core._ import ShowError.showErrorMessageFromException - implicit def jsoniterBodySerializer[B](implicit encoder: JsonValueCodec[B]): BodySerializer[B] = - b => StringBody(writeToString(b), Utf8, MediaType.ApplicationJson) + + /** Serialize the given value as JSON, to be used as a request's body using [[sttp.client4.Request.body]]. */ + def asJson[B](b: B)(implicit encoder: JsonValueCodec[B]): StringBody = + StringBody(writeToString(b), Utf8, MediaType.ApplicationJson) /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Returns: * - `Right(b)` if the parsing was successful 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 9ea8f9578..d1cfa2ea4 100644 --- a/json/jsoniter/src/test/scala/sttp/client4/jsoniter/JsoniterJsonTests.scala +++ b/json/jsoniter/src/test/scala/sttp/client4/jsoniter/JsoniterJsonTests.scala @@ -15,7 +15,7 @@ class JsoniterJsonTests extends AnyFlatSpec with Matchers with EitherValues { "The jsoniter module" should "encode arbitrary bodies given an encoder" in { val body = Outer(Inner(42, true, "horses"), "cats") val expected = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) extractBody(req) shouldBe expected } @@ -57,7 +57,7 @@ class JsoniterJsonTests extends AnyFlatSpec with Matchers with EitherValues { it should "read what it writes" in { val outer = Outer(Inner(42, true, "horses"), "cats") - val encoded = extractBody(basicRequest.body(outer)) + val encoded = extractBody(basicRequest.body(asJson(outer))) val decoded = runJsonResponseAs(asJson[Outer])(encoded) decoded.right.value shouldBe outer @@ -65,7 +65,7 @@ class JsoniterJsonTests extends AnyFlatSpec with Matchers with EitherValues { it should "set the content type" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") @@ -74,16 +74,16 @@ class JsoniterJsonTests extends AnyFlatSpec with Matchers with EitherValues { it should "only set the content type if it was not set earlier" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.contentType("horses/cats").body(body) + val req = basicRequest.contentType("horses/cats").body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") ct shouldBe Some("horses/cats") } - it should "serialize from case class Person using implicit jsoniterBodySerializer" in { + it should "serialize from case class Person" in { val person = Person("John") - val request = basicRequest.get(Uri("http://example.org")).body(person) + val request = basicRequest.get(Uri("http://example.org")).body(asJson(person)) val actualBody: String = request.body.show val actualContentType: Option[String] = request.contentType 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 daf10f665..38071b307 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 @@ -13,8 +13,9 @@ trait SttpPlayJsonApi { override def show(t: JsError): String = t.errors.mkString(",") } - implicit def playJsonBodySerializer[B: Writes]: BodySerializer[B] = - b => StringBody(Json.stringify(Json.toJson(b)), Utf8, MediaType.ApplicationJson) + /** Serialize the given value as JSON, to be used as a request's body using [[sttp.client4.Request.body]]. */ + def asJson[B: Writes](b: B): StringBody = + StringBody(Json.stringify(Json.toJson(b)), Utf8, MediaType.ApplicationJson) /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Returns: * - `Right(b)` if the parsing was successful 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 5ab4ee093..957b5ee27 100644 --- a/json/play-json/src/test/scala/sttp/client4/PlayJsonTests.scala +++ b/json/play-json/src/test/scala/sttp/client4/PlayJsonTests.scala @@ -17,7 +17,7 @@ class PlayJsonTests extends AnyFlatSpec with Matchers with EitherValues { val body = Outer(Inner(42, true, "horses"), "cats") val expected = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) extractBody(req) shouldBe expected } @@ -76,7 +76,7 @@ class PlayJsonTests extends AnyFlatSpec with Matchers with EitherValues { it should "read and write back to the same thing" in { val outer = Outer(Inner(42, true, "horses"), "cats") - val encoded = extractBody(basicRequest.body(outer)) + val encoded = extractBody(basicRequest.body(asJson(outer))) val decoded = runJsonResponseAs(asJson[Outer])(encoded) decoded.value shouldBe outer @@ -84,7 +84,7 @@ class PlayJsonTests extends AnyFlatSpec with Matchers with EitherValues { it should "set the content type" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") @@ -93,18 +93,18 @@ class PlayJsonTests extends AnyFlatSpec with Matchers with EitherValues { it should "only set the content type if it was not set earlier" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.contentType("horses/cats").body(body) + val req = basicRequest.contentType("horses/cats").body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") ct shouldBe Some("horses/cats") } - it should "serialize from JsObject using implicit playJsonBodySerializer" in { + it should "serialize from JsObject" in { val fields: Seq[(String, JsValue)] = Seq[(String, JsValue)](("location", JsString("hometown")), ("bio", JsString("Scala programmer"))) val json: JsObject = JsObject(fields) - val request: Request[Either[String, String]] = basicRequest.get(Uri("http://example.org")).body(json) + val request: Request[Either[String, String]] = basicRequest.get(Uri("http://example.org")).body(asJson(json)) val actualBody: String = request.body.show val actualContentType: Option[String] = request.contentType 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 249fad674..49e02d4ae 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 @@ -7,8 +7,10 @@ import sttp.client4.json._ import sttp.model._ trait SttpSprayJsonApi { - implicit def sprayBodySerializer[B: JsonWriter](implicit printer: JsonPrinter = CompactPrinter): BodySerializer[B] = - b => StringBody(printer(b.toJson), Utf8, MediaType.ApplicationJson) + + /** Serialize the given value as JSON, to be used as a request's body using [[sttp.client4.Request.body]]. */ + def asJson[B: JsonWriter](b: B)(implicit printer: JsonPrinter = CompactPrinter): StringBody = + StringBody(printer(b.toJson), Utf8, MediaType.ApplicationJson) /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Returns: * - `Right(b)` if the parsing was successful diff --git a/json/spray-json/src/test/scala/sttp/client4/BackendStubSprayJsonTests.scala b/json/spray-json/src/test/scala/sttp/client4/sprayJson/BackendStubSprayJsonTests.scala similarity index 92% rename from json/spray-json/src/test/scala/sttp/client4/BackendStubSprayJsonTests.scala rename to json/spray-json/src/test/scala/sttp/client4/sprayJson/BackendStubSprayJsonTests.scala index f40627c05..a2d50c9bb 100644 --- a/json/spray-json/src/test/scala/sttp/client4/BackendStubSprayJsonTests.scala +++ b/json/spray-json/src/test/scala/sttp/client4/sprayJson/BackendStubSprayJsonTests.scala @@ -1,4 +1,4 @@ -package sttp.client4 +package sttp.client4.sprayJson import org.scalatest.concurrent.ScalaFutures import org.scalatest.flatspec.AnyFlatSpec @@ -7,7 +7,7 @@ import sttp.client4.testing.SyncBackendStub import sttp.model.Uri import spray.json.DefaultJsonProtocol.{jsonFormat1, StringJsonFormat} import spray.json.RootJsonFormat -import sprayJson._ +import sttp.client4.basicRequest case class Person(name: String) diff --git a/json/spray-json/src/test/scala/sttp/client4/SprayJsonTests.scala b/json/spray-json/src/test/scala/sttp/client4/sprayJson/SprayJsonTests.scala similarity index 85% rename from json/spray-json/src/test/scala/sttp/client4/SprayJsonTests.scala rename to json/spray-json/src/test/scala/sttp/client4/sprayJson/SprayJsonTests.scala index 88086c4c0..a1cf3843c 100644 --- a/json/spray-json/src/test/scala/sttp/client4/SprayJsonTests.scala +++ b/json/spray-json/src/test/scala/sttp/client4/sprayJson/SprayJsonTests.scala @@ -1,26 +1,33 @@ -package sttp.client4 +package sttp.client4.sprayJson import org.scalatest.EitherValues import spray.json.DefaultJsonProtocol._ -import spray.json.DefaultJsonProtocol.RootJsObjectFormat -import spray.json.JsonParser.ParsingException import spray.json.{DeserializationException => _, _} -import sttp.client4.SprayJsonTests._ import sttp.client4.internal.Utf8 -import sttp.client4.sprayJson.{sprayBodySerializer, _} 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 class SprayJsonTests extends AnyFlatSpec with Matchers with EitherValues { + import SprayJsonTests._ + behavior of "The spray-json module" - implicit private val jsObjectSerializer: BodySerializer[JsObject] = sprayBodySerializer(RootJsObjectFormat) + // implicit private val jsObjectSerializer: BodySerializer[JsObject] = sprayBodySerializer(RootJsObjectFormat) it should "encode arbitrary json bodies" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) extractBody(req) should include(""""foo":{"a":42,"b":true,"c":"horses"}""") extractBody(req) should include(""""bar":"cats"""") @@ -73,7 +80,7 @@ class SprayJsonTests extends AnyFlatSpec with Matchers with EitherValues { it should "set the content type" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") @@ -85,7 +92,7 @@ class SprayJsonTests extends AnyFlatSpec with Matchers with EitherValues { "location" -> "hometown".toJson, "bio" -> "Scala programmer".toJson ) - val request: Request[Either[String, String]] = basicRequest.get(Uri("http://example.org")).body(json) + val request: Request[Either[String, String]] = basicRequest.get(Uri("http://example.org")).body(asJson(json)) val actualBody: String = request.body.show val actualContentType: Option[String] = request.contentType @@ -107,7 +114,7 @@ class SprayJsonTests extends AnyFlatSpec with Matchers with EitherValues { def runJsonResponseAs[A](responseAs: ResponseAs[A]): String => A = responseAs.delegate match { - case responseAs: MappedResponseAs[_, A, Nothing] => + case responseAs: MappedResponseAs[_, A, Nothing] @unchecked => responseAs.raw match { case ResponseAsByteArray => s => responseAs.g(s.getBytes(Utf8), ResponseMetadata(StatusCode.Ok, "", Nil)) 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 a646b56fc..607917815 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 @@ -11,11 +11,11 @@ import tethys.writers.tokens.TokenWriterProducer trait SttpTethysApi { - implicit def tethysBodySerializer[B](implicit + /** Serialize the given value as JSON, to be used as a request's body using [[sttp.client4.Request.body]]. */ + def asJson[B](b: B)(implicit jsonWriter: JsonWriter[B], tokenWriterProducer: TokenWriterProducer - ): BodySerializer[B] = - b => StringBody(b.asJson, Utf8, MediaType.ApplicationJson) + ): StringBody = StringBody(b.asJson, Utf8, MediaType.ApplicationJson) /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Returns: * - `Right(b)` if the parsing was successful 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 adf34a56a..e7312ec4e 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 @@ -20,7 +20,7 @@ class TethysTests extends AnyFlatSpec with Matchers with EitherValues { val body = Outer(Inner(42, true, "horses"), "cats") val expected = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) extractBody(req, MediaType.ApplicationJson) shouldBe expected } @@ -73,7 +73,7 @@ class TethysTests extends AnyFlatSpec with Matchers with EitherValues { it should "encode and decode back to the same thing" in { val outer = Outer(Inner(42, true, "horses"), "cats") - val encoded = extractBody(basicRequest.body(outer), MediaType.ApplicationJson) + val encoded = extractBody(basicRequest.body(asJson(outer)), MediaType.ApplicationJson) val decoded = runJsonResponseAs(asJson[Outer])(encoded) decoded.right.value shouldBe outer @@ -81,7 +81,7 @@ class TethysTests extends AnyFlatSpec with Matchers with EitherValues { it should "set the content type" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") @@ -90,7 +90,7 @@ class TethysTests extends AnyFlatSpec with Matchers with EitherValues { it should "only set the content type if it was not set earlier" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.contentType("horses/cats").body(body) + val req = basicRequest.contentType("horses/cats").body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") 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 a91273092..8984e5cfd 100644 --- a/json/upickle/src/main/scala/sttp/client4/upicklejson/SttpUpickleApi.scala +++ b/json/upickle/src/main/scala/sttp/client4/upicklejson/SttpUpickleApi.scala @@ -8,8 +8,9 @@ import sttp.client4.json._ trait SttpUpickleApi { val upickleApi: upickle.Api - implicit def upickleBodySerializer[B](implicit encoder: upickleApi.Writer[B]): BodySerializer[B] = - b => StringBody(upickleApi.write(b), Utf8, MediaType.ApplicationJson) + /** Serialize the given value as JSON, to be used as a request's body using [[sttp.client4.Request.body]]. */ + def asJson[B](b: B)(implicit encoder: upickleApi.Writer[B]): StringBody = + StringBody(upickleApi.write(b), Utf8, MediaType.ApplicationJson) /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Returns: * - `Right(b)` if the parsing was successful 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 cd26902f4..1247cd999 100644 --- a/json/upickle/src/test/scala/sttp/client4/upicklejson/UpickleTests.scala +++ b/json/upickle/src/test/scala/sttp/client4/upicklejson/UpickleTests.scala @@ -16,7 +16,7 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { val body = Outer(Inner(42, true, "horses"), "cats") val expected = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) extractBody(req) shouldBe expected } @@ -87,7 +87,7 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { val outer = Outer(Inner(42, true, "horses"), "cats") - val encoded = extractBody(basicRequest.body(outer)) + val encoded = extractBody(basicRequest.body(asJson(outer))) val decoded = runJsonResponseAs(asJson[Outer])(encoded) decoded.right.value shouldBe outer @@ -98,7 +98,7 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { import sttp.client4.upicklejson.default._ val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") @@ -110,14 +110,14 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { import sttp.client4.upicklejson.default._ val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.contentType("horses/cats").body(body) + val req = basicRequest.contentType("horses/cats").body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") ct shouldBe Some("horses/cats") } - it should "serialize ujson.Obj using implicit upickleBodySerializer" in { + it should "serialize ujson.Obj" in { import UsingDefaultReaderWriters._ import sttp.client4.upicklejson.default._ @@ -125,7 +125,7 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { "location" -> "hometown", "bio" -> "Scala programmer" ) - val request: Request[Either[String, String]] = basicRequest.get(Uri("http://example.org")).body(json) + val request: Request[Either[String, String]] = basicRequest.get(Uri("http://example.org")).body(asJson(json)) val actualBody: String = request.body.show val actualContentType: Option[String] = request.contentType @@ -147,7 +147,7 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { val body = Outer(Inner(42, true, "horses"), "cats") val expected = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) extractBody(req) shouldBe expected } @@ -177,7 +177,7 @@ class UpickleTests extends AnyFlatSpec with Matchers with EitherValues { def runJsonResponseAs[A](responseAs: ResponseAs[A]): String => A = responseAs.delegate match { - case responseAs: MappedResponseAs[_, A, Nothing] => + case responseAs: MappedResponseAs[_, A, Nothing] @unchecked => responseAs.raw match { case ResponseAsByteArray => s => responseAs.g(s.getBytes(Utf8), ResponseMetadata(StatusCode.Ok, "", Nil)) 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 c27c023c6..daff87e65 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 @@ -1,28 +1,25 @@ package sttp.client4.ziojson +import sttp.client4.DeserializationException +import sttp.client4.HttpError +import sttp.client4.IsOption +import sttp.client4.JsonInput +import sttp.client4.ResponseAs +import sttp.client4.ResponseException +import sttp.client4.ShowError +import sttp.client4.StringBody +import sttp.client4.asString +import sttp.client4.asStringAlways import sttp.client4.internal.Utf8 import sttp.client4.json.RichResponseAs -import sttp.client4.{ - asString, - asStringAlways, - BodySerializer, - DeserializationException, - HttpError, - IsOption, - JsonInput, - ResponseAs, - ResponseException, - ShowError, - StringBody -} import sttp.model.MediaType trait SttpZioJsonApi extends SttpZioJsonApiExtensions { import zio.json._ private[ziojson] implicit val stringShowError: ShowError[String] = t => t - implicit def zioJsonBodySerializer[B: JsonEncoder]: BodySerializer[B] = - b => StringBody(b.toJson, Utf8, MediaType.ApplicationJson) + /** Serialize the given value as JSON, to be used as a request's body using [[sttp.client4.Request.body]]. */ + def asJson[B: JsonEncoder](b: B): StringBody = StringBody(b.toJson, Utf8, MediaType.ApplicationJson) /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Returns: * - `Right(b)` if the parsing was successful diff --git a/json/zio-json/src/main/scalajvm/sttp/client4/ziojson/SttpZioJsonApiExtensions.scala b/json/zio-json/src/main/scalajvm/sttp/client4/ziojson/SttpZioJsonApiExtensions.scala index d4ac15ba7..acaeba644 100644 --- a/json/zio-json/src/main/scalajvm/sttp/client4/ziojson/SttpZioJsonApiExtensions.scala +++ b/json/zio-json/src/main/scalajvm/sttp/client4/ziojson/SttpZioJsonApiExtensions.scala @@ -2,13 +2,18 @@ package sttp.client4.ziojson import sttp.capabilities.Effect import sttp.capabilities.zio.ZioStreams -import sttp.client4.{asStream, DeserializationException, HttpError, IsOption, ResponseException, StreamResponseAs} +import sttp.client4.DeserializationException +import sttp.client4.HttpError +import sttp.client4.ResponseException +import sttp.client4.StreamResponseAs +import sttp.client4.asStream +import zio.Task +import zio.ZIO import zio.json.JsonDecoder import zio.stream.ZPipeline -import zio.{Task, ZIO} trait SttpZioJsonApiExtensions { this: SttpZioJsonApi => - def asJsonStream[B: JsonDecoder: IsOption] + def asJsonStream[B: JsonDecoder] : StreamResponseAs[Either[ResponseException[String, String], B], ZioStreams with Effect[Task]] = asStream(ZioStreams)(s => JsonDecoder[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 23f9bf589..31153d1e4 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 @@ -15,7 +15,7 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { "The ziojson module" should "encode arbitrary bodies given an encoder" in { val body = Outer(Inner(42, true, "horses"), "cats") val expected = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) extractBody(req) shouldBe expected } @@ -67,7 +67,7 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { it should "read what it writes" in { val outer = Outer(Inner(42, true, "horses"), "cats") - val encoded = extractBody(basicRequest.body(outer)) + val encoded = extractBody(basicRequest.body(asJson(outer))) val decoded = runJsonResponseAs(asJson[Outer])(encoded) decoded.right.value shouldBe outer @@ -75,7 +75,7 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { it should "set the content type" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") @@ -84,17 +84,17 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { it should "only set the content type if it was not set earlier" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.contentType("horses/cats").body(body) + val req = basicRequest.contentType("horses/cats").body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") ct shouldBe Some("horses/cats") } - it should "serialize from Json.Obj using implicit zioJsonBodySerializer" in { + it should "serialize from Json.Obj" in { val fields: Chunk[(String, Json)] = Chunk(("location", Json.Str("hometown")), ("bio", Json.Str("Scala programmer"))) val jObject: Json.Obj = Json.Obj(fields) - val request = basicRequest.get(Uri("http://example.org")).body(jObject) + val request = basicRequest.get(Uri("http://example.org")).body(asJson(jObject)) val actualBody: String = request.body.show val actualContentType: Option[String] = request.contentType @@ -116,7 +116,7 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { def runJsonResponseAs[A](responseAs: ResponseAs[A]): String => A = responseAs.delegate match { - case responseAs: MappedResponseAs[_, A, Nothing] => + case responseAs: MappedResponseAs[_, A, Nothing] @unchecked => responseAs.raw match { case ResponseAsByteArray => s => responseAs.g(s.getBytes(Utf8), ResponseMetadata(StatusCode.Ok, "", Nil)) 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 c27c023c6..daff87e65 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 @@ -1,28 +1,25 @@ package sttp.client4.ziojson +import sttp.client4.DeserializationException +import sttp.client4.HttpError +import sttp.client4.IsOption +import sttp.client4.JsonInput +import sttp.client4.ResponseAs +import sttp.client4.ResponseException +import sttp.client4.ShowError +import sttp.client4.StringBody +import sttp.client4.asString +import sttp.client4.asStringAlways import sttp.client4.internal.Utf8 import sttp.client4.json.RichResponseAs -import sttp.client4.{ - asString, - asStringAlways, - BodySerializer, - DeserializationException, - HttpError, - IsOption, - JsonInput, - ResponseAs, - ResponseException, - ShowError, - StringBody -} import sttp.model.MediaType trait SttpZioJsonApi extends SttpZioJsonApiExtensions { import zio.json._ private[ziojson] implicit val stringShowError: ShowError[String] = t => t - implicit def zioJsonBodySerializer[B: JsonEncoder]: BodySerializer[B] = - b => StringBody(b.toJson, Utf8, MediaType.ApplicationJson) + /** Serialize the given value as JSON, to be used as a request's body using [[sttp.client4.Request.body]]. */ + def asJson[B: JsonEncoder](b: B): StringBody = StringBody(b.toJson, Utf8, MediaType.ApplicationJson) /** If the response is successful (2xx), tries to deserialize the body from a string into JSON. Returns: * - `Right(b)` if the parsing was successful diff --git a/json/zio1-json/src/main/scalajvm/sttp/client4/ziojson/SttpZioJsonApiExtensions.scala b/json/zio1-json/src/main/scalajvm/sttp/client4/ziojson/SttpZioJsonApiExtensions.scala index b21f8e205..27f16ac1b 100644 --- a/json/zio1-json/src/main/scalajvm/sttp/client4/ziojson/SttpZioJsonApiExtensions.scala +++ b/json/zio1-json/src/main/scalajvm/sttp/client4/ziojson/SttpZioJsonApiExtensions.scala @@ -2,14 +2,19 @@ package sttp.client4.ziojson import sttp.capabilities.Effect import sttp.capabilities.zio.ZioStreams -import sttp.client4.{asStream, DeserializationException, HttpError, IsOption, ResponseException, StreamResponseAs} +import sttp.client4.DeserializationException +import sttp.client4.HttpError +import sttp.client4.ResponseException +import sttp.client4.StreamResponseAs +import sttp.client4.asStream +import zio.RIO +import zio.ZIO import zio.blocking.Blocking import zio.json.JsonDecoder import zio.stream.ZTransducer -import zio.{RIO, ZIO} trait SttpZioJsonApiExtensions { this: SttpZioJsonApi => - def asJsonStream[B: JsonDecoder: IsOption] + def asJsonStream[B: JsonDecoder] : StreamResponseAs[Either[ResponseException[String, String], B], ZioStreams with Effect[RIO[Blocking, *]]] = asStream(ZioStreams)(s => JsonDecoder[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 23f9bf589..406706248 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 @@ -15,7 +15,7 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { "The ziojson module" should "encode arbitrary bodies given an encoder" in { val body = Outer(Inner(42, true, "horses"), "cats") val expected = """{"foo":{"a":42,"b":true,"c":"horses"},"bar":"cats"}""" - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) extractBody(req) shouldBe expected } @@ -67,7 +67,7 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { it should "read what it writes" in { val outer = Outer(Inner(42, true, "horses"), "cats") - val encoded = extractBody(basicRequest.body(outer)) + val encoded = extractBody(basicRequest.body(asJson(outer))) val decoded = runJsonResponseAs(asJson[Outer])(encoded) decoded.right.value shouldBe outer @@ -75,7 +75,7 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { it should "set the content type" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.body(body) + val req = basicRequest.body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") @@ -84,17 +84,17 @@ class ZioJsonTests extends AnyFlatSpec with Matchers with EitherValues { it should "only set the content type if it was not set earlier" in { val body = Outer(Inner(42, true, "horses"), "cats") - val req = basicRequest.contentType("horses/cats").body(body) + val req = basicRequest.contentType("horses/cats").body(asJson(body)) val ct = req.headers.map(h => (h.name, h.value)).toMap.get("Content-Type") ct shouldBe Some("horses/cats") } - it should "serialize from Json.Obj using implicit zioJsonBodySerializer" in { + it should "serialize from Json.Obj" in { val fields: Chunk[(String, Json)] = Chunk(("location", Json.Str("hometown")), ("bio", Json.Str("Scala programmer"))) val jObject: Json.Obj = Json.Obj(fields) - val request = basicRequest.get(Uri("http://example.org")).body(jObject) + val request = basicRequest.get(Uri("http://example.org")).body(asJson(jObject)) val actualBody: String = request.body.show val actualContentType: Option[String] = request.contentType From 492bba79d26ef5023e169b060826866d7956e172 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Tue, 10 Dec 2024 13:41:44 +0100 Subject: [PATCH 06/14] Move options that used tags to RequestOptions, replace tags with attributes (#2364) --- build.sbt | 2 +- .../scala/sttp/client4/RequestOptions.scala | 10 +- .../sttp/client4/SpecifyAuthScheme.scala | 5 +- .../src/main/scala/sttp/client4/SttpApi.scala | 11 +- .../main/scala/sttp/client4/logging/Log.scala | 35 ++---- .../src/main/scala/sttp/client4/package.scala | 6 - .../src/main/scala/sttp/client4/request.scala | 57 +++++---- .../scala/sttp/client4/requestBuilder.scala | 117 ++++++++++-------- .../DigestAuthenticationBackend.scala | 23 ++-- .../client4/curl/AbstractCurlBackend.scala | 4 +- .../client4/BackendOptionsProxyTest2.scala | 2 +- .../client4/testing/HttpTestExtensions.scala | 7 -- docs/backends/wrappers/custom.md | 17 ++- 13 files changed, 158 insertions(+), 138 deletions(-) diff --git a/build.sbt b/build.sbt index faf321986..6b60d14f6 100644 --- a/build.sbt +++ b/build.sbt @@ -152,7 +152,7 @@ val zio2InteropRsVersion = "2.0.2" val oxVersion = "0.5.1" val sttpModelVersion = "1.7.11" -val sttpSharedVersion = "1.4.0" +val sttpSharedVersion = "1.4.2" val logback = "ch.qos.logback" % "logback-classic" % "1.5.12" diff --git a/core/src/main/scala/sttp/client4/RequestOptions.scala b/core/src/main/scala/sttp/client4/RequestOptions.scala index 32c3805e6..78e3b4676 100644 --- a/core/src/main/scala/sttp/client4/RequestOptions.scala +++ b/core/src/main/scala/sttp/client4/RequestOptions.scala @@ -1,10 +1,16 @@ package sttp.client4 import scala.concurrent.duration.Duration +import sttp.model.HttpVersion +import sttp.client4.logging.LoggingOptions +/** Options for a [[Request]]. The defaults can be found on [[emptyRequest]]. */ case class RequestOptions( followRedirects: Boolean, - readTimeout: Duration, // TODO: Use FiniteDuration while migrating to sttp-4 + readTimeout: Duration, maxRedirects: Int, - redirectToGet: Boolean + redirectToGet: Boolean, + disableAutoDecompression: Boolean, + httpVersion: Option[HttpVersion], + loggingOptions: LoggingOptions ) diff --git a/core/src/main/scala/sttp/client4/SpecifyAuthScheme.scala b/core/src/main/scala/sttp/client4/SpecifyAuthScheme.scala index 81e8e3be2..1acb3ab23 100644 --- a/core/src/main/scala/sttp/client4/SpecifyAuthScheme.scala +++ b/core/src/main/scala/sttp/client4/SpecifyAuthScheme.scala @@ -3,11 +3,12 @@ package sttp.client4 import sttp.client4.internal.DigestAuthenticator import sttp.client4.internal.Utf8 import java.util.Base64 +import sttp.attributes.AttributeKey class SpecifyAuthScheme[+R <: PartialRequestBuilder[R, _]]( hn: String, req: R, - digestTag: String + digestAttributeKey: AttributeKey[DigestAuthenticator.DigestAuthData] ) { def basic(user: String, password: String): R = { val c = new String(Base64.getEncoder.encode(s"$user:$password".getBytes(Utf8)), Utf8) @@ -21,5 +22,5 @@ class SpecifyAuthScheme[+R <: PartialRequestBuilder[R, _]]( req.header(hn, s"Bearer $token") def digest(user: String, password: String): R = - req.tag(digestTag, DigestAuthenticator.DigestAuthData(user, password)) + req.attribute(digestAttributeKey, DigestAuthenticator.DigestAuthData(user, password)) } diff --git a/core/src/main/scala/sttp/client4/SttpApi.scala b/core/src/main/scala/sttp/client4/SttpApi.scala index b6f588e1a..35df0200f 100644 --- a/core/src/main/scala/sttp/client4/SttpApi.scala +++ b/core/src/main/scala/sttp/client4/SttpApi.scala @@ -2,16 +2,16 @@ package sttp.client4 import sttp.client4.internal._ import sttp.model._ -import sttp.ws.WebSocket import java.io.InputStream import java.nio.ByteBuffer import scala.collection.immutable.Seq import scala.concurrent.duration._ import sttp.capabilities.Streams -import sttp.ws.WebSocketFrame import sttp.capabilities.Effect import sttp.client4.wrappers.FollowRedirectsBackend +import sttp.client4.logging.LoggingOptions +import sttp.attributes.AttributeMap trait SttpApi extends SttpExtensions with UriInterpolator { val DefaultReadTimeout: Duration = 1.minute @@ -30,9 +30,12 @@ trait SttpApi extends SttpExtensions with UriInterpolator { followRedirects = true, DefaultReadTimeout, FollowRedirectsBackend.MaxRedirects, - redirectToGet = false + redirectToGet = false, + disableAutoDecompression = false, + httpVersion = None, + loggingOptions = LoggingOptions() ), - Map() + AttributeMap.Empty ) /** A starting request, with the following modification comparing to [[emptyRequest]]: `Accept-Encoding` is set to diff --git a/core/src/main/scala/sttp/client4/logging/Log.scala b/core/src/main/scala/sttp/client4/logging/Log.scala index d697cdeb3..afcd7dfd3 100644 --- a/core/src/main/scala/sttp/client4/logging/Log.scala +++ b/core/src/main/scala/sttp/client4/logging/Log.scala @@ -57,15 +57,11 @@ class DefaultLog[F[_]]( ) extends Log[F] { def beforeRequestSend(request: GenericRequest[_, _]): F[Unit] = - request.loggingOptions match { - case Some(options) => - before( - request, - options.logRequestBody.getOrElse(logRequestBody), - options.logRequestHeaders.getOrElse(logRequestHeaders) - ) - case None => before(request, logRequestBody, logRequestHeaders) - } + before( + request, + request.loggingOptions.logRequestBody.getOrElse(logRequestBody), + request.loggingOptions.logRequestHeaders.getOrElse(logRequestHeaders) + ) private def before(request: GenericRequest[_, _], _logRequestBody: Boolean, _logRequestHeaders: Boolean): F[Unit] = logger( @@ -82,19 +78,14 @@ class DefaultLog[F[_]]( response: Response[_], responseBody: Option[String], elapsed: Option[Duration] - ): F[Unit] = request.loggingOptions match { - case Some(options) => - handleResponse( - request.showBasic, - response, - responseBody, - options.logResponseBody.getOrElse(responseBody.isDefined), - options.logResponseHeaders.getOrElse(logResponseHeaders), - elapsed - ) - case None => - handleResponse(request.showBasic, response, responseBody, responseBody.isDefined, logResponseHeaders, elapsed) - } + ): F[Unit] = handleResponse( + request.showBasic, + response, + responseBody, + request.loggingOptions.logResponseBody.getOrElse(responseBody.isDefined), + request.loggingOptions.logResponseHeaders.getOrElse(logResponseHeaders), + elapsed + ) private def handleResponse( showBasic: String, diff --git a/core/src/main/scala/sttp/client4/package.scala b/core/src/main/scala/sttp/client4/package.scala index e53621581..c8364db6d 100644 --- a/core/src/main/scala/sttp/client4/package.scala +++ b/core/src/main/scala/sttp/client4/package.scala @@ -1,11 +1,5 @@ package sttp package object client4 extends SttpApi { - - /** Provide an implicit value of this type to serialize arbitrary classes into a request body. Backends might also - * provide special logic for serializer instances which they define (e.g. to handle streaming). - */ - type BodySerializer[B] = B => BasicBodyPart - type RetryWhen = (GenericRequest[_, _], Either[Throwable, Response[_]]) => Boolean } diff --git a/core/src/main/scala/sttp/client4/request.scala b/core/src/main/scala/sttp/client4/request.scala index 1077b56d2..1cd627d8b 100644 --- a/core/src/main/scala/sttp/client4/request.scala +++ b/core/src/main/scala/sttp/client4/request.scala @@ -6,6 +6,7 @@ import sttp.client4.internal.{ToCurlConverter, ToRfc2616Converter} import sttp.shared.Identity import scala.collection.immutable.Seq +import sttp.attributes.AttributeMap /** A generic description of an HTTP request, along with a description of how the response body should be handled. * @@ -67,8 +68,8 @@ trait GenericRequest[+T, -R] extends RequestBuilder[GenericRequest[T, R]] with R * @param response * Description of how the response body should be handled. Needs to be specified upfront so that the response is * always consumed and hence there are no requirements on client code to consume it. - * @param tags - * Request-specific tags which can be used by backends for logging, metrics, etc. Empty by default. + * @param attributes + * Request-specific attributes which can be used by backends for logging, metrics, etc. Empty by default. * @tparam T * The target type, to which the response body should be read. */ @@ -79,7 +80,7 @@ case class Request[T]( headers: Seq[Header], response: ResponseAs[T], options: RequestOptions, - tags: Map[String, Any] + attributes: AttributeMap ) extends GenericRequest[T, Any] with RequestBuilder[Request[T]] { @@ -88,11 +89,19 @@ case class Request[T]( override def method(method: Method, uri: Uri): Request[T] = copy(uri = uri, method = method) override def withHeaders(headers: Seq[Header]): Request[T] = copy(headers = headers) override def withOptions(options: RequestOptions): Request[T] = copy(options = options) - override def withTags(tags: Map[String, Any]): Request[T] = copy(tags = tags) + override def withAttributes(attributes: AttributeMap): Request[T] = copy(attributes = attributes) override protected def copyWithBody(body: BasicBody): Request[T] = copy(body = body) def multipartStreamBody[S](ps: Seq[Part[BodyPart[S]]]): StreamRequest[T, S] = - StreamRequest(method, uri, MultipartStreamBody(ps), headers, StreamResponseAs(response.delegate), options, tags) + StreamRequest( + method, + uri, + MultipartStreamBody(ps), + headers, + StreamResponseAs(response.delegate), + options, + attributes + ) def multipartStreamBody[S](p1: Part[BodyPart[S]], ps: Part[BodyPart[S]]*): StreamRequest[T, S] = StreamRequest( @@ -102,11 +111,11 @@ case class Request[T]( headers, StreamResponseAs(response.delegate), options, - tags + attributes ) def streamBody[S](s: Streams[S])(b: s.BinaryStream): StreamRequest[T, S] = - StreamRequest(method, uri, StreamBody(s)(b), headers, StreamResponseAs(response.delegate), options, tags) + StreamRequest(method, uri, StreamBody(s)(b), headers, StreamResponseAs(response.delegate), options, attributes) /** Specifies the target type to which the response body should be read. Note that this replaces any previous * specifications, which also includes any previous `mapResponse` invocations. @@ -117,19 +126,19 @@ case class Request[T]( /** 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, tags) + WebSocketRequest(method, uri, body, headers, ra, options, attributes) /** Specifies that the response body should be processed using a non-blocking, asynchronous stream, as witnessed by * the `S` capability. A [[StreamBackend]] will be required to send this request. */ def response[T2, S](ra: StreamResponseAs[T2, S]): StreamRequest[T2, S] = - StreamRequest(method, uri, body, headers, ra, options, tags) + StreamRequest(method, uri, body, headers, ra, options, attributes) /** Specifies that this is a WebSocket request, and the WebSocket will be processed using a non-blocking, asynchronous * stream, as witnessed by the `S` capability. A [[WebSocketStreamBackend]] will be required to send this request. */ def response[T2, S](ra: WebSocketStreamResponseAs[T2, S]): WebSocketStreamRequest[T2, S] = - WebSocketStreamRequest(method, uri, body, headers, ra, options, tags) + WebSocketStreamRequest(method, uri, body, headers, ra, options, attributes) /** Sends the request, using the given backend. * @@ -182,8 +191,8 @@ object Request { * @param response * Description of how the response body should be handled. Needs to be specified upfront so that the response is * always consumed and hence there are no requirements on client code to consume it. - * @param tags - * Request-specific tags which can be used by backends for logging, metrics, etc. Empty by default. + * @param attributes + * Request-specific attributes which can be used by backends for logging, metrics, etc. Empty by default. * @tparam T * The target type, to which the response body should be read. If the response body is streamed, this might be the * value obtained by processing the entire stream. @@ -197,7 +206,7 @@ final case class StreamRequest[T, R]( headers: Seq[Header], response: StreamResponseAs[T, R], options: RequestOptions, - tags: Map[String, Any] + attributes: AttributeMap ) extends GenericRequest[T, R] with RequestBuilder[StreamRequest[T, R]] { @@ -206,7 +215,7 @@ final case class StreamRequest[T, R]( override def method(method: Method, uri: Uri): StreamRequest[T, R] = copy(method = method, uri = uri) override def withHeaders(headers: Seq[Header]): StreamRequest[T, R] = copy(headers = headers) override def withOptions(options: RequestOptions): StreamRequest[T, R] = copy(options = options) - override def withTags(tags: Map[String, Any]): StreamRequest[T, R] = copy(tags = tags) + override def withAttributes(attributes: AttributeMap): StreamRequest[T, R] = copy(attributes = attributes) override protected def copyWithBody(body: BasicBody): StreamRequest[T, R] = copy(body = body) /** Specifies the target type to which the response body should be read. Note that this replaces any previous @@ -229,7 +238,7 @@ final case class StreamRequest[T, R]( headers, WebSocketStreamResponseAs[T2, Effect[F] with R](ra.delegate), options, - tags + attributes ) def mapResponse[T2](f: T => T2): StreamRequest[T2, R] = copy(response = response.map(f)) @@ -263,8 +272,8 @@ final case class StreamRequest[T, R]( * @param response * Description of how the WebSocket should be handled. Needs to be specified upfront so that the response is always * consumed and hence there are no requirements on client code to consume it. - * @param tags - * Request-specific tags which can be used by backends for logging, metrics, etc. Empty by default. + * @param attributes + * Request-specific attributes which can be used by backends for logging, metrics, etc. Empty by default. * @tparam F * The effect type used to process the WebSocket. Might include asynchronous computations (e.g. * [[scala.concurrent.Future]]), pure effect descriptions (`IO`), or synchronous computations ([[Identity]]). @@ -279,7 +288,7 @@ final case class WebSocketRequest[F[_], T]( headers: Seq[Header], response: WebSocketResponseAs[F, T], options: RequestOptions, - tags: Map[String, Any] + attributes: AttributeMap ) extends GenericRequest[T, WebSockets with Effect[F]] with RequestBuilder[WebSocketRequest[F, T]] { @@ -288,7 +297,7 @@ final case class WebSocketRequest[F[_], T]( override def method(method: Method, uri: Uri): WebSocketRequest[F, T] = copy(method = method, uri = uri) override def withHeaders(headers: Seq[Header]): WebSocketRequest[F, T] = copy(headers = headers) override def withOptions(options: RequestOptions): WebSocketRequest[F, T] = copy(options = options) - override def withTags(tags: Map[String, Any]): WebSocketRequest[F, T] = copy(tags = tags) + override def withAttributes(attributes: AttributeMap): WebSocketRequest[F, T] = copy(attributes = attributes) override protected def copyWithBody(body: BasicBody): WebSocketRequest[F, T] = copy(body = body) def streamBody[S](s: Streams[S])(b: s.BinaryStream): WebSocketStreamRequest[T, Effect[F] with S] = @@ -299,7 +308,7 @@ final case class WebSocketRequest[F[_], T]( headers, WebSocketStreamResponseAs[T, Effect[F] with S](response.delegate), options, - tags + attributes ) def mapResponse[T2](f: T => T2): WebSocketRequest[F, T2] = copy(response = response.map(f)) @@ -343,8 +352,8 @@ final case class WebSocketRequest[F[_], T]( * @param response * Description of how the WebSocket should be handled. Needs to be specified upfront so that the response is always * consumed and hence there are no requirements on client code to consume it. - * @param tags - * Request-specific tags which can be used by backends for logging, metrics, etc. Empty by default. + * @param attributes + * Request-specific attributes which can be used by backends for logging, metrics, etc. Empty by default. * @tparam T * The target type, to which the response body should be read. If the WebSocket interactions are described entirely * by the response description, this might be `Unit`. Otherwise, this can be an `S` stream of frames or mapped @@ -359,7 +368,7 @@ final case class WebSocketStreamRequest[T, S]( headers: Seq[Header], response: WebSocketStreamResponseAs[T, S], options: RequestOptions, - tags: Map[String, Any] + attributes: AttributeMap ) extends GenericRequest[T, S with WebSockets] with RequestBuilder[WebSocketStreamRequest[T, S]] { @@ -368,7 +377,7 @@ final case class WebSocketStreamRequest[T, S]( override def method(method: Method, uri: Uri): WebSocketStreamRequest[T, S] = copy(method = method, uri = uri) override def withHeaders(headers: Seq[Header]): WebSocketStreamRequest[T, S] = copy(headers = headers) override def withOptions(options: RequestOptions): WebSocketStreamRequest[T, S] = copy(options = options) - override def withTags(tags: Map[String, Any]): WebSocketStreamRequest[T, S] = copy(tags = tags) + override def withAttributes(attributes: AttributeMap): WebSocketStreamRequest[T, S] = copy(attributes = attributes) override protected def copyWithBody(body: BasicBody): WebSocketStreamRequest[T, S] = copy(body = body) def mapResponse[T2](f: T => T2): WebSocketStreamRequest[T2, S] = copy(response = response.map(f)) diff --git a/core/src/main/scala/sttp/client4/requestBuilder.scala b/core/src/main/scala/sttp/client4/requestBuilder.scala index 143f70e51..753a227e1 100644 --- a/core/src/main/scala/sttp/client4/requestBuilder.scala +++ b/core/src/main/scala/sttp/client4/requestBuilder.scala @@ -19,6 +19,8 @@ import java.io.InputStream import java.nio.ByteBuffer import scala.concurrent.duration.Duration import scala.collection.immutable.Seq +import sttp.attributes.AttributeKey +import sttp.attributes.AttributeMap /** The builder methods of requests or partial requests of type `PR`. * @@ -43,8 +45,8 @@ trait PartialRequestBuilder[+PR <: PartialRequestBuilder[PR, R], +R] def response: ResponseAsDelegate[_, _] def options: RequestOptions - /** Request-specific tags which can be used by backends for logging, metrics, etc. Empty by default. */ - def tags: Map[String, Any] + /** Request-specific attributes which can be used by backends for logging, metrics, etc. Empty by default. */ + def attributes: AttributeMap /** Set the method & uri to the given ones. */ def method(method: Method, uri: Uri): R @@ -55,8 +57,8 @@ trait PartialRequestBuilder[+PR <: PartialRequestBuilder[PR, R], +R] /** Replace all options with the given ones. */ def withOptions(options: RequestOptions): PR - /** Replace all tags with the given ones. */ - def withTags(tags: Map[String, Any]): PR + /** Replace attributes with the given ones. */ + def withAttributes(attributes: AttributeMap): PR protected def copyWithBody(body: BasicBody): PR @@ -121,10 +123,21 @@ trait PartialRequestBuilder[+PR <: PartialRequestBuilder[PR, R], +R] */ def headers(hs: Header*): PR = hs.foldLeft(this)(_.header(_)) + /** Allows specifying basic, token, bearer (in the `Authorization` header) or digest authentication for this request. + */ def auth: SpecifyAuthScheme[PR] = - new SpecifyAuthScheme[PR](HeaderNames.Authorization, this, DigestAuthenticationBackend.DigestAuthTag) + new SpecifyAuthScheme[PR](HeaderNames.Authorization, this, DigestAuthenticationBackend.DigestAuthAttributeKey) + + /** Allows specifying basic, token, bearer (in the `Proxy-Authorization` header) or digest proxy authentication for + * this request. + */ def proxyAuth: SpecifyAuthScheme[PR] = - new SpecifyAuthScheme[PR](HeaderNames.ProxyAuthorization, this, DigestAuthenticationBackend.ProxyDigestAuthTag) + new SpecifyAuthScheme[PR]( + HeaderNames.ProxyAuthorization, + this, + DigestAuthenticationBackend.ProxyDigestAuthAttributeKey + ) + def acceptEncoding(encoding: String): PR = header(HeaderNames.AcceptEncoding, encoding) /** Adds the given cookie. Any previously defined cookies are left intact. */ @@ -288,64 +301,60 @@ trait PartialRequestBuilder[+PR <: PartialRequestBuilder[PR, R], +R] */ def redirectToGet(r: Boolean): PR = withOptions(options.copy(redirectToGet = r)) - def tag(k: String, v: Any): PR = withTags(tags + (k -> v)) - - def tag(k: String): Option[Any] = tags.get(k) - - private val disableAutoDecompressionKey = "disableAutoDecompression" - - // Used as a workaround to keep binary compatibility - // TODO: replace with additional parameter in RequestOptions when writing sttp4 - def disableAutoDecompression: PR = tag(disableAutoDecompressionKey, true) - - def autoDecompressionDisabled: Boolean = tags.getOrElse(disableAutoDecompressionKey, false).asInstanceOf[Boolean] - - private val httpVersionKey = "httpVersion" - - // Used as a workaround to keep binary compatibility - // TODO: replace with additional parameter in RequestOptions when writing sttp4 - // TODO: add similar functionality to Response + /** Disables auto-decompression of response bodies which are received with supported `Content-Encoding headers. */ + def disableAutoDecompression: PR = withOptions(options.copy(disableAutoDecompression = true)) - /** Allows setting HTTP version per request. Supported only is a few backends + /** True iff auto-decompression is disabled. * - * @param version: - * one of values from [[HttpVersion]] enum. - * @return - * request with version tag + * @see + * disableAutoDecompression */ - def httpVersion(version: HttpVersion): PR = tag(httpVersionKey, version) + def autoDecompressionDisabled: Boolean = options.disableAutoDecompression + + /** Set the HTTP version with which this request should be sent. Supported only in a few backends. */ + def httpVersion(version: HttpVersion): PR = withOptions(options.copy(httpVersion = Some(version))) - /** Get[[HttpVersion]] from tags in request. Supported only is a few backends + /** Get the [[HttpVersion]], with which this request should be sent, if any. Setting the HTTP version is supported + * only in a few backends. * * @return - * one of values form [[HttpVersion]] enum or [[None]] + * [[None]], if the request will be sent with the backend-default HTTP version. */ - def httpVersion: Option[HttpVersion] = tags.get(httpVersionKey).map(_.asInstanceOf[HttpVersion]) - - private val loggingOptionsTagKey = "loggingOptions" + def httpVersion: Option[HttpVersion] = options.httpVersion - /** Will only have effect when using the `LoggingBackend` */ - def logSettings( + /** Sets per-request logging options. Will only have effect when using the [[sttp.client4.logging.LoggingBackend]] + * wrapper. + */ + def loggingOptions( logRequestBody: Option[Boolean] = None, logResponseBody: Option[Boolean] = None, logRequestHeaders: Option[Boolean] = None, logResponseHeaders: Option[Boolean] = None - ): PR = { - val loggingOptions = LoggingOptions( - logRequestBody = logRequestBody, - logResponseBody = logResponseBody, - logRequestHeaders = logRequestHeaders, - logResponseHeaders = logResponseHeaders + ): PR = withOptions( + options.copy(loggingOptions = + LoggingOptions( + logRequestBody = logRequestBody, + logResponseBody = logResponseBody, + logRequestHeaders = logRequestHeaders, + logResponseHeaders = logResponseHeaders + ) ) - this.tag(loggingOptionsTagKey, loggingOptions) - } + ) + + /** Sets per-request logging options. Will only have effect when using the [[sttp.client4.logging.LoggingBackend]] + * wrapper. + */ + def loggingOptions(loggingOptions: LoggingOptions): PR = withOptions(options.copy(loggingOptions = loggingOptions)) + + /** The per-request logging options, which have effect when using the [[sttp.client4.logging.LoggingBackend]] wrapper. + */ + def loggingOptions: LoggingOptions = options.loggingOptions - def logSettings( - loggingOptions: Option[LoggingOptions] - ): PR = - this.tag(loggingOptionsTagKey, loggingOptions) + /** Reads a per-request attribute for the given key, if present. */ + def attribute[T](k: AttributeKey[T]): Option[T] = attributes.get(k) - def loggingOptions: Option[LoggingOptions] = tag(loggingOptionsTagKey).asInstanceOf[Option[LoggingOptions]] + /** Sets a per-request attribute for the given key, with the given value. */ + def attribute[T](k: AttributeKey[T], v: T): PR = withAttributes(attributes.put(k, v)) def show( includeBody: Boolean = true, @@ -365,8 +374,8 @@ trait PartialRequestBuilder[+PR <: PartialRequestBuilder[PR, R], +R] * @param response * Description of how the response body should be handled. Needs to be specified upfront so that the response is * always consumed and hence there are no requirements on client code to consume it. - * @param tags - * Request-specific tags which can be used by backends for logging, metrics, etc. Empty by default. + * @param attributes + * Request-specific attributes which can be used by backends for logging, metrics, etc. Empty by default. * @tparam T * The target type, to which the response body should be read. */ @@ -375,16 +384,16 @@ final case class PartialRequest[T]( headers: Seq[Header], response: ResponseAs[T], options: RequestOptions, - tags: Map[String, Any] + attributes: AttributeMap ) extends PartialRequestBuilder[PartialRequest[T], Request[T]] { override def showBasic: String = "(no method & uri set)" override def method(method: Method, uri: Uri): Request[T] = - Request(method, uri, body, headers, response, options, tags) + Request(method, uri, body, headers, response, options, attributes) override def withHeaders(headers: Seq[Header]): PartialRequest[T] = copy(headers = headers) override def withOptions(options: RequestOptions): PartialRequest[T] = copy(options = options) - override def withTags(tags: Map[String, Any]): PartialRequest[T] = copy(tags = tags) + override def withAttributes(attributes: AttributeMap): PartialRequest[T] = copy(attributes = attributes) override protected def copyWithBody(body: BasicBody): PartialRequest[T] = copy(body = body) def response[T2](ra: ResponseAs[T2]): PartialRequest[T2] = copy(response = ra) } diff --git a/core/src/main/scala/sttp/client4/wrappers/DigestAuthenticationBackend.scala b/core/src/main/scala/sttp/client4/wrappers/DigestAuthenticationBackend.scala index b00856500..f10954031 100644 --- a/core/src/main/scala/sttp/client4/wrappers/DigestAuthenticationBackend.scala +++ b/core/src/main/scala/sttp/client4/wrappers/DigestAuthenticationBackend.scala @@ -17,6 +17,7 @@ import sttp.client4.{ } import sttp.model.Header import sttp.monad.syntax._ +import sttp.attributes.AttributeKey abstract class DigestAuthenticationBackend[F[_], P] private ( delegate: GenericBackend[F, P], @@ -27,13 +28,18 @@ abstract class DigestAuthenticationBackend[F[_], P] private ( delegate .send(request) .flatMap { firstResponse => - handleResponse(request, firstResponse, ProxyDigestAuthTag, DigestAuthenticator.proxy(_, clientNonceGenerator)) + handleResponse( + request, + firstResponse, + ProxyDigestAuthAttributeKey, + DigestAuthenticator.proxy(_, clientNonceGenerator) + ) } .flatMap { case (secondResponse, proxyAuthHeader) => handleResponse( proxyAuthHeader.map(h => request.header(h)).getOrElse(request), secondResponse, - DigestAuthTag, + DigestAuthAttributeKey, DigestAuthenticator.apply(_, clientNonceGenerator) ).map(_._1) } @@ -41,12 +47,11 @@ abstract class DigestAuthenticationBackend[F[_], P] private ( private def handleResponse[T]( request: GenericRequest[T, P with Effect[F]], response: Response[T], - digestTag: String, + digestAttributeKey: AttributeKey[DigestAuthenticator.DigestAuthData], digestAuthenticator: DigestAuthData => DigestAuthenticator ): F[(Response[T], Option[Header])] = request - .tag(digestTag) - .map(_.asInstanceOf[DigestAuthData]) + .attribute(digestAttributeKey) .flatMap { digestAuthData => val header = digestAuthenticator(digestAuthData).authenticate(request, response) header.map(h => delegate.send(request.header(h)).map(_ -> Option(h))) @@ -81,6 +86,10 @@ object DigestAuthenticationBackend { ): WebSocketStreamBackend[F, S] = new DigestAuthenticationBackend(delegate, clientNonceGenerator) with WebSocketStreamBackend[F, S] {} - private[client4] val DigestAuthTag = "__sttp_DigestAuth" - private[client4] val ProxyDigestAuthTag = "__sttp_ProxyDigestAuth" + private[client4] val DigestAuthAttributeKey = new AttributeKey[DigestAuthenticator.DigestAuthData]( + "sttp.client4.internal.DigestAuthenticator.DigestAuthData.direct" + ) + private[client4] val ProxyDigestAuthAttributeKey = new AttributeKey[DigestAuthenticator.DigestAuthData]( + "sttp.client4.internal.DigestAuthenticator.DigestAuthData.proxy" + ) } diff --git a/core/src/main/scalanative/sttp/client4/curl/AbstractCurlBackend.scala b/core/src/main/scalanative/sttp/client4/curl/AbstractCurlBackend.scala index d7e657bc7..f223feabe 100644 --- a/core/src/main/scalanative/sttp/client4/curl/AbstractCurlBackend.scala +++ b/core/src/main/scalanative/sttp/client4/curl/AbstractCurlBackend.scala @@ -75,8 +75,8 @@ abstract class AbstractCurlBackend[F[_]](_monad: MonadError[F], verbose: Boolean if (verbose) { curl.option(Verbose, parameter = true) } - if (request.tags.nonEmpty) { - return monad.error(new UnsupportedOperationException("Tags are not supported")) + if (request.attributes.nonEmpty) { + return monad.error(new UnsupportedOperationException("Attributes are not supported")) } val reqHeaders = request.headers if (reqHeaders.nonEmpty) { diff --git a/core/src/test/scalajvm/sttp/client4/BackendOptionsProxyTest2.scala b/core/src/test/scalajvm/sttp/client4/BackendOptionsProxyTest2.scala index bcec486d9..4859ef6bb 100644 --- a/core/src/test/scalajvm/sttp/client4/BackendOptionsProxyTest2.scala +++ b/core/src/test/scalajvm/sttp/client4/BackendOptionsProxyTest2.scala @@ -22,6 +22,6 @@ class BackendOptionsProxyTest2 extends AnyFlatSpec with Matchers { val ioe = new IOException("bar") proxySelector.connectFailed(uri, proxySetting.inetSocketAddress, ioe) } - ex.getMessage should be("Couldn't connect to the proxy server, uri: foo, socket: fakeproxyserverhost:8080") + ex.getMessage should startWith("Couldn't connect to the proxy server, uri: foo, socket: fakeproxyserverhost") } } diff --git a/core/src/test/scalajvm/sttp/client4/testing/HttpTestExtensions.scala b/core/src/test/scalajvm/sttp/client4/testing/HttpTestExtensions.scala index a9a1a9d80..9f82a32ac 100644 --- a/core/src/test/scalajvm/sttp/client4/testing/HttpTestExtensions.scala +++ b/core/src/test/scalajvm/sttp/client4/testing/HttpTestExtensions.scala @@ -266,14 +266,7 @@ trait HttpTestExtensions[F[_]] extends AsyncFreeSpecLike { self: HttpTest[F] => if (self.supportsAutoDecompressionDisabling) { "should return compressed data" in { withTemporaryNonExistentFile { file => - val options = RequestOptions( - followRedirects = true, - DefaultReadTimeout, - FollowRedirectsBackend.MaxRedirects, - redirectToGet = false - ) val req = emptyRequest - .copy(options = options) .get(uri"$endpoint/raw-gzip-file") .response(asFile(file)) .acceptEncoding("gzip") diff --git a/docs/backends/wrappers/custom.md b/docs/backends/wrappers/custom.md index e248a1429..7c80f0fc0 100644 --- a/docs/backends/wrappers/custom.md +++ b/docs/backends/wrappers/custom.md @@ -10,13 +10,13 @@ Possible use-cases for wrapper-backend include: See also the section on [resilience](../../resilience.md) which covers topics such as retries, circuit breaking and rate limiting. -## Request tagging +## Request attributes -Each request contains a `tags: Map[String, Any]` map. This map can be used to tag the request with any backend-specific information, and isn't used in any way by sttp itself. +Each request contains a `attributes: AttributeMap` type-safe map. This map can be used to tag the request with any backend-specific information, and isn't used in any way by sttp itself. -Tags can be added to a request using the `def tag(k: String, v: Any)` method, and read using the `def tag(k: String): Option[Any]` method. +Attributes can be added to a request using the `def attribute[T](k: AttributeKey[T], v: T)` method, and read using the `def attribute[T](k: Attribute[T]): Option[T]` method. -Backends, or backend wrappers can use tags e.g. for logging, passing a metric name, using different connection pools, or even different delegate backends. +Backends, or backend wrappers can use attributes e.g. for logging, passing a metric name, using different connection pools, or even different delegate backends. ## Listener backend @@ -42,6 +42,7 @@ Below is an example on how to implement a backend wrapper, which sends metrics for completed requests and wraps any `Future`-based backend: ```scala mdoc:compile-only +import sttp.attributes.AttributeKey import sttp.capabilities.Effect import sttp.client4._ import sttp.client4.akkahttp._ @@ -49,6 +50,7 @@ import sttp.client4.wrappers.DelegateBackend import scala.concurrent.Future import scala.concurrent.ExecutionContext.Implicits.global import scala.util._ + // the metrics infrastructure trait MetricsServer { def reportDuration(name: String, duration: Long): Unit @@ -58,6 +60,9 @@ class CloudMetricsServer extends MetricsServer { override def reportDuration(name: String, duration: Long): Unit = ??? } +case class MetricPrefix(prefix: String) +val MetricPrefixAttributeKey = AttributeKey[MetricPrefix] + // the backend wrapper abstract class MetricWrapper[P](delegate: GenericBackend[Future, P], metrics: MetricsServer) @@ -67,7 +72,7 @@ abstract class MetricWrapper[P](delegate: GenericBackend[Future, P], val start = System.currentTimeMillis() def report(metricSuffix: String): Unit = { - val metricPrefix = request.tag("metric").getOrElse("?") + val metricPrefix = request.attribute(MetricPrefixAttributeKey).getOrElse(MetricPrefix("?")) val end = System.currentTimeMillis() metrics.reportDuration(metricPrefix + "-" + metricSuffix, end - start) } @@ -93,7 +98,7 @@ val backend = MetricWrapper(AkkaHttpBackend(), new CloudMetricsServer()) basicRequest .get(uri"http://company.com/api/service1") - .tag("metric", "service1") + .attribute(MetricPrefixAttributeKey, MetricPrefix("service1")) .send(backend) ``` From 542c701e6a8064ed62432f678abd4ad8294325d9 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Thu, 12 Dec 2024 01:43:25 +0100 Subject: [PATCH 07/14] Update armeria to 1.31.3 (#2366) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 6b60d14f6..d001ddd40 100644 --- a/build.sbt +++ b/build.sbt @@ -734,7 +734,7 @@ lazy val armeriaBackend = (projectMatrix in file("armeria-backend")) .settings(testServerSettings) .settings( name := "armeria-backend", - libraryDependencies += "com.linecorp.armeria" % "armeria" % "1.31.2" + libraryDependencies += "com.linecorp.armeria" % "armeria" % "1.31.3" ) .jvmPlatform(scalaVersions = scala2 ++ scala3) .dependsOn(core % compileAndTest) From d592d1ad1650e1a1cd362b6c3354097b3cb0961f Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Thu, 12 Dec 2024 01:44:13 +0100 Subject: [PATCH 08/14] Update mdoc, sbt-mdoc to 2.6.2 (#2367) --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 218e65a66..174a1497e 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -9,7 +9,7 @@ addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-common" % s addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-publish" % sbtSoftwareMillVersion) addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-browser-test-js" % sbtSoftwareMillVersion) -addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.6.0") +addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.6.2") addSbtPlugin("org.jetbrains.scala" % "sbt-ide-settings" % "1.1.2") addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.4") From d40b8e72133579087a4bea7a4a46ca3ab45c5e57 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Thu, 12 Dec 2024 21:28:41 +0100 Subject: [PATCH 09/14] Improve usability of response specifications (#2365) --- .../zio/AsyncHttpClientZioHttpTest.scala | 5 +- build.sbt | 23 +- .../main/scala/sttp/client4/ResponseAs.scala | 198 ++++++++++++++---- .../sttp/client4/ResponseException.scala | 28 +++ .../src/main/scala/sttp/client4/SttpApi.scala | 160 ++++++++++++-- .../sttp/client4/SttpWebSocketAsyncApi.scala | 61 +++++- .../sttp/client4/SttpWebSocketStreamApi.scala | 42 +++- .../sttp/client4/SttpWebSocketSyncApi.scala | 48 ++++- .../src/main/scala/sttp/client4/request.scala | 28 +-- .../client4/testing/BackendStubTests.scala | 12 +- .../scala/sttp/client4/testing/HttpTest.scala | 37 +++- .../testing/streaming/StreamingTest.scala | 29 +++ .../sttp/client4/testing/SyncHttpTest.scala | 8 +- docs/json.md | 13 +- docs/responses/basics.md | 5 +- docs/responses/body.md | 34 +-- docs/responses/exceptions.md | 2 +- ... => GetAndParseJsonOrFailMonixCirce.scala} | 4 +- .../client4/examples/LogRequestsSlf4j.scala | 2 +- generated-docs/out/examples.md | 2 +- .../sttp/client4/circe/SttpCirceApi.scala | 23 +- .../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 | 31 ++- .../test/scala/sttp/client4/Json4sTests.scala | 58 +++-- .../jsoniter/SttpJsoniterJsonApi.scala | 22 +- .../client4/jsoniter/JsoniterJsonTests.scala | 57 +++-- .../client4/playJson/SttpPlayJsonApi.scala | 25 ++- .../scala/sttp/client4/PlayJsonTests.scala | 58 +++-- .../client4/sprayJson/SttpSprayJsonApi.scala | 25 ++- .../client4/sprayJson/SprayJsonTests.scala | 61 ++++-- .../client4/tethysJson/SttpTethysApi.scala | 19 ++ .../sttp/client4/tethysJson/TethysTests.scala | 58 +++-- .../client4/upicklejson/SttpUpickleApi.scala | 23 +- .../client4/upicklejson/UpickleTests.scala | 70 +++++-- .../sttp/client4/ziojson/SttpZioJsonApi.scala | 22 +- .../sttp/client4/ziojson/ZioJsonTests.scala | 58 +++-- .../sttp/client4/ziojson/SttpZioJsonApi.scala | 22 +- .../sttp/client4/ziojson/ZioJsonTests.scala | 58 +++-- .../OpenTelemetryMetricsBackendTest.scala | 4 +- .../prometheus/PrometheusBackendTest.scala | 4 +- 42 files changed, 1178 insertions(+), 347 deletions(-) rename examples-ce2/src/main/scala/sttp/client4/examples/{GetAndParseJsonGetRightMonixCirce.scala => GetAndParseJsonOrFailMonixCirce.scala} (86%) create mode 100644 json/common/src/test/scala/sttp/client4/json/RunResponseAs.scala 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 237712403..3102225a3 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/build.sbt b/build.sbt index d001ddd40..4bdc570ae 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 fa3a6bbfb..a695b54d1 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,53 +37,77 @@ 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] { + + /** 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 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 + 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 / 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 + 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. @@ -148,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. @@ -156,19 +206,39 @@ 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] { + + /** 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)) } @@ -178,18 +248,38 @@ 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] { + + /** 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)) } @@ -199,18 +289,38 @@ 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] { + + /** 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/ResponseException.scala b/core/src/main/scala/sttp/client4/ResponseException.scala index cfb2329a8..b8153ae05 100644 --- a/core/src/main/scala/sttp/client4/ResponseException.scala +++ b/core/src/main/scala/sttp/client4/ResponseException.scala @@ -4,9 +4,37 @@ import sttp.model.StatusCode import scala.annotation.tailrec +/** 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 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. + */ 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 35df0200f..368242e0a 100644 --- a/core/src/main/scala/sttp/client4/SttpApi.scala +++ b/core/src/main/scala/sttp/client4/SttpApi.scala @@ -38,29 +38,44 @@ 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") - /** 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 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 +83,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,39 +95,84 @@ trait SttpApi extends SttpExtensions with UriInterpolator { } .showAs("as string") + /** 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.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. + */ 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. */ + /** 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.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. + */ 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") } + /** 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.showAs("as params or fail") + 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 +182,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 +271,99 @@ 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 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. + */ 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 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.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 + * 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. + */ 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 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. + */ 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 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. + */ 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/core/src/main/scala/sttp/client4/SttpWebSocketAsyncApi.scala b/core/src/main/scala/sttp/client4/SttpWebSocketAsyncApi.scala index bc8ff93f0..2176905a8 100644 --- a/core/src/main/scala/sttp/client4/SttpWebSocketAsyncApi.scala +++ b/core/src/main/scala/sttp/client4/SttpWebSocketAsyncApi.scala @@ -4,34 +4,91 @@ 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.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. + * + * 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 ec9ea08f2..cdb064076 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.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. + * + * 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 9226c8e77..09f504f69 100644 --- a/core/src/main/scala/sttp/client4/SttpWebSocketSyncApi.scala +++ b/core/src/main/scala/sttp/client4/SttpWebSocketSyncApi.scala @@ -6,36 +6,80 @@ 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.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. + * + * 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/main/scala/sttp/client4/request.scala b/core/src/main/scala/sttp/client4/request.scala index 1cd627d8b..df7730ca4 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.getRight) - } - - 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) - } -} - // /** 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 53a5a87ac..7f91e7bad 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)) } @@ -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)) @@ -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 90c2c6273..ad368c06d 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))) @@ -92,13 +92,13 @@ 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))) } - "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") @@ -172,7 +199,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 => @@ -572,7 +599,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/scala/sttp/client4/testing/streaming/StreamingTest.scala b/core/src/test/scala/sttp/client4/testing/streaming/StreamingTest.scala index 3826dd0ee..824307a20 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() diff --git a/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala b/core/src/test/scalanative/sttp/client4/testing/SyncHttpTest.scala index 26f900263..774234563 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)) } @@ -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)) } @@ -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 dd83ee4ae..fc439da2e 100644 --- a/docs/json.md +++ b/docs/json.md @@ -6,23 +6,30 @@ 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: 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 `.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/basics.md b/docs/responses/basics.md index 2d88fdbf5..7bed1da56 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 9a9c35192..5d860cfd5 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,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 description 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. @@ -110,14 +110,14 @@ 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") .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. diff --git a/docs/responses/exceptions.md b/docs/responses/exceptions.md index ef10a8700..03a21b095 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 2f373d1bf..fedbe735e 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/examples/src/main/scala/sttp/client4/examples/LogRequestsSlf4j.scala b/examples/src/main/scala/sttp/client4/examples/LogRequestsSlf4j.scala index 9c5d73f93..34d57d541 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( diff --git a/generated-docs/out/examples.md b/generated-docs/out/examples.md index 563d19814..23b92e046 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/json/circe/src/main/scala/sttp/client4/circe/SttpCirceApi.scala b/json/circe/src/main/scala/sttp/client4/circe/SttpCirceApi.scala index a43016505..0a4c34e92 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 @@ -39,11 +46,21 @@ 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 + /** 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 8ad141755..6326593b0 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 4899e1e5d..b423cad04 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 000000000..f87ba8ad8 --- /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 4e037e4b1..bef89f980 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 @@ -46,12 +56,25 @@ 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 + /** 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 08cc91f3f..d255f06df 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 b2038472f..d6347824b 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 @@ -47,11 +53,21 @@ 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 + /** 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 d1cfa2ea4..c1e0fd44a 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 38071b307..4bb475e42 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 @@ -39,12 +46,22 @@ 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 + /** 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 957b5ee27..decc61631 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 49e02d4ae..021fe4577 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 @@ -35,12 +42,22 @@ 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 + /** 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 a1cf3843c..cbdba27b3 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 607917815..cfad65863 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 e7312ec4e..fbfcc4910 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 8984e5cfd..338411029 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 @@ -35,11 +42,21 @@ 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 + /** 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 1247cd999..5681dd445 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 daff87e65..21c72048b 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 @@ -44,11 +50,21 @@ 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 + /** 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 31153d1e4..8777f7683 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 daff87e65..21c72048b 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 @@ -44,11 +50,21 @@ 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 + /** 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 406706248..8777f7683 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]] 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 e7a83c531..fa4cf385f 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 81cf153cc..df451b319 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 0b6c40e102592d46c390d6a71b18b5dd53db0534 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Thu, 12 Dec 2024 22:11:35 +0100 Subject: [PATCH 10/14] Update fs2-io, fs2-reactive-streams to 3.11.0 (#2264) From d5a457a9e6b31fbb5e6d5c97f2502973e2afbc5e Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Fri, 13 Dec 2024 01:47:00 +0100 Subject: [PATCH 11/14] Update jsoniter-scala-core, ... to 2.32.0 (#2369) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 4bdc570ae..9017a78c9 100644 --- a/build.sbt +++ b/build.sbt @@ -120,7 +120,7 @@ val testServerSettings = Seq( val circeVersion: String = "0.14.10" -val jsoniterVersion = "2.31.3" +val jsoniterVersion = "2.32.0" val play29JsonVersion = "2.10.6" From 568105990a02487f418124ec3cdee31d86e1ac51 Mon Sep 17 00:00:00 2001 From: adamw Date: Fri, 13 Dec 2024 10:59:39 +0100 Subject: [PATCH 12/14] Release 4.0.0-M20 --- README.md | 10 ++-- generated-docs/out/backends/akka.md | 2 +- generated-docs/out/backends/catseffect.md | 6 +- generated-docs/out/backends/finagle.md | 2 +- generated-docs/out/backends/fs2.md | 8 +-- generated-docs/out/backends/future.md | 6 +- generated-docs/out/backends/http4s.md | 4 +- .../out/backends/javascript/fetch.md | 12 ++-- generated-docs/out/backends/monix.md | 6 +- generated-docs/out/backends/native/curl.md | 2 +- generated-docs/out/backends/pekko.md | 2 +- generated-docs/out/backends/scalaz.md | 2 +- generated-docs/out/backends/synchronous.md | 8 +-- .../out/backends/wrappers/custom.md | 23 +++++--- .../out/backends/wrappers/logging.md | 4 +- .../out/backends/wrappers/opentelemetry.md | 4 +- .../out/backends/wrappers/prometheus.md | 2 +- generated-docs/out/backends/zio.md | 8 +-- generated-docs/out/examples.md | 48 ++++++++-------- generated-docs/out/json.md | 57 +++++++++++-------- generated-docs/out/openapi.md | 8 +-- generated-docs/out/quickstart.md | 12 ++-- generated-docs/out/requests/body.md | 18 +++--- generated-docs/out/responses/basics.md | 5 +- generated-docs/out/responses/body.md | 34 +++++------ generated-docs/out/responses/exceptions.md | 2 +- generated-docs/out/testing.md | 4 +- generated-docs/out/websockets.md | 2 +- generated-docs/out/xml.md | 13 +++-- 29 files changed, 162 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index bbea82185..8d5eeab29 100755 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ sttp (v2) documentation is available at [sttp.softwaremill.com/en/v2](https://st sttp (v1) documentation is available at [sttp.softwaremill.com/en/v1](https://sttp.softwaremill.com/en/v1). -scaladoc is available at [https://www.javadoc.io](https://www.javadoc.io/doc/com.softwaremill.sttp.client4/core_2.12/4.0.0-M19) +scaladoc is available at [https://www.javadoc.io](https://www.javadoc.io/doc/com.softwaremill.sttp.client4/core_2.12/4.0.0-M20) ## Quickstart with scala-cli @@ -56,7 +56,7 @@ Add the following directive to the top of your scala file to add the core sttp d If you are using [scala-cli](https://scala-cli.virtuslab.org), you can quickly start experimenting with sttp by copy-pasting the following: ``` -//> using dep "com.softwaremill.sttp.client4::core:4.0.0-M19" +//> using dep "com.softwaremill.sttp.client4::core:4.0.0-M20" import sttp.client4.quick._ quickRequest.get(uri"http://httpbin.org/ip").send() ``` @@ -68,7 +68,7 @@ The `quick` package import brings in the sttp API and a pre-configured, global s Similarly, using [Ammonite](http://ammonite.io): ```scala -import $ivy.`com.softwaremill.sttp.client4::core:4.0.0-M19` +import $ivy.`com.softwaremill.sttp.client4::core:4.0.0-M20` import sttp.client4.quick._ quickRequest.get(uri"http://httpbin.org/ip").send() ``` @@ -78,7 +78,7 @@ quickRequest.get(uri"http://httpbin.org/ip").send() Add the following dependency: ```scala -"com.softwaremill.sttp.client4" %% "core" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "core" % "4.0.0-M20" ``` Then, import: @@ -135,7 +135,7 @@ The documentation is typechecked using [mdoc](https://scalameta.org/mdoc/). The When generating documentation, it's best to set the version to the current one, so that the generated doc files don't include modifications with the current snapshot version. -That is, in sbt run: `set version := "4.0.0-M19"`, before running `mdoc` in `docs`. +That is, in sbt run: `set version := "4.0.0-M20"`, before running `mdoc` in `docs`. ### Testing the Scala.JS backend diff --git a/generated-docs/out/backends/akka.md b/generated-docs/out/backends/akka.md index de8c36816..fbb4d3e84 100644 --- a/generated-docs/out/backends/akka.md +++ b/generated-docs/out/backends/akka.md @@ -3,7 +3,7 @@ This backend is based on [akka-http](http://doc.akka.io/docs/akka-http/current/scala/http/). To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "akka-http-backend" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "akka-http-backend" % "4.0.0-M20" ``` A fully **asynchronous** backend. Uses the `Future` effect to return responses. There are also [other `Future`-based backends](future.md), which don't depend on Akka. diff --git a/generated-docs/out/backends/catseffect.md b/generated-docs/out/backends/catseffect.md index 7e0c916ff..6c9d40eb0 100644 --- a/generated-docs/out/backends/catseffect.md +++ b/generated-docs/out/backends/catseffect.md @@ -14,7 +14,7 @@ Also note that the [http4s](http4s.md) backend can also be created for a type im Firstly, add the following dependency to your project: ```scala -"com.softwaremill.sttp.client4" %% "cats" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "cats" % "4.0.0-M20" ``` Obtain a cats-effect `Resource` which creates the backend, and closes the thread pool after the resource is no longer used: @@ -82,9 +82,9 @@ Creation of the backend can be done in two basic ways: Firstly, add the following dependency to your project: ```scala -"com.softwaremill.sttp.client4" %% "armeria-backend-cats" % "4.0.0-M19" // for cats-effect 3.x +"com.softwaremill.sttp.client4" %% "armeria-backend-cats" % "4.0.0-M20" // for cats-effect 3.x // or -"com.softwaremill.sttp.client4" %% "armeria-backend-cats-ce2" % "4.0.0-M19" // for cats-effect 2.x +"com.softwaremill.sttp.client4" %% "armeria-backend-cats-ce2" % "4.0.0-M20" // for cats-effect 2.x ``` create client: diff --git a/generated-docs/out/backends/finagle.md b/generated-docs/out/backends/finagle.md index 2840dc130..1d316ff32 100644 --- a/generated-docs/out/backends/finagle.md +++ b/generated-docs/out/backends/finagle.md @@ -3,7 +3,7 @@ To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "finagle-backend" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "finagle-backend" % "4.0.0-M20" ``` Next you'll need to add an implicit value: diff --git a/generated-docs/out/backends/fs2.md b/generated-docs/out/backends/fs2.md index eca42ebe4..9f272c1ee 100644 --- a/generated-docs/out/backends/fs2.md +++ b/generated-docs/out/backends/fs2.md @@ -12,9 +12,9 @@ Creation of the backend can be done in two basic ways: Firstly, add the following dependency to your project: ```scala -"com.softwaremill.sttp.client4" %% "fs2" % "4.0.0-M19" // for cats-effect 3.x & fs2 3.x +"com.softwaremill.sttp.client4" %% "fs2" % "4.0.0-M20" // for cats-effect 3.x & fs2 3.x // or -"com.softwaremill.sttp.client4" %% "fs2ce2" % "4.0.0-M19" // for cats-effect 2.x & fs2 2.x +"com.softwaremill.sttp.client4" %% "fs2ce2" % "4.0.0-M20" // for cats-effect 2.x & fs2 2.x ``` Obtain a cats-effect `Resource` which creates the backend, and closes the thread pool after the resource is no longer used: @@ -78,9 +78,9 @@ Host header override is supported in environments running Java 12 onwards, but i To use, add the following dependency to your project: ```scala -"com.softwaremill.sttp.client4" %% "armeria-backend-fs2" % "4.0.0-M19" // for cats-effect 3.x & fs2 3.x +"com.softwaremill.sttp.client4" %% "armeria-backend-fs2" % "4.0.0-M20" // for cats-effect 3.x & fs2 3.x // or -"com.softwaremill.sttp.client4" %% "armeria-backend-fs2" % "4.0.0-M19" // for cats-effect 2.x & fs2 2.x +"com.softwaremill.sttp.client4" %% "armeria-backend-fs2" % "4.0.0-M20" // for cats-effect 2.x & fs2 2.x ``` create client: diff --git a/generated-docs/out/backends/future.md b/generated-docs/out/backends/future.md index 65d22719f..b65ecd3be 100644 --- a/generated-docs/out/backends/future.md +++ b/generated-docs/out/backends/future.md @@ -20,7 +20,7 @@ Class Supported stream type To use, you don't need any extra dependencies, `core` is enough: ``` -"com.softwaremill.sttp.client4" %% "core" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "core" % "4.0.0-M20" ``` You'll need the following imports: @@ -59,7 +59,7 @@ Host header override is supported in environments running Java 12 onwards, but i To use, add the following dependency to your project: ```scala -"com.softwaremill.sttp.client4" %% "okhttp-backend" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "okhttp-backend" % "4.0.0-M20" ``` and some imports: @@ -91,7 +91,7 @@ This backend depends on [OkHttp](http://square.github.io/okhttp/) and fully supp To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "armeria-backend" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "armeria-backend" % "4.0.0-M20" ``` add imports: diff --git a/generated-docs/out/backends/http4s.md b/generated-docs/out/backends/http4s.md index bedfc9a11..df05f9b67 100644 --- a/generated-docs/out/backends/http4s.md +++ b/generated-docs/out/backends/http4s.md @@ -3,9 +3,9 @@ This backend is based on [http4s](https://http4s.org) (client) and is **asynchronous**. To use, add the following dependency to your project: ```scala -"com.softwaremill.sttp.client4" %% "http4s-backend" % "4.0.0-M19" // for cats-effect 3.x & http4s 1.0.0-Mx +"com.softwaremill.sttp.client4" %% "http4s-backend" % "4.0.0-M20" // for cats-effect 3.x & http4s 1.0.0-Mx // or -"com.softwaremill.sttp.client4" %% "http4s-ce2-backend" % "4.0.0-M19" // for cats-effect 2.x & http4s 0.21.x +"com.softwaremill.sttp.client4" %% "http4s-ce2-backend" % "4.0.0-M20" // for cats-effect 2.x & http4s 0.21.x ``` The backend can be created in a couple of ways, e.g.: diff --git a/generated-docs/out/backends/javascript/fetch.md b/generated-docs/out/backends/javascript/fetch.md index 6d0a12b89..1faf56f90 100644 --- a/generated-docs/out/backends/javascript/fetch.md +++ b/generated-docs/out/backends/javascript/fetch.md @@ -7,7 +7,7 @@ A JavaScript backend with web socket support. Implemented using the [Fetch API]( This is the default backend, available in the main jar for JS. To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %%% "core" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %%% "core" % "4.0.0-M20" ``` And create the backend instance: @@ -26,7 +26,7 @@ Note that `Fetch` does not pass cookies by default. If your request needs cookie To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %%% "monix" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %%% "monix" % "4.0.0-M20" ``` And create the backend instance: @@ -40,7 +40,7 @@ val backend = FetchMonixBackend() To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %%% "zio" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %%% "zio" % "4.0.0-M20" ``` And create the backend instance: @@ -55,13 +55,13 @@ Any effect implementing the cats-effect `Concurrent` typeclass can be used. To u your project: ``` -"com.softwaremill.sttp.client4" %%% "cats" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %%% "cats" % "4.0.0-M20" ``` If you are on Cats Effect 2 (CE2) you will need to add the CE2 specific dependency instead: ``` -"com.softwaremill.sttp.client4" %%% "catsce2 % "4.0.0-M19" +"com.softwaremill.sttp.client4" %%% "catsce2 % "4.0.0-M20" ``` And create the backend instance: @@ -129,7 +129,7 @@ Streaming support is provided via `FetchMonixBackend`. Note that streaming suppo To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %%% "monix" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %%% "monix" % "4.0.0-M20" ``` An example of streaming a response: diff --git a/generated-docs/out/backends/monix.md b/generated-docs/out/backends/monix.md index 18457b378..080fedaf7 100644 --- a/generated-docs/out/backends/monix.md +++ b/generated-docs/out/backends/monix.md @@ -12,7 +12,7 @@ Creation of the backend can be done in two basic ways: Firstly, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "monix" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "monix" % "4.0.0-M20" ``` and create the backend using: @@ -50,7 +50,7 @@ Host header override is supported in environments running Java 12 onwards, but i To use, add the following dependency to your project: ```scala -"com.softwaremill.sttp.client4" %% "okhttp-backend-monix" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "okhttp-backend-monix" % "4.0.0-M20" ``` Create the backend using: @@ -76,7 +76,7 @@ This backend depends on [OkHttp](http://square.github.io/okhttp/) and fully supp To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "armeria-backend-monix" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "armeria-backend-monix" % "4.0.0-M20" ``` add imports: diff --git a/generated-docs/out/backends/native/curl.md b/generated-docs/out/backends/native/curl.md index 92157693b..d9df8a792 100644 --- a/generated-docs/out/backends/native/curl.md +++ b/generated-docs/out/backends/native/curl.md @@ -5,7 +5,7 @@ A Scala Native backend implemented using [Curl](https://github.com/curl/curl/blo To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %%% "core" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %%% "core" % "4.0.0-M20" ``` and initialize one of the backends: diff --git a/generated-docs/out/backends/pekko.md b/generated-docs/out/backends/pekko.md index 71edbfd86..f75c8e0a8 100644 --- a/generated-docs/out/backends/pekko.md +++ b/generated-docs/out/backends/pekko.md @@ -3,7 +3,7 @@ This backend is based on [pekko-http](https://pekko.apache.org/docs/pekko-http/current/). To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "pekko-http-backend" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "pekko-http-backend" % "4.0.0-M20" ``` A fully **asynchronous** backend. Uses the `Future` effect to return responses. There are also [other `Future`-based backends](future.md), which don't depend on Pekko. diff --git a/generated-docs/out/backends/scalaz.md b/generated-docs/out/backends/scalaz.md index 23ed895ac..4167765d8 100644 --- a/generated-docs/out/backends/scalaz.md +++ b/generated-docs/out/backends/scalaz.md @@ -8,7 +8,7 @@ The [Scalaz](https://github.com/scalaz/scalaz) backend is **asynchronous**. Send To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "armeria-backend-scalaz" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "armeria-backend-scalaz" % "4.0.0-M20" ``` add imports: diff --git a/generated-docs/out/backends/synchronous.md b/generated-docs/out/backends/synchronous.md index ed3c03149..d1d6dc7b8 100644 --- a/generated-docs/out/backends/synchronous.md +++ b/generated-docs/out/backends/synchronous.md @@ -7,7 +7,7 @@ There are several synchronous backend implementations. Sending a request using t The default **synchronous** backend. To use, you don't need any extra dependencies, `core` is enough: ``` -"com.softwaremill.sttp.client4" %% "core" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "core" % "4.0.0-M20" ``` Create the backend using: @@ -40,7 +40,7 @@ Host header override is supported in environments running Java 12 onwards, but i To use, you don't need any extra dependencies, `core` is enough: ``` -"com.softwaremill.sttp.client4" %% "core" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "core" % "4.0.0-M20" ``` Create the backend using: @@ -62,7 +62,7 @@ This backend supports host header override, but it has to be enabled by system p To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "okhttp-backend" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "okhttp-backend" % "4.0.0-M20" ``` Create the backend using: @@ -99,7 +99,7 @@ Both HttpClient and OkHttp backends support regular [websockets](../websockets.m ``` // sbt dependency -"com.softwaremill.sttp.client4" %% "ox" % "4.0.0-M19", +"com.softwaremill.sttp.client4" %% "ox" % "4.0.0-M20", ``` ```scala diff --git a/generated-docs/out/backends/wrappers/custom.md b/generated-docs/out/backends/wrappers/custom.md index 670f44070..1e77f2d67 100644 --- a/generated-docs/out/backends/wrappers/custom.md +++ b/generated-docs/out/backends/wrappers/custom.md @@ -10,13 +10,13 @@ Possible use-cases for wrapper-backend include: See also the section on [resilience](../../resilience.md) which covers topics such as retries, circuit breaking and rate limiting. -## Request tagging +## Request attributes -Each request contains a `tags: Map[String, Any]` map. This map can be used to tag the request with any backend-specific information, and isn't used in any way by sttp itself. +Each request contains a `attributes: AttributeMap` type-safe map. This map can be used to tag the request with any backend-specific information, and isn't used in any way by sttp itself. -Tags can be added to a request using the `def tag(k: String, v: Any)` method, and read using the `def tag(k: String): Option[Any]` method. +Attributes can be added to a request using the `def attribute[T](k: AttributeKey[T], v: T)` method, and read using the `def attribute[T](k: Attribute[T]): Option[T]` method. -Backends, or backend wrappers can use tags e.g. for logging, passing a metric name, using different connection pools, or even different delegate backends. +Backends, or backend wrappers can use attributes e.g. for logging, passing a metric name, using different connection pools, or even different delegate backends. ## Listener backend @@ -42,6 +42,7 @@ Below is an example on how to implement a backend wrapper, which sends metrics for completed requests and wraps any `Future`-based backend: ```scala +import sttp.attributes.AttributeKey import sttp.capabilities.Effect import sttp.client4._ import sttp.client4.akkahttp._ @@ -49,6 +50,7 @@ import sttp.client4.wrappers.DelegateBackend import scala.concurrent.Future import scala.concurrent.ExecutionContext.Implicits.global import scala.util._ + // the metrics infrastructure trait MetricsServer { def reportDuration(name: String, duration: Long): Unit @@ -58,6 +60,9 @@ class CloudMetricsServer extends MetricsServer { override def reportDuration(name: String, duration: Long): Unit = ??? } +case class MetricPrefix(prefix: String) +val MetricPrefixAttributeKey = AttributeKey[MetricPrefix] + // the backend wrapper abstract class MetricWrapper[P](delegate: GenericBackend[Future, P], metrics: MetricsServer) @@ -67,7 +72,7 @@ abstract class MetricWrapper[P](delegate: GenericBackend[Future, P], val start = System.currentTimeMillis() def report(metricSuffix: String): Unit = { - val metricPrefix = request.tag("metric").getOrElse("?") + val metricPrefix = request.attribute(MetricPrefixAttributeKey).getOrElse(MetricPrefix("?")) val end = System.currentTimeMillis() metrics.reportDuration(metricPrefix + "-" + metricSuffix, end - start) } @@ -93,7 +98,7 @@ val backend = MetricWrapper(AkkaHttpBackend(), new CloudMetricsServer()) basicRequest .get(uri"http://company.com/api/service1") - .tag("metric", "service1") + .attribute(MetricPrefixAttributeKey, MetricPrefix("service1")) .send(backend) ``` @@ -260,7 +265,7 @@ object RateLimitingSttpBackend { Implementing a new backend is made easy as the tests are published in the `core` jar file under the `tests` classifier. Simply add the follow dependencies to your `build.sbt`: ``` -"com.softwaremill.sttp.client4" %% "core" % "4.0.0-M19" % Test classifier "tests" +"com.softwaremill.sttp.client4" %% "core" % "4.0.0-M20" % Test classifier "tests" ``` Implement your backend and extend the `HttpTest` class: @@ -288,9 +293,9 @@ import sttp.client4.impl.cats.implicits._ from the cats integration module. The module should be available on the classpath after adding following dependency: ```scala -"com.softwaremill.sttp.client4" %% "cats" % "4.0.0-M19" // for cats-effect 3.x +"com.softwaremill.sttp.client4" %% "cats" % "4.0.0-M20" // for cats-effect 3.x // or -"com.softwaremill.sttp.client4" %% "catsce2" % "4.0.0-M19" // for cats-effect 2.x +"com.softwaremill.sttp.client4" %% "catsce2" % "4.0.0-M20" // for cats-effect 2.x ``` The object contains implicits to convert a cats `MonadError` into the sttp `MonadError`, diff --git a/generated-docs/out/backends/wrappers/logging.md b/generated-docs/out/backends/wrappers/logging.md index c7deb594c..80890923c 100644 --- a/generated-docs/out/backends/wrappers/logging.md +++ b/generated-docs/out/backends/wrappers/logging.md @@ -28,7 +28,7 @@ Log levels can be configured when creating the `LoggingBackend`, or specified in To use the [slf4j](http://www.slf4j.org) logging backend wrapper, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "slf4j-backend" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "slf4j-backend" % "4.0.0-M20" ``` There are three backend wrappers available, which log request & response information using a slf4j `Logger`. To see the logs, you'll need to use an slf4j-compatible logger implementation, e.g. [logback](http://logback.qos.ch), or use a binding, e.g. [log4j-slf4j](https://logging.apache.org/log4j/2.x/log4j-slf4j-impl.html). @@ -53,5 +53,5 @@ To create a customised logging backend, see the section on [custom backends](cus To use the [scribe](https://github.com/outr/scribe) logging backend wrapper, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "scribe-backend" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "scribe-backend" % "4.0.0-M20" ``` \ No newline at end of file diff --git a/generated-docs/out/backends/wrappers/opentelemetry.md b/generated-docs/out/backends/wrappers/opentelemetry.md index 20234dcdb..9e1560fa6 100644 --- a/generated-docs/out/backends/wrappers/opentelemetry.md +++ b/generated-docs/out/backends/wrappers/opentelemetry.md @@ -12,7 +12,7 @@ The backend depends only on [opentelemetry-api](https://github.com/open-telemetr following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "opentelemetry-metrics-backend" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "opentelemetry-metrics-backend" % "4.0.0-M20" ``` Then an instance can be obtained as follows: @@ -55,7 +55,7 @@ OpenTelemetryMetricsBackend( To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "opentelemetry-tracing-zio-backend" % "4.0.0-M19" // for ZIO 2.x +"com.softwaremill.sttp.client4" %% "opentelemetry-tracing-zio-backend" % "4.0.0-M20" // for ZIO 2.x ``` This backend depends on [zio-opentelemetry](https://github.com/zio/zio-telemetry). diff --git a/generated-docs/out/backends/wrappers/prometheus.md b/generated-docs/out/backends/wrappers/prometheus.md index 41f854df7..1f4ea53b9 100644 --- a/generated-docs/out/backends/wrappers/prometheus.md +++ b/generated-docs/out/backends/wrappers/prometheus.md @@ -3,7 +3,7 @@ To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "prometheus-backend" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "prometheus-backend" % "4.0.0-M20" ``` and some imports: diff --git a/generated-docs/out/backends/zio.md b/generated-docs/out/backends/zio.md index 30d7be6db..0d0e3a323 100644 --- a/generated-docs/out/backends/zio.md +++ b/generated-docs/out/backends/zio.md @@ -9,8 +9,8 @@ The `*-zio` modules depend on ZIO 2.x. For ZIO 1.x support, use modules with the To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "zio" % "4.0.0-M19" // for ZIO 2.x -"com.softwaremill.sttp.client4" %% "zio1" % "4.0.0-M19" // for ZIO 1.x +"com.softwaremill.sttp.client4" %% "zio" % "4.0.0-M20" // for ZIO 2.x +"com.softwaremill.sttp.client4" %% "zio1" % "4.0.0-M20" // for ZIO 1.x ``` Create the backend using: @@ -45,8 +45,8 @@ Host header override is supported in environments running Java 12 onwards, but i To use, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "armeria-backend-zio" % "4.0.0-M19" // for ZIO 2.x -"com.softwaremill.sttp.client4" %% "armeria-backend-zio1" % "4.0.0-M19" // for ZIO 1.x +"com.softwaremill.sttp.client4" %% "armeria-backend-zio" % "4.0.0-M20" // for ZIO 2.x +"com.softwaremill.sttp.client4" %% "armeria-backend-zio1" % "4.0.0-M20" // for ZIO 1.x ``` add imports: diff --git a/generated-docs/out/examples.md b/generated-docs/out/examples.md index 23b92e046..8a0c6b632 100644 --- a/generated-docs/out/examples.md +++ b/generated-docs/out/examples.md @@ -7,7 +7,7 @@ All of the examples are available [in the sources](https://github.com/softwaremi Required dependencies: ```scala -libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "core" % "4.0.0-M19") +libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "core" % "4.0.0-M20") ``` Example code: @@ -22,7 +22,7 @@ Example code: Required dependencies: ```scala -libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "core" % "4.0.0-M19") +libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "core" % "4.0.0-M20") ``` Example code: @@ -38,8 +38,8 @@ Required dependencies: ```scala libraryDependencies ++= List( - "com.softwaremill.sttp.client4" %% "akka-http-backend" % "4.0.0-M19", - "com.softwaremill.sttp.client4" %% "json4s" % "4.0.0-M19", + "com.softwaremill.sttp.client4" %% "akka-http-backend" % "4.0.0-M20", + "com.softwaremill.sttp.client4" %% "json4s" % "4.0.0-M20", "org.json4s" %% "json4s-native" % "3.6.0" ) ``` @@ -57,8 +57,8 @@ Required dependencies: ```scala libraryDependencies ++= List( - "com.softwaremill.sttp.client4" %% "zio" % "4.0.0-M19", - "com.softwaremill.sttp.client4" %% "circe" % "4.0.0-M19", + "com.softwaremill.sttp.client4" %% "zio" % "4.0.0-M20", + "com.softwaremill.sttp.client4" %% "circe" % "4.0.0-M20", "io.circe" %% "circe-generic" % "0.14.10" ) ``` @@ -76,8 +76,8 @@ Required dependencies: ```scala libraryDependencies ++= List( - "com.softwaremill.sttp.client4" %% "monix" % "4.0.0-M19", - "com.softwaremill.sttp.client4" %% "circe" % "4.0.0-M19", + "com.softwaremill.sttp.client4" %% "monix" % "4.0.0-M20", + "com.softwaremill.sttp.client4" %% "circe" % "4.0.0-M20", "io.circe" %% "circe-generic" % "0.14.10" ) ``` @@ -85,7 +85,7 @@ libraryDependencies ++= List( Example code: ```eval_rst -.. literalinclude:: ../../examples-ce2/src/main/scala/sttp/client4/examples/GetAndParseJsonOrFailMonixCirce.scala +.. literalinclude:: ../../examples-ce2/src/main/scala/sttp/client4/examples/GetAndParseJsonGetRightMonixCirce.scala :language: scala ``` @@ -95,8 +95,8 @@ Required dependencies: ```scala libraryDependencies ++= List( - "com.softwaremill.sttp.client4" %% "slf4j-backend" % "4.0.0-M19", - "com.softwaremill.sttp.client4" %% "circe" % "4.0.0-M19", + "com.softwaremill.sttp.client4" %% "slf4j-backend" % "4.0.0-M20", + "com.softwaremill.sttp.client4" %% "circe" % "4.0.0-M20", "io.circe" %% "circe-generic" % "0.14.10" ) ``` @@ -114,8 +114,8 @@ Required dependencies: ```scala libraryDependencies ++= List( - "com.softwaremill.sttp.client4" %% "monix" % "4.0.0-M19", - "com.softwaremill.sttp.client4" %% "circe" % "4.0.0-M19", + "com.softwaremill.sttp.client4" %% "monix" % "4.0.0-M20", + "com.softwaremill.sttp.client4" %% "circe" % "4.0.0-M20", "io.circe" %% "circe-generic" % "0.14.10" ) ``` @@ -132,7 +132,7 @@ Example code: Required dependencies: ```scala -libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "core" % "4.0.0-M19") +libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "core" % "4.0.0-M20") ``` Example code: @@ -146,7 +146,7 @@ Example code: Required dependencies: ```scala -libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "zio" % "4.0.0-M19") +libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "zio" % "4.0.0-M20") ``` Example code: @@ -161,7 +161,7 @@ Example code: Required dependencies: ```scala -libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "fs2" % "4.0.0-M19") +libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "fs2" % "4.0.0-M20") ``` Example code: @@ -176,7 +176,7 @@ Example code: Required dependencies: ```scala -libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "monix" % "4.0.0-M19") +libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "monix" % "4.0.0-M20") ``` Example code: @@ -191,7 +191,7 @@ Example code: Required dependencies: ```scala -libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "akka-http-backend" % "4.0.0-M19") +libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "akka-http-backend" % "4.0.0-M20") ``` Example code: @@ -206,7 +206,7 @@ Example code: Required dependencies: ```scala -libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "pekko-http-backend" % "4.0.0-M19") +libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "pekko-http-backend" % "4.0.0-M20") ``` Example code: @@ -221,7 +221,7 @@ Example code: Required dependencies: ```scala -libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "monix" % "4.0.0-M19") +libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "monix" % "4.0.0-M20") ``` Example code: @@ -236,7 +236,7 @@ Example code: Required dependencies: ```scala -libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "fs2" % "4.0.0-M19") +libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "fs2" % "4.0.0-M20") ``` Example code: @@ -251,7 +251,7 @@ Example code: Required dependencies: ```scala -libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "zio" % "4.0.0-M19") +libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "zio" % "4.0.0-M20") ``` Example code: @@ -266,7 +266,7 @@ Example code: Required dependencies: ```scala -libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "zio" % "4.0.0-M19") +libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "zio" % "4.0.0-M20") ``` Example code: @@ -281,7 +281,7 @@ Example code: Required dependencies: ```scala -libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "core" % "4.0.0-M19") +libraryDependencies ++= List("com.softwaremill.sttp.client4" %% "core" % "4.0.0-M20") ``` Example code: diff --git a/generated-docs/out/json.md b/generated-docs/out/json.md index d84dba384..f91ce9fe8 100644 --- a/generated-docs/out/json.md +++ b/generated-docs/out/json.md @@ -2,25 +2,34 @@ Adding support for JSON (or other format) bodies in requests/responses is a matter of providing a [body serializer](requests/body.md) and/or a [response body specification](responses/body.md). Both are quite straightforward to implement, so integrating with your favorite JSON library shouldn't be a problem. However, there are some integrations available out-of-the-box. -Each integration is available as an import, which brings the implicit `BodySerializer`s and `asJson` methods into scope. Alternatively, these values are grouped intro traits (e.g. `sttp.client4.circe.SttpCirceApi`), which can be extended to group multiple integrations in one object, and thus reduce the number of necessary imports. +Each integration is available as an import, which brings `asJson` methods into scope. Alternatively, these values are grouped intro traits (e.g. `sttp.client4.circe.SttpCirceApi`), which can be extended to group multiple integrations in one object, and thus reduce the number of necessary imports. The following variants of `asJson` methods are available: -* regular - deserializes the body to json, only if the response is successful (2xx) -* `always` - deserializes the body to json regardless of the status code -* `either` - uses different deserializers for error and successful (2xx) responses +* `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 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 `.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: @@ -34,7 +43,7 @@ case class ResponsePayload(data: String) JSON encoding of bodies and decoding of responses can be handled using [Circe](https://circe.github.io/circe/) by the `circe` module. To use add the following dependency to your project: ```scala -"com.softwaremill.sttp.client4" %% "circe" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "circe" % "4.0.0-M20" ``` This module adds a body serialized, so that json payloads can be sent as request bodies. To send a payload of type `T` as json, a `io.circe.Encoder[T]` implicit value must be available in scope. @@ -54,7 +63,7 @@ val requestPayload = RequestPayload("some data") val response: Response[Either[ResponseException[String, io.circe.Error], ResponsePayload]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asJson(requestPayload)) .response(asJson[ResponsePayload]) .send(backend) ``` @@ -66,7 +75,7 @@ Arbitrary JSON structures can be traversed by parsing the result as `io.circe.Js To encode and decode json using json4s, add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "json4s" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "json4s" % "4.0.0-M20" "org.json4s" %% "json4s-native" % "3.6.0" ``` @@ -90,7 +99,7 @@ implicit val formats = org.json4s.DefaultFormats val response: Response[Either[ResponseException[String, Exception], ResponsePayload]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asJson(requestPayload)) .response(asJson[ResponsePayload]) .send(backend) ``` @@ -100,7 +109,7 @@ val response: Response[Either[ResponseException[String, Exception], ResponsePayl To encode and decode JSON using [spray-json](https://github.com/spray/spray-json), add the following dependency to your project: ``` -"com.softwaremill.sttp.client4" %% "spray-json" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "spray-json" % "4.0.0-M20" ``` Using this module it is possible to set request bodies and read response bodies as your custom types, using the implicitly available instances of `spray.json.JsonWriter` / `spray.json.JsonReader` or `spray.json.JsonFormat`. @@ -122,7 +131,7 @@ val requestPayload = RequestPayload("some data") val response: Response[Either[ResponseException[String, Exception], ResponsePayload]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asJson(requestPayload)) .response(asJson[ResponsePayload]) .send(backend) ``` @@ -132,13 +141,13 @@ val response: Response[Either[ResponseException[String, Exception], ResponsePayl To encode and decode JSON using [play-json](https://www.playframework.com), add the following dependency to your project: ```scala -"com.softwaremill.sttp.client4" %% "play-json" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "play-json" % "4.0.0-M20" ``` If you use older version of play (2.9.x), add the following dependency to your project: ```scala -"com.softwaremill.sttp.client4" %% "play29-json" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "play29-json" % "4.0.0-M20" ``` To use, add an import: `import sttp.client4.playJson._`. @@ -150,13 +159,13 @@ To encode and decode JSON using the high-performance [zio-json](https://zio.gith The `zio-json` module depends on ZIO 2.x. For ZIO 1.x support, use `zio1-json`. ```scala -"com.softwaremill.sttp.client4" %% "zio-json" % "4.0.0-M19" // for ZIO 2.x -"com.softwaremill.sttp.client4" %% "zio1-json" % "4.0.0-M19" // for ZIO 1.x +"com.softwaremill.sttp.client4" %% "zio-json" % "4.0.0-M20" // for ZIO 2.x +"com.softwaremill.sttp.client4" %% "zio1-json" % "4.0.0-M20" // for ZIO 1.x ``` or for ScalaJS (cross build) projects: ```scala -"com.softwaremill.sttp.client4" %%% "zio-json" % "4.0.0-M19" // for ZIO 2.x -"com.softwaremill.sttp.client4" %%% "zio1-json" % "4.0.0-M19" // for ZIO 1.x +"com.softwaremill.sttp.client4" %%% "zio-json" % "4.0.0-M20" // for ZIO 2.x +"com.softwaremill.sttp.client4" %%% "zio1-json" % "4.0.0-M20" // for ZIO 1.x ``` To use, add an import: `import sttp.client4.ziojson._` (or extend `SttpZioJsonApi`), define an implicit `JsonCodec`, or `JsonDecoder`/`JsonEncoder` for your datatype. @@ -178,7 +187,7 @@ val requestPayload = RequestPayload("some data") val response: Response[Either[ResponseException[String, String], ResponsePayload]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asJson(requestPayload)) .response(asJson[ResponsePayload]) .send(backend) ``` @@ -188,13 +197,13 @@ basicRequest To encode and decode JSON using the [high(est)-performant](https://plokhotnyuk.github.io/jsoniter-scala/) [jsoniter-scala](https://github.com/plokhotnyuk/jsoniter-scala) library, one add the following dependency to your project. ```scala -"com.softwaremill.sttp.client4" %% "jsoniter" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "jsoniter" % "4.0.0-M20" ``` or for ScalaJS (cross build) projects: ```scala -"com.softwaremill.sttp.client4" %%% "jsoniter" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %%% "jsoniter" % "4.0.0-M20" ``` To use, add an import: `import sttp.client4.jsoniter._` (or extend `SttpJsonIterJsonApi`), define an implicit `JsonCodec`, or `JsonDecoder`/`JsonEncoder` for your datatype. @@ -218,7 +227,7 @@ val requestPayload = RequestPayload("some data") val response: Response[Either[ResponseException[String, Exception], ResponsePayload]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asJson(requestPayload)) .response(asJson[ResponsePayload]) .send(backend) ``` @@ -228,13 +237,13 @@ basicRequest To encode and decode JSON using the [uPickle](https://github.com/com-lihaoyi/upickle) library, add the following dependency to your project: ```scala -"com.softwaremill.sttp.client4" %% "upickle" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "upickle" % "4.0.0-M20" ``` or for ScalaJS (cross build) projects: ```scala -"com.softwaremill.sttp.client4" %%% "upickle" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %%% "upickle" % "4.0.0-M20" ``` To use, add an import: `import sttp.client4.upicklejson.default._` and define an implicit `ReadWriter` (or separately `Reader` and `Writer`) for your datatype. @@ -255,7 +264,7 @@ val requestPayload = RequestPayload("some data") val response: Response[Either[ResponseException[String, Exception], ResponsePayload]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asJson(requestPayload)) .response(asJson[ResponsePayload]) .send(backend) ``` diff --git a/generated-docs/out/openapi.md b/generated-docs/out/openapi.md index 1935a3b2a..62917b525 100644 --- a/generated-docs/out/openapi.md +++ b/generated-docs/out/openapi.md @@ -43,8 +43,8 @@ lazy val petstoreApi: Project = project openApiGeneratorName := "scala-sttp", openApiOutputDir := baseDirectory.value.name, libraryDependencies ++= Seq( - "com.softwaremill.sttp.client4" %% "core" % "4.0.0-M19", - "com.softwaremill.sttp.client4" %% "json4s" % "4.0.0-M19", + "com.softwaremill.sttp.client4" %% "core" % "4.0.0-M20", + "com.softwaremill.sttp.client4" %% "json4s" % "4.0.0-M20", "org.json4s" %% "json4s-jackson" % "3.6.8" ) ) @@ -94,8 +94,8 @@ lazy val petstoreApi: Project = project openApiOutputDir := baseDirectory.value.name, openApiIgnoreFileOverride := s"${baseDirectory.in(ThisBuild).value.getPath}/openapi-ignore-file", libraryDependencies ++= Seq( - "com.softwaremill.sttp.client4" %% "core" % "4.0.0-M19", - "com.softwaremill.sttp.client4" %% "json4s" % "4.0.0-M19", + "com.softwaremill.sttp.client4" %% "core" % "4.0.0-M20", + "com.softwaremill.sttp.client4" %% "json4s" % "4.0.0-M20", "org.json4s" %% "json4s-jackson" % "3.6.8" ), (compile in Compile) := ((compile in Compile) dependsOn openApiGenerate).value, diff --git a/generated-docs/out/quickstart.md b/generated-docs/out/quickstart.md index c50ed5d57..cbb04ea91 100644 --- a/generated-docs/out/quickstart.md +++ b/generated-docs/out/quickstart.md @@ -15,7 +15,7 @@ platforms, and that each has its own dedicated set of backends. The basic dependency which provides the API, together with a synchronous and `Future`-based backends, is: ```scala -"com.softwaremill.sttp.client4" %% "core" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "core" % "4.0.0-M20" ``` ## Using scala-cli @@ -23,7 +23,7 @@ The basic dependency which provides the API, together with a synchronous and `Fu Add the following directive to the top of your scala file to add the core sttp dependency: ``` -//> using dep "com.softwaremill.sttp.client4::core:4.0.0-M19" +//> using dep "com.softwaremill.sttp.client4::core:4.0.0-M20" ``` ## Using Ammonite @@ -31,7 +31,7 @@ Add the following directive to the top of your scala file to add the core sttp d If you are an [Ammonite](https://ammonite.io) user, you can quickly start experimenting with sttp by copy-pasting the following: ```scala -import $ivy.`com.softwaremill.sttp.client4::core:4.0.0-M19` +import $ivy.`com.softwaremill.sttp.client4::core:4.0.0-M20` ``` ## Imports @@ -72,7 +72,7 @@ As an example, to integrate with the [uPickle](https://github.com/com-lihaoyi/up dependency: ```scala -"com.softwaremill.sttp.client4" %% "upickle" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "upickle" % "4.0.0-M20" ``` Your code might then look as follows: @@ -93,7 +93,7 @@ implicit val responseRW: ReadWriter[HttpBinResponse] = macroRW[HttpBinResponse] val request = basicRequest .post(uri"https://httpbin.org/post") - .body(MyRequest("test", 42)) + .body(asJson(MyRequest("test", 42))) .response(asJson[HttpBinResponse]) val response = request.send(backend) @@ -109,7 +109,7 @@ Logging can be added using the [logging backend wrapper](backends/wrappers/loggi use slf4j, you'll need the following dependency: ``` -"com.softwaremill.sttp.client4" %% "slf4j-backend" % "4.0.0-M19" +"com.softwaremill.sttp.client4" %% "slf4j-backend" % "4.0.0-M20" ``` Then, you'll need to configure your client: diff --git a/generated-docs/out/requests/body.md b/generated-docs/out/requests/body.md index 7f05de54e..037581877 100644 --- a/generated-docs/out/requests/body.md +++ b/generated-docs/out/requests/body.md @@ -83,15 +83,11 @@ basicRequest.body("k1" -> "v1", "k2" -> "v2") basicRequest.body(Seq("k1" -> "v1", "k2" -> "v2"), "utf-8") ``` -## Custom body serializers +## Custom serializers -It is also possible to set custom types as request bodies, as long as there's an implicit `BodySerializer[B]` value in scope, which is simply an alias for a function: - -```scala -type BodySerializer[B] = B => BasicRequestBody -``` - -A `BasicRequestBody` is a wrapper for one of the supported request body types: a `String`/byte array or an input stream. +It is also possible to write custom serializers, which return arbitrary body representations. These should be +methods/functions which return instances of `BasicBody`, which is a wrapper for one of the supported request body +types: a `String`, byte array, an input stream, etc. For example, here's how to write a custom serializer for a case class, with serializer-specific default content type: @@ -101,12 +97,12 @@ import sttp.model.MediaType case class Person(name: String, surname: String, age: Int) // for this example, assuming names/surnames can't contain commas -implicit val personSerializer: BodySerializer[Person] = { p: Person => +def serializePerson(p: Person): BasicBody = { val serialized = s"${p.name},${p.surname},${p.age}" StringBody(serialized, "UTF-8", MediaType.TextCsv) } -basicRequest.body(Person("mary", "smith", 67)) +basicRequest.body(serializePerson(Person("mary", "smith", 67))) ``` -See the implementations of the `BasicRequestBody` trait for more options. +See the implementations of the `BasicBody` trait for more options. diff --git a/generated-docs/out/responses/basics.md b/generated-docs/out/responses/basics.md index a98cc7f17..caf8c0475 100644 --- a/generated-docs/out/responses/basics.md +++ b/generated-docs/out/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/generated-docs/out/responses/body.md b/generated-docs/out/responses/body.md index d2da065e5..e39cacb27 100644 --- a/generated-docs/out/responses/body.md +++ b/generated-docs/out/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 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,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 description can be modified using the `.orFail` combinator: ```scala 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. @@ -110,14 +110,14 @@ As an example, to read the response body as an int, the following response descr ```scala 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") .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 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 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. diff --git a/generated-docs/out/responses/exceptions.md b/generated-docs/out/responses/exceptions.md index dbf07da8f..ec206538b 100644 --- a/generated-docs/out/responses/exceptions.md +++ b/generated-docs/out/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/generated-docs/out/testing.md b/generated-docs/out/testing.md index 99fc243fb..37f236022 100644 --- a/generated-docs/out/testing.md +++ b/generated-docs/out/testing.md @@ -285,12 +285,12 @@ backend.whenAnyRequest.thenRespond(webSocketStub) There is a possiblity to add error responses as well. If this is not enough, using a custom implementation of the `WebSocket` trait is recommended. -## Verifying, that a request was sent +## Verifying that a request was sent Using `RecordingSttpBackend` it's possible to capture all interactions in which a backend has been involved. The recording backend is a [backend wrapper](backends/wrappers/custom.md), and it can wrap any backend, but it's most -useful when combine with the backend stub. +useful when combined with the backend stub. Example usage: diff --git a/generated-docs/out/websockets.md b/generated-docs/out/websockets.md index 9fd5ee5a3..6da0bf556 100644 --- a/generated-docs/out/websockets.md +++ b/generated-docs/out/websockets.md @@ -104,7 +104,7 @@ as Ox `Source` and `Sink`: ``` // sbt dependency -"com.softwaremill.sttp.client4" %% "ox" % "4.0.0-M19", +"com.softwaremill.sttp.client4" %% "ox" % "4.0.0-M20", ``` ```scala diff --git a/generated-docs/out/xml.md b/generated-docs/out/xml.md index c5286377b..96bea7e2e 100644 --- a/generated-docs/out/xml.md +++ b/generated-docs/out/xml.md @@ -12,7 +12,7 @@ After code generation, create the `SttpScalaxbApi` trait (or trait with another import generated.defaultScope // import may differ depending on location of generated code import scalaxb.`package`.{fromXML, toXML} // import may differ depending on location of generated code import scalaxb.{CanWriteXML, XMLFormat} // import may differ depending on location of generated code -import sttp.client4.{BodySerializer, ResponseAs, ResponseException, StringBody, asString} +import sttp.client4.{ResponseAs, ResponseException, StringBody, asString} import sttp.model.MediaType import scala.xml.{NodeSeq, XML} @@ -20,7 +20,7 @@ import scala.xml.{NodeSeq, XML} trait SttpScalaxbApi { case class XmlElementLabel(label: String) - implicit def scalaxbBodySerializer[B](implicit format: CanWriteXML[B], label: XmlElementLabel): BodySerializer[B] = { (b: B) => + def asXml[B](b: B)(implicit format: CanWriteXML[B], label: XmlElementLabel): StringBody = { val nodeSeq: NodeSeq = toXML[B](obj = b, elementLabel = label.label, scope = defaultScope) StringBody(nodeSeq.toString(), "utf-8", MediaType.ApplicationXml) } @@ -38,11 +38,11 @@ trait SttpScalaxbApi { .showAs("either(as string, as xml)") } ``` -This would add `BodySerializer` needed for serialization and `asXml` method needed for deserialization. Please notice, that `fromXML`, `toXML`, `CanWriteXML`, `XMLFormat` and `defaultScope` are members of code generated by scalaxb. +This would add `asXml` methods needed for serialization and deserialization. Please notice, that `fromXML`, `toXML`, `CanWriteXML`, `XMLFormat` and `defaultScope` are members of code generated by scalaxb. + +Next to this trait, you might want to introduce `sttpScalaxb` package object to simplify imports. -Next to this trait, you might want to introduce `sttpScalaxb` -package object to simplify imports. ```scala package object sttpScalaxb extends SttpScalaxbApi ``` @@ -50,6 +50,7 @@ package object sttpScalaxb extends SttpScalaxbApi From now on, XML serialization/deserialization would work for all classes generated from `.xsd` file as long as `XMLFormat` for the type in the question and `XmlElementLabel` for the top XML node would be implicitly provided in the scope. Usage example: + ```scala val backend: SyncBackend = DefaultSyncBackend() val requestPayload = Outer(Inner(42, b = true, "horses"), "cats") // `Outer` and `Inner` classes are generated by scalaxb from xsd file @@ -61,7 +62,7 @@ import generated.Generated_OuterFormat // imports member of code generated by sc val response: Response[Either[ResponseException[String, Exception], Outer]] = basicRequest .post(uri"...") - .body(requestPayload) + .body(asXml(requestPayload)) .response(asXml[Outer]) .send(backend) ``` \ No newline at end of file From 9d6c15976366a8b29f0ecc2768396e500bf6299e Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Sat, 14 Dec 2024 01:44:09 +0100 Subject: [PATCH 13/14] Update prometheus-metrics-core to 1.3.5 (#2370) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 9017a78c9..aec3fea47 100644 --- a/build.sbt +++ b/build.sbt @@ -975,7 +975,7 @@ lazy val prometheusBackend = (projectMatrix in file("observability/prometheus-ba .settings( name := "prometheus-backend", libraryDependencies ++= Seq( - "io.prometheus" % "prometheus-metrics-core" % "1.3.4" + "io.prometheus" % "prometheus-metrics-core" % "1.3.5" ), scalaTest ) From 067e78c036cb724311afa510e75f1b1e4f1484f6 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Mon, 16 Dec 2024 01:44:50 +0100 Subject: [PATCH 14/14] Update async-http-client to 2.12.4 (#2371) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index aec3fea47..be44fd211 100644 --- a/build.sbt +++ b/build.sbt @@ -584,7 +584,7 @@ lazy val asyncHttpClientBackend = (projectMatrix in file("async-http-client-back .settings( name := "async-http-client-backend", libraryDependencies ++= Seq( - "org.asynchttpclient" % "async-http-client" % "2.12.3" + "org.asynchttpclient" % "async-http-client" % "2.12.4" ) ) .dependsOn(core % compileAndTest)