diff --git a/CHANGELOG.md b/CHANGELOG.md index 76a1f55bb..c2f8da783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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)* diff --git a/README.md b/README.md index b452dff24..a75542ede 100644 --- a/README.md +++ b/README.md @@ -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 { diff --git a/build.gradle b/build.gradle index 63ad9d547..58cdbf368 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/library/build.gradle b/library/build.gradle index af232db1d..6ec88e9bf 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -66,6 +66,8 @@ dependencies { implementation "com.google.code.gson:gson:$gsonVersion" + implementation "org.brotli:dec:$brotliVersion" + api platform("com.squareup.okhttp3:okhttp-bom:$okhttpVersion") api "com.squareup.okhttp3:okhttp" testImplementation "com.squareup.okhttp3:mockwebserver" diff --git a/library/src/main/java/com/chuckerteam/chucker/api/BodyDecoder.kt b/library/src/main/java/com/chuckerteam/chucker/api/BodyDecoder.kt index 9b52f1222..fe2bdcd47 100644 --- a/library/src/main/java/com/chuckerteam/chucker/api/BodyDecoder.kt +++ b/library/src/main/java/com/chuckerteam/chucker/api/BodyDecoder.kt @@ -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? @@ -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? diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/OkHttpUtils.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/OkHttpUtils.kt index 8fc5c9c1f..2f268194a 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/support/OkHttpUtils.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/OkHttpUtils.kt @@ -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 @@ -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() + else -> this } internal fun Headers.redact(names: Iterable): Headers { diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/TransactionCurlCommandSharable.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/TransactionCurlCommandSharable.kt index fb7ed5fbb..59de63d8b 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/support/TransactionCurlCommandSharable.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/TransactionCurlCommandSharable.kt @@ -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 @@ -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}\"") @@ -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) + ) + } } diff --git a/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt b/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt index b66b7f8bb..b5c7ec150 100644 --- a/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt +++ b/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt @@ -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 @@ -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) @@ -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) } @@ -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) @@ -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) @@ -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) { @@ -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 @@ -464,7 +521,8 @@ 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() @@ -472,7 +530,7 @@ internal class ChuckerInterceptorTest { assertThat(transaction.requestBody).isEqualTo( """ ${"!".repeat(SEGMENT_SIZE.toInt())} - + --- Content truncated --- """.trimIndent() ) diff --git a/library/src/test/java/com/chuckerteam/chucker/internal/support/OkHttpUtilsTest.kt b/library/src/test/java/com/chuckerteam/chucker/internal/support/OkHttpUtilsTest.kt index 6c658bd78..8c94f542e 100644 --- a/library/src/test/java/com/chuckerteam/chucker/internal/support/OkHttpUtilsTest.kt +++ b/library/src/test/java/com/chuckerteam/chucker/internal/support/OkHttpUtilsTest.kt @@ -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 @@ -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) } @@ -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!" @@ -83,6 +102,7 @@ internal class OkHttpUtilsTest { fun supportedEncodingSource(): Stream = Stream.of( null to true, "" to true, + "br" to true, "identity" to true, "gzip" to true, "other" to false, diff --git a/library/src/test/java/com/chuckerteam/chucker/internal/support/TransactionCurlCommandSharableTest.kt b/library/src/test/java/com/chuckerteam/chucker/internal/support/TransactionCurlCommandSharableTest.kt index 9a661cb33..51cc7587d 100644 --- a/library/src/test/java/com/chuckerteam/chucker/internal/support/TransactionCurlCommandSharableTest.kt +++ b/library/src/test/java/com/chuckerteam/chucker/internal/support/TransactionCurlCommandSharableTest.kt @@ -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) + } + } } diff --git a/sample/src/main/java/com/chuckerteam/chucker/sample/HttpBinHttpTask.kt b/sample/src/main/java/com/chuckerteam/chucker/sample/HttpBinHttpTask.kt index e934a54a4..213f837b5 100644 --- a/sample/src/main/java/com/chuckerteam/chucker/sample/HttpBinHttpTask.kt +++ b/sample/src/main/java/com/chuckerteam/chucker/sample/HttpBinHttpTask.kt @@ -62,6 +62,7 @@ class HttpBinHttpTask( stream(500).enqueue(noOpCallback) streamBytes(2048).enqueue(noOpCallback) image("image/png").enqueue(noOpCallback) + brotliResponse().enqueue(noOpCallback) gzipResponse().enqueue(noOpCallback) gzipRequest(Data("Some gzip request")).enqueue(noOpCallback) xml().enqueue(noOpCallback) @@ -135,7 +136,12 @@ class HttpBinHttpTask( @GET("/image") fun image(@Header("Accept") accept: String): Call + @GET("/brotli") + @Headers("Accept-Encoding: br") + fun brotliResponse(): Call + @GET("/gzip") + @Headers("Accept-Encoding: gzip") fun gzipResponse(): Call @POST("/post")