Skip to content

Commit

Permalink
Add missing default http headers (zio#2557) (zio#2595)
Browse files Browse the repository at this point in the history
  • Loading branch information
987Nabil authored Jan 7, 2024
1 parent 11f13c9 commit ba1f618
Show file tree
Hide file tree
Showing 7 changed files with 321 additions and 0 deletions.
3 changes: 3 additions & 0 deletions zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand Down
130 changes: 130 additions & 0 deletions zio-http/src/main/scala/zio/http/Header.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down
3 changes: 3 additions & 0 deletions zio-http/src/main/scala/zio/http/codec/HeaderCodecs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions zio-http/src/test/scala/zio/http/headers/ClearSiteDataSpec.scala
Original file line number Diff line number Diff line change
@@ -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"))
},
)
}
62 changes: 62 additions & 0 deletions zio-http/src/test/scala/zio/http/headers/ForwardedSpec.scala
Original file line number Diff line number Diff line change
@@ -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))
},
)
}
71 changes: 71 additions & 0 deletions zio-http/src/test/scala/zio/http/headers/LinkSpec.scala
Original file line number Diff line number Diff line change
@@ -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 <uri>") {
val headerValue = "<https://example.com/TheBook/chapter2>; 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 = "<https://example.com/TheBook/chapter2>; 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 = "<https://example.com/TheBook/chapter2>; 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 = "<https://example.com/TheBook/chapter2>; 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")),
),
)
},
)
}
14 changes: 14 additions & 0 deletions zio-http/src/test/scala/zio/http/internal/HttpGen.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit ba1f618

Please sign in to comment.