Repository: http4s/http4s
Pull request #6895 (2023-01-03 01:22:12)

Merge 0.23 -> main


Patch:
diff --git a/build.sbt b/build.sbt
index 0588eac247..870c8a7281 100644
--- a/build.sbt
+++ b/build.sbt
@@ -354,6 +354,7 @@ lazy val bench = http4sProject("bench")
 lazy val jsArtifactSizeTest = http4sProject("js-artifact-size-test")
   .enablePlugins(ScalaJSPlugin, NoPublishPlugin)
   .settings(
+    startYear := Some(2022),
     // CI automatically links SJS test artifacts in a separate step, to avoid OOMs while running tests
     // By placing the app in Test scope it gets linked as part of that CI step
     Test / scalaJSUseMainModuleInitializer := true,
diff --git a/core/shared/src/main/scala/org/http4s/UrlForm.scala b/core/shared/src/main/scala/org/http4s/UrlForm.scala
index 6bdf367891..9cf8cfd58d 100644
--- a/core/shared/src/main/scala/org/http4s/UrlForm.scala
+++ b/core/shared/src/main/scala/org/http4s/UrlForm.scala
@@ -94,11 +94,25 @@ object UrlForm {
     if (values.get("").fold(false)(_.isEmpty)) new UrlForm(values - "")
     else new UrlForm(values)
 
+  def single(key: String, value: String): UrlForm =
+    new UrlForm(Map(key -> Chain.one(value)))
+
   def apply(values: (String, String)*): UrlForm =
-    values.foldLeft(empty)(_ + _)
+    values match {
+      case Seq() => empty
+      case Seq(x) => single(x._1, x._2)
+      case h +: tail => tail.foldLeft(single(h._1, h._2))(_ + _)
+    }
 
   def fromChain(values: Chain[(String, String)]): UrlForm =
-    values.foldLeft(empty)(_ + _)
+    values.knownSize match {
+      case 0 => empty
+      case 1 =>
+        val h = values.headOption.get
+        single(h._1, h._2)
+      case _ =>
+        values.foldLeft(empty)(_ + _)
+    }
 
   implicit def entityEncoder(implicit charset: Charset = `UTF-8`): EntityEncoder.Pure[UrlForm] =
     EntityEncoder.stringEncoder
diff --git a/ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Client.scala b/ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Client.scala
index c00154886c..617d14699b 100644
--- a/ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Client.scala
+++ b/ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Client.scala
@@ -276,7 +276,7 @@ private[ember] class H2Client[F[_]](
       case Some(_) => true
       case None => true
     }
-    val priorKnowledge = req.attributes.lookup(H2Keys.Http2PriorKnowledge).isDefined
+    val priorKnowledge = req.attributes.contains(H2Keys.Http2PriorKnowledge)
     for {
       connection <- Resource.eval(
         getOrCreate(key, useTLS, priorKnowledge, enableEndpointValidation)
@@ -335,7 +335,7 @@ private[ember] object H2Client {
       h2 = new H2Client(Network[F], settings, tlsContext, mapH2, onPushPromise, logger)
     } yield (http1Client: TinyClient[F]) => { (req: Request[F]) =>
       val key = H2Client.RequestKey.fromRequest(req)
-      val priorKnowledge = req.attributes.lookup(H2Keys.Http2PriorKnowledge).isDefined
+      val priorKnowledge = req.attributes.contains(H2Keys.Http2PriorKnowledge)
       val socketTypeF = if (priorKnowledge) Some(Http2).pure[F] else socketMap.get.map(_.get(key))
       Resource.eval(socketTypeF).flatMap {
         case Some(Http2) => h2.runHttp2Only(req, enableEndpointValidation)
diff --git a/ember-server/shared/src/main/scala/org/http4s/ember/server/internal/ServerHelpers.scala b/ember-server/shared/src/main/scala/org/http4s/ember/server/internal/ServerHelpers.scala
index fea9d96ec7..8f5b9bb3fe 100644
--- a/ember-server/shared/src/main/scala/org/http4s/ember/server/internal/ServerHelpers.scala
+++ b/ember-server/shared/src/main/scala/org/http4s/ember/server/internal/ServerHelpers.scala
@@ -417,106 +417,103 @@ private[server] object ServerHelpers extends ServerHelpersPlatform {
         case _: TimeoutException => EmberException.ReadTimeout(idleTimeout)
       }
     Stream
-      .unfoldEval[F, State, (Request[F], Response[F])](initialBuffer.toArray -> false) {
-        case (buffer, reuse) =>
-          val initRead: F[Array[Byte]] = if (buffer.nonEmpty) {
-            // next request has already been (partially) received
-            buffer.pure[F]
-          } else if (reuse) {
-            // the connection is keep-alive, but we don't have any bytes.
-            // we want to be on the idle timeout until the next request is received.
-            read
-              .flatMap {
-                case Some(chunk) => chunk.toArray.pure[F]
-                case None => Concurrent[F].raiseError(EmberException.EmptyStream())
-              }
-          } else {
-            // first request begins immediately
-            Array.emptyByteArray.pure[F]
-          }
+      .unfoldEval[F, State, Response[F]](initialBuffer.toArray -> false) { case (buffer, reuse) =>
+        val initRead: F[Array[Byte]] = if (buffer.nonEmpty) {
+          // next request has already been (partially) received
+          buffer.pure[F]
+        } else if (reuse) {
+          // the connection is keep-alive, but we don't have any bytes.
+          // we want to be on the idle timeout until the next request is received.
+          read
+            .flatMap {
+              case Some(chunk) => chunk.toArray.pure[F]
+              case None => Concurrent[F].raiseError(EmberException.EmptyStream())
+            }
+        } else {
+          // first request begins immediately
+          Array.emptyByteArray.pure[F]
+        }
 
-          val result = initRead.flatMap { initBuffer =>
-            runApp(
-              initBuffer,
-              read,
-              maxHeaderSize,
-              requestHeaderReceiveTimeout,
-              finalApp,
-              errorHandler,
-              socket,
-              createRequestVault,
-            )
-          }
+        val result = initRead.flatMap { initBuffer =>
+          runApp(
+            initBuffer,
+            read,
+            maxHeaderSize,
+            requestHeaderReceiveTimeout,
+            finalApp,
+            errorHandler,
+            socket,
+            createRequestVault,
+          )
+        }
 
-          result.attempt.flatMap {
-            case Right((req, resp, drain)) =>
-              // TODO: Should we pay this cost for every HTTP request?
-              // Intercept the response for various upgrade paths
-              resp.attributes.lookup(webSocketKey) match {
-                case Some(ctx) =>
-                  drain.flatMap {
-                    case Some(buffer) =>
-                      WebSocketHelpers
-                        .upgrade(
+        result.attempt.flatMap {
+          case Right((req, resp, drain)) =>
+            // TODO: Should we pay this cost for every HTTP request?
+            // Intercept the response for various upgrade paths
+            resp.attributes.lookup(webSocketKey) match {
+              case Some(ctx) =>
+                drain.flatMap {
+                  case Some(buffer) =>
+                    WebSocketHelpers
+                      .upgrade(
+                        socket,
+                        req,
+                        ctx,
+                        buffer,
+                        receiveBufferSize,
+                        idleTimeout,
+                        onWriteFailure,
+                        errorHandler,
+                        logger,
+                      )
+                      .as(None)
+                  case None =>
+                    Applicative[F].pure(None)
+                }
+              case None =>
+                resp.attributes.lookup(H2Keys.H2cUpgrade) match {
+                  // Http1.1
+                  case None =>
+                    for {
+                      nextResp <- postProcessResponse(req, resp)
+                      _ <- send(socket)(Some(req), nextResp, idleTimeout, onWriteFailure)
+                      nextBuffer <- drain
+                    } yield nextBuffer.map(buffer => (nextResp, (buffer, true)))
+                  // h2c escalation of the connection
+                  case Some((settings, newReq)) =>
+                    for {
+                      nextResp <- postProcessResponse(req, resp)
+                      _ <- send(socket)(Some(req), nextResp, idleTimeout, onWriteFailure)
+                      _ <- H2Server.requireConnectionPreface(socket)
+                      out <- H2Server
+                        .fromSocket(
                           socket,
-                          req,
-                          ctx,
-                          buffer,
-                          receiveBufferSize,
-                          idleTimeout,
-                          onWriteFailure,
-                          errorHandler,
+                          httpApp,
+                          H2Frame.Settings.ConnectionSettings.default,
                           logger,
+                          settings,
+                          newReq.some,
                         )
+                        .use(_ => Async[F].never[Unit])
                         .as(None)
-                    case None =>
-                      Applicative[F].pure(None)
-                  }
-                case None =>
-                  resp.attributes.lookup(H2Keys.H2cUpgrade) match {
-                    // Http1.1
-                    case None =>
-                      for {
-                        nextResp <- postProcessResponse(req, resp)
-                        _ <- send(socket)(Some(req), nextResp, idleTimeout, onWriteFailure)
-                        nextBuffer <- drain
-                      } yield nextBuffer.map(buffer => ((req, nextResp), (buffer, true)))
-                    // h2c escalation of the connection
-                    case Some((settings, newReq)) =>
-                      for {
-                        nextResp <- postProcessResponse(req, resp)
-                        _ <- send(socket)(Some(req), nextResp, idleTimeout, onWriteFailure)
-                        _ <- H2Server.requireConnectionPreface(socket)
-                        out <- H2Server
-                          .fromSocket(
-                            socket,
-                            httpApp,
-                            H2Frame.Settings.ConnectionSettings.default,
-                            logger,
-                            settings,
-                            newReq.some,
-                          )
-                          .use(_ => Async[F].never[Unit])
-                          .as(None)
-                      } yield out
-                  }
-              }
-            case Left(err) =>
-              err match {
-                case EmberException.EmptyStream() | EmberException.RequestHeadersTimeout(_) |
-                    EmberException.ReadTimeout(_) =>
-                  Applicative[F].pure(None)
-                case err =>
-                  errorHandler(err)
-                    .handleError(_ => serverFailure)
-                    .flatMap(send(socket)(None, _, idleTimeout, onWriteFailure))
-                    .as(None)
-              }
-          }
-      }
-      .takeWhile { case (_, resp) =>
-        resp.headers.get[Connection].exists(_.hasKeepAlive)
+                    } yield out
+                }
+            }
+          case Left(err) =>
+            err match {
+              case EmberException.EmptyStream() | EmberException.RequestHeadersTimeout(_) |
+                  EmberException.ReadTimeout(_) =>
+                Applicative[F].pure(None)
+              case err =>
+                errorHandler(err)
+                  .handleError(_ => serverFailure)
+                  .flatMap(send(socket)(None, _, idleTimeout, onWriteFailure))
+                  .as(None)
+            }
+        }
       }
+      .takeWhile(_.headers.get[Connection].exists(_.hasKeepAlive))
       .drain
   }
 
diff --git a/project/Http4sPlugin.scala b/project/Http4sPlugin.scala
index 3bf95ff084..3303c522de 100644
--- a/project/Http4sPlugin.scala
+++ b/project/Http4sPlugin.scala
@@ -100,7 +100,7 @@ object Http4sPlugin extends AutoPlugin {
     val blaze = "0.15.3"
     val caseInsensitive = "1.3.0"
     val cats = "2.9.0"
-    val catsEffect = "3.4.3"
+    val catsEffect = "3.4.4"
     val catsParse = "0.3.8"
     val circe = "0.14.3"
     val crypto = "0.2.4"
@@ -133,7 +133,7 @@ object Http4sPlugin extends AutoPlugin {
     val slf4j = "1.7.36"
     val treehugger = "0.4.4"
     val twitterHpack = "1.0.2"
-    val vault = "3.4.0"
+    val vault = "3.5.0"
   }
 
   lazy val blazeCore = "org.http4s" %% "blaze-core" % V.blaze


Test patch:
diff --git a/client-testkit/jvm/src/main/scala/org/http4s/client/testkit/scaffold/NettyTestServer.scala b/client-testkit/jvm/src/main/scala/org/http4s/client/testkit/scaffold/NettyTestServer.scala
index b2fec82b12..694d4db7dc 100644
--- a/client-testkit/jvm/src/main/scala/org/http4s/client/testkit/scaffold/NettyTestServer.scala
+++ b/client-testkit/jvm/src/main/scala/org/http4s/client/testkit/scaffold/NettyTestServer.scala
@@ -30,7 +30,6 @@ import io.netty.channel.nio.NioEventLoopGroup
 import io.netty.channel.socket.nio.NioServerSocketChannel
 import io.netty.channel.socket.nio.NioSocketChannel
 import io.netty.handler.codec.http._
-import io.netty.handler.logging.LogLevel
 import io.netty.handler.logging.LoggingHandler
 import io.netty.handler.ssl.SslHandler
 import org.http4s.Uri
@@ -74,7 +73,7 @@ private[http4s] object NettyTestServer {
       .channelFactory(new ChannelFactory[NioServerSocketChannel] {
         override def newChannel(): NioServerSocketChannel = new NioServerSocketChannel()
       })
-      .handler(new LoggingHandler(LogLevel.INFO))
+      .handler(new LoggingHandler())
       .childHandler(new ChannelInitializer[NioSocketChannel]() {
         def initChannel(ch: NioSocketChannel): Unit = {
           logger.trace(s"Accepted new connection from [${ch.remoteAddress()}].").unsafeRunSync()
diff --git a/tests/shared/src/test/scala/org/http4s/UrlFormSpec.scala b/tests/shared/src/test/scala/org/http4s/UrlFormSpec.scala
index 5f08498d53..7bcd0ff30b 100644
--- a/tests/shared/src/test/scala/org/http4s/UrlFormSpec.scala
+++ b/tests/shared/src/test/scala/org/http4s/UrlFormSpec.scala
@@ -141,6 +141,12 @@ class UrlFormSpec extends Http4sSuite {
       )
     }
 
+    test("UrlForm.single") {
+      assertEquals(UrlForm.single("foo", "bar").get("foo"), Chain.one("bar"))
+      assertEquals(UrlForm.single("foo", "bar").get("baz"), Chain.empty[String])
+      assertEquals(UrlForm.single("", "bar"), UrlForm(Map("" -> Chain("bar"))))
+    }
+
     test("UrlForm should construct consistently from kv-pairs or and Map[String, Chain[String]]") {
       Prop.forAll { (map: Map[String, NonEmptyList[String]]) =>
         // non-empty because the kv-constructor can't represent valueless fields