1use axum::http::header;
20use axum::response::Response;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Encoding {
25 Brotli,
26 Gzip,
27}
28
29impl Encoding {
30 fn token(self) -> &'static str {
31 match self {
32 Encoding::Brotli => "br",
33 Encoding::Gzip => "gzip",
34 }
35 }
36}
37
38pub fn negotiate(accept_encoding: Option<&str>) -> Option<Encoding> {
43 let ae = accept_encoding?;
44 let mut best: Option<(Encoding, f32)> = None;
46 let mut identity_q: f32 = 1.0;
47 let mut star_q: Option<f32> = None;
48 for part in ae.split(',') {
49 let mut it = part.trim().split(';');
50 let token = it.next().unwrap_or("").trim().to_ascii_lowercase();
51 let mut q = 1.0f32;
52 for p in it {
53 let p = p.trim();
54 if let Some(v) = p.strip_prefix("q=") {
55 q = v.trim().parse().unwrap_or(1.0);
56 }
57 }
58 match token.as_str() {
59 "gzip" => consider(&mut best, Encoding::Gzip, q),
60 "br" => consider(&mut best, Encoding::Brotli, q),
61 "identity" => identity_q = q,
62 "*" => star_q = Some(q),
63 _ => {}
64 }
65 }
66 if best.is_none()
70 && let Some(sq) = star_q
71 && sq >= identity_q
72 {
73 best = Some((Encoding::Brotli, sq));
74 }
75 best.filter(|(_, q)| *q > 0.0).map(|(e, _)| e)
76}
77
78fn consider(best: &mut Option<(Encoding, f32)>, enc: Encoding, q: f32) {
79 if q <= 0.0 {
80 return;
81 }
82 let better = match best {
87 None => true,
88 Some((cur, bq)) => {
89 q > *bq || (q == *bq && *cur == Encoding::Gzip && enc == Encoding::Brotli)
90 }
91 };
92 if better {
93 *best = Some((enc, q));
94 }
95}
96
97pub fn compress(data: &[u8], enc: Encoding) -> Vec<u8> {
102 match enc {
103 Encoding::Gzip => {
104 use flate2::write::GzEncoder;
105 use std::io::Write as _;
106 let mut e = GzEncoder::new(Vec::new(), flate2::Compression::default());
107 if e.write_all(data).is_err() {
109 return data.to_vec();
110 }
111 e.finish().unwrap_or_else(|_| data.to_vec())
112 }
113 Encoding::Brotli => {
114 use std::io::Write as _;
115 let mut out = Vec::with_capacity(data.len() / 4 + 64);
116 {
117 let mut w = brotli::CompressorWriter::new(&mut out, 4096, 5, 22);
118 if w.write_all(data).is_err() {
119 return data.to_vec();
120 }
121 }
122 out
123 }
124 }
125}
126
127pub fn etag_for(body: &[u8]) -> String {
130 use sha2::{Digest as _, Sha256};
131 let d = Sha256::digest(body);
132 format!("\"{}\"", hex::encode(&d[..16]))
133}
134
135pub fn if_none_match_matches(header_value: Option<&str>, etag: &str) -> bool {
139 let Some(hv) = header_value else { return false };
140 let hv = hv.trim();
141 if hv == "*" {
142 return true;
143 }
144 hv.split(',').any(|t| t.trim() == etag)
145}
146
147pub fn is_compressible(content_type: &str) -> bool {
151 let ct = content_type.to_ascii_lowercase();
152 if ct.starts_with("text/event-stream") {
153 return false; }
155 (ct.starts_with("application/json")
156 || ct.starts_with("text/")
157 || ct.contains("javascript")
158 || ct.contains("svg"))
159 && !ct.starts_with("image/")
160}
161
162pub fn encode_response(
166 status: axum::http::StatusCode,
167 content_type: &str,
168 identity: bytes::Bytes,
169 identity_etag: Option<&str>,
170 accept_encoding: Option<&str>,
171 if_none_match: Option<&str>,
172 extra: &[(&str, &str)],
173) -> Response {
174 if let (Some(etag), Some(inm)) = (identity_etag, if_none_match)
176 && if_none_match_matches(Some(inm), etag)
177 {
178 let mut b = Response::builder()
179 .status(axum::http::StatusCode::NOT_MODIFIED)
180 .header(header::ETAG, etag);
181 for (k, v) in extra {
182 b = b.header(*k, *v);
183 }
184 return b.body(axum::body::Body::empty()).expect("static 304");
185 }
186
187 let compressible = is_compressible(content_type);
188 let chosen = if compressible {
189 negotiate(accept_encoding)
190 } else {
191 None
192 };
193
194 let mut builder = Response::builder().status(status);
195 for (k, v) in extra {
196 builder = builder.header(*k, *v);
197 }
198 builder = builder.header(header::CONTENT_TYPE, content_type);
199 if let Some(etag) = identity_etag {
200 builder = builder.header(header::ETAG, etag);
201 }
202 if compressible {
205 builder = builder.header(header::VARY, "Accept-Encoding");
206 }
207
208 match chosen {
209 Some(enc) => {
210 let compressed = compress(&identity, enc);
211 builder
212 .header(header::CONTENT_ENCODING, enc.token())
213 .body(axum::body::Body::from(compressed))
214 .expect("static response")
215 }
216 None => builder
217 .body(axum::body::Body::from(identity))
218 .expect("static response"),
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn negotiate_q_values_and_identity() {
228 assert_eq!(negotiate(None), None);
229 assert_eq!(negotiate(Some("")), None);
230 assert_eq!(negotiate(Some("gzip")), Some(Encoding::Gzip));
231 assert_eq!(negotiate(Some("br")), Some(Encoding::Brotli));
232 assert_eq!(negotiate(Some("br, gzip")), Some(Encoding::Brotli));
234 assert_eq!(
235 negotiate(Some("gzip, deflate, br, zstd")),
236 Some(Encoding::Brotli)
237 );
238 assert_eq!(
240 negotiate(Some("br;q=0.5, gzip;q=1.0")),
241 Some(Encoding::Gzip)
242 );
243 assert_eq!(
245 negotiate(Some("br;q=0.5, identity;q=1.0")),
246 Some(Encoding::Brotli)
247 );
248 assert_eq!(negotiate(Some("gzip;q=0, br;q=0")), None);
250 assert_eq!(negotiate(Some("*")), Some(Encoding::Brotli));
252 assert_eq!(negotiate(Some("deflate, zstd")), None);
254 }
255
256 #[test]
257 fn compress_roundtrips_shapes() {
258 let data = b"hello hello hello world world world".repeat(50);
259 let gz = compress(&data, Encoding::Gzip);
260 let br = compress(&data, Encoding::Brotli);
261 assert!(gz.len() < data.len());
262 assert!(br.len() < data.len());
263 assert_eq!(&gz[..2], &[0x1f, 0x8b]);
265 use std::io::Read as _;
267 let mut d = flate2::read::GzDecoder::new(&gz[..]);
268 let mut out = Vec::new();
269 d.read_to_end(&mut out).unwrap();
270 assert_eq!(out, data);
271 }
272
273 #[test]
274 fn etag_stable_and_inm() {
275 let a = etag_for(b"abc");
276 assert_eq!(a, etag_for(b"abc"));
277 assert_ne!(a, etag_for(b"abd"));
278 assert!(a.starts_with('"') && a.ends_with('"'));
279 assert!(if_none_match_matches(Some(&a), &a));
280 assert!(if_none_match_matches(Some("*"), &a));
281 assert!(if_none_match_matches(Some(&format!("junk, {a}")), &a));
282 assert!(!if_none_match_matches(Some("\"other\""), &a));
283 assert!(!if_none_match_matches(None, &a));
284 }
285
286 #[test]
287 fn sse_never_compressible() {
288 assert!(!is_compressible("text/event-stream"));
289 assert!(is_compressible("application/json"));
290 assert!(is_compressible("text/html;charset=UTF-8"));
291 assert!(is_compressible("text/javascript"));
292 assert!(!is_compressible("image/png"));
293 }
294
295 #[test]
296 fn encode_response_identity_is_byte_identical() {
297 let body = bytes::Bytes::from_static(b"{\"a\":1}");
300 let resp = encode_response(
301 axum::http::StatusCode::OK,
302 "application/json",
303 body.clone(),
304 None,
305 None,
306 None,
307 &[],
308 );
309 assert!(resp.headers().get(header::CONTENT_ENCODING).is_none());
310 assert_eq!(resp.headers().get(header::VARY).unwrap(), "Accept-Encoding");
312 }
313
314 #[test]
315 fn encode_response_compresses_when_asked() {
316 let body = bytes::Bytes::from_static(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
317 let resp = encode_response(
318 axum::http::StatusCode::OK,
319 "application/json",
320 body,
321 Some("\"etag\""),
322 Some("br"),
323 None,
324 &[],
325 );
326 assert_eq!(resp.headers().get(header::CONTENT_ENCODING).unwrap(), "br");
327 assert_eq!(resp.headers().get(header::ETAG).unwrap(), "\"etag\"");
328 }
329
330 #[test]
331 fn encode_response_304_short_circuit() {
332 let body = bytes::Bytes::from_static(b"{\"a\":1}");
333 let resp = encode_response(
334 axum::http::StatusCode::OK,
335 "application/json",
336 body,
337 Some("\"abc\""),
338 None,
339 Some("\"abc\""),
340 &[],
341 );
342 assert_eq!(resp.status(), axum::http::StatusCode::NOT_MODIFIED);
343 }
344}