Skip to content

Commit

Permalink
Request processing refactor (#542)
Browse files Browse the repository at this point in the history
* Add limiting source

* Move request processing to a separate class

- Limit content length with limiting source.
- Do not assume that request has plain text body.

* Simplify test name
  • Loading branch information
MiSikora authored Jan 29, 2021
1 parent 087d632 commit 270dc80
Show file tree
Hide file tree
Showing 12 changed files with 230 additions and 198 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,17 @@ 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.IOUtils
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.isGzipped
import com.chuckerteam.chucker.internal.support.isProbablyPlainText
import com.chuckerteam.chucker.internal.support.uncompress
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.asResponseBody
import okio.Buffer
Expand Down Expand Up @@ -53,8 +51,8 @@ public class ChuckerInterceptor private constructor(
private val maxContentLength = builder.maxContentLength
private val cacheDirectoryProvider = builder.cacheDirectoryProvider ?: CacheDirectoryProvider { context.filesDir }
private val alwaysReadResponseBody = builder.alwaysReadResponseBody
private val io = IOUtils(builder.context)
private val headersToRedact = builder.headersToRedact.toMutableSet()
private val requestProcessor = RequestProcessor(context, collector, maxContentLength)

/** Adds [headerName] into [headersToRedact] */
public fun redactHeader(vararg headerName: String) {
Expand All @@ -63,11 +61,8 @@ public class ChuckerInterceptor private constructor(

@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val transaction = HttpTransaction()

processRequest(request, transaction)
collector.onRequestSent(transaction)
val request = requestProcessor.process(chain.request(), transaction)

val response = try {
chain.proceed(request)
Expand All @@ -81,39 +76,6 @@ public class ChuckerInterceptor private constructor(
return multiCastResponseBody(response, transaction)
}

/**
* Processes a [Request] and populates corresponding fields of a [HttpTransaction].
*/
private fun processRequest(request: Request, transaction: HttpTransaction) {
val requestBody = request.body

val encodingIsSupported = request.headers.hasSupportedContentEncoding

transaction.apply {
setRequestHeaders(request.headers)
populateUrl(request.url)

isRequestBodyPlainText = encodingIsSupported
requestDate = System.currentTimeMillis()
method = request.method
requestContentType = requestBody?.contentType()?.toString()
requestPayloadSize = requestBody?.contentLength() ?: 0L
}

if (requestBody != null && encodingIsSupported) {
val source = io.getNativeSource(Buffer(), request.isGzipped)
val buffer = source.buffer
requestBody.writeTo(buffer)
val charset = requestBody.contentType()?.charset() ?: UTF_8
if (buffer.isProbablyPlainText) {
val content = io.readFromBuffer(buffer, charset, maxContentLength)
transaction.requestBody = content
} else {
transaction.isRequestBodyPlainText = false
}
}
}

/**
* Processes [Response] metadata and populates corresponding fields of a [HttpTransaction].
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ internal class HttpTransaction(
@ColumnInfo(name = "requestContentType") var requestContentType: String?,
@ColumnInfo(name = "requestHeaders") var requestHeaders: String?,
@ColumnInfo(name = "requestBody") var requestBody: String?,
@ColumnInfo(name = "isRequestBodyPlainText") var isRequestBodyPlainText: Boolean = true,
@ColumnInfo(name = "isRequestBodyPlainText") var isRequestBodyPlainText: Boolean = false,
@ColumnInfo(name = "responseCode") var responseCode: Int?,
@ColumnInfo(name = "responseMessage") var responseMessage: String?,
@ColumnInfo(name = "error") var error: String?,
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.chuckerteam.chucker.internal.support

import okio.Buffer
import okio.ForwardingSource
import okio.Source

internal class LimitingSource(
delegate: Source,
private val bytesCountThreshold: Long,
) : ForwardingSource(delegate) {
private var bytesRead = 0L
val isThresholdReached get() = bytesRead >= bytesCountThreshold

override fun read(sink: Buffer, byteCount: Long) = if (!isThresholdReached) {
super.read(sink, byteCount).also { bytesRead += it }
} else {
-1L
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.chuckerteam.chucker.internal.support

import okhttp3.Headers
import okhttp3.Request
import okhttp3.Response
import okio.Source
import okio.gzip
Expand Down Expand Up @@ -47,18 +46,6 @@ internal val Response.contentType: String?
return this.header("Content-Type")
}

/** Checks if the OkHttp response uses gzip encoding. */
internal val Response.isGzipped: Boolean
get() {
return this.headers.containsGzip
}

/** Checks if the OkHttp request uses gzip encoding. */
internal val Request.isGzipped: Boolean
get() {
return this.headers.containsGzip
}

private val Headers.containsGzip: Boolean
get() {
return this["Content-Encoding"].equals("gzip", ignoreCase = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.chuckerteam.chucker.internal.support

import android.content.Context
import com.chuckerteam.chucker.R
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import okhttp3.Request
import okio.Buffer
import okio.IOException
import kotlin.text.Charsets.UTF_8

internal class RequestProcessor(
private val context: Context,
private val collector: ChuckerCollector,
private val maxContentLength: Long,
) {
fun process(request: Request, transaction: HttpTransaction): Request {
processMetadata(request, transaction)
processBody(request, transaction)
collector.onRequestSent(transaction)
return request
}

private fun processMetadata(request: Request, transaction: HttpTransaction) {
transaction.apply {
setRequestHeaders(request.headers)
populateUrl(request.url)

requestDate = System.currentTimeMillis()
method = request.method
requestContentType = request.body?.contentType()?.toString()
requestPayloadSize = request.body?.contentLength()
}
}

private fun processBody(request: Request, transaction: HttpTransaction) {
val body = request.body ?: return

val isEncodingSupported = request.headers.hasSupportedContentEncoding
if (!isEncodingSupported) {
return
}

val limitingSource = try {
Buffer().apply { body.writeTo(this) }
} catch (e: IOException) {
Logger.error("Failed to read request payload", e)
return
}.uncompress(request.headers).let { LimitingSource(it, maxContentLength) }

val contentBuffer = Buffer().apply { limitingSource.use { writeAll(it) } }
if (!contentBuffer.isProbablyPlainText) {
return
}

transaction.isRequestBodyPlainText = true
try {
transaction.requestBody = contentBuffer.readString(body.contentType()?.charset() ?: UTF_8)
} catch (e: IOException) {
Logger.error("Failed to process request payload", e)
}
if (limitingSource.isThresholdReached) {
transaction.requestBody += context.getString(R.string.chucker_body_content_truncated)
}
}
}
1 change: 0 additions & 1 deletion library/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
<string name="chucker_body_empty">(body is empty)</string>
<string name="chucker_body_omitted">(encoded or binary body omitted)</string>
<string name="chucker_search">Search</string>
<string name="chucker_body_unexpected_eof">\n\n--- Unexpected end of content ---</string>
<string name="chucker_body_content_truncated">\n\n--- Content truncated ---</string>
<string name="chucker_tab_network">Network</string>
<string name="chucker_tab_errors">Errors</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ internal class ChuckerInterceptorDelegate(
private val transactions = CopyOnWriteArrayList<HttpTransaction>()

private val mockContext = mockk<Context> {
every { getString(any()) } returns ""
every { getString(R.string.chucker_body_content_truncated) } returns "\n\n--- Content truncated ---"
}
private val mockCollector = mockk<ChuckerCollector> {
every { onRequestSent(any()) } returns Unit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import okio.Buffer
import okio.ByteString
import okio.ByteString.Companion.encodeUtf8
import okio.GzipSink
import okio.buffer
import org.junit.Rule
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith
Expand Down Expand Up @@ -421,5 +422,79 @@ internal class ChuckerInterceptorTest {
assertThat(transaction.isRequestBodyPlainText).isFalse()
}

@ParameterizedTest
@EnumSource(value = ClientFactory::class)
fun requestBody_isAvailableToServer(factory: ClientFactory) {
server.enqueue(MockResponse())
val client = factory.create(chuckerInterceptor)

val request = "Hello, world!".toRequestBody().toServerRequest()
client.newCall(request).execute().readByteStringBody()
val serverRequestContent = server.takeRequest().body.readByteString()

assertThat(serverRequestContent.utf8()).isEqualTo("Hello, world!")
}

@ParameterizedTest
@EnumSource(value = ClientFactory::class)
fun plainTextRequestBody_isAvailableToChucker(factory: ClientFactory) {
server.enqueue(MockResponse())
val client = factory.create(chuckerInterceptor)

val request = "Hello, world!".toRequestBody().toServerRequest()
client.newCall(request).execute().readByteStringBody()

val transaction = chuckerInterceptor.expectTransaction()
assertThat(transaction.isRequestBodyPlainText).isTrue()
assertThat(transaction.requestBody).isEqualTo("Hello, world!")
assertThat(transaction.requestPayloadSize).isEqualTo(request.body!!.contentLength())
}

@ParameterizedTest
@EnumSource(value = ClientFactory::class)
fun gzippedRequestBody_isGunzippedForChucker(factory: ClientFactory) {
server.enqueue(MockResponse())
val client = factory.create(chuckerInterceptor)

val gzippedBytes = Buffer().apply {
GzipSink(this).buffer().use { sink -> sink.writeUtf8("Hello, world!") }
}.readByteString()
val request = gzippedBytes.toRequestBody().toServerRequest()
.newBuilder()
.header("Content-Encoding", "gzip")
.build()
client.newCall(request).execute().readByteStringBody()

val transaction = chuckerInterceptor.expectTransaction()
assertThat(transaction.isRequestBodyPlainText).isTrue()
assertThat(transaction.requestBody).isEqualTo("Hello, world!")
assertThat(transaction.requestPayloadSize).isEqualTo(request.body!!.contentLength())
}

@ParameterizedTest
@EnumSource(value = ClientFactory::class)
fun requestBody_isTruncatedToMaxContentLength(factory: ClientFactory) {
server.enqueue(MockResponse())
val chuckerInterceptor = ChuckerInterceptorDelegate(
maxContentLength = SEGMENT_SIZE,
cacheDirectoryProvider = { tempDir },
)
val client = factory.create(chuckerInterceptor)

val request = "!".repeat(SEGMENT_SIZE.toInt() * 10).toRequestBody().toServerRequest()
client.newCall(request).execute().readByteStringBody()

val transaction = chuckerInterceptor.expectTransaction()
assertThat(transaction.isRequestBodyPlainText).isTrue()
assertThat(transaction.requestBody).isEqualTo(
"""
${"!".repeat(SEGMENT_SIZE.toInt())}
--- Content truncated ---
""".trimIndent()
)
assertThat(transaction.requestPayloadSize).isEqualTo(request.body!!.contentLength())
}

private fun RequestBody.toServerRequest() = Request.Builder().url(serverUrl).post(this).build()
}
Loading

0 comments on commit 270dc80

Please sign in to comment.