warpdrive_proxy/router/
route.rs

1//! Route matching and path transformation
2//!
3//! Routes are evaluated in order (first match wins).
4//! Supports path prefix, exact path, regex, host-based, method-based, and header-based routing.
5
6use anyhow::Result;
7use pingora::prelude::*;
8use regex::Regex;
9use tracing::debug;
10
11use crate::config::toml::{HeaderMatch, RouteConfig};
12
13/// Route matching and upstream selection
14#[derive(Debug)]
15pub struct Route {
16    /// Path prefix to match (e.g., "/api")
17    path_prefix: Option<String>,
18
19    /// Exact path to match
20    path_exact: Option<String>,
21
22    /// Regex pattern to match
23    path_regex: Option<Regex>,
24
25    /// Host header to match
26    host: Option<String>,
27
28    /// HTTP methods to match
29    methods: Option<Vec<String>>,
30
31    /// Header to match
32    header: Option<HeaderMatch>,
33
34    /// Target upstream name
35    pub upstream_name: String,
36
37    /// Strip the matched prefix before forwarding
38    pub strip_prefix: bool,
39
40    /// Rewrite the path
41    pub rewrite: Option<String>,
42
43    /// Description (for logging)
44    #[allow(dead_code)]
45    description: Option<String>,
46}
47
48impl Route {
49    /// Create route from TOML configuration
50    pub fn from_config(config: &RouteConfig) -> Result<Self> {
51        let path_regex = if let Some(pattern) = &config.path_regex {
52            Some(Regex::new(pattern)?)
53        } else {
54            None
55        };
56
57        Ok(Self {
58            path_prefix: config.path_prefix.clone(),
59            path_exact: config.path_exact.clone(),
60            path_regex,
61            host: config.host.clone(),
62            methods: config.methods.clone(),
63            header: config.header.clone(),
64            upstream_name: config.upstream.clone(),
65            strip_prefix: config.strip_prefix,
66            rewrite: config.rewrite.clone(),
67            description: config.description.clone(),
68        })
69    }
70
71    /// Check if this route matches the incoming request
72    pub fn matches(&self, session: &Session) -> bool {
73        // 1. Check host match (if specified)
74        if let Some(required_host) = &self.host {
75            let request_host = session
76                .req_header()
77                .headers
78                .get("host")
79                .and_then(|h| h.to_str().ok());
80
81            if request_host != Some(required_host.as_str()) {
82                debug!(
83                    "Route: host mismatch (required: {}, got: {:?})",
84                    required_host, request_host
85                );
86                return false;
87            }
88        }
89
90        // 2. Check method match (if specified)
91        if let Some(allowed_methods) = &self.methods {
92            let method = session.req_header().method.as_str();
93            if !allowed_methods.iter().any(|m| m == method) {
94                debug!(
95                    "Route: method mismatch (allowed: {:?}, got: {})",
96                    allowed_methods, method
97                );
98                return false;
99            }
100        }
101
102        // 3. Check header match (if specified)
103        if let Some(header_match) = &self.header {
104            let header_value = session
105                .req_header()
106                .headers
107                .get(&header_match.name)
108                .and_then(|h| h.to_str().ok());
109
110            if header_value != Some(header_match.value.as_str()) {
111                debug!(
112                    "Route: header mismatch ({}={}, got: {:?})",
113                    header_match.name, header_match.value, header_value
114                );
115                return false;
116            }
117        }
118
119        let path = session.req_header().uri.path();
120
121        // 4. Check exact path match (highest priority)
122        if let Some(exact_path) = &self.path_exact {
123            if path == exact_path {
124                debug!("Route: exact path match '{}'", exact_path);
125                return true;
126            } else {
127                debug!(
128                    "Route: exact path mismatch (required: {}, got: {})",
129                    exact_path, path
130                );
131                return false;
132            }
133        }
134
135        // 5. Check regex match
136        if let Some(regex) = &self.path_regex {
137            if regex.is_match(path) {
138                debug!("Route: regex match '{}'", regex.as_str());
139                return true;
140            } else {
141                debug!(
142                    "Route: regex mismatch (pattern: {}, path: {})",
143                    regex.as_str(),
144                    path
145                );
146                return false;
147            }
148        }
149
150        // 6. Check path prefix match
151        if let Some(prefix) = &self.path_prefix {
152            if path.starts_with(prefix) {
153                debug!("Route: prefix match '{}'", prefix);
154                return true;
155            } else {
156                debug!(
157                    "Route: prefix mismatch (required: {}, got: {})",
158                    prefix, path
159                );
160                return false;
161            }
162        }
163
164        // 7. If no path matching specified, match all
165        debug!("Route: match all (no path criteria)");
166        true
167    }
168
169    /// Transform the path according to route configuration
170    pub fn transform_path(&self, original: &str) -> String {
171        // Priority: rewrite > strip_prefix > original
172        if let Some(rewrite) = &self.rewrite {
173            debug!("Route: rewriting path '{}' -> '{}'", original, rewrite);
174            return rewrite.clone();
175        }
176
177        if self.strip_prefix {
178            if let Some(prefix) = &self.path_prefix {
179                let stripped = original.strip_prefix(prefix).unwrap_or(original);
180                let result = if stripped.is_empty() { "/" } else { stripped };
181                debug!(
182                    "Route: stripping prefix '{}' from '{}' -> '{}'",
183                    prefix, original, result
184                );
185                return result.to_string();
186            }
187        }
188
189        original.to_string()
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn mock_session(method: &str, host: &str, path: &str) -> Session {
198        // This is a simplified mock - in real tests we'd use Pingora's test utilities
199        // For now, this shows the intended API
200        unimplemented!("Mock session creation requires Pingora test utilities")
201    }
202
203    #[test]
204    fn test_path_prefix_matching() {
205        let config = RouteConfig {
206            path_prefix: Some("/api".to_string()),
207            path_exact: None,
208            path_regex: None,
209            host: None,
210            methods: None,
211            header: None,
212            upstream: "api".to_string(),
213            strip_prefix: false,
214            rewrite: None,
215            add_headers: vec![],
216            read_timeout_secs: None,
217            connect_timeout_secs: None,
218            description: None,
219        };
220
221        let route = Route::from_config(&config).unwrap();
222        assert_eq!(route.upstream_name, "api");
223        assert_eq!(route.path_prefix, Some("/api".to_string()));
224    }
225
226    #[test]
227    fn test_strip_prefix() {
228        let route = Route {
229            path_prefix: Some("/api".to_string()),
230            path_exact: None,
231            path_regex: None,
232            host: None,
233            methods: None,
234            header: None,
235            upstream_name: "api".to_string(),
236            strip_prefix: true,
237            rewrite: None,
238            description: None,
239        };
240
241        assert_eq!(route.transform_path("/api/users"), "/users");
242        assert_eq!(route.transform_path("/api"), "/");
243        assert_eq!(route.transform_path("/other"), "/other");
244    }
245
246    #[test]
247    fn test_rewrite_priority() {
248        let route = Route {
249            path_prefix: Some("/old".to_string()),
250            path_exact: None,
251            path_regex: None,
252            host: None,
253            methods: None,
254            header: None,
255            upstream_name: "api".to_string(),
256            strip_prefix: true,
257            rewrite: Some("/new".to_string()),
258            description: None,
259        };
260
261        // Rewrite takes priority over strip_prefix
262        assert_eq!(route.transform_path("/old/users"), "/new");
263    }
264}