Skip to content

Commit c7f9c7b

Browse files
committed
fix: #21 3
1 parent 241c573 commit c7f9c7b

File tree

4 files changed

+277
-5
lines changed

4 files changed

+277
-5
lines changed

connectrpc-axum/src/context/compression.rs

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,47 @@ impl CompressionConfig {
201201
}
202202

203203
/// Negotiate response encoding from Accept-Encoding header.
204+
///
205+
/// Follows connect-go's approach: first supported encoding wins (client preference order).
206+
/// Respects `q=0` which means "not acceptable" per RFC 7231.
204207
pub fn negotiate_response_encoding(accept: Option<&str>) -> CompressionEncoding {
205-
// Simple: if gzip is in Accept-Encoding, use it
206-
match accept {
207-
Some(s) if s.contains("gzip") => CompressionEncoding::Gzip,
208-
_ => CompressionEncoding::Identity,
208+
let Some(accept) = accept else {
209+
return CompressionEncoding::Identity;
210+
};
211+
212+
for token in accept.split(',') {
213+
let token = token.trim();
214+
if token.is_empty() {
215+
continue;
216+
}
217+
218+
// Parse "gzip;q=0.5" into encoding="gzip", q_value=Some("0.5")
219+
let (encoding, q_value) = match token.split_once(';') {
220+
Some((enc, params)) => {
221+
let q = params
222+
.split(';')
223+
.find_map(|p| p.trim().strip_prefix("q="));
224+
(enc.trim(), q)
225+
}
226+
None => (token, None),
227+
};
228+
229+
// Skip if q=0 (explicitly disabled)
230+
if let Some(q) = q_value {
231+
if q.trim() == "0" || q.trim() == "0.0" || q.trim() == "0.00" || q.trim() == "0.000" {
232+
continue;
233+
}
234+
}
235+
236+
// Return first supported encoding
237+
match encoding {
238+
"gzip" => return CompressionEncoding::Gzip,
239+
"identity" => return CompressionEncoding::Identity,
240+
_ => continue,
241+
}
209242
}
243+
244+
CompressionEncoding::Identity
210245
}
211246

212247
/// Header name for Connect streaming request compression.
@@ -363,6 +398,89 @@ mod tests {
363398
);
364399
}
365400

