diff --git a/zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala b/zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala index 71f1282fb0..adfa45342d 100644 --- a/zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala +++ b/zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala @@ -172,6 +172,7 @@ object CodeGen { case "allow" => "HeaderCodec.allow" case "authorization" => "HeaderCodec.authorization" case "cache-control" => "HeaderCodec.cacheControl" + case "clear-site-data" => "HeaderCodec.clearSiteData" case "connection" => "HeaderCodec.connection" case "content-base" => "HeaderCodec.contentBase" case "content-encoding" => "HeaderCodec.contentEncoding" @@ -190,6 +191,7 @@ object CodeGen { case "etag" => "HeaderCodec.etag" case "expect" => "HeaderCodec.expect" case "expires" => "HeaderCodec.expires" + case "forwarded" => "HeaderCodec.forwarded" case "from" => "HeaderCodec.from" case "host" => "HeaderCodec.host" case "if-match" => "HeaderCodec.ifMatch" @@ -198,6 +200,7 @@ object CodeGen { case "if-range" => "HeaderCodec.ifRange" case "if-unmodified-since" => "HeaderCodec.ifUnmodifiedSince" case "last-modified" => "HeaderCodec.lastModified" + case "link" => "HeaderCodec.link" case "location" => "HeaderCodec.location" case "max-forwards" => "HeaderCodec.maxForwards" case "origin" => "HeaderCodec.origin" diff --git a/zio-http/src/main/scala/zio/http/Header.scala b/zio-http/src/main/scala/zio/http/Header.scala index cfe2a0dad7..987f82322a 100644 --- a/zio-http/src/main/scala/zio/http/Header.scala +++ b/zio-http/src/main/scala/zio/http/Header.scala @@ -1399,6 +1399,62 @@ object Header { } + final case class ClearSiteData(directives: NonEmptyChunk[ClearSiteDataDirective]) extends Header { + override type Self = ClearSiteData + override def self: Self = this + override def headerType: HeaderType.Typed[ClearSiteData] = ClearSiteData + } + + object ClearSiteData extends HeaderType { + override type HeaderValue = ClearSiteData + + override def name: String = "clear-site-data" + + def parse(value: String): Either[String, ClearSiteData] = { + val values = value.split(",").map(_.trim) + val directives = values.flatMap { directive => + directive match { + case """"cache"""" => Some(ClearSiteDataDirective.Cache) + case """"clientHints"""" => Some(ClearSiteDataDirective.ClientHints) + case """"cookies"""" => Some(ClearSiteDataDirective.Cookies) + case """"storage"""" => Some(ClearSiteDataDirective.Storage) + case """"executionContexts"""" => Some(ClearSiteDataDirective.ExecutionContexts) + case """"*"""" => Some(ClearSiteDataDirective.All) + case _ => None + } + } + + if (values.exists(x => !x.headOption.contains('"') || !x.lastOption.contains('"'))) + Left("Invalid Clear-Site-Data header") + else { + NonEmptyChunk.fromIterableOption(directives) match { + case Some(directives) => Right(ClearSiteData(directives)) + case None => Left("Invalid Clear-Site-Data header") + } + } + } + + def render(clearSiteData: ClearSiteData): String = + clearSiteData.directives.map { + case ClearSiteDataDirective.Cache => """"cache"""" + case ClearSiteDataDirective.ClientHints => """"clientHints"""" + case ClearSiteDataDirective.Cookies => """"cookies"""" + case ClearSiteDataDirective.Storage => """"storage"""" + case ClearSiteDataDirective.ExecutionContexts => """"executionContexts"""" + case ClearSiteDataDirective.All => """"*"""" + }.mkString(", ") + } + + sealed trait ClearSiteDataDirective + object ClearSiteDataDirective { + case object Cache extends ClearSiteDataDirective + case object ClientHints extends ClearSiteDataDirective + case object Cookies extends ClearSiteDataDirective + case object Storage extends ClearSiteDataDirective + case object ExecutionContexts extends ClearSiteDataDirective + case object All extends ClearSiteDataDirective + } + /** * Connection header value. */ @@ -2745,6 +2801,30 @@ object Header { DateEncoding.default.encodeDate(expires.value) } + final case class Forwarded(by: Option[String] = None, forValues: List[String] = Nil, host: Option[String] = None, proto: Option[String] = None) extends Header { + override type Self = Forwarded + override def self: Self = this + override def headerType: HeaderType.Typed[Forwarded] = Forwarded + } + + object Forwarded extends HeaderType { + override type HeaderValue = Forwarded + + override def name: String = "forwarded" + + def parse(forwarded: String): Either[String, Forwarded] = { + val parts = forwarded.split(";") + val by = parts.collectFirst { case s if s.startsWith("by=") => s.drop(3) }.map(_.trim) + val forValue = parts.collectFirst { case s if s.startsWith("for=") => s.split(',') }.map(_.map(_.trim.drop(4).trim).toList).getOrElse(Nil) + val host = parts.collectFirst { case s if s.startsWith("host=") => s.drop(5) }.map(_.trim) + val proto = parts.collectFirst { case s if s.startsWith("proto=") => s.drop(6) }.map(_.trim) + Right(Forwarded(by, forValue, host, proto)) + } + + def render(forwarded: Forwarded): String = + s"${forwarded.by}; ${forwarded.forValues.map(v => s"for=$v").mkString(",")}; ${forwarded.host}; ${forwarded.proto}" + } + /** From header value. */ final case class From(email: String) extends Header { override type Self = From @@ -2967,6 +3047,56 @@ object Header { DateEncoding.default.encodeDate(lastModified.value) } + final case class Link(uri: URL, params: Map[String, String]) extends Header { + override type Self = Link + override def self: Self = this + override def headerType: HeaderType.Typed[Link] = Link + } + + object Link extends HeaderType { + override type HeaderValue = Link + + override def name: String = "link" + + def parse(value: String): Either[String, Link] = { + val parts = value.split(";").map(_.trim).filter(_.nonEmpty) + if (parts.length < 2) Left("Invalid Link header") + else if (!parts(0).startsWith("<") || !parts(0).endsWith(">")) Left("Invalid Link header") + else { + val uri = parts(0).substring(1, parts(0).length - 1) + URL.decode(uri) match { + case Left(_) => Left("Invalid Link header") + case Right(url) => + val params = parts.drop(1).map { part => + val keyValue = part.split("=").map(_.trim).filter(_.nonEmpty) + if (keyValue.length != 2) Left("Invalid Link header") + else { + val (key, value) = (keyValue(0), keyValue(1)) + val unquoted = + if (value.startsWith("\"") && value.endsWith("\"")) value.substring(1, value.length - 1) + else value + Right(key -> unquoted) + } + + } + val paramsMap = params.foldLeft[Either[String, Map[String, String]]](Right(Map.empty)) { + case (Left(error), _) => Left(error) + case (Right(map), Right((key, value))) => + if (map.contains(key)) Left("Invalid Link header") + else Right(map + (key -> value)) + case _ => Left("Invalid Link header") + } + paramsMap.map(Link(url, _)) + } + } + } + + def render(link: Link): String = { + val params = link.params.map { case (key, value) => s"""$key="$value"""" }.mkString("; ") + s"""<${link.uri.encode}>; $params""" + } + } + /** * Location header value. */ diff --git a/zio-http/src/main/scala/zio/http/codec/HeaderCodecs.scala b/zio-http/src/main/scala/zio/http/codec/HeaderCodecs.scala index 2149e73c38..76a4acd5e1 100644 --- a/zio-http/src/main/scala/zio/http/codec/HeaderCodecs.scala +++ b/zio-http/src/main/scala/zio/http/codec/HeaderCodecs.scala @@ -84,6 +84,7 @@ private[codec] trait HeaderCodecs { final val allow: HeaderCodec[Header.Allow] = header(Header.Allow) final val authorization: HeaderCodec[Header.Authorization] = header(Header.Authorization) final val cacheControl: HeaderCodec[Header.CacheControl] = header(Header.CacheControl) + final val clearSiteData: HeaderCodec[Header.ClearSiteData] = header(Header.ClearSiteData) final val connection: HeaderCodec[Header.Connection] = header(Header.Connection) final val contentBase: HeaderCodec[Header.ContentBase] = header(Header.ContentBase) final val contentEncoding: HeaderCodec[Header.ContentEncoding] = header(Header.ContentEncoding) @@ -104,6 +105,7 @@ private[codec] trait HeaderCodecs { final val etag: HeaderCodec[Header.ETag] = header(Header.ETag) final val expect: HeaderCodec[Header.Expect] = header(Header.Expect) final val expires: HeaderCodec[Header.Expires] = header(Header.Expires) + final val forwarded: HeaderCodec[Header.Forwarded] = header(Header.Forwarded) final val from: HeaderCodec[Header.From] = header(Header.From) final val host: HeaderCodec[Header.Host] = header(Header.Host) final val ifMatch: HeaderCodec[Header.IfMatch] = header(Header.IfMatch) @@ -112,6 +114,7 @@ private[codec] trait HeaderCodecs { final val ifRange: HeaderCodec[Header.IfRange] = header(Header.IfRange) final val ifUnmodifiedSince: HeaderCodec[Header.IfUnmodifiedSince] = header(Header.IfUnmodifiedSince) final val lastModified: HeaderCodec[Header.LastModified] = header(Header.LastModified) + final val link: HeaderCodec[Header.Link] = header(Header.Link) final val location: HeaderCodec[Header.Location] = header(Header.Location) final val maxForwards: HeaderCodec[Header.MaxForwards] = header(Header.MaxForwards) final val origin: HeaderCodec[Header.Origin] = header(Header.Origin) diff --git a/zio-http/src/test/scala/zio/http/headers/ClearSiteDataSpec.scala b/zio-http/src/test/scala/zio/http/headers/ClearSiteDataSpec.scala new file mode 100644 index 0000000000..c83db4bcfd --- /dev/null +++ b/zio-http/src/test/scala/zio/http/headers/ClearSiteDataSpec.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP contributors. + * + * Licensed 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 zio.http.headers + +import zio.Scope +import zio.test._ + +import zio.http.Header.{CacheControl, ClearSiteData, ClearSiteDataDirective} +import zio.http.ZIOHttpSpec +import zio.http.internal.HttpGen + +object ClearSiteDataSpec extends ZIOHttpSpec { + override def spec: Spec[TestEnvironment with Scope, Any] = suite("ClearSiteData suite")( + test("ClearSiteData header value transformation should be symmetrical") { + check(HttpGen.clearSiteData) { value => + assertTrue(ClearSiteData.parse(ClearSiteData.render(value)) == Right(value)) + } + }, + test("Unquoted ClearSiteData header value should not parse") { + val headerValue = "cache, cookies, storage, executionContexts" + assertTrue(ClearSiteData.parse(headerValue) == Left("Invalid Clear-Site-Data header")) + }, + ) +} diff --git a/zio-http/src/test/scala/zio/http/headers/ForwardedSpec.scala b/zio-http/src/test/scala/zio/http/headers/ForwardedSpec.scala new file mode 100644 index 0000000000..c979b79095 --- /dev/null +++ b/zio-http/src/test/scala/zio/http/headers/ForwardedSpec.scala @@ -0,0 +1,62 @@ +/* + * Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP contributors. + * + * Licensed 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 zio.http.headers + +import zio.Scope +import zio.test._ + +import zio.http.{Header, ZIOHttpSpec} + +object ForwardedSpec extends ZIOHttpSpec { + override def spec: Spec[TestEnvironment with Scope, Any] = suite("Forwarded suite")( + test("parse Forwarded header") { + val headerValue = """for="[2001:db8:cafe::17]:4711"""" + val header = Header.Forwarded(forValues = List(""""[2001:db8:cafe::17]:4711"""")) + assertTrue(Header.Forwarded.parse(headerValue) == Right(header)) + }, + test("parse Forwarded header with multiple for values") { + val headerValue = """for="[2001:db8:cafe::17]:4711", for=192.0.0.25""" + val header = Header.Forwarded(forValues = List(""""[2001:db8:cafe::17]:4711"""", "192.0.0.25")) + assertTrue(Header.Forwarded.parse(headerValue) == Right(header)) + }, + test("parse Forwarded header with by") { + val headerValue = """for="[2001:db8:cafe::17]:4711";by=_value""" + val header = Header.Forwarded(forValues = List(""""[2001:db8:cafe::17]:4711""""), by = Some("_value")) + assertTrue(Header.Forwarded.parse(headerValue) == Right(header)) + }, + test("parse Forwarded header with host") { + val headerValue = """for="[2001:db8:cafe::17]:4711";host=example.com""" + val header = Header.Forwarded(forValues = List(""""[2001:db8:cafe::17]:4711""""), host = Some("example.com")) + assertTrue(Header.Forwarded.parse(headerValue) == Right(header)) + }, + test("parse Forwarded header with proto") { + val headerValue = """for="[2001:db8:cafe::17]:4711";proto=https""" + val header = Header.Forwarded(forValues = List(""""[2001:db8:cafe::17]:4711""""), proto = Some("https")) + assertTrue(Header.Forwarded.parse(headerValue) == Right(header)) + }, + test("parse Forwarded header with all attributes") { + val headerValue = """for="[2001:db8:cafe::17]:4711";by=_value;host=example.com;proto=https""" + val header = Header.Forwarded( + forValues = List(""""[2001:db8:cafe::17]:4711""""), + by = Some("_value"), + host = Some("example.com"), + proto = Some("https"), + ) + assertTrue(Header.Forwarded.parse(headerValue) == Right(header)) + }, + ) +} diff --git a/zio-http/src/test/scala/zio/http/headers/LinkSpec.scala b/zio-http/src/test/scala/zio/http/headers/LinkSpec.scala new file mode 100644 index 0000000000..b5f3c8449e --- /dev/null +++ b/zio-http/src/test/scala/zio/http/headers/LinkSpec.scala @@ -0,0 +1,71 @@ +/* + * Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP contributors. + * + * Licensed 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 zio.http.headers + +import java.net.URI + +import zio.Scope +import zio.test._ + +import zio.http.Header.ClearSiteData +import zio.http.internal.HttpGen +import zio.http.{Header, URL, ZIOHttpSpec} + +object LinkSpec extends ZIOHttpSpec { + override def spec: Spec[TestEnvironment with Scope, Any] = suite("Link suite")( + test("Parse links in the form of ") { + val headerValue = "; rel=\"previous\"" + val uri = URL.decode("https://example.com/TheBook/chapter2").getOrElse(throw new Exception("Invalid URL")) + assertTrue(Header.Link.parse(headerValue) == Right(Header.Link(uri, Map("rel" -> "previous")))) + }, + test("Fail to parse links without a URI") { + val headerValue = "rel=\"previous\"" + assertTrue(Header.Link.parse(headerValue) == Left("Invalid Link header")) + }, + test("Fail to parse links without pointy brackets") { + val headerValue = "https://example.com/TheBook/chapter2; rel=\"previous\"" + assertTrue(Header.Link.parse(headerValue) == Left("Invalid Link header")) + }, + test("Parse links with multiple parameters") { + val headerValue = "; rel=\"previous\"; title=\"previous chapter\"" + val uri = URL.decode("https://example.com/TheBook/chapter2").getOrElse(throw new Exception("Invalid URL")) + assertTrue( + Header.Link.parse(headerValue) == Right( + Header.Link(uri, Map("rel" -> "previous", "title" -> "previous chapter")), + ), + ) + }, + test("Parse links with multiple parameters and spaces") { + val headerValue = "; rel=\"previous\"; title=\"previous chapter\"" + val uri = URL.decode("https://example.com/TheBook/chapter2").getOrElse(throw new Exception("Invalid URL")) + assertTrue( + Header.Link.parse(headerValue) == Right( + Header.Link(uri, Map("rel" -> "previous", "title" -> "previous chapter")), + ), + ) + }, + test("Parse unquoted parameters") { + val headerValue = "; rel=previous; title=previous chapter" + val uri = URL.decode("https://example.com/TheBook/chapter2").getOrElse(throw new Exception("Invalid URL")) + assertTrue( + Header.Link.parse(headerValue) == Right( + Header.Link(uri, Map("rel" -> "previous", "title" -> "previous chapter")), + ), + ) + }, + ) +} diff --git a/zio-http/src/test/scala/zio/http/internal/HttpGen.scala b/zio-http/src/test/scala/zio/http/internal/HttpGen.scala index e8c56f22cf..05febadcf6 100644 --- a/zio-http/src/test/scala/zio/http/internal/HttpGen.scala +++ b/zio-http/src/test/scala/zio/http/internal/HttpGen.scala @@ -67,6 +67,20 @@ object HttpGen { } yield Request(version, method, url, headers, body, None) } + private def clearSiteDataDirective: Gen[Any, ClearSiteDataDirective] = Gen.fromIterable( + List( + ClearSiteDataDirective.Cache, + ClearSiteDataDirective.ClientHints, + ClearSiteDataDirective.Cookies, + ClearSiteDataDirective.Storage, + ClearSiteDataDirective.ExecutionContexts, + ClearSiteDataDirective.All, + ), + ) + + def clearSiteData: Gen[Any, ClearSiteData] = + Gen.chunkOfBounded(1, 5)(clearSiteDataDirective).map(c => ClearSiteData(NonEmptyChunk.fromChunk(c).get)) + def genAbsoluteLocation: Gen[Any, Location.Absolute] = for { scheme <- Gen.fromIterable(List(Scheme.HTTP, Scheme.HTTPS)) host <- Gen.alphaNumericStringBounded(1, 5)