Skip to content

Commit 8378c54

Browse files
authored
Support empty buffers on Gzip decompression (#139)
Fixes #138 Gzipped responses that return an empty message Buffer fail with an `EOFException`, halting the request chain.
1 parent 37fb4f9 commit 8378c54

File tree

7 files changed

+179
-0
lines changed

7 files changed

+179
-0
lines changed

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ dokka-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref
3232
junit = { module = "junit:junit", version.ref = "junit" }
3333
kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
3434
kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
35+
kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
3536
kotlin-jsr223 = { module = "org.jetbrains.kotlin:kotlin-scripting-jsr223", version.ref = "kotlin" }
3637
kotlin-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
3738
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
@@ -44,6 +45,7 @@ moshiKotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi
4445
moshiKotlinCodegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
4546
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
4647
okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
48+
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
4749
okio-core = { module = "com.squareup.okio:okio", version.ref = "okio" }
4850
protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" }
4951
protobuf-java-util = { module = "com.google.protobuf:protobuf-java-util", version.ref = "protobuf" }

library/src/main/kotlin/com/connectrpc/compression/GzipCompressionPool.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ object GzipCompressionPool : CompressionPool {
3838

3939
override fun decompress(buffer: Buffer): Buffer {
4040
val result = Buffer()
41+
if (buffer.size == 0L) return result
42+
4143
GzipSource(buffer).use {
4244
while (it.read(result, Int.MAX_VALUE.toLong()) != -1L) {
4345
// continue reading.

library/src/test/kotlin/com/connectrpc/compression/GzipCompressionPoolTest.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,11 @@ class GzipCompressionPoolTest {
4343
val resultString = compressionPool.decompress(result).readUtf8()
4444
assertThat(resultString).isEqualTo("some_string")
4545
}
46+
47+
@Test
48+
fun emptyBufferGzipDecompression() {
49+
val compressionPool = GzipCompressionPool
50+
val resultString = compressionPool.decompress(Buffer()).readUtf8()
51+
assertThat(resultString).isEqualTo("")
52+
}
4653
}

library/src/test/kotlin/com/connectrpc/protocols/ConnectInterceptorTest.kt

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,34 @@ class ConnectInterceptorTest {
170170
assertThat(decompressed.readUtf8()).isEqualTo("message")
171171
}
172172

173+
@Test
174+
fun compressedEmptyRequestMessage() {
175+
val config = ProtocolClientConfig(
176+
host = "https://connectrpc.com",
177+
serializationStrategy = serializationStrategy,
178+
requestCompression = RequestCompression(1, GzipCompressionPool),
179+
compressionPools = listOf(GzipCompressionPool),
180+
)
181+
val connectInterceptor = ConnectInterceptor(config)
182+
val unaryFunction = connectInterceptor.unaryFunction()
183+
184+
val request = unaryFunction.requestFunction(
185+
HTTPRequest(
186+
url = URL(config.host),
187+
contentType = "content_type",
188+
headers = emptyMap(),
189+
message = "".commonAsUtf8ToByteArray(),
190+
methodSpec = MethodSpec(
191+
path = "",
192+
requestClass = Any::class,
193+
responseClass = Any::class,
194+
),
195+
),
196+
)
197+
val decompressed = GzipCompressionPool.decompress(Buffer().write(request.message!!))
198+
assertThat(decompressed.readUtf8()).isEqualTo("")
199+
}
200+
173201
@Test
174202
fun uncompressedResponseMessage() {
175203
val config = ProtocolClientConfig(
@@ -214,6 +242,28 @@ class ConnectInterceptorTest {
214242
assertThat(response.message.readUtf8()).isEqualTo("message")
215243
}
216244

245+
@Test
246+
fun compressedEmptyResponseMessage() {
247+
val config = ProtocolClientConfig(
248+
host = "https://connectrpc.com",
249+
serializationStrategy = serializationStrategy,
250+
compressionPools = listOf(GzipCompressionPool),
251+
)
252+
val connectInterceptor = ConnectInterceptor(config)
253+
val unaryFunction = connectInterceptor.unaryFunction()
254+
255+
val response = unaryFunction.responseFunction(
256+
HTTPResponse(
257+
code = Code.OK,
258+
headers = mapOf(CONTENT_ENCODING to listOf(GzipCompressionPool.name())),
259+
message = Buffer(),
260+
trailers = emptyMap(),
261+
tracingInfo = null,
262+
),
263+
)
264+
assertThat(response.message.readUtf8()).isEqualTo("")
265+
}
266+
217267
@Test
218268
fun responseError() {
219269
val config = ProtocolClientConfig(

okhttp/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ dependencies {
1616
implementation(libs.kotlin.coroutines.core)
1717

1818
api(project(":library"))
19+
20+
testImplementation(libs.assertj)
21+
testImplementation(libs.okhttp.mockwebserver)
22+
testImplementation(libs.kotlin.coroutines.test)
23+
testImplementation(project(":extensions:google-java"))
24+
testImplementation(project(":examples:generated-google-java"))
1925
}
2026

2127
mavenPublishing {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright 2022-2023 The Connect Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.connectrpc.okhttp
16+
17+
import okhttp3.mockwebserver.MockWebServer
18+
import org.junit.rules.TestWatcher
19+
import org.junit.runner.Description
20+
21+
class MockWebServerRule(
22+
private val port: Int = 0,
23+
) : TestWatcher() {
24+
25+
lateinit var server: MockWebServer
26+
private set
27+
28+
override fun starting(description: Description) {
29+
super.starting(description)
30+
server = MockWebServer()
31+
server.start(port)
32+
}
33+
34+
override fun finished(description: Description) {
35+
super.finished(description)
36+
server.shutdown()
37+
}
38+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright 2022-2023 The Connect Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.connectrpc.okhttp
16+
17+
import com.connectrpc.Code
18+
import com.connectrpc.ProtocolClientConfig
19+
import com.connectrpc.RequestCompression
20+
import com.connectrpc.compression.GzipCompressionPool
21+
import com.connectrpc.eliza.v1.ElizaServiceClient
22+
import com.connectrpc.eliza.v1.sayRequest
23+
import com.connectrpc.extensions.GoogleJavaProtobufStrategy
24+
import com.connectrpc.impl.ProtocolClient
25+
import com.connectrpc.protocols.NetworkProtocol
26+
import kotlinx.coroutines.test.runTest
27+
import okhttp3.OkHttpClient
28+
import okhttp3.Protocol
29+
import okhttp3.mockwebserver.MockResponse
30+
import org.assertj.core.api.Assertions.assertThat
31+
import org.junit.Rule
32+
import org.junit.Test
33+
34+
class MockWebServerTests {
35+
36+
@get:Rule val mockWebServerRule = MockWebServerRule()
37+
38+
@Test
39+
fun `compressed empty failure response is parsed correctly`() = runTest {
40+
mockWebServerRule.server.enqueue(
41+
MockResponse().apply {
42+
addHeader("accept-encoding", "gzip")
43+
addHeader("content-encoding", "gzip")
44+
setBody("{}")
45+
setResponseCode(401)
46+
},
47+
)
48+
49+
val host = mockWebServerRule.server.url("/")
50+
51+
val protocolClient = ProtocolClient(
52+
ConnectOkHttpClient(
53+
OkHttpClient.Builder()
54+
.protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1))
55+
.build(),
56+
),
57+
ProtocolClientConfig(
58+
host = host.toString(),
59+
serializationStrategy = GoogleJavaProtobufStrategy(),
60+
networkProtocol = NetworkProtocol.CONNECT,
61+
requestCompression = RequestCompression(0, GzipCompressionPool),
62+
compressionPools = listOf(GzipCompressionPool),
63+
),
64+
)
65+
66+
val response = ElizaServiceClient(protocolClient).say(sayRequest { sentence = "hello" })
67+
68+
mockWebServerRule.server.takeRequest().apply {
69+
assertThat(path).isEqualTo("/connectrpc.eliza.v1.ElizaService/Say")
70+
}
71+
72+
assertThat(response.code).isEqualTo(Code.UNKNOWN)
73+
}
74+
}

0 commit comments

Comments
 (0)