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

Custom body decoding #555

Merged
merged 11 commits into from
Feb 14, 2021
21 changes: 12 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# Change Log

This file follows [Keepachangelog](https://keepachangelog.com/) format.
This file follows [Keepachangelog](https://keepachangelog.com/) format.
Please add your entries according to this format.

## Unreleased

### 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.

### Fixed

* Fixed not setting request body type correctly [#538].
Expand Down Expand Up @@ -46,16 +49,16 @@ Please add your entries according to this format.

## Version 3.3.0 *(2020-09-30)*

This is a new minor release with multiple fixes and improvements.
After this release we are starting to work on a new major release 4.x with minSDK 21.
This is a new minor release with multiple fixes and improvements.
After this release we are starting to work on a new major release 4.x with minSDK 21.
Bumping minSDK to 21 is required to keep up with [newer versions of OkHttp](https://medium.com/square-corner-blog/okhttp-3-13-requires-android-5-818bb78d07ce).
Versions 3.x will be supported for 6 months (till March 2021) getting bugfixes and minor improvements.

### Summary of changes

* Added a new flag `alwaysReadResponseBody` into Chucker configuration to read the whole response body even if consumer fails to consume it.
* Added port numbers as part of the URL. Numbers appear if they are different from default 80 or 443.
* Chucker now shows partially read application responses properly. Earlier in 3.2.0 such responses didn't appear in the UI.
* Chucker now shows partially read application responses properly. Earlier in 3.2.0 such responses didn't appear in the UI.
* Transaction size is defined by actual payload size now, not by `Content-length` header.
* Added empty state UI for payloads, so no more guessing if there is some error or the payload is really empty.
* Added ability to export list of transactions.
Expand Down Expand Up @@ -198,7 +201,7 @@ This release was possible thanks to the contribution of:

### This version shouldn't be used as dependency due to [#203](https://github.com/ChuckerTeam/chucker/issues/203). Use 3.1.1 instead.

This is a new minor release of Chucker. Please note that this minor release contains multiple new features (see below) as well as multiple bugfixes.
This is a new minor release of Chucker. Please note that this minor release contains multiple new features (see below) as well as multiple bugfixes.

### Summary of Changes

Expand Down Expand Up @@ -235,11 +238,11 @@ This is a new minor release of Chucker. Please note that this minor release cont
This release was possible thanks to the contribution of:

@christopherniksch
@yoavst
@yoavst
@psh
@kmayoral
@vbuberen
@dcampogiani
@dcampogiani
@ullas-jain
@rakshit444
@olivierperez
Expand All @@ -249,8 +252,8 @@ This release was possible thanks to the contribution of:
@koral--
@redwarp
@uOOOO
@sprohaszka
@PaulWoitaschek
@sprohaszka
@PaulWoitaschek


## Version 3.0.1 *(2019-08-16)*
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ _A fork of [Chuck](https://github.com/jgilfelt/chuck)_
* [Multi-Window](#multi-window-)
* [Configure](#configure-)
* [Redact-Header️](#redact-header-️)
* [Decode-Body](#decode-body-)
* [Migrating](#migrating-)
* [Snapshots](#snapshots-)
* [FAQ](#faq-)
Expand Down Expand Up @@ -65,7 +66,7 @@ android {

**That's it!** πŸŽ‰ Chucker will now record all HTTP interactions made by your OkHttp client.

Historically, Chucker was distributed through JitPack.
Historically, Chucker was distributed through JitPack.
You can find older version of Chucker here: [![JitPack](https://jitpack.io/v/ChuckerTeam/chucker.svg)](https://jitpack.io/#ChuckerTeam/chucker).

## Features 🧰
Expand All @@ -79,6 +80,7 @@ Don't forget to check the [changelog](CHANGELOG.md) to have a look at all the ch
* **Empty release artifact** 🧼 (no traces of Chucker in your final APK).
* Support for body text search with **highlighting** πŸ•΅οΈβ€β™‚οΈ
* Support for showing **images** in HTTP Responses πŸ–Ό
* Support for custom decoding of HTTP bodies

### Multi-Window πŸšͺ

Expand Down Expand Up @@ -112,6 +114,9 @@ val chuckerInterceptor = ChuckerInterceptor.Builder(context)
// This is useful in case of parsing errors or when the response body
// is closed before being read like in Retrofit with Void and Unit types.
.alwaysReadResponseBody(true)
// Use decoder when processing request and response bodies. When multiple decoders are installed they
// are applied in an order they were added.
.addBodyDecoder(decoder)
.build()

// Don't forget to plug the ChuckerInterceptor inside the OkHttpClient
Expand All @@ -128,10 +133,28 @@ It is intended for **use during development**, and not in release builds or othe

You can redact headers that contain sensitive information by calling `redactHeader(String)` on the `ChuckerInterceptor`.


```kotlin
interceptor.redactHeader("Auth-Token", "User-Session");
```

### Decode-Body πŸ“–

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.

```kotlin
object ProtoDecoder : BinaryDecoder {
fun decodeRequest(request: Request, body: ByteString): String? = if (request.isExpectedProtoRequest) {
decodeProtoBody(body)
} else null
MiSikora marked this conversation as resolved.
Show resolved Hide resolved

fun decodeResponse(request: Response, body: ByteString): String? = if (request.isExpectedProtoResponse) {
decodeProtoBody(body)
} else null
MiSikora marked this conversation as resolved.
Show resolved Hide resolved
}
interceptorBuilder.addBodyDecoder(ProtoDecoder).build()
```

## Migrating πŸš—

If you're migrating **from [Chuck](https://github.com/jgilfelt/chuck) to Chucker**, please refer to this [migration guide](/docs/migrating-from-chuck.md).
Expand Down
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ buildscript {
gsonVersion = '2.8.6'
okhttpVersion = '4.9.1'
retrofitVersion = '2.9.0'
wireVersion = '3.6.0'

// Debug and quality control
binaryCompatibilityValidator = '0.2.4'
Expand Down Expand Up @@ -51,6 +52,7 @@ buildscript {
classpath "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:$detektVersion"
classpath "org.jlleitschuh.gradle:ktlint-gradle:$ktLintGradleVersion"
classpath "org.jetbrains.kotlinx:binary-compatibility-validator:$binaryCompatibilityValidator"
classpath "com.squareup.wire:wire-gradle-plugin:$wireVersion"
}
}
apply plugin: 'binary-compatibility-validator'
Expand Down
2 changes: 1 addition & 1 deletion gradle/kotlin-static-analysis.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ ktlint {
include fileTree("scripts/")
}
filter {
exclude("**/generated/**")
exclude { element -> element.file.path.contains("generated/") }
include("**/kotlin/**")
}
}
Expand Down
6 changes: 6 additions & 0 deletions library-no-op/api/library-no-op.api
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
public abstract interface class com/chuckerteam/chucker/api/BodyDecoder {
public abstract fun decodeRequest (Lokhttp3/Request;Lokio/ByteString;)Ljava/lang/String;
public abstract fun decodeResponse (Lokhttp3/Response;Lokio/ByteString;)Ljava/lang/String;
}

public final class com/chuckerteam/chucker/api/Chucker {
public static final field INSTANCE Lcom/chuckerteam/chucker/api/Chucker;
public static final fun dismissNotifications (Landroid/content/Context;)V
Expand All @@ -23,6 +28,7 @@ public final class com/chuckerteam/chucker/api/ChuckerInterceptor : okhttp3/Inte

public final class com/chuckerteam/chucker/api/ChuckerInterceptor$Builder {
public fun <init> (Landroid/content/Context;)V
public final fun addBodyDecoder (Ljava/lang/Object;)Lcom/chuckerteam/chucker/api/ChuckerInterceptor$Builder;
public final fun alwaysReadResponseBody (Z)Lcom/chuckerteam/chucker/api/ChuckerInterceptor$Builder;
public final fun build ()Lcom/chuckerteam/chucker/api/ChuckerInterceptor;
public final fun collector (Lcom/chuckerteam/chucker/api/ChuckerCollector;)Lcom/chuckerteam/chucker/api/ChuckerInterceptor$Builder;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.chuckerteam.chucker.api

import okhttp3.Request
import okhttp3.Response
import okio.ByteString
import okio.IOException

/**
* No-op declaration
*/
public interface BodyDecoder {
@Throws(IOException::class)
public fun decodeRequest(request: Request, body: ByteString): String?

@Throws(IOException::class)
public fun decodeResponse(response: Response, body: ByteString): String?
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ public class ChuckerInterceptor private constructor(

public fun alwaysReadResponseBody(enable: Boolean): Builder = this

public fun addBodyDecoder(decoder: Any): Builder = this

public fun build(): ChuckerInterceptor = ChuckerInterceptor(this)
}
}
6 changes: 6 additions & 0 deletions library/api/library.api
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
public abstract interface class com/chuckerteam/chucker/api/BodyDecoder {
public abstract fun decodeRequest (Lokhttp3/Request;Lokio/ByteString;)Ljava/lang/String;
public abstract fun decodeResponse (Lokhttp3/Response;Lokio/ByteString;)Ljava/lang/String;
}

public final class com/chuckerteam/chucker/api/Chucker {
public static final field INSTANCE Lcom/chuckerteam/chucker/api/Chucker;
public static final fun dismissNotifications (Landroid/content/Context;)V
Expand All @@ -23,6 +28,7 @@ public final class com/chuckerteam/chucker/api/ChuckerInterceptor : okhttp3/Inte

public final class com/chuckerteam/chucker/api/ChuckerInterceptor$Builder {
public fun <init> (Landroid/content/Context;)V
public final fun addBodyDecoder (Lcom/chuckerteam/chucker/api/BodyDecoder;)Lcom/chuckerteam/chucker/api/ChuckerInterceptor$Builder;
public final fun alwaysReadResponseBody (Z)Lcom/chuckerteam/chucker/api/ChuckerInterceptor$Builder;
public final fun build ()Lcom/chuckerteam/chucker/api/ChuckerInterceptor;
public final fun collector (Lcom/chuckerteam/chucker/api/ChuckerCollector;)Lcom/chuckerteam/chucker/api/ChuckerInterceptor$Builder;
Expand Down
29 changes: 29 additions & 0 deletions library/src/main/java/com/chuckerteam/chucker/api/BodyDecoder.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.chuckerteam.chucker.api

import okhttp3.Request
import okhttp3.Response
import okio.ByteString
import okio.IOException

/**
* Decodes HTTP request and response bodies to human–readable texts.
*/
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
Copy link
Collaborator

Choose a reason for hiding this comment

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

Looks like a typo with 2 body

Copy link
Member

Choose a reason for hiding this comment

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

I believe is actually valid KDoc where you can specify the display text for a link πŸ€”

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it is deliberate. It allows to link to body property and start the sentence with a capital letter.

* [max content length][ChuckerInterceptor.Builder.maxContentLength] and is guaranteed to be
* gunzipped even if [request] has gzip header.
*/
@Throws(IOException::class)
public fun decodeRequest(request: Request, body: ByteString): String?
Comment on lines +18 to +19
Copy link
Member

Choose a reason for hiding this comment

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

nit: This is more an API design question.

What we're modelling here is an operation that could have 3 outcomes: success (String), failure (IOException), skipped (null).

Have you considered different approaches (e.g. sealed classes or the Kotlin Result type)? I'm not saying that the approach here is wrong, but I was wondering if we have some benefit in modelling it differently.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Agree with this nitpick that it might be a good idea to model the result to leave less ambiguity.
For example, we could have something like Failure instead of null in case of unsupported content.

Copy link
Contributor Author

@MiSikora MiSikora Feb 13, 2021

Choose a reason for hiding this comment

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

Yes, I considered it and I don't think it is the right choice. I don't see how it helps us in the processing pipeline. We are interested only in the first successfully decoded body.

IOException does not model a type of respons. Methods are annotated with it because tools like adapters generally can throw this type of exception so it removes form users a burden of needing to deal with it. And in Kotlin it is easy to forget since exceptions are not checked. Also, it allows us to log exceptions for the user so they have feedback information when something goes wrong. I would be either for keeping this part as is or to remove @Throws and move the responsibility to the users.

Returning null instead of some type is a nod towards libraries like Retrofit or Moshi, where factories return it when they can't handle input. I don't mind modelling it with a sealed class but I don't know if it brings anything to the table. If you feel that it would be helpful I'll change it.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks for your insights πŸ‘ I think we're fine with the current modelling for the reasons you mentioned + is the one that has the smaller API surface

Copy link
Collaborator

Choose a reason for hiding this comment

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

This thread started with nit annotation, so let's leave it as it is :)


/**
* 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
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same typo with 2 body

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same, not a typo. :)

* [max content length][ChuckerInterceptor.Builder.maxContentLength] and is guaranteed to be
* gunzipped even if [response] has gzip header.
*/
@Throws(IOException::class)
public fun decodeResponse(response: Response, body: ByteString): String?
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import androidx.annotation.VisibleForTesting
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import com.chuckerteam.chucker.internal.support.CacheDirectoryProvider
import com.chuckerteam.chucker.internal.support.PlainTextDecoder
import com.chuckerteam.chucker.internal.support.RequestProcessor
import com.chuckerteam.chucker.internal.support.ResponseProcessor
import okhttp3.Interceptor
Expand Down Expand Up @@ -31,21 +32,25 @@ public class ChuckerInterceptor private constructor(

private val headersToRedact = builder.headersToRedact.toMutableSet()

private val decoders = builder.decoders + BUILT_IN_DECODERS

private val collector = builder.collector ?: ChuckerCollector(builder.context)

private val requestProcessor = RequestProcessor(
builder.context,
collector,
builder.maxContentLength,
headersToRedact,
decoders,
)

private val responseProcessor = ResponseProcessor(
collector,
builder.cacheDirectoryProvider ?: CacheDirectoryProvider { builder.context.filesDir },
builder.maxContentLength,
headersToRedact,
builder.alwaysReadResponseBody
builder.alwaysReadResponseBody,
decoders,
)

/** Adds [headerName] into [headersToRedact] */
Expand Down Expand Up @@ -82,6 +87,7 @@ public class ChuckerInterceptor private constructor(
internal var cacheDirectoryProvider: CacheDirectoryProvider? = null
internal var alwaysReadResponseBody = false
internal var headersToRedact = emptySet<String>()
internal var decoders = emptyList<BodyDecoder>()

/**
* Sets the [ChuckerCollector] to customize data retention.
Expand Down Expand Up @@ -126,6 +132,14 @@ public class ChuckerInterceptor private constructor(
this.alwaysReadResponseBody = enable
}

/**
* Adds a [decoder] into Chucker's processing pipeline. Decoders are applied in an order they were added in.
* Request and response bodies are set to the first non–null value returned by any of the decoders.
*/
public fun addBodyDecoder(decoder: BodyDecoder): Builder = apply {
this.decoders += decoder
}

/**
* Sets provider of a directory where Chucker will save temporary responses
* before processing them.
Expand All @@ -143,5 +157,7 @@ public class ChuckerInterceptor private constructor(

private companion object {
private const val MAX_CONTENT_LENGTH = 250_000L

private val BUILT_IN_DECODERS = listOf(PlainTextDecoder)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.chuckerteam.chucker.internal.support

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

Expand All @@ -23,4 +24,10 @@ internal val Buffer.isProbablyPlainText
false // Truncated UTF-8 sequence
}

internal val ByteString.isProbablyPlainText: Boolean
get() {
val byteCount = min(size, MAX_PREFIX_SIZE.toInt())
return Buffer().write(this, offset = 0, byteCount).isProbablyPlainText
}

private fun Int.isPlainTextChar() = Character.isWhitespace(this) || !Character.isISOControl(this)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.chuckerteam.chucker.internal.support

import com.chuckerteam.chucker.api.BodyDecoder
import okhttp3.Headers
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.Response
import okio.ByteString
import kotlin.text.Charsets.UTF_8

internal object PlainTextDecoder : BodyDecoder {
override fun decodeRequest(
request: Request,
body: ByteString,
) = body.tryDecodeAsPlainText(request.headers, request.body?.contentType())

override fun decodeResponse(
response: Response,
body: ByteString,
) = body.tryDecodeAsPlainText(response.headers, response.body?.contentType())

private fun ByteString.tryDecodeAsPlainText(
headers: Headers,
contentType: MediaType?,
) = if (headers.hasSupportedContentEncoding && isProbablyPlainText) {
string(contentType?.charset() ?: UTF_8)
} else null
MiSikora marked this conversation as resolved.
Show resolved Hide resolved
}
Loading