This commit is contained in:
Denis Kayshev 2025-03-10 14:57:49 -07:00 committed by GitHub
commit b86dd51b04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 209 additions and 25 deletions

View File

@ -27,6 +27,7 @@
### Added ### Added
- Add `error::InvalidStatusCode` re-export. - Add `error::InvalidStatusCode` re-export.
- New method `response_with_level` for `Encoder<B>` for setup compress level. [#2948]
## 3.7.0 ## 3.7.0

View File

@ -28,6 +28,30 @@ use crate::{
const MAX_CHUNK_SIZE_ENCODE_IN_PLACE: usize = 1024; 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! { pin_project! {
pub struct Encoder<B> { pub struct Encoder<B> {
#[pin] #[pin]
@ -60,6 +84,15 @@ impl<B: MessageBody> Encoder<B> {
} }
pub fn response(encoding: ContentEncoding, head: &mut ResponseHead, body: B) -> Self { 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<u32>,
) -> Self {
// no need to compress empty bodies // no need to compress empty bodies
match body.size() { match body.size() {
BodySize::None => return Self::none(), BodySize::None => return Self::none(),
@ -78,8 +111,9 @@ impl<B: MessageBody> Encoder<B> {
}; };
if should_encode { if should_encode {
let enconding_level = ContentEncodingWithLevel::new(encoding, level);
// wrap body only if encoder is feature-enabled // 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); update_head(encoding, head);
return Encoder { return Encoder {
@ -287,27 +321,74 @@ enum ContentEncoder {
Zstd(ZstdEncoder<'static, Writer>), Zstd(ZstdEncoder<'static, Writer>),
} }
enum ContentEncodingWithLevel {
Deflate(u32),
Gzip(u32),
Brotli(u32),
Zstd(i32),
Identity,
}
impl ContentEncodingWithLevel {
pub fn new(encoding: ContentEncoding, level: Option<u32>) -> 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 { impl ContentEncoder {
fn select(encoding: ContentEncoding) -> Option<Self> { fn select(encoding: ContentEncodingWithLevel) -> Option<Self> {
match encoding { match encoding {
#[cfg(feature = "compress-gzip")] #[cfg(feature = "compress-gzip")]
ContentEncoding::Deflate => Some(ContentEncoder::Deflate(ZlibEncoder::new( ContentEncodingWithLevel::Deflate(level) => Some(ContentEncoder::Deflate(
Writer::new(), ZlibEncoder::new(Writer::new(), flate2::Compression::new(level)),
flate2::Compression::fast(), )),
))),
#[cfg(feature = "compress-gzip")] #[cfg(feature = "compress-gzip")]
ContentEncoding::Gzip => Some(ContentEncoder::Gzip(GzEncoder::new( ContentEncodingWithLevel::Gzip(level) => Some(ContentEncoder::Gzip(GzEncoder::new(
Writer::new(), Writer::new(),
flate2::Compression::fast(), flate2::Compression::new(level),
))), ))),
#[cfg(feature = "compress-brotli")] #[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")] #[cfg(feature = "compress-zstd")]
ContentEncoding::Zstd => { ContentEncodingWithLevel::Zstd(level) => {
let encoder = ZstdEncoder::new(Writer::new(), 3).ok()?; let encoder = ZstdEncoder::new(Writer::new(), level).ok()?;
Some(ContentEncoder::Zstd(encoder)) Some(ContentEncoder::Zstd(encoder))
} }
@ -401,16 +482,6 @@ impl ContentEncoder {
} }
} }
#[cfg(feature = "compress-brotli")]
fn new_brotli_compressor() -> Box<brotli::CompressorWriter<Writer>> {
Box::new(brotli::CompressorWriter::new(
Writer::new(),
32 * 1024, // 32 KiB buffer
3, // BROTLI_PARAM_QUALITY
22, // BROTLI_PARAM_LGWIN
))
}
#[derive(Debug, Display)] #[derive(Debug, Display)]
#[non_exhaustive] #[non_exhaustive]
pub enum EncoderError { pub enum EncoderError {

View File

@ -35,6 +35,7 @@
- Add `web::Html` responder. - Add `web::Html` responder.
- Add `HttpRequest::full_url()` method to get the complete URL of the request. - Add `HttpRequest::full_url()` method to get the complete URL of the request.
- Add level setup for `middleware::Compress`.
### Fixed ### Fixed

View File

@ -4,10 +4,11 @@ use std::{
future::Future, future::Future,
marker::PhantomData, marker::PhantomData,
pin::Pin, pin::Pin,
rc::Rc,
task::{Context, Poll}, task::{Context, Poll},
}; };
use actix_http::encoding::Encoder; use actix_http::{encoding::Encoder, header::ContentEncoding};
use actix_service::{Service, Transform}; use actix_service::{Service, Transform};
use actix_utils::future::{ok, Either, Ready}; use actix_utils::future::{ok, Either, Ready};
use futures_core::ready; use futures_core::ready;
@ -55,6 +56,20 @@ use crate::{
/// .wrap(middleware::Compress::default()) /// .wrap(middleware::Compress::default())
/// .default_service(web::to(|| async { HttpResponse::Ok().body("hello world") })); /// .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: /// Pre-compressed Gzip file being served from disk with correct headers added to bypass middleware:
/// ```no_run /// ```no_run
@ -74,7 +89,71 @@ use crate::{
/// [feature flags]: ../index.html#crate-features /// [feature flags]: ../index.html#crate-features
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
#[non_exhaustive] #[non_exhaustive]
pub struct Compress; pub struct Compress {
inner: Rc<Inner>,
}
impl Compress {
/// Constructs new compress middleware instance with default settings.
pub fn new() -> Self {
Default::default()
}
}
#[derive(Debug, Clone, Default)]
struct Inner {
deflate: Option<u32>,
gzip: Option<u32>,
brotli: Option<u32>,
zstd: Option<u32>,
}
impl Inner {
pub fn level(&self, encoding: &ContentEncoding) -> Option<u32> {
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<S, B> Transform<S, ServiceRequest> for Compress impl<S, B> Transform<S, ServiceRequest> for Compress
where where
@ -88,12 +167,16 @@ where
type Future = Ready<Result<Self::Transform, Self::InitError>>; type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future { fn new_transform(&self, service: S) -> Self::Future {
ok(CompressMiddleware { service }) ok(CompressMiddleware {
service,
inner: Rc::clone(&self.inner),
})
} }
} }
pub struct CompressMiddleware<S> { pub struct CompressMiddleware<S> {
service: S, service: S,
inner: Rc<Inner>,
} }
impl<S, B> Service<ServiceRequest> for CompressMiddleware<S> impl<S, B> Service<ServiceRequest> for CompressMiddleware<S>
@ -112,6 +195,7 @@ where
fn call(&self, req: ServiceRequest) -> Self::Future { fn call(&self, req: ServiceRequest) -> Self::Future {
// negotiate content-encoding // negotiate content-encoding
let accept_encoding = req.get_header::<AcceptEncoding>(); let accept_encoding = req.get_header::<AcceptEncoding>();
let inner = self.inner.clone();
let accept_encoding = match accept_encoding { let accept_encoding = match accept_encoding {
// missing header; fallback to identity // missing header; fallback to identity
@ -119,6 +203,7 @@ where
return Either::left(CompressResponse { return Either::left(CompressResponse {
encoding: Encoding::identity(), encoding: Encoding::identity(),
fut: self.service.call(req), fut: self.service.call(req),
inner,
_phantom: PhantomData, _phantom: PhantomData,
}) })
} }
@ -146,6 +231,7 @@ where
Some(encoding) => Either::left(CompressResponse { Some(encoding) => Either::left(CompressResponse {
fut: self.service.call(req), fut: self.service.call(req),
encoding, encoding,
inner,
_phantom: PhantomData, _phantom: PhantomData,
}), }),
} }
@ -160,6 +246,7 @@ pin_project! {
#[pin] #[pin]
fut: S::Future, fut: S::Future,
encoding: Encoding, encoding: Encoding,
inner: Rc<Inner>,
_phantom: PhantomData<B>, _phantom: PhantomData<B>,
} }
} }
@ -182,6 +269,7 @@ where
unimplemented!("encoding '{enc}' should not be here"); unimplemented!("encoding '{enc}' should not be here");
} }
}; };
let level = this.inner.level(&enc);
Poll::Ready(Ok(resp.map_body(move |head, body| { Poll::Ready(Ok(resp.map_body(move |head, body| {
let content_type = head.headers.get(header::CONTENT_TYPE); let content_type = head.headers.get(header::CONTENT_TYPE);
@ -205,7 +293,7 @@ where
ContentEncoding::Identity 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"))); 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) { fn configure_predicate_test(cfg: &mut web::ServiceConfig) {
cfg.route( cfg.route(
"/html", "/html",