You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pekko.apache.org by fa...@apache.org on 2025/06/17 14:53:26 UTC

(pekko-http) branch main updated: Add trailer header (#684)

This is an automated email from the ASF dual-hosted git repository.

fanningpj pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-http.git


The following commit(s) were added to refs/heads/main by this push:
     new f910254ac Add trailer header (#684)
f910254ac is described below

commit f910254ac52f3cee653e9b33d35bbd298b53fc0f
Author: navid <na...@moia.io>
AuthorDate: Tue Jun 17 14:53:21 2025 +0000

    Add trailer header (#684)
    
    * add trailer header
    
    * add docs + format
    
    * add Trailer to list of @ApiMayChange
    
    * run javafmtAll
    
    * Seq -> immutable.Seq
    
    * fix incomplete javadoc
    
    * disallow creation of trailer header with forbidden values + improve parsing error messages
    
    * use an ExceptionWithErrorInfo
    
    * add ApiMayChange to object as well
---
 docs/src/main/paradox/compatibility-guidelines.md  |  1 +
 .../pekko/http/javadsl/model/headers/Trailer.java  | 44 +++++++++++++
 .../http/impl/model/parser/HeaderParser.scala      |  3 +-
 .../http/impl/model/parser/SimpleHeaders.scala     |  3 +
 .../http/scaladsl/model/headers/headers.scala      | 72 +++++++++++++++++++++-
 .../http/impl/model/parser/HttpHeaderSpec.scala    | 23 ++++++-
 .../http/scaladsl/model/headers/HeaderSpec.scala   | 12 ++--
 .../engine/http2/HttpMessageRenderingSpec.scala    |  2 +-
 8 files changed, 152 insertions(+), 8 deletions(-)

diff --git a/docs/src/main/paradox/compatibility-guidelines.md b/docs/src/main/paradox/compatibility-guidelines.md
index c73965384..107cfbebc 100644
--- a/docs/src/main/paradox/compatibility-guidelines.md
+++ b/docs/src/main/paradox/compatibility-guidelines.md
@@ -86,6 +86,7 @@ Scala
     org.apache.pekko.http.scaladsl.model.headers.CacheDirectives.immutableDirective
     org.apache.pekko.http.scaladsl.model.headers.X-Forwarded-Host
     org.apache.pekko.http.scaladsl.model.headers.X-Forwarded-Proto
+    org.apache.pekko.http.scaladsl.model.headers.Trailer
     org.apache.pekko.http.scaladsl.model.http2.PeerClosedStreamException
     org.apache.pekko.http.scaladsl.model.http2.Http2Exception
     org.apache.pekko.http.scaladsl.model.SimpleRequestResponseAttribute
diff --git a/http-core/src/main/java/org/apache/pekko/http/javadsl/model/headers/Trailer.java b/http-core/src/main/java/org/apache/pekko/http/javadsl/model/headers/Trailer.java
new file mode 100644
index 000000000..27670498b
--- /dev/null
+++ b/http-core/src/main/java/org/apache/pekko/http/javadsl/model/headers/Trailer.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.http.javadsl.model.headers;
+
+import org.apache.pekko.http.impl.util.Util;
+
+/**
+ * The `Trailer` header is used before a message body to indicate which fields will be present in
+ * the trailers when using chunked transfer encoding. See <a
+ * href="https://datatracker.ietf.org/doc/html/rfc7230#section-4.4">RFC 7230, Section 4.4</a>
+ *
+ * @since 1.3.0
+ */
+public abstract class Trailer extends org.apache.pekko.http.scaladsl.model.HttpHeader {
+  /**
+   * @return an iterable collection of field names that are expected to be present in the trailer
+   */
+  public abstract Iterable<String> getTrailers();
+
+  /**
+   * Creates a new `Trailer` header with the specified trailer field names.
+   *
+   * @param values the names of the fields that are expected to be present in the trailer
+   * @return a new `Trailer` header instance
+   */
+  public static Trailer create(String... values) {
+    return org.apache.pekko.http.scaladsl.model.headers.Trailer.apply(Util.convertArray(values));
+  }
+}
diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/model/parser/HeaderParser.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/model/parser/HeaderParser.scala
index 68ae18e63..b770723d6 100644
--- a/http-core/src/main/scala/org/apache/pekko/http/impl/model/parser/HeaderParser.scala
+++ b/http-core/src/main/scala/org/apache/pekko/http/impl/model/parser/HeaderParser.scala
@@ -201,7 +201,8 @@ private[http] object HeaderParser {
     "x-forwarded-for",
     "x-forwarded-host",
     "x-forwarded-proto",
-    "x-real-ip")
+    "x-real-ip",
+    "trailer")
 
   abstract class Settings {
     def uriParsingMode: Uri.ParsingMode
diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/model/parser/SimpleHeaders.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/model/parser/SimpleHeaders.scala
index 306db5e35..0522a774b 100644
--- a/http-core/src/main/scala/org/apache/pekko/http/impl/model/parser/SimpleHeaders.scala
+++ b/http-core/src/main/scala/org/apache/pekko/http/impl/model/parser/SimpleHeaders.scala
@@ -284,4 +284,7 @@ private[parser] trait SimpleHeaders {
     (`ip-v4-address` | `ip-v6-address`) ~ EOI ~> (b => `X-Real-Ip`(RemoteAddress(b)))
   }
 
+  def trailer = rule {
+    zeroOrMore(token).separatedBy(listSep) ~ EOI ~> (Trailer(_))
+  }
 }
diff --git a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/headers/headers.scala b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/headers/headers.scala
index 05d9cb565..e6aa0e7d1 100644
--- a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/headers/headers.scala
+++ b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/headers/headers.scala
@@ -17,7 +17,6 @@ import java.lang.Iterable
 import java.net.InetSocketAddress
 import java.security.MessageDigest
 import java.util
-
 import javax.net.ssl.SSLSession
 import org.apache.pekko
 import pekko.annotation.{ ApiMayChange, InternalApi }
@@ -1226,3 +1225,74 @@ final case class `X-Real-Ip`(address: RemoteAddress) extends jm.headers.XRealIp
   def renderValue[R <: Rendering](r: R): r.type = r ~~ address
   protected def companion = `X-Real-Ip`
 }
+
+@ApiMayChange
+object Trailer extends ModeledCompanion[Trailer] {
+  private implicit val trailersRenderer: Renderer[immutable.Iterable[String]] =
+    Renderer.defaultSeqRenderer[String]
+
+  def apply(values: immutable.Seq[String]): Trailer = {
+    val clean = values.map(_.trim).filter(_.nonEmpty)
+    val (forbidden, allowed) = clean.partition(name => isForbidden(name.toRootLowerCase))
+    if (clean.isEmpty)
+      throw IllegalHeaderException(
+        "Trailer values must not be empty",
+        "No valid header names specified")
+    else if (forbidden.nonEmpty) {
+      val forbiddenInput = forbidden.mkString("[", ", ", "]")
+      throw IllegalHeaderException(
+        "Trailer values must not contain forbidden header names",
+        s"Trailer contained $forbiddenInput")
+    } else new Trailer(allowed)
+  }
+
+  /**
+   * Non-exhaustive set of header names that are disallowed in the `Trailer` header.
+   * See <a href="https://datatracker.ietf.org/doc/html/rfc7230#section-4.1.2">RFC 7230, Section 4.1.2</a>
+   */
+  private val isForbidden: Set[String] =
+    Set(
+      Trailer,
+      // Framing
+      `Transfer-Encoding`,
+      `Content-Length`,
+      // Routing
+      Host,
+      // Modifiers
+      Expect,
+      Range,
+      `If-Match`,
+      `If-None-Match`,
+      `If-Modified-Since`,
+      `If-Unmodified-Since`,
+      `Cache-Control`,
+      TE,
+      // Auth and cookies
+      Authorization,
+      `Proxy-Authorization`,
+      `Cookie`,
+      `Set-Cookie`,
+      // Response control
+      `WWW-Authenticate`,
+      Age,
+      // Payload processing
+      `Content-Encoding`,
+      `Content-Type`,
+      `Content-Range`).map(_.lowercaseName)
+}
+
+/**
+ * The `Trailer` header is used before a message body to indicate which fields will be present
+ * in the trailers when using chunked transfer encoding.
+ * See <a href="https://datatracker.ietf.org/doc/html/rfc7230#section-4.4">RFC 7230, Section 4.4</a>
+ *
+ * @since 1.3.0
+ */
+@ApiMayChange
+final case class Trailer private (values: immutable.Seq[String]) extends jm.headers.Trailer with RequestResponseHeader {
+  require(values.nonEmpty, "Trailer values must not be empty")
+  import Trailer.trailersRenderer
+  def getTrailers: Iterable[String] = values.asJava
+  protected[http] def renderValue[R <: Rendering](r: R): r.type = r ~~ values
+  protected def companion: ModeledCompanion[Trailer] = Trailer
+}
diff --git a/http-core/src/test/scala/org/apache/pekko/http/impl/model/parser/HttpHeaderSpec.scala b/http-core/src/test/scala/org/apache/pekko/http/impl/model/parser/HttpHeaderSpec.scala
index 9314d51ab..b8ef0594e 100644
--- a/http-core/src/test/scala/org/apache/pekko/http/impl/model/parser/HttpHeaderSpec.scala
+++ b/http-core/src/test/scala/org/apache/pekko/http/impl/model/parser/HttpHeaderSpec.scala
@@ -19,7 +19,7 @@ import pekko.http.impl.model.parser.HeaderParser.Settings
 import org.scalatest.matchers.{ MatchResult, Matcher }
 import pekko.http.impl.util._
 import pekko.http.scaladsl.model.{ HttpHeader, _ }
-import headers._
+import headers.{ Trailer => `Trailer`, _ }
 import CacheDirectives._
 import MediaTypes._
 import MediaRanges._
@@ -28,6 +28,7 @@ import HttpEncodings._
 import HttpMethods._
 
 import java.net.InetAddress
+import scala.collection.immutable.Seq
 import pekko.http.scaladsl.model.MediaType.WithOpenCharset
 import org.scalatest.exceptions.TestFailedException
 import org.scalatest.freespec.AnyFreeSpec
@@ -820,6 +821,26 @@ class HttpHeaderSpec extends AnyFreeSpec with Matchers {
           "pekko.apache.org\n^")
     }
 
