diff --git a/akka-http-core/src/main/scala/akka/http/model/headers/WebsocketExtension.scala b/akka-http-core/src/main/scala/akka/http/model/headers/WebsocketExtension.scala new file mode 100644 index 0000000000..5fcdf6bd8e --- /dev/null +++ b/akka-http-core/src/main/scala/akka/http/model/headers/WebsocketExtension.scala @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2009-2015 Typesafe Inc. + */ + +package akka.http.model.headers + +import akka.http.util.{ Rendering, ValueRenderable } + +import scala.collection.immutable + +/** + * A websocket extension as defined in http://tools.ietf.org/html/rfc6455#section-4.3 + */ +final case class WebsocketExtension(name: String, params: immutable.Map[String, String] = Map.empty) extends ValueRenderable { + def render[R <: Rendering](r: R): r.type = { + r ~~ name + if (params.nonEmpty) + params.foreach { + case (k, "") ⇒ r ~~ "; " ~~ k + case (k, v) ⇒ r ~~ "; " ~~ k ~~ '=' ~~# v + } + r + } +} diff --git a/akka-http-core/src/main/scala/akka/http/model/headers/headers.scala b/akka-http-core/src/main/scala/akka/http/model/headers/headers.scala index 383489bf44..0f0e29eade 100644 --- a/akka-http-core/src/main/scala/akka/http/model/headers/headers.scala +++ b/akka-http-core/src/main/scala/akka/http/model/headers/headers.scala @@ -7,7 +7,10 @@ package headers import java.lang.Iterable import java.net.InetSocketAddress +import java.security.MessageDigest import java.util +import akka.parboiled2.util.Base64 + import scala.annotation.tailrec import scala.collection.immutable import akka.http.util._ @@ -563,6 +566,103 @@ final case class Referer(uri: Uri) extends japi.headers.Referer with ModeledHead def getUri: akka.http.model.japi.Uri = uri.asJava } +/** + * INTERNAL API + */ +// http://tools.ietf.org/html/rfc6455#section-4.3 +private[http] object `Sec-WebSocket-Accept` extends ModeledCompanion { + // Defined at http://tools.ietf.org/html/rfc6455#section-4.2.2 + val MagicGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + /** Generates the matching accept header for this key */ + def forKey(key: `Sec-WebSocket-Key`): `Sec-WebSocket-Accept` = { + val sha1 = MessageDigest.getInstance("sha1") + val salted = key.key + MagicGuid + val hash = sha1.digest(salted.asciiBytes) + val acceptKey = Base64.rfc2045().encodeToString(hash, false) + `Sec-WebSocket-Accept`(acceptKey) + } +} +/** + * INTERNAL API + */ +private[http] final case class `Sec-WebSocket-Accept`(key: String) extends ModeledHeader { + protected[http] def renderValue[R <: Rendering](r: R): r.type = r ~~ key + + protected def companion = `Sec-WebSocket-Accept` +} + +/** + * INTERNAL API + */ +// http://tools.ietf.org/html/rfc6455#section-4.3 +private[http] object `Sec-WebSocket-Extensions` extends ModeledCompanion { + implicit val extensionsRenderer = Renderer.defaultSeqRenderer[WebsocketExtension] +} +/** + * INTERNAL API + */ +private[http] final case class `Sec-WebSocket-Extensions`(extensions: immutable.Seq[WebsocketExtension]) extends ModeledHeader { + require(extensions.nonEmpty, "Sec-WebSocket-Extensions.extensions must not be empty") + import `Sec-WebSocket-Extensions`.extensionsRenderer + protected[http] def renderValue[R <: Rendering](r: R): r.type = r ~~ extensions + + protected def companion = `Sec-WebSocket-Extensions` +} + +// http://tools.ietf.org/html/rfc6455#section-4.3 +/** + * INTERNAL API + */ +private[http] object `Sec-WebSocket-Key` extends ModeledCompanion +/** + * INTERNAL API + */ +private[http] final case class `Sec-WebSocket-Key`(key: String) extends ModeledHeader { + protected[http] def renderValue[R <: Rendering](r: R): r.type = r ~~ key + + protected def companion = `Sec-WebSocket-Key` +} + +// http://tools.ietf.org/html/rfc6455#section-4.3 +/** + * INTERNAL API + */ +private[http] object `Sec-WebSocket-Protocol` extends ModeledCompanion { + implicit val protocolsRenderer = Renderer.defaultSeqRenderer[String] +} +/** + * INTERNAL API + */ +private[http] final case class `Sec-WebSocket-Protocol`(protocols: immutable.Seq[String]) extends ModeledHeader { + require(protocols.nonEmpty, "Sec-WebSocket-Protocol.protocols must not be empty") + import `Sec-WebSocket-Protocol`.protocolsRenderer + protected[http] def renderValue[R <: Rendering](r: R): r.type = r ~~ protocols + + protected def companion = `Sec-WebSocket-Protocol` +} + +// http://tools.ietf.org/html/rfc6455#section-4.3 +/** + * INTERNAL API + */ +private[http] object `Sec-WebSocket-Version` extends ModeledCompanion { + implicit val versionsRenderer = Renderer.defaultSeqRenderer[Int] +} +/** + * INTERNAL API + */ +private[http] final case class `Sec-WebSocket-Version`(versions: immutable.Seq[Int]) extends ModeledHeader { + require(versions.nonEmpty, "Sec-WebSocket-Version.versions must not be empty") + require(versions.forall(v ⇒ v >= 0 && v <= 255), s"Sec-WebSocket-Version.versions must be in the range 0 <= version <= 255 but were $versions") + import `Sec-WebSocket-Version`.versionsRenderer + protected[http] def renderValue[R <: Rendering](r: R): r.type = r ~~ versions + + def hasVersion(versionNumber: Int): Boolean = versions.exists(_ == versionNumber) + + protected def companion = `Sec-WebSocket-Version` +} + // http://tools.ietf.org/html/rfc7231#section-7.4.2 object Server extends ModeledCompanion { def apply(products: String): Server = apply(ProductVersion.parseMultiple(products)) diff --git a/akka-http-core/src/main/scala/akka/http/model/parser/HeaderParser.scala b/akka-http-core/src/main/scala/akka/http/model/parser/HeaderParser.scala index 1c1f822aea..f7d8407cec 100644 --- a/akka-http-core/src/main/scala/akka/http/model/parser/HeaderParser.scala +++ b/akka-http-core/src/main/scala/akka/http/model/parser/HeaderParser.scala @@ -26,7 +26,8 @@ private[http] class HeaderParser(val input: ParserInput) extends Parser with Dyn with IpAddressParsing with LinkHeader with SimpleHeaders - with StringBuilding { + with StringBuilding + with WebsocketHeaders { import CharacterClasses._ // http://www.rfc-editor.org/errata_search.php?rfc=7230 errata id 4189 @@ -111,6 +112,11 @@ private[http] object HeaderParser { "range", "referer", "server", + "sec-websocket-accept", + "sec-websocket-extensions", + "sec-websocket-key", + "sec-websocket-protocol", + "sec-websocket-version", "set-cookie", "transfer-encoding", "user-agent", diff --git a/akka-http-core/src/main/scala/akka/http/model/parser/WebsocketHeaders.scala b/akka-http-core/src/main/scala/akka/http/model/parser/WebsocketHeaders.scala new file mode 100644 index 0000000000..3df0bee907 --- /dev/null +++ b/akka-http-core/src/main/scala/akka/http/model/parser/WebsocketHeaders.scala @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2009-2015 Typesafe Inc. + */ + +package akka.http.model.parser + +import akka.http.model.headers._ +import akka.parboiled2._ + +// see grammar at http://tools.ietf.org/html/rfc6455#section-4.3 +private[parser] trait WebsocketHeaders { this: Parser with CommonRules with CommonActions ⇒ + import CharacterClasses._ + import Base64Parsing.rfc2045Alphabet + + def `sec-websocket-accept` = rule { + `base64-value-non-empty` ~ EOI ~> (`Sec-WebSocket-Accept`(_)) + } + + def `sec-websocket-extensions` = rule { + oneOrMore(extension).separatedBy(listSep) ~ EOI ~> (`Sec-WebSocket-Extensions`(_)) + } + + def `sec-websocket-key` = rule { + `base64-value-non-empty` ~ EOI ~> (`Sec-WebSocket-Key`(_)) + } + + def `sec-websocket-protocol` = rule { + oneOrMore(token).separatedBy(listSep) ~ EOI ~> (`Sec-WebSocket-Protocol`(_)) + } + + def `sec-websocket-version` = rule { + oneOrMore(version).separatedBy(listSep) ~ EOI ~> (`Sec-WebSocket-Version`(_)) + } + + private def `base64-value-non-empty` = rule { + capture(oneOrMore(`base64-data`) ~ optional(`base64-padding`) | `base64-padding`) + } + private def `base64-data` = rule { 4.times(`base64-character`) } + private def `base64-padding` = rule { + 2.times(`base64-character`) ~ "==" | + 3.times(`base64-character`) ~ "=" + } + private def `base64-character` = rfc2045Alphabet + + private def extension = rule { + `extension-token` ~ zeroOrMore(ws(";") ~ `extension-param`) ~> + ((name, params) ⇒ WebsocketExtension(name, Map(params: _*))) + } + private def `extension-token`: Rule1[String] = token + private def `extension-param`: Rule1[(String, String)] = + rule { + token ~ optional(ws("=") ~ word) ~> ((name: String, value: Option[String]) ⇒ (name, value.getOrElse(""))) + } + + private def version = rule { + capture( + NZDIGIT ~ optional(DIGIT ~ optional(DIGIT)) | + DIGIT) ~> (_.toInt) + } + private def NZDIGIT = DIGIT19 +} diff --git a/akka-http-core/src/main/scala/akka/http/util/Rendering.scala b/akka-http-core/src/main/scala/akka/http/util/Rendering.scala index 9e1333e78e..39eba4ab39 100644 --- a/akka-http-core/src/main/scala/akka/http/util/Rendering.scala +++ b/akka-http-core/src/main/scala/akka/http/util/Rendering.scala @@ -84,6 +84,9 @@ private[http] object Renderer { implicit object CharRenderer extends Renderer[Char] { def render[R <: Rendering](r: R, value: Char): r.type = r ~~ value } + implicit object IntRenderer extends Renderer[Int] { + def render[R <: Rendering](r: R, value: Int): r.type = r ~~ value + } implicit object StringRenderer extends Renderer[String] { def render[R <: Rendering](r: R, value: String): r.type = r ~~ value } diff --git a/akka-http-core/src/test/scala/akka/http/model/parser/HttpHeaderSpec.scala b/akka-http-core/src/test/scala/akka/http/model/parser/HttpHeaderSpec.scala index 28b4655560..399a4e8c20 100644 --- a/akka-http-core/src/test/scala/akka/http/model/parser/HttpHeaderSpec.scala +++ b/akka-http-core/src/test/scala/akka/http/model/parser/HttpHeaderSpec.scala @@ -369,6 +369,44 @@ class HttpHeaderSpec extends FreeSpec with Matchers { "Range: bytes=0-1, 2-3, -99" =!= Range(ByteRange(0, 1), ByteRange(2, 3), ByteRange.suffix(99)) } + "Sec-WebSocket-Accept" in { + "Sec-WebSocket-Accept: ZGgwOTM0Z2owcmViamRvcGcK" =!= `Sec-WebSocket-Accept`("ZGgwOTM0Z2owcmViamRvcGcK") + } + "Sec-WebSocket-Extensions" in { + "Sec-WebSocket-Extensions: abc" =!= + `Sec-WebSocket-Extensions`(Vector(WebsocketExtension("abc"))) + "Sec-WebSocket-Extensions: abc, def" =!= + `Sec-WebSocket-Extensions`(Vector(WebsocketExtension("abc"), WebsocketExtension("def"))) + "Sec-WebSocket-Extensions: abc; param=2; use_y, def" =!= + `Sec-WebSocket-Extensions`(Vector(WebsocketExtension("abc", Map("param" -> "2", "use_y" -> "")), WebsocketExtension("def"))) + "Sec-WebSocket-Extensions: abc; param=\",xyz\", def" =!= + `Sec-WebSocket-Extensions`(Vector(WebsocketExtension("abc", Map("param" -> ",xyz")), WebsocketExtension("def"))) + + // real examples from https://tools.ietf.org/html/draft-ietf-hybi-permessage-compression-19 + "Sec-WebSocket-Extensions: permessage-deflate" =!= + `Sec-WebSocket-Extensions`(Vector(WebsocketExtension("permessage-deflate"))) + "Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits; server_max_window_bits=10" =!= + `Sec-WebSocket-Extensions`(Vector(WebsocketExtension("permessage-deflate", Map("client_max_window_bits" -> "", "server_max_window_bits" -> "10")))) + "Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits; server_max_window_bits=10, permessage-deflate; client_max_window_bits" =!= + `Sec-WebSocket-Extensions`(Vector( + WebsocketExtension("permessage-deflate", Map("client_max_window_bits" -> "", "server_max_window_bits" -> "10")), + WebsocketExtension("permessage-deflate", Map("client_max_window_bits" -> "")))) + } + "Sec-WebSocket-Key" in { + "Sec-WebSocket-Key: c2Zxb3JpbmgyMzA5dGpoMDIzOWdlcm5vZ2luCg==" =!= `Sec-WebSocket-Key`("c2Zxb3JpbmgyMzA5dGpoMDIzOWdlcm5vZ2luCg==") + } + "Sec-WebSocket-Protocol" in { + "Sec-WebSocket-Protocol: chat" =!= `Sec-WebSocket-Protocol`(Vector("chat")) + "Sec-WebSocket-Protocol: chat, superchat" =!= `Sec-WebSocket-Protocol`(Vector("chat", "superchat")) + } + "Sec-WebSocket-Version" in { + "Sec-WebSocket-Version: 25" =!= `Sec-WebSocket-Version`(Vector(25)) + "Sec-WebSocket-Version: 13, 8, 7" =!= `Sec-WebSocket-Version`(Vector(13, 8, 7)) + + "Sec-WebSocket-Version: 255" =!= `Sec-WebSocket-Version`(Vector(255)) + "Sec-WebSocket-Version: 0" =!= `Sec-WebSocket-Version`(Vector(0)) + } + "Set-Cookie" in { "Set-Cookie: SID=\"31d4d96e407aad42\"" =!= `Set-Cookie`(HttpCookie("SID", "31d4d96e407aad42")).renderedTo("SID=31d4d96e407aad42")