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

KTOR-7722 content negotiation client accept header control #4462

Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ public final class io/ktor/client/plugins/contentnegotiation/ContentConverterExc
public final class io/ktor/client/plugins/contentnegotiation/ContentNegotiationConfig : io/ktor/serialization/Configuration {
public fun <init> ()V
public final fun clearIgnoredTypes ()V
public final fun getDefaultAcceptHeaderQValue ()Ljava/lang/Double;
public final fun ignoreType (Lkotlin/reflect/KClass;)V
public final fun register (Lio/ktor/http/ContentType;Lio/ktor/serialization/ContentConverter;Lio/ktor/http/ContentTypeMatcher;Lkotlin/jvm/functions/Function1;)V
public fun register (Lio/ktor/http/ContentType;Lio/ktor/serialization/ContentConverter;Lkotlin/jvm/functions/Function1;)V
public final fun removeIgnoredType (Lkotlin/reflect/KClass;)V
public final fun setDefaultAcceptHeaderQValue (Ljava/lang/Double;)V
}

public final class io/ktor/client/plugins/contentnegotiation/ContentNegotiationKt {
public static final fun exclude (Lio/ktor/client/request/HttpRequestBuilder;[Lio/ktor/http/ContentType;)V
public static final fun getContentNegotiation ()Lio/ktor/client/plugins/api/ClientPlugin;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ final class io.ktor.client.plugins.contentnegotiation/ContentConverterException
final class io.ktor.client.plugins.contentnegotiation/ContentNegotiationConfig : io.ktor.serialization/Configuration { // io.ktor.client.plugins.contentnegotiation/ContentNegotiationConfig|null[0]
constructor <init>() // io.ktor.client.plugins.contentnegotiation/ContentNegotiationConfig.<init>|<init>(){}[0]

final var defaultAcceptHeaderQValue // io.ktor.client.plugins.contentnegotiation/ContentNegotiationConfig.defaultAcceptHeaderQValue|{}defaultAcceptHeaderQValue[0]
final fun <get-defaultAcceptHeaderQValue>(): kotlin/Double? // io.ktor.client.plugins.contentnegotiation/ContentNegotiationConfig.defaultAcceptHeaderQValue.<get-defaultAcceptHeaderQValue>|<get-defaultAcceptHeaderQValue>(){}[0]
final fun <set-defaultAcceptHeaderQValue>(kotlin/Double?) // io.ktor.client.plugins.contentnegotiation/ContentNegotiationConfig.defaultAcceptHeaderQValue.<set-defaultAcceptHeaderQValue>|<set-defaultAcceptHeaderQValue>(kotlin.Double?){}[0]

final fun <#A1: io.ktor.serialization/ContentConverter> register(io.ktor.http/ContentType, #A1, io.ktor.http/ContentTypeMatcher, kotlin/Function1<#A1, kotlin/Unit>) // io.ktor.client.plugins.contentnegotiation/ContentNegotiationConfig.register|register(io.ktor.http.ContentType;0:0;io.ktor.http.ContentTypeMatcher;kotlin.Function1<0:0,kotlin.Unit>){0§<io.ktor.serialization.ContentConverter>}[0]
final fun <#A1: io.ktor.serialization/ContentConverter> register(io.ktor.http/ContentType, #A1, kotlin/Function1<#A1, kotlin/Unit>) // io.ktor.client.plugins.contentnegotiation/ContentNegotiationConfig.register|register(io.ktor.http.ContentType;0:0;kotlin.Function1<0:0,kotlin.Unit>){0§<io.ktor.serialization.ContentConverter>}[0]
final fun clearIgnoredTypes() // io.ktor.client.plugins.contentnegotiation/ContentNegotiationConfig.clearIgnoredTypes|clearIgnoredTypes(){}[0]
Expand All @@ -28,3 +32,5 @@ final object io.ktor.client.plugins.contentnegotiation/JsonContentTypeMatcher :

final val io.ktor.client.plugins.contentnegotiation/ContentNegotiation // io.ktor.client.plugins.contentnegotiation/ContentNegotiation|{}ContentNegotiation[0]
final fun <get-ContentNegotiation>(): io.ktor.client.plugins.api/ClientPlugin<io.ktor.client.plugins.contentnegotiation/ContentNegotiationConfig> // io.ktor.client.plugins.contentnegotiation/ContentNegotiation.<get-ContentNegotiation>|<get-ContentNegotiation>(){}[0]

final fun (io.ktor.client.request/HttpRequestBuilder).io.ktor.client.plugins.contentnegotiation/exclude(kotlin/Array<out io.ktor.http/ContentType>...) // io.ktor.client.plugins.contentnegotiation/exclude|[email protected](kotlin.Array<out|io.ktor.http.ContentType>...){}[0]
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import io.ktor.client.utils.*
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.serialization.*
import io.ktor.util.AttributeKey
import io.ktor.util.logging.*
import io.ktor.util.reflect.*
import io.ktor.utils.io.*
Expand All @@ -29,6 +30,12 @@ internal val DefaultCommonIgnoredTypes: Set<KClass<*>> = setOf(

internal expect val DefaultIgnoredTypes: Set<KClass<*>>

/**
* The content types that are excluded from the `Accept` header for this specific request. Use the
* [exclude] `HttpRequestBuilder` extension to set this attribute on a request.
*/
internal val ExcludedContentTypes: AttributeKey<List<ContentType>> = AttributeKey("ExcludedContentTypesAttr")

/**
* A [ContentNegotiation] configuration that is used during installation.
*/
Expand All @@ -46,6 +53,12 @@ public class ContentNegotiationConfig : Configuration {

internal val registrations = mutableListOf<ConverterRegistration>()

/**
* By default, `Accept` headers for registered content types will have no q value (implicit 1.0). Set this to
* change that behavior. This is useful to override the preferred `Accept` content types on a per-request basis.
*/
public var defaultAcceptHeaderQValue: Double? = null

/**
* Registers a [contentType] to a specified [converter] with an optional [configuration] script for a converter.
*/
Expand All @@ -54,8 +67,8 @@ public class ContentNegotiationConfig : Configuration {
converter: T,
configuration: T.() -> Unit
) {
val matcher = when (contentType) {
ContentType.Application.Json -> JsonContentTypeMatcher
val matcher = when {
contentType.match(ContentType.Application.Json) -> JsonContentTypeMatcher
else -> defaultMatcher(contentType)
}
register(contentType, converter, matcher, configuration)
Expand Down Expand Up @@ -140,11 +153,25 @@ public val ContentNegotiation: ClientPlugin<ContentNegotiationConfig> = createCl
val ignoredTypes: Set<KClass<*>> = pluginConfig.ignoredTypes

suspend fun convertRequest(request: HttpRequestBuilder, body: Any): OutgoingContent? {
registrations.forEach {
LOGGER.trace("Adding Accept=${it.contentTypeToSend.contentType} header for ${request.url}")
val requestRegistrations = if (request.attributes.contains(ExcludedContentTypes)) {
val excluded = request.attributes[ExcludedContentTypes]
registrations.filter { registration -> excluded.none { registration.contentTypeToSend.match(it) } }
} else {
registrations
}

if (request.headers.contains(HttpHeaders.Accept, it.contentTypeToSend.toString())) return@forEach
request.accept(it.contentTypeToSend)
val acceptHeaders = request.headers.getAll(HttpHeaders.Accept).orEmpty()
requestRegistrations.forEach {
if (acceptHeaders.none { h -> ContentType.parse(h).match(it.contentTypeToSend) }) {
// automatically added headers get a lower content type priority, so user-specified accept headers
// with higher q or implicit q=1 will take precedence
val contentTypeToSend = when (val qValue = pluginConfig.defaultAcceptHeaderQValue) {
null -> it.contentTypeToSend
else -> it.contentTypeToSend.withParameter("q", qValue.toString())
}
LOGGER.trace("Adding Accept=$contentTypeToSend header for ${request.url}")
request.accept(contentTypeToSend)
}
}

if (body is OutgoingContent || ignoredTypes.any { it.isInstance(body) }) {
Expand Down Expand Up @@ -251,3 +278,14 @@ public val ContentNegotiation: ClientPlugin<ContentNegotiationConfig> = createCl
}

public class ContentConverterException(message: String) : Exception(message)

/**
* Excludes the given [ContentType] from the list of types that will be sent in the `Accept` header by
* the [ContentNegotiation] plugin. Can be used to not accept specific types for particular requests.
* This can be called multiple times to exclude multiple content types, or multiple content types can
* be passed in a single call.
*/
public fun HttpRequestBuilder.exclude(vararg contentType: ContentType) {
val excludedContentTypes = attributes.getOrNull(ExcludedContentTypes).orEmpty()
attributes.put(ExcludedContentTypes, excludedContentTypes + contentType)
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,166 @@ class ContentNegotiationTests {
}
}

@Test
fun addAcceptHeadersWithSingleExclusion() {
testWithEngine(MockEngine) {
val registeredTypesToSend = listOf(
ContentType("testing", "a"),
ContentType("testing", "b"),
ContentType("testing", "c")
)

setupWithContentNegotiation {
for (typeToSend in registeredTypesToSend) {
register(typeToSend, TestContentConverter())
}
}

test { client ->
client.get("https://test.com/") {
exclude(ContentType("testing", "b"))
}.apply {
val sentTypes = assertNotNull(call.request.headers.getAll(HttpHeaders.Accept))
.map { ContentType.parse(it) }

// Order NOT tested
for (typeToSend in registeredTypesToSend.filter { it.contentSubtype != "b" }) {
assertContains(sentTypes, typeToSend)
}
assertNull(sentTypes.firstOrNull { it.contentSubtype == "b" })
}
}
}
}

@Test
fun addAcceptHeadersWithExclusionMatchingParameterizedType() {
testWithEngine(MockEngine) {
val registeredTypesToSend = listOf(
ContentType("testing", "a").withParameter("foo", "bar"),
ContentType("testing", "b").withParameter("foo", "bar"),
ContentType("testing", "c").withParameter("foo", "bar")
)

setupWithContentNegotiation {
for (typeToSend in registeredTypesToSend) {
register(typeToSend, TestContentConverter())
}
}

test { client ->
client.get("https://test.com/") {
exclude(ContentType("testing", "b"))
}.apply {
val sentTypes = assertNotNull(call.request.headers.getAll(HttpHeaders.Accept))
.map { ContentType.parse(it) }

// Order NOT tested
for (typeToSend in registeredTypesToSend.filter { it.contentSubtype != "b" }) {
assertContains(sentTypes, typeToSend)
}
assertNull(sentTypes.firstOrNull { it.contentSubtype == "b" })
}
}
}
}

@Test
fun addAcceptHeadersWithMultipleExclusions() {
testWithEngine(MockEngine) {
val registeredTypesToSend = listOf(
ContentType("testing", "a"),
ContentType("testing", "b"),
ContentType("testing", "c")
)

setupWithContentNegotiation {
for (typeToSend in registeredTypesToSend) {
register(typeToSend, TestContentConverter())
}
}

test { client ->
client.get("https://test.com/") {
exclude(ContentType("testing", "b"))
exclude(ContentType("testing", "c"))
}.apply {
val sentTypes = assertNotNull(call.request.headers.getAll(HttpHeaders.Accept))
.map { ContentType.parse(it) }

// Order NOT tested
assertTrue(sentTypes.size == 1)
assertContains(sentTypes, ContentType("testing", "a"))
}
}

test { client ->
client.get("https://test.com/") {
exclude(ContentType("testing", "b"), ContentType("testing", "c"))
}.apply {
val sentTypes = assertNotNull(call.request.headers.getAll(HttpHeaders.Accept))
.map { ContentType.parse(it) }

// Order NOT tested
assertTrue(sentTypes.size == 1)
assertContains(sentTypes, ContentType("testing", "a"))
}
}
}
}

@Test
fun addAcceptHeadersWithDefaultQValue() {
testWithEngine(MockEngine) {
val registeredTypesToSend = listOf(
ContentType("testing", "a"),
ContentType("testing", "b"),
ContentType("testing", "c")
)

setupWithContentNegotiation {
for (typeToSend in registeredTypesToSend) {
register(typeToSend, TestContentConverter())
defaultAcceptHeaderQValue = 0.8
}
}

test { client ->
client.get("https://test.com/").apply {
val sentTypes = assertNotNull(call.request.headers.getAll(HttpHeaders.Accept))
.map { ContentType.parse(it) }

// Order NOT tested
for (typeToSend in registeredTypesToSend) {
assertContains(sentTypes, typeToSend.withParameter("q", "0.8"))
}
}
}
}
}

@Test
fun skipAddAcceptHeadersWithMatchingContentType() {
testWithEngine(MockEngine) {
setupWithContentNegotiation {
register(ContentType("testing", "a"), TestContentConverter())
}

test { client ->
client.get("https://test.com/") {
// our explicitly specified lower q-value should take precedence
accept(ContentType("testing", "a", listOf(HeaderValueParam("q", "0.5"))))
}.apply {
val sentTypes = assertNotNull(call.request.headers.getAll(HttpHeaders.Accept))
.map { ContentType.parse(it) }

assertContains(sentTypes, ContentType("testing", "a", listOf(HeaderValueParam("q", "0.5"))))
assertEquals(1, sentTypes.size)
}
}
}
}

@Test
fun testKeepsContentType() {
testWithEngine(MockEngine) {
Expand Down
8 changes: 8 additions & 0 deletions ktor-http/common/src/io/ktor/http/ContentTypes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ public class ContentType private constructor(

/**
* Checks if `this` type matches a [pattern] type taking into account placeholder symbols `*` and parameters.
* The `this` type must be a more specific type than the [pattern] type. In other words:
*
* ```kotlin
* ContentType("a", "b").match(ContentType("a", "b").withParameter("foo", "bar")) === false
* ContentType("a", "b").withParameter("foo", "bar").match(ContentType("a", "b")) === true
* ContentType("a", "*").match(ContentType("a", "b")) === false
* ContentType("a", "b").match(ContentType("a", "*")) === true
* ```
*/
public fun match(pattern: ContentType): Boolean {
if (pattern.contentType != "*" && !pattern.contentType.equals(contentType, ignoreCase = true)) {
Expand Down