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

Add support for Brotli compression #563

Merged
merged 12 commits into from
Sep 29, 2021
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ Please add your entries according to this format.
### Added
* Decoding of request and response bodies can now be customized. In order to do this a `BodyDecoder` interface needs to be implemented and installed in the `ChuckerInterceptor` via `ChuckerInterceptor.addBinaryDecoder(decoder)` method. Decoded bodies are then displayed in the Chucker UI.
* Create dynamic shortcut when `ChuckerInterceptor` added. Users can opt out of this feature using `createShortcut(false)` in `ChuckerInterceptor.Builder`
* Brotli compression support

### Fixed

* Fixed request headers not being redacted in case of failures [#545].
* Fixed wrongful processing of one shot and duplex requests [#544].
* Fixed writing to database on the main thread [#487].
* Fixed writing to database on the main thread [#487].

### Removed

Expand All @@ -26,7 +27,7 @@ Please add your entries according to this format.

## Version 3.5.2 *(2021-07-28)*

This release is a re-deployment of 3.5.1, since 3.5.1 aar didn't upload properly on Maven Central.
This release is a re-deployment of 3.5.1, since 3.5.1 `aar` didn't upload properly on Maven Central.

## Version 3.5.1 *(2021-07-19)*

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ interceptor.redactHeader("Auth-Token", "User-Session");

**Warning** This feature is available in SNAPSHOT builds at the moment, not in 3.5.2

Chucker by default handles only plain text bodies. If you use a binary format like, for example, Protobuf or Thrift it won't be automatically handled by Chucker. You can, however, install a custom decoder that is capable to read data from different encodings.
Chucker by default handles only plain text, Gzip compressed or Brotli compressed. If you use a binary format like, for example, Protobuf or Thrift it won't be automatically handled by Chucker. You can, however, install a custom decoder that is capable to read data from different encodings.

```kotlin
object ProtoDecoder : BinaryDecoder {
Expand Down
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ buildscript {
paletteKtxVersion = '1.0.0'

// Networking
brotliVersion = '0.1.2'
gsonVersion = '2.8.8'
okhttpVersion = '4.9.1'
retrofitVersion = '2.9.0'
Expand Down
2 changes: 2 additions & 0 deletions library/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ dependencies {

implementation "com.google.code.gson:gson:$gsonVersion"

implementation "org.brotli:dec:$brotliVersion"
cortinico marked this conversation as resolved.
Show resolved Hide resolved

api platform("com.squareup.okhttp3:okhttp-bom:$okhttpVersion")
api "com.squareup.okhttp3:okhttp"
testImplementation "com.squareup.okhttp3:mockwebserver"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public interface BodyDecoder {
* Returns a text representation of [body] that will be displayed in Chucker UI transaction,
* or `null` if [request] cannot be handled by this decoder. [Body][body] is no longer than
* [max content length][ChuckerInterceptor.Builder.maxContentLength] and is guaranteed to be
* gunzipped even if [request] has gzip header.
* uncompressed even if [request] has gzip or br header.
*/
@Throws(IOException::class)
public fun decodeRequest(request: Request, body: ByteString): String?
Expand All @@ -22,7 +22,7 @@ public interface BodyDecoder {
* Returns a text representation of [body] that will be displayed in Chucker UI transaction,
* or `null` if [response] cannot be handled by this decoder. [Body][body] is no longer than
* [max content length][ChuckerInterceptor.Builder.maxContentLength] and is guaranteed to be
* gunzipped even if [response] has gzip header.
* uncompressed even if [response] has gzip or br header.
*/
@Throws(IOException::class)
public fun decodeResponse(response: Response, body: ByteString): String?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package com.chuckerteam.chucker.internal.support
import okhttp3.Headers
import okhttp3.Response
import okio.Source
import okio.buffer
import okio.gzip
import okio.source
import org.brotli.dec.BrotliInputStream
import java.net.HttpURLConnection.HTTP_NOT_MODIFIED
import java.net.HttpURLConnection.HTTP_NO_CONTENT
import java.net.HttpURLConnection.HTTP_OK
Expand Down Expand Up @@ -51,18 +54,23 @@ private val Headers.containsGzip: Boolean
return this["Content-Encoding"].equals("gzip", ignoreCase = true)
}

private val supportedEncodings = listOf("identity", "gzip")
private val Headers.containsBrotli: Boolean
get() {
return this["Content-Encoding"].equals("br", ignoreCase = true)
}

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

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

internal fun Source.uncompress(headers: Headers) = if (headers.containsGzip) {
gzip()
} else {
this
internal fun Source.uncompress(headers: Headers) = when {
headers.containsGzip -> gzip()
headers.containsBrotli -> BrotliInputStream(this.buffer().inputStream()).source()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should add a list of supported compression types in ChuckerInterceptor KDoc. I'm saying that because BinaryDecoder mentions that it the body will be gunzipped but now we also understand Brotli. This way in BinaryDecoder KDoc we could just mention that body will be uncompressed and to see which compression types are supported see ChuckerInterceptor.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. We should mention in KDoc or add something in our Readme, because now it is not clear for end users that Chucker supports compressed payloads, so even having Gzip is something unclear.

else -> this
}

internal fun Headers.redact(names: Iterable<String>): Headers {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.chuckerteam.chucker.internal.support

import android.content.Context
import com.chuckerteam.chucker.internal.data.entity.HttpHeader
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import okio.Buffer
import okio.Source
Expand All @@ -14,9 +15,7 @@ internal class TransactionCurlCommandSharable(
val headers = transaction.getParsedRequestHeaders()

headers?.forEach { header ->
if ("Accept-Encoding".equals(header.name, ignoreCase = true) &&
"gzip".equals(header.value, ignoreCase = true)
) {
if (isCompressed(header)) {
compressed = true
}
writeUtf8(" -H \"${header.name}: ${header.value}\"")
Expand All @@ -29,4 +28,12 @@ internal class TransactionCurlCommandSharable(
}
writeUtf8((if (compressed) " --compressed " else " ") + transaction.getFormattedUrl(encode = false))
}

private fun isCompressed(header: HttpHeader): Boolean {
return (
"Accept-Encoding".equals(header.name, ignoreCase = true) &&
"gzip".contains(header.value, ignoreCase = true) ||
"br".contains(header.value, ignoreCase = true)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import okhttp3.mockwebserver.SocketPolicy
import okio.Buffer
import okio.BufferedSink
import okio.ByteString
import okio.ByteString.Companion.decodeHex
import okio.ByteString.Companion.encodeUtf8
import okio.GzipSink
import okio.buffer
Expand All @@ -36,12 +37,15 @@ import java.net.HttpURLConnection.HTTP_NO_CONTENT

@ExtendWith(NoLoggerRule::class)
internal class ChuckerInterceptorTest {
@get:Rule val server = MockWebServer()
@get:Rule
val server = MockWebServer()

private val serverUrl = server.url("/") // Starts server implicitly

@TempDir lateinit var tempDir: File
private val chuckerInterceptor = ChuckerInterceptorDelegate(cacheDirectoryProvider = { tempDir })
@TempDir
lateinit var tempDir: File
private val chuckerInterceptor =
ChuckerInterceptorDelegate(cacheDirectoryProvider = { tempDir })

@ParameterizedTest
@EnumSource(value = ClientFactory::class)
Expand All @@ -53,7 +57,8 @@ internal class ChuckerInterceptorTest {

val client = factory.create(chuckerInterceptor)
client.newCall(request).execute().readByteStringBody()
val responseBody = ByteString.of(*chuckerInterceptor.expectTransaction().responseImageData!!)
val responseBody =
ByteString.of(*chuckerInterceptor.expectTransaction().responseImageData!!)

assertThat(responseBody).isEqualTo(expectedBody)
}
Expand Down Expand Up @@ -108,8 +113,10 @@ internal class ChuckerInterceptorTest {

@ParameterizedTest
@EnumSource(value = ClientFactory::class)
fun `gzipped response body without content is transparent to Chucker`(factory: ClientFactory) {
server.enqueue(MockResponse().addHeader("Content-Encoding: gzip").setResponseCode(HTTP_NO_CONTENT))
fun `compressed response body without content is transparent to Chucker`(factory: ClientFactory) {
server.enqueue(
MockResponse().addHeader("Content-Encoding: gzip").setResponseCode(HTTP_NO_CONTENT)
)
val request = Request.Builder().url(serverUrl).build()

val client = factory.create(chuckerInterceptor)
Expand All @@ -121,8 +128,10 @@ internal class ChuckerInterceptorTest {

@ParameterizedTest
@EnumSource(value = ClientFactory::class)
fun `gzipped response body without content is transparent to consumer`(factory: ClientFactory) {
server.enqueue(MockResponse().addHeader("Content-Encoding: gzip").setResponseCode(HTTP_NO_CONTENT))
fun `compressed response body without content is transparent to consumer`(factory: ClientFactory) {
server.enqueue(
MockResponse().addHeader("Content-Encoding: br").setResponseCode(HTTP_NO_CONTENT)
)
val request = Request.Builder().url(serverUrl).build()

val client = factory.create(chuckerInterceptor)
Expand All @@ -131,6 +140,49 @@ internal class ChuckerInterceptorTest {
assertThat(responseBody).isNull()
}

@ParameterizedTest
@EnumSource(value = ClientFactory::class)
fun `brotli response body is uncompressed for Chucker`(factory: ClientFactory) {
val brotliEncodedString =
"1bce00009c05ceb9f028d14e416230f718960a537b0922d2f7b6adef56532c08dff44551516690131494db" +
"6021c7e3616c82c1bc2416abb919aaa06e8d30d82cc2981c2f5c900bfb8ee29d5c03deb1c0dacff80e" +
"abe82ba64ed250a497162006824684db917963ecebe041b352a3e62d629cc97b95cac24265b175171e" +
"5cb384cd0912aeb5b5dd9555f2dd1a9b20688201"

val brotliSource = Buffer().write(brotliEncodedString.decodeHex())

server.enqueue(MockResponse().addHeader("Content-Encoding: br").setBody(brotliSource))
val request = Request.Builder().url(serverUrl).build()

val client = factory.create(chuckerInterceptor)
client.newCall(request).execute().readByteStringBody()
val transaction = chuckerInterceptor.expectTransaction()

assertThat(transaction.isRequestBodyEncoded).isFalse()
assertThat(transaction.responseBody).contains("\"brotli\": true")
assertThat(transaction.responseBody).contains("\"Accept-Encoding\": \"br\"")
}

@ParameterizedTest
@EnumSource(value = ClientFactory::class)
fun `brotli response body is not changed for consumer`(factory: ClientFactory) {
val brotliEncodedString =
"1bce00009c05ceb9f028d14e416230f718960a537b0922d2f7b6adef56532c08dff44551516690131494db" +
"6021c7e3616c82c1bc2416abb919aaa06e8d30d82cc2981c2f5c900bfb8ee29d5c03deb1c0dacff80e" +
"abe82ba64ed250a497162006824684db917963ecebe041b352a3e62d629cc97b95cac24265b175171e" +
"5cb384cd0912aeb5b5dd9555f2dd1a9b20688201"

val brotliSource = Buffer().write(brotliEncodedString.decodeHex())

server.enqueue(MockResponse().addHeader("Content-Encoding: br").setBody(brotliSource))
val request = Request.Builder().url(serverUrl).build()

val client = factory.create(chuckerInterceptor)
val responseBody = client.newCall(request).execute().readByteStringBody()!!

assertThat(responseBody.hex()).isEqualTo(brotliEncodedString)
}

@ParameterizedTest
@EnumSource(value = ClientFactory::class)
fun `plain text response body is available to Chucker`(factory: ClientFactory) {
Expand Down Expand Up @@ -202,7 +254,12 @@ internal class ChuckerInterceptorTest {
//
// It is only best effort attempt and if we download less than 8KiB reading will continue
// in 8KiB batches until at least 8KiB is downloaded.
assertThat(transaction.responsePayloadSize).isIn(Range.closed(SEGMENT_SIZE, 2 * SEGMENT_SIZE))
assertThat(transaction.responsePayloadSize).isIn(
Range.closed(
SEGMENT_SIZE,
2 * SEGMENT_SIZE
)
)
}

@ParameterizedTest
Expand Down Expand Up @@ -464,15 +521,16 @@ internal class ChuckerInterceptorTest {
)
val client = factory.create(chuckerInterceptor)

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

val transaction = chuckerInterceptor.expectTransaction()
assertThat(transaction.isRequestBodyEncoded).isFalse()
assertThat(transaction.requestBody).isEqualTo(
"""
${"!".repeat(SEGMENT_SIZE.toInt())}

--- Content truncated ---
""".trimIndent()
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import okhttp3.Headers.Companion.headersOf
import okhttp3.Response
import okio.Buffer
import okio.BufferedSource
import okio.ByteString.Companion.decodeHex
import okio.GzipSink
import okio.buffer
import org.junit.jupiter.api.DisplayName
Expand Down Expand Up @@ -45,7 +46,7 @@ internal class OkHttpUtilsTest {
}

@Test
fun `gizpped response is gunzipped`() {
fun `gzip compressed response is uncompressed`() {
val content = "Hello there!"
val source = Buffer()
GzipSink(source).buffer().use { it.writeUtf8(content) }
Expand All @@ -57,6 +58,24 @@ internal class OkHttpUtilsTest {
assertThat(result).isEqualTo(content)
}

@Test
fun `brotli compressed response is uncompressed`() {
val brotliEncodedString =
"1bce00009c05ceb9f028d14e416230f718960a537b0922d2f7b6adef56532c08dff44551516690131494db" +
"6021c7e3616c82c1bc2416abb919aaa06e8d30d82cc2981c2f5c900bfb8ee29d5c03deb1c0dacff80e" +
"abe82ba64ed250a497162006824684db917963ecebe041b352a3e62d629cc97b95cac24265b175171e" +
"5cb384cd0912aeb5b5dd9555f2dd1a9b20688201"

val brotliSource = Buffer().write(brotliEncodedString.decodeHex())

val result = brotliSource.uncompress(headersOf("Content-Encoding", "br"))
.buffer()
.use(BufferedSource::readUtf8)

assertThat(result).contains("\"brotli\": true,")
assertThat(result).contains("\"Accept-Encoding\": \"br\"")
}

@Test
fun `plain text response is not affected by uncompressing`() {
val content = "Hello there!"
Expand All @@ -83,6 +102,7 @@ internal class OkHttpUtilsTest {
fun supportedEncodingSource(): Stream<Arguments> = Stream.of(
null to true,
"" to true,
"br" to true,
"identity" to true,
"gzip" to true,
"other" to false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,50 @@ internal class TransactionCurlCommandSharableTest {
requestBody = dummyRequestBody
}
val shareableTransaction = TransactionCurlCommandSharable(transaction)
val expectedCurlCommand = "curl -X $method --data $'$dummyRequestBody' http://localhost/getUsers"
val expectedCurlCommand =
"curl -X $method --data $'$dummyRequestBody' http://localhost/getUsers"

val sharedContent = shareableTransaction.toSharableUtf8Content(context)

assertThat(sharedContent).isEqualTo(expectedCurlCommand)
}
}

@Test
fun `create cURL command with gzip header`() {
val headers = listOf(HttpHeader("Accept-Encoding", "gzip"))
val convertedHeader = JsonConverter.instance.toJson(headers)

requestMethods.forEach { method ->
val transaction = TestTransactionFactory.createTransaction(method).apply {
requestHeaders = convertedHeader
}
val sharableTransaction = TransactionCurlCommandSharable(transaction)

val sharedContent = sharableTransaction.toSharableUtf8Content(context)

val expected = "curl -X $method -H \"Accept-Encoding: gzip\" --compressed http://localhost/getUsers"

assertThat(sharedContent).isEqualTo(expected)
}
}

@Test
fun `create cURL command with brotli header`() {
val headers = listOf(HttpHeader("Accept-Encoding", "br"))
val convertedHeader = JsonConverter.instance.toJson(headers)

requestMethods.forEach { method ->
val transaction = TestTransactionFactory.createTransaction(method).apply {
requestHeaders = convertedHeader
}
val sharableTransaction = TransactionCurlCommandSharable(transaction)

val sharedContent = sharableTransaction.toSharableUtf8Content(context)

val expected = "curl -X $method -H \"Accept-Encoding: br\" --compressed http://localhost/getUsers"

assertThat(sharedContent).isEqualTo(expected)
}
}
}
Loading