diff --git a/README.md b/README.md index 7abfb4b38..24d06834b 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ interceptor.redactHeader("Auth-Token", "User-Session"); ### Skip-Inspection ️🕵️ -If you need to selectively skip Chucker inspection on some endpoints or on particular requests you can add a special header - `Skip-ChuckerInterceptor: true`. This will inform Chucker to not process this request. Chucker will also strip this header from any request before sending it to a server. +If you need to selectively skip Chucker inspection on some endpoints or on particular requests you can add a special header - `Skip-Chucker-Interceptor: true`. This will inform Chucker to not process this request. Chucker will also strip this header from any request before sending it to a server. If you use `OkHttp` directly, create requests like below. 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 b2f916d32..14e5078a7 100755 --- a/library/src/main/java/com/chuckerteam/chucker/api/ChuckerInterceptor.kt +++ b/library/src/main/java/com/chuckerteam/chucker/api/ChuckerInterceptor.kt @@ -2,10 +2,14 @@ package com.chuckerteam.chucker.api import android.content.Context import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import com.chuckerteam.chucker.internal.support.AndroidCacheFileFactory +import com.chuckerteam.chucker.internal.support.FileFactory import com.chuckerteam.chucker.internal.support.IOUtils +import com.chuckerteam.chucker.internal.support.TeeSource import com.chuckerteam.chucker.internal.support.contentLength import com.chuckerteam.chucker.internal.support.contentType import com.chuckerteam.chucker.internal.support.isGzipped +import java.io.File import java.io.IOException import java.nio.charset.Charset import okhttp3.Headers @@ -15,8 +19,7 @@ import okhttp3.Response import okhttp3.ResponseBody import okio.Buffer import okio.GzipSource - -private const val MAX_BLOB_SIZE = 1000_000L +import okio.Okio /** * An OkHttp Interceptor which persists and displays HTTP activity @@ -27,16 +30,39 @@ private const val MAX_BLOB_SIZE = 1000_000L * @param maxContentLength The maximum length for request and response content * before their truncation. Warning: setting this value too high may cause unexpected * results. + * @param fileFactory Provider for [File]s where Chucker will save temporary responses before + * processing them. * @param headersToRedact a [Set] of headers you want to redact. They will be replaced * with a `**` in the Chucker UI. */ -class ChuckerInterceptor @JvmOverloads constructor( +class ChuckerInterceptor internal constructor( private val context: Context, private val collector: ChuckerCollector = ChuckerCollector(context), private val maxContentLength: Long = 250000L, + private val fileFactory: FileFactory, headersToRedact: Set = emptySet() ) : Interceptor { + /** + * An OkHttp Interceptor which persists and displays HTTP activity + * in your application for later inspection. + * + * @param context An Android [Context] + * @param collector A [ChuckerCollector] to customize data retention + * @param maxContentLength The maximum length for request and response content + * before their truncation. Warning: setting this value too high may cause unexpected + * results. + * @param headersToRedact a [Set] of headers you want to redact. They will be replaced + * with a `**` in the Chucker UI. + */ + @JvmOverloads + constructor( + context: Context, + collector: ChuckerCollector = ChuckerCollector(context), + maxContentLength: Long = 250000L, + headersToRedact: Set = emptySet() + ) : this(context, collector, maxContentLength, AndroidCacheFileFactory(context), headersToRedact) + private val io: IOUtils = IOUtils(context) private val headersToRedact: MutableSet = headersToRedact.toMutableSet() @@ -69,10 +95,8 @@ class ChuckerInterceptor @JvmOverloads constructor( throw e } - val processedResponse = processResponse(response, transaction) - collector.onResponseReceived(transaction) - - return processedResponse + processResponseMetadata(response, transaction) + return multiCastResponseBody(response, transaction) } /** @@ -113,9 +137,12 @@ class ChuckerInterceptor @JvmOverloads constructor( } /** - * Processes a [Response] and populates corresponding fields of a [HttpTransaction]. + * Processes [Response] metadata and populates corresponding fields of a [HttpTransaction]. */ - private fun processResponse(response: Response, transaction: HttpTransaction): Response { + private fun processResponseMetadata( + response: Response, + transaction: HttpTransaction + ) { val responseEncodingIsSupported = io.bodyHasSupportedEncoding(response.headers().get(CONTENT_ENCODING)) transaction.apply { @@ -140,35 +167,52 @@ class ChuckerInterceptor @JvmOverloads constructor( tookMs = (response.receivedResponseAtMillis() - response.sentRequestAtMillis()) } - - return if (responseEncodingIsSupported) { - processResponseBody(response, transaction) - } else { - response - } } /** - * Processes a [ResponseBody] and populates corresponding fields of a [HttpTransaction]. + * 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 processResponseBody(response: Response, transaction: HttpTransaction): Response { - val responseBody = response.body() ?: return response + private fun multiCastResponseBody( + response: Response, + transaction: HttpTransaction + ): Response { + val responseBody = response.body() + if (responseBody == null) { + collector.onResponseReceived(transaction) + return response + } val contentType = responseBody.contentType() - val charset = contentType?.charset(UTF8) ?: UTF8 val contentLength = responseBody.contentLength() - val responseSource = if (response.isGzipped) { - GzipSource(responseBody.source()) - } else { - responseBody.source() - } - val buffer = Buffer().apply { responseSource.use { writeAll(it) } } + val teeSource = TeeSource( + responseBody.source(), + fileFactory.create(), + ChuckerTransactionTeeCallback(response, transaction), + maxContentLength + ) + + return response.newBuilder() + .body(ResponseBody.create(contentType, contentLength, Okio.buffer(teeSource))) + .build() + } + + private fun processResponseBody( + response: Response, + responseBodyBuffer: Buffer, + transaction: HttpTransaction + ) { + val responseBody = response.body() ?: return - if (io.isPlaintext(buffer)) { + val contentType = responseBody.contentType() + val charset = contentType?.charset(UTF8) ?: UTF8 + + if (io.isPlaintext(responseBodyBuffer)) { transaction.isResponseBodyPlainText = true - if (contentLength != 0L) { - transaction.responseBody = buffer.clone().readString(charset) + if (responseBodyBuffer.size() != 0L) { + transaction.responseBody = responseBodyBuffer.readString(charset) } } else { transaction.isResponseBodyPlainText = false @@ -176,14 +220,10 @@ class ChuckerInterceptor @JvmOverloads constructor( val isImageContentType = (contentType?.toString()?.contains(CONTENT_TYPE_IMAGE, ignoreCase = true) == true) - if (isImageContentType && buffer.size() < MAX_BLOB_SIZE) { - transaction.responseImageData = buffer.clone().readByteArray() + if (isImageContentType && (responseBodyBuffer.size() < MAX_BLOB_SIZE)) { + transaction.responseImageData = responseBodyBuffer.readByteArray() } } - - return response.newBuilder() - .body(ResponseBody.create(contentType, contentLength, buffer)) - .build() } /** Overrides all headers from [headersToRedact] with `**` */ @@ -197,6 +237,35 @@ class ChuckerInterceptor @JvmOverloads constructor( return builder.build() } + private inner class ChuckerTransactionTeeCallback( + private val response: Response, + private val transaction: HttpTransaction + ) : TeeSource.Callback { + override fun onSuccess(file: File) { + val buffer = readResponseBuffer(file, response.isGzipped) + file.delete() + processResponseBody(response, buffer, transaction) + collector.onResponseReceived(transaction) + } + + override fun onFailure(exception: IOException, file: File) { + file.delete() + collector.onResponseReceived(transaction) + } + + private fun readResponseBuffer(responseBody: File, isGzipped: Boolean): Buffer { + val bufferedSource = Okio.buffer(Okio.source(responseBody)) + val source = if (isGzipped) { + GzipSource(bufferedSource) + } else { + bufferedSource + } + return Buffer().apply { + writeAll(source) + } + } + } + companion object { private val UTF8 = Charset.forName("UTF-8") diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/AndroidCacheFileFactory.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/AndroidCacheFileFactory.kt new file mode 100644 index 000000000..196369b82 --- /dev/null +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/AndroidCacheFileFactory.kt @@ -0,0 +1,16 @@ +package com.chuckerteam.chucker.internal.support + +import android.content.Context +import java.io.File +import java.util.concurrent.atomic.AtomicLong + +internal class AndroidCacheFileFactory( + context: Context +) : FileFactory { + private val fileDir = context.cacheDir + private val uniqueIdGenerator = AtomicLong() + + override fun create(): File { + return File(fileDir, "chucker-${uniqueIdGenerator.getAndIncrement()}") + } +} diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/FileFactory.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/FileFactory.kt new file mode 100644 index 000000000..4eac2269c --- /dev/null +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/FileFactory.kt @@ -0,0 +1,7 @@ +package com.chuckerteam.chucker.internal.support + +import java.io.File + +internal interface FileFactory { + fun create(): File +} diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/TeeSource.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/TeeSource.kt new file mode 100644 index 000000000..b56a6c302 --- /dev/null +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/TeeSource.kt @@ -0,0 +1,90 @@ +package com.chuckerteam.chucker.internal.support + +import java.io.File +import java.io.IOException +import okio.Buffer +import okio.Okio +import okio.Source +import okio.Timeout + +/** + * A source that acts as a tee operator - https://en.wikipedia.org/wiki/Tee_(command). + * + * It takes the input [upstream] and reads from it serving the bytes to the end consumer + * like a regular [Source]. While bytes are read from the [upstream] the are also copied + * to a [sideChannel] file. After the [upstream] is depleted or when a failure occurs + * an appropriate [callback] method is called. + * + * Failure is considered any [IOException] during reading the bytes or exceeding [readBytesLimit] length. + */ +internal class TeeSource( + private val upstream: Source, + private val sideChannel: File, + private val callback: Callback, + private val readBytesLimit: Long = Long.MAX_VALUE +) : Source { + private val sideStream = Okio.buffer(Okio.sink(sideChannel)) + private var totalBytesRead = 0L + private var reachedLimit = false + private var upstreamFailed = false + + override fun read(sink: Buffer, byteCount: Long): Long { + val bytesRead = try { + upstream.read(sink, byteCount) + } catch (e: IOException) { + callSideChannelFailure(e) + throw e + } + + if (bytesRead == -1L) { + sideStream.close() + return -1L + } + + totalBytesRead += bytesRead + if (!reachedLimit && (totalBytesRead <= readBytesLimit)) { + val offset = sink.size() - bytesRead + sink.copyTo(sideStream.buffer(), offset, bytesRead) + sideStream.emitCompleteSegments() + return bytesRead + } + if (!reachedLimit) { + reachedLimit = true + sideStream.close() + callSideChannelFailure(IOException("Capacity of $readBytesLimit bytes exceeded")) + } + + return bytesRead + } + + override fun close() { + sideStream.close() + upstream.close() + if (!upstreamFailed) { + callback.onSuccess(sideChannel) + } + } + + override fun timeout(): Timeout = upstream.timeout() + + private fun callSideChannelFailure(exception: IOException) { + if (!upstreamFailed) { + upstreamFailed = true + callback.onFailure(exception, sideChannel) + } + } + + interface Callback { + /** + * Called when the upstream was successfully copied to the [file]. + */ + fun onSuccess(file: File) + + /** + * Called when there was an issue while copying bytes to the [file]. + * + * It might occur due to an exception thrown while reading bytes or due to exceeding capacity limit. + */ + fun onFailure(exception: IOException, file: File) + } +} 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 48db84fa7..b91707ec0 100644 --- a/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt +++ b/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt @@ -3,9 +3,11 @@ package com.chuckerteam.chucker.api import android.content.Context import com.chuckerteam.chucker.getResourceFile import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import com.chuckerteam.chucker.internal.support.FileFactory import com.google.common.truth.Truth.assertThat import io.mockk.every import io.mockk.mockk +import java.io.File import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.mockwebserver.MockResponse @@ -14,10 +16,13 @@ import okio.Buffer import okio.ByteString import okio.GzipSink import org.junit.Rule +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir class ChuckerInterceptorTest { - @get:Rule val server = MockWebServer() + @get:Rule + val server = MockWebServer() private val serverUrl = server.url("/") // Starts server implicitly private var transaction: HttpTransaction? = null @@ -31,9 +36,19 @@ class ChuckerInterceptorTest { } } - private val client = OkHttpClient.Builder() - .addInterceptor(ChuckerInterceptor(mockContext, mockCollector)) - .build() + private lateinit var client: OkHttpClient + + @BeforeEach + fun setUp(@TempDir tempDir: File) { + val fileFactory = object : FileFactory { + override fun create(): File { + return File(tempDir, "testFile") + } + } + client = OkHttpClient.Builder() + .addInterceptor(ChuckerInterceptor(mockContext, mockCollector, fileFactory = fileFactory)) + .build() + } @Test fun imageResponse_isAvailableToChucker() { @@ -42,7 +57,7 @@ class ChuckerInterceptorTest { val request = Request.Builder().url(serverUrl).build() val expectedBody = image.snapshot() - client.newCall(request).execute() + client.newCall(request).execute().body()?.bytes() assertThat(expectedBody).isEqualTo(ByteString.of(*transaction!!.responseImageData!!)) } @@ -68,7 +83,7 @@ class ChuckerInterceptorTest { server.enqueue(MockResponse().addHeader("Content-Encoding: gzip").setBody(gzippedBytes)) val request = Request.Builder().url(serverUrl).build() - client.newCall(request).execute() + client.newCall(request).execute().body()?.bytes() assertThat(transaction!!.isResponseBodyPlainText).isTrue() assertThat(transaction!!.responseBody).isEqualTo("Hello, world!") @@ -95,7 +110,7 @@ class ChuckerInterceptorTest { .addHeader(Chucker.SKIP_INTERCEPTOR_HEADER_NAME, "true") .build() - client.newCall(request).execute() + client.newCall(request).execute().body()?.bytes() assertThat(transaction).isNull() } @@ -119,7 +134,7 @@ class ChuckerInterceptorTest { .addHeader(Chucker.SKIP_INTERCEPTOR_HEADER_NAME, "false") .build() - client.newCall(request).execute() + client.newCall(request).execute().body()?.bytes() assertThat(transaction!!.responseBody).isEqualTo("Hello, world!") } diff --git a/library/src/test/java/com/chuckerteam/chucker/internal/support/TeeSourceTest.kt b/library/src/test/java/com/chuckerteam/chucker/internal/support/TeeSourceTest.kt new file mode 100644 index 000000000..165372d7e --- /dev/null +++ b/library/src/test/java/com/chuckerteam/chucker/internal/support/TeeSourceTest.kt @@ -0,0 +1,140 @@ +package com.chuckerteam.chucker.internal.support + +import com.google.common.truth.Truth.assertThat +import java.io.File +import java.io.IOException +import kotlin.random.Random +import okio.Buffer +import okio.ByteString +import okio.Okio +import okio.Source +import okio.Timeout +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.io.TempDir + +class TeeSourceTest { + private val teeCallback = TestTeeCallback() + + @Test + fun bytesReadFromUpstream_areAvailableDownstream(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + val testSource = TestSource() + val downstream = Buffer() + + val teeSource = TeeSource(testSource, testFile, teeCallback) + Okio.buffer(teeSource).use { it.readAll(downstream) } + + assertThat(downstream.snapshot()).isEqualTo(testSource.content) + } + + @Test + fun bytesReadFromUpstream_areAvailableToSideChannel(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + val testSource = TestSource() + val downstream = Buffer() + + val teeSource = TeeSource(testSource, testFile, teeCallback) + Okio.buffer(teeSource).use { it.readAll(downstream) } + + assertThat(teeCallback.fileContent).isEqualTo(testSource.content) + } + + @Test + fun bytesPulledFromUpstream_arePulledToSideChannel_alongTheDownstream(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + val repetitions = Random.nextInt(0, 100) + // Okio uses 8KiB as a single size read. + val testSource = TestSource(8_192 * repetitions) + + val teeSource = TeeSource(testSource, testFile, teeCallback) + Okio.buffer(teeSource).use { source -> + repeat(repetitions) { index -> + source.readByteString(8_192) + + val subContent = testSource.content.substring(0, (index + 1) * 8_192) + Okio.buffer(Okio.source(testFile)).use { + assertThat(it.readByteString()).isEqualTo(subContent) + } + } + } + } + + @Test + fun tooBigSources_informOfFailures_inSideChannel(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + val testSource = TestSource(10_000) + val downstream = Buffer() + + val teeSource = TeeSource(testSource, testFile, teeCallback, readBytesLimit = 9_999) + Okio.buffer(teeSource).use { it.readAll(downstream) } + + assertThat(teeCallback.exception) + .hasMessageThat() + .isEqualTo("Capacity of 9999 bytes exceeded") + } + + @Test + fun tooBigSources_areAvailableDownstream(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + val testSource = TestSource(10_000) + val downstream = Buffer() + + val teeSource = TeeSource(testSource, testFile, teeCallback, readBytesLimit = 9_999) + Okio.buffer(teeSource).use { it.readAll(downstream) } + + assertThat(downstream.snapshot()).isEqualTo(testSource.content) + } + + @Test + fun readException_informOfFailures_inSideChannel(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + val testSource = ThrowingSource + + val teeSource = TeeSource(testSource, testFile, teeCallback) + + assertThrows { + Okio.buffer(teeSource).use { it.readByte() } + } + + assertThat(teeCallback.exception) + .hasMessageThat() + .isEqualTo("Hello there!") + } + + private class TestSource(contentLength: Int = 1_000) : Source { + val content: ByteString = ByteString.of(*Random.nextBytes(contentLength)) + private val buffer = Buffer().apply { write(content) } + + override fun read(sink: Buffer, byteCount: Long): Long = buffer.read(sink, byteCount) + + override fun close() = buffer.close() + + override fun timeout(): Timeout = buffer.timeout() + } + + private object ThrowingSource : Source { + override fun read(sink: Buffer, byteCount: Long): Long { + throw IOException("Hello there!") + } + + override fun close() = Unit + + override fun timeout(): Timeout = Timeout.NONE + } + + private class TestTeeCallback : TeeSource.Callback { + private var file: File? = null + val fileContent get() = file?.let { Okio.buffer(Okio.source(it)).readByteString() } + var exception: IOException? = null + + override fun onSuccess(file: File) { + this.file = file + } + + override fun onFailure(exception: IOException, file: File) { + this.exception = exception + this.file = file + } + } +}