From 6f5d449bd0f6408aec9ba0c76f02d35fbcadb1dc Mon Sep 17 00:00:00 2001 From: Konrad Malawski Date: Tue, 24 Nov 2015 11:59:57 +0100 Subject: [PATCH] +htc #18898 modeledCustomHeader to ease matching on headers --- .../rst/scala/http/common/http-model.rst | 34 ++++++ .../engine/parsing/HttpHeaderParser.scala | 4 +- .../akka/http/scaladsl/model/HttpEntity.scala | 28 ++--- .../http/scaladsl/model/headers/headers.scala | 49 +++++++- .../server/ModeledCustomHeaderSpec.scala | 113 ++++++++++++++++++ .../MultipartUnmarshallersSpec.scala | 4 +- 6 files changed, 212 insertions(+), 20 deletions(-) create mode 100644 akka-http-tests/src/test/scala/akka/http/scaladsl/server/ModeledCustomHeaderSpec.scala diff --git a/akka-docs-dev/rst/scala/http/common/http-model.rst b/akka-docs-dev/rst/scala/http/common/http-model.rst index d2d0e5c283..c1a3f15144 100644 --- a/akka-docs-dev/rst/scala/http/common/http-model.rst +++ b/akka-docs-dev/rst/scala/http/common/http-model.rst @@ -216,6 +216,8 @@ header across persistent HTTP connections. .. _RFC 7230: http://tools.ietf.org/html/rfc7230#section-3.3.3 +.. _header-model-scala: + Header Model ------------ @@ -281,6 +283,38 @@ Connection __ @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 ------------------- diff --git a/akka-http-core/src/main/scala/akka/http/impl/engine/parsing/HttpHeaderParser.scala b/akka-http-core/src/main/scala/akka/http/impl/engine/parsing/HttpHeaderParser.scala index 0ab613fc90..010519f582 100644 --- a/akka-http-core/src/main/scala/akka/http/impl/engine/parsing/HttpHeaderParser.scala +++ b/akka-http-core/src/main/scala/akka/http/impl/engine/parsing/HttpHeaderParser.scala @@ -442,7 +442,7 @@ private[http] object HttpHeaderParser { def prime(parser: HttpHeaderParser): HttpHeaderParser = { val valueParsers: Seq[HeaderValueParser] = 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) def insertInGoodOrder(items: Seq[Any])(startIx: Int = 0, endIx: Int = items.size): Unit = if (endIx - startIx > 0) { @@ -477,7 +477,7 @@ private[http] object HttpHeaderParser { 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) { 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) diff --git a/akka-http-core/src/main/scala/akka/http/scaladsl/model/HttpEntity.scala b/akka-http-core/src/main/scala/akka/http/scaladsl/model/HttpEntity.scala index 7d7a9bbdf2..77b907e76a 100644 --- a/akka-http-core/src/main/scala/akka/http/scaladsl/model/HttpEntity.scala +++ b/akka-http-core/src/main/scala/akka/http/scaladsl/model/HttpEntity.scala @@ -120,8 +120,8 @@ sealed trait BodyPartEntity extends HttpEntity with jm.BodyPartEntity { def withContentType(contentType: ContentType): BodyPartEntity /** - * See [[HttpEntity#withSizeLimit]]. - */ + * See [[HttpEntity#withSizeLimit]]. + */ def withSizeLimit(maxBytes: Long): BodyPartEntity } @@ -134,8 +134,8 @@ sealed trait RequestEntity extends HttpEntity with jm.RequestEntity with Respons def withContentType(contentType: ContentType): RequestEntity /** - * See [[HttpEntity#withSizeLimit]]. - */ + * See [[HttpEntity#withSizeLimit]]. + */ def withSizeLimit(maxBytes: Long): RequestEntity def transformDataBytes(transformer: Flow[ByteString, ByteString, Any]): RequestEntity @@ -150,8 +150,8 @@ sealed trait ResponseEntity extends HttpEntity with jm.ResponseEntity { def withContentType(contentType: ContentType): ResponseEntity /** - * See [[HttpEntity#withSizeLimit]]. - */ + * See [[HttpEntity#withSizeLimit]]. + */ def withSizeLimit(maxBytes: Long): ResponseEntity def transformDataBytes(transformer: Flow[ByteString, ByteString, Any]): ResponseEntity @@ -161,8 +161,8 @@ sealed trait UniversalEntity extends jm.UniversalEntity with MessageEntity with def withContentType(contentType: ContentType): UniversalEntity /** - * See [[HttpEntity#withSizeLimit]]. - */ + * See [[HttpEntity#withSizeLimit]]. + */ def withSizeLimit(maxBytes: Long): UniversalEntity def contentLength: Long @@ -233,8 +233,8 @@ object HttpEntity { if (contentType == this.contentType) this else copy(contentType = contentType) /** - * See [[HttpEntity#withSizeLimit]]. - */ + * See [[HttpEntity#withSizeLimit]]. + */ def withSizeLimit(maxBytes: Long): UniversalEntity = if (data.length <= maxBytes) this else Default(contentType, data.length, limitableByteSource(Source.single(data))) withSizeLimit maxBytes @@ -265,8 +265,8 @@ object HttpEntity { if (contentType == this.contentType) this else copy(contentType = contentType) /** - * See [[HttpEntity#withSizeLimit]]. - */ + * See [[HttpEntity#withSizeLimit]]. + */ def withSizeLimit(maxBytes: Long): Default = copy(data = data withAttributes Attributes(SizeLimit(maxBytes, Some(contentLength)))) @@ -287,8 +287,8 @@ object HttpEntity { def dataBytes: Source[ByteString, Any] = data /** - * See [[HttpEntity#withSizeLimit]]. - */ + * See [[HttpEntity#withSizeLimit]]. + */ def withSizeLimit(maxBytes: Long): Self = withData(data withAttributes Attributes(SizeLimit(maxBytes))) diff --git a/akka-http-core/src/main/scala/akka/http/scaladsl/model/headers/headers.scala b/akka-http-core/src/main/scala/akka/http/scaladsl/model/headers/headers.scala index 46fbb83522..77b3a40ec8 100644 --- a/akka-http-core/src/main/scala/akka/http/scaladsl/model/headers/headers.scala +++ b/akka-http-core/src/main/scala/akka/http/scaladsl/model/headers/headers.scala @@ -9,8 +9,10 @@ import java.net.InetSocketAddress import java.security.MessageDigest import java.util +import akka.event.Logging + import scala.reflect.ClassTag -import scala.util.Try +import scala.util.{ Failure, Success, Try } import scala.annotation.tailrec 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`. + * + * 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 { /** 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 } +/** + * 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._ // 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 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 object Accept extends ModeledCompanion[Accept] { diff --git a/akka-http-tests/src/test/scala/akka/http/scaladsl/server/ModeledCustomHeaderSpec.scala b/akka-http-tests/src/test/scala/akka/http/scaladsl/server/ModeledCustomHeaderSpec.scala new file mode 100644 index 0000000000..addf17f5e5 --- /dev/null +++ b/akka-http-tests/src/test/scala/akka/http/scaladsl/server/ModeledCustomHeaderSpec.scala @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2009-2015 Typesafe Inc. + */ + +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") + } + } + +} diff --git a/akka-http-tests/src/test/scala/akka/http/scaladsl/unmarshalling/MultipartUnmarshallersSpec.scala b/akka-http-tests/src/test/scala/akka/http/scaladsl/unmarshalling/MultipartUnmarshallersSpec.scala index e1953f2758..ae3213c7c4 100644 --- a/akka-http-tests/src/test/scala/akka/http/scaladsl/unmarshalling/MultipartUnmarshallersSpec.scala +++ b/akka-http-tests/src/test/scala/akka/http/scaladsl/unmarshalling/MultipartUnmarshallersSpec.scala @@ -92,9 +92,7 @@ class MultipartUnmarshallersSpec extends FreeSpec with Matchers with BeforeAndAf |filecontent |--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(`application/octet-stream`, "filecontent"), - List(RawHeader("Content-Transfer-Encoding", "binary")))) + Multipart.General.BodyPart.Strict(HttpEntity(`application/octet-stream`, "filecontent"), List(RawHeader("Content-Transfer-Encoding", "binary")))) } "illegal headers" in ( Unmarshal(HttpEntity(`multipart/form-data` withBoundary "XYZABC",