Skip to main content

rainier_middleware/
compression.rs

1//! Compressing responses on the way out — [`Compress`].
2//!
3//! ```ignore
4//! registry.global(Compress::new());
5//! ```
6//!
7//! A JSON list of two hundred records is mostly repeated key names, and gzip
8//! takes it to roughly a tenth. On a mobile connection that is the difference
9//! between a page that appears and a page that arrives.
10//!
11//! # What it deliberately leaves alone
12//!
13//! - **Small bodies.** Below [`min_size`](Compress::min_size) the gzip header
14//!   and trailer cost more than the saving, and the CPU is spent for nothing.
15//! - **Already-compressed types.** A PNG, a JPEG, a zip, a woff2 — running
16//!   deflate over them adds a few bytes and a few milliseconds and takes away
17//!   nothing.
18//! - **Streaming bodies.** A server-sent-events response works by arriving in
19//!   pieces; buffering it to compress it would hold every event until the
20//!   stream ended, which is the one thing that must not happen to it.
21//! - **Anything with a `content-encoding` already.** Somebody has decided.
22//!
23//! # `Vary: accept-encoding`
24//!
25//! Set on every response that *could* have been compressed, whether or not it
26//! was. Without it a shared cache can hand a gzipped body to a client that did
27//! not ask for one — which reads, at the far end, as a corrupt response from
28//! an endpoint that works fine when tested directly.
29//!
30//! # It compresses on the runtime thread
31//!
32//! Deflating a few hundred kilobytes is a millisecond or two, and doing it
33//! inline avoids a `spawn_blocking` hop per response. A route that returns
34//! megabytes should raise [`min_size`], or not use this.
35//!
36//! [`min_size`]: Compress::min_size
37
38use std::io::Write;
39
40use flate2::write::{DeflateEncoder, GzEncoder};
41use flate2::Compression;
42use rainier_http::{Body, Request, Response};
43
44use crate::pipeline::{Middleware, Next};
45
46/// The default below which compressing costs more than it saves.
47const DEFAULT_MIN_SIZE: usize = 1024;
48
49/// Which encoding was negotiated.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum Encoding {
52    Gzip,
53    Deflate,
54}
55
56impl Encoding {
57    fn header_value(self) -> &'static str {
58        match self {
59            Self::Gzip => "gzip",
60            Self::Deflate => "deflate",
61        }
62    }
63}
64
65/// Gzips or deflates a response when the client asked for it and the body is
66/// worth it.
67#[derive(Debug, Clone)]
68pub struct Compress {
69    min_size: usize,
70    level: u32,
71}
72
73impl Default for Compress {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl Compress {
80    /// Compression at the default level, for bodies of 1 KiB or more.
81    pub fn new() -> Self {
82        Self { min_size: DEFAULT_MIN_SIZE, level: Compression::default().level() }
83    }
84
85    /// Only compress bodies of at least this many bytes.
86    #[must_use = "this returns a configured middleware rather than configuring in place"]
87    pub fn min_size(mut self, bytes: usize) -> Self {
88        self.min_size = bytes;
89        self
90    }
91
92    /// The deflate level, `0` (none) to `9` (smallest).
93    ///
94    /// The default is `6`. `9` costs noticeably more CPU for a few percent,
95    /// which is rarely the trade a web server wants to make per request.
96    #[must_use = "this returns a configured middleware rather than configuring in place"]
97    pub fn level(mut self, level: u32) -> Self {
98        self.level = level.min(9);
99        self
100    }
101}
102
103#[async_trait::async_trait]
104impl Middleware for Compress {
105    async fn handle(&self, request: Request, next: Next) -> Response {
106        let accepted = request.header("accept-encoding").map(str::to_owned);
107
108        let mut response = next.run(request).await;
109
110        // Somebody already encoded this — a pre-compressed asset off disk, or
111        // another layer of middleware.
112        if response.headers().contains_key("content-encoding") {
113            return response;
114        }
115
116        let content_type = response
117            .headers()
118            .get("content-type")
119            .and_then(|value| value.to_str().ok())
120            .unwrap_or("")
121            .to_string();
122
123        if !worth_compressing(&content_type) {
124            return response;
125        }
126
127        // Compressible by type, so a cache must key on the request's
128        // `accept-encoding` even if this particular response went out plain.
129        response = response.with_header("vary", "accept-encoding");
130
131        let Some(encoding) = accepted.as_deref().and_then(best_encoding) else {
132            return response;
133        };
134
135        // Only an in-memory body, and *checked* before taking it: a stream
136        // taken and not put back is a response whose body silently vanished.
137        if !matches!(response.body(), Body::Bytes(_)) {
138            return response;
139        }
140        let Body::Bytes(bytes) = response.take_body() else {
141            unreachable!("just checked");
142        };
143
144        if bytes.len() < self.min_size {
145            return response.with_body(bytes);
146        }
147
148        match compress(&bytes, encoding, self.level) {
149            Some(compressed) if compressed.len() < bytes.len() => response
150                .with_body(compressed)
151                .with_header("content-encoding", encoding.header_value()),
152            // Either it failed, or it came out bigger — which happens with
153            // small or already-dense payloads. Either way the original is the
154            // better answer.
155            _ => response.with_body(bytes),
156        }
157    }
158
159    fn name(&self) -> &'static str {
160        "Compress"
161    }
162}
163
164/// The best encoding the client will take, or `None` for none of ours.
165///
166/// Understands q-values, so `gzip;q=0` means *not* gzip — a client that says
167/// so usually has a reason.
168fn best_encoding(accept: &str) -> Option<Encoding> {
169    let mut best: Option<(Encoding, f32)> = None;
170    let mut wildcard: Option<f32> = None;
171
172    for part in accept.split(',') {
173        let mut pieces = part.split(';');
174        let name = pieces.next()?.trim().to_ascii_lowercase();
175
176        let quality = pieces
177            .find_map(|piece| piece.trim().strip_prefix("q=").map(str::to_owned))
178            .and_then(|q| q.parse::<f32>().ok())
179            .unwrap_or(1.0);
180
181        if quality <= 0.0 {
182            continue;
183        }
184
185        let encoding = match name.as_str() {
186            "gzip" | "x-gzip" => Encoding::Gzip,
187            "deflate" => Encoding::Deflate,
188            "*" => {
189                wildcard = Some(quality);
190                continue;
191            }
192            _ => continue,
193        };
194
195        // Gzip wins a tie: every proxy, CDN and client handles it without
196        // surprises, and raw deflate has a long history of being sent with the
197        // wrong framing.
198        let better = match best {
199            None => true,
200            Some((_, best_q)) if quality > best_q => true,
201            Some((best_encoding, best_q)) => {
202                quality == best_q && encoding == Encoding::Gzip && best_encoding != Encoding::Gzip
203            }
204        };
205        if better {
206            best = Some((encoding, quality));
207        }
208    }
209
210    best.map(|(encoding, _)| encoding).or(wildcard.map(|_| Encoding::Gzip))
211}
212
213/// Whether a body of this content type is worth the CPU.
214fn worth_compressing(content_type: &str) -> bool {
215    let content_type = content_type.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
216
217    if content_type.is_empty() {
218        // Nothing said what this is. Compressing bytes of unknown shape is a
219        // guess, and a wrong guess here costs CPU on every response.
220        return false;
221    }
222
223    // `image/svg+xml` is text wearing an image prefix, and so is every
224    // `application/vnd.something+json`. The suffix is the more specific fact,
225    // so it is checked first.
226    if content_type.starts_with("text/")
227        || content_type.ends_with("+json")
228        || content_type.ends_with("+xml")
229    {
230        return true;
231    }
232
233    // Already compressed, every one of them. Deflating a PNG makes it very
234    // slightly larger.
235    const DENSE: &[&str] = &[
236        "image/",
237        "video/",
238        "audio/",
239        "font/woff",
240        "application/zip",
241        "application/gzip",
242        "application/x-gzip",
243        "application/x-bzip",
244        "application/x-7z",
245        "application/x-rar",
246        "application/pdf",
247        "application/octet-stream",
248        "application/wasm",
249    ];
250    if DENSE.iter().any(|dense| content_type.starts_with(dense)) {
251        return false;
252    }
253
254    // The textual `application/*`: json, xml, javascript, x-www-form-urlencoded,
255    // and every `+json` / `+xml` vendor type.
256    content_type.starts_with("application/")
257}
258
259fn compress(bytes: &[u8], encoding: Encoding, level: u32) -> Option<Vec<u8>> {
260    let level = Compression::new(level);
261
262    match encoding {
263        Encoding::Gzip => {
264            let mut encoder = GzEncoder::new(Vec::new(), level);
265            encoder.write_all(bytes).ok()?;
266            encoder.finish().ok()
267        }
268        Encoding::Deflate => {
269            let mut encoder = DeflateEncoder::new(Vec::new(), level);
270            encoder.write_all(bytes).ok()?;
271            encoder.finish().ok()
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::pipeline::Pipeline;
280    use rainier_http::{Method, StatusCode};
281    use std::io::Read;
282
283    /// A body big enough to be worth compressing, and repetitive enough to
284    /// compress well — which is what a JSON list actually looks like.
285    fn json_body() -> String {
286        let record = r#"{"id":1,"name":"Ada Lovelace","role":"engineer"},"#;
287        format!("[{}]", record.repeat(64))
288    }
289
290    async fn respond(accept: Option<&str>, response: Response) -> Response {
291        let mut builder = Request::builder().method(Method::GET).uri("/data");
292        if let Some(accept) = accept {
293            builder = builder.header("accept-encoding", accept);
294        }
295
296        let response = std::sync::Arc::new(std::sync::Mutex::new(Some(response)));
297
298        Pipeline::new()
299            .through(Compress::new())
300            .then(move |_| {
301                let response = std::sync::Arc::clone(&response);
302                async move { response.lock().unwrap().take().expect("called once") }
303            })
304            .run(builder.build())
305            .await
306    }
307
308    fn json(body: String) -> Response {
309        Response::ok(body).with_content_type("application/json")
310    }
311
312    fn gunzip(bytes: &[u8]) -> String {
313        let mut out = String::new();
314        flate2::read::GzDecoder::new(bytes).read_to_string(&mut out).unwrap();
315        out
316    }
317
318    #[tokio::test]
319    async fn a_json_body_is_gzipped_and_round_trips() {
320        let original = json_body();
321        let response = respond(Some("gzip, deflate"), json(original.clone())).await;
322
323        assert_eq!(response.header("content-encoding"), Some("gzip"));
324        assert_eq!(response.header("vary"), Some("accept-encoding"));
325
326        let bytes = response.into_bytes().await.unwrap();
327        assert!(bytes.len() < original.len() / 2, "{} vs {}", bytes.len(), original.len());
328        assert_eq!(gunzip(&bytes), original);
329    }
330
331    #[tokio::test]
332    async fn a_client_that_asked_for_nothing_gets_the_original() {
333        let original = json_body();
334        let response = respond(None, json(original.clone())).await;
335
336        assert_eq!(response.header("content-encoding"), None);
337        // Still varies: the same URL *would* be compressed for the next client.
338        assert_eq!(response.header("vary"), Some("accept-encoding"));
339        assert_eq!(response.into_string().await.unwrap(), original);
340    }
341
342    #[tokio::test]
343    async fn a_small_body_is_left_alone() {
344        let response = respond(Some("gzip"), json("[]".to_string())).await;
345
346        assert_eq!(response.header("content-encoding"), None);
347        assert_eq!(response.into_string().await.unwrap(), "[]");
348    }
349
350    #[tokio::test]
351    async fn an_image_is_left_alone() {
352        let body = "x".repeat(4096);
353        let response =
354            respond(Some("gzip"), Response::ok(body).with_content_type("image/png")).await;
355
356        assert_eq!(response.header("content-encoding"), None);
357        // Not even a `vary`: this URL will never be compressed, so a cache has
358        // nothing to key on.
359        assert_eq!(response.header("vary"), None);
360    }
361
362    #[tokio::test]
363    async fn a_response_that_is_already_encoded_is_not_encoded_twice() {
364        let response =
365            respond(Some("gzip"), json(json_body()).with_header("content-encoding", "br")).await;
366
367        assert_eq!(response.header("content-encoding"), Some("br"));
368    }
369
370    #[tokio::test]
371    async fn a_streaming_body_is_never_buffered() {
372        // Compressing this would mean holding every event until the stream
373        // ended — which for server-sent events is indistinguishable from the
374        // endpoint being broken.
375        use futures_util::stream;
376
377        let events = stream::iter(vec![
378            Ok(bytes::Bytes::from("data: one\n\n")),
379            Ok(bytes::Bytes::from("data: two\n\n")),
380        ]);
381        let streaming = Response::stream(events).with_content_type("text/event-stream");
382
383        let response = respond(Some("gzip"), streaming).await;
384
385        assert_eq!(response.header("content-encoding"), None);
386        assert!(matches!(response.body(), Body::Stream(_)));
387    }
388
389    #[tokio::test]
390    async fn deflate_is_used_when_it_is_the_only_thing_offered() {
391        let response = respond(Some("deflate"), json(json_body())).await;
392
393        assert_eq!(response.header("content-encoding"), Some("deflate"));
394    }
395
396    #[tokio::test]
397    async fn an_encoding_refused_by_q_zero_is_not_used() {
398        let response = respond(Some("gzip;q=0, deflate;q=1.0"), json(json_body())).await;
399
400        assert_eq!(response.header("content-encoding"), Some("deflate"));
401    }
402
403    #[test]
404    fn the_best_offer_wins() {
405        assert_eq!(best_encoding("gzip"), Some(Encoding::Gzip));
406        assert_eq!(best_encoding("deflate, gzip"), Some(Encoding::Gzip));
407        assert_eq!(best_encoding("deflate;q=0.9, gzip;q=0.1"), Some(Encoding::Deflate));
408        assert_eq!(best_encoding("*"), Some(Encoding::Gzip));
409        assert_eq!(best_encoding("br"), None, "we do not do brotli");
410        assert_eq!(best_encoding("identity"), None);
411        assert_eq!(best_encoding(""), None);
412        assert_eq!(best_encoding("gzip;q=0"), None);
413    }
414
415    #[test]
416    fn only_the_textual_types_are_compressed() {
417        assert!(worth_compressing("text/html; charset=utf-8"));
418        assert!(worth_compressing("application/json"));
419        assert!(worth_compressing("application/vnd.api+json"));
420        assert!(worth_compressing("image/svg+xml"), "SVG is text wearing an image prefix");
421
422        assert!(!worth_compressing("image/png"));
423        assert!(!worth_compressing("video/mp4"));
424        assert!(!worth_compressing("application/zip"));
425        assert!(!worth_compressing("application/octet-stream"));
426        assert!(!worth_compressing(""), "nothing said what this is");
427    }
428
429    #[tokio::test]
430    async fn the_status_and_other_headers_survive() {
431        let response = respond(
432            Some("gzip"),
433            json(json_body()).with_status(StatusCode::CREATED).with_header("location", "/data/1"),
434        )
435        .await;
436
437        assert_eq!(response.status(), StatusCode::CREATED);
438        assert_eq!(response.header("location"), Some("/data/1"));
439        assert_eq!(response.header("content-encoding"), Some("gzip"));
440    }
441}