featherbit/plugins/native/
proxy_rewrite.rs1use async_trait::async_trait;
5use std::collections::HashMap;
6
7use crate::context::Context;
8use crate::plugins::{Plugin, PluginOutput, PluginResult};
9use crate::vars::template::Template;
10
11pub struct ProxyRewritePlugin {
22 strip_path_prefix: Option<String>,
23 add_path_prefix: Option<Template>,
24 add_headers: HashMap<String, Template>,
25 remove_headers: Vec<String>,
26 phase: RewritePhase,
27}
28
29#[derive(Debug, Clone, PartialEq)]
31enum RewritePhase {
32 Request,
33 Response,
34}
35
36fn header_value_to_string(v: &serde_json::Value) -> Option<String> {
39 match v {
40 serde_json::Value::String(s) => Some(s.clone()),
41 serde_json::Value::Number(n) => Some(n.to_string()),
42 serde_json::Value::Bool(b) => Some(b.to_string()),
43 _ => None,
44 }
45}
46
47fn parse_add_headers(
57 config: &HashMap<String, serde_json::Value>,
58) -> Result<HashMap<String, Template>, String> {
59 let Some(raw) = config.get("add_headers") else {
60 return Ok(HashMap::new());
61 };
62
63 match raw {
64 serde_json::Value::Object(m) => m
65 .iter()
66 .map(|(k, v)| {
67 header_value_to_string(v)
68 .map(|s| (k.clone(), Template::parse(&s).0))
69 .ok_or_else(|| format!("add_headers['{}'] must be a scalar value", k))
70 })
71 .collect(),
72 serde_json::Value::Array(items) => {
73 let mut headers = HashMap::new();
74 for item in items {
75 let obj = item.as_object().ok_or_else(|| {
76 "add_headers entries must be objects with 'name' and 'value'".to_string()
77 })?;
78 let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
79 if name.trim().is_empty() {
80 continue;
82 }
83 let value = match obj.get("value") {
84 None => String::new(),
85 Some(v) => header_value_to_string(v)
86 .ok_or_else(|| format!("add_headers['{}'] must be a scalar value", name))?,
87 };
88 headers.insert(name.to_string(), Template::parse(&value).0);
89 }
90 Ok(headers)
91 }
92 _ => Err(
93 "add_headers must be a map of name: value or a list of {name, value} objects"
94 .to_string(),
95 ),
96 }
97}
98
99impl ProxyRewritePlugin {
100 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
125 if config.contains_key("headers") {
129 return Err(
130 "proxy-rewrite has no 'headers' key (that is response-rewrite's schema); \
131 use add_headers: { name: value } and remove_headers: [name]"
132 .to_string(),
133 );
134 }
135
136 let phase = match config.get("phase").and_then(|v| v.as_str()) {
137 Some("response") => RewritePhase::Response,
138 _ => RewritePhase::Request,
139 };
140
141 let strip_path_prefix = config
142 .get("strip_path_prefix")
143 .and_then(|v| v.as_str())
144 .map(String::from);
145
146 let add_path_prefix = config
147 .get("add_path_prefix")
148 .and_then(|v| v.as_str())
149 .map(|s| Template::parse(s).0);
150
151 let add_headers = parse_add_headers(config)?;
152
153 let remove_headers = config
154 .get("remove_headers")
155 .and_then(|v| v.as_array())
156 .map(|seq| {
157 seq.iter()
158 .filter_map(|v| v.as_str().map(String::from))
159 .collect()
160 })
161 .unwrap_or_default();
162
163 Ok(Self {
164 strip_path_prefix,
165 add_path_prefix,
166 add_headers,
167 remove_headers,
168 phase,
169 })
170 }
171}
172
173#[async_trait]
174impl Plugin for ProxyRewritePlugin {
175 fn plugin_type(&self) -> &str {
176 "proxy-rewrite"
177 }
178
179 fn reads_response_body(&self) -> bool {
180 self.phase == RewritePhase::Response
185 && self
186 .add_headers
187 .values()
188 .any(|t| t.references_response_body())
189 }
190
191 async fn execute(&self, mut ctx: Context) -> PluginResult {
192 match self.phase {
193 RewritePhase::Request => {
194 if let Some(ref prefix) = self.strip_path_prefix {
196 if ctx.request.path.starts_with(prefix.as_str()) {
197 let new_path = ctx.request.path[prefix.len()..].to_string();
198 ctx.request.path = if new_path.is_empty() || !new_path.starts_with('/') {
199 format!("/{}", new_path.trim_start_matches('/'))
200 } else {
201 new_path
202 };
203 }
204 }
205
206 if let Some(ref prefix) = self.add_path_prefix {
208 let rendered = prefix.render(&ctx).into_owned();
209 ctx.request.path = format!("{}{}", rendered, ctx.request.path);
210 }
211
212 for (key, tpl) in &self.add_headers {
214 let value = tpl.render(&ctx).into_owned();
215 ctx.request
216 .headers
217 .entry(key.to_lowercase())
218 .or_default()
219 .push(value);
220 }
221
222 for key in &self.remove_headers {
224 crate::plugins::util::headers::remove_ci(&mut ctx.request.headers, key);
225 }
226 }
227 RewritePhase::Response => {
228 for (key, tpl) in &self.add_headers {
230 let value = tpl.render(&ctx).into_owned();
231 ctx.response
232 .headers
233 .entry(key.to_lowercase())
234 .or_default()
235 .push(value);
236 }
237
238 for key in &self.remove_headers {
240 crate::plugins::util::headers::remove_ci(&mut ctx.response.headers, key);
241 }
242 }
243 }
244
245 Ok(PluginOutput::success(ctx))
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
253 use bytes::Bytes;
254
255 fn test_context(path: &str) -> Context {
256 Context {
257 request: GatewayRequest {
258 method: "GET".to_string(),
259 path: path.to_string(),
260 host: "localhost".to_string(),
261 scheme: "http".to_string(),
262 headers: HashMap::new(),
263 query_params: HashMap::new(),
264 body: Bytes::new(),
265 remote_addr: "127.0.0.1:12345".to_string(),
266 protocol: Protocol::Http1,
267 },
268 response: GatewayResponse {
269 status_code: 0,
270 headers: HashMap::new(),
271 body: Bytes::new(),
272 stream: None,
273 },
274 message: HashMap::new(),
275 errors: Vec::new(),
276 }
277 }
278
279 #[tokio::test]
280 async fn test_strip_path_prefix() {
281 let mut config = HashMap::new();
282 config.insert(
283 "strip_path_prefix".to_string(),
284 serde_json::Value::String("/api/v1".to_string()),
285 );
286 config.insert(
287 "phase".to_string(),
288 serde_json::Value::String("request".to_string()),
289 );
290
291 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
292 let ctx = test_context("/api/v1/users");
293 let result = plugin.execute(ctx).await.unwrap();
294 assert_eq!(result.context.request.path, "/users");
295 }
296
297 #[tokio::test]
298 async fn test_strip_path_prefix_root() {
299 let mut config = HashMap::new();
300 config.insert(
301 "strip_path_prefix".to_string(),
302 serde_json::Value::String("/api/v1".to_string()),
303 );
304
305 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
306 let ctx = test_context("/api/v1");
307 let result = plugin.execute(ctx).await.unwrap();
308 assert_eq!(result.context.request.path, "/");
309 }
310
311 #[tokio::test]
312 async fn test_add_request_header() {
313 let mut config = HashMap::new();
314 let mut headers = serde_json::Map::new();
315 headers.insert(
316 "x-custom".to_string(),
317 serde_json::Value::String("value".to_string()),
318 );
319 config.insert(
320 "add_headers".to_string(),
321 serde_json::Value::Object(headers),
322 );
323
324 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
325 let ctx = test_context("/test");
326 let result = plugin.execute(ctx).await.unwrap();
327 assert_eq!(
328 result.context.request.headers.get("x-custom"),
329 Some(&vec!["value".to_string()])
330 );
331 }
332
333 #[tokio::test]
334 async fn test_add_request_header_array_shape() {
335 let mut config = HashMap::new();
337 config.insert(
338 "add_headers".to_string(),
339 serde_json::json!([{ "name": "x-custom", "value": "value" }]),
340 );
341
342 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
343 let ctx = test_context("/test");
344 let result = plugin.execute(ctx).await.unwrap();
345 assert_eq!(
346 result.context.request.headers.get("x-custom"),
347 Some(&vec!["value".to_string()])
348 );
349 }
350
351 #[tokio::test]
352 async fn test_add_response_header_array_shape() {
353 let mut config = HashMap::new();
354 config.insert(
355 "phase".to_string(),
356 serde_json::Value::String("response".to_string()),
357 );
358 config.insert(
359 "add_headers".to_string(),
360 serde_json::json!([
361 { "name": "x-custom", "value": "value" },
362 { "name": "", "value": "ignored blank row" }
363 ]),
364 );
365
366 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
367 let ctx = test_context("/test");
368 let result = plugin.execute(ctx).await.unwrap();
369 assert_eq!(
370 result.context.response.headers.get("x-custom"),
371 Some(&vec!["value".to_string()])
372 );
373 assert_eq!(result.context.response.headers.len(), 1);
374 }
375
376 #[tokio::test]
377 async fn test_add_headers_numeric_value_map_shape() {
378 let mut config = HashMap::new();
379 config.insert(
380 "add_headers".to_string(),
381 serde_json::json!({ "x-version": 2 }),
382 );
383
384 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
385 let ctx = test_context("/test");
386 let result = plugin.execute(ctx).await.unwrap();
387 assert_eq!(
388 result.context.request.headers.get("x-version"),
389 Some(&vec!["2".to_string()])
390 );
391 }
392
393 #[tokio::test]
394 async fn test_add_headers_rejects_malformed() {
395 let mut config = HashMap::new();
396 config.insert(
397 "add_headers".to_string(),
398 serde_json::Value::String("x-custom: value".to_string()),
399 );
400 assert!(ProxyRewritePlugin::from_config(&config).is_err());
401
402 let mut config = HashMap::new();
403 config.insert(
404 "add_headers".to_string(),
405 serde_json::json!({ "x-nested": {"a": 1} }),
406 );
407 assert!(ProxyRewritePlugin::from_config(&config).is_err());
408 }
409
410 #[tokio::test]
411 async fn test_remove_response_header() {
412 let mut config = HashMap::new();
413 config.insert(
414 "phase".to_string(),
415 serde_json::Value::String("response".to_string()),
416 );
417 config.insert(
418 "remove_headers".to_string(),
419 serde_json::Value::Array(vec![serde_json::Value::String("x-internal".to_string())]),
420 );
421
422 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
423 let mut ctx = test_context("/test");
424 ctx.response
425 .headers
426 .insert("x-internal".to_string(), vec!["secret".to_string()]);
427
428 let result = plugin.execute(ctx).await.unwrap();
429 assert!(!result.context.response.headers.contains_key("x-internal"));
430 }
431
432 #[tokio::test]
436 async fn test_add_headers_value_renders_template() {
437 let mut config = HashMap::new();
438 config.insert(
439 "add_headers".to_string(),
440 serde_json::json!({ "x-method": "{{request.method}}" }),
441 );
442
443 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
444 let mut ctx = test_context("/test");
445 ctx.request.method = "POST".to_string();
446 let result = plugin.execute(ctx).await.unwrap();
447 assert_eq!(
448 result.context.request.headers.get("x-method"),
449 Some(&vec!["POST".to_string()])
450 );
451 }
452
453 #[tokio::test]
458 async fn test_add_headers_value_dollar_untouched() {
459 let mut config = HashMap::new();
460 config.insert(
461 "add_headers".to_string(),
462 serde_json::json!({ "x-secret": "pa$sword4" }),
463 );
464
465 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
466 let ctx = test_context("/test");
467 let result = plugin.execute(ctx).await.unwrap();
468 assert_eq!(
469 result.context.request.headers.get("x-secret"),
470 Some(&vec!["pa$sword4".to_string()])
471 );
472 }
473
474 #[tokio::test]
476 async fn test_add_path_prefix_renders_template() {
477 let mut config = HashMap::new();
478 config.insert(
479 "add_path_prefix".to_string(),
480 serde_json::json!("/{{request.headers.x-tenant}}"),
481 );
482
483 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
484 let mut ctx = test_context("/users");
485 ctx.request
486 .headers
487 .insert("x-tenant".to_string(), vec!["acme".to_string()]);
488 let result = plugin.execute(ctx).await.unwrap();
489 assert_eq!(result.context.request.path, "/acme/users");
490 }
491
492 #[tokio::test]
496 async fn test_remove_response_header_is_case_insensitive() {
497 let mut config = HashMap::new();
498 config.insert(
499 "phase".to_string(),
500 serde_json::Value::String("response".to_string()),
501 );
502 config.insert(
503 "remove_headers".to_string(),
504 serde_json::Value::Array(vec![serde_json::Value::String("x-powered-by".to_string())]),
505 );
506
507 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
508 let mut ctx = test_context("/test");
509 ctx.response
511 .headers
512 .insert("X-Powered-By".to_string(), vec!["php".to_string()]);
513
514 let result = plugin.execute(ctx).await.unwrap();
515 assert!(!result.context.response.headers.contains_key("X-Powered-By"));
516 }
517
518 #[test]
522 fn test_proxy_rewrite_response_header_reading_the_body_forces_buffering() {
523 let config: HashMap<String, serde_json::Value> =
524 serde_json::from_value(serde_json::json!({
525 "phase": "response",
526 "add_headers": { "x-echo": "$resp_body" }
527 }))
528 .unwrap();
529 let p = ProxyRewritePlugin::from_config(&config).unwrap();
530 assert!(p.reads_response_body());
531 }
532
533 #[test]
535 fn test_proxy_rewrite_plain_headers_stay_stream_safe() {
536 let config: HashMap<String, serde_json::Value> =
537 serde_json::from_value(serde_json::json!({
538 "phase": "response",
539 "add_headers": { "x-served-by": "featherbit" }
540 }))
541 .unwrap();
542 let p = ProxyRewritePlugin::from_config(&config).unwrap();
543 assert!(!p.reads_response_body());
544 }
545}