Skip to content

Commit

Permalink
Add a Forwarded header parser
Browse files Browse the repository at this point in the history
  • Loading branch information
adamw committed Mar 10, 2025
1 parent 202b8d1 commit 4fbc694
Show file tree
Hide file tree
Showing 2 changed files with 112 additions and 0 deletions.
65 changes: 65 additions & 0 deletions core/src/main/scala/sttp/model/headers/Forwarded.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package sttp.model.headers

import sttp.model.internal.Validate

// Forwarded: by=<identifier>;for=<identifier>;host=<host>;proto=<http|https>
case class Forwarded(by: Option[String], `for`: Option[String], host: Option[String], proto: Option[String]) {

/** Serialize a single [[Forwarded]] header to a string
*
* @see
* [[Forwarded#toString]] for a multi-header variant
*/
override def toString: String = {
val sb = new java.lang.StringBuilder()
by.foreach(v => sb.append("by=").append(v).append(";"))
`for`.foreach(v => sb.append("for=").append(v).append(";"))
host.foreach(v => sb.append("host=").append(v).append(";"))
proto.foreach(v => sb.append("proto=").append(v).append(";"))
sb.toString
}
}

object Forwarded {

/** Parses a list of Forwarded headers. Each header can contain multiple Forwarded values.
*
* @see
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
*/
def parse(headerValues: List[String]): Either[String, List[Forwarded]] = {
Validate.sequence(headerValues.map(parse)).map(_.flatten)
}

/** Parses a single Forwarded header, which can contain multiple Forwarded values.
*
* @see
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
*/
def parse(headerValue: String): Either[String, List[Forwarded]] = {
def parseSingle(headerValue: String): Either[String, Forwarded] = {
val parts = headerValue.split(";").map(_.trim).toList
val kvPairs = parts.map { part =>
part.split("=").map(_.trim).toList match {
case key :: value :: Nil => Right(key -> value)
case _ => Left(s"Invalid part: $part")
}
}

val pairs = kvPairs.collect { case Right(pair) => pair }
if (pairs.size == kvPairs.size) {
val by = pairs.collectFirst { case ("by", v) => v }
val `for` = pairs.collectFirst { case ("for", v) => v }
val host = pairs.collectFirst { case ("host", v) => v }
val proto = pairs.collectFirst { case ("proto", v) => v }
Right(Forwarded(by, `for`, host, proto))
} else
Left(kvPairs.collect { case Left(error) => error }.mkString(", "))
}

Validate.sequence(headerValue.split(",").map(_.trim).toList.map(parseSingle))
}

/** Serialize a list of [[Forwarded]] headers to a single string. Each header will be separated by a comma. */
def toString(headers: List[Forwarded]): String = headers.map(_.toString).mkString(", ")
}
47 changes: 47 additions & 0 deletions core/src/test/scala/sttp/model/headers/ForwardedTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package sttp.model.headers

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class ForwardedTest extends AnyFlatSpec with Matchers {

it should "parse a single header correctly" in {
val actual = Forwarded.parse("by=1.2.3.4;for=4.3.2.1;host=example.com;proto=http")
actual shouldBe Right(List(Forwarded(Some("1.2.3.4"), Some("4.3.2.1"), Some("example.com"), Some("http"))))
}

it should "parse multiple headers correctly, when given as separate headers" in {
val actual = Forwarded.parse(List("by=1.2.3.4;for=4.3.2.1", "host=example.com;proto=https"))
actual shouldBe Right(
List(
Forwarded(Some("1.2.3.4"), Some("4.3.2.1"), None, None),
Forwarded(None, None, Some("example.com"), Some("https"))
)
)
}

it should "parse multiple headers correctly, when given as a single header" in {
val actual = Forwarded.parse(List("by=1.2.3.4;for=4.3.2.1, host=example.com;proto=https"))
actual shouldBe Right(
List(
Forwarded(Some("1.2.3.4"), Some("4.3.2.1"), None, None),
Forwarded(None, None, Some("example.com"), Some("https"))
)
)
}

it should "handle missing fields" in {
val actual = Forwarded.parse("by=1.2.3.4;host=example.com")
actual shouldBe Right(List(Forwarded(Some("1.2.3.4"), None, Some("example.com"), None)))
}

it should "return an error for invalid headers" in {
val actual = Forwarded.parse("by=1.2.3.4;invalid")
actual shouldBe Left("Invalid part: invalid")
}

it should "return an error for invalid key-value pairs" in {
val actual = Forwarded.parse("by=1.2.3.4;for")
actual shouldBe Left("Invalid part: for")
}
}

0 comments on commit 4fbc694

Please sign in to comment.