-
-
Notifications
You must be signed in to change notification settings - Fork 427
feat: Multipart request support #1548
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<String>, | ||||||||||||||||||
| private val bodyDecoders: List<BodyDecoder>, | ||||||||||||||||||
| ) { | ||||||||||||||||||
| private companion object { | ||||||||||||||||||
| const val MAX_PREFIX_LENGTH = 64L | ||||||||||||||||||
| const val MAX_CODEPOINTS_TO_CHECK = 16 | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+21
to
+24
|
||||||||||||||||||
|
|
||||||||||||||||||
| fun process( | ||||||||||||||||||
| request: Request, | ||||||||||||||||||
| transaction: HttpTransaction, | ||||||||||||||||||
|
|
@@ -60,14 +66,22 @@ 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) } | ||||||||||||||||||
| } catch (e: IOException) { | ||||||||||||||||||
| 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") | ||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can you string interpolate here? |
||||||||||||||||||
| } | ||||||||||||||||||
| 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) | ||||||||||||||||||
|
||||||||||||||||||
| partBody.writeTo(buffer) | |
| try { | |
| partBody.writeTo(buffer) | |
| } catch (e: IOException) { | |
| Logger.error("Failed to read multipart request payload", e) | |
| append(context.getString(R.string.chucker_body_content_truncated)) | |
| return@buildString | |
| } |
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Each multipart part's body is fully read into memory before checking against maxContentLength. For very large plain text parts, this could cause memory issues. Consider checking the accumulated length before reading each part, or implementing a streaming approach similar to how regular request bodies are handled with LimitingSource.
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The isPlainText method duplicates existing functionality. The codebase already has a Buffer.isProbablyPlainText extension property in OkioUtils.kt that does the same thing. Consider using the existing utility instead of creating a duplicate implementation. This would reduce code duplication and ensure consistent behavior across the codebase.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -97,21 +97,27 @@ internal class TransactionActivity : BaseChuckerActivity() { | |
|
|
||
| override fun onOptionsItemSelected(item: MenuItem) = | ||
| when (item.itemId) { | ||
| R.id.share_text -> | ||
| R.id.share_text -> { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please send those changes in a separate PR |
||
| 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. which 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)") | ||
| } | ||
|
Comment on lines
+31
to
+73
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you send this + all the other related changes as a separate PR please? |
||
| 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 { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we need to bump the DB version because of this