Merge pull request #18942 from ktoso/wip-customheaders-impr-ktoso

+htc #18898 modeledCustomHeader to ease matching on headers
This commit is contained in:
Konrad Malawski 2015-11-24 16:27:10 +01:00
commit fd5a3afc9d
5 changed files with 198 additions and 6 deletions

View file

@ -216,6 +216,8 @@ header across persistent HTTP connections.
.. _RFC 7230: http://tools.ietf.org/html/rfc7230#section-3.3.3 .. _RFC 7230: http://tools.ietf.org/html/rfc7230#section-3.3.3
.. _header-model-scala:
Header Model Header Model
------------ ------------
@ -281,6 +283,38 @@ Connection
__ @github@/akka-http-core/src/test/scala/akka/http/impl/engine/rendering/ResponseRendererSpec.scala#L422 __ @github@/akka-http-core/src/test/scala/akka/http/impl/engine/rendering/ResponseRendererSpec.scala#L422
Custom Headers
--------------
Sometimes you may need to model a custom header type which is not part of HTTP and still be able to use it
as convienient as is possible with the built-in types.
Because of the number of ways one may interact with headers (i.e. try to match a ``CustomHeader`` against a ``RawHeader``
or the other way around etc), a helper trait for custom Header types and their companions classes are provided by Akka HTTP.
Thanks to extending :class:`ModeledCustomHeader` instead of the plain ``CustomHeader`` such header can be matched
.. includecode:: ../../../../../akka-http-tests/src/test/scala/akka/http/scaladsl/server/CustomHeaderRoutingSpec.scala
:include: modeled-api-key-custom-header
Which allows the this CustomHeader to be used in the following scenarios:
.. includecode:: ../../../../../akka-http-tests/src/test/scala/akka/http/scaladsl/server/CustomHeaderRoutingSpec.scala
:include: matching-examples
Including usage within the header directives like in the following :ref:`-headerValuePF-` example:
.. includecode:: ../../../../../akka-http-tests/src/test/scala/akka/http/scaladsl/server/CustomHeaderRoutingSpec.scala
:include: matching-in-routes
One can also directly extend :class:`CustomHeader` which requires less boilerplate, however that has the downside of
matching against :ref:`RawHeader` instances not working out-of-the-box, thus limiting its usefulnes in the routing layer
of Akka HTTP. For only rendering such header however it would be enough.
.. note::
When defining custom headers, prefer to extend :class:`ModeledCustomHeader` instead of :class:`CustomHeader` directly
as it will automatically make your header abide all the expected pattern matching semantics one is accustomed to
when using built-in types (such as matching a custom header against a ``RawHeader`` as is often the case in routing
layers of Akka HTTP applications).
Parsing / Rendering Parsing / Rendering
------------------- -------------------

View file

