Skip to main content

actix_web/middleware/
compress.rs

1//! For middleware documentation, see [`Compress`].
2
3use std::{
4    future::Future,
5    marker::PhantomData,
6    pin::Pin,
7    task::{Context, Poll},
8};
9
10use actix_http::encoding::Encoder;
11use actix_service::{Service, Transform};
12#[expect(deprecated, reason = "preserve the public ready future type")]
13use actix_utils::future::{ready, Either, Ready};
14use futures_core::ready;
15use mime::Mime;
16use once_cell::sync::Lazy;
17use pin_project_lite::pin_project;
18
19use crate::{
20    body::{EitherBody, MessageBody},
21    http::{
22        header::{self, AcceptEncoding, ContentEncoding, Encoding, HeaderValue},
23        StatusCode,
24    },
25    service::{ServiceRequest, ServiceResponse},
26    Error, HttpMessage, HttpResponse,
27};
28
29/// Middleware for compressing response payloads.
30///
31/// # Encoding Negotiation
32/// `Compress` will read the `Accept-Encoding` header to negotiate which compression codec to use.
33/// Payloads are not compressed if the header is not sent. The `compress-*` [feature flags] are also
34/// considered in this selection process.
35///
36/// # Pre-compressed Payload
37/// If you are serving some data that is already using a compressed representation (e.g., a gzip
38/// compressed HTML file from disk) you can signal this to `Compress` by setting an appropriate
39/// `Content-Encoding` header. In addition to preventing double compressing the payload, this header
40/// is required by the spec when using compressed representations and will inform the client that
41/// the content should be uncompressed.
42///
43/// However, it is not advised to unconditionally serve encoded representations of content because
44/// the client may not support it. The [`AcceptEncoding`] typed header has some utilities to help
45/// perform manual encoding negotiation, if required. When negotiating content encoding, it is also
46/// required by the spec to send a `Vary: Accept-Encoding` header.
47///
48/// A (naïve) example serving an pre-compressed Gzip file is included below.
49///
50/// # Examples
51/// To enable automatic payload compression just include `Compress` as a top-level middleware:
52/// ```
53/// use actix_web::{middleware, web, App, HttpResponse};
54///
55/// let app = App::new()
56///     .wrap(middleware::Compress::default())
57///     .default_service(web::to(|| async { HttpResponse::Ok().body("hello world") }));
58/// ```
59///
60/// Pre-compressed Gzip file being served from disk with correct headers added to bypass middleware:
61/// ```no_run
62/// use actix_web::{middleware, http::header, web, App, HttpResponse, Responder};
63///
64/// async fn index_handler() -> actix_web::Result<impl Responder> {
65///     Ok(actix_files::NamedFile::open("./assets/index.html.gz")?
66///         .customize()
67///         .insert_header(header::ContentEncoding::Gzip))
68/// }
69///
70/// let app = App::new()
71///     .wrap(middleware::Compress::default())
72///     .default_service(web::to(index_handler));
73/// ```
74///
75/// [feature flags]: ../index.html#crate-features
76#[derive(Debug, Clone, Default)]
77#[non_exhaustive]
78pub struct Compress;
79
80#[expect(deprecated, reason = "preserve the public ready future type")]
81impl<S, B> Transform<S, ServiceRequest> for Compress
82where
83    B: MessageBody,
84    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
85{
86    type Response = ServiceResponse<EitherBody<Encoder<B>>>;
87    type Error = Error;
88    type Transform = CompressMiddleware<S>;
89    type InitError = ();
90    type Future = Ready<Result<Self::Transform, Self::InitError>>;
91
92    fn new_transform(&self, service: S) -> Self::Future {
93        ready(Ok(CompressMiddleware { service }))
94    }
95}
96
97pub struct CompressMiddleware<S> {
98    service: S,
99}
100
101#[expect(deprecated, reason = "preserve the public ready future type")]
102impl<S, B> Service<ServiceRequest> for CompressMiddleware<S>
103where
104    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
105    B: MessageBody,
106{
107    type Response = ServiceResponse<EitherBody<Encoder<B>>>;
108    type Error = Error;
109    #[allow(clippy::type_complexity)]
110    type Future = Either<CompressResponse<S, B>, Ready<Result<Self::Response, Self::Error>>>;
111
112    actix_service::forward_ready!(service);
113
114    #[allow(clippy::borrow_interior_mutable_const)]
115    fn call(&self, req: ServiceRequest) -> Self::Future {
116        // negotiate content-encoding
117        let accept_encoding = req.get_header::<AcceptEncoding>();
118
119        let accept_encoding = match accept_encoding {
120            // missing header; fallback to identity
121            None => {
122                return Either::left(CompressResponse {
123                    encoding: Encoding::identity(),
124                    fut: self.service.call(req),
125                    _phantom: PhantomData,
126                })
127            }
128
129            // valid accept-encoding header
130            Some(accept_encoding) => accept_encoding,
131        };
132
133        match accept_encoding.negotiate(SUPPORTED_ENCODINGS.iter()) {
134            None => {
135                let mut res = HttpResponse::with_body(
136                    StatusCode::NOT_ACCEPTABLE,
137                    SUPPORTED_ENCODINGS_STRING.as_str(),
138                );
139
140                res.headers_mut()
141                    .insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
142
143                Either::right(ready(Ok(req
144                    .into_response(res)
145                    .map_into_boxed_body()
146                    .map_into_right_body())))
147            }
148
149            Some(encoding) => Either::left(CompressResponse {
150                fut: self.service.call(req),
151                encoding,
152                _phantom: PhantomData,
153            }),
154        }
155    }
156}
157
158pin_project! {
159    pub struct CompressResponse<S, B>
160    where
161        S: Service<ServiceRequest>,
162    {
163        #[pin]
164        fut: S::Future,
165        encoding: Encoding,
166        _phantom: PhantomData<B>,
167    }
168}
169
170impl<S, B> Future for CompressResponse<S, B>
171where
172    B: MessageBody,
173    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
174{
175    type Output = Result<ServiceResponse<EitherBody<Encoder<B>>>, Error>;
176
177    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
178        let this = self.as_mut().project();
179
180        match ready!(this.fut.poll(cx)) {
181            Ok(resp) => {
182                let enc = match this.encoding {
183                    Encoding::Known(enc) => *enc,
184                    Encoding::Unknown(enc) => {
185                        unimplemented!("encoding '{enc}' should not be here");
186                    }
187                };
188
189                Poll::Ready(Ok(resp.map_body(move |head, body| {
190                    let content_type = head.headers.get(header::CONTENT_TYPE);
191
192                    fn default_compress_predicate(content_type: Option<&HeaderValue>) -> bool {
193                        match content_type {
194                            None => true,
195                            Some(hdr) => {
196                                match hdr.to_str().ok().and_then(|hdr| hdr.parse::<Mime>().ok()) {
197                                    Some(mime) if mime.type_() == mime::IMAGE => {
198                                        matches!(mime.subtype(), mime::SVG)
199                                    }
200                                    Some(mime) if mime.type_() == mime::VIDEO => false,
201                                    _ => true,
202                                }
203                            }
204                        }
205                    }
206
207                    let enc = if default_compress_predicate(content_type) {
208                        enc
209                    } else {
210                        ContentEncoding::Identity
211                    };
212
213                    EitherBody::left(Encoder::response(enc, head, body))
214                })))
215            }
216
217            Err(err) => Poll::Ready(Err(err)),
218        }
219    }
220}
221
222static SUPPORTED_ENCODINGS_STRING: Lazy<String> = Lazy::new(|| {
223    #[allow(unused_mut)] // only unused when no compress features enabled
224    let mut encoding: Vec<&str> = vec![];
225
226    #[cfg(feature = "compress-brotli")]
227    {
228        encoding.push("br");
229    }
230
231    #[cfg(feature = "compress-gzip")]
232    {
233        encoding.push("gzip");
234        encoding.push("deflate");
235    }
236
237    #[cfg(feature = "compress-zstd")]
238    {
239        encoding.push("zstd");
240    }
241
242    assert!(
243        !encoding.is_empty(),
244        "encoding can not be empty unless __compress feature has been explicitly enabled by itself"
245    );
246
247    encoding.join(", ")
248});
249
250static SUPPORTED_ENCODINGS: &[Encoding] = &[
251    Encoding::identity(),
252    #[cfg(feature = "compress-brotli")]
253    {
254        Encoding::brotli()
255    },
256    #[cfg(feature = "compress-gzip")]
257    {
258        Encoding::gzip()
259    },
260    #[cfg(feature = "compress-gzip")]
261    {
262        Encoding::deflate()
263    },
264    #[cfg(feature = "compress-zstd")]
265    {
266        Encoding::zstd()
267    },
268];
269
270// move cfg(feature) to prevents_double_compressing if more tests are added
271#[cfg(feature = "compress-gzip")]
272#[cfg(test)]
273mod tests {
274    use std::collections::HashSet;
275
276    use static_assertions::assert_impl_all;
277
278    use super::*;
279    use crate::{http::header::ContentType, middleware::DefaultHeaders, test, web, App};
280
281    const HTML_DATA_PART: &str = "<html><h1>hello world</h1></html";
282    const HTML_DATA: &str = const_str::repeat!(HTML_DATA_PART, 100);
283
284    const TEXT_DATA_PART: &str = "hello world ";
285    const TEXT_DATA: &str = const_str::repeat!(TEXT_DATA_PART, 100);
286
287    assert_impl_all!(Compress: Send, Sync);
288
289    pub fn gzip_decode(bytes: impl AsRef<[u8]>) -> Vec<u8> {
290        use std::io::Read as _;
291        let mut decoder = flate2::read::GzDecoder::new(bytes.as_ref());
292        let mut buf = Vec::new();
293        decoder.read_to_end(&mut buf).unwrap();
294        buf
295    }
296
297    #[track_caller]
298    fn assert_successful_res_with_content_type<B>(res: &ServiceResponse<B>, ct: &str) {
299        assert!(res.status().is_success());
300        assert!(
301            res.headers()
302                .get(header::CONTENT_TYPE)
303                .expect("content-type header should be present")
304                .to_str()
305                .expect("content-type header should be utf-8")
306                .contains(ct),
307            "response's content-type did not match {}",
308            ct
309        );
310    }
311
312    #[track_caller]
313    fn assert_successful_gzip_res_with_content_type<B>(res: &ServiceResponse<B>, ct: &str) {
314        assert_successful_res_with_content_type(res, ct);
315        assert_eq!(
316            res.headers()
317                .get(header::CONTENT_ENCODING)
318                .expect("response should be gzip compressed"),
319            "gzip",
320        );
321    }
322
323    #[track_caller]
324    fn assert_successful_identity_res_with_content_type<B>(res: &ServiceResponse<B>, ct: &str) {
325        assert_successful_res_with_content_type(res, ct);
326        assert!(
327            res.headers().get(header::CONTENT_ENCODING).is_none(),
328            "response should not be compressed",
329        );
330    }
331
332    #[actix_rt::test]
333    async fn prevents_double_compressing() {
334        let app = test::init_service({
335            App::new()
336                .wrap(Compress::default())
337                .route(
338                    "/single",
339                    web::get().to(move || HttpResponse::Ok().body(TEXT_DATA)),
340                )
341                .service(
342                    web::resource("/double")
343                        .wrap(Compress::default())
344                        .wrap(DefaultHeaders::new().add(("x-double", "true")))
345                        .route(web::get().to(move || HttpResponse::Ok().body(TEXT_DATA))),
346                )
347        })
348        .await;
349
350        let req = test::TestRequest::default()
351            .uri("/single")
352            .insert_header((header::ACCEPT_ENCODING, "gzip"))
353            .to_request();
354        let res = test::call_service(&app, req).await;
355        assert_eq!(res.status(), StatusCode::OK);
356        assert_eq!(res.headers().get("x-double"), None);
357        assert_eq!(res.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip");
358        let bytes = test::read_body(res).await;
359        assert_eq!(gzip_decode(bytes), TEXT_DATA.as_bytes());
360
361        let req = test::TestRequest::default()
362            .uri("/double")
363            .insert_header((header::ACCEPT_ENCODING, "gzip"))
364            .to_request();
365        let res = test::call_service(&app, req).await;
366        assert_eq!(res.status(), StatusCode::OK);
367        assert_eq!(res.headers().get("x-double").unwrap(), "true");
368        assert_eq!(res.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip");
369        let bytes = test::read_body(res).await;
370        assert_eq!(gzip_decode(bytes), TEXT_DATA.as_bytes());
371    }
372
373    #[actix_rt::test]
374    async fn retains_previously_set_vary_header() {
375        let app = test::init_service({
376            App::new()
377                .wrap(Compress::default())
378                .default_service(web::to(move || {
379                    HttpResponse::Ok()
380                        .insert_header((header::VARY, "x-test"))
381                        .body(TEXT_DATA)
382                }))
383        })
384        .await;
385
386        let req = test::TestRequest::default()
387            .insert_header((header::ACCEPT_ENCODING, "gzip"))
388            .to_request();
389        let res = test::call_service(&app, req).await;
390        assert_eq!(res.status(), StatusCode::OK);
391        #[allow(clippy::mutable_key_type)]
392        let vary_headers = res.headers().get_all(header::VARY).collect::<HashSet<_>>();
393        assert!(vary_headers.contains(&HeaderValue::from_static("x-test")));
394        assert!(vary_headers.contains(&HeaderValue::from_static("accept-encoding")));
395    }
396
397    fn configure_predicate_test(cfg: &mut web::ServiceConfig) {
398        cfg.route(
399            "/html",
400            web::get().to(|| {
401                HttpResponse::Ok()
402                    .content_type(ContentType::html())
403                    .body(HTML_DATA)
404            }),
405        )
406        .route(
407            "/image",
408            web::get().to(|| {
409                HttpResponse::Ok()
410                    .content_type(ContentType::jpeg())
411                    .body(TEXT_DATA)
412            }),
413        );
414    }
415
416    #[actix_rt::test]
417    async fn prevents_compression_jpeg() {
418        let app = test::init_service(
419            App::new()
420                .wrap(Compress::default())
421                .configure(configure_predicate_test),
422        )
423        .await;
424
425        let req =
426            test::TestRequest::with_uri("/html").insert_header((header::ACCEPT_ENCODING, "gzip"));
427        let res = test::call_service(&app, req.to_request()).await;
428        assert_successful_gzip_res_with_content_type(&res, "text/html");
429        assert_ne!(test::read_body(res).await, HTML_DATA.as_bytes());
430
431        let req =
432            test::TestRequest::with_uri("/image").insert_header((header::ACCEPT_ENCODING, "gzip"));
433        let res = test::call_service(&app, req.to_request()).await;
434        assert_successful_identity_res_with_content_type(&res, "image/jpeg");
435        assert_eq!(test::read_body(res).await, TEXT_DATA.as_bytes());
436    }
437
438    #[actix_rt::test]
439    async fn prevents_compression_empty() {
440        let app = test::init_service({
441            App::new()
442                .wrap(Compress::default())
443                .default_service(web::to(move || HttpResponse::Ok().finish()))
444        })
445        .await;
446
447        let req = test::TestRequest::default()
448            .insert_header((header::ACCEPT_ENCODING, "gzip"))
449            .to_request();
450        let res = test::call_service(&app, req).await;
451        assert_eq!(res.status(), StatusCode::OK);
452        assert!(!res.headers().contains_key(header::CONTENT_ENCODING));
453        assert!(test::read_body(res).await.is_empty());
454    }
455
456    #[actix_rt::test]
457    async fn skips_compression_partial_content() {
458        let app = test::init_service({
459            App::new()
460                .wrap(Compress::default())
461                .default_service(web::to(|| {
462                    HttpResponse::PartialContent()
463                        .insert_header((header::CONTENT_TYPE, "text/plain"))
464                        .insert_header((header::CONTENT_RANGE, "bytes 0-10/100"))
465                        .body(TEXT_DATA)
466                }))
467        })
468        .await;
469
470        let req = test::TestRequest::default()
471            .insert_header((header::ACCEPT_ENCODING, "gzip"))
472            .to_request();
473        let res = test::call_service(&app, req).await;
474        assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
475        assert!(!res.headers().contains_key(header::CONTENT_ENCODING));
476        assert_eq!(test::read_body(res).await, TEXT_DATA.as_bytes());
477    }
478}
479
480#[cfg(feature = "compress-brotli")]
481#[cfg(test)]
482mod tests_brotli {
483    use super::*;
484    use crate::{test, web, App};
485
486    #[actix_rt::test]
487    async fn prevents_compression_empty() {
488        let app = test::init_service({
489            App::new()
490                .wrap(Compress::default())
491                .default_service(web::to(move || HttpResponse::Ok().finish()))
492        })
493        .await;
494
495        let req = test::TestRequest::default()
496            .insert_header((header::ACCEPT_ENCODING, "br"))
497            .to_request();
498        let res = test::call_service(&app, req).await;
499        assert_eq!(res.status(), StatusCode::OK);
500        assert!(!res.headers().contains_key(header::CONTENT_ENCODING));
501        assert!(test::read_body(res).await.is_empty());
502    }
503}