Skip to main content

salvo_compression/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![cfg_attr(test, allow(clippy::unwrap_used))]
3
4//! Compression middleware for the Salvo web framework.
5//!
6//! This middleware automatically compresses HTTP responses using various algorithms,
7//! reducing bandwidth usage and improving load times for clients.
8//!
9//! # Supported Algorithms
10//!
11//! | Algorithm | Feature | Content-Encoding |
12//! |-----------|---------|------------------|
13//! | Gzip | `gzip` | `gzip` |
14//! | Brotli | `brotli` | `br` |
15//! | Deflate | `deflate` | `deflate` |
16//! | Zstd | `zstd` | `zstd` |
17//!
18//! # Example
19//!
20//! ```no_run
21//! use salvo_compression::{Compression, CompressionLevel};
22//! use salvo_core::prelude::*;
23//!
24//! #[handler]
25//! async fn hello() -> &'static str {
26//!     "hello"
27//! }
28//!
29//! let compression = Compression::new()
30//!     .enable_gzip(CompressionLevel::Default)
31//!     .min_length(1024); // Only compress responses > 1KB
32//!
33//! let _router = Router::new().hoop(compression).get(hello);
34//! ```
35//!
36//! # Algorithm Negotiation
37//!
38//! The middleware negotiates the compression algorithm based on the client's
39//! `Accept-Encoding` header. By default, it respects the client's preference order.
40//! Use `force_priority(true)` to use the server's configured priority instead.
41//!
42//! # Compression Levels
43//!
44//! - [`CompressionLevel::Fastest`]: Fastest compression, larger output
45//! - [`CompressionLevel::Default`]: Balanced compression (recommended)
46//! - [`CompressionLevel::Minsize`]: Best compression, slower
47//! - `CompressionLevel::Precise(u32)`: Fine-grained control
48//!
49//! # Default Content Types
50//!
51//! By default, the middleware compresses:
52//! - `text/*` (HTML, CSS, plain text, etc.)
53//! - `application/javascript`
54//! - `application/json`
55//! - `application/xml`, `application/rss+xml`
56//! - `application/wasm`
57//! - `image/svg+xml`
58//!
59//! Use `.content_types()` to customize which MIME types are compressed.
60//!
61//! # Minimum Length
62//!
63//! Small responses may not benefit from compression. Use `.min_length(bytes)`
64//! to skip compression for responses smaller than the specified size.
65//!
66//! Read more: <https://salvo.rs>
67
68use std::fmt::{self, Display, Formatter};
69use std::str::FromStr;
70use std::sync::LazyLock;
71
72use indexmap::IndexMap;
73use salvo_core::http::body::ResBody;
74use salvo_core::http::header::{
75    ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, HeaderValue, VARY,
76};
77use salvo_core::http::{self, Mime, StatusCode, mime};
78use salvo_core::{Depot, FlowCtrl, Handler, Request, Response, async_trait};
79
80mod encoder;
81mod stream;
82use encoder::Encoder;
83use stream::EncodeStream;
84
85/// Level of compression data should be compressed with.
86#[non_exhaustive]
87#[derive(Clone, Copy, Default, Debug, Eq, PartialEq)]
88pub enum CompressionLevel {
89    /// Fastest quality of compression, usually produces a bigger size.
90    Fastest,
91    /// Best quality of compression, usually produces the smallest size.
92    Minsize,
93    /// Default quality of compression defined by the selected compression algorithm.
94    #[default]
95    Default,
96    /// Precise quality based on the underlying compression algorithms'
97    /// qualities. The interpretation of this depends on the algorithm chosen
98    /// and the specific implementation backing it.
99    /// Qualities are implicitly clamped to the algorithm's maximum.
100    Precise(u32),
101}
102
103/// CompressionAlgo
104#[derive(Eq, PartialEq, Clone, Copy, Debug, Hash)]
105#[non_exhaustive]
106pub enum CompressionAlgo {
107    /// Compress use Brotli algo.
108    #[cfg(feature = "brotli")]
109    #[cfg_attr(docsrs, doc(cfg(feature = "brotli")))]
110    Brotli,
111
112    /// Compress use Deflate algo.
113    #[cfg(feature = "deflate")]
114    #[cfg_attr(docsrs, doc(cfg(feature = "deflate")))]
115    Deflate,
116
117    /// Compress use Gzip algo.
118    #[cfg(feature = "gzip")]
119    #[cfg_attr(docsrs, doc(cfg(feature = "gzip")))]
120    Gzip,
121
122    /// Compress use Zstd algo.
123    #[cfg(feature = "zstd")]
124    #[cfg_attr(docsrs, doc(cfg(feature = "zstd")))]
125    Zstd,
126}
127
128impl FromStr for CompressionAlgo {
129    type Err = String;
130
131    fn from_str(s: &str) -> Result<Self, Self::Err> {
132        match s {
133            #[cfg(feature = "brotli")]
134            "br" => Ok(Self::Brotli),
135            #[cfg(feature = "brotli")]
136            "brotli" => Ok(Self::Brotli),
137
138            #[cfg(feature = "deflate")]
139            "deflate" => Ok(Self::Deflate),
140
141            #[cfg(feature = "gzip")]
142            "gzip" => Ok(Self::Gzip),
143
144            #[cfg(feature = "zstd")]
145            "zstd" => Ok(Self::Zstd),
146            _ => Err(format!("unknown compression algorithm: {s}")),
147        }
148    }
149}
150
151impl Display for CompressionAlgo {
152    #[allow(unreachable_patterns)]
153    #[allow(unused_variables)]
154    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
155        match self {
156            #[cfg(feature = "brotli")]
157            Self::Brotli => write!(f, "br"),
158            #[cfg(feature = "deflate")]
159            Self::Deflate => write!(f, "deflate"),
160            #[cfg(feature = "gzip")]
161            Self::Gzip => write!(f, "gzip"),
162            #[cfg(feature = "zstd")]
163            Self::Zstd => write!(f, "zstd"),
164            _ => unreachable!(),
165        }
166    }
167}
168
169impl From<CompressionAlgo> for HeaderValue {
170    #[inline]
171    fn from(algo: CompressionAlgo) -> Self {
172        match algo {
173            #[cfg(feature = "brotli")]
174            CompressionAlgo::Brotli => Self::from_static("br"),
175            #[cfg(feature = "deflate")]
176            CompressionAlgo::Deflate => Self::from_static("deflate"),
177            #[cfg(feature = "gzip")]
178            CompressionAlgo::Gzip => Self::from_static("gzip"),
179            #[cfg(feature = "zstd")]
180            CompressionAlgo::Zstd => Self::from_static("zstd"),
181        }
182    }
183}
184
185/// Compression
186#[derive(Clone, Debug)]
187#[non_exhaustive]
188pub struct Compression {
189    /// Compression algorithms to use.
190    pub algos: IndexMap<CompressionAlgo, CompressionLevel>,
191    /// Content types to compress.
192    pub content_types: Vec<Mime>,
193    /// Minimum body size to compress; bodies smaller than this value are not compressed.
194    ///
195    /// This threshold only applies to bodies whose length is known up front
196    /// (in-memory `Once`/`Chunks` bodies). Streaming bodies (`Hyper`/`Stream`)
197    /// have no known length and are always compressed regardless of
198    /// `min_length`, so a tiny streamed body can still end up larger after
199    /// adding `Content-Encoding` and framing overhead.
200    pub min_length: usize,
201    /// Ignore the client's algorithm order in `Accept-Encoding` and always use the server's
202    /// configured priority.
203    pub force_priority: bool,
204}
205
206static DEFAULT_CONTENT_TYPES: LazyLock<Vec<Mime>> = LazyLock::new(|| {
207    vec![
208        mime::TEXT_STAR,
209        mime::APPLICATION_JAVASCRIPT,
210        mime::APPLICATION_JSON,
211        mime::IMAGE_SVG,
212        "application/wasm".parse().expect("invalid mime type"),
213        "application/xml".parse().expect("invalid mime type"),
214        "application/rss+xml".parse().expect("invalid mime type"),
215    ]
216});
217
218impl Default for Compression {
219    fn default() -> Self {
220        #[allow(unused_mut)]
221        let mut algos = IndexMap::new();
222        #[cfg(feature = "zstd")]
223        algos.insert(CompressionAlgo::Zstd, CompressionLevel::Default);
224        #[cfg(feature = "gzip")]
225        algos.insert(CompressionAlgo::Gzip, CompressionLevel::Default);
226        #[cfg(feature = "deflate")]
227        algos.insert(CompressionAlgo::Deflate, CompressionLevel::Default);
228        #[cfg(feature = "brotli")]
229        algos.insert(CompressionAlgo::Brotli, CompressionLevel::Default);
230        Self {
231            algos,
232            content_types: DEFAULT_CONTENT_TYPES.clone(),
233            min_length: 1024,
234            force_priority: false,
235        }
236    }
237}
238
239impl Compression {
240    /// Create a new `Compression`.
241    #[inline]
242    #[must_use]
243    pub fn new() -> Self {
244        Default::default()
245    }
246
247    /// Remove all compression algorithms.
248    #[inline]
249    #[must_use]
250    pub fn disable_all(mut self) -> Self {
251        self.algos.clear();
252        self
253    }
254
255    /// Sets `Compression` with algos.
256    #[cfg(feature = "gzip")]
257    #[cfg_attr(docsrs, doc(cfg(feature = "gzip")))]
258    #[inline]
259    #[must_use]
260    pub fn enable_gzip(mut self, level: CompressionLevel) -> Self {
261        self.algos.insert(CompressionAlgo::Gzip, level);
262        self
263    }
264    /// Disable gzip compression.
265    #[cfg(feature = "gzip")]
266    #[cfg_attr(docsrs, doc(cfg(feature = "gzip")))]
267    #[inline]
268    #[must_use]
269    pub fn disable_gzip(mut self) -> Self {
270        self.algos.shift_remove(&CompressionAlgo::Gzip);
271        self
272    }
273    /// Enable zstd compression.
274    #[cfg(feature = "zstd")]
275    #[cfg_attr(docsrs, doc(cfg(feature = "zstd")))]
276    #[inline]
277    #[must_use]
278    pub fn enable_zstd(mut self, level: CompressionLevel) -> Self {
279        self.algos.insert(CompressionAlgo::Zstd, level);
280        self
281    }
282    /// Disable zstd compression.
283    #[cfg(feature = "zstd")]
284    #[cfg_attr(docsrs, doc(cfg(feature = "zstd")))]
285    #[inline]
286    #[must_use]
287    pub fn disable_zstd(mut self) -> Self {
288        self.algos.shift_remove(&CompressionAlgo::Zstd);
289        self
290    }
291    /// Enable brotli compression.
292    #[cfg(feature = "brotli")]
293    #[cfg_attr(docsrs, doc(cfg(feature = "brotli")))]
294    #[inline]
295    #[must_use]
296    pub fn enable_brotli(mut self, level: CompressionLevel) -> Self {
297        self.algos.insert(CompressionAlgo::Brotli, level);
298        self
299    }
300    /// Disable brotli compression.
301    #[cfg(feature = "brotli")]
302    #[cfg_attr(docsrs, doc(cfg(feature = "brotli")))]
303    #[inline]
304    #[must_use]
305    pub fn disable_brotli(mut self) -> Self {
306        self.algos.shift_remove(&CompressionAlgo::Brotli);
307        self
308    }
309
310    /// Enable deflate compression.
311    #[cfg(feature = "deflate")]
312    #[cfg_attr(docsrs, doc(cfg(feature = "deflate")))]
313    #[inline]
314    #[must_use]
315    pub fn enable_deflate(mut self, level: CompressionLevel) -> Self {
316        self.algos.insert(CompressionAlgo::Deflate, level);
317        self
318    }
319
320    /// Disable deflate compression.
321    #[cfg(feature = "deflate")]
322    #[cfg_attr(docsrs, doc(cfg(feature = "deflate")))]
323    #[inline]
324    #[must_use]
325    pub fn disable_deflate(mut self) -> Self {
326        self.algos.shift_remove(&CompressionAlgo::Deflate);
327        self
328    }
329
330    /// Sets minimum compression size, if body is less than this value, no compression.
331    /// Default is 1kb.
332    ///
333    /// Only effective for bodies with a known length (`Once`/`Chunks`); streaming
334    /// bodies (`Hyper`/`Stream`) are always compressed regardless of this value.
335    #[inline]
336    #[must_use]
337    pub fn min_length(mut self, size: usize) -> Self {
338        self.min_length = size;
339        self
340    }
341    /// Sets `Compression` with force_priority.
342    #[inline]
343    #[must_use]
344    pub fn force_priority(mut self, force_priority: bool) -> Self {
345        self.force_priority = force_priority;
346        self
347    }
348
349    /// Sets `Compression` with content types list.
350    #[inline]
351    #[must_use]
352    pub fn content_types(mut self, content_types: &[Mime]) -> Self {
353        self.content_types = content_types.to_vec();
354        self
355    }
356
357    fn negotiate(
358        &self,
359        req: &Request,
360        res: &Response,
361    ) -> Option<(CompressionAlgo, CompressionLevel)> {
362        if !self.content_types.is_empty() {
363            let content_type = res
364                .headers()
365                .get(CONTENT_TYPE)
366                .and_then(|v| v.to_str().ok())
367                .unwrap_or_default();
368            if content_type.is_empty() {
369                return None;
370            }
371            if let Ok(content_type) = content_type.parse::<Mime>() {
372                if !self.content_types.iter().any(|citem| {
373                    citem.type_() == content_type.type_()
374                        && (citem.subtype() == "*" || citem.subtype() == content_type.subtype())
375                }) {
376                    return None;
377                }
378            } else {
379                return None;
380            }
381        }
382        let header = req
383            .headers()
384            .get(ACCEPT_ENCODING)
385            .and_then(|v| v.to_str().ok())?;
386
387        let accept_list = http::parse_accept_encoding(header);
388
389        // `parse_accept_encoding` sorts entries by descending q-value, so the
390        // first wildcard token is also the one with the highest q. Mirror the
391        // original `.find(...)` semantics rather than overwriting `wildcard_q`
392        // on every iteration — see https://github.com/salvo-rs/salvo/pull/1489
393        // for the multi-wildcard regression this guards against.
394        let wildcard_q: Option<u8> = accept_list
395            .iter()
396            .find(|(name, _)| name == "*")
397            .map(|(_, q)| *q);
398
399        // Parse each non-wildcard `Accept-Encoding` entry once, dropping tokens
400        // that do not name a known compression algorithm. The lookups below
401        // (`is_accepted` / `is_rejected` / client-preference) then never have
402        // to call `.parse::<CompressionAlgo>()` again. Entries keep the order
403        // produced by `parse_accept_encoding` (descending q-value).
404        let parsed: smallvec::SmallVec<[(CompressionAlgo, u8); 4]> = accept_list
405            .iter()
406            .filter(|(name, _)| name != "*")
407            .filter_map(|(name, q)| name.parse::<CompressionAlgo>().ok().map(|algo| (algo, *q)))
408            .collect();
409
410        let is_accepted =
411            |algo: &CompressionAlgo| -> bool { parsed.iter().any(|(a, q)| a == algo && *q > 0) };
412        let is_rejected =
413            |algo: &CompressionAlgo| -> bool { parsed.iter().any(|(a, q)| a == algo && *q == 0) };
414
415        if self.force_priority {
416            // Server preference: pick the highest-priority server algo the client accepts.
417            self.algos
418                .iter()
419                .find(|(algo, _)| {
420                    !is_rejected(algo) && (is_accepted(algo) || wildcard_q.is_some_and(|q| q > 0))
421                })
422                .map(|(algo, level)| (*algo, *level))
423        } else {
424            // Client preference: pick the highest q-value algo the server supports.
425            let result = parsed
426                .iter()
427                .filter(|(_, q)| *q > 0)
428                .find_map(|(algo, _)| self.algos.get(algo).map(|level| (*algo, *level)));
429
430            if result.is_some() {
431                return result;
432            }
433
434            // Wildcard `*`: use the server's top algo that is not explicitly rejected.
435            if wildcard_q.is_some_and(|q| q > 0) {
436                self.algos
437                    .iter()
438                    .find(|(algo, _)| !is_rejected(algo))
439                    .map(|(algo, level)| (*algo, *level))
440            } else {
441                None
442            }
443        }
444    }
445}
446
447#[async_trait]
448impl Handler for Compression {
449    async fn handle(
450        &self,
451        req: &mut Request,
452        depot: &mut Depot,
453        res: &mut Response,
454        ctrl: &mut FlowCtrl,
455    ) {
456        ctrl.call_next(req, depot, res).await;
457        if ctrl.is_ceased() || res.headers().contains_key(CONTENT_ENCODING) {
458            return;
459        }
460
461        if let Some(StatusCode::SWITCHING_PROTOCOLS | StatusCode::NO_CONTENT) = res.status_code {
462            return;
463        }
464
465        match res.take_body() {
466            ResBody::None => {
467                return;
468            }
469            ResBody::Once(bytes) => {
470                if self.min_length > 0 && bytes.len() < self.min_length {
471                    res.body(ResBody::Once(bytes));
472                    return;
473                }
474                if let Some((algo, level)) = self.negotiate(req, res) {
475                    res.stream(EncodeStream::new(algo, level, Some(bytes)));
476                    res.headers_mut().insert(CONTENT_ENCODING, algo.into());
477                } else {
478                    res.body(ResBody::Once(bytes));
479                    return;
480                }
481            }
482            ResBody::Chunks(chunks) => {
483                if self.min_length > 0 {
484                    let len: usize = chunks.iter().map(|c| c.len()).sum();
485                    if len < self.min_length {
486                        res.body(ResBody::Chunks(chunks));
487                        return;
488                    }
489                }
490                if let Some((algo, level)) = self.negotiate(req, res) {
491                    res.stream(EncodeStream::new(algo, level, chunks));
492                    res.headers_mut().insert(CONTENT_ENCODING, algo.into());
493                } else {
494                    res.body(ResBody::Chunks(chunks));
495                    return;
496                }
497            }
498            ResBody::Hyper(body) => {
499                if let Some((algo, level)) = self.negotiate(req, res) {
500                    res.stream(EncodeStream::new(algo, level, body));
501                    res.headers_mut().insert(CONTENT_ENCODING, algo.into());
502                } else {
503                    res.body(ResBody::Hyper(body));
504                    return;
505                }
506            }
507            ResBody::Stream(body) => {
508                let body = body.into_inner();
509                if let Some((algo, level)) = self.negotiate(req, res) {
510                    res.stream(EncodeStream::new(algo, level, body));
511                    res.headers_mut().insert(CONTENT_ENCODING, algo.into());
512                } else {
513                    res.body(ResBody::stream(body));
514                    return;
515                }
516            }
517            body => {
518                res.body(body);
519                return;
520            }
521        }
522        res.headers_mut().remove(CONTENT_LENGTH);
523        res.headers_mut()
524            .append(VARY, HeaderValue::from_static("accept-encoding"));
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use salvo_core::prelude::*;
531    use salvo_core::test::{ResponseExt, TestClient};
532
533    use super::*;
534
535    #[handler]
536    async fn hello() -> &'static str {
537        "hello"
538    }
539
540    #[tokio::test]
541    async fn test_gzip() {
542        let comp_handler = Compression::new().min_length(1);
543        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
544
545        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
546            .add_header(ACCEPT_ENCODING, "gzip", true)
547            .send(router)
548            .await;
549        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "gzip");
550        let content = res.take_string().await.unwrap();
551        assert_eq!(content, "hello");
552    }
553
554    #[tokio::test]
555    async fn test_brotli() {
556        let comp_handler = Compression::new().min_length(1);
557        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
558
559        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
560            .add_header(ACCEPT_ENCODING, "br", true)
561            .send(router)
562            .await;
563        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "br");
564        let content = res.take_string().await.unwrap();
565        assert_eq!(content, "hello");
566    }
567
568    #[tokio::test]
569    async fn test_deflate() {
570        let comp_handler = Compression::new().min_length(1);
571        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
572
573        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
574            .add_header(ACCEPT_ENCODING, "deflate", true)
575            .send(router)
576            .await;
577        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "deflate");
578        let content = res.take_string().await.unwrap();
579        assert_eq!(content, "hello");
580    }
581
582    #[tokio::test]
583    async fn test_zstd() {
584        let comp_handler = Compression::new().min_length(1);
585        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
586
587        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
588            .add_header(ACCEPT_ENCODING, "zstd", true)
589            .send(router)
590            .await;
591        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "zstd");
592        let content = res.take_string().await.unwrap();
593        assert_eq!(content, "hello");
594    }
595
596    #[tokio::test]
597    async fn test_min_length_not_compress() {
598        let comp_handler = Compression::new().min_length(10);
599        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
600
601        let res = TestClient::get("http://127.0.0.1:5801/hello")
602            .add_header(ACCEPT_ENCODING, "gzip", true)
603            .send(router)
604            .await;
605        assert!(res.headers().get(CONTENT_ENCODING).is_none());
606    }
607
608    #[tokio::test]
609    async fn test_min_length_should_compress() {
610        let comp_handler = Compression::new().min_length(1);
611        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
612
613        let res = TestClient::get("http://127.0.0.1:5801/hello")
614            .add_header(ACCEPT_ENCODING, "gzip", true)
615            .send(router)
616            .await;
617        assert!(res.headers().get(CONTENT_ENCODING).is_some());
618    }
619
620    #[handler]
621    async fn hello_html(res: &mut Response) {
622        res.render(Text::Html("<html><body>hello</body></html>"));
623    }
624    #[tokio::test]
625    async fn test_content_types_should_compress() {
626        let comp_handler = Compression::new()
627            .min_length(1)
628            .content_types(&[mime::TEXT_HTML]);
629        let router =
630            Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello_html));
631
632        let res = TestClient::get("http://127.0.0.1:5801/hello")
633            .add_header(ACCEPT_ENCODING, "gzip", true)
634            .send(router)
635            .await;
636        assert!(res.headers().get(CONTENT_ENCODING).is_some());
637    }
638
639    #[tokio::test]
640    async fn test_content_types_not_compress() {
641        let comp_handler = Compression::new()
642            .min_length(1)
643            .content_types(&[mime::APPLICATION_JSON]);
644        let router =
645            Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello_html));
646
647        let res = TestClient::get("http://127.0.0.1:5801/hello")
648            .add_header(ACCEPT_ENCODING, "gzip", true)
649            .send(router)
650            .await;
651        assert!(res.headers().get(CONTENT_ENCODING).is_none());
652    }
653
654    #[tokio::test]
655    async fn test_q_value_preference() {
656        // Client prefers br (q=1.0) over gzip (q=0.5)
657        let comp_handler = Compression::new().min_length(1);
658        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
659
660        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
661            .add_header(ACCEPT_ENCODING, "gzip;q=0.5, br;q=1.0", true)
662            .send(router)
663            .await;
664        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "br");
665        let content = res.take_string().await.unwrap();
666        assert_eq!(content, "hello");
667    }
668
669    #[tokio::test]
670    async fn test_q_value_zero_rejects_algo() {
671        // gzip is explicitly rejected (q=0), only br is acceptable
672        let comp_handler = Compression::new().min_length(1);
673        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
674
675        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
676            .add_header(ACCEPT_ENCODING, "gzip;q=0, br", true)
677            .send(router)
678            .await;
679        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "br");
680        let content = res.take_string().await.unwrap();
681        assert_eq!(content, "hello");
682    }
683
684    #[tokio::test]
685    async fn test_identity_only_no_compression() {
686        // identity means no encoding; server must not compress
687        let comp_handler = Compression::new().min_length(1);
688        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
689
690        let res = TestClient::get("http://127.0.0.1:5801/hello")
691            .add_header(ACCEPT_ENCODING, "identity", true)
692            .send(router)
693            .await;
694        assert!(res.headers().get(CONTENT_ENCODING).is_none());
695    }
696
697    #[tokio::test]
698    async fn test_wildcard_uses_server_algo() {
699        // `*` means accept any encoding; server picks its preferred algo
700        let comp_handler = Compression::new()
701            .disable_all()
702            .enable_gzip(CompressionLevel::Default)
703            .min_length(1);
704        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
705
706        let res = TestClient::get("http://127.0.0.1:5801/hello")
707            .add_header(ACCEPT_ENCODING, "*", true)
708            .send(router)
709            .await;
710        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "gzip");
711    }
712
713    #[tokio::test]
714    async fn test_wildcard_excludes_rejected_algo() {
715        // `*` but gzip;q=0 — server must not use gzip, falls back to next algo
716        let comp_handler = Compression::new()
717            .disable_all()
718            .enable_gzip(CompressionLevel::Default)
719            .enable_brotli(CompressionLevel::Default)
720            .min_length(1);
721        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
722
723        let res = TestClient::get("http://127.0.0.1:5801/hello")
724            .add_header(ACCEPT_ENCODING, "*, gzip;q=0", true)
725            .send(router)
726            .await;
727        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "br");
728    }
729
730    #[tokio::test]
731    async fn test_multiple_wildcards_take_highest_q() {
732        // When the client lists multiple wildcards, the effective q-value
733        // is the highest one — matching the pre-refactor behaviour. With
734        // `*;q=1, *;q=0` (q-sorted by `parse_accept_encoding`, so the q=1
735        // wildcard comes first), wildcard compression must still be allowed.
736        let comp_handler = Compression::new()
737            .disable_all()
738            .enable_gzip(CompressionLevel::Default)
739            .min_length(1);
740        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
741
742        let res = TestClient::get("http://127.0.0.1:5801/hello")
743            .add_header(ACCEPT_ENCODING, "*;q=1, *;q=0", true)
744            .send(router)
745            .await;
746        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "gzip");
747    }
748
749    #[tokio::test]
750    async fn test_single_content_encoding_header() {
751        // Ensure only one Content-Encoding header is set (no duplicates via append)
752        let comp_handler = Compression::new().min_length(1);
753        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
754
755        let res = TestClient::get("http://127.0.0.1:5801/hello")
756            .add_header(ACCEPT_ENCODING, "gzip", true)
757            .send(router)
758            .await;
759        let count = res.headers().get_all(CONTENT_ENCODING).iter().count();
760        assert_eq!(count, 1, "must have exactly one Content-Encoding header");
761    }
762
763    #[tokio::test]
764    async fn test_force_priority() {
765        let comp_handler = Compression::new()
766            .disable_all()
767            .enable_brotli(CompressionLevel::Default)
768            .enable_gzip(CompressionLevel::Default)
769            .min_length(1)
770            .force_priority(true);
771        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
772
773        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
774            .add_header(ACCEPT_ENCODING, "gzip, br", true)
775            .send(router)
776            .await;
777        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "br");
778        let content = res.take_string().await.unwrap();
779        assert_eq!(content, "hello");
780    }
781
782    // Tests for CompressionLevel
783    #[test]
784    fn test_compression_level_default() {
785        let level: CompressionLevel = Default::default();
786        assert_eq!(level, CompressionLevel::Default);
787    }
788
789    #[test]
790    fn test_compression_level_fastest() {
791        let level = CompressionLevel::Fastest;
792        assert_eq!(level, CompressionLevel::Fastest);
793    }
794
795    #[test]
796    fn test_compression_level_minsize() {
797        let level = CompressionLevel::Minsize;
798        assert_eq!(level, CompressionLevel::Minsize);
799    }
800
801    #[test]
802    fn test_compression_level_precise() {
803        let level = CompressionLevel::Precise(5);
804        assert_eq!(level, CompressionLevel::Precise(5));
805    }
806
807    #[test]
808    fn test_compression_level_clone() {
809        let level = CompressionLevel::Fastest;
810        let cloned = level;
811        assert_eq!(level, cloned);
812    }
813
814    #[test]
815    fn test_compression_level_copy() {
816        let level = CompressionLevel::Default;
817        let copied = level;
818        assert_eq!(level, copied);
819    }
820
821    #[test]
822    fn test_compression_level_debug() {
823        let level = CompressionLevel::Fastest;
824        let debug_str = format!("{level:?}");
825        assert!(debug_str.contains("Fastest"));
826    }
827
828    // Tests for CompressionAlgo
829    #[cfg(feature = "gzip")]
830    #[test]
831    fn test_compression_algo_gzip_from_str() {
832        let algo: CompressionAlgo = "gzip".parse().unwrap();
833        assert_eq!(algo, CompressionAlgo::Gzip);
834    }
835
836    #[cfg(feature = "brotli")]
837    #[test]
838    fn test_compression_algo_brotli_from_str() {
839        let algo: CompressionAlgo = "br".parse().unwrap();
840        assert_eq!(algo, CompressionAlgo::Brotli);
841
842        let algo: CompressionAlgo = "brotli".parse().unwrap();
843        assert_eq!(algo, CompressionAlgo::Brotli);
844    }
845
846    #[cfg(feature = "deflate")]
847    #[test]
848    fn test_compression_algo_deflate_from_str() {
849        let algo: CompressionAlgo = "deflate".parse().unwrap();
850        assert_eq!(algo, CompressionAlgo::Deflate);
851    }
852
853    #[cfg(feature = "zstd")]
854    #[test]
855    fn test_compression_algo_zstd_from_str() {
856        let algo: CompressionAlgo = "zstd".parse().unwrap();
857        assert_eq!(algo, CompressionAlgo::Zstd);
858    }
859
860    #[test]
861    fn test_compression_algo_unknown_from_str() {
862        let result: Result<CompressionAlgo, _> = "unknown".parse();
863        assert!(result.is_err());
864        assert!(
865            result
866                .unwrap_err()
867                .contains("unknown compression algorithm")
868        );
869    }
870
871    #[cfg(feature = "gzip")]
872    #[test]
873    fn test_compression_algo_gzip_display() {
874        let algo = CompressionAlgo::Gzip;
875        assert_eq!(format!("{algo}"), "gzip");
876    }
877
878    #[cfg(feature = "brotli")]
879    #[test]
880    fn test_compression_algo_brotli_display() {
881        let algo = CompressionAlgo::Brotli;
882        assert_eq!(format!("{algo}"), "br");
883    }
884
885    #[cfg(feature = "deflate")]
886    #[test]
887    fn test_compression_algo_deflate_display() {
888        let algo = CompressionAlgo::Deflate;
889        assert_eq!(format!("{algo}"), "deflate");
890    }
891
892    #[cfg(feature = "zstd")]
893    #[test]
894    fn test_compression_algo_zstd_display() {
895        let algo = CompressionAlgo::Zstd;
896        assert_eq!(format!("{algo}"), "zstd");
897    }
898
899    #[cfg(feature = "gzip")]
900    #[test]
901    fn test_compression_algo_into_header_value() {
902        let algo = CompressionAlgo::Gzip;
903        let header: HeaderValue = algo.into();
904        assert_eq!(header, "gzip");
905    }
906
907    #[test]
908    fn test_compression_algo_debug() {
909        #[cfg(feature = "gzip")]
910        {
911            let algo = CompressionAlgo::Gzip;
912            let debug_str = format!("{algo:?}");
913            assert!(debug_str.contains("Gzip"));
914        }
915    }
916
917    #[test]
918    fn test_compression_algo_clone() {
919        #[cfg(feature = "gzip")]
920        {
921            let algo = CompressionAlgo::Gzip;
922            let cloned = algo;
923            assert_eq!(algo, cloned);
924        }
925    }
926
927    #[test]
928    fn test_compression_algo_hash() {
929        use std::collections::HashSet;
930        #[cfg(feature = "gzip")]
931        {
932            let mut set = HashSet::new();
933            set.insert(CompressionAlgo::Gzip);
934            assert!(set.contains(&CompressionAlgo::Gzip));
935        }
936    }
937
938    // Tests for Compression struct
939    #[test]
940    fn test_compression_new() {
941        let comp = Compression::new();
942        assert!(!comp.algos.is_empty());
943        assert!(!comp.content_types.is_empty());
944        assert_eq!(comp.min_length, 1024);
945        assert!(!comp.force_priority);
946    }
947
948    #[test]
949    fn test_compression_default() {
950        let comp = Compression::default();
951        assert!(!comp.algos.is_empty());
952    }
953
954    #[test]
955    fn test_compression_disable_all() {
956        let comp = Compression::new().disable_all();
957        assert!(comp.algos.is_empty());
958    }
959
960    #[cfg(feature = "gzip")]
961    #[test]
962    fn test_compression_enable_gzip() {
963        let comp = Compression::new()
964            .disable_all()
965            .enable_gzip(CompressionLevel::Fastest);
966        assert!(comp.algos.contains_key(&CompressionAlgo::Gzip));
967        assert_eq!(
968            comp.algos.get(&CompressionAlgo::Gzip),
969            Some(&CompressionLevel::Fastest)
970        );
971    }
972
973    #[cfg(feature = "gzip")]
974    #[test]
975    fn test_compression_disable_gzip() {
976        let comp = Compression::new().disable_gzip();
977        assert!(!comp.algos.contains_key(&CompressionAlgo::Gzip));
978    }
979
980    #[cfg(feature = "brotli")]
981    #[test]
982    fn test_compression_enable_brotli() {
983        let comp = Compression::new()
984            .disable_all()
985            .enable_brotli(CompressionLevel::Minsize);
986        assert!(comp.algos.contains_key(&CompressionAlgo::Brotli));
987    }
988
989    #[cfg(feature = "brotli")]
990    #[test]
991    fn test_compression_disable_brotli() {
992        let comp = Compression::new().disable_brotli();
993        assert!(!comp.algos.contains_key(&CompressionAlgo::Brotli));
994    }
995
996    #[cfg(feature = "zstd")]
997    #[test]
998    fn test_compression_enable_zstd() {
999        let comp = Compression::new()
1000            .disable_all()
1001            .enable_zstd(CompressionLevel::Default);
1002        assert!(comp.algos.contains_key(&CompressionAlgo::Zstd));
1003    }
1004
1005    #[cfg(feature = "zstd")]
1006    #[test]
1007    fn test_compression_disable_zstd() {
1008        let comp = Compression::new().disable_zstd();
1009        assert!(!comp.algos.contains_key(&CompressionAlgo::Zstd));
1010    }
1011
1012    #[cfg(feature = "deflate")]
1013    #[test]
1014    fn test_compression_enable_deflate() {
1015        let comp = Compression::new()
1016            .disable_all()
1017            .enable_deflate(CompressionLevel::Default);
1018        assert!(comp.algos.contains_key(&CompressionAlgo::Deflate));
1019    }
1020
1021    #[cfg(feature = "deflate")]
1022    #[test]
1023    fn test_compression_disable_deflate() {
1024        let comp = Compression::new().disable_deflate();
1025        assert!(!comp.algos.contains_key(&CompressionAlgo::Deflate));
1026    }
1027
1028    #[test]
1029    fn test_compression_min_length() {
1030        let comp = Compression::new().min_length(1024);
1031        assert_eq!(comp.min_length, 1024);
1032    }
1033
1034    #[test]
1035    fn test_compression_force_priority() {
1036        let comp = Compression::new().force_priority(true);
1037        assert!(comp.force_priority);
1038    }
1039
1040    #[test]
1041    fn test_compression_content_types() {
1042        let comp = Compression::new().content_types(&[mime::TEXT_PLAIN, mime::TEXT_HTML]);
1043        assert_eq!(comp.content_types.len(), 2);
1044        assert!(comp.content_types.contains(&mime::TEXT_PLAIN));
1045        assert!(comp.content_types.contains(&mime::TEXT_HTML));
1046    }
1047
1048    #[test]
1049    fn test_compression_debug() {
1050        let comp = Compression::new();
1051        let debug_str = format!("{comp:?}");
1052        assert!(debug_str.contains("Compression"));
1053        assert!(debug_str.contains("algos"));
1054        assert!(debug_str.contains("content_types"));
1055    }
1056
1057    #[test]
1058    fn test_compression_clone() {
1059        let comp = Compression::new().min_length(100);
1060        let cloned = comp.clone();
1061        assert_eq!(comp.min_length, cloned.min_length);
1062        assert_eq!(comp.algos.len(), cloned.algos.len());
1063    }
1064
1065    // Tests for no compression scenarios
1066    #[tokio::test]
1067    async fn test_no_accept_encoding_header() {
1068        let comp_handler = Compression::new().min_length(1);
1069        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
1070
1071        let res = TestClient::get("http://127.0.0.1:5801/hello")
1072            .send(router)
1073            .await;
1074        assert!(res.headers().get(CONTENT_ENCODING).is_none());
1075    }
1076
1077    #[tokio::test]
1078    async fn test_unsupported_encoding() {
1079        let comp_handler = Compression::new().min_length(1);
1080        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
1081
1082        let res = TestClient::get("http://127.0.0.1:5801/hello")
1083            .add_header(ACCEPT_ENCODING, "unknown", true)
1084            .send(router)
1085            .await;
1086        assert!(res.headers().get(CONTENT_ENCODING).is_none());
1087    }
1088
1089    #[tokio::test]
1090    async fn test_empty_response() {
1091        #[handler]
1092        async fn empty() {}
1093
1094        let comp_handler = Compression::new();
1095        let router = Router::with_hoop(comp_handler).push(Router::with_path("empty").get(empty));
1096
1097        let res = TestClient::get("http://127.0.0.1:5801/empty")
1098            .add_header(ACCEPT_ENCODING, "gzip", true)
1099            .send(router)
1100            .await;
1101        assert!(res.headers().get(CONTENT_ENCODING).is_none());
1102    }
1103
1104    #[tokio::test]
1105    async fn test_chained_configuration() {
1106        #[cfg(all(feature = "gzip", feature = "brotli"))]
1107        {
1108            let comp_handler = Compression::new()
1109                .disable_all()
1110                .enable_gzip(CompressionLevel::Fastest)
1111                .enable_brotli(CompressionLevel::Default)
1112                .min_length(1)
1113                .force_priority(false)
1114                .content_types(&[mime::TEXT_PLAIN]);
1115
1116            assert_eq!(comp_handler.algos.len(), 2);
1117            assert_eq!(comp_handler.min_length, 1);
1118            assert!(!comp_handler.force_priority);
1119            assert_eq!(comp_handler.content_types.len(), 1);
1120        }
1121    }
1122}