Skip to content

Commit

Permalink
Response processing refactor (#554)
Browse files Browse the repository at this point in the history
* Move response processing to a separate class

* Don't set response body to true for no body
  • Loading branch information
MiSikora authored Feb 10, 2021
1 parent e412626 commit 5420645
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 161 deletions.
176 changes: 19 additions & 157 deletions library/src/main/java/com/chuckerteam/chucker/api/ChuckerInterceptor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,11 @@ import android.content.Context
import androidx.annotation.VisibleForTesting
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import com.chuckerteam.chucker.internal.support.CacheDirectoryProvider
import com.chuckerteam.chucker.internal.support.DepletingSource
import com.chuckerteam.chucker.internal.support.FileFactory
import com.chuckerteam.chucker.internal.support.Logger
import com.chuckerteam.chucker.internal.support.ReportingSink
import com.chuckerteam.chucker.internal.support.RequestProcessor
import com.chuckerteam.chucker.internal.support.TeeSource
import com.chuckerteam.chucker.internal.support.contentType
import com.chuckerteam.chucker.internal.support.hasBody
import com.chuckerteam.chucker.internal.support.hasSupportedContentEncoding
import com.chuckerteam.chucker.internal.support.isProbablyPlainText
import com.chuckerteam.chucker.internal.support.redact
import com.chuckerteam.chucker.internal.support.uncompress
import com.chuckerteam.chucker.internal.support.ResponseProcessor
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.ResponseBody.Companion.asResponseBody
import okio.Buffer
import okio.Source
import okio.buffer
import okio.source
import java.io.File
import java.io.IOException
import kotlin.text.Charsets.UTF_8

/**
* An OkHttp Interceptor which persists and displays HTTP activity
Expand All @@ -46,13 +29,24 @@ public class ChuckerInterceptor private constructor(
*/
public constructor(context: Context) : this(Builder(context))

private val context = builder.context
private val collector = builder.collector ?: ChuckerCollector(context)
private val maxContentLength = builder.maxContentLength
private val cacheDirectoryProvider = builder.cacheDirectoryProvider ?: CacheDirectoryProvider { context.filesDir }
private val alwaysReadResponseBody = builder.alwaysReadResponseBody
private val headersToRedact = builder.headersToRedact.toMutableSet()
private val requestProcessor = RequestProcessor(context, collector, maxContentLength, headersToRedact)

private val collector = builder.collector ?: ChuckerCollector(builder.context)

private val requestProcessor = RequestProcessor(
builder.context,
collector,
builder.maxContentLength,
headersToRedact,
)

private val responseProcessor = ResponseProcessor(
collector,
builder.cacheDirectoryProvider ?: CacheDirectoryProvider { builder.context.filesDir },
builder.maxContentLength,
headersToRedact,
builder.alwaysReadResponseBody
)

/** Adds [headerName] into [headersToRedact] */
public fun redactHeader(vararg headerName: String) {
Expand All @@ -74,136 +68,7 @@ public class ChuckerInterceptor private constructor(
throw e
}

processResponseMetadata(response, transaction)
return multiCastResponseBody(response, transaction)
}

/**
* Processes [Response] metadata and populates corresponding fields of a [HttpTransaction].
*/
private fun processResponseMetadata(
response: Response,
transaction: HttpTransaction
) {
val responseEncodingIsSupported = response.headers.hasSupportedContentEncoding

transaction.apply {
// includes headers added later in the chain
setRequestHeaders(response.request.headers.redact(headersToRedact))
setResponseHeaders(response.headers.redact(headersToRedact))

isResponseBodyPlainText = responseEncodingIsSupported
requestDate = response.sentRequestAtMillis
responseDate = response.receivedResponseAtMillis
protocol = response.protocol.toString()
responseCode = response.code
responseMessage = response.message

response.handshake?.let { handshake ->
responseTlsVersion = handshake.tlsVersion.javaName
responseCipherSuite = handshake.cipherSuite.javaName
}

responseContentType = response.contentType

tookMs = (response.receivedResponseAtMillis - response.sentRequestAtMillis)
}
}

/**
* Multi casts a [Response] body if it is available and downstreams it to a file which will
* be available for Chucker to consume and save in the [transaction] at some point in the future
* when the end user reads bytes form the [response].
*/
private fun multiCastResponseBody(
response: Response,
transaction: HttpTransaction
): Response {
val responseBody = response.body
if (!response.hasBody() || responseBody == null) {
collector.onResponseReceived(transaction)
return response
}

val contentType = responseBody.contentType()
val contentLength = responseBody.contentLength()

val sideStream = ReportingSink(
createTempTransactionFile(),
ChuckerTransactionReportingSinkCallback(response, transaction),
maxContentLength
)
var upstream: Source = TeeSource(responseBody.source(), sideStream)
if (alwaysReadResponseBody) upstream = DepletingSource(upstream)

return response.newBuilder()
.body(upstream.buffer().asResponseBody(contentType, contentLength))
.build()
}

private fun createTempTransactionFile(): File? {
val cache = cacheDirectoryProvider.provide()
return if (cache == null) {
Logger.warn("Failed to obtain a valid cache directory for transaction files")
null
} else {
FileFactory.create(cache)
}
}

private fun processResponsePayload(
response: Response,
payload: Buffer,
transaction: HttpTransaction
) {
val responseBody = response.body ?: return

val contentType = responseBody.contentType()
val charset = contentType?.charset() ?: UTF_8

if (payload.isProbablyPlainText) {
transaction.isResponseBodyPlainText = true
if (payload.size != 0L) {
transaction.responseBody = payload.readString(charset)
}
} else {
transaction.isResponseBodyPlainText = false

val isImageContentType =
(contentType?.toString()?.contains(CONTENT_TYPE_IMAGE, ignoreCase = true) == true)

if (isImageContentType && (payload.size < MAX_BLOB_SIZE)) {
transaction.responseImageData = payload.readByteArray()
}
}
}

private inner class ChuckerTransactionReportingSinkCallback(
private val response: Response,
private val transaction: HttpTransaction
) : ReportingSink.Callback {

override fun onClosed(file: File?, sourceByteCount: Long) {
file?.readResponsePayload()?.let { payload ->
processResponsePayload(response, payload, transaction)
}
transaction.responsePayloadSize = sourceByteCount
collector.onResponseReceived(transaction)
file?.delete()
}

override fun onFailure(file: File?, exception: IOException) {
Logger.error("Failed to read response payload", exception)
}

private fun File.readResponsePayload() = try {
source().uncompress(response.headers).use { source ->
Buffer().apply { writeAll(source) }
}
} catch (e: IOException) {
Logger.error("Response payload couldn't be processed", e)
null
}
return responseProcessor.process(response, transaction)
}

/**
Expand Down Expand Up @@ -278,8 +143,5 @@ public class ChuckerInterceptor private constructor(

private companion object {
private const val MAX_CONTENT_LENGTH = 250_000L
private const val MAX_BLOB_SIZE = 1_000_000L

private const val CONTENT_TYPE_IMAGE = "image"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ internal class HttpTransaction(
@ColumnInfo(name = "responseContentType") var responseContentType: String?,
@ColumnInfo(name = "responseHeaders") var responseHeaders: String?,
@ColumnInfo(name = "responseBody") var responseBody: String?,
@ColumnInfo(name = "isResponseBodyPlainText") var isResponseBodyPlainText: Boolean = true,
@ColumnInfo(name = "isResponseBodyPlainText") var isResponseBodyPlainText: Boolean = false,
@ColumnInfo(name = "responseImageData") var responseImageData: ByteArray?
) {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package com.chuckerteam.chucker.internal.support

import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import okhttp3.Response
import okhttp3.ResponseBody.Companion.asResponseBody
import okio.Buffer
import okio.Source
import okio.buffer
import okio.source
import java.io.File

internal class ResponseProcessor(
private val collector: ChuckerCollector,
private val cacheDirectoryProvider: CacheDirectoryProvider,
private val maxContentLength: Long,
private val headersToRedact: Set<String>,
private val alwaysReadResponseBody: Boolean,
) {
fun process(response: Response, transaction: HttpTransaction): Response {
processResponseMetadata(response, transaction)
return multiCastResponse(response, transaction)
}

private fun processResponseMetadata(response: Response, transaction: HttpTransaction) {
transaction.apply {
// includes headers added later in the chain
setRequestHeaders(response.request.headers.redact(headersToRedact))
setResponseHeaders(response.headers.redact(headersToRedact))

requestDate = response.sentRequestAtMillis
responseDate = response.receivedResponseAtMillis
protocol = response.protocol.toString()
responseCode = response.code
responseMessage = response.message

response.handshake?.let { handshake ->
responseTlsVersion = handshake.tlsVersion.javaName
responseCipherSuite = handshake.cipherSuite.javaName
}

responseContentType = response.contentType

tookMs = (response.receivedResponseAtMillis - response.sentRequestAtMillis)
}
}

private fun multiCastResponse(response: Response, transaction: HttpTransaction): Response {
val responseBody = response.body
if (!response.hasBody() || responseBody == null) {
collector.onResponseReceived(transaction)
return response
}

val contentType = responseBody.contentType()
val contentLength = responseBody.contentLength()

val sideStream = ReportingSink(
createTempTransactionFile(),
ResponseReportingSinkCallback(response, transaction),
maxContentLength
)
var upstream: Source = TeeSource(responseBody.source(), sideStream)
if (alwaysReadResponseBody) upstream = DepletingSource(upstream)

return response.newBuilder()
.body(upstream.buffer().asResponseBody(contentType, contentLength))
.build()
}

private fun createTempTransactionFile(): File? {
val cache = cacheDirectoryProvider.provide()
return if (cache == null) {
Logger.warn("Failed to obtain a valid cache directory for transaction files")
null
} else {
FileFactory.create(cache)
}
}

private fun processResponsePayload(response: Response, payload: Buffer, transaction: HttpTransaction) {
val responseBody = response.body ?: return

val contentType = responseBody.contentType()
val charset = contentType?.charset() ?: Charsets.UTF_8

if (payload.isProbablyPlainText) {
transaction.isResponseBodyPlainText = true
if (payload.size != 0L) {
transaction.responseBody = payload.readString(charset)
}
} else {
val isImageContentType = contentType?.toString()?.contains(CONTENT_TYPE_IMAGE, ignoreCase = true) == true

if (isImageContentType && (payload.size < MAX_BLOB_SIZE)) {
transaction.responseImageData = payload.readByteArray()
}
}
}

private inner class ResponseReportingSinkCallback(
private val response: Response,
private val transaction: HttpTransaction,
) : ReportingSink.Callback {

override fun onClosed(file: File?, sourceByteCount: Long) {
file?.readResponsePayload()?.let { payload ->
processResponsePayload(response, payload, transaction)
}
transaction.responsePayloadSize = sourceByteCount
collector.onResponseReceived(transaction)
file?.delete()
}

override fun onFailure(file: File?, exception: java.io.IOException) {
Logger.error("Failed to read response payload", exception)
}

private fun File.readResponsePayload() = try {
source().uncompress(response.headers).use { source ->
Buffer().apply { writeAll(source) }
}
} catch (e: java.io.IOException) {
Logger.error("Response payload couldn't be processed", e)
null
}
}

private companion object {
const val MAX_BLOB_SIZE = 1_000_000L

const val CONTENT_TYPE_IMAGE = "image"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -180,21 +180,20 @@ internal class ChuckerInterceptorTest {

@ParameterizedTest
@EnumSource(value = ClientFactory::class)
fun plainTextResponseBody_withNoContent_isAvailableForChucker(factory: ClientFactory) {
fun responseBody_withNoContent_isAvailableForChucker(factory: ClientFactory) {
server.enqueue(MockResponse().setResponseCode(HTTP_NO_CONTENT))
val request = Request.Builder().url(serverUrl).build()

val client = factory.create(chuckerInterceptor)
client.newCall(request).execute().readByteStringBody()
val transaction = chuckerInterceptor.expectTransaction()

assertThat(transaction.isResponseBodyPlainText).isTrue()
assertThat(transaction.responseBody).isNull()
}

@ParameterizedTest
@EnumSource(value = ClientFactory::class)
fun plainTextResponseBody_withNoContent_isAvailableForTheEndConsumer(factory: ClientFactory) {
fun responseBody_withNoContent_isAvailableForTheEndConsumer(factory: ClientFactory) {
server.enqueue(MockResponse().setResponseCode(HTTP_NO_CONTENT))
val request = Request.Builder().url(serverUrl).build()

Expand Down

0 comments on commit 5420645

Please sign in to comment.