401+
#[test]
402+
fn test_negotiate_response_encoding_order() {
403+
// First supported encoding wins (client preference order)
404+
assert_eq!(
405+
negotiate_response_encoding(Some("br, gzip")),
406+
CompressionEncoding::Gzip
407+
);
408+
assert_eq!(
409+
negotiate_response_encoding(Some("gzip, identity")),
410+
CompressionEncoding::Gzip
411+
);
412+
assert_eq!(
413+
negotiate_response_encoding(Some("identity, gzip")),
414+
CompressionEncoding::Identity
415+
);
416+
assert_eq!(
417+
negotiate_response_encoding(Some("br, zstd, gzip")),
418+
CompressionEncoding::Gzip
419+
);
420+
}
421+
422+
#[test]
423+
fn test_negotiate_response_encoding_q_values() {
424+
// q=0 means "not acceptable" - should be skipped
425+
assert_eq!(
426+
negotiate_response_encoding(Some("gzip;q=0")),
427+
CompressionEncoding::Identity
428+
);
429+
assert_eq!(
430+
negotiate_response_encoding(Some("gzip;q=0, identity")),
431+
CompressionEncoding::Identity
432+
);
433+
assert_eq!(
434+
negotiate_response_encoding(Some("gzip;q=0.0")),
435+
CompressionEncoding::Identity
436+
);
437+
438+
// Non-zero q values should be accepted (we ignore the actual weight)
439+
assert_eq!(
440+
negotiate_response_encoding(Some("gzip;q=1")),
441+
CompressionEncoding::Gzip
442+
);
443+
assert_eq!(
444+
negotiate_response_encoding(Some("gzip;q=0.5")),
445+
CompressionEncoding::Gzip
446+
);
447+
assert_eq!(
448+
negotiate_response_encoding(Some("gzip;q=0.001")),
449+
CompressionEncoding::Gzip
450+
);
451+
452+
// Mixed: skip disabled, use first enabled
453+
assert_eq!(
454+
negotiate_response_encoding(Some("br;q=1, gzip;q=0, identity")),
455+
CompressionEncoding::Identity
456+
);
457+
assert_eq!(
458+
negotiate_response_encoding(Some("gzip;q=0, identity;q=0")),
459+
CompressionEncoding::Identity
460+
);
461+
}
462+
463+
#[test]
464+
fn test_negotiate_response_encoding_whitespace() {
465+
// Handle various whitespace scenarios
466+
assert_eq!(
467+
negotiate_response_encoding(Some(" gzip ")),
468+
CompressionEncoding::Gzip
469+
);
470+
assert_eq!(
471+
negotiate_response_encoding(Some("gzip ; q=0")),
472+
CompressionEncoding::Identity
473+
);
474+
assert_eq!(
475+
negotiate_response_encoding(Some("gzip; q=0")),
476+
CompressionEncoding::Identity
477+
);
478+
assert_eq!(
479+
negotiate_response_encoding(Some("br , gzip")),
480+
CompressionEncoding::Gzip
481+
);
482+
}
483+
366484
#[test]
367485
fn test_compress_decompress_gzip() {
368486
let original = Bytes::from_static(b"Hello, World! This is a test message.");

docs/.vitepress/config.mts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ export default defineConfig({
4444
text: 'MakeServiceBuilder',
4545
link: '/guide/configuration',
4646
items: [
47-
{ text: 'Timeout', link: '/guide/timeout' }
47+
{ text: 'Timeout', link: '/guide/timeout' },
48+
{ text: 'Compression', link: '/guide/compression' }
4849
]
4950
},
5051
{ text: 'HTTP Endpoints', link: '/guide/http-endpoints' },

docs/guide/compression.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Compression
2+
3+
## Basic Usage
4+
5+
Configure response compression using `MakeServiceBuilder`:
6+
7+
```rust
8+
use connectrpc_axum::{MakeServiceBuilder, CompressionConfig};
9+
10+
MakeServiceBuilder::new()
11+
.add_router(router)
12+
.compression(CompressionConfig::new(512)) // Compress responses >= 512 bytes
13+
.build()
14+
```
15+
16+
## Supported Encodings
17+
18+
| Encoding | Description |
19+
|----------|-------------|
20+
| `gzip` | Gzip compression via flate2 |
21+
| `identity` | No compression (passthrough) |
22+
23+
## Accept-Encoding Negotiation
24+
25+
The server negotiates response compression using standard HTTP headers:
26+
27+
- **Unary RPCs**: `Accept-Encoding` header
28+
- **Streaming RPCs**: `Connect-Accept-Encoding` header
29+
30+
### How Negotiation Works
31+
32+
Following connect-go's approach:
33+
34+
1. **First supported encoding wins** - client preference order is respected
35+
2. **`q=0` means disabled** - encodings with `q=0` are skipped per RFC 7231
36+
3. **Other q-values are ignored** - no preference weighting beyond order
37+
38+
| Accept-Encoding | Result |
39+
|-----------------|--------|
40+
| `gzip` | gzip |
41+
| `gzip, deflate, br` | gzip (first supported) |
42+
| `br, zstd, gzip` | gzip (first supported) |
43+
| `identity, gzip` | identity (first supported) |
44+
| `gzip;q=0` | identity (gzip disabled) |
45+
| `gzip;q=0, identity` | identity (gzip disabled) |
46+
| `gzip;q=0.5` | gzip (non-zero q accepted) |
47+
| `deflate, br` | identity (none supported) |
48+
49+
::: tip
50+
Unlike full HTTP content negotiation, Connect protocol doesn't weight by q-value—it uses client order. This matches connect-go's behavior.
51+
:::
52+
53+
## Configuration Options
54+
55+
### Minimum Bytes Threshold
56+
57+
Only compress responses larger than a threshold (default: 1024 bytes):
58+
59+
```rust
60+
use connectrpc_axum::CompressionConfig;
61+
62+
// Compress responses >= 512 bytes
63+
let config = CompressionConfig::new(512);
64+
65+
// Default: 1024 bytes
66+
let config = CompressionConfig::default();
67+
```
68+
69+
Small messages often don't benefit from compression due to overhead.
70+
71+
### Disabling Compression
72+
73+
Disable response compression entirely:
74+
75+
```rust
76+
use connectrpc_axum::CompressionConfig;
77+
78+
MakeServiceBuilder::new()
79+
.add_router(router)
80+
.compression(CompressionConfig::disabled())
81+
.build()
82+
```
83+
84+
## Request Decompression
85+
86+
The server automatically decompresses incoming requests based on:
87+
88+
- **Unary RPCs**: `Content-Encoding` header
89+
- **Streaming RPCs**: `Connect-Content-Encoding` header
90+
91+
Unsupported encodings return `Unimplemented` error:
92+
93+
```
94+
unsupported compression "br": supported encodings are gzip, identity
95+
```
96+
97+
## Streaming Compression
98+
99+
For streaming RPCs, compression is applied per-message using the envelope format:
100+
101+
- Each message frame has a compression flag (byte 0, bit 0x01)
102+
- Compressed frames are automatically decompressed on read
103+
- Response frames are compressed based on negotiated encoding and size threshold
104+
105+
## Protocol Headers
106+
107+
| RPC Type | Request Compression | Response Negotiation |
108+
|----------|---------------------|----------------------|
109+
| Unary | `Content-Encoding` | `Accept-Encoding` |
110+
| Streaming | `Connect-Content-Encoding` | `Connect-Accept-Encoding` |
111+
112+
## Implementation Notes
113+
114+
### Conformance with connect-go
115+
116+
This implementation matches connect-go's compression behavior:
117+
118+
- First-match-wins negotiation (no q-value weighting)
119+
- Respects `q=0` as "not acceptable"
120+
- Same header names for unary vs streaming
121+
- Same error messages for unsupported encodings
122+
123+
### Custom Codecs
124+
125+
For custom compression algorithms (zstd, brotli), implement the `Codec` trait:
126+
127+
```rust
128+
use connectrpc_axum::compression::Codec;
129+
use bytes::Bytes;
130+
use std::io;
131+
132+
struct ZstdCodec { level: i32 }
133+
134+
impl Codec for ZstdCodec {
135+
fn name(&self) -> &'static str { "zstd" }
136+
137+
fn compress(&self, data: Bytes) -> io::Result<Bytes> {
138+
// ... zstd compression
139+
}
140+
141+
fn decompress(&self, data: Bytes) -> io::Result<Bytes> {
142+
// ... zstd decompression
143+
}
144+
}
145+
```
146+
147+
::: warning
148+
Custom codecs require additional wiring to integrate with the negotiation logic. This API is subject to change.
149+
:::

docs/guide/configuration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ MakeServiceBuilder::new()
7070

7171
See [Timeout](./timeout.md) for detailed timeout configuration.
7272

73+
### Compression
74+
75+
See [Compression](./compression.md) for detailed compression configuration.
76+
7377
### Protocol Header Validation
7478

7579
Require the `Connect-Protocol-Version` header for Connect protocol requests:

0 commit comments

Comments
 (0)