pub mod checksum;
pub mod deflate;
pub mod gzip;
use crate::handler::BoxFuture;
use crate::middleware::{Middleware, Next};
use crate::request::Request;
use crate::response::Response;
#[derive(Debug, Clone, Copy)]
pub struct Compress {
min_size: usize,
}
impl Default for Compress {
fn default() -> Self {
Compress { min_size: 1024 }
}
}
impl Compress {
pub fn new() -> Self {
Self::default()
}
pub fn min_size(mut self, bytes: usize) -> Self {
self.min_size = bytes;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Encoding {
Gzip,
Deflate,
}
impl Encoding {
fn token(self) -> &'static str {
match self {
Encoding::Gzip => "gzip",
Encoding::Deflate => "deflate",
}
}
}
fn negotiate(accept_encoding: &str) -> Option<Encoding> {
let mut gzip: Option<f32> = None;
let mut deflate: Option<f32> = None;
let mut wildcard: Option<f32> = None;
for part in accept_encoding.split(',') {
let mut pieces = part.split(';');
let coding = pieces.next().unwrap_or("").trim().to_ascii_lowercase();
let weight = pieces
.find_map(|p| p.trim().strip_prefix("q=").or_else(|| p.trim().strip_prefix("Q=")))
.and_then(|q| q.trim().parse::<f32>().ok())
.unwrap_or(1.0);
match coding.as_str() {
"gzip" | "x-gzip" => gzip = Some(weight),
"deflate" => deflate = Some(weight),
"*" => wildcard = Some(weight),
_ => {}
}
}
let gzip = gzip.or(wildcard).unwrap_or(0.0);
let deflate = deflate.or(wildcard).unwrap_or(0.0);
if gzip <= 0.0 && deflate <= 0.0 {
None
} else if gzip >= deflate {
Some(Encoding::Gzip)
} else {
Some(Encoding::Deflate)
}
}
fn is_compressible(content_type: Option<&str>) -> bool {
let Some(content_type) = content_type else { return false };
let mime = content_type.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
mime.starts_with("text/")
|| mime.ends_with("+json")
|| mime.ends_with("+xml")
|| matches!(
mime.as_str(),
"application/json"
| "application/xml"
| "application/javascript"
| "application/x-javascript"
| "application/ecmascript"
| "application/x-www-form-urlencoded"
| "application/graphql"
| "application/ld+json"
| "application/manifest+json"
| "application/wasm"
| "image/svg+xml"
| "font/ttf"
| "font/otf"
)
}
impl Middleware for Compress {
fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
let Some(encoding) = request.header("accept-encoding").and_then(negotiate) else {
return next.run(request);
};
let settings = *self;
Box::pin(async move {
let response = next.run(request).await;
settings.encode(encoding, response)
})
}
}
impl Compress {
fn encode(&self, encoding: Encoding, mut response: Response) -> Response {
let no_transform = response
.headers
.get("cache-control")
.is_some_and(|cc| cc.split(',').any(|d| d.trim().eq_ignore_ascii_case("no-transform")));
if response.body.len() < self.min_size
|| response.headers.contains("content-encoding")
|| !(200..300).contains(&response.status.code())
|| no_transform
|| !is_compressible(response.headers.content_type())
{
return response;
}
let compressed = match encoding {
Encoding::Gzip => gzip::compress(&response.body),
Encoding::Deflate => gzip::zlib_compress(&response.body),
};
if compressed.len() >= response.body.len() {
return response;
}
response.body = compressed;
response.headers.set("content-encoding", encoding.token());
response.headers.remove("content-length");
if let Some(etag) = response.headers.get("etag")
&& !etag.starts_with("W/")
{
let weakened = format!("W/{etag}");
response.headers.set("etag", weakened);
}
let vary = response.headers.get("vary").unwrap_or("").to_string();
if !vary.split(',').any(|v| v.trim().eq_ignore_ascii_case("accept-encoding")) {
let value = if vary.is_empty() {
"accept-encoding".to_string()
} else {
format!("{vary}, accept-encoding")
};
response.headers.set("vary", value);
}
response
}
}
#[cfg(test)]
mod middleware_tests {
use super::*;
use crate::method::Method;
use crate::router::Router;
use crate::status::Status;
use crate::testing::TestClient;
use rustlavel_core::Json;
fn big_json() -> Json {
Json::Array(
(0..200)
.map(|i| {
Json::object([
("id", Json::from(i)),
("name", Json::from(format!("user-{i}"))),
("email", Json::from(format!("user-{i}@example.com"))),
("role", Json::from("member")),
])
})
.collect(),
)
}
fn client(compress: Compress) -> TestClient {
let mut router = Router::new();
router.middleware(compress);
router.get("/users", |_req: Request| async { Response::json(big_json()) });
router.get("/tiny", |_req: Request| async { Response::json(Json::object([("ok", Json::from(true))])) });
router.get("/image", |_req: Request| async {
Response::ok().with_header("content-type", "image/png").with_body(vec![0u8; 4096])
});
router.get("/already", |_req: Request| async {
Response::ok()
.with_header("content-type", "text/plain")
.with_header("content-encoding", "br")
.with_body(vec![b'x'; 4096])
});
router.get("/no-transform", |_req: Request| async {
Response::text("y".repeat(4096)).with_header("cache-control", "no-transform")
});
router.get("/tagged", |_req: Request| async {
Response::text("z".repeat(4096)).with_header("etag", "\"abc\"").with_header("vary", "Origin")
});
router.get("/random", |_req: Request| async {
let mut state = 0x9E37_79B9_7F4A_7C15_u64;
let body: Vec<u8> = (0..4096)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state & 0xFF) as u8
})
.collect();
Response::ok().with_header("content-type", "text/plain").with_body(body)
});
router.get("/missing", |_req: Request| async { Response::not_found().with_text("n".repeat(4096)) });
TestClient::new(router)
}
fn get(path: &str, accept: &str) -> Request {
Request::new(Method::Get, path).with_header("accept-encoding", accept)
}
#[tokio::test]
async fn a_json_body_is_gzipped_and_round_trips() {
let plain = client(Compress::new()).get("/users").await;
let response = client(Compress::new()).send(get("/users", "gzip, deflate, br")).await;
assert_eq!(response.header("content-encoding"), Some("gzip"));
assert_eq!(response.header("vary"), Some("accept-encoding"));
let compressed = response.body_bytes();
assert!(compressed.len() < plain.body().len() / 3, "{} vs {}", compressed.len(), plain.body().len());
let restored = gzip::decompress(compressed).expect("valid gzip");
assert_eq!(String::from_utf8(restored).unwrap(), plain.body());
}
#[tokio::test]
async fn deflate_means_the_zlib_format() {
let response = client(Compress::new()).send(get("/users", "deflate")).await;
assert_eq!(response.header("content-encoding"), Some("deflate"));
gzip::zlib_decompress(response.body_bytes()).expect("zlib-framed, as browsers expect");
}
#[tokio::test]
async fn without_accept_encoding_nothing_changes() {
let response = client(Compress::new()).get("/users").await;
assert_eq!(response.header("content-encoding"), None);
assert!(response.body().starts_with('['));
}
#[tokio::test]
async fn small_bodies_are_left_alone() {
let response = client(Compress::new()).send(get("/tiny", "gzip")).await;
assert_eq!(response.header("content-encoding"), None);
assert_eq!(response.body(), "{\"ok\":true}");
}
#[tokio::test]
async fn the_threshold_is_configurable() {
let response = client(Compress::new().min_size(0)).send(get("/tiny", "gzip")).await;
assert_eq!(response.header("content-encoding"), None);
}
#[tokio::test]
async fn incompressible_types_and_already_encoded_bodies_are_skipped() {
let client = client(Compress::new());
assert_eq!(client.send(get("/image", "gzip")).await.header("content-encoding"), None);
assert_eq!(client.send(get("/already", "gzip")).await.header("content-encoding"), Some("br"));
assert_eq!(client.send(get("/no-transform", "gzip")).await.header("content-encoding"), None);
}
#[tokio::test]
async fn a_body_that_does_not_shrink_is_sent_as_it_was() {
let response = client(Compress::new()).send(get("/random", "gzip")).await;
assert_eq!(response.header("content-encoding"), None);
assert_eq!(response.body_bytes().len(), 4096);
}
#[tokio::test]
async fn only_successful_responses_are_compressed() {
let response = client(Compress::new()).send(get("/missing", "gzip")).await;
let response = response.assert_status(404);
assert_eq!(response.header("content-encoding"), None);
}
#[tokio::test]
async fn a_strong_etag_becomes_weak_and_vary_is_appended() {
let response = client(Compress::new()).send(get("/tagged", "gzip")).await;
assert_eq!(response.header("etag"), Some("W/\"abc\""));
assert_eq!(response.header("vary"), Some("Origin, accept-encoding"));
}
#[tokio::test]
async fn head_keeps_the_headers_a_get_would_have() {
let request = Request::new(Method::Head, "/users").with_header("accept-encoding", "gzip");
let response = client(Compress::new()).send(request).await;
assert_eq!(response.status(), Status::OK.code());
assert_eq!(response.header("content-encoding"), Some("gzip"));
}
#[test]
fn negotiation_follows_the_weights() {
assert_eq!(negotiate("gzip, deflate, br"), Some(Encoding::Gzip));
assert_eq!(negotiate("deflate"), Some(Encoding::Deflate));
assert_eq!(negotiate("x-gzip"), Some(Encoding::Gzip));
assert_eq!(negotiate("deflate;q=1.0, gzip;q=0.5"), Some(Encoding::Deflate));
assert_eq!(negotiate("gzip;q=0, deflate"), Some(Encoding::Deflate));
assert_eq!(negotiate("gzip;q=0, deflate;q=0"), None);
assert_eq!(negotiate("*"), Some(Encoding::Gzip));
assert_eq!(negotiate("*;q=0, gzip"), Some(Encoding::Gzip));
assert_eq!(negotiate("br"), None);
assert_eq!(negotiate("identity"), None);
assert_eq!(negotiate(""), None);
assert_eq!(negotiate("GZIP ; Q=0.8"), Some(Encoding::Gzip));
}
#[test]
fn compressibility_is_decided_by_type() {
assert!(is_compressible(Some("application/json; charset=utf-8")));
assert!(is_compressible(Some("text/html")));
assert!(is_compressible(Some("application/problem+json")));
assert!(is_compressible(Some("image/svg+xml")));
assert!(!is_compressible(Some("image/png")));
assert!(!is_compressible(Some("application/zip")));
assert!(!is_compressible(Some("video/mp4")));
assert!(!is_compressible(None));
}
}