Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add otherParameters to ContentTypeRange, update MediaType#matches to take into account otherParameters. #306

Merged
merged 4 commits into from
Oct 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 52 additions & 17 deletions core/src/main/scala/sttp/model/ContentTypeRange.scala
Original file line number Diff line number Diff line change
@@ -1,14 +1,40 @@
package sttp.model

import sttp.model.ContentTypeRange.Wildcard
import sttp.model.ContentTypeRange.{Wildcard, EmptyParameters}

case class ContentTypeRange(mainType: String, subType: String, charset: String, otherParameters: Map[String, String]) {
kamilkloch marked this conversation as resolved.
Show resolved Hide resolved
// required for binary compatibility
def this(mainType: String, subType: String, charset: String) = this(mainType, subType, charset, EmptyParameters)

def copy(
mainType: String = this.mainType,
subType: String = this.subType,
charset: String = this.charset,
otherParameters: Map[String, String] = this.otherParameters
): ContentTypeRange =
ContentTypeRange(mainType, subType, charset, otherParameters)

// required for binary compatibility
def copy(mainType: String, subType: String, charset: String): ContentTypeRange =
ContentTypeRange(mainType, subType, charset, this.otherParameters)

case class ContentTypeRange(mainType: String, subType: String, charset: String) {
def anyCharset: ContentTypeRange = copy(charset = Wildcard)

def anySubType: ContentTypeRange = copy(subType = Wildcard)

override def toString: String = s"$mainType/$subType" + (if (charset == Wildcard) "" else s"; charset=$charset")
override def toString: String = {
val sb = new java.lang.StringBuilder(32) // "application/json; charset=utf-8".length == 31 ;)
sb.append(mainType).append('/').append(subType)
if (charset != Wildcard) sb.append("; charset=").append(charset)
otherParameters.foreach { case (p, v) =>
if (p != "charset") sb.append("; ").append(p).append('=').append(v)
else ()
}
sb.toString
}

override def hashCode(): Int = toString.toLowerCase.hashCode

override def equals(that: Any): Boolean =
that match {
case t: AnyRef if this.eq(t) => true
Expand All @@ -18,19 +44,28 @@ case class ContentTypeRange(mainType: String, subType: String, charset: String)
}

object ContentTypeRange {
// required for binary compatibility
def apply(mainType: String, subType: String, charset: String): ContentTypeRange =
new ContentTypeRange(mainType, subType, charset, EmptyParameters)

val Wildcard = "*"
val AnyRange: ContentTypeRange = ContentTypeRange(Wildcard, Wildcard, Wildcard)
val AnyApplication: ContentTypeRange = ContentTypeRange("application", Wildcard, Wildcard)
val AnyAudio: ContentTypeRange = ContentTypeRange("audio", Wildcard, Wildcard)
val AnyImage: ContentTypeRange = ContentTypeRange("image", Wildcard, Wildcard)
val AnyMessage: ContentTypeRange = ContentTypeRange("message", Wildcard, Wildcard)
val AnyMultipart: ContentTypeRange = ContentTypeRange("multipart", Wildcard, Wildcard)
val AnyText: ContentTypeRange = ContentTypeRange("text", Wildcard, Wildcard)
val AnyVideo: ContentTypeRange = ContentTypeRange("video", Wildcard, Wildcard)
val AnyFont: ContentTypeRange = ContentTypeRange("font", Wildcard, Wildcard)
val AnyExample: ContentTypeRange = ContentTypeRange("example", Wildcard, Wildcard)
val AnyModel: ContentTypeRange = ContentTypeRange("model", Wildcard, Wildcard)

def exact(mt: MediaType): ContentTypeRange = ContentTypeRange(mt.mainType, mt.subType, mt.charset.getOrElse(Wildcard))
def exactNoCharset(mt: MediaType): ContentTypeRange = ContentTypeRange(mt.mainType, mt.subType, Wildcard)
val EmptyParameters: Map[String, String] = Map.empty

val AnyRange: ContentTypeRange = ContentTypeRange(Wildcard, Wildcard, Wildcard, EmptyParameters)
val AnyApplication: ContentTypeRange = ContentTypeRange("application", Wildcard, Wildcard, EmptyParameters)
val AnyAudio: ContentTypeRange = ContentTypeRange("audio", Wildcard, Wildcard, EmptyParameters)
val AnyImage: ContentTypeRange = ContentTypeRange("image", Wildcard, Wildcard, EmptyParameters)
val AnyMessage: ContentTypeRange = ContentTypeRange("message", Wildcard, Wildcard, EmptyParameters)
val AnyMultipart: ContentTypeRange = ContentTypeRange("multipart", Wildcard, Wildcard, EmptyParameters)
val AnyText: ContentTypeRange = ContentTypeRange("text", Wildcard, Wildcard, EmptyParameters)
val AnyVideo: ContentTypeRange = ContentTypeRange("video", Wildcard, Wildcard, EmptyParameters)
val AnyFont: ContentTypeRange = ContentTypeRange("font", Wildcard, Wildcard, EmptyParameters)
val AnyExample: ContentTypeRange = ContentTypeRange("example", Wildcard, Wildcard, EmptyParameters)
val AnyModel: ContentTypeRange = ContentTypeRange("model", Wildcard, Wildcard, EmptyParameters)

def exact(mt: MediaType): ContentTypeRange =
ContentTypeRange(mt.mainType, mt.subType, mt.charset.getOrElse(Wildcard), mt.otherParameters)

def exactNoCharset(mt: MediaType): ContentTypeRange =
ContentTypeRange(mt.mainType, mt.subType, Wildcard, mt.otherParameters)
}
28 changes: 24 additions & 4 deletions core/src/main/scala/sttp/model/MediaType.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package sttp.model

import sttp.model.ContentTypeRange.Wildcard
import sttp.model.internal.Patterns
import sttp.model.internal.Rfc2616._
import sttp.model.internal.Validate._
import sttp.model.internal.Patterns

import java.nio.charset.Charset

Expand All @@ -14,26 +14,43 @@ case class MediaType(
otherParameters: Map[String, String] = Map.empty
) {
def charset(c: Charset): MediaType = charset(c.name())

def charset(c: String): MediaType = copy(charset = Some(c))

def noCharset: MediaType = copy(charset = None)

// #2994 from tapir: when the media type doesn't define a charset, it shouldn't be taken into account in the matching logic
def matches(range: ContentTypeRange): Boolean =
range != null &&
(range.mainType == Wildcard ||
mainType.equalsIgnoreCase(range.mainType) &&
(range.mainType == Wildcard || mainType.equalsIgnoreCase(range.mainType) &&
(range.subType == Wildcard || subType.equalsIgnoreCase(range.subType))) &&
(range.charset == Wildcard || charset.forall(_.equalsIgnoreCase(range.charset)))
(range.charset == Wildcard || charset.forall(_.equalsIgnoreCase(range.charset))) &&
(otherParameters.isEmpty || {
// `otherParameters` needs to be fully contained within `range.otherParameters` (ignoring case)
val rangeOtherParametersLowerCased = range.otherParameters.map(x => (x._1.toLowerCase, x._2.toLowerCase))
otherParametersLowerCased.forall { case (k, v) =>
rangeOtherParametersLowerCased.get(k).contains(v)
}
})

def isApplication: Boolean = mainType.equalsIgnoreCase("application")

def isAudio: Boolean = mainType.equalsIgnoreCase("audio")

def isImage: Boolean = mainType.equalsIgnoreCase("image")

def isMessage: Boolean = mainType.equalsIgnoreCase("message")

def isMultipart: Boolean = mainType.equalsIgnoreCase("multipart")

def isText: Boolean = mainType.equalsIgnoreCase("text")

def isVideo: Boolean = mainType.equalsIgnoreCase("video")

def isFont: Boolean = mainType.equalsIgnoreCase("font")

def isExample: Boolean = mainType.equalsIgnoreCase("example")

def isModel: Boolean = mainType.equalsIgnoreCase("model")

override def toString: String = {
Expand Down Expand Up @@ -61,6 +78,9 @@ case class MediaType(

def equalsIgnoreParameters(that: MediaType): Boolean =
mainType.equalsIgnoreCase(that.mainType) && subType.equalsIgnoreCase(that.subType)

private val otherParametersLowerCased: Map[String, String] =
otherParameters.map(x => (x._1.toLowerCase, x._2.toLowerCase))
}

/** For a description of the behavior of `apply`, `parse`, `safeApply` and `unsafeApply` methods, see [[sttp.model]]. */
Expand Down
16 changes: 10 additions & 6 deletions core/src/main/scala/sttp/model/headers/Accepts.scala
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,25 @@ object Accepts {
): Seq[ContentTypeRange] = {
(mediaTypes, charsets) match {
case (Nil, Nil) => AnyRange :: Nil
case (Nil, (ch, _) :: Nil) => ContentTypeRange(Wildcard, Wildcard, ch) :: Nil
case ((mt, _) :: Nil, Nil) => ContentTypeRange(mt.mainType, mt.subType, Wildcard) :: Nil
case (Nil, (ch, _) :: Nil) => ContentTypeRange(Wildcard, Wildcard, ch, EmptyParameters) :: Nil
case ((mt, _) :: Nil, Nil) => ContentTypeRange(mt.mainType, mt.subType, Wildcard, mt.otherParameters) :: Nil
case (Nil, chs) =>
chs.sortBy({ case (_, q) => -q }).map { case (ch, _) => ContentTypeRange(Wildcard, Wildcard, ch) }
chs.sortBy({ case (_, q) => -q }).map { case (ch, _) =>
ContentTypeRange(Wildcard, Wildcard, ch, EmptyParameters)
}
case (mts, Nil) =>
mts.sortBy({ case (_, q) => -q }).map { case (mt, _) => ContentTypeRange(mt.mainType, mt.subType, Wildcard) }
mts.sortBy({ case (_, q) => -q }).map { case (mt, _) =>
ContentTypeRange(mt.mainType, mt.subType, Wildcard, mt.otherParameters)
}
case (mts, chs) =>
mts.flatMap { case (mt, mtQ) =>
// if Accept-Charset is defined then any other charset specified in Accept header in not acceptable
chs.map { case (ch, chQ) => (mt, ch) -> math.min(mtQ, chQ) }
} match {
case ((mt, ch), _) :: Nil => ContentTypeRange(mt.mainType, mt.subType, ch) :: Nil
case ((mt, ch), _) :: Nil => ContentTypeRange(mt.mainType, mt.subType, ch, mt.otherParameters) :: Nil
case merged =>
merged.sortBy({ case (_, q) => -q }).map { case ((mt, ch), _) =>
ContentTypeRange(mt.mainType, mt.subType, ch)
ContentTypeRange(mt.mainType, mt.subType, ch, mt.otherParameters)
}
}
}
Expand Down
57 changes: 31 additions & 26 deletions core/src/test/scala/sttp/model/MediaTypeTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package sttp.model
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.prop.TableDrivenPropertyChecks
import sttp.model.ContentTypeRange.AnyRange
import sttp.model.ContentTypeRange.{AnyRange, EmptyParameters}

import scala.collection.immutable.Seq

Expand Down Expand Up @@ -62,19 +62,24 @@ class MediaTypeTests extends AnyFlatSpec with Matchers with TableDrivenPropertyC
private val matchCases = Table(
("media type", "content type range", "matches"),
(MediaType.ApplicationJson, AnyRange, true),
(MediaType("*", "html"), ContentTypeRange("*", "json", "*"), true),
(MediaType("text", "*"), ContentTypeRange("text", "*", "*"), true),
(MediaType.ApplicationJson, ContentTypeRange("application", "*", "*"), true),
(MediaType.ApplicationJson, ContentTypeRange("application", "json", "*"), true),
(MediaType("*", "html"), ContentTypeRange("*", "json", "*", EmptyParameters), true),
(MediaType("text", "*"), ContentTypeRange("text", "*", "*", EmptyParameters), true),
(MediaType.ApplicationJson, ContentTypeRange("application", "*", "*", EmptyParameters), true),
(MediaType.ApplicationJson, ContentTypeRange("application", "json", "*", EmptyParameters), true),
(MediaType.ApplicationJson, ContentTypeRange("application", "json", "*", Map("a" -> "1")), true),
(MediaType.ApplicationJson.copy(otherParameters = Map("a" -> "truE")), ContentTypeRange("application", "json", "*", Map("A" -> "TrUe")), true),
(MediaType.ApplicationJson.copy(otherParameters = Map("a" -> "1")), ContentTypeRange("application", "json", "*", Map("A" -> "1", "b" -> "2")), true),
(MediaType.ApplicationJson.copy(otherParameters = Map("a" -> "1", "b" -> "2")), ContentTypeRange("application", "json", "*", Map("A" -> "1")), false),
(MediaType.ApplicationJson.copy(otherParameters = Map("a" -> "1")), ContentTypeRange("application", "json", "*", Map("b" -> "2")), false),
//
(MediaType.ApplicationJson.charset("utf-8"), ContentTypeRange("*", "*", "utf-16"), false),
(MediaType("*", "html").charset("utf-8"), ContentTypeRange("*", "json", "utf-16"), false),
(MediaType("text", "*").charset("utf-8"), ContentTypeRange("text", "*", "utf-16"), false),
(MediaType.ApplicationJson.charset("utf-8"), ContentTypeRange("application", "*", "utf-16"), false),
(MediaType.ApplicationJson.charset("utf-8"), ContentTypeRange("application", "json", "utf-16"), false),
(MediaType.ApplicationJson.charset("utf-8"), ContentTypeRange("*", "*", "utf-16", EmptyParameters), false),
(MediaType("*", "html").charset("utf-8"), ContentTypeRange("*", "json", "utf-16", EmptyParameters), false),
(MediaType("text", "*").charset("utf-8"), ContentTypeRange("text", "*", "utf-16", EmptyParameters), false),
(MediaType.ApplicationJson.charset("utf-8"), ContentTypeRange("application", "*", "utf-16", EmptyParameters), false),
(MediaType.ApplicationJson.charset("utf-8"), ContentTypeRange("application", "json", "utf-16", EmptyParameters), false),
//
(MediaType.ApplicationJson.charset("utf-8"), ContentTypeRange("application", "json", "*"), true),
(MediaType.ApplicationOctetStream, ContentTypeRange("*", "*", "utf-8"), true)
(MediaType.ApplicationJson.charset("utf-8"), ContentTypeRange("application", "json", "*", EmptyParameters), true),
(MediaType.ApplicationOctetStream, ContentTypeRange("*", "*", "utf-8", EmptyParameters), true)
)

forAll(matchCases) { (mt, range, matches) =>
Expand All @@ -86,48 +91,48 @@ class MediaTypeTests extends AnyFlatSpec with Matchers with TableDrivenPropertyC
private val bestMatchCases = Table(
("ranges", "best match"),
(Seq(AnyRange), Some(MediaType.ApplicationJson.charset("utf-8"))),
(Seq(ContentTypeRange("application", "json", "*")), Some(MediaType.ApplicationJson.charset("utf-8"))),
(Seq(ContentTypeRange("application", "xml", "*")), Some(MediaType.ApplicationXml.charset("utf-8"))),
(Seq(ContentTypeRange("application", "json", "*", EmptyParameters)), Some(MediaType.ApplicationJson.charset("utf-8"))),
(Seq(ContentTypeRange("application", "xml", "*", EmptyParameters)), Some(MediaType.ApplicationXml.charset("utf-8"))),
(
Seq(
ContentTypeRange("application", "xml", "*"),
ContentTypeRange("application", "json", "*")
ContentTypeRange("application", "xml", "*", EmptyParameters),
ContentTypeRange("application", "json", "*", EmptyParameters)
),
Some(MediaType.ApplicationXml.charset("utf-8"))
),
(
Seq(
ContentTypeRange("application", "json", "*"),
ContentTypeRange("application", "xml", "*")
ContentTypeRange("application", "json", "*", EmptyParameters),
ContentTypeRange("application", "xml", "*", EmptyParameters)
),
Some(MediaType.ApplicationJson.charset("utf-8"))
),
(
Seq(
ContentTypeRange("application", "xml", "*"),
ContentTypeRange("application", "json", "*"),
ContentTypeRange("text", "html", "*")
ContentTypeRange("application", "xml", "*", EmptyParameters),
ContentTypeRange("application", "json", "*", EmptyParameters),
ContentTypeRange("text", "html", "*", EmptyParameters)
),
Some(MediaType.ApplicationXml.charset("utf-8"))
),
(
Seq(ContentTypeRange("text", "*", "*"), ContentTypeRange("application", "*", "*")),
Seq(ContentTypeRange("text", "*", "*", EmptyParameters), ContentTypeRange("application", "*", "*", EmptyParameters)),
Some(MediaType.TextHtml.charset("utf-8"))
),
(
Seq(ContentTypeRange("*", "*", "iso-8859-1")),
Seq(ContentTypeRange("*", "*", "iso-8859-1", EmptyParameters)),
Some(MediaType.TextHtml.charset("iso-8859-1"))
),
(
Seq(ContentTypeRange("text", "html", "iso-8859-1"), ContentTypeRange("text", "html", "utf-8")),
Seq(ContentTypeRange("text", "html", "iso-8859-1", EmptyParameters), ContentTypeRange("text", "html", "utf-8", EmptyParameters)),
Some(MediaType.TextHtml.charset("iso-8859-1"))
),
(
Seq(ContentTypeRange("text", "csv", "*")),
Seq(ContentTypeRange("text", "csv", "*", EmptyParameters)),
None
),
(
Seq(ContentTypeRange("text", "html", "utf-16")),
Seq(ContentTypeRange("text", "html", "utf-16", EmptyParameters)),
None
)
)
Expand Down
Loading
Loading