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