Repository: http4s/http4s
Pull request #6136 (2022-03-17 20:55:11)

Support parsing response bodies without Content-Length in ember (0.21)
Backport of #5881 to series/0.21


Patch:
diff --git a/ember-core/src/main/scala/org/http4s/ember/core/Parser.scala b/ember-core/src/main/scala/org/http4s/ember/core/Parser.scala
index 097b1bce66..be39e93488 100644
--- a/ember-core/src/main/scala/org/http4s/ember/core/Parser.scala
+++ b/ember-core/src/main/scala/org/http4s/ember/core/Parser.scala
@@ -283,7 +283,13 @@ private[ember] object Parser {
     def parser[F[_]: Concurrent](maxHeaderSize: Int)(
         buffer: Array[Byte],
         read: Read[F]
-    ): F[(Response[F], Drain[F])] =
+    ): F[(Response[F], Drain[F])] = {
+      // per https://httpwg.org/specs/rfc7230.html#rfc.section.3.3.3
+      def expectNoBody(status: Status): Boolean =
+        status == Status.NoContent ||
+          status == Status.NotModified ||
+          status.responseClass == Status.Informational
+
       for {
         message <- MessageP.parseMessage(buffer, read, maxHeaderSize)
         prelude <- RespPrelude.parsePrelude(message.bytes)
@@ -305,13 +311,19 @@ private[ember] object Parser {
                     .decode(message.rest, read, maxHeaderSize, maxHeaderSize, trailers, rest)) ->
                   rest.get
             }
+          } else if (expectNoBody(prelude.status)) {
+            (baseResp -> (Some(message.rest): Option[Array[Byte]]).pure[F]).pure[F]
           } else {
-            Body.parseFixedBody(headerP.contentLength.getOrElse(0L), message.rest, read).map {
-              case (bodyStream, drain) =>
+            headerP.contentLength
+              .fold(Body.parseUnknownBody(message.rest, read))(
+                Body.parseFixedBody(_, message.rest, read)
+              )
+              .map { case (bodyStream, drain) =>
                 baseResp.withBodyStream(bodyStream) -> drain
-            }
+              }
           }
       } yield resp
+    }
 
     object RespPrelude {
 
@@ -432,6 +444,31 @@ private[ember] object Parser {
         (EmptyBody.covary[F], (Some(buffer): Option[Array[Byte]]).pure[F]).pure[F]
       }
 
+    def parseUnknownBody[F[_]: Concurrent](
+        buffer: Array[Byte],
+        read: Read[F]
+    ): F[(EntityBody[F], Drain[F])] =
+      Ref[F].of(false).map { consumed =>
+        lazy val readAll: Pull[F, Byte, Unit] =
+          Pull.eval(read).flatMap {
+            case Some(c) => Pull.output(c) >> readAll
+            case None => Pull.eval(consumed.set(true)).void
+          }
+
+        val body =
+          Pull
+            .eval(consumed.get)
+            .flatMap {
+              case true => Pull.raiseError(BodyAlreadyConsumedError())
+              case false => Pull.output(Chunk.array(buffer)) >> readAll
+            }
+            .stream
+
+        val drain: Drain[F] = (None: Option[Array[Byte]]).pure[F]
+
+        (body, drain)
+      }
+
     final case class BodyAlreadyConsumedError()
         extends Exception("Body Has Been Consumed Completely Already")
 


Test patch:
diff --git a/ember-core/src/test/scala/org/http4s/ember/core/ParserSuite.scala b/ember-core/src/test/scala/org/http4s/ember/core/ParserSuite.scala
index a81953ec96..d760b8d5e7 100644
--- a/ember-core/src/test/scala/org/http4s/ember/core/ParserSuite.scala
+++ b/ember-core/src/test/scala/org/http4s/ember/core/ParserSuite.scala
@@ -362,6 +362,27 @@ class ParsingSuite extends Http4sSuite {
     }
   }
 
+  test("Parser.Response.parser should Parse a response without Content-Length") {
+    val defaultMaxHeaderLength = 4096
+    val raw =
+      """HTTP/1.0 200 OK
+      |Content-Type: text/plain
+      |
+      |helloallthethings""".stripMargin
+
+    val byteStream = Stream
+      .emit(raw)
+      .map(Helpers.httpifyString)
+      .through(text.utf8Encode)
+
+    for {
+      take <- Helpers.taking[IO, Byte](byteStream)
+      result <- Parser.Response
+        .parser[IO](defaultMaxHeaderLength)(Array.emptyByteArray, take)
+      body <- result._1.body.through(text.utf8Decode).compile.string
+    } yield assertEquals(body, "helloallthethings")
+  }
+
   test("Header Parser should handle headers in a section") {
     val base = """Content-Type: text/plain; charset=UTF-8
       |Content-Length: 11