Skip to content

Commit a0ed6bc

Browse files
[fix] Correctly identify non-UTF-8 responses.
Close #162
1 parent c87f270 commit a0ed6bc

4 files changed

Lines changed: 196 additions & 2 deletions

File tree

shared/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ kotlin {
197197
"kotlin.experimental.ExperimentalNativeApi",
198198
"kotlin.ExperimentalStdlibApi",
199199
"com.google.accompanist.permissions.ExperimentalPermissionsApi",
200+
"io.ktor.utils.io.InternalAPI",
200201
"coil3.annotation.ExperimentalCoilApi"
201202
)
202203
}

shared/src/commonMain/kotlin/com/skyd/podaura/di/IOModule.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import com.skyd.podaura.ext.getOrDefault
44
import com.skyd.podaura.model.preference.behavior.LoadNetImageOnWifiOnlyPreference
55
import com.skyd.podaura.model.preference.dataStore
66
import com.skyd.podaura.util.isFreeNetworkAvailable
7+
import com.skyd.podaura.util.ktor.ContentNegotiation
78
import io.ktor.client.HttpClient
89
import io.ktor.client.HttpClientConfig
910
import io.ktor.client.plugins.api.createClientPlugin
10-
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
1111
import io.ktor.client.plugins.logging.LogLevel
1212
import io.ktor.client.plugins.logging.Logger
1313
import io.ktor.client.plugins.logging.Logging

