From 4fbc694ce3be407a71519e72c72ac19d705ef4c7 Mon Sep 17 00:00:00 2001 From: adamw Date: Mon, 10 Mar 2025 11:04:46 +0100 Subject: [PATCH] Add a Forwarded header parser --- .../scala/sttp/model/headers/Forwarded.scala | 65 +++++++++++++++++++ .../sttp/model/headers/ForwardedTest.scala | 47 ++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 core/src/main/scala/sttp/model/headers/Forwarded.scala create mode 100644 core/src/test/scala/sttp/model/headers/ForwardedTest.scala diff --git a/core/src/main/scala/sttp/model/headers/Forwarded.scala b/core/src/main/scala/sttp/model/headers/Forwarded.scala new file mode 100644 index 00000000..bc2c4e05 --- /dev/null +++ b/core/src/main/scala/sttp/model/headers/Forwarded.scala @@ -0,0 +1,65 @@ +package sttp.model.headers + +import sttp.model.internal.Validate + +// Forwarded: by=;for=;host=;proto= +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(", ") +} diff --git a/core/src/test/scala/sttp/model/headers/ForwardedTest.scala b/core/src/test/scala/sttp/model/headers/ForwardedTest.scala new file mode 100644 index 00000000..c556fb46 --- /dev/null +++ b/core/src/test/scala/sttp/model/headers/ForwardedTest.scala @@ -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") + } +}