Skip to content
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

Fix depleting bytes from the response. #226

Merged
merged 6 commits into from
Feb 9, 2020
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions library/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ dependencies {
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion"
testImplementation "org.junit.jupiter:junit-jupiter-params:$junitVersion"
testImplementation "io.mockk:mockk:$mockkVersion"
testImplementation "com.squareup.okhttp3:mockwebserver:$okhttp3Version"
}

apply from: rootProject.file('gradle/gradle-mvn-push.gradle')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package com.chuckerteam.chucker.api
import android.content.Context
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import com.chuckerteam.chucker.internal.support.IOUtils
import com.chuckerteam.chucker.internal.support.hasBody
import com.chuckerteam.chucker.internal.support.contentLenght
import com.chuckerteam.chucker.internal.support.contentType
import com.chuckerteam.chucker.internal.support.isGzipped
import java.io.IOException
import java.nio.charset.Charset
import okhttp3.Headers
Expand Down Expand Up @@ -58,10 +60,10 @@ class ChuckerInterceptor @JvmOverloads constructor(
throw e
}

processResponse(response, transaction)
val processedResponse = processResponse(response, transaction)
collector.onResponseReceived(transaction)

return response
return processedResponse
}

/**
Expand All @@ -74,7 +76,7 @@ class ChuckerInterceptor @JvmOverloads constructor(

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

isRequestBodyPlainText = encodingIsSupported
requestDate = System.currentTimeMillis()
Expand All @@ -84,7 +86,7 @@ class ChuckerInterceptor @JvmOverloads constructor(
}

if (requestBody != null && encodingIsSupported) {
val source = io.getNativeSource(Buffer(), io.bodyIsGzipped(request.headers().get(CONTENT_ENCODING)))
val source = io.getNativeSource(Buffer(), request.isGzipped)
val buffer = source.buffer()
requestBody.writeTo(buffer)
var charset: Charset = UTF8
Expand All @@ -104,8 +106,7 @@ class ChuckerInterceptor @JvmOverloads constructor(
/**
* Processes a [Response] and populates corresponding fields of a [HttpTransaction].
*/
private fun processResponse(response: Response, transaction: HttpTransaction) {
val responseBody = response.body()!!
private fun processResponse(response: Response, transaction: HttpTransaction): Response {
val responseEncodingIsSupported = io.bodyHasSupportedEncoding(response.headers().get(CONTENT_ENCODING))

transaction.apply {
Expand All @@ -120,35 +121,35 @@ class ChuckerInterceptor @JvmOverloads constructor(
responseCode = response.code()
responseMessage = response.message()

responseContentType = responseBody.contentType()?.toString()
responseContentLength = responseBody.contentLength()
responseContentType = response.contentType
responseContentLength = response.contentLenght

tookMs = (response.receivedResponseAtMillis() - response.sentRequestAtMillis())
}

if (response.hasBody() && responseEncodingIsSupported) {
processResponseBody(response, responseBody, transaction)
return if (responseEncodingIsSupported) {
processResponseBody(response, transaction)
} else {
response
}
}

/**
* Processes a [ResponseBody] and populates corresponding fields of a [HttpTransaction].
*/
private fun processResponseBody(response: Response, responseBody: ResponseBody, transaction: HttpTransaction) {
private fun processResponseBody(response: Response, transaction: HttpTransaction): Response {
val responseBody = response.body() ?: return response

val contentType = responseBody.contentType()
val charset: Charset = contentType?.charset(UTF8) ?: UTF8
val charset = contentType?.charset(UTF8) ?: UTF8
val contentLength = responseBody.contentLength()

val source = responseBody.source()
source.request(Long.MAX_VALUE) // Buffer the entire body.
var buffer = source.buffer()

if (io.bodyIsGzipped(response.headers()[CONTENT_ENCODING])) {
GzipSource(buffer.clone()).use { gzippedResponseBody ->
buffer = Buffer()
buffer.writeAll(gzippedResponseBody)
}
val responseSource = if (response.isGzipped) {
GzipSource(responseBody.source())
} else {
responseBody.source()
}
val buffer = Buffer().apply { responseSource.use { writeAll(it) } }

if (io.isPlaintext(buffer)) {
transaction.isResponseBodyPlainText = true
Expand All @@ -162,9 +163,13 @@ class ChuckerInterceptor @JvmOverloads constructor(
(contentType?.toString()?.contains(CONTENT_TYPE_IMAGE, ignoreCase = true) == true)

if (isImageContentType && buffer.size() < MAX_BLOB_SIZE) {
transaction.responseImageData = buffer.readByteArray()
transaction.responseImageData = buffer.clone().readByteArray()
}
}

return response.newBuilder()
.body(ResponseBody.create(contentType, contentLength, buffer))
.build()
}

/** Overrides all headers from [headersToRedact] with `**` */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package com.chuckerteam.chucker.internal.data.entity

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
Expand All @@ -13,8 +12,8 @@ import com.chuckerteam.chucker.internal.support.FormatUtils
import com.chuckerteam.chucker.internal.support.JsonConverter
import com.google.gson.reflect.TypeToken
import java.util.Date
import kotlin.collections.ArrayList
import okhttp3.Headers
import okhttp3.HttpUrl

/**
* Represent a full HTTP transaction (with Request and Response). Instances of this classes
Expand Down Expand Up @@ -208,12 +207,11 @@ internal class HttpTransaction(
return responseBody?.let { formatBody(it, responseContentType) } ?: ""
}

fun populateUrl(url: String): HttpTransaction {
this.url = url
val uri = Uri.parse(url)
host = uri.host
path = ("${uri.path}${uri.query?.let { "?$it" } ?: ""}")
scheme = uri.scheme
fun populateUrl(url: HttpUrl): HttpTransaction {
this.url = url.toString()
host = url.host()
path = ("/${url.pathSegments().joinToString("/")}${url.query()?.let { "?$it" } ?: ""}")
scheme = url.scheme()
return this
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,4 @@ internal class IOUtils(private val context: Context) {
contentEncoding.isNullOrEmpty() ||
contentEncoding.equals("identity", ignoreCase = true) ||
contentEncoding.equals("gzip", ignoreCase = true)

fun bodyIsGzipped(contentEncoding: String?) = CONTENT_ENCODING_GZIP.equals(contentEncoding, ignoreCase = true)

private companion object {
const val CONTENT_ENCODING_GZIP = "gzip"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,38 @@

package com.chuckerteam.chucker.internal.support

import java.net.HttpURLConnection.HTTP_NOT_MODIFIED
import java.net.HttpURLConnection.HTTP_NO_CONTENT
import java.net.HttpURLConnection.HTTP_OK
import okhttp3.Headers
import okhttp3.Request
import okhttp3.Response

const val HTTP_CONTINUE = 100
internal val Response.contentLenght: Long
get() {
return this.header("Content-Length")?.toLongOrNull() ?: -1
}

/** Returns true if the response must have a (possibly 0-length) body. See RFC 7231. */
internal fun Response.hasBody(): Boolean {
// HEAD requests never yield a body regardless of the response headers.
if (this.request().method() == "HEAD") {
return false
internal val Response.isChunked: Boolean
get() {
return this.header("Transfer-Encoding").equals("chunked", ignoreCase = true)
}

val responseCode = this.code()
if ((responseCode < HTTP_CONTINUE || responseCode >= HTTP_OK) &&
responseCode != HTTP_NO_CONTENT &&
responseCode != HTTP_NOT_MODIFIED
) {
return true
internal val Response.contentType: String?
get() {
return this.header("Content-Type")
}

// If the Content-Length or Transfer-Encoding headers disagree with the response code, the
// response is malformed. For best compatibility, we honor the headers.
return this.contentLenght != -1L || this.isChunked
}
/** Checks if the OkHttp response uses gzip encoding. */
internal val Response.isGzipped: Boolean
get() {
return this.headers().containsGzip
}

internal val Response.contentLenght: Long
/** Checks if the OkHttp response uses gzip encoding. */
MiSikora marked this conversation as resolved.
Show resolved Hide resolved
internal val Request.isGzipped: Boolean
get() {
return this.header("Content-Length")?.toLongOrNull() ?: -1
return this.headers().containsGzip
}

internal val Response.isChunked: Boolean
private val Headers.containsGzip: Boolean
get() {
return this.header("Transfer-Encoding").equals("chunked", ignoreCase = true)
return this["Content-Encoding"].equals("gzip", ignoreCase = true)
}
11 changes: 11 additions & 0 deletions library/src/test/java/com/chuckerteam/chucker/TestUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.chuckerteam.chucker

import java.io.File
import okio.Buffer
import okio.Okio

fun getResourceFile(file: String): Buffer {
return Buffer().apply {
writeAll(Okio.buffer(Okio.source(File("./src/test/resources/$file"))))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.chuckerteam.chucker.api

import android.content.Context
import com.chuckerteam.chucker.getResourceFile
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import io.mockk.every
import io.mockk.mockk
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import okio.ByteString
import okio.GzipSink
import org.junit.Rule
import org.junit.Test

class ChuckerInterceptorTest {
@get:Rule val server = MockWebServer()
private val serverUrl = server.url("/") // Starts server implicitly

private var transaction: HttpTransaction? = null
private val mockContext = mockk<Context> {
every { getString(any()) } returns ""
}
private val mockCollector = mockk<ChuckerCollector>() {
every { onRequestSent(any()) } returns Unit
every { onResponseReceived(any()) } answers {
transaction = args[0] as HttpTransaction
}
}

private val client = OkHttpClient.Builder()
.addInterceptor(ChuckerInterceptor(mockContext, mockCollector))
.build()

@Test
fun imageResponse_isAvailableToChucker() {
val image = getResourceFile("sample_image.png")
server.enqueue(MockResponse().addHeader("Content-Type:image/jpeg").setBody(image))
val request = Request.Builder().url(serverUrl).build()
val expectedBody = image.snapshot()

client.newCall(request).execute()

assertEquals(expectedBody, ByteString.of(*transaction!!.responseImageData!!))
}

@Test
fun imageResponse_isAvailableToTheEndConsumer() {
val image = getResourceFile("sample_image.png")
server.enqueue(MockResponse().addHeader("Content-Type:image/jpeg").setBody(image))
val request = Request.Builder().url(serverUrl).build()
val expectedBody = image.snapshot()

val responseBody = client.newCall(request).execute().body()!!.source().readByteString()

assertEquals(expectedBody, responseBody)
}

@Test
fun gzippedBody_isGunzippedForChucker() {
val bytes = Buffer().apply { writeUtf8("Hello, world!") }
val gzippedBytes = Buffer().apply {
GzipSink(this).use { sink -> sink.write(bytes, bytes.size()) }
}
server.enqueue(MockResponse().addHeader("Content-Encoding: gzip").setBody(gzippedBytes))
val request = Request.Builder().url(serverUrl).build()

client.newCall(request).execute()

assertTrue(transaction!!.isResponseBodyPlainText)
assertEquals("Hello, world!", transaction!!.responseBody)
}

@Test
fun gzippedBody_isGunzippedForTheEndConsumer() {
val bytes = Buffer().apply { writeUtf8("Hello, world!") }
val gzippedBytes = Buffer().apply {
GzipSink(this).use { sink -> sink.write(bytes, bytes.size()) }
}
server.enqueue(MockResponse().addHeader("Content-Encoding: gzip").setBody(gzippedBytes))
val request = Request.Builder().url(serverUrl).build()

val responseBody = client.newCall(request).execute().body()!!.source().readByteString()

assertEquals("Hello, world!", responseBody.utf8())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,16 +127,6 @@ class IOUtilsTest {
assertEquals(supported, result)
}

@Test
fun bodyIsGzipped_withGzip_returnsTrue() {
assertTrue(ioUtils.bodyIsGzipped("gzip"))
}

@Test
fun bodyIsGzipped_withOtherEncoding_returnsFalse() {
assertFalse(ioUtils.bodyIsGzipped("other"))
}

companion object {
@JvmStatic
fun supportedEncodingSource(): Stream<Arguments> {
Expand Down
Loading