Skip to content

Commit

Permalink
Remove BodySerializer (#2362)
Browse files Browse the repository at this point in the history
  • Loading branch information
adamw authored Dec 10, 2024
1 parent b8ddedd commit 3ce24c1
Show file tree
Hide file tree
Showing 32 changed files with 226 additions and 208 deletions.
4 changes: 2 additions & 2 deletions core/src/main/scala/sttp/client4/SttpApi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
81 changes: 51 additions & 30 deletions core/src/main/scala/sttp/client4/requestBuilder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -155,87 +155,108 @@ 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)
.setContentTypeIfMissing(MediaType.ApplicationXWwwFormUrlencoded)
.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))
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand All @@ -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))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
22 changes: 12 additions & 10 deletions docs/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = ???
Expand Down Expand Up @@ -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)
```
Expand Down Expand Up @@ -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)
```
Expand Down Expand Up @@ -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)
```
Expand Down Expand Up @@ -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)
```
Expand Down Expand Up @@ -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)
```
Expand Down Expand Up @@ -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)
```
Expand Down
2 changes: 1 addition & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
18 changes: 7 additions & 11 deletions docs/requests/body.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
13 changes: 7 additions & 6 deletions docs/xml.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ 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}

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)
}
Expand All @@ -38,18 +38,19 @@ 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
```

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

0 comments on commit 3ce24c1

Please sign in to comment.