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};
9
10pub struct ProxyRewritePlugin {
15 strip_path_prefix: Option<String>,
16 add_path_prefix: Option<String>,
17 add_headers: HashMap<String, String>,
18 remove_headers: Vec<String>,
19 phase: RewritePhase,
20}
21
22#[derive(Debug, Clone, PartialEq)]
24enum RewritePhase {
25 Request,
26 Response,
27}
28
29fn header_value_to_string(v: &serde_json::Value) -> Option<String> {
32 match v {
33 serde_json::Value::String(s) => Some(s.clone()),
34 serde_json::Value::Number(n) => Some(n.to_string()),
35 serde_json::Value::Bool(b) => Some(b.to_string()),
36 _ => None,
37 }
38}
39
40fn parse_add_headers(
47 config: &HashMap<String, serde_json::Value>,
48) -> Result<HashMap<String, String>, String> {
49 let Some(raw) = config.get("add_headers") else {
50 return Ok(HashMap::new());
51 };
52
53 match raw {
54 serde_json::Value::Object(m) => m
55 .iter()
56 .map(|(k, v)| {
57 header_value_to_string(v)
58 .map(|s| (k.clone(), s))
59 .ok_or_else(|| format!("add_headers['{}'] must be a scalar value", k))
60 })
61 .collect(),
62 serde_json::Value::Array(items) => {
63 let mut headers = HashMap::new();
64 for item in items {
65 let obj = item.as_object().ok_or_else(|| {
66 "add_headers entries must be objects with 'name' and 'value'".to_string()
67 })?;
68 let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
69 if name.trim().is_empty() {
70 continue;
72 }
73 let value = match obj.get("value") {
74 None => String::new(),
75 Some(v) => header_value_to_string(v)
76 .ok_or_else(|| format!("add_headers['{}'] must be a scalar value", name))?,
77 };
78 headers.insert(name.to_string(), value);
79 }
80 Ok(headers)
81 }
82 _ => Err(
83 "add_headers must be a map of name: value or a list of {name, value} objects"
84 .to_string(),
85 ),
86 }
87}
88
89impl ProxyRewritePlugin {
90 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
115 if config.contains_key("headers") {
119 return Err(
120 "proxy-rewrite has no 'headers' key (that is response-rewrite's schema); \
121 use add_headers: { name: value } and remove_headers: [name]"
122 .to_string(),
123 );
124 }
125
126 let phase = match config.get("phase").and_then(|v| v.as_str()) {
127 Some("response") => RewritePhase::Response,
128 _ => RewritePhase::Request,
129 };
130
131 let strip_path_prefix = config
132 .get("strip_path_prefix")
133 .and_then(|v| v.as_str())
134 .map(String::from);
135
136 let add_path_prefix = config
137 .get("add_path_prefix")
138 .and_then(|v| v.as_str())
139 .map(String::from);
140
141 let add_headers = parse_add_headers(config)?;
142
143 let remove_headers = config
144 .get("remove_headers")
145 .and_then(|v| v.as_array())
146 .map(|seq| {
147 seq.iter()
148 .filter_map(|v| v.as_str().map(String::from))
149 .collect()
150 })
151 .unwrap_or_default();
152
153 Ok(Self {
154 strip_path_prefix,
155 add_path_prefix,
156 add_headers,
157 remove_headers,
158 phase,
159 })
160 }
161}
162
163#[async_trait]
164impl Plugin for ProxyRewritePlugin {
165 fn plugin_type(&self) -> &str {
166 "proxy-rewrite"
167 }
168
169 async fn execute(
170 &self,
171 mut ctx: Context,
172 _named_inputs: &HashMap<String, serde_json::Value>,
173 ) -> PluginResult {
174 match self.phase {
175 RewritePhase::Request => {
176 if let Some(ref prefix) = self.strip_path_prefix {
178 if ctx.request.path.starts_with(prefix.as_str()) {
179 let new_path = ctx.request.path[prefix.len()..].to_string();
180 ctx.request.path = if new_path.is_empty() || !new_path.starts_with('/') {
181 format!("/{}", new_path.trim_start_matches('/'))
182 } else {
183 new_path
184 };
185 }
186 }
187
188 if let Some(ref prefix) = self.add_path_prefix {
190 ctx.request.path = format!("{}{}", prefix, ctx.request.path);
191 }
192
193 for (key, value) in &self.add_headers {
195 ctx.request
196 .headers
197 .entry(key.to_lowercase())
198 .or_default()
199 .push(value.clone());
200 }
201
202 for key in &self.remove_headers {
204 crate::plugins::util::headers::remove_ci(&mut ctx.request.headers, key);
205 }
206 }
207 RewritePhase::Response => {
208 for (key, value) in &self.add_headers {
210 ctx.response
211 .headers
212 .entry(key.to_lowercase())
213 .or_default()
214 .push(value.clone());
215 }
216
217 for key in &self.remove_headers {
219 crate::plugins::util::headers::remove_ci(&mut ctx.response.headers, key);
220 }
221 }
222 }
223
224 Ok(PluginOutput {
225 context: ctx,
226 named_outputs: HashMap::new(),
227 })
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
235 use bytes::Bytes;
236
237 fn test_context(path: &str) -> Context {
238 Context {
239 request: GatewayRequest {
240 method: "GET".to_string(),
241 path: path.to_string(),
242 host: "localhost".to_string(),
243 scheme: "http".to_string(),
244 headers: HashMap::new(),
245 query_params: HashMap::new(),
246 body: Bytes::new(),
247 remote_addr: "127.0.0.1:12345".to_string(),
248 protocol: Protocol::Http1,
249 },
250 response: GatewayResponse {
251 status_code: 0,
252 headers: HashMap::new(),
253 body: Bytes::new(),
254 },
255 message: HashMap::new(),
256 errors: Vec::new(),
257 }
258 }
259
260 #[tokio::test]
261 async fn test_strip_path_prefix() {
262 let mut config = HashMap::new();
263 config.insert(
264 "strip_path_prefix".to_string(),
265 serde_json::Value::String("/api/v1".to_string()),
266 );
267 config.insert(
268 "phase".to_string(),
269 serde_json::Value::String("request".to_string()),
270 );
271
272 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
273 let ctx = test_context("/api/v1/users");
274 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
275 assert_eq!(result.context.request.path, "/users");
276 }
277
278 #[tokio::test]
279 async fn test_strip_path_prefix_root() {
280 let mut config = HashMap::new();
281 config.insert(
282 "strip_path_prefix".to_string(),
283 serde_json::Value::String("/api/v1".to_string()),
284 );
285
286 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
287 let ctx = test_context("/api/v1");
288 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
289 assert_eq!(result.context.request.path, "/");
290 }
291
292 #[tokio::test]
293 async fn test_add_request_header() {
294 let mut config = HashMap::new();
295 let mut headers = serde_json::Map::new();
296 headers.insert(
297 "x-custom".to_string(),
298 serde_json::Value::String("value".to_string()),
299 );
300 config.insert(
301 "add_headers".to_string(),
302 serde_json::Value::Object(headers),
303 );
304
305 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
306 let ctx = test_context("/test");
307 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
308 assert_eq!(
309 result.context.request.headers.get("x-custom"),
310 Some(&vec!["value".to_string()])
311 );
312 }
313
314 #[tokio::test]
315 async fn test_add_request_header_array_shape() {
316 let mut config = HashMap::new();
318 config.insert(
319 "add_headers".to_string(),
320 serde_json::json!([{ "name": "x-custom", "value": "value" }]),
321 );
322
323 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
324 let ctx = test_context("/test");
325 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
326 assert_eq!(
327 result.context.request.headers.get("x-custom"),
328 Some(&vec!["value".to_string()])
329 );
330 }
331
332 #[tokio::test]
333 async fn test_add_response_header_array_shape() {
334 let mut config = HashMap::new();
335 config.insert(
336 "phase".to_string(),
337 serde_json::Value::String("response".to_string()),
338 );
339 config.insert(
340 "add_headers".to_string(),
341 serde_json::json!([
342 { "name": "x-custom", "value": "value" },
343 { "name": "", "value": "ignored blank row" }
344 ]),
345 );
346
347 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
348 let ctx = test_context("/test");
349 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
350 assert_eq!(
351 result.context.response.headers.get("x-custom"),
352 Some(&vec!["value".to_string()])
353 );
354 assert_eq!(result.context.response.headers.len(), 1);
355 }
356
357 #[tokio::test]
358 async fn test_add_headers_numeric_value_map_shape() {
359 let mut config = HashMap::new();
360 config.insert(
361 "add_headers".to_string(),
362 serde_json::json!({ "x-version": 2 }),
363 );
364
365 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
366 let ctx = test_context("/test");
367 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
368 assert_eq!(
369 result.context.request.headers.get("x-version"),
370 Some(&vec!["2".to_string()])
371 );
372 }
373
374 #[tokio::test]
375 async fn test_add_headers_rejects_malformed() {
376 let mut config = HashMap::new();
377 config.insert(
378 "add_headers".to_string(),
379 serde_json::Value::String("x-custom: value".to_string()),
380 );
381 assert!(ProxyRewritePlugin::from_config(&config).is_err());
382
383 let mut config = HashMap::new();
384 config.insert(
385 "add_headers".to_string(),
386 serde_json::json!({ "x-nested": {"a": 1} }),
387 );
388 assert!(ProxyRewritePlugin::from_config(&config).is_err());
389 }
390
391 #[tokio::test]
392 async fn test_remove_response_header() {
393 let mut config = HashMap::new();
394 config.insert(
395 "phase".to_string(),
396 serde_json::Value::String("response".to_string()),
397 );
398 config.insert(
399 "remove_headers".to_string(),
400 serde_json::Value::Array(vec![serde_json::Value::String("x-internal".to_string())]),
401 );
402
403 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
404 let mut ctx = test_context("/test");
405 ctx.response
406 .headers
407 .insert("x-internal".to_string(), vec!["secret".to_string()]);
408
409 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
410 assert!(!result.context.response.headers.contains_key("x-internal"));
411 }
412
413 #[tokio::test]
417 async fn test_remove_response_header_is_case_insensitive() {
418 let mut config = HashMap::new();
419 config.insert(
420 "phase".to_string(),
421 serde_json::Value::String("response".to_string()),
422 );
423 config.insert(
424 "remove_headers".to_string(),
425 serde_json::Value::Array(vec![serde_json::Value::String("x-powered-by".to_string())]),
426 );
427
428 let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
429 let mut ctx = test_context("/test");
430 ctx.response
432 .headers
433 .insert("X-Powered-By".to_string(), vec!["php".to_string()]);
434
435 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
436 assert!(!result.context.response.headers.contains_key("X-Powered-By"));
437 }
438}