featherbit/plugins/native/
brotli.rs1use async_trait::async_trait;
19use std::collections::HashMap;
20
21use crate::context::Context;
22use crate::plugins::native::gzip::{accepts_encoding, response_already_encoded, ContentTypes};
23use crate::plugins::util::content_codec::{self, ContentEncoding};
24use crate::plugins::{Plugin, PluginOutput, PluginResult};
25
26pub struct BrotliPlugin {
35 types: ContentTypes,
36 min_length: usize,
37 comp_level: u32,
38 vary: bool,
39}
40
41fn weaken_etag(ctx: &mut Context) {
45 let Some(etag) = ctx
46 .response
47 .headers
48 .get("etag")
49 .and_then(|v| v.first())
50 .cloned()
51 else {
52 return;
53 };
54
55 if etag.starts_with("W/") {
56 return; }
58 if etag.len() >= 2 && etag.starts_with('"') && etag.ends_with('"') {
59 ctx.response
60 .headers
61 .insert("etag".to_string(), vec![format!("W/{}", etag)]);
62 } else {
63 ctx.response.headers.remove("etag");
64 }
65}
66
67impl BrotliPlugin {
68 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
91 let types = ContentTypes::parse(config.get("types"), &["text/html"])?;
92
93 let min_length = match config.get("min_length") {
94 None => 20,
95 Some(v) => {
96 let n = v
97 .as_u64()
98 .filter(|n| *n >= 1)
99 .ok_or("min_length must be an integer >= 1".to_string())?;
100 n as usize
101 }
102 };
103
104 let comp_level = match config.get("comp_level") {
105 None => 6,
106 Some(v) => {
107 let n = v
108 .as_u64()
109 .ok_or("comp_level must be an integer".to_string())?;
110 if n > 11 {
111 return Err(format!("comp_level must be within 0-11, got {}", n));
112 }
113 n as u32
114 }
115 };
116
117 let vary = config
118 .get("vary")
119 .and_then(|v| v.as_bool())
120 .unwrap_or(false);
121
122 Ok(Self {
123 types,
124 min_length,
125 comp_level,
126 vary,
127 })
128 }
129}
130
131#[async_trait]
132impl Plugin for BrotliPlugin {
133 fn plugin_type(&self) -> &str {
134 "brotli"
135 }
136
137 async fn execute(&self, mut ctx: Context) -> PluginResult {
138 let skip = !accepts_encoding(&ctx, "br")
139 || response_already_encoded(&ctx)
140 || !self.types.matches(&ctx)
141 || ctx.response.body.len() < self.min_length;
142
143 if !skip {
144 match content_codec::encode(
145 &ContentEncoding::Brotli,
146 &ctx.response.body,
147 self.comp_level,
148 ) {
149 Ok(compressed) => {
150 ctx.response.body = compressed;
151 ctx.response.headers.remove("content-length");
154 ctx.response.headers.insert(
155 "content-encoding".to_string(),
156 vec![ContentEncoding::Brotli.header_value().to_string()],
157 );
158 weaken_etag(&mut ctx);
159 if self.vary {
160 ctx.response
161 .headers
162 .entry("vary".to_string())
163 .or_default()
164 .push("Accept-Encoding".to_string());
165 }
166 }
167 Err(e) => {
168 tracing::warn!("brotli: compression skipped: {}", e);
169 }
170 }
171 }
172
173 Ok(PluginOutput::success(ctx))
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
181 use bytes::Bytes;
182
183 const BODY: &[u8] = b"<html>a body long enough to clear the default min_length</html>";
184
185 fn test_context(body: &[u8], content_type: &str, accept: &str) -> Context {
186 let mut request_headers = HashMap::new();
187 request_headers.insert("accept-encoding".to_string(), vec![accept.to_string()]);
188 let mut response_headers = HashMap::new();
189 response_headers.insert("content-type".to_string(), vec![content_type.to_string()]);
190 response_headers.insert("content-length".to_string(), vec![body.len().to_string()]);
191 Context {
192 request: GatewayRequest {
193 method: "GET".to_string(),
194 path: "/test".to_string(),
195 host: "localhost".to_string(),
196 scheme: "http".to_string(),
197 headers: request_headers,
198 query_params: HashMap::new(),
199 body: Bytes::new(),
200 remote_addr: "127.0.0.1:12345".to_string(),
201 protocol: Protocol::Http1,
202 },
203 response: GatewayResponse {
204 status_code: 200,
205 headers: response_headers,
206 body: Bytes::copy_from_slice(body),
207 stream: None,
208 },
209 message: HashMap::new(),
210 errors: Vec::new(),
211 }
212 }
213
214 fn plugin(config: serde_json::Value) -> BrotliPlugin {
215 let map: HashMap<String, serde_json::Value> =
216 serde_json::from_value(config).expect("test config must be an object");
217 BrotliPlugin::from_config(&map).expect("config should be valid")
218 }
219
220 #[tokio::test]
221 async fn test_brotli_compresses_matching_response() {
222 let p = plugin(serde_json::json!({ "vary": true }));
223 let ctx = test_context(BODY, "text/html; charset=utf-8", "gzip, br");
224 let out = p.execute(ctx).await.unwrap();
225
226 let headers = &out.context.response.headers;
227 assert_eq!(
228 headers.get("content-encoding"),
229 Some(&vec!["br".to_string()])
230 );
231 assert!(!headers.contains_key("content-length"));
232 assert_eq!(
233 headers.get("vary"),
234 Some(&vec!["Accept-Encoding".to_string()])
235 );
236
237 let decoded =
238 content_codec::decode(&ContentEncoding::Brotli, &out.context.response.body).unwrap();
239 assert_eq!(decoded.as_ref(), BODY);
240 }
241
242 #[tokio::test]
243 async fn test_brotli_skips_when_client_does_not_accept_br() {
244 let p = plugin(serde_json::json!({}));
245 let ctx = test_context(BODY, "text/html", "gzip, deflate");
246 let out = p.execute(ctx).await.unwrap();
247 assert_eq!(out.context.response.body.as_ref(), BODY);
248 assert!(!out
249 .context
250 .response
251 .headers
252 .contains_key("content-encoding"));
253
254 let p = plugin(serde_json::json!({}));
256 let ctx = test_context(BODY, "text/html", "*");
257 let out = p.execute(ctx).await.unwrap();
258 assert_eq!(
259 out.context.response.headers.get("content-encoding"),
260 Some(&vec!["br".to_string()])
261 );
262 }
263
264 #[tokio::test]
265 async fn test_brotli_skips_already_encoded_response() {
266 let plain = Bytes::copy_from_slice(BODY);
267 let pre_encoded = content_codec::encode(&ContentEncoding::Gzip, &plain, 6).unwrap();
268
269 let p = plugin(serde_json::json!({}));
270 let mut ctx = test_context(&pre_encoded, "text/html", "br");
271 ctx.response
272 .headers
273 .insert("content-encoding".to_string(), vec!["gzip".to_string()]);
274 let out = p.execute(ctx).await.unwrap();
275
276 assert_eq!(
277 out.context.response.headers.get("content-encoding"),
278 Some(&vec!["gzip".to_string()])
279 );
280 assert_eq!(out.context.response.body, pre_encoded);
281 assert!(out.context.response.headers.contains_key("content-length"));
282 }
283
284 #[tokio::test]
285 async fn test_brotli_type_and_length_gates() {
286 let p = plugin(serde_json::json!({}));
287 let ctx = test_context(BODY, "application/json", "br");
288 let out = p.execute(ctx).await.unwrap();
289 assert!(!out
290 .context
291 .response
292 .headers
293 .contains_key("content-encoding"));
294
295 let p = plugin(serde_json::json!({ "min_length": 1000 }));
296 let ctx = test_context(BODY, "text/html", "br");
297 let out = p.execute(ctx).await.unwrap();
298 assert!(!out
299 .context
300 .response
301 .headers
302 .contains_key("content-encoding"));
303 }
304
305 #[tokio::test]
306 async fn test_brotli_etag_handling() {
307 let p = plugin(serde_json::json!({}));
309 let mut ctx = test_context(BODY, "text/html", "br");
310 ctx.response
311 .headers
312 .insert("etag".to_string(), vec!["\"abc123\"".to_string()]);
313 let out = p.execute(ctx).await.unwrap();
314 assert_eq!(
315 out.context.response.headers.get("etag"),
316 Some(&vec!["W/\"abc123\"".to_string()])
317 );
318
319 let p = plugin(serde_json::json!({}));
321 let mut ctx = test_context(BODY, "text/html", "br");
322 ctx.response
323 .headers
324 .insert("etag".to_string(), vec!["W/\"abc123\"".to_string()]);
325 let out = p.execute(ctx).await.unwrap();
326 assert_eq!(
327 out.context.response.headers.get("etag"),
328 Some(&vec!["W/\"abc123\"".to_string()])
329 );
330
331 let p = plugin(serde_json::json!({}));
333 let mut ctx = test_context(BODY, "text/html", "br");
334 ctx.response
335 .headers
336 .insert("etag".to_string(), vec!["abc123".to_string()]);
337 let out = p.execute(ctx).await.unwrap();
338 assert!(!out.context.response.headers.contains_key("etag"));
339 }
340
341 #[test]
342 fn test_brotli_config_validation() {
343 for bad in [
344 serde_json::json!({ "comp_level": 12 }),
345 serde_json::json!({ "min_length": 0 }),
346 serde_json::json!({ "types": [] }),
347 ] {
348 let map: HashMap<String, serde_json::Value> = serde_json::from_value(bad).unwrap();
349 assert!(BrotliPlugin::from_config(&map).is_err());
350 }
351 let map: HashMap<String, serde_json::Value> =
353 serde_json::from_value(serde_json::json!({ "comp_level": 0 })).unwrap();
354 assert!(BrotliPlugin::from_config(&map).is_ok());
355 }
356}