Hey all. I have created a ktor client plugin that ...
# ktor
a
Hey all. I have created a ktor client plugin that logs the body of the http response received. However, when I do read that response body for logging it out, I also happen to consume the associated ByteReadChannel. Subsequent calls to bodyAsText() by the application itself (for example to show the error message on the UI) thus only return empty strings. Is it possible to somehow read the response body twice, or to restore the channel in the plugin after it was read?
a
The issue (KTOR-4225) of double-consuming the response body is fixed in Ktor 3.0.0. You can use the
SaveBodyPlugin
to make the subsequent calls to
bodyAsText()
return the actual body as a String.
j
I had a similar issue and replaced the response received with a cached response. The relevant part looks like this
Copy code
...
        client.receivePipeline.intercept(HttpReceivePipeline.State) { response -> interceptReceive(response) }
...

private suspend fun PipelineContext<HttpResponse, Unit>.interceptReceive(response: HttpResponse) {
    val logIdValue = response.request.attributes[logId]
    val responseData = CachedHttpResponseData.create(response)
    fileLog.responseFile(logIdValue).writeText(response.createResponseText(responseData.body))
    proceedWith(CachedHttpResponse(response.call, responseData, response.coroutineContext))
}
a
Thanks for the info. Good to know that it will work out of the box in 3.0.0 @Jaap Beetstra I understand your example is for ktor 2.x? Where does that CachedHttpResponseData class come from?
j
@Alexander Weickmann Yes it's Ktor 2.x. The CachedHttpResponseData class is a simple data class that stores the request.
Copy code
data class CachedHttpResponseData(val headers: CachedHttpHeaders, val body: String) {
    companion object {
        suspend fun create(response: HttpResponse) =
            CachedHttpResponseData(CachedHttpHeaders(response), response.bodyAsText())
    }
}

data class CachedHttpHeaders(val statusCode: Int, val timestamp: Long, val headers: List<CachedHttpHeaderValue>) {
    constructor(response: HttpResponse) : this(
        response.status.value, response.responseTime.timestamp,
        response.headers.entries().flatMap { (name, values) ->
            values.map { CachedHttpHeaderValue(name, it) }
        }
    )
}

data class CachedHttpHeaderValue(val name: String, val value: String)
CachedHttpResponse wraps the data class in a Ktor HttpResponse
Copy code
class CachedHttpResponse(
    override val call: HttpClientCall,
    response: CachedHttpResponseData,
    override val coroutineContext: CoroutineContext
) : HttpResponse() {
    @InternalAPI
    override val content: ByteReadChannel = ByteReadChannel(response.body)
    override val headers: Headers = response.headers.headers.filterNot { it.name == "content-encoding" }.toHeaders()
    override val requestTime: GMTDate = GMTDate(response.headers.timestamp)
    override val responseTime: GMTDate = GMTDate()
    override val status: HttpStatusCode = HttpStatusCode.fromValue(response.headers.statusCode)
    override val version: HttpProtocolVersion = HttpProtocolVersion.HTTP_1_1
}

private fun List<CachedHttpHeaderValue>.toHeaders(): Headers = HeadersBuilder().apply {
    forEach { header -> append(header.name, header.value) }
}.build()
a
Cool, thank you very much. It works 👍
As I learned the hard way, it is actually not that easy The body is actually not always a string. So when you do a file download, bodyAsText does not work. You also don't want to cache a byte array instead of a string, because you might have a large file download that you don't want to copy to the heap Maybe I'll try to only do the above modifications to the pipeline if the content type is application/json or text/plain