@ -442,7 +442,7 @@ private[http] object HttpHeaderParser {
def prime(parser: HttpHeaderParser): HttpHeaderParser = { def prime(parser: HttpHeaderParser): HttpHeaderParser = {
val valueParsers: Seq[HeaderValueParser] = val valueParsers: Seq[HeaderValueParser] =
HeaderParser.ruleNames.map { name HeaderParser.ruleNames.map { name
new ModelledHeaderValueParser(name, parser.settings.maxHeaderValueLength, parser.settings.headerValueCacheLimit(name), parser.settings) new ModeledHeaderValueParser(name, parser.settings.maxHeaderValueLength, parser.settings.headerValueCacheLimit(name), parser.settings)
}(collection.breakOut) }(collection.breakOut)
def insertInGoodOrder(items: Seq[Any])(startIx: Int = 0, endIx: Int = items.size): Unit = def insertInGoodOrder(items: Seq[Any])(startIx: Int = 0, endIx: Int = items.size): Unit =
if (endIx - startIx > 0) { if (endIx - startIx > 0) {
@ -477,7 +477,7 @@ private[http] object HttpHeaderParser {
def cachingEnabled = maxValueCount > 0 def cachingEnabled = maxValueCount > 0
} }
private[parsing] class ModelledHeaderValueParser(headerName: String, maxHeaderValueLength: Int, maxValueCount: Int, settings: HeaderParser.Settings) private[parsing] class ModeledHeaderValueParser(headerName: String, maxHeaderValueLength: Int, maxValueCount: Int, settings: HeaderParser.Settings)
extends HeaderValueParser(headerName, maxValueCount) { extends HeaderValueParser(headerName, maxValueCount) {
def apply(hhp: HttpHeaderParser, input: ByteString, valueStart: Int, onIllegalHeader: ErrorInfo Unit): (HttpHeader, Int) = { def apply(hhp: HttpHeaderParser, input: ByteString, valueStart: Int, onIllegalHeader: ErrorInfo Unit): (HttpHeader, Int) = {
// TODO: optimize by running the header value parser directly on the input ByteString (rather than an extracted String) // TODO: optimize by running the header value parser directly on the input ByteString (rather than an extracted String)

View file

@ -9,8 +9,10 @@ import java.net.InetSocketAddress
import java.security.MessageDigest import java.security.MessageDigest
import java.util import java.util
import akka.event.Logging
import scala.reflect.ClassTag import scala.reflect.ClassTag
import scala.util.Try import scala.util.{ Failure, Success, Try }
import scala.annotation.tailrec import scala.annotation.tailrec
import scala.collection.immutable import scala.collection.immutable
@ -48,6 +50,10 @@ sealed trait ModeledHeader extends HttpHeader with Serializable {
/** /**
* Superclass for user-defined custom headers defined by implementing `name` and `value`. * Superclass for user-defined custom headers defined by implementing `name` and `value`.
*
* Prefer to extend [[ModeledCustomHeader]] and [[ModeledCustomHeaderCompanion]] instead if
* planning to use the defined header in match clauses (e.g. in the routing layer of Akka HTTP),
* as they allow the custom header to be matched from [[RawHeader]] and vice-versa.
*/ */
abstract class CustomHeader extends jm.headers.CustomHeader { abstract class CustomHeader extends jm.headers.CustomHeader {
/** Override to return true if this header shouldn't be rendered */ /** Override to return true if this header shouldn't be rendered */
@ -57,6 +63,43 @@ abstract class CustomHeader extends jm.headers.CustomHeader {
final def render[R <: Rendering](r: R): r.type = r ~~ name ~~ ':' ~~ ' ' ~~ value final def render[R <: Rendering](r: R): r.type = r ~~ name ~~ ':' ~~ ' ' ~~ value
} }
/**
* To be extended by companion object of a custom header extending [[ModeledCustomHeader]].
* Implements necessary apply and unapply methods to make the such defined header feel "native".
*/
abstract class ModeledCustomHeaderCompanion[H <: ModeledCustomHeader[H]] {
def name: String
def lowercaseName: String = name.toRootLowerCase
def parse(value: String): Try[H]
def apply(value: String): H =
parse(value) match {
case Success(parsed) parsed
case Failure(ex) throw new IllegalArgumentException(s"Unable to construct custom header by parsing: '$value'", ex)
}
def unapply(h: HttpHeader): Option[String] = h match {
case _: RawHeader if (h.lowercaseName == lowercaseName) Some(h.value) else None
case _: CustomHeader if (h.lowercaseName == lowercaseName) Some(h.value) else None
case _ None
}
}
/**
* Support class for building user-defined custom headers defined by implementing `name` and `value`.
* By implementing a [[ModeledCustomHeader]] instead of [[CustomHeader]] directly, all needed unapply
* methods are provided for this class, such that it can be pattern matched on from [[RawHeader]] and
* the other way around as well.
*/
abstract class ModeledCustomHeader[H <: ModeledCustomHeader[H]] extends CustomHeader { this: H
def companion: ModeledCustomHeaderCompanion[H]
final override def name = companion.name
final override def lowercaseName: String = name.toRootLowerCase
}
import akka.http.impl.util.JavaMapping.Implicits._ import akka.http.impl.util.JavaMapping.Implicits._
// http://tools.ietf.org/html/rfc7230#section-6.1 // http://tools.ietf.org/html/rfc7230#section-6.1
@ -139,6 +182,10 @@ final case class RawHeader(name: String, value: String) extends jm.headers.RawHe
val lowercaseName = name.toRootLowerCase val lowercaseName = name.toRootLowerCase
def render[R <: Rendering](r: R): r.type = r ~~ name ~~ ':' ~~ ' ' ~~ value def render[R <: Rendering](r: R): r.type = r ~~ name ~~ ':' ~~ ' ' ~~ value
} }
object RawHeader {
def unapply[H <: HttpHeader](customHeader: H): Option[(String, String)] =
Some(customHeader.name -> customHeader.value)
}
// http://tools.ietf.org/html/rfc7231#section-5.3.2 // http://tools.ietf.org/html/rfc7231#section-5.3.2
object Accept extends ModeledCompanion[Accept] { object Accept extends ModeledCompanion[Accept] {

View file

@ -0,0 +1,113 @@
/*
* Copyright (C) 2009-2015 Typesafe Inc. <http://www.typesafe.com>
*/
package akka.http.scaladsl.server
import akka.http.scaladsl.model.{ HttpHeader, StatusCodes }
import akka.http.scaladsl.model.headers._
import scala.concurrent.Future
import scala.util.{ Success, Failure, Try }
object ModeledCustomHeaderSpec {
//#modeled-api-key-custom-header
object ApiTokenHeader extends ModeledCustomHeaderCompanion[ApiTokenHeader] {
override val name = "apiKey"
override def parse(value: String) = Try(new ApiTokenHeader(value))
}
final class ApiTokenHeader(token: String) extends ModeledCustomHeader[ApiTokenHeader] {
override val companion = ApiTokenHeader
override def value: String = token
}
//#modeled-api-key-custom-header
object DifferentHeader extends ModeledCustomHeaderCompanion[DifferentHeader] {
override val name = "different"
override def parse(value: String) =
if (value contains " ") Failure(new Exception("Contains illegal whitespace!"))
else Success(new DifferentHeader(value))
}
final class DifferentHeader(token: String) extends ModeledCustomHeader[DifferentHeader] {
override val companion = DifferentHeader
override def value = token
}
}
class ModeledCustomHeaderSpec extends RoutingSpec {
import ModeledCustomHeaderSpec._
"CustomHeader" should {
"be able to be extracted using expected syntax" in {
//#matching-examples
val ApiTokenHeader(t1) = ApiTokenHeader("token")
t1 should ===("token")
val RawHeader(k2, v2) = ApiTokenHeader("token")
k2 should ===("apiKey")
v2 should ===("token")
// will match, header keys are case insensitive
val ApiTokenHeader(v3) = RawHeader("APIKEY", "token")
v3 should ===("token")
intercept[MatchError] {
// won't match, different header name
val ApiTokenHeader(v4) = DifferentHeader("token")
}
intercept[MatchError] {
// won't match, different header name
val RawHeader("something", v5) = DifferentHeader("token")
}
intercept[MatchError] {
// won't match, different header name
val ApiTokenHeader(v6) = RawHeader("different", "token")
}
//#matching-examples
}
"be able to match from RawHeader" in {
//#matching-in-routes
def extractFromCustomHeader = headerValuePF {
case t @ ApiTokenHeader(token) s"extracted> $t"
case raw: RawHeader s"raw> $raw"
}
val routes = extractFromCustomHeader { s
complete(s)
}
Get().withHeaders(RawHeader("apiKey", "TheKey")) ~> routes ~> check {
status should ===(StatusCodes.OK)
responseAs[String] should ===("extracted> apiKey: TheKey")
}
Get().withHeaders(RawHeader("somethingElse", "TheKey")) ~> routes ~> check {
status should ===(StatusCodes.OK)
responseAs[String] should ===("raw> somethingElse: TheKey")
}
Get().withHeaders(ApiTokenHeader("TheKey")) ~> routes ~> check {
status should ===(StatusCodes.OK)
responseAs[String] should ===("extracted> apiKey: TheKey")
}
//#matching-in-routes
}
"fail with useful message when unable to parse" in {
val ex = intercept[Exception] {
DifferentHeader("Hello world") // illegal " "
}
ex.getMessage should ===("Unable to construct custom header by parsing: 'Hello world'")
ex.getCause.getMessage should include("whitespace")
}
}
}

View file

@ -92,9 +92,7 @@ class MultipartUnmarshallersSpec extends FreeSpec with Matchers with BeforeAndAf
|filecontent |filecontent
|--12345--""".stripMarginWithNewline("\r\n"))).to[Multipart.General] should haveParts( |--12345--""".stripMarginWithNewline("\r\n"))).to[Multipart.General] should haveParts(
Multipart.General.BodyPart.Strict(HttpEntity(ContentTypes.`text/plain(UTF-8)`, "first part, with a trailing newline\r\n")), Multipart.General.BodyPart.Strict(HttpEntity(ContentTypes.`text/plain(UTF-8)`, "first part, with a trailing newline\r\n")),
Multipart.General.BodyPart.Strict( Multipart.General.BodyPart.Strict(HttpEntity(`application/octet-stream`, "filecontent"), List(RawHeader("Content-Transfer-Encoding", "binary"))))
HttpEntity(`application/octet-stream`, "filecontent"),
List(RawHeader("Content-Transfer-Encoding", "binary"))))
} }
"illegal headers" in ( "illegal headers" in (
Unmarshal(HttpEntity(`multipart/form-data` withBoundary "XYZABC", Unmarshal(HttpEntity(`multipart/form-data` withBoundary "XYZABC",