Permalink
Browse files

! http: add support for Accept-Header extensions and media-type param…

…eters, closes #310

Apart from adding another member to the MediaType and MediaRange case class models
(`parameters: Map[String, String]`) this patch also fixes an inconsistency in API design.
For the `HttpCharset` and `HttpEncoding` type you create a custom model instance with
the `custom` method defined on the `HttpCharset` or `HttpEncoding` companion object,
e.g. `HttpCharset.custom("foo")`.
For media-types the `custom` method was defined on the `MediaTypes` object. This has
been changed, you now create a custom media-type with `MediaType.custom`.

Note that quality value (e.g. `q=.2`) on media-range definitions in an `Accept` header are
still not supported in content negotiation. Since we don't want the existence of quality
values in request `Accept` headers to trigger the creation of custom (unregistered)
MediaRange or MediaType instances (which would come with performance penalty due to
not reusing registered instances) we filter the `q` values out of the parameters list.
You therefore won't find them in the `parameters` map.
  • Loading branch information...
sirthias committed Jun 19, 2013
1 parent 572d4df commit d8a9ee4711f06cf4861015247756c50df6ede50c
@@ -19,11 +19,10 @@ package spray.http
import java.util.concurrent.atomic.AtomicReference
import scala.annotation.tailrec
-import org.parboiled.common.Base64
-import java.util
sealed abstract class MediaRange extends LazyValueBytesRenderable {
def mainType: String
+ def parameters: Map[String, String]
def matches(mediaType: MediaType): Boolean
def isApplication = false
def isAudio = false
@@ -34,10 +33,37 @@ sealed abstract class MediaRange extends LazyValueBytesRenderable {
def isVideo = false
}
+object MediaRange {
+ private[http] trait MainTypeBased extends MediaRange {
+ override def isApplication = mainType == "application"
+ override def isAudio = mainType == "audio"
+ override def isImage = mainType == "image"
+ override def isMessage = mainType == "message"
+ override def isMultipart = mainType == "multipart"
+ override def isText = mainType == "text"
+ override def isVideo = mainType == "video"
+ }
+
+ private[http] def valueFor(mainType: String, subType: String, parameters: Map[String, String]): String = {
+ val r = new StringRendering ~~ mainType ~~ '/' ~~ subType
+ if (parameters.nonEmpty) parameters foreach { case (k, v) r ~~ ';' ~~ ' ' ~~ k ~~ '=' ~~# v }
+ r.get
+ }
+
+ private case class CustomMediaRange(mainType: String, parameters: Map[String, String]) extends MainTypeBased {
+ val value = valueFor(mainType, "*", parameters)
+ def matches(mediaType: MediaType) = mediaType.mainType == mainType
+ }
+
+ def custom(mainType: String, parameters: Map[String, String] = Map.empty): MediaRange =
+ CustomMediaRange(mainType, parameters)
+}
+
object MediaRanges extends ObjectRegistry[String, MediaRange] {
sealed abstract case class PredefinedMediaRange(value: String) extends MediaRange {
val mainType = value takeWhile (_ != '/')
+ def parameters = Map.empty
register(mainType.toLowerCase, this)
}
@@ -72,27 +98,12 @@ object MediaRanges extends ObjectRegistry[String, MediaRange] {
def matches(mediaType: MediaType) = mediaType.isVideo
override def isVideo: Boolean = true
}
-
- private[http] trait MainTypeBased extends MediaRange {
- override def isApplication = mainType == "application"
- override def isAudio = mainType == "audio"
- override def isImage = mainType == "image"
- override def isMessage = mainType == "message"
- override def isMultipart = mainType == "multipart"
- override def isText = mainType == "text"
- override def isVideo = mainType == "video"
- }
-
- private case class CustomMediaRange(mainType: String) extends MainTypeBased {
- val value = mainType + "/*"
- def matches(mediaType: MediaType) = mediaType.mainType == mainType
- }
-
- def custom(mainType: String): MediaRange = CustomMediaRange(mainType)
}
sealed abstract case class MediaType private[http] (value: String)(val mainType: String, val subType: String,
- val compressible: Boolean, val binary: Boolean, val fileExtensions: Seq[String]) extends MediaRange {
+ val compressible: Boolean, val binary: Boolean,
+ val fileExtensions: Seq[String],
+ val parameters: Map[String, String]) extends MediaRange {
override def matches(mediaType: MediaType) = this == mediaType
}
@@ -103,10 +114,11 @@ object MediaType {
* your custom Marshallers and Unmarshallers.
*/
def custom(mainType: String, subType: String, compressible: Boolean = false, binary: Boolean = false,
- fileExtensions: Seq[String] = Nil): MediaType =
- new MediaType(mainType + '/' + subType)(mainType, subType, compressible, binary, fileExtensions) with MediaRanges.MainTypeBased {
- def withCompressible = custom(mainType, subType, compressible = true, binary, fileExtensions)
- def withBinary = custom(mainType, subType, compressible, binary = true, fileExtensions)
+ fileExtensions: Seq[String] = Nil, parameters: Map[String, String] = Map.empty): MediaType =
+ new MediaType(MediaRange.valueFor(mainType, subType, parameters))(mainType, subType, compressible, binary,
+ fileExtensions, parameters) with MediaRange.MainTypeBased {
+ def withCompressible = custom(mainType, subType, compressible = true, binary, fileExtensions, parameters)
+ def withBinary = custom(mainType, subType, compressible, binary = true, fileExtensions, parameters)
}
def custom(value: String): MediaType = {
@@ -135,40 +147,41 @@ object MediaTypes extends ObjectRegistry[(String, String), MediaType] {
def forExtension(ext: String): Option[MediaType] = extensionMap.get.get(ext.toLowerCase)
private def app(subType: String, compressible: Boolean, binary: Boolean, fileExtensions: String*) = register {
- new MediaType("application/" + subType)("application", subType, compressible, binary, fileExtensions) {
+ new MediaType("application/" + subType)("application", subType, compressible, binary, fileExtensions, Map.empty) {
override def isApplication = true
}
}
private def aud(subType: String, compressible: Boolean, fileExtensions: String*) = register {
- new MediaType("audio/" + subType)("audio", subType, compressible, binary = true, fileExtensions) {
+ new MediaType("audio/" + subType)("audio", subType, compressible, binary = true, fileExtensions, Map.empty) {
override def isAudio = true
}
}
private def img(subType: String, compressible: Boolean, binary: Boolean, fileExtensions: String*) = register {
- new MediaType("image/" + subType)("image", subType, compressible, binary, fileExtensions) {
+ new MediaType("image/" + subType)("image", subType, compressible, binary, fileExtensions, Map.empty) {
override def isImage = true
}
}
private def msg(subType: String, fileExtensions: String*) = register {
- new MediaType("message/" + subType)("message", subType, compressible = true, binary = false, fileExtensions) {
+ new MediaType("message/" + subType)("message", subType, compressible = true, binary = false, fileExtensions, Map.empty) {
override def isMessage = true
}
}
private def txt(subType: String, fileExtensions: String*) = register {
- new MediaType("text/" + subType)("text", subType, compressible = true, binary = false, fileExtensions) {
+ new MediaType("text/" + subType)("text", subType, compressible = true, binary = false, fileExtensions, Map.empty) {
override def isText = true
}
}
private def vid(subType: String, fileExtensions: String*) = register {
- new MediaType("video/" + subType)("video", subType, compressible = false, binary = true, fileExtensions) {
+ new MediaType("video/" + subType)("video", subType, compressible = false, binary = true, fileExtensions, Map.empty) {
override def isVideo = true
}
}
- class MultipartMediaType(subType: String, val boundary: String) extends MediaType({
+ class MultipartMediaType(subType: String, val boundary: String,
+ parameters: Map[String, String] = Map.empty) extends MediaType({
if (boundary.isEmpty) "multipart/" + subType
else (new StringRendering ~~ "multipart/" ~~ subType ~~ "; boundary=" ~~# boundary).get
- })("multipart", subType, compressible = true, binary = false, fileExtensions = Nil) {
+ })("multipart", subType, compressible = true, binary = false, fileExtensions = Nil, parameters = parameters) {
override def isMultipart = true
override def matches(that: MediaType): Boolean = that match {
case x: MultipartMediaType x.subType == this.subType
@@ -285,19 +298,19 @@ object MediaTypes extends ObjectRegistry[(String, String), MediaType] {
val `message/delivery-status` = msg("delivery-status")
val `message/rfc822` = msg("rfc822", "eml", "mht", "mhtml", "mime")
- class `multipart/mixed` (boundary: String) extends MultipartMediaType("mixed", boundary)
- class `multipart/alternative`(boundary: String) extends MultipartMediaType("alternative", boundary)
- class `multipart/related` (boundary: String) extends MultipartMediaType("related", boundary)
- class `multipart/form-data` (boundary: String) extends MultipartMediaType("form-data", boundary)
- class `multipart/signed` (boundary: String) extends MultipartMediaType("signed", boundary)
- class `multipart/encrypted` (boundary: String) extends MultipartMediaType("encrypted", boundary)
+ class `multipart/mixed` (boundary: String, parameters: Map[String, String] = Map.empty) extends MultipartMediaType("mixed", boundary, parameters)
+ class `multipart/alternative`(boundary: String, parameters: Map[String, String] = Map.empty) extends MultipartMediaType("alternative", boundary, parameters)
+ class `multipart/related` (boundary: String, parameters: Map[String, String] = Map.empty) extends MultipartMediaType("related", boundary, parameters)
+ class `multipart/form-data` (boundary: String, parameters: Map[String, String] = Map.empty) extends MultipartMediaType("form-data", boundary, parameters)
+ class `multipart/signed` (boundary: String, parameters: Map[String, String] = Map.empty) extends MultipartMediaType("signed", boundary, parameters)
+ class `multipart/encrypted` (boundary: String, parameters: Map[String, String] = Map.empty) extends MultipartMediaType("encrypted", boundary, parameters)
- object `multipart/mixed` extends `multipart/mixed`("")
- object `multipart/alternative` extends `multipart/alternative`("")
- object `multipart/related` extends `multipart/related`("")
- object `multipart/form-data` extends `multipart/form-data`("")
- object `multipart/signed` extends `multipart/signed`("")
- object `multipart/encrypted` extends `multipart/encrypted`("")
+ object `multipart/mixed` extends `multipart/mixed`("", Map.empty)
+ object `multipart/alternative` extends `multipart/alternative`("", Map.empty)
+ object `multipart/related` extends `multipart/related`("", Map.empty)
+ object `multipart/form-data` extends `multipart/form-data`("", Map.empty)
+ object `multipart/signed` extends `multipart/signed`("", Map.empty)
+ object `multipart/encrypted` extends `multipart/encrypted`("", Map.empty)
val `text/asp` = txt("asp", "asp")
val `text/cache-manifest` = txt("cache-manifest", "manifest")
@@ -17,36 +17,41 @@
package spray.http
package parser
+import scala.annotation.tailrec
import org.parboiled.scala._
import BasicRules._
private[parser] trait AcceptHeader {
this: Parser with ProtocolParameterRules with CommonActions
def `*Accept` = rule(
- zeroOrMore(MediaRangeDecl ~ optional(AcceptParams), separator = ListSep) ~ EOI ~~> (HttpHeaders.Accept(_)))
+ zeroOrMore(MediaRangeDecl, separator = ListSep) ~ EOI ~~> (HttpHeaders.Accept(_)))
def MediaRangeDecl = rule {
- MediaRangeDef ~ zeroOrMore(";" ~ Parameter ~ DROP) // TODO: support parameters
+ MediaRangeDef ~ zeroOrMore(";" ~ Parameter) ~~> { (main, sub, params)
+ // we don't support q values yet and don't want them to cause creation of custom MediaTypes every time
+ // we see them, so we filter them out of the parameter list here
+ @tailrec def toNonQValueMap(remaining: List[(String, String)],
+ builder: StringMapBuilder = null): Map[String, String] =
+ remaining match {
+ case Nil if (builder eq null) Map.empty else builder.result()
+ case ("q", _) :: tail toNonQValueMap(tail, builder)
+ case kvp :: tail
+ val b = if (builder eq null) Map.newBuilder[String, String] else builder
+ b += kvp
+ toNonQValueMap(tail, b)
+ }
+
+ if (sub == "*") {
+ val mainLower = main.toLowerCase
+ val parameters = toNonQValueMap(params)
+ if (parameters.isEmpty) MediaRanges.getForKey(mainLower) getOrElse MediaRange.custom(mainLower)
+ else MediaRange.custom(mainLower, parameters)
+ } else getMediaType(main, sub, parameters = toNonQValueMap(params))
+ }
}
- def MediaRangeDef = rule(
- ("*/*" ~ push("*", "*") | Type ~ "/" ~ ("*" ~ push("*") | Subtype) | "*" ~ push("*", "*"))
- ~~> (getMediaRange(_, _)))
-
- def AcceptParams = rule {
- ";" ~ "q" ~ "=" ~ QValue ~ zeroOrMore(AcceptExtension) // TODO: support qvalues
- }
-
- def AcceptExtension = rule {
- ";" ~ Token ~ optional("=" ~ (Token | QuotedString)) ~ DROP2 // TODO: support extensions
+ def MediaRangeDef = rule {
+ "*/*" ~ push("*", "*") | Type ~ "/" ~ ("*" ~ push("*") | Subtype) | "*" ~ push("*", "*")
}
-
- // helpers
-
- def getMediaRange(mainType: String, subType: String): MediaRange =
- if (subType == "*") {
- val mainTypeLower = mainType.toLowerCase
- MediaRanges.getForKey(mainTypeLower) getOrElse MediaRanges.custom(mainTypeLower)
- } else getMediaType(mainType, subType)
}
@@ -22,19 +22,23 @@ import org.parboiled.errors.ParsingException
private[parser] trait CommonActions {
- def getMediaType(mainType: String, subType: String, boundary: String = ""): MediaType = {
+ type StringMapBuilder = scala.collection.mutable.Builder[(String, String), Map[String, String]]
+
+ def getMediaType(mainType: String, subType: String, boundary: String = "",
+ parameters: Map[String, String] = Map.empty): MediaType = {
mainType.toLowerCase match {
case "multipart" subType.toLowerCase match {
- case "mixed" new `multipart/mixed`(boundary)
- case "alternative" new `multipart/alternative`(boundary)
- case "related" new `multipart/related`(boundary)
- case "form-data" new `multipart/form-data`(boundary)
- case "signed" new `multipart/signed`(boundary)
- case "encrypted" new `multipart/encrypted`(boundary)
- case custom new MultipartMediaType(custom, boundary)
+ case "mixed" new `multipart/mixed`(boundary, parameters)
+ case "alternative" new `multipart/alternative`(boundary, parameters)
+ case "related" new `multipart/related`(boundary, parameters)
+ case "form-data" new `multipart/form-data`(boundary, parameters)
+ case "signed" new `multipart/signed`(boundary, parameters)
+ case "encrypted" new `multipart/encrypted`(boundary, parameters)
+ case custom new MultipartMediaType(custom, boundary, parameters)
}
case mainLower
- MediaTypes.getForKey((mainLower, subType.toLowerCase)) getOrElse MediaType.custom(mainType, subType)
+ val registered = if (parameters.isEmpty) MediaTypes.getForKey((mainLower, subType.toLowerCase)) else None
+ registered getOrElse MediaType.custom(mainType, subType, parameters = parameters)
}
}
@@ -18,6 +18,7 @@ package spray
package http
package parser
+import scala.annotation.tailrec
import org.parboiled.scala._
import HttpHeaders._
import ProtectedHeaderCreation.enable
@@ -30,13 +31,24 @@ private[parser] trait ContentTypeHeader {
}
lazy val ContentTypeHeaderValue = rule {
- MediaTypeDef ~ EOI ~~> (createContentType(_, _, _))
- }
+ MediaTypeDef ~ EOI ~~> { (main, sub, params)
+ @tailrec def processParams(remaining: List[(String, String)] = params,
+ boundary: String = "",
+ charset: Option[HttpCharset] = None,
+ builder: StringMapBuilder = null): (String, Option[HttpCharset], Map[String, String]) =
+ remaining match {
+ case Nil (boundary, charset, if (builder eq null) Map.empty else builder.result())
+ case ("boundary", value) :: tail processParams(tail, value, charset, builder)
+ case ("charset", value) :: tail processParams(tail, boundary, Some(getCharset(value)), builder)
+ case kvp :: tail
+ val b = if (builder eq null) Map.newBuilder[String, String] else builder
+ b += kvp
+ processParams(tail, boundary, charset, b)
+ }
- private def createContentType(mainType: String, subType: String, params: Map[String, String]) = {
- val mimeType = getMediaType(mainType, subType, params.getOrElse("boundary", ""))
- val charset = params.get("charset").map(getCharset)
- ContentType(mimeType, charset)
+ val (boundary, charset, parameters) = processParams()
+ val mediaType = getMediaType(main, sub, boundary, parameters)
+ ContentType(mediaType, charset)
+ }
}
-
}
@@ -108,8 +108,8 @@ private[parser] trait ProtocolParameterRules {
/* 3.7 Media Types */
- def MediaTypeDef: Rule3[String, String, Map[String, String]] = rule {
- Type ~ "/" ~ Subtype ~ zeroOrMore(";" ~ Parameter) ~~> (_.toMap)
+ def MediaTypeDef: Rule3[String, String, List[(String, String)]] = rule {
+ Type ~ "/" ~ Subtype ~ zeroOrMore(";" ~ Parameter)
}
def Type = rule { Token }
@@ -42,8 +42,8 @@ class HttpHeaderSpec extends Specification {
example(Accept(`text/html`, `image/gif`, `image/jpeg`, `*/*`, `*/*`), fix(_).replace("*,", "*/*,"))_ ^
"Accept: application/vnd.spray" !
example(Accept(`application/vnd.spray`))_ ^
- "Accept: */*, text/plain, custom/custom" !
- example(Accept(`*/*`, `text/plain`, MediaType.custom("custom/custom")))_ ^
+ "Accept: */*, text/*; foo=bar, custom/custom; bar=\"b>az\"" !
+ example(Accept(`*/*`, MediaRange.custom("text", Map("foo" -> "bar")), MediaType.custom("custom", "custom", parameters = Map("bar" -> "b>az"))))_ ^
p ^
"Accept-Charset: utf8; q=0.5, *" !
example(`Accept-Charset`(`UTF-8`, HttpCharsets.`*`), fix(_).replace("utf", "UTF-"))_ ^
@@ -100,8 +100,8 @@ class HttpHeaderSpec extends Specification {
example(`Content-Type`(`application/pdf`))_ ^
"Content-Type: text/plain; charset=utf8" !
example(`Content-Type`(ContentType(`text/plain`, `UTF-8`)), fix(_).replace("utf", "UTF-"))_ ^
- "Content-Type: text/xml; charset=windows-1252" !
- example(`Content-Type`(ContentType(`text/xml`, `windows-1252`)))_ ^
+ "Content-Type: text/xml; version=3; charset=windows-1252" !
+ example(`Content-Type`(ContentType(MediaType.custom("text", "xml", parameters = Map("version" -> "3")), `windows-1252`)))_ ^
"Content-Type: text/plain; charset=fancy-pants" !
errorExample(ErrorInfo("Illegal HTTP header 'Content-Type': Unsupported charset", "fancy-pants"))_ ^
"Content-Type: multipart/mixed; boundary=ABC123" ! example(`Content-Type`(ContentType(new `multipart/mixed`("ABC123"))))_ ^
@@ -177,7 +177,7 @@ class HttpHeaderSpec extends Specification {
def example(expected: HttpHeader, fix: String ⇒ String = fix)(line: String) = {
val Array(name, value) = line.split(": ", 2)
- (HttpParser.parseHeader(RawHeader(name, value)) === Right(expected)) and (expected.toString === fix(line))
+ HttpParser.parseHeader(RawHeader(name, value)) === Right(expected) and expected.toString === fix(line)
}
def fix(line: String) = line.replaceAll("""\s*;\s*q=\d?(\.\d)?""", "").replaceAll("""\s\s+""", " ")

0 comments on commit d8a9ee4

Please sign in to comment.