Skip to main content

Entity Streaming Directives

Framed entity streaming reads a request body as a stream of elements and renders a stream of elements as a response body, with the framing described by an EntityStreamingSupport in implicit scope. EntityStreamingSupport.json() splits a JSON array (or newline-separated objects) into one object per element and renders responses as a JSON array; EntityStreamingSupport.csv() works line by line. Both take a maximum frame size (8 KiB by default); a larger frame fails the request with 400 Bad Request.

asSourceOf​

An EntityDecoder for entity that yields a fs2.Stream[IO, T]. Each frame is decoded as UTF-8 text with an in-scope Unmarshaller[String, T]. A Content-Type the support does not accept rejects with an UnsupportedRequestContentTypeRejection.

import io.circe.parser.decode
import pl.iterators.stir.common.EntityStreamingSupport
import pl.iterators.stir.unmarshalling.Unmarshaller

case class Measurement(id: String, value: Double)

implicit val measurementUnmarshaller: Unmarshaller[String, Measurement] =
Unmarshaller.strict(json => decode[Measurement](json).fold(throw _, identity))
implicit val jsonStreaming: EntityStreamingSupport = EntityStreamingSupport.json()

val route: Route = post {
entity(asSourceOf[Measurement]) { measurements =>
onSuccess(measurements.map(_.value).compile.foldMonoid) { total =>
complete(s"Total: $total")
}
}
}

Failures​

Framing errors (an object or line over the limit, a body that is not an array of objects, trailing content after the last object, a body cut off inside an object) fail the element stream with a 400 Bad Request IllegalRequestException as soon as the offending byte is read; the elements before it are still delivered. When the route consumes the stream before completing, as above, the exception reaches the ExceptionHandler and the client gets the 400. When the route streams the elements straight into the response, the status has already been sent and the response body fails instead. An element the Unmarshaller cannot decode fails the stream with the unmarshaller's own exception (a 500 under the default handler); throw an IllegalRequestException from the unmarshaller to answer with a 400.

asSourceOf[T](support) takes the support explicitly. stir sets no default size limit on request bodies (the framing bounds each element), so bound a streamed upload as a whole with withSizeLimit where that matters.

Completing with a stream​

With an EntityStreamingSupport in scope, complete accepts a fs2.Stream[IO, T] for any T with an EntityEncoder; each element is encoded and the frames are joined by the support, which also sets the response content type.

import fs2.Stream
import org.http4s.circe.CirceEntityEncoder._

implicit val jsonStreaming: EntityStreamingSupport = EntityStreamingSupport.json()

val route: Route = get {
complete(Stream.emits(List(Measurement("a", 1.0), Measurement("b", 2.5))).covary[IO])
}
// [{"id":"a","value":1.0},{"id":"b","value":2.5}]

Newline separated rendering​

withFramingRenderer replaces the renderer, as Pekko's withFramingRenderer does. Newline separated JSON objects instead of an array:

import cats.effect.IO
import fs2.{ Chunk, Pipe, Stream }

val newlineSeparated: Pipe[IO, Chunk[Byte], Byte] =
frames => frames.flatMap(frame => Stream.chunk(frame) ++ Stream.emit('\n'.toByte))

implicit val ndjsonStreaming: EntityStreamingSupport =
EntityStreamingSupport.json().withFramingRenderer(newlineSeparated)
// {"id":"a","value":1.0}
// {"id":"b","value":2.5}

The renderer receives one encoded element per chunk and produces the response bytes; the content type stays the support's (withContentType changes it).

Parallel (un)marshalling​

Elements are encoded and decoded one at a time by default. withParallelMarshalling(parallelism, unordered) lets asSourceOf unmarshal and the stream marshaller encode up to parallelism elements at once, through fs2's parEvalMap; with unordered = true (parEvalMapUnordered) an element is emitted as soon as it is ready, so a large element does not hold back the smaller ones that are in flight with it, at the price of the order. Pekko honours these settings when unmarshalling a request only and always renders a response one element at a time; stir applies them on both sides. A failing element fails the stream on both sides, as in Pekko: the request fails, or the response body breaks after the elements already sent.

implicit val parallelStreaming: EntityStreamingSupport =
EntityStreamingSupport.json().withParallelMarshalling(parallelism = 4, unordered = false)

parallelism must be at least 1. Parallelism only pays when the Unmarshaller or EntityEncoder does real work per element, such as a database lookup or a large serialisation; the framing itself is sequential.