rustlavel_http/compression/
mod.rs1pub 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#[derive(Debug, Clone, Copy)]
41pub struct Compress {
42 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 pub fn min_size(mut self, bytes: usize) -> Self {
59 self.min_size = bytes;
60 self
61 }
62}
63
64#[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
80fn 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
117fn 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 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 if compressed.len() >= response.body.len() {
184 return response;
185 }
186
187 response.body = compressed;
188 response.headers.set("content-encoding", encoding.token());
189 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 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 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}