1use anyhow::Result;
7use pingora::prelude::*;
8use regex::Regex;
9use tracing::debug;
10
11use crate::config::toml::{HeaderMatch, RouteConfig};
12
13#[derive(Debug)]
15pub struct Route {
16 path_prefix: Option<String>,
18
19 path_exact: Option<String>,
21
22 path_regex: Option<Regex>,
24
25 host: Option<String>,
27
28 methods: Option<Vec<String>>,
30
31 header: Option<HeaderMatch>,
33
34 pub upstream_name: String,
36
37 pub strip_prefix: bool,
39
40 pub rewrite: Option<String>,
42
43 #[allow(dead_code)]
45 description: Option<String>,
46}
47
48impl Route {
49 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 pub fn matches(&self, session: &Session) -> bool {
73 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 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 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 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 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 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 debug!("Route: match all (no path criteria)");
166 true
167 }
168
169 pub fn transform_path(&self, original: &str) -> String {
171 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 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 assert_eq!(route.transform_path("/old/users"), "/new");
263 }
264}