diff --git a/actix-http/src/encoding/encoder.rs b/actix-http/src/encoding/encoder.rs index bbe53e8e..49a6acfa 100644 --- a/actix-http/src/encoding/encoder.rs +++ b/actix-http/src/encoding/encoder.rs @@ -30,6 +30,22 @@ use crate::{ const MAX_CHUNK_SIZE_ENCODE_IN_PLACE: usize = 1024; +const DEFLATE_MIN_LEVEL: u32 = 0; +const DEFLATE_MAX_LEVEL: u32 = 9; +const DEFLATE_DEFAULT: u32 = 1; + +const GZIP_MIN_LEVEL: u32 = 0; +const GZIP_MAX_LEVEL: u32 = 9; +const GZIP_DEFAULT: u32 = 1; + +const BROTLI_MIN_QUALITY: u32 = 0; +const BROTLI_MAX_QUALITY: u32 = 11; +const BROTLI_DEFAULT: u32 = 3; + +const ZSTD_MIN_LEVEL: i32 = 0; +const ZSTD_MAX_LEVEL: i32 = 22; +const ZSTD_DEFAULT: i32 = 3; + pin_project! { pub struct Encoder { #[pin] @@ -53,6 +69,15 @@ impl Encoder { } pub fn response(encoding: ContentEncoding, head: &mut ResponseHead, body: B) -> Self { + Encoder::response_with_level(encoding, head, body, None) + } + + pub fn response_with_level( + encoding: ContentEncoding, + head: &mut ResponseHead, + body: B, + level: Option, + ) -> Self { // no need to compress an empty body if matches!(body.size(), BodySize::None) { return Self::none(); @@ -69,8 +94,9 @@ impl Encoder { }; if should_encode { + let enconding_level = ContentEncodingWithLevel::new(encoding, level); // wrap body only if encoder is feature-enabled - if let Some(enc) = ContentEncoder::select(encoding) { + if let Some(enc) = ContentEncoder::select(enconding_level) { update_head(encoding, head); return Encoder { @@ -278,27 +304,73 @@ enum ContentEncoder { Zstd(ZstdEncoder<'static, Writer>), } +enum ContentEncodingWithLevel { + Deflate(u32), + Gzip(u32), + Brotli(u32), + Zstd(i32), + Identity, +} + +impl ContentEncodingWithLevel { + pub fn new(encoding: ContentEncoding, level: Option) -> Self { + match encoding { + ContentEncoding::Deflate => { + let level = level + .filter(|l| (DEFLATE_MIN_LEVEL..(DEFLATE_MAX_LEVEL + 1)).contains(l)) + .unwrap_or(DEFLATE_DEFAULT); + ContentEncodingWithLevel::Deflate(level) + } + ContentEncoding::Gzip => { + let level = level + .filter(|l| (GZIP_MIN_LEVEL..(GZIP_MAX_LEVEL + 1)).contains(l)) + .unwrap_or(GZIP_DEFAULT); + ContentEncodingWithLevel::Gzip(level) + } + ContentEncoding::Brotli => { + let level = level + .filter(|l| (BROTLI_MIN_QUALITY..(BROTLI_MAX_QUALITY + 1)).contains(l)) + .unwrap_or(BROTLI_DEFAULT); + ContentEncodingWithLevel::Brotli(level) + } + ContentEncoding::Zstd => { + let level = level + .map(|l| l as i32) + .filter(|l| (ZSTD_MIN_LEVEL..(ZSTD_MAX_LEVEL + 1)).contains(l)) + .unwrap_or(ZSTD_DEFAULT); + ContentEncodingWithLevel::Zstd(level) + } + ContentEncoding::Identity => ContentEncodingWithLevel::Identity, + } + } +} + impl ContentEncoder { - fn select(encoding: ContentEncoding) -> Option { + fn select(encoding: ContentEncodingWithLevel) -> Option { match encoding { #[cfg(feature = "compress-gzip")] - ContentEncoding::Deflate => Some(ContentEncoder::Deflate(ZlibEncoder::new( - Writer::new(), - flate2::Compression::fast(), - ))), + ContentEncodingWithLevel::Deflate(level) => Some(ContentEncoder::Deflate( + ZlibEncoder::new(Writer::new(), flate2::Compression::new(level)), + )), #[cfg(feature = "compress-gzip")] - ContentEncoding::Gzip => Some(ContentEncoder::Gzip(GzEncoder::new( - Writer::new(), - flate2::Compression::fast(), - ))), + ContentEncodingWithLevel::Gzip(level) => Some(ContentEncoder::Gzip( + GzEncoder::new(Writer::new(), flate2::Compression::new(level)), + )), #[cfg(feature = "compress-brotli")] - ContentEncoding::Brotli => Some(ContentEncoder::Brotli(new_brotli_compressor())), + ContentEncodingWithLevel::Brotli(level) => Some(ContentEncoder::Brotli(Box::new( + brotli::CompressorWriter::new( + Writer::new(), + 32 * 1024, // 32 KiB buffer + level, // BROTLI_PARAM_QUALITY + 22, // BROTLI_PARAM_LGWIN + ), + ))), #[cfg(feature = "compress-zstd")] - ContentEncoding::Zstd => { - let encoder = ZstdEncoder::new(Writer::new(), 3).ok()?; + ContentEncodingWithLevel::Zstd(level) => { + let encoder = ZstdEncoder::new(Writer::new(), level).ok()?; Some(ContentEncoder::Zstd(encoder)) } @@ -392,16 +464,6 @@ impl ContentEncoder { } } -#[cfg(feature = "compress-brotli")] -fn new_brotli_compressor() -> Box> { - Box::new(brotli::CompressorWriter::new( - Writer::new(), - 32 * 1024, // 32 KiB buffer - 3, // BROTLI_PARAM_QUALITY - 22, // BROTLI_PARAM_LGWIN - )) -} - #[derive(Debug, Display)] #[non_exhaustive] pub enum EncoderError { diff --git a/actix-web/src/middleware/compress.rs b/actix-web/src/middleware/compress.rs index 51b44c6e..689ff0bf 100644 --- a/actix-web/src/middleware/compress.rs +++ b/actix-web/src/middleware/compress.rs @@ -4,10 +4,11 @@ use std::{ future::Future, marker::PhantomData, pin::Pin, + rc::Rc, task::{Context, Poll}, }; -use actix_http::encoding::Encoder; +use actix_http::{encoding::Encoder, header::ContentEncoding}; use actix_service::{Service, Transform}; use actix_utils::future::{ok, Either, Ready}; use futures_core::ready; @@ -54,6 +55,20 @@ use crate::{ /// .wrap(middleware::Compress::default()) /// .default_service(web::to(|| async { HttpResponse::Ok().body("hello world") })); /// ``` +/// You can also set compression level for supported algorithms +/// ``` +/// use actix_web::{middleware, web, App, HttpResponse}; +/// +/// let app = App::new() +/// .wrap( +/// middleware::Compress::new() +/// .set_gzip_level(3) +/// .set_deflate_level(1) +/// .set_brotli_level(7) +/// .set_zstd_level(10), +/// ) +/// .default_service(web::to(|| async { HttpResponse::Ok().body("hello world") })); +/// ``` /// /// Pre-compressed Gzip file being served from disk with correct headers added to bypass middleware: /// ```no_run @@ -73,7 +88,71 @@ use crate::{ /// [feature flags]: ../index.html#crate-features #[derive(Debug, Clone, Default)] #[non_exhaustive] -pub struct Compress; +pub struct Compress { + inner: Rc, +} + +impl Compress { + /// Constructs new compress middleware instance with default settings. + pub fn new() -> Self { + Default::default() + } +} + +#[derive(Debug, Clone, Default)] +struct Inner { + deflate: Option, + gzip: Option, + brotli: Option, + zstd: Option, +} + +impl Inner { + pub fn level(&self, encoding: &ContentEncoding) -> Option { + match encoding { + ContentEncoding::Deflate => self.deflate, + ContentEncoding::Gzip => self.gzip, + ContentEncoding::Brotli => self.brotli, + ContentEncoding::Zstd => self.zstd, + _ => None, + } + } +} + +impl Compress { + /// Set deflate compression level. + /// + /// The integer here is on a scale of 0-9. + /// When going out of range, level 1 will be used. + pub fn set_deflate_level(mut self, value: u32) -> Self { + Rc::get_mut(&mut self.inner).unwrap().deflate = Some(value); + self + } + /// Set gzip compression level. + /// + /// The integer here is on a scale of 0-9. + /// When going out of range, level 1 will be used. + pub fn set_gzip_level(mut self, value: u32) -> Self { + Rc::get_mut(&mut self.inner).unwrap().gzip = Some(value); + self + } + /// Set gzip compression level. + /// + /// The integer here is on a scale of 0-11. + /// When going out of range, level 3 will be used. + pub fn set_brotli_level(mut self, value: u32) -> Self { + Rc::get_mut(&mut self.inner).unwrap().brotli = Some(value); + self + } + /// Set gzip compression level. + /// + /// The integer here is on a scale of 0-22. + /// When going out of range, level 3 will be used. + pub fn set_zstd_level(mut self, value: u32) -> Self { + Rc::get_mut(&mut self.inner).unwrap().zstd = Some(value); + self + } +} impl Transform for Compress where @@ -87,12 +166,16 @@ where type Future = Ready>; fn new_transform(&self, service: S) -> Self::Future { - ok(CompressMiddleware { service }) + ok(CompressMiddleware { + service, + inner: Rc::clone(&self.inner), + }) } } pub struct CompressMiddleware { service: S, + inner: Rc, } impl Service for CompressMiddleware @@ -111,6 +194,7 @@ where fn call(&self, req: ServiceRequest) -> Self::Future { // negotiate content-encoding let accept_encoding = req.get_header::(); + let inner = self.inner.clone(); let accept_encoding = match accept_encoding { // missing header; fallback to identity @@ -118,6 +202,7 @@ where return Either::left(CompressResponse { encoding: Encoding::identity(), fut: self.service.call(req), + inner, _phantom: PhantomData, }) } @@ -145,6 +230,7 @@ where Some(encoding) => Either::left(CompressResponse { fut: self.service.call(req), encoding, + inner, _phantom: PhantomData, }), } @@ -159,6 +245,7 @@ pin_project! { #[pin] fut: S::Future, encoding: Encoding, + inner: Rc, _phantom: PhantomData, } } @@ -181,9 +268,10 @@ where unimplemented!("encoding {} should not be here", enc); } }; + let level = this.inner.level(&enc); Poll::Ready(Ok(resp.map_body(move |head, body| { - EitherBody::left(Encoder::response(enc, head, body)) + EitherBody::left(Encoder::response_with_level(enc, head, body, level)) }))) } @@ -324,4 +412,27 @@ mod tests { assert!(vary_headers.contains(&HeaderValue::from_static("x-test"))); assert!(vary_headers.contains(&HeaderValue::from_static("accept-encoding"))); } + + #[actix_rt::test] + async fn custom_compress_level() { + const D: &str = "hello world "; + const DATA: &str = const_str::repeat!(D, 100); + + let app = test::init_service({ + App::new().wrap(Compress::new().set_gzip_level(9)).route( + "/compress", + web::get().to(move || HttpResponse::Ok().body(DATA)), + ) + }) + .await; + + let req = test::TestRequest::default() + .uri("/compress") + .insert_header((header::ACCEPT_ENCODING, "gzip")) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::OK); + let bytes = test::read_body(res).await; + assert_eq!(gzip_decode(bytes), DATA.as_bytes()); + } }