Skip to main content

featherbit/plugins/native/
proxy_rewrite.rs

1//! The `proxy-rewrite` node — rewrites the request path and adds/removes
2//! headers on either the request or the response, depending on `phase`.
3
4use async_trait::async_trait;
5use std::collections::HashMap;
6
7use crate::context::Context;
8use crate::plugins::{Plugin, PluginOutput, PluginResult};
9
10/// Mutates either `Context.request` or `Context.response` (selected by
11/// `phase`): strips/adds a path prefix and adds/removes headers. Path
12/// rewriting only applies in the request phase; header names are lowercased
13/// before being applied. This plugin never fails at execution time.
14pub 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/// Which side of the exchange the rewrite applies to.
23#[derive(Debug, Clone, PartialEq)]
24enum RewritePhase {
25    Request,
26    Response,
27}
28
29/// Stringifies a scalar header value (string, number, or bool); returns `None`
30/// for non-scalar shapes so callers can reject them at config load.
31fn 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
40/// Accepts both config shapes for `add_headers`:
41/// - map form (YAML):        `add_headers: { x-foo: bar }`
42/// - array form (UI editor): `add_headers: [{ name: x-foo, value: bar }]`
43///
44/// Scalar values (numbers, bools) are stringified; array entries with a blank
45/// `name` are skipped; any other shape is an error at config load.
46fn 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                    // blank row left in the UI editor
71                    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    /// Builds the plugin from node config.
91    ///
92    /// Accepted keys (all optional):
93    /// - `phase` (string, default `request`): `request` or `response`; any
94    ///   value other than `response` falls back to `request`.
95    /// - `strip_path_prefix` (string): prefix removed from the request path
96    ///   when it matches; the result is normalized to start with `/`.
97    /// - `add_path_prefix` (string): prefix prepended to the request path
98    ///   (after stripping).
99    /// - `add_headers` (map `{name: value}` **or** array `[{name, value}]`,
100    ///   the UI editor's form): headers to append. Scalar values are
101    ///   stringified; malformed shapes (e.g. a plain string, or nested
102    ///   objects as values) error here at config load.
103    /// - `remove_headers` (array of strings): header names to delete.
104    ///
105    /// ```yaml
106    /// type: proxy-rewrite
107    /// config:
108    ///   phase: request
109    ///   strip_path_prefix: /api/v1
110    ///   add_headers:
111    ///     x-forwarded-tier: edge
112    ///   remove_headers: [x-internal]
113    /// ```
114    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
115        // The mirror of response-rewrite's mix-up: that plugin takes a single
116        // `headers` object, but proxy-rewrite takes `add_headers`/`remove_headers`.
117        // Reject the misplaced key rather than silently ignoring it.
118        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                // Strip path prefix
177                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                // Add path prefix
189                if let Some(ref prefix) = self.add_path_prefix {
190                    ctx.request.path = format!("{}{}", prefix, ctx.request.path);
191                }
192
193                // Add headers
194                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                // Remove headers (case-insensitive: the stored key may not be lowercase)
203                for key in &self.remove_headers {
204                    crate::plugins::util::headers::remove_ci(&mut ctx.request.headers, key);
205                }
206            }
207            RewritePhase::Response => {
208                // Add headers to response
209                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                // Remove headers from response (case-insensitive)
218                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        // The UI editor serializes add_headers as [{name, value}]
317        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    /// Header names are case-insensitive: a response header stored with a
414    /// non-lowercase key (e.g. from a Lua script or another plugin) must still
415    /// be removed by a lowercase `remove_headers` entry, and vice versa.
416    #[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        // Stored with mixed case, removed with a lowercase config entry.
431        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}