Skip to main content

rust_web_server/compression/
mod.rs

1#[cfg(test)]
2mod tests;
3
4use flate2::Compression;
5use flate2::write::GzEncoder;
6use std::io::Write;
7use crate::header::Header;
8use crate::request::Request;
9use crate::response::Response;
10
11/// MIME types whose responses are worth compressing.
12const COMPRESSIBLE: &[&str] = &[
13    "text/html",
14    "text/css",
15    "text/javascript",
16    "text/plain",
17    "text/xml",
18    "application/json",
19    "application/javascript",
20    "application/xml",
21    "application/xhtml+xml",
22    "image/svg+xml",
23];
24
25/// Bodies smaller than this are sent uncompressed: gzip's framing overhead
26/// and the CPU time outweigh what it saves (Caddy's `encode` uses the same
27/// minimum).
28pub const MIN_COMPRESS_SIZE: usize = 512;
29
30/// If the client accepts gzip and the response body is compressible text of
31/// at least [`MIN_COMPRESS_SIZE`] bytes, compress every content range
32/// in-place and add `Content-Encoding: gzip`; also appends `Accept-Encoding`
33/// to the `Vary` header. A body that gzip doesn't make smaller (already
34/// compressed or random data) is left as it was, and so is a response that
35/// already has a `Content-Encoding` (e.g. a backend that compressed it).
36pub fn apply_gzip(request: &Request, response: &mut Response) {
37    if response.content_range_list.is_empty() {
38        return;
39    }
40    if response.headers.iter().any(|h| h.name.eq_ignore_ascii_case(Header::_CONTENT_ENCODING)) {
41        return;
42    }
43    let original_size: usize = response.content_range_list.iter().map(|cr| cr.body.len()).sum();
44    if original_size < MIN_COMPRESS_SIZE {
45        return;
46    }
47
48    let accepts_gzip = request
49        .get_header(Header::_ACCEPT_ENCODING.to_string())
50        .map(|h| h.value.to_lowercase().contains("gzip"))
51        .unwrap_or(false);
52
53    if !accepts_gzip {
54        return;
55    }
56
57    let content_type = response
58        .content_range_list
59        .first()
60        .map(|cr| cr.content_type.to_lowercase())
61        .unwrap_or_default();
62
63    let is_compressible = COMPRESSIBLE.iter().any(|mime| content_type.starts_with(mime));
64    if !is_compressible {
65        return;
66    }
67
68    let mut compressed_bodies = Vec::with_capacity(response.content_range_list.len());
69    for cr in &response.content_range_list {
70        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
71        if encoder.write_all(&cr.body).is_err() {
72            return;
73        }
74        match encoder.finish() {
75            Ok(compressed) => compressed_bodies.push(compressed),
76            Err(_) => return,
77        }
78    }
79    let compressed_size: usize = compressed_bodies.iter().map(Vec::len).sum();
80    if compressed_size >= original_size {
81        return;
82    }
83    for (cr, compressed) in response.content_range_list.iter_mut().zip(compressed_bodies) {
84        let new_len = compressed.len() as u64;
85        cr.body = compressed;
86        cr.range.end = new_len;
87        cr.size = new_len.to_string();
88    }
89
90    response.headers.push(Header {
91        name: Header::_CONTENT_ENCODING.to_string(),
92        value: "gzip".to_string(),
93    });
94
95    // append Accept-Encoding to Vary, or add it
96    let vary_pos = response.headers.iter().position(|h| h.name == Header::_VARY);
97    if let Some(i) = vary_pos {
98        let current = response.headers[i].value.clone();
99        if !current.to_lowercase().contains("accept-encoding") {
100            response.headers[i].value = format!("{}, Accept-Encoding", current);
101        }
102    } else {
103        response.headers.push(Header {
104            name: Header::_VARY.to_string(),
105            value: "Accept-Encoding".to_string(),
106        });
107    }
108}