+    "Trailer" in {
+      "Trailer: one" =!= `Trailer`(Seq("one"))
+      "Trailer: one, two" =!= `Trailer`(Seq("one", "two"))
+      "Trailer: ignore,spacing,          between,    values" =!=> "ignore, spacing, between, values"
+      "Trailer: Content-MD5 , ETag,  X-Custom-Field" =!= `Trailer`(Seq("Content-MD5", "ETag", "X-Custom-Field"))
+        .renderedTo("Content-MD5, ETag, X-Custom-Field")
+      "Trailer: content-md5, ETAG, X-Custom-Field" =!= `Trailer`(Seq("content-md5", "ETAG", "X-Custom-Field"))
+      "Trailer: " =!= ErrorInfo(
+        summary = "Illegal HTTP header 'Trailer': Trailer values must not be empty",
+        detail = "No valid header names specified")
+      "Trailer: X-Custom-Hash, X-Custom-Signature" =!= `Trailer`(Seq("X-Custom-Hash", "X-Custom-Signature"))
+      "Trailer: Content@Md5" =!= ErrorInfo(
+        summary =
+          "Illegal HTTP header 'Trailer': Invalid input '@', expected tchar, OWS, listSep or 'EOI' (line 1, column 8)",
+        detail = "Content@Md5\n       ^")
+      "Trailer: Content-Length" =!= ErrorInfo(
+        summary = "Illegal HTTP header 'Trailer': Trailer values must not contain forbidden header names",
+        detail = "Trailer contained [Content-Length]")
+    }
+
     "RawHeader" in {
       "X-Space-Ranger: no, this rock!" =!= RawHeader("X-Space-Ranger", "no, this rock!")
     }
