diff --git a/actix-web/CHANGES.md b/actix-web/CHANGES.md index 757e31eeb..03ea23330 100644 --- a/actix-web/CHANGES.md +++ b/actix-web/CHANGES.md @@ -5,10 +5,12 @@ ### Added - Add `Resource::{get, post, etc...}` methods for more concisely adding routes that don't need additional guards. +- Add `Compress::with_predicate()` method for customizing when compression is applied. ### Changed - Handler functions can now receive up to 16 extractor parameters. +- The `Compress` no longer compresses image or video content by default. ## 4.3.1 - 2023-02-26 diff --git a/actix-web/src/http/header/content_type.rs b/actix-web/src/http/header/content_type.rs index 7e399a1d0..bf86afffa 100644 --- a/actix-web/src/http/header/content_type.rs +++ b/actix-web/src/http/header/content_type.rs @@ -1,60 +1,54 @@ -use super::CONTENT_TYPE; use mime::Mime; +use super::CONTENT_TYPE; + crate::http::header::common_header! { - /// `Content-Type` header, defined - /// in [RFC 7231 §3.1.1.5](https://datatracker.ietf.org/doc/html/rfc7231#section-3.1.1.5) + /// `Content-Type` header, defined in [RFC 9110 §8.3]. /// - /// The `Content-Type` header field indicates the media type of the - /// associated representation: either the representation enclosed in the - /// message payload or the selected representation, as determined by the - /// message semantics. The indicated media type defines both the data - /// format and how that data is intended to be processed by a recipient, - /// within the scope of the received message semantics, after any content - /// codings indicated by Content-Encoding are decoded. + /// The `Content-Type` header field indicates the media type of the associated representation: + /// either the representation enclosed in the message payload or the selected representation, + /// as determined by the message semantics. The indicated media type defines both the data + /// format and how that data is intended to be processed by a recipient, within the scope of the + /// received message semantics, after any content codings indicated by Content-Encoding are + /// decoded. /// - /// Although the `mime` crate allows the mime options to be any slice, this crate - /// forces the use of Vec. This is to make sure the same header can't have more than 1 type. If - /// this is an issue, it's possible to implement `Header` on a custom struct. + /// Although the `mime` crate allows the mime options to be any slice, this crate forces the use + /// of Vec. This is to make sure the same header can't have more than 1 type. If this is an + /// issue, it's possible to implement `Header` on a custom struct. /// /// # ABNF + /// /// ```plain /// Content-Type = media-type /// ``` /// /// # Example Values - /// * `text/html; charset=utf-8` - /// * `application/json` + /// + /// - `text/html; charset=utf-8` + /// - `application/json` /// /// # Examples - /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::ContentType; - /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// ContentType::json() - /// ); - /// ``` /// /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::ContentType; + /// use actix_web::{http::header::ContentType, HttpResponse}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// ContentType(mime::TEXT_HTML) - /// ); + /// let res_json = HttpResponse::Ok() + /// .insert_header(ContentType::json()); + /// + /// let res_html = HttpResponse::Ok() + /// .insert_header(ContentType(mime::TEXT_HTML)); /// ``` + /// + /// [RFC 9110 §8.3]: https://datatracker.ietf.org/doc/html/rfc9110#section-8.3 (ContentType, CONTENT_TYPE) => [Mime] test_parse_and_format { crate::http::header::common_header_test!( - test1, + test_text_html, vec![b"text/html"], Some(HeaderField(mime::TEXT_HTML))); crate::http::header::common_header_test!( - test2, + test_image_star, vec![b"image/*"], Some(HeaderField(mime::IMAGE_STAR))); @@ -62,54 +56,49 @@ crate::http::header::common_header! { } impl ContentType { - /// A constructor to easily create a `Content-Type: application/json` - /// header. + /// Constructs a `Content-Type: application/json` header. #[inline] pub fn json() -> ContentType { ContentType(mime::APPLICATION_JSON) } - /// A constructor to easily create a `Content-Type: text/plain; - /// charset=utf-8` header. + /// Constructs a `Content-Type: text/plain; charset=utf-8` header. #[inline] pub fn plaintext() -> ContentType { ContentType(mime::TEXT_PLAIN_UTF_8) } - /// A constructor to easily create a `Content-Type: text/html; charset=utf-8` - /// header. + /// Constructs a `Content-Type: text/html; charset=utf-8` header. #[inline] pub fn html() -> ContentType { ContentType(mime::TEXT_HTML_UTF_8) } - /// A constructor to easily create a `Content-Type: text/xml` header. + /// Constructs a `Content-Type: text/xml` header. #[inline] pub fn xml() -> ContentType { ContentType(mime::TEXT_XML) } - /// A constructor to easily create a `Content-Type: - /// application/www-form-url-encoded` header. + /// Constructs a `Content-Type: application/www-form-url-encoded` header. #[inline] pub fn form_url_encoded() -> ContentType { ContentType(mime::APPLICATION_WWW_FORM_URLENCODED) } - /// A constructor to easily create a `Content-Type: image/jpeg` header. + /// Constructs a `Content-Type: image/jpeg` header. #[inline] pub fn jpeg() -> ContentType { ContentType(mime::IMAGE_JPEG) } - /// A constructor to easily create a `Content-Type: image/png` header. + /// Constructs a `Content-Type: image/png` header. #[inline] pub fn png() -> ContentType { ContentType(mime::IMAGE_PNG) } - /// A constructor to easily create a `Content-Type: - /// application/octet-stream` header. + /// Constructs a `Content-Type: application/octet-stream` header. #[inline] pub fn octet_stream() -> ContentType { ContentType(mime::APPLICATION_OCTET_STREAM) diff --git a/actix-web/src/middleware/compress.rs b/actix-web/src/middleware/compress.rs index c35e7cb6c..76c3d968b 100644 --- a/actix-web/src/middleware/compress.rs +++ b/actix-web/src/middleware/compress.rs @@ -5,6 +5,7 @@ use std::{ future::Future, marker::PhantomData, pin::Pin, + rc::Rc, task::{Context, Poll}, }; @@ -26,6 +27,8 @@ use crate::{ Error, HttpMessage, HttpResponse, }; +type CompressPredicateFn = Rc) -> bool>; + /// Middleware for compressing response payloads. /// /// # Encoding Negotiation @@ -76,27 +79,74 @@ use crate::{ #[derive(Clone)] #[non_exhaustive] pub struct Compress { - pub compress: fn(Option<&HeaderValue>) -> bool, + predicate: CompressPredicateFn, +} + +impl Compress { + /// Sets the `predicate` function to use when deciding if response should be compressed or not. + /// + /// The `predicate` function receives the response's current `Content-Type` header, if set. + /// Returning true from the predicate will instruct this middleware to compress the response. + /// + /// By default, video and image responses are unaffected (since they are typically compressed + /// already) and responses without a Content-Type header will be compressed. Custom predicate + /// functions should try to maintain these default rules. + /// + /// # Examples + /// + /// ``` + /// use actix_web::{App, middleware::Compress}; + /// + /// App::new() + /// .wrap(Compress::default().with_predicate(|content_type| { + /// // preserve that missing Content-Type header compresses content + /// let ct = match content_type.and_then(|ct| ct.to_str().ok()) { + /// None => return true, + /// Some(ct) => ct, + /// }; + /// + /// // parse Content-Type as MIME type + /// let ct_mime = match ct.parse::() { + /// Err(_) => return true, + /// Ok(mime) => mime, + /// }; + /// + /// // compress everything except HTML documents + /// ct_mime.subtype() != mime::HTML + /// })) + /// # ; + /// ``` + pub fn with_predicate( + self, + predicate: impl Fn(Option<&HeaderValue>) -> bool + 'static, + ) -> Self { + Self { + predicate: Rc::new(predicate), + } + } } impl fmt::Debug for Compress { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Compress").finish() + f.debug_struct("Compress").finish_non_exhaustive() } } + impl Default for Compress { fn default() -> Self { - Compress { - compress: |content_type| match content_type { + fn default_compress_predicate(content_type: Option<&HeaderValue>) -> bool { + match content_type { None => true, - Some(value) => { - let response_mime: Mime = value.to_str().unwrap().parse::().unwrap(); - match response_mime.type_().as_str() { - "image" => false, - _ => true, - } - } - }, + Some(hdr) => match hdr.to_str().ok().and_then(|hdr| hdr.parse::().ok()) { + Some(mime) if mime.type_().as_str() == "image" => false, + Some(mime) if mime.type_().as_str() == "video" => false, + _ => true, + }, + } + } + + Compress { + predicate: Rc::new(default_compress_predicate), } } } @@ -115,14 +165,14 @@ where fn new_transform(&self, service: S) -> Self::Future { ok(CompressMiddleware { service, - compress: self.compress, + predicate: Rc::clone(&self.predicate), }) } } pub struct CompressMiddleware { service: S, - compress: fn(Option<&HeaderValue>) -> bool, + predicate: CompressPredicateFn, } impl Service for CompressMiddleware @@ -148,8 +198,8 @@ where return Either::left(CompressResponse { encoding: Encoding::identity(), fut: self.service.call(req), + predicate: Rc::clone(&self.predicate), _phantom: PhantomData, - compress: self.compress, }) } @@ -176,8 +226,8 @@ where Some(encoding) => Either::left(CompressResponse { fut: self.service.call(req), encoding, + predicate: Rc::clone(&self.predicate), _phantom: PhantomData, - compress: self.compress, }), } } @@ -191,8 +241,8 @@ pin_project! { #[pin] fut: S::Future, encoding: Encoding, + predicate: CompressPredicateFn, _phantom: PhantomData, - compress: fn(Option<&HeaderValue>) -> bool, } } @@ -211,22 +261,22 @@ where let enc = match this.encoding { Encoding::Known(enc) => *enc, Encoding::Unknown(enc) => { - unimplemented!("encoding {} should not be here", enc); + unimplemented!("encoding '{enc}' should not be here"); } }; Poll::Ready(Ok(resp.map_body(move |head, body| { let content_type = head.headers.get(header::CONTENT_TYPE); - let should_compress = (self.compress)(content_type); - if should_compress { - EitherBody::left(Encoder::response(enc, head, body)) + + let should_compress = (self.predicate)(content_type); + + let enc = if should_compress { + enc } else { - EitherBody::left(Encoder::response( - ContentEncoding::Identity, - head, - body, - )) - } + ContentEncoding::Identity + }; + + EitherBody::left(Encoder::response(enc, head, body)) }))) } @@ -289,10 +339,20 @@ static SUPPORTED_ENCODINGS: &[Encoding] = &[ mod tests { use std::collections::HashSet; + // use static_assertions::assert_impl_all; + use super::*; use crate::http::header::ContentType; use crate::{middleware::DefaultHeaders, test, web, App}; + const HTML_DATA_PART: &str = "

