Skip to main content

featherbit/plugins/native/
gzip.rs

1//! The `gzip` node — compresses the response body with gzip when the client
2//! accepts it and the response matches the configured content types and
3//! minimum size. Port of APISIX's `gzip` plugin (response-phase: place after
4//! `upstream`, before `client`).
5//!
6//! In APISIX the plugin only flips nginx's gzip switches and nginx does the
7//! actual work — including checking the request's `Accept-Encoding` header
8//! below APISIX. featherbit has no nginx underneath, so this node performs
9//! the `Accept-Encoding` check and the compression itself (via
10//! [`content_codec`]).
11//!
12//! Deviations from APISIX:
13//! - `http_version` and `buffers` are not supported (nginx tuning knobs with
14//!   no featherbit equivalent — responses are fully buffered).
15//! - `min_length` is compared against the actual buffered body length, not
16//!   the upstream `Content-Length` header (which APISIX/nginx skip checking
17//!   when absent).
18
19use async_trait::async_trait;
20use std::collections::HashMap;
21
22use crate::context::Context;
23use crate::plugins::util::content_codec::{self, ContentEncoding};
24use crate::plugins::{Plugin, PluginOutput, PluginResult};
25
26/// Compresses `Context.response.body` with gzip, sets
27/// `content-encoding: gzip`, and drops the stale `content-length` so the
28/// server layer recomputes it. Skipped (pure passthrough) when the client
29/// does not accept gzip, the response is already content-encoded, the
30/// content type does not match, or the body is shorter than `min_length`.
31/// This plugin never fails at execution time — a codec error logs a warning
32/// and leaves the response untouched.
33pub struct GzipPlugin {
34    types: ContentTypes,
35    min_length: usize,
36    comp_level: u32,
37    vary: bool,
38}
39
40/// The `types` config: either any content type (`"*"`) or an allowlist
41/// compared against the response `content-type` stripped of parameters.
42pub(crate) enum ContentTypes {
43    Any,
44    List(Vec<String>),
45}
46
47impl ContentTypes {
48    /// Parses the APISIX `types` shape: `"*"` or a non-empty array of
49    /// non-empty strings. `None` (key absent) yields the given default list.
50    pub(crate) fn parse(raw: Option<&serde_json::Value>, default: &[&str]) -> Result<Self, String> {
51        match raw {
52            None => Ok(Self::List(default.iter().map(|s| s.to_string()).collect())),
53            Some(serde_json::Value::String(s)) if s == "*" => Ok(Self::Any),
54            Some(serde_json::Value::Array(items)) => {
55                if items.is_empty() {
56                    return Err("types must contain at least one content type".to_string());
57                }
58                let list = items
59                    .iter()
60                    .map(|v| {
61                        v.as_str()
62                            .filter(|s| !s.is_empty())
63                            .map(String::from)
64                            .ok_or("types entries must be non-empty strings".to_string())
65                    })
66                    .collect::<Result<Vec<_>, _>>()?;
67                Ok(Self::List(list))
68            }
69            Some(_) => Err("types must be \"*\" or an array of content types".to_string()),
70        }
71    }
72
73    /// Whether the response `content-type` matches. As in APISIX, a missing
74    /// content type never matches, and the value is compared with anything
75    /// from `;` onward (e.g. `;charset=utf-8`) stripped.
76    pub(crate) fn matches(&self, ctx: &Context) -> bool {
77        let Some(content_type) = ctx
78            .response
79            .headers
80            .get("content-type")
81            .and_then(|v| v.first())
82        else {
83            return false;
84        };
85        match self {
86            Self::Any => true,
87            Self::List(list) => {
88                let base = content_type.split(';').next().unwrap_or("").trim();
89                list.iter().any(|t| t == base)
90            }
91        }
92    }
93}
94
95/// Whether the request's `Accept-Encoding` allows `token` (e.g. `"gzip"`,
96/// `"br"`): the token (or `*`) must be listed with a non-zero `q` value.
97/// A missing header means the client did not opt in — no compression.
98pub(crate) fn accepts_encoding(ctx: &Context, token: &str) -> bool {
99    let Some(values) = ctx.request.headers.get("accept-encoding") else {
100        return false;
101    };
102    for value in values {
103        for part in value.split(',') {
104            let mut pieces = part.trim().splitn(2, ';');
105            let name = pieces.next().unwrap_or("").trim().to_ascii_lowercase();
106            if name != token && name != "*" {
107                continue;
108            }
109            let q_is_zero = pieces
110                .next()
111                .and_then(|params| params.trim().strip_prefix("q="))
112                .map(|q| q.trim().parse::<f32>() == Ok(0.0))
113                .unwrap_or(false);
114            if !q_is_zero {
115                return true;
116            }
117        }
118    }
119    false
120}
121
122/// Whether the response already carries a `content-encoding` other than
123/// identity. Compressing twice (or compressing opaque encoded bytes) is
124/// never wanted, whatever the encoding is — so an unknown value also counts
125/// as encoded.
126pub(crate) fn response_already_encoded(ctx: &Context) -> bool {
127    match ctx
128        .response
129        .headers
130        .get("content-encoding")
131        .and_then(|v| v.first())
132    {
133        None => false,
134        Some(value) => !matches!(ContentEncoding::parse(value), Ok(None)),
135    }
136}
137
138impl GzipPlugin {
139    /// Builds the plugin from node config.
140    ///
141    /// Accepted keys (all optional):
142    /// - `types` (array of content types, or the string `"*"` for any;
143    ///   default `["text/html"]`): response content types to compress. The
144    ///   response `content-type` is compared with parameters (`;charset=...`)
145    ///   stripped; a response without a content type is never compressed.
146    /// - `min_length` (integer ≥ 1, default `20`): bodies shorter than this
147    ///   are not compressed.
148    /// - `comp_level` (integer 1-9, default `1`): gzip compression level.
149    /// - `vary` (bool, default `false`): when true, appends
150    ///   `Vary: Accept-Encoding` to compressed responses.
151    ///
152    /// ```yaml
153    /// type: gzip
154    /// config:
155    ///   types: ["text/html", "application/json"]
156    ///   min_length: 20
157    ///   comp_level: 6
158    ///   vary: true
159    /// ```
160    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
161        let types = ContentTypes::parse(config.get("types"), &["text/html"])?;
162
163        let min_length = match config.get("min_length") {
164            None => 20,
165            Some(v) => {
166                let n = v
167                    .as_u64()
168                    .filter(|n| *n >= 1)
169                    .ok_or("min_length must be an integer >= 1".to_string())?;
170                n as usize
171            }
172        };
173
174        let comp_level = match config.get("comp_level") {
175            None => 1,
176            Some(v) => {
177                let n = v
178                    .as_u64()
179                    .ok_or("comp_level must be an integer".to_string())?;
180                if !(1..=9).contains(&n) {
181                    return Err(format!("comp_level must be within 1-9, got {}", n));
182                }
183                n as u32
184            }
185        };
186
187        let vary = config
188            .get("vary")
189            .and_then(|v| v.as_bool())
190            .unwrap_or(false);
191
192        Ok(Self {
193            types,
194            min_length,
195            comp_level,
196            vary,
197        })
198    }
199}
200
201#[async_trait]
202impl Plugin for GzipPlugin {
203    fn plugin_type(&self) -> &str {
204        "gzip"
205    }
206
207    async fn execute(
208        &self,
209        mut ctx: Context,
210        _named_inputs: &HashMap<String, serde_json::Value>,
211    ) -> PluginResult {
212        let skip = !accepts_encoding(&ctx, "gzip")
213            || response_already_encoded(&ctx)
214            || !self.types.matches(&ctx)
215            || ctx.response.body.len() < self.min_length;
216
217        if !skip {
218            match content_codec::encode(&ContentEncoding::Gzip, &ctx.response.body, self.comp_level)
219            {
220                Ok(compressed) => {
221                    ctx.response.body = compressed;
222                    // Body-mutation convention: recompute length, declare the
223                    // new encoding.
224                    ctx.response.headers.remove("content-length");
225                    ctx.response.headers.insert(
226                        "content-encoding".to_string(),
227                        vec![ContentEncoding::Gzip.header_value().to_string()],
228                    );
229                    if self.vary {
230                        ctx.response
231                            .headers
232                            .entry("vary".to_string())
233                            .or_default()
234                            .push("Accept-Encoding".to_string());
235                    }
236                }
237                Err(e) => {
238                    tracing::warn!("gzip: compression skipped: {}", e);
239                }
240            }
241        }
242
243        Ok(PluginOutput {
244            context: ctx,
245            named_outputs: HashMap::new(),
246        })
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
254    use bytes::Bytes;
255
256    pub(crate) fn compressible_context(body: &[u8], content_type: &str) -> Context {
257        let mut request_headers = HashMap::new();
258        request_headers.insert(
259            "accept-encoding".to_string(),
260            vec!["gzip, deflate, br".to_string()],
261        );
262        let mut response_headers = HashMap::new();
263        response_headers.insert("content-type".to_string(), vec![content_type.to_string()]);
264        response_headers.insert("content-length".to_string(), vec![body.len().to_string()]);
265        Context {
266            request: GatewayRequest {
267                method: "GET".to_string(),
268                path: "/test".to_string(),
269                host: "localhost".to_string(),
270                scheme: "http".to_string(),
271                headers: request_headers,
272                query_params: HashMap::new(),
273                body: Bytes::new(),
274                remote_addr: "127.0.0.1:12345".to_string(),
275                protocol: Protocol::Http1,
276            },
277            response: GatewayResponse {
278                status_code: 200,
279                headers: response_headers,
280                body: Bytes::copy_from_slice(body),
281            },
282            message: HashMap::new(),
283            errors: Vec::new(),
284        }
285    }
286
287    const BODY: &[u8] = b"<html>a body long enough to clear the default min_length</html>";
288
289    fn plugin(config: serde_json::Value) -> GzipPlugin {
290        let map: HashMap<String, serde_json::Value> =
291            serde_json::from_value(config).expect("test config must be an object");
292        GzipPlugin::from_config(&map).expect("config should be valid")
293    }
294
295    #[tokio::test]
296    async fn test_gzip_compresses_matching_response() {
297        let p = plugin(serde_json::json!({ "vary": true }));
298        let ctx = compressible_context(BODY, "text/html; charset=utf-8");
299        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
300
301        let headers = &out.context.response.headers;
302        assert_eq!(
303            headers.get("content-encoding"),
304            Some(&vec!["gzip".to_string()])
305        );
306        assert!(!headers.contains_key("content-length"));
307        assert_eq!(
308            headers.get("vary"),
309            Some(&vec!["Accept-Encoding".to_string()])
310        );
311
312        let decoded =
313            content_codec::decode(&ContentEncoding::Gzip, &out.context.response.body).unwrap();
314        assert_eq!(decoded.as_ref(), BODY);
315    }
316
317    #[tokio::test]
318    async fn test_gzip_skips_when_client_does_not_accept() {
319        let p = plugin(serde_json::json!({}));
320        let mut ctx = compressible_context(BODY, "text/html");
321        ctx.request.headers.remove("accept-encoding");
322        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
323        assert_eq!(out.context.response.body.as_ref(), BODY);
324        assert!(!out
325            .context
326            .response
327            .headers
328            .contains_key("content-encoding"));
329
330        // Explicit q=0 also opts out.
331        let p = plugin(serde_json::json!({}));
332        let mut ctx = compressible_context(BODY, "text/html");
333        ctx.request.headers.insert(
334            "accept-encoding".to_string(),
335            vec!["gzip;q=0, br".to_string()],
336        );
337        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
338        assert!(!out
339            .context
340            .response
341            .headers
342            .contains_key("content-encoding"));
343    }
344
345    #[tokio::test]
346    async fn test_gzip_skips_already_encoded_response() {
347        let plain = Bytes::copy_from_slice(BODY);
348        let pre_encoded = content_codec::encode(&ContentEncoding::Brotli, &plain, 6).unwrap();
349
350        let p = plugin(serde_json::json!({}));
351        let mut ctx = compressible_context(&pre_encoded, "text/html");
352        ctx.response
353            .headers
354            .insert("content-encoding".to_string(), vec!["br".to_string()]);
355        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
356
357        // Untouched: still brotli, not double-compressed.
358        assert_eq!(
359            out.context.response.headers.get("content-encoding"),
360            Some(&vec!["br".to_string()])
361        );
362        assert_eq!(out.context.response.body, pre_encoded);
363        assert!(out.context.response.headers.contains_key("content-length"));
364    }
365
366    #[tokio::test]
367    async fn test_gzip_type_and_length_gates() {
368        // Non-matching content type.
369        let p = plugin(serde_json::json!({}));
370        let ctx = compressible_context(BODY, "application/json");
371        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
372        assert!(!out
373            .context
374            .response
375            .headers
376            .contains_key("content-encoding"));
377
378        // Missing content type never matches, even with types "*".
379        let p = plugin(serde_json::json!({ "types": "*" }));
380        let mut ctx = compressible_context(BODY, "text/html");
381        ctx.response.headers.remove("content-type");
382        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
383        assert!(!out
384            .context
385            .response
386            .headers
387            .contains_key("content-encoding"));
388
389        // Wildcard matches any present content type.
390        let p = plugin(serde_json::json!({ "types": "*" }));
391        let ctx = compressible_context(BODY, "application/octet-stream");
392        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
393        assert!(out
394            .context
395            .response
396            .headers
397            .contains_key("content-encoding"));
398
399        // Body below min_length.
400        let p = plugin(serde_json::json!({ "min_length": 1000 }));
401        let ctx = compressible_context(BODY, "text/html");
402        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
403        assert!(!out
404            .context
405            .response
406            .headers
407            .contains_key("content-encoding"));
408    }
409
410    #[test]
411    fn test_gzip_config_validation() {
412        for bad in [
413            serde_json::json!({ "comp_level": 0 }),
414            serde_json::json!({ "comp_level": 10 }),
415            serde_json::json!({ "min_length": 0 }),
416            serde_json::json!({ "types": [] }),
417            serde_json::json!({ "types": [""] }),
418            serde_json::json!({ "types": "text/html" }),
419        ] {
420            let map: HashMap<String, serde_json::Value> = serde_json::from_value(bad).unwrap();
421            assert!(GzipPlugin::from_config(&map).is_err());
422        }
423    }
424
425    #[test]
426    fn test_accepts_encoding_parsing() {
427        let mut ctx = compressible_context(BODY, "text/html");
428        let cases = [
429            ("gzip", true),
430            ("*", true),
431            ("br;q=0.8, gzip;q=0.5", true),
432            ("GZIP", true),
433            ("gzip;q=0", false),
434            ("br", false),
435            ("", false),
436            ("*;q=0", false),
437        ];
438        for (header, expected) in cases {
439            ctx.request
440                .headers
441                .insert("accept-encoding".to_string(), vec![header.to_string()]);
442            assert_eq!(
443                accepts_encoding(&ctx, "gzip"),
444                expected,
445                "header {header:?}"
446            );
447        }
448    }
449}