featherbit/plugins/native/
redirect.rs1use 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
22pub struct RedirectPlugin {
31 http_to_https: bool,
33 uri: Option<String>,
35 ret_code: u16,
37 append_query_string: bool,
39}
40
41impl RedirectPlugin {
42 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 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 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,
148 };
149 (interpolate(&ctx, "https://$host$request_uri"), ret_code)
150 } else {
151 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 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 assert!(RedirectPlugin::from_config(&HashMap::new()).is_err());
218 assert!(RedirectPlugin::from_config(&config(serde_json::json!({
220 "uri": "/new",
221 "http_to_https": true
222 })))
223 .is_err());
224 assert!(RedirectPlugin::from_config(&config(serde_json::json!({
226 "http_to_https": false
227 })))
228 .is_err());
229 assert!(RedirectPlugin::from_config(&config(serde_json::json!({
231 "http_to_https": true,
232 "append_query_string": true
233 })))
234 .is_err());
235 assert!(RedirectPlugin::from_config(&config(serde_json::json!({
237 "uri": "/new",
238 "ret_code": 100
239 })))
240 .is_err());
241 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); 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 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 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 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 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 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 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}