1
0
Fork 0
mirror of https://github.com/actix/actix-web.git synced 2024-12-16 21:26:34 +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
- 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

View file

@ -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)

View file

@ -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<dyn Fn(Option<&HeaderValue>) -> 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::<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 {
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::<Mime>().unwrap();
match response_mime.type_().as_str() {
"image" => false,
_ => true,
}
}
},
Some(hdr) => match hdr.to_str().ok().and_then(|hdr| hdr.parse::<Mime>().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<S> {
service: S,
compress: fn(Option<&HeaderValue>) -> bool,
predicate: CompressPredicateFn,
}
impl<S, B> Service<ServiceRequest> for CompressMiddleware<S>
@ -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<B>,
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 = "<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> {
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<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]
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::<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;
}