hello world

) -> Vec { use std::io::Read as _; let mut decoder = flate2::read::GzDecoder::new(bytes.as_ref()); @@ -301,23 +361,55 @@ mod tests { buf } + #[track_caller] + fn assert_successful_res_with_content_type(res: &ServiceResponse, ct: &str) { + assert!(res.status().is_success()); + assert!( + res.headers() + .get(header::CONTENT_TYPE) + .expect("content-type header should be present") + .to_str() + .expect("content-type header should be utf-8") + .contains(ct), + "response's content-type did not match {}", + ct + ); + } + + #[track_caller] + fn assert_successful_gzip_res_with_content_type(res: &ServiceResponse, ct: &str) { + assert_successful_res_with_content_type(res, ct); + assert_eq!( + res.headers() + .get(header::CONTENT_ENCODING) + .expect("response should be gzip compressed"), + "gzip", + ); + } + + #[track_caller] + fn assert_successful_identity_res_with_content_type(res: &ServiceResponse, ct: &str) { + assert_successful_res_with_content_type(res, ct); + assert!( + res.headers().get(header::CONTENT_ENCODING).is_none(), + "response should not be compressed", + ); + } + #[actix_rt::test] async fn prevents_double_compressing() { - const D: &str = "hello world "; - const DATA: &str = const_str::repeat!(D, 100); - let app = test::init_service({ App::new() .wrap(Compress::default()) .route( "/single", - web::get().to(move || HttpResponse::Ok().body(DATA)), + web::get().to(move || HttpResponse::Ok().body(TEXT_DATA)), ) .service( web::resource("/double") .wrap(Compress::default()) .wrap(DefaultHeaders::new().add(("x-double", "true"))) - .route(web::get().to(move || HttpResponse::Ok().body(DATA))), + .route(web::get().to(move || HttpResponse::Ok().body(TEXT_DATA))), ) }) .await; @@ -331,7 +423,7 @@ mod tests { assert_eq!(res.headers().get("x-double"), None); assert_eq!(res.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip"); let bytes = test::read_body(res).await; - assert_eq!(gzip_decode(bytes), DATA.as_bytes()); + assert_eq!(gzip_decode(bytes), TEXT_DATA.as_bytes()); let req = test::TestRequest::default() .uri("/double") @@ -342,7 +434,7 @@ mod tests { assert_eq!(res.headers().get("x-double").unwrap(), "true"); assert_eq!(res.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip"); let bytes = test::read_body(res).await; - assert_eq!(gzip_decode(bytes), DATA.as_bytes()); + assert_eq!(gzip_decode(bytes), TEXT_DATA.as_bytes()); } #[actix_rt::test] @@ -369,33 +461,80 @@ mod tests { assert!(vary_headers.contains(&HeaderValue::from_static("accept-encoding"))); } + fn configure_predicate_test(cfg: &mut web::ServiceConfig) { + cfg.route( + "/html", + web::get().to(|| { + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(HTML_DATA) + }), + ) + .route( + "/image", + web::get().to(|| { + HttpResponse::Ok() + .content_type(ContentType::jpeg()) + .body(TEXT_DATA) + }), + ); + } + #[actix_rt::test] async fn prevents_compression_jpeg() { - const D: &str = "test image"; - const DATA: &str = const_str::repeat!(D, 100); - let app = test::init_service({ - App::new().wrap(Compress::default()).route( - "/image", - web::get().to(move || { - let builder = HttpResponse::Ok() - .insert_header(ContentType::jpeg()) - .body(DATA); - builder - }), - ) - }) + let app = test::init_service( + App::new() + .wrap(Compress::default()) + .configure(configure_predicate_test), + ) .await; - let req = test::TestRequest::default() - .uri("/image") - .insert_header((header::ACCEPT_ENCODING, "gzip")) - .to_request(); - let res = test::call_service(&app, req).await; - assert_eq!(res.status(), StatusCode::OK); - assert_eq!( - res.headers().get(header::CONTENT_TYPE).unwrap(), - "image/jpeg" - ); - let bytes = test::read_body(res).await; - assert_eq!(bytes, DATA.as_bytes()); + + let req = test::TestRequest::with_uri("/html") + .insert_header((header::ACCEPT_ENCODING, "gzip")); + let res = test::call_service(&app, req.to_request()).await; + assert_successful_gzip_res_with_content_type(&res, "text/html"); + assert_ne!(test::read_body(res).await, HTML_DATA.as_bytes()); + + let req = test::TestRequest::with_uri("/image") + .insert_header((header::ACCEPT_ENCODING, "gzip")); + let res = test::call_service(&app, req.to_request()).await; + assert_successful_identity_res_with_content_type(&res, "image/jpeg"); + assert_eq!(test::read_body(res).await, TEXT_DATA.as_bytes()); + } + + #[actix_rt::test] + async fn prevents_compression_custom_predicate() { + let app = test::init_service( + App::new() + .wrap(Compress::default().with_predicate(|hdr| { + // preserve that missing CT header compresses content + let hdr = match hdr.and_then(|hdr| hdr.to_str().ok()) { + None => return true, + Some(hdr) => hdr, + }; + + let mime = match hdr.parse::() { + Err(_) => return true, + Ok(mime) => mime, + }; + + // compress everything except HTML documents + mime.subtype() != mime::HTML + })) + .configure(configure_predicate_test), + ) + .await; + + let req = test::TestRequest::with_uri("/html") + .insert_header((header::ACCEPT_ENCODING, "gzip")); + let res = test::call_service(&app, req.to_request()).await; + assert_successful_identity_res_with_content_type(&res, "text/html"); + assert_eq!(test::read_body(res).await, HTML_DATA.as_bytes()); + + let req = test::TestRequest::with_uri("/image") + .insert_header((header::ACCEPT_ENCODING, "gzip")); + let res = test::call_service(&app, req.to_request()).await; + assert_successful_gzip_res_with_content_type(&res, "image/jpeg"); + assert_ne!(test::read_body(res).await, TEXT_DATA.as_bytes()); } } diff --git a/shell.nix b/shell.nix deleted file mode 100644 index 18dfd2590..000000000 --- a/shell.nix +++ /dev/null @@ -1,19 +0,0 @@ -{ pkgs ? import { } }: - -with pkgs; - -mkShell rec { - nativeBuildInputs = [ - pkg-config - emacs - rust-analyzer - openssl - ripgrep - ]; - buildInputs = [ - udev alsa-lib vulkan-loader - xorg.libX11 xorg.libXcursor xorg.libXi xorg.libXrandr # To use the x11 feature - libxkbcommon wayland # To use the wayland feature - ]; - LD_LIBRARY_PATH = lib.makeLibraryPath buildInputs; -}