Skip to main content

featherbit/plugins/native/
brotli.rs

1//! The `brotli` node — compresses the response body with brotli when the
2//! client accepts it and the response matches the configured content types
3//! and minimum size. Port of APISIX's `brotli` plugin (response-phase: place
4//! after `upstream`, before `client`).
5//!
6//! Shares its gating helpers (`Accept-Encoding` parsing, content-type
7//! matching, already-encoded detection) with the [`gzip`](super::gzip) node;
8//! featherbit performs the `Accept-Encoding` check explicitly since there is
9//! no nginx below the gateway to do it.
10//!
11//! Deviations from APISIX:
12//! - `mode`, `lgwin`, `lgblock`, and `http_version` are not supported —
13//!   [`content_codec`] uses generic mode with its own window size, and
14//!   responses are fully buffered.
15//! - `min_length` is compared against the actual buffered body length, not
16//!   the upstream `Content-Length` header.
17
18use 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
26/// Compresses `Context.response.body` with brotli, sets
27/// `content-encoding: br`, drops the stale `content-length`, and downgrades
28/// a strong `ETag` to a weak one (as APISIX does — the compressed bytes are
29/// not byte-identical to the original representation). Skipped (pure
30/// passthrough) when the client does not accept `br`, the response is
31/// already content-encoded, the content type does not match, or the body is
32/// shorter than `min_length`. This plugin never fails at execution time — a
33/// codec error logs a warning and leaves the response untouched.
34pub struct BrotliPlugin {
35    types: ContentTypes,
36    min_length: usize,
37    comp_level: u32,
38    vary: bool,
39}
40
41/// Mirrors APISIX's `weak_etag_header`: a strong quoted etag (`"abc"`)
42/// becomes weak (`W/"abc"`), a weak etag is kept, and a non-standard
43/// (unquoted) etag is dropped since it cannot be weakened.
44fn 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; // already weak
57    }
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    /// Builds the plugin from node config.
69    ///
70    /// Accepted keys (all optional):
71    /// - `types` (array of content types, or the string `"*"` for any;
72    ///   default `["text/html"]`): response content types to compress. The
73    ///   response `content-type` is compared with parameters (`;charset=...`)
74    ///   stripped; a response without a content type is never compressed.
75    /// - `min_length` (integer ≥ 1, default `20`): bodies shorter than this
76    ///   are not compressed.
77    /// - `comp_level` (integer 0-11, default `6`): brotli quality level
78    ///   (matches ngx_brotli's `brotli_comp_level` default).
79    /// - `vary` (bool, default `false`): when true, appends
80    ///   `Vary: Accept-Encoding` to compressed responses.
81    ///
82    /// ```yaml
83    /// type: brotli
84    /// config:
85    ///   types: ["text/html", "application/json"]
86    ///   min_length: 20
87    ///   comp_level: 6
88    ///   vary: true
89    /// ```
90    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                    // Body-mutation convention: recompute length, declare the
152                    // new encoding.
153                    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        // But a wildcard accept matches.
255        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        // Strong etag downgraded to weak.
308        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        // Weak etag kept as-is.
320        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        // Non-standard (unquoted) etag dropped.
332        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        // comp_level 0 is valid for brotli (unlike gzip).
352        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}