use axum::http::header;
use axum::response::Response;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Encoding {
Brotli,
Gzip,
}
impl Encoding {
fn token(self) -> &'static str {
match self {
Encoding::Brotli => "br",
Encoding::Gzip => "gzip",
}
}
}
pub fn negotiate(accept_encoding: Option<&str>) -> Option<Encoding> {
let ae = accept_encoding?;
let mut best: Option<(Encoding, f32)> = None;
let mut identity_q: f32 = 1.0;
let mut star_q: Option<f32> = None;
for part in ae.split(',') {
let mut it = part.trim().split(';');
let token = it.next().unwrap_or("").trim().to_ascii_lowercase();
let mut q = 1.0f32;
for p in it {
let p = p.trim();
if let Some(v) = p.strip_prefix("q=") {
q = v.trim().parse().unwrap_or(1.0);
}
}
match token.as_str() {
"gzip" => consider(&mut best, Encoding::Gzip, q),
"br" => consider(&mut best, Encoding::Brotli, q),
"identity" => identity_q = q,
"*" => star_q = Some(q),
_ => {}
}
}
if best.is_none()
&& let Some(sq) = star_q
&& sq >= identity_q
{
best = Some((Encoding::Brotli, sq));
}
best.filter(|(_, q)| *q > 0.0).map(|(e, _)| e)
}
fn consider(best: &mut Option<(Encoding, f32)>, enc: Encoding, q: f32) {
if q <= 0.0 {
return;
}
let better = match best {
None => true,
Some((cur, bq)) => {
q > *bq || (q == *bq && *cur == Encoding::Gzip && enc == Encoding::Brotli)
}
};
if better {
*best = Some((enc, q));
}
}
pub fn compress(data: &[u8], enc: Encoding) -> Vec<u8> {
match enc {
Encoding::Gzip => {
use flate2::write::GzEncoder;
use std::io::Write as _;
let mut e = GzEncoder::new(Vec::new(), flate2::Compression::default());
if e.write_all(data).is_err() {
return data.to_vec();
}
e.finish().unwrap_or_else(|_| data.to_vec())
}
Encoding::Brotli => {
use std::io::Write as _;
let mut out = Vec::with_capacity(data.len() / 4 + 64);
{
let mut w = brotli::CompressorWriter::new(&mut out, 4096, 5, 22);
if w.write_all(data).is_err() {
return data.to_vec();
}
}
out
}
}
}
pub fn etag_for(body: &[u8]) -> String {
use sha2::{Digest as _, Sha256};
let d = Sha256::digest(body);
format!("\"{}\"", hex::encode(&d[..16]))
}
pub fn if_none_match_matches(header_value: Option<&str>, etag: &str) -> bool {
let Some(hv) = header_value else { return false };
let hv = hv.trim();
if hv == "*" {
return true;
}
hv.split(',').any(|t| t.trim() == etag)
}
pub fn is_compressible(content_type: &str) -> bool {
let ct = content_type.to_ascii_lowercase();
if ct.starts_with("text/event-stream") {
return false; }
(ct.starts_with("application/json")
|| ct.starts_with("text/")
|| ct.contains("javascript")
|| ct.contains("svg"))
&& !ct.starts_with("image/")
}
pub fn encode_response(
status: axum::http::StatusCode,
content_type: &str,
identity: bytes::Bytes,
identity_etag: Option<&str>,
accept_encoding: Option<&str>,
if_none_match: Option<&str>,
extra: &[(&str, &str)],
) -> Response {
if let (Some(etag), Some(inm)) = (identity_etag, if_none_match)
&& if_none_match_matches(Some(inm), etag)
{
let mut b = Response::builder()
.status(axum::http::StatusCode::NOT_MODIFIED)
.header(header::ETAG, etag);
for (k, v) in extra {
b = b.header(*k, *v);
}
return b.body(axum::body::Body::empty()).expect("static 304");
}
let compressible = is_compressible(content_type);
let chosen = if compressible {
negotiate(accept_encoding)
} else {
None
};
let mut builder = Response::builder().status(status);
for (k, v) in extra {
builder = builder.header(*k, *v);
}
builder = builder.header(header::CONTENT_TYPE, content_type);
if let Some(etag) = identity_etag {
builder = builder.header(header::ETAG, etag);
}
if compressible {
builder = builder.header(header::VARY, "Accept-Encoding");
}
match chosen {
Some(enc) => {
let compressed = compress(&identity, enc);
builder
.header(header::CONTENT_ENCODING, enc.token())
.body(axum::body::Body::from(compressed))
.expect("static response")
}
None => builder
.body(axum::body::Body::from(identity))
.expect("static response"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn negotiate_q_values_and_identity() {
assert_eq!(negotiate(None), None);
assert_eq!(negotiate(Some("")), None);
assert_eq!(negotiate(Some("gzip")), Some(Encoding::Gzip));
assert_eq!(negotiate(Some("br")), Some(Encoding::Brotli));
assert_eq!(negotiate(Some("br, gzip")), Some(Encoding::Brotli));
assert_eq!(
negotiate(Some("gzip, deflate, br, zstd")),
Some(Encoding::Brotli)
);
assert_eq!(
negotiate(Some("br;q=0.5, gzip;q=1.0")),
Some(Encoding::Gzip)
);
assert_eq!(
negotiate(Some("br;q=0.5, identity;q=1.0")),
Some(Encoding::Brotli)
);
assert_eq!(negotiate(Some("gzip;q=0, br;q=0")), None);
assert_eq!(negotiate(Some("*")), Some(Encoding::Brotli));
assert_eq!(negotiate(Some("deflate, zstd")), None);
}
#[test]
fn compress_roundtrips_shapes() {
let data = b"hello hello hello world world world".repeat(50);
let gz = compress(&data, Encoding::Gzip);
let br = compress(&data, Encoding::Brotli);
assert!(gz.len() < data.len());
assert!(br.len() < data.len());
assert_eq!(&gz[..2], &[0x1f, 0x8b]);
use std::io::Read as _;
let mut d = flate2::read::GzDecoder::new(&gz[..]);
let mut out = Vec::new();
d.read_to_end(&mut out).unwrap();
assert_eq!(out, data);
}
#[test]
fn etag_stable_and_inm() {
let a = etag_for(b"abc");
assert_eq!(a, etag_for(b"abc"));
assert_ne!(a, etag_for(b"abd"));
assert!(a.starts_with('"') && a.ends_with('"'));
assert!(if_none_match_matches(Some(&a), &a));
assert!(if_none_match_matches(Some("*"), &a));
assert!(if_none_match_matches(Some(&format!("junk, {a}")), &a));
assert!(!if_none_match_matches(Some("\"other\""), &a));
assert!(!if_none_match_matches(None, &a));
}
#[test]
fn sse_never_compressible() {
assert!(!is_compressible("text/event-stream"));
assert!(is_compressible("application/json"));
assert!(is_compressible("text/html;charset=UTF-8"));
assert!(is_compressible("text/javascript"));
assert!(!is_compressible("image/png"));
}
#[test]
fn encode_response_identity_is_byte_identical() {
let body = bytes::Bytes::from_static(b"{\"a\":1}");
let resp = encode_response(
axum::http::StatusCode::OK,
"application/json",
body.clone(),
None,
None,
None,
&[],
);
assert!(resp.headers().get(header::CONTENT_ENCODING).is_none());
assert_eq!(resp.headers().get(header::VARY).unwrap(), "Accept-Encoding");
}
#[test]
fn encode_response_compresses_when_asked() {
let body = bytes::Bytes::from_static(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
let resp = encode_response(
axum::http::StatusCode::OK,
"application/json",
body,
Some("\"etag\""),
Some("br"),
None,
&[],
);
assert_eq!(resp.headers().get(header::CONTENT_ENCODING).unwrap(), "br");
assert_eq!(resp.headers().get(header::ETAG).unwrap(), "\"etag\"");
}
#[test]
fn encode_response_304_short_circuit() {
let body = bytes::Bytes::from_static(b"{\"a\":1}");
let resp = encode_response(
axum::http::StatusCode::OK,
"application/json",
body,
Some("\"abc\""),
None,
Some("\"abc\""),
&[],
);
assert_eq!(resp.status(), axum::http::StatusCode::NOT_MODIFIED);
}
}