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    /// Sets minimum compression size, if body is less than this value, no compression.
194    pub min_length: usize,
195    /// Ignore request algorithms order in `Accept-Encoding` header and always server's config.
196    pub force_priority: bool,
197}
198
199static DEFAULT_CONTENT_TYPES: LazyLock<Vec<Mime>> = LazyLock::new(|| {
200    vec![
201        mime::TEXT_STAR,
202        mime::APPLICATION_JAVASCRIPT,
203        mime::APPLICATION_JSON,
204        mime::IMAGE_SVG,
205        "application/wasm".parse().expect("invalid mime type"),
206        "application/xml".parse().expect("invalid mime type"),
207        "application/rss+xml".parse().expect("invalid mime type"),
208    ]
209});
210
211impl Default for Compression {
212    fn default() -> Self {
213        #[allow(unused_mut)]
214        let mut algos = IndexMap::new();
215        #[cfg(feature = "zstd")]
216        algos.insert(CompressionAlgo::Zstd, CompressionLevel::Default);
217        #[cfg(feature = "gzip")]
218        algos.insert(CompressionAlgo::Gzip, CompressionLevel::Default);
219        #[cfg(feature = "deflate")]
220        algos.insert(CompressionAlgo::Deflate, CompressionLevel::Default);
221        #[cfg(feature = "brotli")]
222        algos.insert(CompressionAlgo::Brotli, CompressionLevel::Default);
223        Self {
224            algos,
225            content_types: DEFAULT_CONTENT_TYPES.clone(),
226            min_length: 1024,
227            force_priority: false,
228        }
229    }
230}
231
232impl Compression {
233    /// Create a new `Compression`.
234    #[inline]
235    #[must_use]
236    pub fn new() -> Self {
237        Default::default()
238    }
239
240    /// Remove all compression algorithms.
241    #[inline]
242    #[must_use]
243    pub fn disable_all(mut self) -> Self {
244        self.algos.clear();
245        self
246    }
247
248    /// Sets `Compression` with algos.
249    #[cfg(feature = "gzip")]
250    #[cfg_attr(docsrs, doc(cfg(feature = "gzip")))]
251    #[inline]
252    #[must_use]
253    pub fn enable_gzip(mut self, level: CompressionLevel) -> Self {
254        self.algos.insert(CompressionAlgo::Gzip, level);
255        self
256    }
257    /// Disable gzip compression.
258    #[cfg(feature = "gzip")]
259    #[cfg_attr(docsrs, doc(cfg(feature = "gzip")))]
260    #[inline]
261    #[must_use]
262    pub fn disable_gzip(mut self) -> Self {
263        self.algos.shift_remove(&CompressionAlgo::Gzip);
264        self
265    }
266    /// Enable zstd compression.
267    #[cfg(feature = "zstd")]
268    #[cfg_attr(docsrs, doc(cfg(feature = "zstd")))]
269    #[inline]
270    #[must_use]
271    pub fn enable_zstd(mut self, level: CompressionLevel) -> Self {
272        self.algos.insert(CompressionAlgo::Zstd, level);
273        self
274    }
275    /// Disable zstd compression.
276    #[cfg(feature = "zstd")]
277    #[cfg_attr(docsrs, doc(cfg(feature = "zstd")))]
278    #[inline]
279    #[must_use]
280    pub fn disable_zstd(mut self) -> Self {
281        self.algos.shift_remove(&CompressionAlgo::Zstd);
282        self
283    }
284    /// Enable brotli compression.
285    #[cfg(feature = "brotli")]
286    #[cfg_attr(docsrs, doc(cfg(feature = "brotli")))]
287    #[inline]
288    #[must_use]
289    pub fn enable_brotli(mut self, level: CompressionLevel) -> Self {
290        self.algos.insert(CompressionAlgo::Brotli, level);
291        self
292    }
293    /// Disable brotli compression.
294    #[cfg(feature = "brotli")]
295    #[cfg_attr(docsrs, doc(cfg(feature = "brotli")))]
296    #[inline]
297    #[must_use]
298    pub fn disable_brotli(mut self) -> Self {
299        self.algos.shift_remove(&CompressionAlgo::Brotli);
300        self
301    }
302
303    /// Enable deflate compression.
304    #[cfg(feature = "deflate")]
305    #[cfg_attr(docsrs, doc(cfg(feature = "deflate")))]
306    #[inline]
307    #[must_use]
308    pub fn enable_deflate(mut self, level: CompressionLevel) -> Self {
309        self.algos.insert(CompressionAlgo::Deflate, level);
310        self
311    }
312
313    /// Disable deflate compression.
314    #[cfg(feature = "deflate")]
315    #[cfg_attr(docsrs, doc(cfg(feature = "deflate")))]
316    #[inline]
317    #[must_use]
318    pub fn disable_deflate(mut self) -> Self {
319        self.algos.shift_remove(&CompressionAlgo::Deflate);
320        self
321    }
322
323    /// Sets minimum compression size, if body is less than this value, no compression.
324    /// Default is 1kb.
325    #[inline]
326    #[must_use]
327    pub fn min_length(mut self, size: usize) -> Self {
328        self.min_length = size;
329        self
330    }
331    /// Sets `Compression` with force_priority.
332    #[inline]
333    #[must_use]
334    pub fn force_priority(mut self, force_priority: bool) -> Self {
335        self.force_priority = force_priority;
336        self
337    }
338
339    /// Sets `Compression` with content types list.
340    #[inline]
341    #[must_use]
342    pub fn content_types(mut self, content_types: &[Mime]) -> Self {
343        self.content_types = content_types.to_vec();
344        self
345    }
346
347    fn negotiate(
348        &self,
349        req: &Request,
350        res: &Response,
351    ) -> Option<(CompressionAlgo, CompressionLevel)> {
352        if !self.content_types.is_empty() {
353            let content_type = res
354                .headers()
355                .get(CONTENT_TYPE)
356                .and_then(|v| v.to_str().ok())
357                .unwrap_or_default();
358            if content_type.is_empty() {
359                return None;
360            }
361            if let Ok(content_type) = content_type.parse::<Mime>() {
362                if !self.content_types.iter().any(|citem| {
363                    citem.type_() == content_type.type_()
364                        && (citem.subtype() == "*" || citem.subtype() == content_type.subtype())
365                }) {
366                    return None;
367                }
368            } else {
369                return None;
370            }
371        }
372        let header = req
373            .headers()
374            .get(ACCEPT_ENCODING)
375            .and_then(|v| v.to_str().ok())?;
376
377        let accept_list = http::parse_accept_encoding(header);
378
379        let wildcard_q = accept_list.iter().find(|(a, _)| a == "*").map(|(_, q)| *q);
380
381        let is_accepted = |algo: &CompressionAlgo| -> bool {
382            accept_list
383                .iter()
384                .filter(|(_, q)| *q > 0)
385                .any(|(a, _)| a.parse::<CompressionAlgo>().ok().as_ref() == Some(algo))
386        };
387        let is_rejected = |algo: &CompressionAlgo| -> bool {
388            accept_list
389                .iter()
390                .filter(|(_, q)| *q == 0)
391                .any(|(a, _)| a.parse::<CompressionAlgo>().ok().as_ref() == Some(algo))
392        };
393
394        if self.force_priority {
395            // Server preference: pick the highest-priority server algo the client accepts.
396            self.algos
397                .iter()
398                .find(|(algo, _)| {
399                    !is_rejected(algo) && (is_accepted(algo) || wildcard_q.is_some_and(|q| q > 0))
400                })
401                .map(|(algo, level)| (*algo, *level))
402        } else {
403            // Client preference: pick the highest q-value algo the server supports.
404            let result = accept_list
405                .iter()
406                .filter(|(_, q)| *q > 0)
407                .filter_map(|(algo, _)| algo.parse::<CompressionAlgo>().ok())
408                .find_map(|algo| self.algos.get(&algo).map(|level| (algo, *level)));
409
410            if result.is_some() {
411                return result;
412            }
413
414            // Wildcard `*`: use the server's top algo that is not explicitly rejected.
415            if wildcard_q.is_some_and(|q| q > 0) {
416                self.algos
417                    .iter()
418                    .find(|(algo, _)| !is_rejected(algo))
419                    .map(|(algo, level)| (*algo, *level))
420            } else {
421                None
422            }
423        }
424    }
425}
426
427#[async_trait]
428impl Handler for Compression {
429    async fn handle(
430        &self,
431        req: &mut Request,
432        depot: &mut Depot,
433        res: &mut Response,
434        ctrl: &mut FlowCtrl,
435    ) {
436        ctrl.call_next(req, depot, res).await;
437        if ctrl.is_ceased() || res.headers().contains_key(CONTENT_ENCODING) {
438            return;
439        }
440
441        if let Some(StatusCode::SWITCHING_PROTOCOLS | StatusCode::NO_CONTENT) = res.status_code {
442            return;
443        }
444
445        match res.take_body() {
446            ResBody::None => {
447                return;
448            }
449            ResBody::Once(bytes) => {
450                if self.min_length > 0 && bytes.len() < self.min_length {
451                    res.body(ResBody::Once(bytes));
452                    return;
453                }
454                if let Some((algo, level)) = self.negotiate(req, res) {
455                    res.stream(EncodeStream::new(algo, level, Some(bytes)));
456                    res.headers_mut().insert(CONTENT_ENCODING, algo.into());
457                } else {
458                    res.body(ResBody::Once(bytes));
459                    return;
460                }
461            }
462            ResBody::Chunks(chunks) => {
463                if self.min_length > 0 {
464                    let len: usize = chunks.iter().map(|c| c.len()).sum();
465                    if len < self.min_length {
466                        res.body(ResBody::Chunks(chunks));
467                        return;
468                    }
469                }
470                if let Some((algo, level)) = self.negotiate(req, res) {
471                    res.stream(EncodeStream::new(algo, level, chunks));
472                    res.headers_mut().insert(CONTENT_ENCODING, algo.into());
473                } else {
474                    res.body(ResBody::Chunks(chunks));
475                    return;
476                }
477            }
478            ResBody::Hyper(body) => {
479                if let Some((algo, level)) = self.negotiate(req, res) {
480                    res.stream(EncodeStream::new(algo, level, body));
481                    res.headers_mut().insert(CONTENT_ENCODING, algo.into());
482                } else {
483                    res.body(ResBody::Hyper(body));
484                    return;
485                }
486            }
487            ResBody::Stream(body) => {
488                let body = body.into_inner();
489                if let Some((algo, level)) = self.negotiate(req, res) {
490                    res.stream(EncodeStream::new(algo, level, body));
491                    res.headers_mut().insert(CONTENT_ENCODING, algo.into());
492                } else {
493                    res.body(ResBody::stream(body));
494                    return;
495                }
496            }
497            body => {
498                res.body(body);
499                return;
500            }
501        }
502        res.headers_mut().remove(CONTENT_LENGTH);
503        res.headers_mut()
504            .append(VARY, HeaderValue::from_static("accept-encoding"));
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use salvo_core::prelude::*;
511    use salvo_core::test::{ResponseExt, TestClient};
512
513    use super::*;
514
515    #[handler]
516    async fn hello() -> &'static str {
517        "hello"
518    }
519
520    #[tokio::test]
521    async fn test_gzip() {
522        let comp_handler = Compression::new().min_length(1);
523        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
524
525        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
526            .add_header(ACCEPT_ENCODING, "gzip", true)
527            .send(router)
528            .await;
529        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "gzip");
530        let content = res.take_string().await.unwrap();
531        assert_eq!(content, "hello");
532    }
533
534    #[tokio::test]
535    async fn test_brotli() {
536        let comp_handler = Compression::new().min_length(1);
537        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
538
539        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
540            .add_header(ACCEPT_ENCODING, "br", true)
541            .send(router)
542            .await;
543        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "br");
544        let content = res.take_string().await.unwrap();
545        assert_eq!(content, "hello");
546    }
547
548    #[tokio::test]
549    async fn test_deflate() {
550        let comp_handler = Compression::new().min_length(1);
551        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
552
553        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
554            .add_header(ACCEPT_ENCODING, "deflate", true)
555            .send(router)
556            .await;
557        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "deflate");
558        let content = res.take_string().await.unwrap();
559        assert_eq!(content, "hello");
560    }
561
562    #[tokio::test]
563    async fn test_zstd() {
564        let comp_handler = Compression::new().min_length(1);
565        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
566
567        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
568            .add_header(ACCEPT_ENCODING, "zstd", true)
569            .send(router)
570            .await;
571        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "zstd");
572        let content = res.take_string().await.unwrap();
573        assert_eq!(content, "hello");
574    }
575
576    #[tokio::test]
577    async fn test_min_length_not_compress() {
578        let comp_handler = Compression::new().min_length(10);
579        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
580
581        let res = TestClient::get("http://127.0.0.1:5801/hello")
582            .add_header(ACCEPT_ENCODING, "gzip", true)
583            .send(router)
584            .await;
585        assert!(res.headers().get(CONTENT_ENCODING).is_none());
586    }
587
588    #[tokio::test]
589    async fn test_min_length_should_compress() {
590        let comp_handler = Compression::new().min_length(1);
591        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
592
593        let res = TestClient::get("http://127.0.0.1:5801/hello")
594            .add_header(ACCEPT_ENCODING, "gzip", true)
595            .send(router)
596            .await;
597        assert!(res.headers().get(CONTENT_ENCODING).is_some());
598    }
599
600    #[handler]
601    async fn hello_html(res: &mut Response) {
602        res.render(Text::Html("<html><body>hello</body></html>"));
603    }
604    #[tokio::test]
605    async fn test_content_types_should_compress() {
606        let comp_handler = Compression::new()
607            .min_length(1)
608            .content_types(&[mime::TEXT_HTML]);
609        let router =
610            Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello_html));
611
612        let res = TestClient::get("http://127.0.0.1:5801/hello")
613            .add_header(ACCEPT_ENCODING, "gzip", true)
614            .send(router)
615            .await;
616        assert!(res.headers().get(CONTENT_ENCODING).is_some());
617    }
618
619    #[tokio::test]
620    async fn test_content_types_not_compress() {
621        let comp_handler = Compression::new()
622            .min_length(1)
623            .content_types(&[mime::APPLICATION_JSON]);
624        let router =
625            Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello_html));
626
627        let res = TestClient::get("http://127.0.0.1:5801/hello")
628            .add_header(ACCEPT_ENCODING, "gzip", true)
629            .send(router)
630            .await;
631        assert!(res.headers().get(CONTENT_ENCODING).is_none());
632    }
633
634    #[tokio::test]
635    async fn test_q_value_preference() {
636        // Client prefers br (q=1.0) over gzip (q=0.5)
637        let comp_handler = Compression::new().min_length(1);
638        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
639
640        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
641            .add_header(ACCEPT_ENCODING, "gzip;q=0.5, br;q=1.0", true)
642            .send(router)
643            .await;
644        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "br");
645        let content = res.take_string().await.unwrap();
646        assert_eq!(content, "hello");
647    }
648
649    #[tokio::test]
650    async fn test_q_value_zero_rejects_algo() {
651        // gzip is explicitly rejected (q=0), only br is acceptable
652        let comp_handler = Compression::new().min_length(1);
653        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
654
655        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
656            .add_header(ACCEPT_ENCODING, "gzip;q=0, br", true)
657            .send(router)
658            .await;
659        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "br");
660        let content = res.take_string().await.unwrap();
661        assert_eq!(content, "hello");
662    }
663
664    #[tokio::test]
665    async fn test_identity_only_no_compression() {
666        // identity means no encoding; server must not compress
667        let comp_handler = Compression::new().min_length(1);
668        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
669
670        let res = TestClient::get("http://127.0.0.1:5801/hello")
671            .add_header(ACCEPT_ENCODING, "identity", true)
672            .send(router)
673            .await;
674        assert!(res.headers().get(CONTENT_ENCODING).is_none());
675    }
676
677    #[tokio::test]
678    async fn test_wildcard_uses_server_algo() {
679        // `*` means accept any encoding; server picks its preferred algo
680        let comp_handler = Compression::new()
681            .disable_all()
682            .enable_gzip(CompressionLevel::Default)
683            .min_length(1);
684        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
685
686        let res = TestClient::get("http://127.0.0.1:5801/hello")
687            .add_header(ACCEPT_ENCODING, "*", true)
688            .send(router)
689            .await;
690        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "gzip");
691    }
692
693    #[tokio::test]
694    async fn test_wildcard_excludes_rejected_algo() {
695        // `*` but gzip;q=0 — server must not use gzip, falls back to next algo
696        let comp_handler = Compression::new()
697            .disable_all()
698            .enable_gzip(CompressionLevel::Default)
699            .enable_brotli(CompressionLevel::Default)
700            .min_length(1);
701        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
702
703        let res = TestClient::get("http://127.0.0.1:5801/hello")
704            .add_header(ACCEPT_ENCODING, "*, gzip;q=0", true)
705            .send(router)
706            .await;
707        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "br");
708    }
709
710    #[tokio::test]
711    async fn test_single_content_encoding_header() {
712        // Ensure only one Content-Encoding header is set (no duplicates via append)
713        let comp_handler = Compression::new().min_length(1);
714        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
715
716        let res = TestClient::get("http://127.0.0.1:5801/hello")
717            .add_header(ACCEPT_ENCODING, "gzip", true)
718            .send(router)
719            .await;
720        let count = res.headers().get_all(CONTENT_ENCODING).iter().count();
721        assert_eq!(count, 1, "must have exactly one Content-Encoding header");
722    }
723
724    #[tokio::test]
725    async fn test_force_priority() {
726        let comp_handler = Compression::new()
727            .disable_all()
728            .enable_brotli(CompressionLevel::Default)
729            .enable_gzip(CompressionLevel::Default)
730            .min_length(1)
731            .force_priority(true);
732        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
733
734        let mut res = TestClient::get("http://127.0.0.1:5801/hello")
735            .add_header(ACCEPT_ENCODING, "gzip, br", true)
736            .send(router)
737            .await;
738        assert_eq!(res.headers().get(CONTENT_ENCODING).unwrap(), "br");
739        let content = res.take_string().await.unwrap();
740        assert_eq!(content, "hello");
741    }
742
743    // Tests for CompressionLevel
744    #[test]
745    fn test_compression_level_default() {
746        let level: CompressionLevel = Default::default();
747        assert_eq!(level, CompressionLevel::Default);
748    }
749
750    #[test]
751    fn test_compression_level_fastest() {
752        let level = CompressionLevel::Fastest;
753        assert_eq!(level, CompressionLevel::Fastest);
754    }
755
756    #[test]
757    fn test_compression_level_minsize() {
758        let level = CompressionLevel::Minsize;
759        assert_eq!(level, CompressionLevel::Minsize);
760    }
761
762    #[test]
763    fn test_compression_level_precise() {
764        let level = CompressionLevel::Precise(5);
765        assert_eq!(level, CompressionLevel::Precise(5));
766    }
767
768    #[test]
769    fn test_compression_level_clone() {
770        let level = CompressionLevel::Fastest;
771        let cloned = level;
772        assert_eq!(level, cloned);
773    }
774
775    #[test]
776    fn test_compression_level_copy() {
777        let level = CompressionLevel::Default;
778        let copied = level;
779        assert_eq!(level, copied);
780    }
781
782    #[test]
783    fn test_compression_level_debug() {
784        let level = CompressionLevel::Fastest;
785        let debug_str = format!("{level:?}");
786        assert!(debug_str.contains("Fastest"));
787    }
788
789    // Tests for CompressionAlgo
790    #[cfg(feature = "gzip")]
791    #[test]
792    fn test_compression_algo_gzip_from_str() {
793        let algo: CompressionAlgo = "gzip".parse().unwrap();
794        assert_eq!(algo, CompressionAlgo::Gzip);
795    }
796
797    #[cfg(feature = "brotli")]
798    #[test]
799    fn test_compression_algo_brotli_from_str() {
800        let algo: CompressionAlgo = "br".parse().unwrap();
801        assert_eq!(algo, CompressionAlgo::Brotli);
802
803        let algo: CompressionAlgo = "brotli".parse().unwrap();
804        assert_eq!(algo, CompressionAlgo::Brotli);
805    }
806
807    #[cfg(feature = "deflate")]
808    #[test]
809    fn test_compression_algo_deflate_from_str() {
810        let algo: CompressionAlgo = "deflate".parse().unwrap();
811        assert_eq!(algo, CompressionAlgo::Deflate);
812    }
813
814    #[cfg(feature = "zstd")]
815    #[test]
816    fn test_compression_algo_zstd_from_str() {
817        let algo: CompressionAlgo = "zstd".parse().unwrap();
818        assert_eq!(algo, CompressionAlgo::Zstd);
819    }
820
821    #[test]
822    fn test_compression_algo_unknown_from_str() {
823        let result: Result<CompressionAlgo, _> = "unknown".parse();
824        assert!(result.is_err());
825        assert!(
826            result
827                .unwrap_err()
828                .contains("unknown compression algorithm")
829        );
830    }
831
832    #[cfg(feature = "gzip")]
833    #[test]
834    fn test_compression_algo_gzip_display() {
835        let algo = CompressionAlgo::Gzip;
836        assert_eq!(format!("{algo}"), "gzip");
837    }
838
839    #[cfg(feature = "brotli")]
840    #[test]
841    fn test_compression_algo_brotli_display() {
842        let algo = CompressionAlgo::Brotli;
843        assert_eq!(format!("{algo}"), "br");
844    }
845
846    #[cfg(feature = "deflate")]
847    #[test]
848    fn test_compression_algo_deflate_display() {
849        let algo = CompressionAlgo::Deflate;
850        assert_eq!(format!("{algo}"), "deflate");
851    }
852
853    #[cfg(feature = "zstd")]
854    #[test]
855    fn test_compression_algo_zstd_display() {
856        let algo = CompressionAlgo::Zstd;
857        assert_eq!(format!("{algo}"), "zstd");
858    }
859
860    #[cfg(feature = "gzip")]
861    #[test]
862    fn test_compression_algo_into_header_value() {
863        let algo = CompressionAlgo::Gzip;
864        let header: HeaderValue = algo.into();
865        assert_eq!(header, "gzip");
866    }
867
868    #[test]
869    fn test_compression_algo_debug() {
870        #[cfg(feature = "gzip")]
871        {
872            let algo = CompressionAlgo::Gzip;
873            let debug_str = format!("{algo:?}");
874            assert!(debug_str.contains("Gzip"));
875        }
876    }
877
878    #[test]
879    fn test_compression_algo_clone() {
880        #[cfg(feature = "gzip")]
881        {
882            let algo = CompressionAlgo::Gzip;
883            let cloned = algo;
884            assert_eq!(algo, cloned);
885        }
886    }
887
888    #[test]
889    fn test_compression_algo_hash() {
890        use std::collections::HashSet;
891        #[cfg(feature = "gzip")]
892        {
893            let mut set = HashSet::new();
894            set.insert(CompressionAlgo::Gzip);
895            assert!(set.contains(&CompressionAlgo::Gzip));
896        }
897    }
898
899    // Tests for Compression struct
900    #[test]
901    fn test_compression_new() {
902        let comp = Compression::new();
903        assert!(!comp.algos.is_empty());
904        assert!(!comp.content_types.is_empty());
905        assert_eq!(comp.min_length, 1024);
906        assert!(!comp.force_priority);
907    }
908
909    #[test]
910    fn test_compression_default() {
911        let comp = Compression::default();
912        assert!(!comp.algos.is_empty());
913    }
914
915    #[test]
916    fn test_compression_disable_all() {
917        let comp = Compression::new().disable_all();
918        assert!(comp.algos.is_empty());
919    }
920
921    #[cfg(feature = "gzip")]
922    #[test]
923    fn test_compression_enable_gzip() {
924        let comp = Compression::new()
925            .disable_all()
926            .enable_gzip(CompressionLevel::Fastest);
927        assert!(comp.algos.contains_key(&CompressionAlgo::Gzip));
928        assert_eq!(
929            comp.algos.get(&CompressionAlgo::Gzip),
930            Some(&CompressionLevel::Fastest)
931        );
932    }
933
934    #[cfg(feature = "gzip")]
935    #[test]
936    fn test_compression_disable_gzip() {
937        let comp = Compression::new().disable_gzip();
938        assert!(!comp.algos.contains_key(&CompressionAlgo::Gzip));
939    }
940
941    #[cfg(feature = "brotli")]
942    #[test]
943    fn test_compression_enable_brotli() {
944        let comp = Compression::new()
945            .disable_all()
946            .enable_brotli(CompressionLevel::Minsize);
947        assert!(comp.algos.contains_key(&CompressionAlgo::Brotli));
948    }
949
950    #[cfg(feature = "brotli")]
951    #[test]
952    fn test_compression_disable_brotli() {
953        let comp = Compression::new().disable_brotli();
954        assert!(!comp.algos.contains_key(&CompressionAlgo::Brotli));
955    }
956
957    #[cfg(feature = "zstd")]
958    #[test]
959    fn test_compression_enable_zstd() {
960        let comp = Compression::new()
961            .disable_all()
962            .enable_zstd(CompressionLevel::Default);
963        assert!(comp.algos.contains_key(&CompressionAlgo::Zstd));
964    }
965
966    #[cfg(feature = "zstd")]
967    #[test]
968    fn test_compression_disable_zstd() {
969        let comp = Compression::new().disable_zstd();
970        assert!(!comp.algos.contains_key(&CompressionAlgo::Zstd));
971    }
972
973    #[cfg(feature = "deflate")]
974    #[test]
975    fn test_compression_enable_deflate() {
976        let comp = Compression::new()
977            .disable_all()
978            .enable_deflate(CompressionLevel::Default);
979        assert!(comp.algos.contains_key(&CompressionAlgo::Deflate));
980    }
981
982    #[cfg(feature = "deflate")]
983    #[test]
984    fn test_compression_disable_deflate() {
985        let comp = Compression::new().disable_deflate();
986        assert!(!comp.algos.contains_key(&CompressionAlgo::Deflate));
987    }
988
989    #[test]
990    fn test_compression_min_length() {
991        let comp = Compression::new().min_length(1024);
992        assert_eq!(comp.min_length, 1024);
993    }
994
995    #[test]
996    fn test_compression_force_priority() {
997        let comp = Compression::new().force_priority(true);
998        assert!(comp.force_priority);
999    }
1000
1001    #[test]
1002    fn test_compression_content_types() {
1003        let comp = Compression::new().content_types(&[mime::TEXT_PLAIN, mime::TEXT_HTML]);
1004        assert_eq!(comp.content_types.len(), 2);
1005        assert!(comp.content_types.contains(&mime::TEXT_PLAIN));
1006        assert!(comp.content_types.contains(&mime::TEXT_HTML));
1007    }
1008
1009    #[test]
1010    fn test_compression_debug() {
1011        let comp = Compression::new();
1012        let debug_str = format!("{comp:?}");
1013        assert!(debug_str.contains("Compression"));
1014        assert!(debug_str.contains("algos"));
1015        assert!(debug_str.contains("content_types"));
1016    }
1017
1018    #[test]
1019    fn test_compression_clone() {
1020        let comp = Compression::new().min_length(100);
1021        let cloned = comp.clone();
1022        assert_eq!(comp.min_length, cloned.min_length);
1023        assert_eq!(comp.algos.len(), cloned.algos.len());
1024    }
1025
1026    // Tests for no compression scenarios
1027    #[tokio::test]
1028    async fn test_no_accept_encoding_header() {
1029        let comp_handler = Compression::new().min_length(1);
1030        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
1031
1032        let res = TestClient::get("http://127.0.0.1:5801/hello")
1033            .send(router)
1034            .await;
1035        assert!(res.headers().get(CONTENT_ENCODING).is_none());
1036    }
1037
1038    #[tokio::test]
1039    async fn test_unsupported_encoding() {
1040        let comp_handler = Compression::new().min_length(1);
1041        let router = Router::with_hoop(comp_handler).push(Router::with_path("hello").get(hello));
1042
1043        let res = TestClient::get("http://127.0.0.1:5801/hello")
1044            .add_header(ACCEPT_ENCODING, "unknown", true)
1045            .send(router)
1046            .await;
1047        assert!(res.headers().get(CONTENT_ENCODING).is_none());
1048    }
1049
1050    #[tokio::test]
1051    async fn test_empty_response() {
1052        #[handler]
1053        async fn empty() {}
1054
1055        let comp_handler = Compression::new();
1056        let router = Router::with_hoop(comp_handler).push(Router::with_path("empty").get(empty));
1057
1058        let res = TestClient::get("http://127.0.0.1:5801/empty")
1059            .add_header(ACCEPT_ENCODING, "gzip", true)
1060            .send(router)
1061            .await;
1062        assert!(res.headers().get(CONTENT_ENCODING).is_none());
1063    }
1064
1065    #[tokio::test]
1066    async fn test_chained_configuration() {
1067        #[cfg(all(feature = "gzip", feature = "brotli"))]
1068        {
1069            let comp_handler = Compression::new()
1070                .disable_all()
1071                .enable_gzip(CompressionLevel::Fastest)
1072                .enable_brotli(CompressionLevel::Default)
1073                .min_length(1)
1074                .force_priority(false)
1075                .content_types(&[mime::TEXT_PLAIN]);
1076
1077            assert_eq!(comp_handler.algos.len(), 2);
1078            assert_eq!(comp_handler.min_length, 1);
1079            assert!(!comp_handler.force_priority);
1080            assert_eq!(comp_handler.content_types.len(), 1);
1081        }
1082    }
1083}