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