1
0
Fork 0
mirror of https://github.com/actix/actix-web.git synced 2024-12-17 13:46:36 +00:00

add Compress::with_predicate

This commit is contained in:
Rob Ede 2023-04-02 05:54:34 +01:00
parent 2b543437c3
commit 0216cb186e
No known key found for this signature in database
GPG key ID: 97C636207D3EF933
4 changed files with 234 additions and 123 deletions

View file

@ -5,10 +5,12 @@
### Added ### Added
- Add `Resource::{get, post, etc...}` methods for more concisely adding routes that don't need additional guards. - 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 ### Changed
- Handler functions can now receive up to 16 extractor parameters. - 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 ## 4.3.1 - 2023-02-26

View file

@ -1,60 +1,54 @@
use super::CONTENT_TYPE;
use mime::Mime; use mime::Mime;
use super::CONTENT_TYPE;
crate::http::header::common_header! { crate::http::header::common_header! {
/// `Content-Type` header, defined /// `Content-Type` header, defined in [RFC 9110 §8.3].
/// in [RFC 7231 §3.1.1.5](https://datatracker.ietf.org/doc/html/rfc7231#section-3.1.1.5)
/// ///
/// The `Content-Type` header field indicates the media type of the /// The `Content-Type` header field indicates the media type of the associated representation:
/// associated representation: either the representation enclosed in the /// either the representation enclosed in the message payload or the selected representation,
/// message payload or the selected representation, as determined by the /// as determined by the message semantics. The indicated media type defines both the data
/// 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
/// format and how that data is intended to be processed by a recipient, /// received message semantics, after any content codings indicated by Content-Encoding are
/// within the scope of the received message semantics, after any content /// decoded.
/// codings indicated by Content-Encoding are decoded.
/// ///
/// Although the `mime` crate allows the mime options to be any slice, this crate /// Although the `mime` crate allows the mime options to be any slice, this crate forces the use
/// forces the use of Vec. This is to make sure the same header can't have more than 1 type. If /// of Vec. This is to make sure the same header can't have more than 1 type. If this is an
/// this is an issue, it's possible to implement `Header` on a custom struct. /// issue, it's possible to implement `Header` on a custom struct.
/// ///
/// # ABNF /// # ABNF
///
/// ```plain /// ```plain
/// Content-Type = media-type /// Content-Type = media-type
/// ``` /// ```
/// ///
/// # Example Values /// # Example Values
/// * `text/html; charset=utf-8` ///
/// * `application/json` /// - `text/html; charset=utf-8`
/// - `application/json`
/// ///
/// # Examples /// # 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, HttpResponse};
/// use actix_web::http::header::ContentType;
/// ///
/// let mut builder = HttpResponse::Ok(); /// let res_json = HttpResponse::Ok()
/// builder.insert_header( /// .insert_header(ContentType::json());
/// ContentType(mime::TEXT_HTML) ///
/// ); /// 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] (ContentType, CONTENT_TYPE) => [Mime]
test_parse_and_format { test_parse_and_format {
crate::http::header::common_header_test!( crate::http::header::common_header_test!(
test1, test_text_html,
vec![b"text/html"], vec![b"text/html"],
Some(HeaderField(mime::TEXT_HTML))); Some(HeaderField(mime::TEXT_HTML)));
crate::http::header::common_header_test!( crate::http::header::common_header_test!(
test2, test_image_star,
vec![b"image/*"], vec![b"image/*"],
Some(HeaderField(mime::IMAGE_STAR))); Some(HeaderField(mime::IMAGE_STAR)));
@ -62,54 +56,49 @@ crate::http::header::common_header! {
} }
impl ContentType { impl ContentType {
/// A constructor to easily create a `Content-Type: application/json` /// Constructs a `Content-Type: application/json` header.
/// header.
#[inline] #[inline]
pub fn json() -> ContentType { pub fn json() -> ContentType {
ContentType(mime::APPLICATION_JSON) ContentType(mime::APPLICATION_JSON)
} }
/// A constructor to easily create a `Content-Type: text/plain; /// Constructs a `Content-Type: text/plain; charset=utf-8` header.
/// charset=utf-8` header.
#[inline] #[inline]
pub fn plaintext() -> ContentType { pub fn plaintext() -> ContentType {
ContentType(mime::TEXT_PLAIN_UTF_8) ContentType(mime::TEXT_PLAIN_UTF_8)
} }
/// A constructor to easily create a `Content-Type: text/html; charset=utf-8` /// Constructs a `Content-Type: text/html; charset=utf-8` header.
/// header.
#[inline] #[inline]
pub fn html() -> ContentType { pub fn html() -> ContentType {
ContentType(mime::TEXT_HTML_UTF_8) 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] #[inline]
pub fn xml() -> ContentType { pub fn xml() -> ContentType {
ContentType(mime::TEXT_XML) ContentType(mime::TEXT_XML)
} }
/// A constructor to easily create a `Content-Type: /// Constructs a `Content-Type: application/www-form-url-encoded` header.
/// application/www-form-url-encoded` header.
#[inline] #[inline]
pub fn form_url_encoded() -> ContentType { pub fn form_url_encoded() -> ContentType {
ContentType(mime::APPLICATION_WWW_FORM_URLENCODED) 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] #[inline]
pub fn jpeg() -> ContentType { pub fn jpeg() -> ContentType {
ContentType(mime::IMAGE_JPEG) ContentType(mime::IMAGE_JPEG)
} }
/// A constructor to easily create a `Content-Type: image/png` header. /// Constructs a `Content-Type: image/png` header.
#[inline] #[inline]
pub fn png() -> ContentType { pub fn png() -> ContentType {
ContentType(mime::IMAGE_PNG) ContentType(mime::IMAGE_PNG)
} }
/// A constructor to easily create a `Content-Type: /// Constructs a `Content-Type: application/octet-stream` header.
/// application/octet-stream` header.
#[inline] #[inline]
pub fn octet_stream() -> ContentType { pub fn octet_stream() -> ContentType {
ContentType(mime::APPLICATION_OCTET_STREAM) ContentType(mime::APPLICATION_OCTET_STREAM)

View file

@ -5,6 +5,7 @@ use std::{
future::Future, future::Future,
marker::PhantomData, marker::PhantomData,
pin::Pin, pin::Pin,
rc::Rc,
task::{Context, Poll}, task::{Context, Poll},
}; };
@ -26,6 +27,8 @@ use crate::{
Error, HttpMessage, HttpResponse, Error, HttpMessage, HttpResponse,
}; };
type CompressPredicateFn = Rc<dyn Fn(Option<&HeaderValue>) -> bool>;
/// Middleware for compressing response payloads. /// Middleware for compressing response payloads.
/// ///
/// # Encoding Negotiation /// # Encoding Negotiation
@ -76,29 +79,76 @@ use crate::{
#[derive(Clone)] #[derive(Clone)]
#[non_exhaustive] #[non_exhaustive]
pub struct Compress { 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::<mime::Mime>() {
/// 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 { impl fmt::Debug for Compress {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 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 { impl Default for Compress {
fn default() -> Self { fn default() -> Self {
Compress { fn default_compress_predicate(content_type: Option<&HeaderValue>) -> bool {
compress: |content_type| match content_type { match content_type {
None => true, None => true,
Some(value) => { Some(hdr) => match hdr.to_str().ok().and_then(|hdr| hdr.parse::<Mime>().ok()) {
let response_mime: Mime = value.to_str().unwrap().parse::<Mime>().unwrap(); Some(mime) if mime.type_().as_str() == "image" => false,
match response_mime.type_().as_str() { Some(mime) if mime.type_().as_str() == "video" => false,
"image" => false,
_ => true, _ => true,
}
}
}, },
} }
} }
Compress {
predicate: Rc::new(default_compress_predicate),
}
}
} }
impl<S, B> Transform<S, ServiceRequest> for Compress impl<S, B> Transform<S, ServiceRequest> for Compress
@ -115,14 +165,14 @@ where
fn new_transform(&self, service: S) -> Self::Future { fn new_transform(&self, service: S) -> Self::Future {
ok(CompressMiddleware { ok(CompressMiddleware {
service, service,
compress: self.compress, predicate: Rc::clone(&self.predicate),
}) })
} }
} }
pub struct CompressMiddleware<S> { pub struct CompressMiddleware<S> {
service: S, service: S,
compress: fn(Option<&HeaderValue>) -> bool, predicate: CompressPredicateFn,
} }
impl<S, B> Service<ServiceRequest> for CompressMiddleware<S> impl<S, B> Service<ServiceRequest> for CompressMiddleware<S>
@ -148,8 +198,8 @@ 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),
predicate: Rc::clone(&self.predicate),
_phantom: PhantomData, _phantom: PhantomData,
compress: self.compress,
}) })
} }
@ -176,8 +226,8 @@ where
Some(encoding) => Either::left(CompressResponse { Some(encoding) => Either::left(CompressResponse {
fut: self.service.call(req), fut: self.service.call(req),
encoding, encoding,
predicate: Rc::clone(&self.predicate),
_phantom: PhantomData, _phantom: PhantomData,
compress: self.compress,
}), }),
} }
} }
@ -191,8 +241,8 @@ pin_project! {
#[pin] #[pin]
fut: S::Future, fut: S::Future,
encoding: Encoding, encoding: Encoding,
predicate: CompressPredicateFn,
_phantom: PhantomData<B>, _phantom: PhantomData<B>,
compress: fn(Option<&HeaderValue>) -> bool,
} }
} }
@ -211,22 +261,22 @@ where
let enc = match this.encoding { let enc = match this.encoding {
Encoding::Known(enc) => *enc, Encoding::Known(enc) => *enc,
Encoding::Unknown(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| { 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);
let should_compress = (self.compress)(content_type);
if should_compress { let should_compress = (self.predicate)(content_type);
EitherBody::left(Encoder::response(enc, head, body))
let enc = if should_compress {
enc
} else { } else {
EitherBody::left(Encoder::response( ContentEncoding::Identity
ContentEncoding::Identity, };
head,
body, EitherBody::left(Encoder::response(enc, head, body))
))
}
}))) })))
} }
@ -289,10 +339,20 @@ static SUPPORTED_ENCODINGS: &[Encoding] = &[
mod tests { mod tests {
use std::collections::HashSet; use std::collections::HashSet;
// use static_assertions::assert_impl_all;
use super::*; use super::*;
use crate::http::header::ContentType; use crate::http::header::ContentType;
use crate::{middleware::DefaultHeaders, test, web, App}; use crate::{middleware::DefaultHeaders, test, web, App};
const HTML_DATA_PART: &str = "<html><h1>hello world</h1></html";
const HTML_DATA: &str = const_str::repeat!(HTML_DATA_PART, 100);
const TEXT_DATA_PART: &str = "hello world ";
const TEXT_DATA: &str = const_str::repeat!(TEXT_DATA_PART, 100);
// assert_impl_all!(Compress: Send, Sync);
pub fn gzip_decode(bytes: impl AsRef<[u8]>) -> Vec<u8> { pub fn gzip_decode(bytes: impl AsRef<[u8]>) -> Vec<u8> {
use std::io::Read as _; use std::io::Read as _;
let mut decoder = flate2::read::GzDecoder::new(bytes.as_ref()); let mut decoder = flate2::read::GzDecoder::new(bytes.as_ref());
@ -301,23 +361,55 @@ mod tests {
buf buf
} }
#[track_caller]
fn assert_successful_res_with_content_type<B>(res: &ServiceResponse<B>, 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<B>(res: &ServiceResponse<B>, 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<B>(res: &ServiceResponse<B>, 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] #[actix_rt::test]
async fn prevents_double_compressing() { async fn prevents_double_compressing() {
const D: &str = "hello world ";
const DATA: &str = const_str::repeat!(D, 100);
let app = test::init_service({ let app = test::init_service({
App::new() App::new()
.wrap(Compress::default()) .wrap(Compress::default())
.route( .route(
"/single", "/single",
web::get().to(move || HttpResponse::Ok().body(DATA)), web::get().to(move || HttpResponse::Ok().body(TEXT_DATA)),
) )
.service( .service(
web::resource("/double") web::resource("/double")
.wrap(Compress::default()) .wrap(Compress::default())
.wrap(DefaultHeaders::new().add(("x-double", "true"))) .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; .await;
@ -331,7 +423,7 @@ mod tests {
assert_eq!(res.headers().get("x-double"), None); assert_eq!(res.headers().get("x-double"), None);
assert_eq!(res.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip"); assert_eq!(res.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip");
let bytes = test::read_body(res).await; 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() let req = test::TestRequest::default()
.uri("/double") .uri("/double")
@ -342,7 +434,7 @@ mod tests {
assert_eq!(res.headers().get("x-double").unwrap(), "true"); assert_eq!(res.headers().get("x-double").unwrap(), "true");
assert_eq!(res.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip"); assert_eq!(res.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip");
let bytes = test::read_body(res).await; 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] #[actix_rt::test]
@ -369,33 +461,80 @@ mod tests {
assert!(vary_headers.contains(&HeaderValue::from_static("accept-encoding"))); assert!(vary_headers.contains(&HeaderValue::from_static("accept-encoding")));
} }
#[actix_rt::test] fn configure_predicate_test(cfg: &mut web::ServiceConfig) {
async fn prevents_compression_jpeg() { cfg.route(
const D: &str = "test image"; "/html",
const DATA: &str = const_str::repeat!(D, 100); web::get().to(|| {
let app = test::init_service({ HttpResponse::Ok()
App::new().wrap(Compress::default()).route( .content_type(ContentType::html())
"/image", .body(HTML_DATA)
web::get().to(move || {
let builder = HttpResponse::Ok()
.insert_header(ContentType::jpeg())
.body(DATA);
builder
}), }),
) )
}) .route(
.await; "/image",
let req = test::TestRequest::default() web::get().to(|| {
.uri("/image") HttpResponse::Ok()
.insert_header((header::ACCEPT_ENCODING, "gzip")) .content_type(ContentType::jpeg())
.to_request(); .body(TEXT_DATA)
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());
#[actix_rt::test]
async fn prevents_compression_jpeg() {
let app = test::init_service(
App::new()
.wrap(Compress::default())
.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_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::<mime::Mime>() {
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());
} }
} }

View file

@ -1,19 +0,0 @@
{ pkgs ? import <nixpkgs> { } }:
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;
}