diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/entity/HttpTransactionTuple.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/entity/HttpTransactionTuple.kt index 57be2273..c0086e8e 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/entity/HttpTransactionTuple.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/entity/HttpTransactionTuple.kt @@ -25,6 +25,7 @@ internal data class HttpTransactionTuple( @ColumnInfo(name = "error") var error: String?, @ColumnInfo(name = "graphQlDetected") var graphQlDetected: Boolean = false, @ColumnInfo(name = "graphQlOperationName") var graphQlOperationName: String?, + @ColumnInfo(name = "requestContentType") var requestContentType: String?, ) { val isSsl: Boolean get() = scheme.equals("https", ignoreCase = true) diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/HttpTransactionDatabaseRepository.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/HttpTransactionDatabaseRepository.kt index 163c41fa..31d3bf31 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/HttpTransactionDatabaseRepository.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/HttpTransactionDatabaseRepository.kt @@ -24,6 +24,7 @@ internal class HttpTransactionDatabaseRepository( * more context */ graphQlQuery = pathQuery, + contentTypeQuery = pathQuery, ) } diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/room/HttpTransactionDao.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/room/HttpTransactionDao.kt index c55d87b9..513c01d5 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/room/HttpTransactionDao.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/room/HttpTransactionDao.kt @@ -14,21 +14,25 @@ import com.chuckerteam.chucker.internal.data.entity.HttpTransactionTuple internal interface HttpTransactionDao { @Query( "SELECT id, requestDate, tookMs, protocol, method, host, path, scheme, responseCode, " + - "requestPayloadSize, responsePayloadSize, error, graphQLDetected, graphQlOperationName FROM " + + "requestPayloadSize, responsePayloadSize, error, graphQLDetected, " + + "graphQlOperationName, requestContentType FROM " + "transactions ORDER BY requestDate DESC", ) fun getSortedTuples(): LiveData> @Query( "SELECT id, requestDate, tookMs, protocol, method, host, path, scheme, responseCode, " + - "requestPayloadSize, responsePayloadSize, error, graphQLDetected, graphQlOperationName FROM " + + "requestPayloadSize, responsePayloadSize, error, graphQLDetected, " + + "graphQlOperationName, requestContentType FROM " + "transactions WHERE responseCode LIKE :codeQuery AND (path LIKE :pathQuery OR " + - "graphQlOperationName LIKE :graphQlQuery) ORDER BY requestDate DESC", + "graphQlOperationName LIKE :graphQlQuery OR " + + "requestContentType LIKE :contentTypeQuery) ORDER BY requestDate DESC", ) fun getFilteredTuples( codeQuery: String, pathQuery: String, graphQlQuery: String = "", + contentTypeQuery: String = "", ): LiveData> @Insert diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/RequestProcessor.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/RequestProcessor.kt index a489627b..2db9c3a1 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/RequestProcessor.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/RequestProcessor.kt @@ -5,6 +5,7 @@ import com.chuckerteam.chucker.R import com.chuckerteam.chucker.api.BodyDecoder import com.chuckerteam.chucker.api.ChuckerCollector import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import okhttp3.MultipartBody import okhttp3.Request import okio.Buffer import okio.ByteString @@ -17,6 +18,11 @@ internal class RequestProcessor( private val headersToRedact: Set, private val bodyDecoders: List, ) { + private companion object { + const val MAX_PREFIX_LENGTH = 64L + const val MAX_CODEPOINTS_TO_CHECK = 16 + } + fun process( request: Request, transaction: HttpTransaction, @@ -60,6 +66,13 @@ internal class RequestProcessor( return } + if (body is MultipartBody) { + val content = processMultipartPayload(body) + transaction.requestBody = content + transaction.isRequestBodyEncoded = false + return + } + val requestSource = try { Buffer().apply { body.writeTo(this) } @@ -67,7 +80,8 @@ internal class RequestProcessor( Logger.error("Failed to read request payload", e) return } - val limitingSource = LimitingSource(requestSource.uncompress(request.headers), maxContentLength) + val limitingSource = + LimitingSource(requestSource.uncompress(request.headers), maxContentLength) val contentBuffer = Buffer().apply { limitingSource.use { writeAll(it) } } @@ -79,6 +93,60 @@ internal class RequestProcessor( } } + private fun processMultipartPayload(body: MultipartBody): String { + return buildString { + body.parts.forEach { part -> + part.headers?.forEach { header -> + append(header.first + ": " + header.second + "\n") + } + val partBody = part.body + if (partBody.contentType() != null) { + append("Content-Type: ${partBody.contentType()}\n") + } + if (partBody.contentLength() != -1L) { + append("Content-Length: ${partBody.contentLength()}\n") + } + + val buffer = Buffer() + partBody.writeTo(buffer) + + if (isPlainText(buffer)) { + append("\n") + append(buffer.readUtf8()) + } else { + append("\n(binary: ${partBody.contentLength()} bytes)") + } + append("\n\n") + + if (length >= maxContentLength) { + append(context.getString(R.string.chucker_body_content_truncated)) + return@buildString + } + } + } + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun isPlainText(buffer: Buffer): Boolean { + try { + val prefix = Buffer() + val byteCount = if (buffer.size < MAX_PREFIX_LENGTH) buffer.size else MAX_PREFIX_LENGTH + buffer.copyTo(prefix, 0, byteCount) + repeat(MAX_CODEPOINTS_TO_CHECK) { + if (prefix.exhausted()) { + return@repeat + } + val codePoint = prefix.readUtf8CodePoint() + if (Character.isISOControl(codePoint) && !Character.isWhitespace(codePoint)) { + return false + } + } + return true + } catch (e: Exception) { + return false + } + } + private fun decodePayload( request: Request, body: ByteString, diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionActivity.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionActivity.kt index 3342d22b..8e0b317a 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionActivity.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionActivity.kt @@ -97,21 +97,27 @@ internal class TransactionActivity : BaseChuckerActivity() { override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { - R.id.share_text -> + R.id.share_text -> { shareTransactionAsText { transaction -> val encodeUrls = viewModel.encodeUrl.value!! TransactionDetailsSharable(transaction, encodeUrls) } - R.id.share_curl -> + } + + R.id.share_curl -> { shareTransactionAsText { transaction -> TransactionCurlCommandSharable(transaction) } - R.id.share_file -> + } + + R.id.share_file -> { shareTransactionAsFile(EXPORT_TXT_FILE_NAME) { transaction -> val encodeUrls = viewModel.encodeUrl.value!! TransactionDetailsSharable(transaction, encodeUrls) } - R.id.share_har -> + } + + R.id.share_har -> { shareTransactionAsFile(EXPORT_HAR_FILE_NAME) { transaction -> TransactionDetailsHarSharable( HarUtils.harStringFromTransactions( @@ -121,7 +127,11 @@ internal class TransactionActivity : BaseChuckerActivity() { ), ) } - else -> super.onOptionsItemSelected(item) + } + + else -> { + super.onOptionsItemSelected(item) + } } private fun shareTransactionAsText(block: (HttpTransaction) -> Sharable): Boolean { diff --git a/library/src/test/kotlin/com/chuckerteam/chucker/api/ChuckerInterceptorMultipartTest.kt b/library/src/test/kotlin/com/chuckerteam/chucker/api/ChuckerInterceptorMultipartTest.kt new file mode 100644 index 00000000..2de4ade0 --- /dev/null +++ b/library/src/test/kotlin/com/chuckerteam/chucker/api/ChuckerInterceptorMultipartTest.kt @@ -0,0 +1,74 @@ +package com.chuckerteam.chucker.api + +import com.chuckerteam.chucker.util.ChuckerInterceptorDelegate +import com.chuckerteam.chucker.util.ClientFactory +import com.chuckerteam.chucker.util.NoLoggerRule +import com.chuckerteam.chucker.util.readByteStringBody +import com.google.common.truth.Truth.assertThat +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Rule +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import java.io.File + +@ExtendWith(NoLoggerRule::class) +internal class ChuckerInterceptorMultipartTest { + @get:Rule + val server = MockWebServer() + + private val serverUrl = server.url("/") + + @TempDir + lateinit var tempDir: File + + @ParameterizedTest + @EnumSource(value = ClientFactory::class) + fun `multipart body is formatted correctly`(factory: ClientFactory) { + val chuckerInterceptor = + ChuckerInterceptorDelegate( + cacheDirectoryProvider = { tempDir }, + ) + val client = factory.create(chuckerInterceptor) + + val binaryData = byteArrayOf(0x00, 0x01, 0x02, 0x03) + val multipartBody = + MultipartBody + .Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("title", "Square Logo") + .addFormDataPart( + "image", + "logo.png", + binaryData.toRequestBody("image/png".toMediaType()), + ).build() + + val request = + Request + .Builder() + .url(serverUrl) + .post(multipartBody) + .build() + server.enqueue(MockResponse().setBody("OK")) + + client.newCall(request).execute().readByteStringBody() + + val transaction = chuckerInterceptor.expectTransaction() + + // This assertion is what we WANT to see after the fix. + // Current behavior will likely fail this. + assertThat(transaction.requestBody).contains("Content-Disposition: form-data; name=\"title\"") + assertThat(transaction.requestBody).contains("Square Logo") + assertThat(transaction.requestBody).contains("Content-Disposition: form-data; name=\"image\"") + assertThat(transaction.requestBody).contains("filename=\"logo.png\"") + assertThat(transaction.requestBody).contains("Content-Type: image/png") + // Binary content should be replaced with placeholder + assertThat(transaction.requestBody).contains("(binary: 4 bytes)") + } +} diff --git a/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/entity/HttpTransactionTupleTest.kt b/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/entity/HttpTransactionTupleTest.kt index 75d3c1a2..04b99cb8 100644 --- a/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/entity/HttpTransactionTupleTest.kt +++ b/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/entity/HttpTransactionTupleTest.kt @@ -121,6 +121,7 @@ internal class HttpTransactionTupleTest { error: String? = null, graphQlOperationName: String? = null, graphQLDetected: Boolean = false, + requestContentType: String? = null, ) = HttpTransactionTuple( id = id, requestDate = requestDate, @@ -136,5 +137,6 @@ internal class HttpTransactionTupleTest { error = error, graphQlOperationName = graphQlOperationName, graphQlDetected = graphQLDetected, + requestContentType = requestContentType, ) } diff --git a/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/entity/TransactionTestUtils.kt b/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/entity/TransactionTestUtils.kt index f3969bab..b1b16515 100644 --- a/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/entity/TransactionTestUtils.kt +++ b/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/entity/TransactionTestUtils.kt @@ -68,6 +68,7 @@ internal fun assertTuple( assertThat(actual?.requestPayloadSize).isEqualTo(expected.requestPayloadSize) assertThat(actual?.responsePayloadSize).isEqualTo(expected.responsePayloadSize) assertThat(actual?.error).isEqualTo(expected.error) + assertThat(actual?.requestContentType).isEqualTo(expected.requestContentType) } internal fun assertTransaction( diff --git a/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/repository/HttpTransactionDatabaseRepositoryTest.kt b/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/repository/HttpTransactionDatabaseRepositoryTest.kt index 32e62e20..03ec0906 100644 --- a/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/repository/HttpTransactionDatabaseRepositoryTest.kt +++ b/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/repository/HttpTransactionDatabaseRepositoryTest.kt @@ -170,9 +170,11 @@ internal class HttpTransactionDatabaseRepositoryTest { testObject.insertTransaction(transactionTwo) testObject.insertTransaction(transactionThree) - testObject.getFilteredTransactionTuples(code = "", path = "def").observeForever { result -> - assertTuples(listOf(transactionThree, transactionTwo), result) - } + testObject + .getFilteredTransactionTuples(code = "", path = "def") + .observeForever { result -> + assertTuples(listOf(transactionThree, transactionTwo), result) + } } @Test @@ -198,9 +200,11 @@ internal class HttpTransactionDatabaseRepositoryTest { testObject.insertTransaction(transactionTwo) testObject.insertTransaction(transactionThree) - testObject.getFilteredTransactionTuples(code = "4", path = "").observeForever { result -> - assertTuples(listOf(transactionThree, transactionOne), result) - } + testObject + .getFilteredTransactionTuples(code = "4", path = "") + .observeForever { result -> + assertTuples(listOf(transactionThree, transactionOne), result) + } } @Test @@ -231,9 +235,11 @@ internal class HttpTransactionDatabaseRepositoryTest { testObject.insertTransaction(transactionTwo) testObject.insertTransaction(transactionThree) testObject.insertTransaction(transactionFour) - testObject.getFilteredTransactionTuples(code = "", path = "GetDe").observeForever { result -> - assertTuples(listOf(transactionFour), result) - } + testObject + .getFilteredTransactionTuples(code = "", path = "GetDe") + .observeForever { result -> + assertTuples(listOf(transactionFour), result) + } } @Test @@ -265,10 +271,48 @@ internal class HttpTransactionDatabaseRepositoryTest { testObject.insertTransaction(transactionThree) testObject.insertTransaction(transactionFour) testObject.getFilteredTransactionTuples(code = "", path = "").observeForever { result -> - assertTuples(listOf(transactionFour, transactionThree, transactionOne, transactionTwo), result) + assertTuples( + listOf( + transactionFour, + transactionThree, + transactionOne, + transactionTwo, + ), + result, + ) } } + @Test + fun `transaction tuples are filtered by requestContentType`() = + runBlocking { + val transactionOne = + createRequest("abc").withResponseData().apply { + requestDate = 200L + requestContentType = "application/json" + } + val transactionTwo = + createRequest("abcdef").withResponseData().apply { + requestDate = 100L + requestContentType = "multipart/form-data" + } + val transactionThree = + createRequest("def").withResponseData().apply { + requestDate = 300L + requestContentType = "text/plain" + } + + testObject.insertTransaction(transactionOne) + testObject.insertTransaction(transactionTwo) + testObject.insertTransaction(transactionThree) + + testObject + .getFilteredTransactionTuples(code = "", path = "multipart") + .observeForever { result -> + assertTuples(listOf(transactionTwo), result) + } + } + @Test fun `delete all transactions`() = runBlocking { diff --git a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/HttpBinHttpTask.kt b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/HttpBinHttpTask.kt index 914f0767..e56fde96 100644 --- a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/HttpBinHttpTask.kt +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/HttpBinHttpTask.kt @@ -4,6 +4,7 @@ import com.chuckerteam.chucker.sample.HttpBinHttpTask.Api.Data import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import okio.Buffer import okio.BufferedSink import retrofit2.Call @@ -19,9 +20,11 @@ import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.Headers +import retrofit2.http.Multipart import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.PUT +import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query @@ -88,6 +91,8 @@ class HttpBinHttpTask( redirectTo("https://ascii.cl?parameter=\"Click on 'URL Encode'!\"").enqueue(noOpCallback) postForm("Value 1", "Value with symbols &$%").enqueue(noOpCallback) postRawRequestBody(oneShotRequestBody()).enqueue(noOpCallback) + val body = "This is a body".toRequestBody("text/plain".toMediaType()) + multipart("Value 1", body).enqueue(noOpCallback) anything().enqueue(noOpCallback) } @@ -244,6 +249,13 @@ class HttpBinHttpTask( @Body body: RequestBody, ): Call + @Multipart + @POST("/post") + fun multipart( + @Part("key1") value1: String, + @Part("key2") value2: RequestBody, + ): Call + @GET("/anything") fun anything(): Call