diff --git a/actix-http/CHANGES.md b/actix-http/CHANGES.md index 7324cba5..beac4923 100644 --- a/actix-http/CHANGES.md +++ b/actix-http/CHANGES.md @@ -27,6 +27,7 @@ ### Added - Add `error::InvalidStatusCode` re-export. +- New method `response_with_level` for `Encoder` for setup compress level. [#2948] ## 3.7.0 diff --git a/actix-http/src/encoding/encoder.rs b/actix-http/src/encoding/encoder.rs index 735dca67..6b03ddc7 100644 --- a/actix-http/src/encoding/encoder.rs +++ b/actix-http/src/encoding/encoder.rs @@ -28,6 +28,30 @@ use crate::{ const MAX_CHUNK_SIZE_ENCODE_IN_PLACE: usize = 1024; +// https://www.zlib.net/manual.html#Constants +const DEFLATE_MIN_LEVEL: u32 = 0; +// https://www.zlib.net/manual.html#Constants +const DEFLATE_MAX_LEVEL: u32 = 9; +const DEFLATE_DEFAULT: u32 = 1; + +// https://www.zlib.net/manual.html#Constants +const GZIP_MIN_LEVEL: u32 = 0; +// https://www.zlib.net/manual.html#Constants +const GZIP_MAX_LEVEL: u32 = 9; +const GZIP_DEFAULT: u32 = 1; + +// https://www.brotli.org/encode.html#a94f +const BROTLI_MIN_QUALITY: u32 = 0; +// https://www.brotli.org/encode.html#ac45 +const BROTLI_MAX_QUALITY: u32 = 11; +const BROTLI_DEFAULT: u32 = 3; + +// https://github.com/facebook/zstd/blob/dev/doc/zstd_manual.html#L42-L43 +const ZSTD_MIN_LEVEL: i32 = 0; +// https://github.com/facebook/zstd/blob/dev/doc/zstd_manual.html#L42-L43 +const ZSTD_MAX_LEVEL: i32 = 22; +const ZSTD_DEFAULT: i32 = 3; + pin_project! { pub struct Encoder { #[pin] @@ -60,6 +84,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 empty bodies match body.size() { BodySize::None => return Self::none(), @@ -78,8 +111,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 { @@ -287,27 +321,74 @@ 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( + ContentEncodingWithLevel::Gzip(level) => Some(ContentEncoder::Gzip(GzEncoder::new( Writer::new(), - flate2::Compression::fast(), + 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)) } @@ -401,16 +482,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/CHANGES.md b/actix-web/CHANGES.md index 394a8c93..2f7bc7e5 100644 --- a/actix-web/CHANGES.md +++ b/actix-web/CHANGES.md @@ -35,6 +35,7 @@ - Add `web::Html` responder. - Add `HttpRequest::full_url()` method to get the complete URL of the request. +- Add level setup for `middleware::Compress`. ### Fixed diff --git a/actix-web/src/middleware/compress.rs b/actix-web/src/middleware/compress.rs index 943868d2..ebd49611 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; @@ -55,6 +56,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() +/// .gzip_level(3) +/// .deflate_level(1) +/// .brotli_level(7) +/// .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 @@ -74,7 +89,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 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 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 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 zstd_level(mut self, value: u32) -> Self { + Rc::get_mut(&mut self.inner).unwrap().zstd = Some(value); + self + } +} impl Transform for Compress where @@ -88,12 +167,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 @@ -112,6 +195,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 @@ -119,6 +203,7 @@ where return Either::left(CompressResponse { encoding: Encoding::identity(), fut: self.service.call(req), + inner, _phantom: PhantomData, }) } @@ -146,6 +231,7 @@ where Some(encoding) => Either::left(CompressResponse { fut: self.service.call(req), encoding, + inner, _phantom: PhantomData, }), } @@ -160,6 +246,7 @@ pin_project! { #[pin] fut: S::Future, encoding: Encoding, + inner: Rc, _phantom: PhantomData, } } @@ -182,6 +269,7 @@ where unimplemented!("encoding '{enc}' should not be here"); } }; + let level = this.inner.level(&enc); Poll::Ready(Ok(resp.map_body(move |head, body| { let content_type = head.headers.get(header::CONTENT_TYPE); @@ -205,7 +293,7 @@ where ContentEncoding::Identity }; - EitherBody::left(Encoder::response(enc, head, body)) + EitherBody::left(Encoder::response_with_level(enc, head, body, level)) }))) } @@ -389,6 +477,29 @@ mod tests { 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().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()); + } + fn configure_predicate_test(cfg: &mut web::ServiceConfig) { cfg.route( "/html",