Skip to main content

featherbit/plugins/native/
redirect.rs

1//! The `redirect` node — answers the request with an HTTP redirect instead of
2//! proxying it upstream.
3//!
4//! Port of APISIX's `redirect` plugin (the `uri` and `http_to_https` modes;
5//! APISIX's `regex_uri` and `encode_uri` are not implemented, and
6//! `http_to_https` always redirects to the default HTTPS port — there is no
7//! `https_port` plugin attribute).
8//!
9//! Redirecting *stops* the pipeline in featherbit terms: the plugin fills in
10//! `context.response` (status + `location`) and exits through the dedicated
11//! `redirect` output port, so the node's `redirect` edge should go straight
12//! to `client.in` — not through an `upstream` node, which would overwrite
13//! the response. Requests that don't redirect (the `http_to_https`
14//! already-secure passthrough) continue on `success`.
15
16use async_trait::async_trait;
17use bytes::Bytes;
18use std::collections::HashMap;
19
20use crate::context::Context;
21use crate::plugins::{Plugin, PluginOutput, PluginResult};
22use crate::vars::template::Template;
23use crate::vars::{interpolate, resolve};
24
25/// Builds a redirect response from either a `uri` template (with
26/// `{{namespace.path}}` references plus legacy `$var` interpolation) or the
27/// `http_to_https` shortcut.
28///
29/// This plugin never fails at execution time. A prepared redirect exits on
30/// the dedicated `redirect` port. The only case where it does **not**
31/// redirect is `http_to_https` on a request that is already HTTPS (per
32/// `x-forwarded-proto` or the request scheme): the context passes through
33/// unchanged on `success`, so `http_to_https` should only be wired into
34/// routes served over plain HTTP.
35pub struct RedirectPlugin {
36    /// Redirect plain-HTTP requests to `https://$host$request_uri`.
37    http_to_https: bool,
38    /// Redirect target template; supports `{{namespace.path}}` references and
39    /// legacy `$var` / `${var}` interpolation (see
40    /// [`Template::render_with_legacy`]).
41    uri_tpl: Option<Template>,
42    /// Status code for `uri` redirects (`http_to_https` picks 301/308 itself).
43    ret_code: u16,
44    /// Append the original query string to the target.
45    append_query_string: bool,
46}
47
48impl RedirectPlugin {
49    /// Builds the plugin from node config.
50    ///
51    /// Accepted keys — exactly one of `uri` / `http_to_https: true` is
52    /// required:
53    /// - `uri` (string): redirect target template. Supports
54    ///   `{{namespace.path}}` references (e.g. `{{request.host}}`) plus
55    ///   legacy `$var` / `${var}` interpolation against the context (see
56    ///   [`crate::vars::template::Template::render_with_legacy`]), e.g.
57    ///   `/new$request_uri` or `https://$host/login`. Unknown legacy
58    ///   variables resolve to `""`.
59    /// - `http_to_https` (bool): redirect HTTP requests to
60    ///   `https://$host$request_uri` with `301` for GET/HEAD and `308`
61    ///   otherwise (so the method and body survive). Already-HTTPS requests
62    ///   pass through unchanged.
63    /// - `ret_code` (integer, default `302`, minimum `200`): status code for
64    ///   `uri` redirects.
65    /// - `append_query_string` (bool, default `false`): append the original
66    ///   query string to the target (`?` or `&` as appropriate). Cannot be
67    ///   combined with `http_to_https`, which already keeps the query string
68    ///   via `$request_uri`.
69    ///
70    /// ```yaml
71    /// type: redirect
72    /// config:
73    ///   uri: https://$host/new-prefix$uri
74    ///   ret_code: 301
75    ///   append_query_string: true
76    /// ```
77    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
78        let http_to_https = config
79            .get("http_to_https")
80            .and_then(|v| v.as_bool())
81            .unwrap_or(false);
82
83        let uri = config.get("uri").and_then(|v| v.as_str()).map(String::from);
84
85        if http_to_https == uri.is_some() {
86            return Err(
87                "redirect plugin requires exactly one of 'uri' or 'http_to_https: true'"
88                    .to_string(),
89            );
90        }
91
92        let ret_code = match config.get("ret_code") {
93            None => 302,
94            Some(v) => {
95                let code = v
96                    .as_u64()
97                    .filter(|c| (200..600).contains(c))
98                    .ok_or("ret_code must be an integer status code >= 200")?;
99                code as u16
100            }
101        };
102
103        let append_query_string = config
104            .get("append_query_string")
105            .and_then(|v| v.as_bool())
106            .unwrap_or(false);
107
108        if http_to_https && append_query_string {
109            return Err(
110                "only one of 'http_to_https' and 'append_query_string' can be configured"
111                    .to_string(),
112            );
113        }
114
115        // Discard warnings here — the compile-time walk (a later task)
116        // reports well-formed-but-unknown references; execution must not.
117        let uri_tpl = uri.as_deref().map(|s| Template::parse(s).0);
118
119        Ok(Self {
120            http_to_https,
121            uri_tpl,
122            ret_code,
123            append_query_string,
124        })
125    }
126}
127
128#[async_trait]
129impl Plugin for RedirectPlugin {
130    fn plugin_type(&self) -> &str {
131        "redirect"
132    }
133
134    async fn execute(&self, mut ctx: Context) -> PluginResult {
135        let (new_uri, ret_code) = if self.http_to_https {
136            // Honor x-forwarded-proto from an outer proxy, like APISIX.
137            let scheme = ctx
138                .request
139                .headers
140                .get("x-forwarded-proto")
141                .and_then(|v| v.first())
142                .cloned()
143                .unwrap_or_else(|| ctx.request.scheme.clone());
144
145            if scheme == "https" {
146                // Already secure: pass through untouched.
147                return Ok(PluginOutput::success(ctx));
148            }
149
150            let ret_code = match ctx.request.method.as_str() {
151                "GET" | "HEAD" => 301,
152                // 308 keeps the method and body across the redirect.
153                _ => 308,
154            };
155            (interpolate(&ctx, "https://$host$request_uri"), ret_code)
156        } else {
157            // from_config guarantees `uri_tpl` is set in this branch.
158            let mut new_uri = match &self.uri_tpl {
159                Some(tpl) => tpl.render_with_legacy(&ctx),
160                None => String::new(),
161            };
162
163            if self.append_query_string {
164                if let Some(qs) = resolve(&ctx, "query_string") {
165                    let sep = if new_uri.contains('?') { '&' } else { '?' };
166                    new_uri.push(sep);
167                    new_uri.push_str(&qs);
168                }
169            }
170            (new_uri, self.ret_code)
171        };
172
173        ctx.response.status_code = ret_code;
174        ctx.response
175            .headers
176            .insert("location".to_string(), vec![new_uri]);
177        // The redirect body is empty; drop stale body-metadata headers per
178        // the body-mutation convention.
179        ctx.response.body = Bytes::new();
180        ctx.response.headers.remove("content-length");
181        ctx.response.headers.remove("content-encoding");
182
183        // The 3xx is fully prepared: exit on the dedicated `redirect` port
184        // rather than `success` (and from there into `upstream`, which would
185        // overwrite the response).
186        Ok(PluginOutput::on_port(ctx, "redirect"))
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
194
195    fn test_context(path: &str) -> Context {
196        Context {
197            request: GatewayRequest {
198                method: "GET".to_string(),
199                path: path.to_string(),
200                host: "example.com".to_string(),
201                scheme: "http".to_string(),
202                headers: HashMap::new(),
203                query_params: HashMap::new(),
204                body: Bytes::new(),
205                remote_addr: "127.0.0.1:12345".to_string(),
206                protocol: Protocol::Http1,
207            },
208            response: GatewayResponse {
209                status_code: 0,
210                headers: HashMap::new(),
211                body: Bytes::new(),
212                stream: None,
213            },
214            message: HashMap::new(),
215            errors: Vec::new(),
216        }
217    }
218
219    fn config(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
220        serde_json::from_value(json).unwrap()
221    }
222
223    #[test]
224    fn test_redirect_config_validation() {
225        // neither mode configured
226        assert!(RedirectPlugin::from_config(&HashMap::new()).is_err());
227        // both modes configured
228        assert!(RedirectPlugin::from_config(&config(serde_json::json!({
229            "uri": "/new",
230            "http_to_https": true
231        })))
232        .is_err());
233        // http_to_https: false does not count as choosing the mode
234        assert!(RedirectPlugin::from_config(&config(serde_json::json!({
235            "http_to_https": false
236        })))
237        .is_err());
238        // http_to_https + append_query_string is rejected (APISIX parity)
239        assert!(RedirectPlugin::from_config(&config(serde_json::json!({
240            "http_to_https": true,
241            "append_query_string": true
242        })))
243        .is_err());
244        // ret_code below 200 rejected
245        assert!(RedirectPlugin::from_config(&config(serde_json::json!({
246            "uri": "/new",
247            "ret_code": 100
248        })))
249        .is_err());
250        // valid configs
251        assert!(RedirectPlugin::from_config(&config(serde_json::json!({"uri": "/new"}))).is_ok());
252        assert!(
253            RedirectPlugin::from_config(&config(serde_json::json!({"http_to_https": true})))
254                .is_ok()
255        );
256    }
257
258    #[tokio::test]
259    async fn test_redirect_uri_template() {
260        let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
261            "uri": "https://$host/moved$uri"
262        })))
263        .unwrap();
264
265        let result = plugin.execute(test_context("/old/path")).await.unwrap();
266        assert_eq!(result.port, Some("redirect"));
267        let ctx = result.context;
268        assert_eq!(ctx.response.status_code, 302); // default ret_code
269        assert_eq!(
270            ctx.response.headers.get("location"),
271            Some(&vec!["https://example.com/moved/old/path".to_string()])
272        );
273        assert!(ctx.response.body.is_empty());
274    }
275
276    /// A matching redirect exits on the dedicated `redirect` port with the
277    /// 3xx + `location` fully prepared.
278    #[tokio::test]
279    async fn test_redirect_exits_on_redirect_port() {
280        let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
281            "uri": "/new"
282        })))
283        .unwrap();
284
285        let out = plugin.execute(test_context("/old")).await.unwrap();
286        assert_eq!(out.port, Some("redirect"));
287        assert_eq!(out.context.response.status_code, 302);
288    }
289
290    #[tokio::test]
291    async fn test_redirect_uri_superset_template_and_legacy_dollar() {
292        // The `uri` field must render both the new `{{...}}` template syntax
293        // and the legacy `$var` syntax in the same value (superset behavior).
294        let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
295            "uri": "{{request.scheme}}://x$uri"
296        })))
297        .unwrap();
298
299        let result = plugin.execute(test_context("/p")).await.unwrap();
300        assert_eq!(
301            result.context.response.headers.get("location"),
302            Some(&vec!["http://x/p".to_string()])
303        );
304    }
305
306    #[tokio::test]
307    async fn test_redirect_custom_ret_code() {
308        let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
309            "uri": "/new",
310            "ret_code": 301
311        })))
312        .unwrap();
313
314        let result = plugin.execute(test_context("/old")).await.unwrap();
315        assert_eq!(result.port, Some("redirect"));
316        assert_eq!(result.context.response.status_code, 301);
317    }
318
319    #[tokio::test]
320    async fn test_redirect_append_query_string() {
321        let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
322            "uri": "/new",
323            "append_query_string": true
324        })))
325        .unwrap();
326
327        let mut ctx = test_context("/old");
328        ctx.request
329            .query_params
330            .insert("a".to_string(), vec!["1".to_string()]);
331        let result = plugin.execute(ctx).await.unwrap();
332        assert_eq!(result.port, Some("redirect"));
333        assert_eq!(
334            result.context.response.headers.get("location"),
335            Some(&vec!["/new?a=1".to_string()])
336        );
337
338        // Target already has a query string: append with '&'.
339        let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
340            "uri": "/new?x=y",
341            "append_query_string": true
342        })))
343        .unwrap();
344        let mut ctx = test_context("/old");
345        ctx.request
346            .query_params
347            .insert("a".to_string(), vec!["1".to_string()]);
348        let result = plugin.execute(ctx).await.unwrap();
349        assert_eq!(result.port, Some("redirect"));
350        assert_eq!(
351            result.context.response.headers.get("location"),
352            Some(&vec!["/new?x=y&a=1".to_string()])
353        );
354
355        // No query params: nothing appended.
356        let result = plugin.execute(test_context("/old")).await.unwrap();
357        assert_eq!(result.port, Some("redirect"));
358        assert_eq!(
359            result.context.response.headers.get("location"),
360            Some(&vec!["/new?x=y".to_string()])
361        );
362    }
363
364    #[tokio::test]
365    async fn test_redirect_http_to_https() {
366        let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
367            "http_to_https": true
368        })))
369        .unwrap();
370
371        // GET -> 301, query string preserved via $request_uri
372        let mut ctx = test_context("/path");
373        ctx.request
374            .query_params
375            .insert("a".to_string(), vec!["1".to_string()]);
376        let result = plugin.execute(ctx).await.unwrap();
377        assert_eq!(result.port, Some("redirect"));
378        assert_eq!(result.context.response.status_code, 301);
379        assert_eq!(
380            result.context.response.headers.get("location"),
381            Some(&vec!["https://example.com/path?a=1".to_string()])
382        );
383
384        // Non-GET/HEAD -> 308
385        let mut ctx = test_context("/path");
386        ctx.request.method = "POST".to_string();
387        let result = plugin.execute(ctx).await.unwrap();
388        assert_eq!(result.port, Some("redirect"));
389        assert_eq!(result.context.response.status_code, 308);
390    }
391
392    #[tokio::test]
393    async fn test_redirect_http_to_https_already_https_passthrough() {
394        let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
395            "http_to_https": true
396        })))
397        .unwrap();
398
399        // Scheme https
400        let mut ctx = test_context("/path");
401        ctx.request.scheme = "https".to_string();
402        let result = plugin.execute(ctx).await.unwrap();
403        assert_eq!(result.port, None);
404        assert_eq!(result.context.response.status_code, 0);
405        assert!(!result.context.response.headers.contains_key("location"));
406
407        // x-forwarded-proto https from an outer proxy wins over the scheme
408        let mut ctx = test_context("/path");
409        ctx.request
410            .headers
411            .insert("x-forwarded-proto".to_string(), vec!["https".to_string()]);
412        let result = plugin.execute(ctx).await.unwrap();
413        assert_eq!(result.port, None);
414        assert_eq!(result.context.response.status_code, 0);
415    }
416}