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(
138 &self,
139 mut ctx: Context,
140 _named_inputs: &HashMap<String, serde_json::Value>,
141 ) -> PluginResult {
142 let skip = !accepts_encoding(&ctx, "br")
143 || response_already_encoded(&ctx)
144 || !self.types.matches(&ctx)
145 || ctx.response.body.len() < self.min_length;
146
147 if !skip {
148 match content_codec::encode(
149 &ContentEncoding::Brotli,
150 &ctx.response.body,
151 self.comp_level,
152 ) {
153 Ok(compressed) => {
154 ctx.response.body = compressed;
155 ctx.response.headers.remove("content-length");
158 ctx.response.headers.insert(
159 "content-encoding".to_string(),
160 vec![ContentEncoding::Brotli.header_value().to_string()],
161 );
162 weaken_etag(&mut ctx);
163 if self.vary {
164 ctx.response
165 .headers
166 .entry("vary".to_string())
167 .or_default()
168 .push("Accept-Encoding".to_string());
169 }
170 }
171 Err(e) => {
172 tracing::warn!("brotli: compression skipped: {}", e);
173 }
174 }
175 }
176
177 Ok(PluginOutput {
178 context: ctx,
179 named_outputs: HashMap::new(),
180 })
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
188 use bytes::Bytes;
189
190 const BODY: &[u8] = b"<html>a body long enough to clear the default min_length</html>";
191
192 fn test_context(body: &[u8], content_type: &str, accept: &str) -> Context {
193 let mut request_headers = HashMap::new();
194 request_headers.insert("accept-encoding".to_string(), vec![accept.to_string()]);
195 let mut response_headers = HashMap::new();
196 response_headers.insert("content-type".to_string(), vec![content_type.to_string()]);
197 response_headers.insert("content-length".to_string(), vec![body.len().to_string()]);
198 Context {
199 request: GatewayRequest {
200 method: "GET".to_string(),
201 path: "/test".to_string(),
202 host: "localhost".to_string(),
203 scheme: "http".to_string(),
204 headers: request_headers,
205 query_params: HashMap::new(),
206 body: Bytes::new(),
207 remote_addr: "127.0.0.1:12345".to_string(),
208 protocol: Protocol::Http1,
209 },
210 response: GatewayResponse {
211 status_code: 200,
212 headers: response_headers,
213 body: Bytes::copy_from_slice(body),
214 },
215 message: HashMap::new(),
216 errors: Vec::new(),
217 }
218 }
219
220 fn plugin(config: serde_json::Value) -> BrotliPlugin {
221 let map: HashMap<String, serde_json::Value> =
222 serde_json::from_value(config).expect("test config must be an object");
223 BrotliPlugin::from_config(&map).expect("config should be valid")
224 }
225
226 #[tokio::test]
227 async fn test_brotli_compresses_matching_response() {
228 let p = plugin(serde_json::json!({ "vary": true }));
229 let ctx = test_context(BODY, "text/html; charset=utf-8", "gzip, br");
230 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
231
232 let headers = &out.context.response.headers;
233 assert_eq!(
234 headers.get("content-encoding"),
235 Some(&vec!["br".to_string()])
236 );
237 assert!(!headers.contains_key("content-length"));
238 assert_eq!(
239 headers.get("vary"),
240 Some(&vec!["Accept-Encoding".to_string()])
241 );
242
243 let decoded =
244 content_codec::decode(&ContentEncoding::Brotli, &out.context.response.body).unwrap();
245 assert_eq!(decoded.as_ref(), BODY);
246 }
247
248 #[tokio::test]
249 async fn test_brotli_skips_when_client_does_not_accept_br() {
250 let p = plugin(serde_json::json!({}));
251 let ctx = test_context(BODY, "text/html", "gzip, deflate");
252 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
253 assert_eq!(out.context.response.body.as_ref(), BODY);
254 assert!(!out
255 .context
256 .response
257 .headers
258 .contains_key("content-encoding"));
259
260 let p = plugin(serde_json::json!({}));
262 let ctx = test_context(BODY, "text/html", "*");
263 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
264 assert_eq!(
265 out.context.response.headers.get("content-encoding"),
266 Some(&vec!["br".to_string()])
267 );
268 }
269
270 #[tokio::test]
271 async fn test_brotli_skips_already_encoded_response() {
272 let plain = Bytes::copy_from_slice(BODY);
273 let pre_encoded = content_codec::encode(&ContentEncoding::Gzip, &plain, 6).unwrap();
274
275 let p = plugin(serde_json::json!({}));
276 let mut ctx = test_context(&pre_encoded, "text/html", "br");
277 ctx.response
278 .headers
279 .insert("content-encoding".to_string(), vec!["gzip".to_string()]);
280 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
281
282 assert_eq!(
283 out.context.response.headers.get("content-encoding"),
284 Some(&vec!["gzip".to_string()])
285 );
286 assert_eq!(out.context.response.body, pre_encoded);
287 assert!(out.context.response.headers.contains_key("content-length"));
288 }
289
290 #[tokio::test]
291 async fn test_brotli_type_and_length_gates() {
292 let p = plugin(serde_json::json!({}));
293 let ctx = test_context(BODY, "application/json", "br");
294 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
295 assert!(!out
296 .context
297 .response
298 .headers
299 .contains_key("content-encoding"));
300
301 let p = plugin(serde_json::json!({ "min_length": 1000 }));
302 let ctx = test_context(BODY, "text/html", "br");
303 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
304 assert!(!out
305 .context
306 .response
307 .headers
308 .contains_key("content-encoding"));
309 }
310
311 #[tokio::test]
312 async fn test_brotli_etag_handling() {
313 let p = plugin(serde_json::json!({}));
315 let mut ctx = test_context(BODY, "text/html", "br");
316 ctx.response
317 .headers
318 .insert("etag".to_string(), vec!["\"abc123\"".to_string()]);
319 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
320 assert_eq!(
321 out.context.response.headers.get("etag"),
322 Some(&vec!["W/\"abc123\"".to_string()])
323 );
324
325 let p = plugin(serde_json::json!({}));
327 let mut ctx = test_context(BODY, "text/html", "br");
328 ctx.response
329 .headers
330 .insert("etag".to_string(), vec!["W/\"abc123\"".to_string()]);
331 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
332 assert_eq!(
333 out.context.response.headers.get("etag"),
334 Some(&vec!["W/\"abc123\"".to_string()])
335 );
336
337 let p = plugin(serde_json::json!({}));
339 let mut ctx = test_context(BODY, "text/html", "br");
340 ctx.response
341 .headers
342 .insert("etag".to_string(), vec!["abc123".to_string()]);
343 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
344 assert!(!out.context.response.headers.contains_key("etag"));
345 }
346
347 #[test]
348 fn test_brotli_config_validation() {
349 for bad in [
350 serde_json::json!({ "comp_level": 12 }),
351 serde_json::json!({ "min_length": 0 }),
352 serde_json::json!({ "types": [] }),
353 ] {
354 let map: HashMap<String, serde_json::Value> = serde_json::from_value(bad).unwrap();
355 assert!(BrotliPlugin::from_config(&map).is_err());
356 }
357 let map: HashMap<String, serde_json::Value> =
359 serde_json::from_value(serde_json::json!({ "comp_level": 0 })).unwrap();
360 assert!(BrotliPlugin::from_config(&map).is_ok());
361 }
362}