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(
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                    // Body-mutation convention: recompute length, declare the
156                    // new encoding.
157                    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        // But a wildcard accept matches.
261        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        // Strong etag downgraded to weak.
314        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        // Weak etag kept as-is.
326        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        // Non-standard (unquoted) etag dropped.
338        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        // comp_level 0 is valid for brotli (unlike gzip).
358        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}