featherbit/plugins/native/
gzip.rs1use 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
26pub struct GzipPlugin {
34 types: ContentTypes,
35 min_length: usize,
36 comp_level: u32,
37 vary: bool,
38}
39
40pub(crate) enum ContentTypes {
43 Any,
44 List(Vec<String>),
45}
46
47impl ContentTypes {
48 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 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
95pub(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
122pub(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 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 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 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 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 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 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 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 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}