Repository: http4s/http4s
Pull request #6741 (2022-10-10 13:40:20)

Add Uri.Host.fromString method
<!--- Thank you for contributing to http4s! Before opening a pull request, please
run `sbt quicklint` on your branch and commit the results. If this fails and you
need help, feel free to open a draft pull request. If the formatting or scalafix
check still fails in CI, run `sbt lint`. --->

Adds a `fromString` method to `org.http4s.Uri.Host`. While it is possible to get a `org.http4s.Uri.Host` from a `String` using `org.http4s.headers.Forwarded.Host.fromString` to get a `org.http4s.headers.Forwarded.Host` which itself has a `host` field of type `org.http4s.Uri.Host`, it felt less discoverable. This also bring `Host` in line with other objects in `org.http4s.Uri` which themselves have a convenient `fromString` method.


Patch:
diff --git a/core/shared/src/main/scala/org/http4s/Uri.scala b/core/shared/src/main/scala/org/http4s/Uri.scala
index 5fbce2a39d..b22a781f9e 100644
--- a/core/shared/src/main/scala/org/http4s/Uri.scala
+++ b/core/shared/src/main/scala/org/http4s/Uri.scala
@@ -682,6 +682,12 @@ object Uri extends UriPlatform {
 
   object Host {
 
+    def fromString(s: String): ParseResult[Host] =
+      ParseResult.fromParser(Parser.host, "Invalid host")(s)
+
+    def unsafeFromString(s: String): Host =
+      fromString(s).fold(throw _, identity)
+
     /** Create a [[Host]] value from an [[com.comcast.ip4s.IpAddress]].
       *
       * This is a convenience method for creating the correct host based on


Test patch:
diff --git a/tests/shared/src/test/scala/org/http4s/UriHostSuite.scala b/tests/shared/src/test/scala/org/http4s/UriHostSuite.scala
index 4de1c5af50..d0ce4278d6 100644
--- a/tests/shared/src/test/scala/org/http4s/UriHostSuite.scala
+++ b/tests/shared/src/test/scala/org/http4s/UriHostSuite.scala
@@ -23,4 +23,62 @@ final class UriHostSuite extends Http4sSuite {
   checkAll("Order[Uri.Host]", OrderTests[Uri.Host].order)
   checkAll("Hash[Uri.Host]", HashTests[Uri.Host].hash)
 
+  // https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.2
+
+  test("Uri.Host.fromString should parse ALPHA reg-name") {
+    val s = "localhost"
+    assertEquals(Uri.Host.fromString(s), Right(Uri.RegName(s)))
+  }
+
+  test("Uri.Host.fromString should parse DIGIT reg-name") {
+    val s = "42"
+    assertEquals(Uri.Host.fromString(s), Right(Uri.RegName(s)))
+  }
+
+  test("Uri.Host.fromString should parse other unreserved reg-name") {
+    val s = "-._~"
+    assertEquals(Uri.Host.fromString(s), Right(Uri.RegName(s)))
+  }
+
+  test("Uri.Host.fromString should parse pct-encoded reg-name") {
+    // https://datatracker.ietf.org/doc/html/rfc3986#section-2.1
+    // corresponds to the space character in US-ASCII
+    val pctEncoded = "%20"
+    assertEquals(Uri.Host.fromString(pctEncoded), Right(Uri.RegName(" ")))
+  }
+
+  test("Uri.Host.fromString should parse sub-delims reg-name") {
+    val s = "!$&'()*+,;="
+    assertEquals(Uri.Host.fromString(s), Right(Uri.RegName(s)))
+  }
+
+  test("Uri.Host.fromString should parse a IPv4address") {
+    val s = "192.0.2.16"
+    assertEquals(Uri.Host.fromString(s), Right(Uri.Ipv4Address.unsafeFromString(s)))
+  }
+
+  test("Uri.Host.fromString should parse a IP-literal") {
+    val ipv6Address = "2001:db8::7"
+    val ipLiteral = s"[$ipv6Address]"
+    assertEquals(
+      Uri.Host.fromString(ipLiteral),
+      Right(Uri.Ipv6Address.unsafeFromString(ipv6Address)),
+    )
+  }
+
+  test("Uri.Host.fromString should fail to parse gen-delims") {
+    val genDelims = ":/?#[]@"
+    assert(Uri.Host.fromString(genDelims).isLeft)
+  }
+
+  test("Uri.Host.unsafeFromString return direct result") {
+    val s = "localhost"
+    assertEquals(Uri.Host.unsafeFromString(s), Uri.RegName(s))
+  }
+
+  test("Uri.Host.unsafeFromString should throw on bad input") {
+    val genDelims = ":/?#[]@"
+    intercept[ParseFailure](Uri.Host.unsafeFromString(genDelims))
+  }
+
 }