shared/src/commonMain/kotlin/com/skyd/podaura/model/repository/feed/RssHelper.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import com.skyd.podaura.model.repository.feed.convert.rssUpdateFeedWithArticleBe
1111
import com.skyd.podaura.model.repository.feed.rssparser.BaseXml
1212
import com.skyd.podaura.model.repository.feed.rssparser.atom.Feed
1313
import com.skyd.podaura.model.repository.feed.rssparser.rss.Rss
14+
import com.skyd.podaura.util.ktor.ContentNegotiation
1415
import io.ktor.client.HttpClient
1516
import io.ktor.client.HttpClientConfig
1617
import io.ktor.client.call.body
1718
import io.ktor.client.plugins.api.createClientPlugin
18-
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
1919
import io.ktor.client.request.get
2020
import io.ktor.http.ContentType
2121
import io.ktor.serialization.kotlinx.xml.xml
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
@file:Suppress("INVISIBLE_REFERENCE")
2+
3+
/*
4+
* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
5+
*/
6+
7+
package com.skyd.podaura.util.ktor
8+
9+
import io.ktor.client.plugins.api.ClientPlugin
10+
import io.ktor.client.plugins.api.createClientPlugin
11+
import io.ktor.client.plugins.contentnegotiation.ContentConverterException
12+
import io.ktor.client.plugins.contentnegotiation.ContentNegotiationConfig
13+
import io.ktor.client.plugins.contentnegotiation.ExcludedContentTypes
14+
import io.ktor.client.request.HttpRequestBuilder
15+
import io.ktor.client.request.accept
16+
import io.ktor.client.statement.request
17+
import io.ktor.client.utils.EmptyContent
18+
import io.ktor.http.ContentType
19+
import io.ktor.http.HeaderValueParam
20+
import io.ktor.http.HttpHeaders
21+
import io.ktor.http.Url
22+
import io.ktor.http.charset
23+
import io.ktor.http.content.NullBody
24+
import io.ktor.http.content.OutgoingContent
25+
import io.ktor.http.contentType
26+
import io.ktor.serialization.deserialize
27+
import io.ktor.util.logging.KtorSimpleLogger
28+
import io.ktor.util.reflect.TypeInfo
29+
import io.ktor.utils.io.ByteReadChannel
30+
import io.ktor.utils.io.charsets.Charset
31+
import io.ktor.utils.io.charsets.Charsets
32+
import io.ktor.utils.io.charsets.forName
33+
import io.ktor.utils.io.charsets.isSupported
34+
import kotlin.reflect.KClass
35+
36+
private val LOGGER = KtorSimpleLogger("io.ktor.client.plugins.contentnegotiation.ContentNegotiation")
37+
38+
/**
39+
* A plugin that serves two primary purposes:
40+
* - Negotiating media types between the client and server. For this, it uses the `Accept` and `Content-Type` headers.
41+
* - Serializing/deserializing the content in a specific format when sending requests and receiving responses.
42+
* Ktor supports the following formats out-of-the-box: `JSON`, `XML`, and `CBOR`.
43+
*
44+
* You can learn more from [Content negotiation and serialization](https://ktor.io/docs/serialization-client.html).
45+
*
46+
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.plugins.contentnegotiation.ContentNegotiation)
47+
*/
48+
val ContentNegotiation: ClientPlugin<ContentNegotiationConfig> = createClientPlugin(
49+
"ContentNegotiation",
50+
::ContentNegotiationConfig
51+
) {
52+
val registrations: List<ContentNegotiationConfig.ConverterRegistration> = pluginConfig.registrations
53+
val ignoredTypes: Set<KClass<*>> = pluginConfig.ignoredTypes
54+
55+
suspend fun convertRequest(request: HttpRequestBuilder, body: Any): OutgoingContent? {
56+
var requestRegistrations: List<ContentNegotiationConfig.ConverterRegistration>
57+
58+
if (request.attributes.contains(ExcludedContentTypes)) {
59+
val excluded = request.attributes[ExcludedContentTypes]
60+
requestRegistrations = registrations.filter { registration -> excluded.none { registration.contentTypeToSend.match(it) } }
61+
} else {
62+
requestRegistrations = registrations
63+
}
64+
65+
val acceptHeaders = request.headers.getAll(HttpHeaders.Accept).orEmpty()
66+
requestRegistrations.forEach {
67+
if (acceptHeaders.none { h -> ContentType.parse(h).match(it.contentTypeToSend) }) {
68+
// automatically added headers get a lower content type priority, so user-specified accept headers
69+
// with higher q or implicit q=1 will take precedence
70+
val contentTypeToSend = when (val qValue = pluginConfig.defaultAcceptHeaderQValue) {
71+
null -> it.contentTypeToSend
72+
else -> it.contentTypeToSend.withParameter("q", qValue.toString())
73+
}
74+
LOGGER.trace("Adding Accept=$contentTypeToSend header for ${request.url}")
75+
request.accept(contentTypeToSend)
76+
}
77+
}
78+
79+
if (body is OutgoingContent || ignoredTypes.any { it.isInstance(body) }) {
80+
LOGGER.trace(
81+
"Body type ${body::class} is in ignored types. " +
82+
"Skipping ContentNegotiation for ${request.url}."
83+
)
84+
return null
85+
}
86+
val contentType = request.contentType() ?: run {
87+
LOGGER.trace("Request doesn't have Content-Type header. Skipping ContentNegotiation for ${request.url}.")
88+
return null
89+
}
90+
91+
if (body is Unit) {
92+
LOGGER.trace("Sending empty body for ${request.url}")
93+
request.headers.remove(HttpHeaders.ContentType)
94+
return EmptyContent
95+
}
96+
97+
val matchingRegistrations = registrations.filter { it.contentTypeMatcher.contains(contentType) }
98+
.takeIf { it.isNotEmpty() } ?: run {
99+
LOGGER.trace(
100+
"None of the registered converters match request Content-Type=$contentType. " +
101+
"Skipping ContentNegotiation for ${request.url}."
102+
)
103+
return null
104+
}
105+
if (request.bodyType == null) {
106+
LOGGER.trace("Request has unknown body type. Skipping ContentNegotiation for ${request.url}.")
107+
return null
108+
}
109+
request.headers.remove(HttpHeaders.ContentType)
110+
111+
// Pick the first one that can convert the subject successfully
112+
val serializedContent = matchingRegistrations.firstNotNullOfOrNull { registration ->
113+
val result = registration.converter.serialize(
114+
contentType,
115+
contentType.charset() ?: Charsets.UTF_8,
116+
request.bodyType!!,
117+
body.takeIf { it != NullBody }
118+
)
119+
if (result != null) {
120+
LOGGER.trace("Converted request body using ${registration.converter} for ${request.url}")
121+
}
122+
result
123+
} ?: throw ContentConverterException(
124+
"Can't convert $body with contentType $contentType using converters " +
125+
matchingRegistrations.joinToString { it.converter.toString() }
126+
)
127+
128+
return serializedContent
129+
}
130+
131+
suspend fun convertResponse(
132+
requestUrl: Url,
133+
info: TypeInfo,
134+
body: Any,
135+
responseContentType: ContentType,
136+
charset: Charset = Charsets.UTF_8
137+
): Any? {
138+
if (body !is ByteReadChannel) {
139+
LOGGER.trace("Response body is already transformed. Skipping ContentNegotiation for $requestUrl.")
140+
return null
141+
}
142+
if (info.type in ignoredTypes) {
143+
LOGGER.trace(
144+
"Response body type ${info.type} is in ignored types. " +
145+
"Skipping ContentNegotiation for $requestUrl."
146+
)
147+
return null
148+
}
149+
150+
val suitableConverters = registrations
151+
.filter { it.contentTypeMatcher.contains(responseContentType) }
152+
.map { it.converter }
153+
.takeIf { it.isNotEmpty() }
154+
?: run {
155+
LOGGER.trace(
156+
"None of the registered converters match response with Content-Type=$responseContentType. " +
157+
"Skipping ContentNegotiation for $requestUrl."
158+
)
159+
return null
160+
}
161+
162+
val result = suitableConverters.deserialize(body, info, charset)
163+
if (result !is ByteReadChannel) {
164+
LOGGER.trace("Response body was converted to ${result::class} for $requestUrl.")
165+
}
166+
return result
167+
}
168+
169+
transformRequestBody { request, body, _ ->
170+
convertRequest(request, body)
171+
}
172+
173+
transformResponseBody { response, body, info ->
174+
val contentType = response.contentType() ?: return@transformResponseBody null
175+
val charset = contentType.parameters.suitableCharset()
176+
177+
convertResponse(response.request.url, info, body, contentType, charset)
178+
}
179+
}
180+
181+
private fun List<HeaderValueParam>.suitableCharset(defaultCharset: Charset = Charsets.UTF_8): Charset =
182+
suitableCharsetOrNull() ?: defaultCharset
183+
184+
private fun List<HeaderValueParam>.suitableCharsetOrNull(): Charset? =
185+
find { it.name == "charset" }?.let { charsetParam ->
186+
val charsetName = charsetParam.value
187+
if (Charsets.isSupported(charsetName)) {
188+
Charsets.forName(charsetName)
189+
} else {
190+
LOGGER.warn("Unsupported charset '$charsetName' in content type'")
191+
null
192+
}
193+
}

0 commit comments

Comments
 (0)