Skip to main content

rustlavel_http/compression/
mod.rs

1//! Response compression.
2//!
3//! The codec is written here — DEFLATE (RFC 1951), and the gzip (RFC 1952)
4//! and zlib (RFC 1950) framings around it — rather than borrowed, like every
5//! other protocol in this framework. The middleware that applies it to
6//! responses lives in this file.
7
8pub mod checksum;
9pub mod deflate;
10pub mod gzip;
11
12use crate::handler::BoxFuture;
13use crate::middleware::{Middleware, Next};
14use crate::request::Request;
15use crate::response::Response;
16
17/// Compress responses for clients that ask.
18///
19/// ```ignore
20/// App::new()?.middleware(Compress::default())
21/// ```
22///
23/// JSON is the best case compression has: a list of a hundred users is mostly
24/// the same twenty key names repeated a hundred times, and typically shrinks
25/// by 70–80%. The cost is CPU on the server, which is why small bodies are
26/// left alone — below a kilobyte the headers outweigh the saving — and why a
27/// body that is already compressed (an image, a zip, anything with a
28/// `Content-Encoding`) is never touched.
29///
30/// `gzip` is preferred over `deflate` when the client accepts both, because
31/// browsers historically disagreed about whether "deflate" meant the zlib
32/// format or a raw stream, and gzip never had that problem. When `deflate` is
33/// what is asked for, the zlib framing is sent, which is what every modern
34/// client means by it.
35///
36/// A strong `ETag` on the response is weakened, because the compressed bytes
37/// are a different representation of the same resource and a strong tag
38/// promises byte-for-byte identity. The validator still works; it just says so
39/// honestly.
40#[derive(Debug, Clone, Copy)]
41pub struct Compress {
42    /// Bodies smaller than this are sent as they are.
43    min_size: usize,
44}
45
46impl Default for Compress {
47    fn default() -> Self {
48        Compress { min_size: 1024 }
49    }
50}
51
52impl Compress {
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// The size below which a body is not worth compressing.
58    pub fn min_size(mut self, bytes: usize) -> Self {
59        self.min_size = bytes;
60        self
61    }
62}
63
64/// The encodings this middleware can produce.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66enum Encoding {
67    Gzip,
68    Deflate,
69}
70
71impl Encoding {
72    fn token(self) -> &'static str {
73        match self {
74            Encoding::Gzip => "gzip",
75            Encoding::Deflate => "deflate",
76        }
77    }
78}
79
80/// Pick an encoding from an `Accept-Encoding` header, or `None` to send plain.
81///
82/// RFC 9110 §12.5.3: a list of codings with optional `q` weights, where a
83/// weight of zero means "not acceptable" and `*` stands for anything not
84/// otherwise named. Among what is acceptable, gzip wins ties for the reason
85/// given on [`Compress`].
86fn negotiate(accept_encoding: &str) -> Option<Encoding> {
87    let mut gzip: Option<f32> = None;
88    let mut deflate: Option<f32> = None;
89    let mut wildcard: Option<f32> = None;
90
91    for part in accept_encoding.split(',') {
92        let mut pieces = part.split(';');
93        let coding = pieces.next().unwrap_or("").trim().to_ascii_lowercase();
94        let weight = pieces
95            .find_map(|p| p.trim().strip_prefix("q=").or_else(|| p.trim().strip_prefix("Q=")))
96            .and_then(|q| q.trim().parse::<f32>().ok())
97            .unwrap_or(1.0);
98        match coding.as_str() {
99            "gzip" | "x-gzip" => gzip = Some(weight),
100            "deflate" => deflate = Some(weight),
101            "*" => wildcard = Some(weight),
102            _ => {}
103        }
104    }
105
106    let gzip = gzip.or(wildcard).unwrap_or(0.0);
107    let deflate = deflate.or(wildcard).unwrap_or(0.0);
108    if gzip <= 0.0 && deflate <= 0.0 {
109        None
110    } else if gzip >= deflate {
111        Some(Encoding::Gzip)
112    } else {
113        Some(Encoding::Deflate)
114    }
115}
116
117/// Whether a body of this type shrinks under compression.
118///
119/// Text does, and so does anything structured as text — JSON, XML, SVG,
120/// JavaScript, form data. Images, audio, video and archives were compressed
121/// by their own encoders already, and running DEFLATE over them costs CPU to
122/// make them very slightly larger.
123fn is_compressible(content_type: Option<&str>) -> bool {
124    let Some(content_type) = content_type else { return false };
125    let mime = content_type.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
126    mime.starts_with("text/")
127        || mime.ends_with("+json")
128        || mime.ends_with("+xml")
129        || matches!(
130            mime.as_str(),
131            "application/json"
132                | "application/xml"
133                | "application/javascript"
134                | "application/x-javascript"
135                | "application/ecmascript"
136                | "application/x-www-form-urlencoded"
137                | "application/graphql"
138                | "application/ld+json"
139                | "application/manifest+json"
140                | "application/wasm"
141                | "image/svg+xml"
142                | "font/ttf"
143                | "font/otf"
144        )
145}
146
147impl Middleware for Compress {
148    fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
149        let Some(encoding) = request.header("accept-encoding").and_then(negotiate) else {
150            return next.run(request);
151        };
152        let settings = *self;
153
154        Box::pin(async move {
155            let response = next.run(request).await;
156            settings.encode(encoding, response)
157        })
158    }
159}
160
161impl Compress {
162    fn encode(&self, encoding: Encoding, mut response: Response) -> Response {
163        // Nothing to gain, or something that says not to.
164        let no_transform = response
165            .headers
166            .get("cache-control")
167            .is_some_and(|cc| cc.split(',').any(|d| d.trim().eq_ignore_ascii_case("no-transform")));
168        if response.body.len() < self.min_size
169            || response.headers.contains("content-encoding")
170            || !(200..300).contains(&response.status.code())
171            || no_transform
172            || !is_compressible(response.headers.content_type())
173        {
174            return response;
175        }
176
177        let compressed = match encoding {
178            Encoding::Gzip => gzip::compress(&response.body),
179            Encoding::Deflate => gzip::zlib_compress(&response.body),
180        };
181        // Incompressible after all — random tokens, say. Sending the larger
182        // form would be paying CPU to waste bandwidth.
183        if compressed.len() >= response.body.len() {
184            return response;
185        }
186
187        response.body = compressed;
188        response.headers.set("content-encoding", encoding.token());
189        // Content-Length is written from the body at serialisation time, so
190        // a stale one set by the handler cannot be sent; but remove it anyway
191        // so nothing that inspects the response in between is misled.
192        response.headers.remove("content-length");
193
194        if let Some(etag) = response.headers.get("etag")
195            && !etag.starts_with("W/")
196        {
197            let weakened = format!("W/{etag}");
198            response.headers.set("etag", weakened);
199        }
200
201        let vary = response.headers.get("vary").unwrap_or("").to_string();
202        if !vary.split(',').any(|v| v.trim().eq_ignore_ascii_case("accept-encoding")) {
203            let value = if vary.is_empty() {
204                "accept-encoding".to_string()
205            } else {
206                format!("{vary}, accept-encoding")
207            };
208            response.headers.set("vary", value);
209        }
210        response
211    }
212}
213
214#[cfg(test)]
215mod middleware_tests {
216    use super::*;
217    use crate::method::Method;
218    use crate::router::Router;
219    use crate::status::Status;
220    use crate::testing::TestClient;
221    use rustlavel_core::Json;
222
223    fn big_json() -> Json {
224        Json::Array(
225            (0..200)
226                .map(|i| {
227                    Json::object([
228                        ("id", Json::from(i)),
229                        ("name", Json::from(format!("user-{i}"))),
230                        ("email", Json::from(format!("user-{i}@example.com"))),
231                        ("role", Json::from("member")),
232                    ])
233                })
234                .collect(),
235        )
236    }
237
238    fn client(compress: Compress) -> TestClient {
239        let mut router = Router::new();
240        router.middleware(compress);
241        router.get("/users", |_req: Request| async { Response::json(big_json()) });
242        router.get("/tiny", |_req: Request| async { Response::json(Json::object([("ok", Json::from(true))])) });
243        router.get("/image", |_req: Request| async {
244            Response::ok().with_header("content-type", "image/png").with_body(vec![0u8; 4096])
245        });
246        router.get("/already", |_req: Request| async {
247            Response::ok()
248                .with_header("content-type", "text/plain")
249                .with_header("content-encoding", "br")
250                .with_body(vec![b'x'; 4096])
251        });
252        router.get("/no-transform", |_req: Request| async {
253            Response::text("y".repeat(4096)).with_header("cache-control", "no-transform")
254        });
255        router.get("/tagged", |_req: Request| async {
256            Response::text("z".repeat(4096)).with_header("etag", "\"abc\"").with_header("vary", "Origin")
257        });
258        router.get("/random", |_req: Request| async {
259            // A pseudo-random body that DEFLATE cannot shrink.
260            let mut state = 0x9E37_79B9_7F4A_7C15_u64;
261            let body: Vec<u8> = (0..4096)
262                .map(|_| {
263                    state ^= state << 13;
264                    state ^= state >> 7;
265                    state ^= state << 17;
266                    (state & 0xFF) as u8
267                })
268                .collect();
269            Response::ok().with_header("content-type", "text/plain").with_body(body)
270        });
271        router.get("/missing", |_req: Request| async { Response::not_found().with_text("n".repeat(4096)) });
272        TestClient::new(router)
273    }
274
275    fn get(path: &str, accept: &str) -> Request {
276        Request::new(Method::Get, path).with_header("accept-encoding", accept)
277    }
278
279    #[tokio::test]
280    async fn a_json_body_is_gzipped_and_round_trips() {
281        let plain = client(Compress::new()).get("/users").await;
282        let response = client(Compress::new()).send(get("/users", "gzip, deflate, br")).await;
283
284        assert_eq!(response.header("content-encoding"), Some("gzip"));
285        assert_eq!(response.header("vary"), Some("accept-encoding"));
286        let compressed = response.body_bytes();
287        assert!(compressed.len() < plain.body().len() / 3, "{} vs {}", compressed.len(), plain.body().len());
288        let restored = gzip::decompress(compressed).expect("valid gzip");
289        assert_eq!(String::from_utf8(restored).unwrap(), plain.body());
290    }
291
292    #[tokio::test]
293    async fn deflate_means_the_zlib_format() {
294        let response = client(Compress::new()).send(get("/users", "deflate")).await;
295        assert_eq!(response.header("content-encoding"), Some("deflate"));
296        gzip::zlib_decompress(response.body_bytes()).expect("zlib-framed, as browsers expect");
297    }
298
299    #[tokio::test]
300    async fn without_accept_encoding_nothing_changes() {
301        let response = client(Compress::new()).get("/users").await;
302        assert_eq!(response.header("content-encoding"), None);
303        assert!(response.body().starts_with('['));
304    }
305
306    #[tokio::test]
307    async fn small_bodies_are_left_alone() {
308        let response = client(Compress::new()).send(get("/tiny", "gzip")).await;
309        assert_eq!(response.header("content-encoding"), None);
310        assert_eq!(response.body(), "{\"ok\":true}");
311    }
312
313    #[tokio::test]
314    async fn the_threshold_is_configurable() {
315        let response = client(Compress::new().min_size(0)).send(get("/tiny", "gzip")).await;
316        // Still not compressed: the compressed form of eleven bytes is larger.
317        assert_eq!(response.header("content-encoding"), None);
318    }
319
320    #[tokio::test]
321    async fn incompressible_types_and_already_encoded_bodies_are_skipped() {
322        let client = client(Compress::new());
323        assert_eq!(client.send(get("/image", "gzip")).await.header("content-encoding"), None);
324        assert_eq!(client.send(get("/already", "gzip")).await.header("content-encoding"), Some("br"));
325        assert_eq!(client.send(get("/no-transform", "gzip")).await.header("content-encoding"), None);
326    }
327
328    #[tokio::test]
329    async fn a_body_that_does_not_shrink_is_sent_as_it_was() {
330        let response = client(Compress::new()).send(get("/random", "gzip")).await;
331        assert_eq!(response.header("content-encoding"), None);
332        assert_eq!(response.body_bytes().len(), 4096);
333    }
334
335    #[tokio::test]
336    async fn only_successful_responses_are_compressed() {
337        let response = client(Compress::new()).send(get("/missing", "gzip")).await;
338        let response = response.assert_status(404);
339        assert_eq!(response.header("content-encoding"), None);
340    }
341
342    #[tokio::test]
343    async fn a_strong_etag_becomes_weak_and_vary_is_appended() {
344        let response = client(Compress::new()).send(get("/tagged", "gzip")).await;
345        assert_eq!(response.header("etag"), Some("W/\"abc\""));
346        assert_eq!(response.header("vary"), Some("Origin, accept-encoding"));
347    }
348
349    #[tokio::test]
350    async fn head_keeps_the_headers_a_get_would_have() {
351        let request = Request::new(Method::Head, "/users").with_header("accept-encoding", "gzip");
352        let response = client(Compress::new()).send(request).await;
353        assert_eq!(response.status(), Status::OK.code());
354        assert_eq!(response.header("content-encoding"), Some("gzip"));
355    }
356
357    #[test]
358    fn negotiation_follows_the_weights() {
359        assert_eq!(negotiate("gzip, deflate, br"), Some(Encoding::Gzip));
360        assert_eq!(negotiate("deflate"), Some(Encoding::Deflate));
361        assert_eq!(negotiate("x-gzip"), Some(Encoding::Gzip));
362        assert_eq!(negotiate("deflate;q=1.0, gzip;q=0.5"), Some(Encoding::Deflate));
363        assert_eq!(negotiate("gzip;q=0, deflate"), Some(Encoding::Deflate));
364        assert_eq!(negotiate("gzip;q=0, deflate;q=0"), None);
365        assert_eq!(negotiate("*"), Some(Encoding::Gzip));
366        assert_eq!(negotiate("*;q=0, gzip"), Some(Encoding::Gzip));
367        assert_eq!(negotiate("br"), None);
368        assert_eq!(negotiate("identity"), None);
369        assert_eq!(negotiate(""), None);
370        assert_eq!(negotiate("GZIP ; Q=0.8"), Some(Encoding::Gzip));
371    }
372
373    #[test]
374    fn compressibility_is_decided_by_type() {
375        assert!(is_compressible(Some("application/json; charset=utf-8")));
376        assert!(is_compressible(Some("text/html")));
377        assert!(is_compressible(Some("application/problem+json")));
378        assert!(is_compressible(Some("image/svg+xml")));
379        assert!(!is_compressible(Some("image/png")));
380        assert!(!is_compressible(Some("application/zip")));
381        assert!(!is_compressible(Some("video/mp4")));
382        assert!(!is_compressible(None));
383    }
384}