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

Response processing refactor #554

Merged
merged 3 commits into from
Feb 10, 2021
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
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