diff --git a/library/src/main/java/com/chuckerteam/chucker/api/ChuckerInterceptor.kt b/library/src/main/java/com/chuckerteam/chucker/api/ChuckerInterceptor.kt index 5bcfd65f8..2cd600f8b 100755 --- a/library/src/main/java/com/chuckerteam/chucker/api/ChuckerInterceptor.kt +++ b/library/src/main/java/com/chuckerteam/chucker/api/ChuckerInterceptor.kt @@ -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 @@ -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) { @@ -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) } /** @@ -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" } } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpTransaction.kt b/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpTransaction.kt index ca7e49ce9..3ac88d5ba 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpTransaction.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpTransaction.kt @@ -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? ) { diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/ResponseProcessor.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/ResponseProcessor.kt new file mode 100644 index 000000000..1cb00e82f --- /dev/null +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/ResponseProcessor.kt @@ -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, + 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" + } +} diff --git a/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt b/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt index 88203aa74..94eb47441 100644 --- a/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt +++ b/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt @@ -180,7 +180,7 @@ 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() @@ -188,13 +188,12 @@ internal class ChuckerInterceptorTest { 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()