Skip to content

Commit

Permalink
Refactor IO utils (ChuckerTeam#540)
Browse files Browse the repository at this point in the history
* Extract plain text verification to Okio utils

* Move Okio utils tests to a separate suite

* Add non ASCII tests to plain text verification

* Use built-in charset constant

* Inline response variable

* Extract supported encoding verification to OkHttp utils

* Extract uncompressed source to OkHttp utils
  • Loading branch information
MiSikora authored Jan 29, 2021
1 parent b3761b7 commit 087d632
Show file tree
Hide file tree
Showing 7 changed files with 180 additions and 136 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,22 @@ import com.chuckerteam.chucker.internal.support.ReportingSink
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
import okio.GzipSource
import okio.Source
import okio.buffer
import okio.source
import java.io.File
import java.io.IOException
import java.nio.charset.Charset
import kotlin.jvm.Throws
import kotlin.text.Charsets.UTF_8

/**
* An OkHttp Interceptor which persists and displays HTTP activity
Expand Down Expand Up @@ -63,14 +64,13 @@ public class ChuckerInterceptor private constructor(
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response: Response
val transaction = HttpTransaction()

processRequest(request, transaction)
collector.onRequestSent(transaction)

try {
response = chain.proceed(request)
val response = try {
chain.proceed(request)
} catch (e: IOException) {
transaction.error = e.toString()
collector.onResponseReceived(transaction)
Expand All @@ -87,7 +87,7 @@ public class ChuckerInterceptor private constructor(
private fun processRequest(request: Request, transaction: HttpTransaction) {
val requestBody = request.body

val encodingIsSupported = io.bodyHasSupportedEncoding(request.headers[CONTENT_ENCODING])
val encodingIsSupported = request.headers.hasSupportedContentEncoding

transaction.apply {
setRequestHeaders(request.headers)
Expand All @@ -104,12 +104,8 @@ public class ChuckerInterceptor private constructor(
val source = io.getNativeSource(Buffer(), request.isGzipped)
val buffer = source.buffer
requestBody.writeTo(buffer)
var charset: Charset = UTF8
val contentType = requestBody.contentType()
if (contentType != null) {
charset = contentType.charset(UTF8) ?: UTF8
}
if (io.isPlaintext(buffer)) {
val charset = requestBody.contentType()?.charset() ?: UTF_8
if (buffer.isProbablyPlainText) {
val content = io.readFromBuffer(buffer, charset, maxContentLength)
transaction.requestBody = content
} else {
Expand All @@ -125,7 +121,7 @@ public class ChuckerInterceptor private constructor(
response: Response,
transaction: HttpTransaction
) {
val responseEncodingIsSupported = io.bodyHasSupportedEncoding(response.headers[CONTENT_ENCODING])
val responseEncodingIsSupported = response.headers.hasSupportedContentEncoding

transaction.apply {
// includes headers added later in the chain
Expand Down Expand Up @@ -191,29 +187,29 @@ public class ChuckerInterceptor private constructor(
}
}

private fun processResponseBody(
private fun processResponsePayload(
response: Response,
responseBodyBuffer: Buffer,
payload: Buffer,
transaction: HttpTransaction
) {
val responseBody = response.body ?: return

val contentType = responseBody.contentType()
val charset = contentType?.charset(UTF8) ?: UTF8
val charset = contentType?.charset() ?: UTF_8

if (io.isPlaintext(responseBodyBuffer)) {
if (payload.isProbablyPlainText) {
transaction.isResponseBodyPlainText = true
if (responseBodyBuffer.size != 0L) {
transaction.responseBody = responseBodyBuffer.readString(charset)
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 && (responseBodyBuffer.size < MAX_BLOB_SIZE)) {
transaction.responseImageData = responseBodyBuffer.readByteArray()
if (isImageContentType && (payload.size < MAX_BLOB_SIZE)) {
transaction.responseImageData = payload.readByteArray()
}
}
}
Expand All @@ -235,11 +231,8 @@ public class ChuckerInterceptor private constructor(
) : ReportingSink.Callback {

override fun onClosed(file: File?, sourceByteCount: Long) {
if (file != null) {
val buffer = readResponseBuffer(file, response.isGzipped)
if (buffer != null) {
processResponseBody(response, buffer, transaction)
}
file?.readResponsePayload()?.let { payload ->
processResponsePayload(response, payload, transaction)
}
transaction.responsePayloadSize = sourceByteCount
collector.onResponseReceived(transaction)
Expand All @@ -250,14 +243,10 @@ public class ChuckerInterceptor private constructor(
Logger.error("Failed to read response payload", exception)
}

private fun readResponseBuffer(responseBody: File, isGzipped: Boolean) = try {
val bufferedSource = responseBody.source().buffer()
val source = if (isGzipped) {
GzipSource(bufferedSource)
} else {
bufferedSource
private fun File.readResponsePayload() = try {
source().uncompress(response.headers).use { source ->
Buffer().apply { writeAll(source) }
}
Buffer().apply { source.use { writeAll(it) } }
} catch (e: IOException) {
Logger.error("Response payload couldn't be processed", e)
null
Expand Down Expand Up @@ -335,12 +324,9 @@ public class ChuckerInterceptor private constructor(
}

private companion object {
private val UTF8 = Charset.forName("UTF-8")

private const val MAX_CONTENT_LENGTH = 250_000L
private const val MAX_BLOB_SIZE = 1_000_000L

private const val CONTENT_TYPE_IMAGE = "image"
private const val CONTENT_ENCODING = "Content-Encoding"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,8 @@ import java.io.EOFException
import java.nio.charset.Charset
import kotlin.math.min

private const val PREFIX_SIZE = 64L
private const val CODE_POINT_SIZE = 16

internal class IOUtils(private val context: Context) {

/**
* Returns true if the body in question probably contains human readable text. Uses a small sample
* of code points to detect unicode control characters commonly used in binary file signatures.
*/
fun isPlaintext(buffer: Buffer): Boolean {
try {
val prefix = Buffer()
val byteCount = if (buffer.size < PREFIX_SIZE) buffer.size else PREFIX_SIZE
buffer.copyTo(prefix, 0, byteCount)
for (i in 0 until CODE_POINT_SIZE) {
if (prefix.exhausted()) {
break
}
val codePoint = prefix.readUtf8CodePoint()
if (Character.isISOControl(codePoint) && !Character.isWhitespace(codePoint)) {
return false
}
}
return true
} catch (e: EOFException) {
return false // Truncated UTF-8 sequence.
}
}

fun readFromBuffer(buffer: Buffer, charset: Charset, maxContentLength: Long): String {
val bufferSize = buffer.size
val maxBytes = min(bufferSize, maxContentLength)
Expand All @@ -61,9 +34,4 @@ internal class IOUtils(private val context: Context) {
} else {
input
}

fun bodyHasSupportedEncoding(contentEncoding: String?) =
contentEncoding.isNullOrEmpty() ||
contentEncoding.equals("identity", ignoreCase = true) ||
contentEncoding.equals("gzip", ignoreCase = true)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package com.chuckerteam.chucker.internal.support
import okhttp3.Headers
import okhttp3.Request
import okhttp3.Response
import okio.Source
import okio.gzip
import java.net.HttpURLConnection.HTTP_NOT_MODIFIED
import java.net.HttpURLConnection.HTTP_NO_CONTENT
import java.net.HttpURLConnection.HTTP_OK
import java.util.Locale

private const val HTTP_CONTINUE = 100

Expand Down Expand Up @@ -60,3 +63,17 @@ private val Headers.containsGzip: Boolean
get() {
return this["Content-Encoding"].equals("gzip", ignoreCase = true)
}

private val supportedEncodings = listOf("identity", "gzip")

internal val Headers.hasSupportedContentEncoding: Boolean
get() = get("Content-Encoding")
?.takeIf { it.isNotEmpty() }
?.let { it.toLowerCase(Locale.ROOT) in supportedEncodings }
?: true

internal fun Source.uncompress(headers: Headers) = if (headers.containsGzip) {
gzip()
} else {
this
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.chuckerteam.chucker.internal.support

import okio.Buffer
import java.io.EOFException
import kotlin.math.min

private const val MAX_PREFIX_SIZE = 64L
private const val CODE_POINT_SIZE = 16

/**
* Returns true if the [Buffer] contains human readable text. Uses a small sample
* of code points to detect unicode control characters commonly used in binary file signatures.
*/
internal val Buffer.isProbablyPlainText
get() = try {
val prefix = Buffer()
val byteCount = min(size, MAX_PREFIX_SIZE)
copyTo(prefix, 0, byteCount)
sequence { while (!prefix.exhausted()) yield(prefix.readUtf8CodePoint()) }
.take(CODE_POINT_SIZE)
.all { codePoint -> codePoint.isPlainTextChar() }
} catch (_: EOFException) {
false // Truncated UTF-8 sequence
}

private fun Int.isPlainTextChar() = Character.isWhitespace(this) || !Character.isISOControl(this)
Original file line number Diff line number Diff line change
Expand Up @@ -7,60 +7,15 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import okio.Buffer
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import java.io.EOFException
import java.nio.charset.Charset
import java.util.stream.Stream

internal class IOUtilsTest {

private val mockContext = mockk<Context>()
private val ioUtils = IOUtils(mockContext)

@Test
fun isPlaintext_withEmptyBuffer_returnsTrue() {
val buffer = Buffer()

assertThat(ioUtils.isPlaintext(buffer)).isTrue()
}

@Test
fun isPlaintext_withWhiteSpace_returnsTrue() {
val buffer = Buffer()
buffer.writeString(" ", Charset.defaultCharset())

assertThat(ioUtils.isPlaintext(buffer)).isTrue()
}

@Test
fun isPlaintext_withPlainText_returnsTrue() {
val buffer = Buffer()
buffer.writeString("just a string", Charset.defaultCharset())

assertThat(ioUtils.isPlaintext(buffer)).isTrue()
}

@Test
fun isPlaintext_withCodepoint_returnsFalse() {
val buffer = Buffer()
buffer.writeByte(0x11000000)

assertThat(ioUtils.isPlaintext(buffer)).isFalse()
}

@Test
fun isPlaintext_withEOF_returnsFalse() {
val mockBuffer = mockk<Buffer>()
every { mockBuffer.size } returns 100L
every { mockBuffer.copyTo(any<Buffer>(), any(), any()) } throws EOFException()

assertThat(ioUtils.isPlaintext(mockBuffer)).isFalse()
}

@Test
fun readFromBuffer_contentNotTruncated() {
val mockBuffer = mockk<Buffer>()
Expand Down Expand Up @@ -113,26 +68,4 @@ internal class IOUtilsTest {
val nativeSource = ioUtils.getNativeSource(buffer, true)
assertThat(nativeSource).isNotEqualTo(buffer)
}

@ParameterizedTest(name = "{0} must be supported? {1}")
@MethodSource("supportedEncodingSource")
@DisplayName("Check if body encoding is supported")
fun bodyHasSupportedEncoding(encoding: String?, isSupported: Boolean) {
val result = ioUtils.bodyHasSupportedEncoding(encoding)

assertThat(result).isEqualTo(isSupported)
}

companion object {
@JvmStatic
fun supportedEncodingSource(): Stream<Arguments> {
return Stream.of(
Arguments.of(null, true),
Arguments.of("", true),
Arguments.of("identity", true),
Arguments.of("gzip", true),
Arguments.of("other", false)
)
}
}
}
Loading

0 comments on commit 087d632

Please sign in to comment.