diff --git a/http-core/src/test/scala/org/apache/pekko/http/scaladsl/model/headers/HeaderSpec.scala b/http-core/src/test/scala/org/apache/pekko/http/scaladsl/model/headers/HeaderSpec.scala
index a2317218d..663b80fb1 100644
--- a/http-core/src/test/scala/org/apache/pekko/http/scaladsl/model/headers/HeaderSpec.scala
+++ b/http-core/src/test/scala/org/apache/pekko/http/scaladsl/model/headers/HeaderSpec.scala
@@ -15,12 +15,14 @@ package org.apache.pekko.http.scaladsl.model.headers
 
 import org.apache.pekko
 import pekko.http.impl.util._
-import java.net.InetAddress
 
-import pekko.http.scaladsl.model.{ headers, _ }
+import java.net.InetAddress
+import pekko.http.scaladsl.model.{ headers, Trailer => _, _ }
 import org.scalatest.freespec.AnyFreeSpec
 import org.scalatest.matchers.should.Matchers
 
+import scala.collection.immutable
+
 class HeaderSpec extends AnyFreeSpec with Matchers {
   "ModeledCompanion should" - {
     "provide parseFromValueString method" - {
@@ -184,7 +186,8 @@ class HeaderSpec extends AnyFreeSpec with Matchers {
         `X-Forwarded-For`(RemoteAddress(InetAddress.getByName("192.168.0.1"))),
         `X-Forwarded-Host`(Uri.Host(InetAddress.getByName("192.168.0.2"))),
         `X-Forwarded-Proto`("https"),
-        `X-Real-Ip`(RemoteAddress(InetAddress.getByName("192.168.1.1"))))
+        `X-Real-Ip`(RemoteAddress(InetAddress.getByName("192.168.1.1"))),
+        Trailer(immutable.Seq("X-My-Trailer", "X-My-Other-Trailer")))
 
       requestHeaders.foreach { header =>
         header shouldBe Symbol("renderInRequests")
@@ -227,7 +230,8 @@ class HeaderSpec extends AnyFreeSpec with Matchers {
         `Transfer-Encoding`(TransferEncodings.chunked),
         Upgrade(Vector(UpgradeProtocol("HTTP", Some("2.0")))),
         `WWW-Authenticate`(HttpChallenge("Basic", Some("example.com"))),
-        `Retry-After`(120))
+        `Retry-After`(120),
+        Trailer(immutable.Seq("X-My-Trailer", "X-My-Other-Trailer")))
 
       responseHeaders.foreach { header =>
         header shouldBe Symbol("renderInResponses")
diff --git a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRenderingSpec.scala b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRenderingSpec.scala
index 09fc4aca7..7c7a53f6d 100644
--- a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRenderingSpec.scala
+++ b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRenderingSpec.scala
@@ -19,7 +19,7 @@ import com.typesafe.config.ConfigFactory
 import org.apache.pekko
 import pekko.event.NoLogging
 import pekko.http.impl.engine.rendering.DateHeaderRendering
-import pekko.http.scaladsl.model.headers._
+import pekko.http.scaladsl.model.headers.{ Trailer => _, _ }
 import pekko.http.scaladsl.model._
 import pekko.http.scaladsl.settings.{ ClientConnectionSettings, ServerSettings }
 import org.scalatest.matchers.should.Matchers


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pekko.apache.org
For additional commands, e-mail: commits-help@pekko.apache.org