1use 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
46const DEFAULT_MIN_SIZE: usize = 1024;
48
49#[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#[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 pub fn new() -> Self {
82 Self { min_size: DEFAULT_MIN_SIZE, level: Compression::default().level() }
83 }
84
85 #[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 #[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 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 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 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 _ => response.with_body(bytes),
156 }
157 }
158
159 fn name(&self) -> &'static str {
160 "Compress"
161 }
162}
163
164fn 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 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
213fn 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 return false;
221 }
222
223 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 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 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 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 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 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 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}