cardinal_base/destinations/
matcher.rs1use std::collections::HashMap;
2use std::sync::Arc;
3
4use cardinal_config::{DestinationMatch, DestinationMatchValue};
5use cardinal_errors::CardinalError;
6use pingora::http::RequestHeader;
7use regex::Regex;
8
9use crate::destinations::container::DestinationWrapper;
10
11pub struct DestinationMatcherIndex {
12 exact_host: HashMap<String, Vec<CompiledDestination>>,
13 regex_host: Vec<RegexHostEntry>,
14 hostless: Vec<CompiledDestination>,
15}
16
17impl DestinationMatcherIndex {
18 pub fn new(
19 destinations: impl Iterator<Item = Arc<DestinationWrapper>>,
20 ) -> Result<Self, CardinalError> {
21 let mut exact_host: HashMap<String, Vec<CompiledDestination>> = HashMap::new();
22 let mut regex_host: Vec<RegexHostEntry> = Vec::new();
23 let mut hostless: Vec<CompiledDestination> = Vec::new();
24
25 for wrapper in destinations {
26 let Some(matchers) = wrapper.destination.r#match.as_ref() else {
27 continue;
28 };
29
30 if matchers.is_empty() {
31 continue;
32 }
33
34 for matcher in matchers {
35 let compiled = CompiledEntry::try_from(wrapper.clone(), matcher)?;
36 match compiled.host_matcher {
37 Some(CompiledHostMatcher::Exact(host)) => {
38 exact_host
39 .entry(host)
40 .or_default()
41 .push(compiled.destination);
42 }
43 Some(CompiledHostMatcher::Regex(regex)) => {
44 regex_host.push(RegexHostEntry {
45 matcher: regex,
46 destination: compiled.destination,
47 });
48 }
49 None => hostless.push(compiled.destination),
50 }
51 }
52 }
53
54 Ok(Self {
55 exact_host,
56 regex_host,
57 hostless,
58 })
59 }
60
61 pub fn resolve(&self, req: &RequestHeader) -> Option<Arc<DestinationWrapper>> {
62 let host = request_host(req);
63 let path = req.uri.path();
64
65 if let Some(host) = host.as_deref() {
66 if let Some(entries) = self.exact_host.get(host) {
67 if let Some(wrapper) = entries
70 .iter()
71 .find_map(|destination| destination.matches(path))
72 {
73 return Some(wrapper);
74 }
75 }
76
77 for entry in &self.regex_host {
78 if entry.matcher.is_match(host) {
79 if let Some(wrapper) = entry.destination.matches(path) {
80 return Some(wrapper);
81 }
82 }
83 }
84 }
85
86 for destination in &self.hostless {
87 if let Some(wrapper) = destination.matches(path) {
88 return Some(wrapper);
89 }
90 }
91
92 None
93 }
94}
95
96struct RegexHostEntry {
97 matcher: Regex,
98 destination: CompiledDestination,
99}
100
101struct CompiledEntry {
102 host_matcher: Option<CompiledHostMatcher>,
103 destination: CompiledDestination,
104}
105
106impl CompiledEntry {
107 fn try_from(
108 wrapper: Arc<DestinationWrapper>,
109 matcher: &DestinationMatch,
110 ) -> Result<Self, CardinalError> {
111 let host_matcher = compile_host_matcher(matcher.host.as_ref())?;
112 let path_prefix = compile_path_prefix(matcher.path_prefix.as_ref())?;
113 let path_exact = matcher.path_exact.clone();
114
115 let destination = CompiledDestination {
116 wrapper,
117 path_prefix,
118 path_exact,
119 };
120
121 Ok(Self {
122 host_matcher,
123 destination,
124 })
125 }
126}
127
128enum CompiledHostMatcher {
129 Exact(String),
130 Regex(Regex),
131}
132
133struct CompiledDestination {
134 wrapper: Arc<DestinationWrapper>,
135 path_prefix: Option<CompiledPathMatcher>,
136 path_exact: Option<String>,
137}
138
139impl CompiledDestination {
140 fn matches(&self, path: &str) -> Option<Arc<DestinationWrapper>> {
141 if self.matches_path(path) {
142 Some(self.wrapper.clone())
143 } else {
144 None
145 }
146 }
147
148 fn matches_path(&self, path: &str) -> bool {
149 if let Some(exact) = &self.path_exact {
150 if path != exact {
151 return false;
152 }
153 }
154
155 if let Some(prefix) = &self.path_prefix {
156 return prefix.matches(path);
157 }
158
159 true
160 }
161}
162
163enum CompiledPathMatcher {
164 Prefix(String),
165 Regex(Regex),
166}
167
168impl CompiledPathMatcher {
169 fn matches(&self, path: &str) -> bool {
170 match self {
171 CompiledPathMatcher::Prefix(prefix) => path.starts_with(prefix),
172 CompiledPathMatcher::Regex(regex) => regex.is_match(path),
173 }
174 }
175}
176
177fn compile_host_matcher(
178 value: Option<&DestinationMatchValue>,
179) -> Result<Option<CompiledHostMatcher>, CardinalError> {
180 match value {
181 Some(DestinationMatchValue::String(host)) => {
182 Ok(Some(CompiledHostMatcher::Exact(host.to_ascii_lowercase())))
183 }
184 Some(DestinationMatchValue::Regex { regex }) => {
185 let compiled = Regex::new(regex).map_err(|err| {
186 CardinalError::Other(format!("invalid host regex '{regex}': {err}"))
187 })?;
188 Ok(Some(CompiledHostMatcher::Regex(compiled)))
189 }
190 None => Ok(None),
191 }
192}
193
194fn compile_path_prefix(
195 value: Option<&DestinationMatchValue>,
196) -> Result<Option<CompiledPathMatcher>, CardinalError> {
197 match value {
198 Some(DestinationMatchValue::String(prefix)) => {
199 Ok(Some(CompiledPathMatcher::Prefix(prefix.clone())))
200 }
201 Some(DestinationMatchValue::Regex { regex }) => {
202 let compiled = Regex::new(regex).map_err(|err| {
203 CardinalError::Other(format!("invalid path regex '{regex}': {err}"))
204 })?;
205 Ok(Some(CompiledPathMatcher::Regex(compiled)))
206 }
207 None => Ok(None),
208 }
209}
210
211fn request_host(req: &RequestHeader) -> Option<String> {
212 let host = req.uri.host().map(|h| h.to_string()).or_else(|| {
213 req.headers
214 .get("host")
215 .and_then(|v| v.to_str().ok())
216 .map(|s| s.to_string())
217 })?;
218
219 let host_no_port = host.split(':').next()?.to_ascii_lowercase();
220 Some(host_no_port)
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use cardinal_config::{Destination, DestinationMatch};
227 use http::Method;
228 use pingora::http::RequestHeader;
229
230 fn build_destination(
231 name: &str,
232 host: Option<DestinationMatchValue>,
233 path_prefix: Option<DestinationMatchValue>,
234 path_exact: Option<&str>,
235 ) -> Arc<DestinationWrapper> {
236 build_destination_with_matchers(
237 name,
238 Some(vec![DestinationMatch {
239 host,
240 path_prefix,
241 path_exact: path_exact.map(|s| s.to_string()),
242 }]),
243 )
244 }
245
246 fn build_destination_with_matchers(
247 name: &str,
248 matchers: Option<Vec<DestinationMatch>>,
249 ) -> Arc<DestinationWrapper> {
250 let destination = Destination {
251 name: name.to_string(),
252 url: "https://example.com".to_string(),
253 health_check: None,
254 default: false,
255 r#match: matchers,
256 routes: Vec::new(),
257 middleware: Vec::new(),
258 timeout: None,
259 retry: None,
260 };
261
262 Arc::new(DestinationWrapper::new(destination, None))
263 }
264
265 fn build_request(host: &str, path: &str) -> RequestHeader {
266 let mut req = RequestHeader::build(Method::GET, path.as_bytes(), None).unwrap();
267 req.insert_header("host", host).unwrap();
268 req
269 }
270
271 #[test]
272 fn matches_exact_host() {
273 let destination = build_destination(
274 "customer_service",
275 Some(DestinationMatchValue::String("api.example.com".into())),
276 None,
277 None,
278 );
279
280 let matcher = DestinationMatcherIndex::new(vec![destination.clone()].into_iter()).unwrap();
281 let req = build_request("API.EXAMPLE.com", "/v1/customers");
282
283 let resolved = matcher.resolve(&req).unwrap();
284 assert_eq!(resolved.destination.name, "customer_service");
285 }
286
287 #[test]
288 fn matches_host_regex() {
289 let destination = build_destination(
290 "billing",
291 Some(DestinationMatchValue::Regex {
292 regex: "^api\\.(eu|us)\\.example\\.com$".into(),
293 }),
294 None,
295 None,
296 );
297
298 let matcher = DestinationMatcherIndex::new(vec![destination.clone()].into_iter()).unwrap();
299 let req = build_request("api.eu.example.com", "/billing");
300
301 let resolved = matcher.resolve(&req).unwrap();
302 assert_eq!(resolved.destination.name, "billing");
303 }
304
305 #[test]
306 fn supports_multiple_match_entries_per_destination() {
307 let destination = build_destination_with_matchers(
308 "api",
309 Some(vec![
310 DestinationMatch {
311 host: Some(DestinationMatchValue::String("api.example.com".into())),
312 path_prefix: Some(DestinationMatchValue::String("/billing".into())),
313 path_exact: None,
314 },
315 DestinationMatch {
316 host: Some(DestinationMatchValue::String("api.example.com".into())),
317 path_prefix: Some(DestinationMatchValue::String("/support".into())),
318 path_exact: None,
319 },
320 ]),
321 );
322
323 let matcher = DestinationMatcherIndex::new(vec![destination.clone()].into_iter()).unwrap();
324
325 let billing_req = build_request("api.example.com", "/billing/payments");
326 let billing_destination = matcher.resolve(&billing_req).unwrap();
327 assert_eq!(billing_destination.destination.name, "api");
328
329 let support_req = build_request("api.example.com", "/support/chat");
330 let support_destination = matcher.resolve(&support_req).unwrap();
331 assert_eq!(support_destination.destination.name, "api");
332
333 let missing_req = build_request("api.example.com", "/reports");
334 assert!(matcher.resolve(&missing_req).is_none());
335 }
336
337 #[test]
338 fn exact_host_entries_are_prioritized_before_regex() {
339 let destination = build_destination_with_matchers(
340 "api",
341 Some(vec![
342 DestinationMatch {
343 host: Some(DestinationMatchValue::String("api.example.com".into())),
344 path_prefix: Some(DestinationMatchValue::String("/billing".into())),
345 path_exact: None,
346 },
347 DestinationMatch {
348 host: Some(DestinationMatchValue::Regex {
349 regex: "^api\\..+".into(),
350 }),
351 path_prefix: Some(DestinationMatchValue::String("/regex".into())),
352 path_exact: None,
353 },
354 ]),
355 );
356
357 let matcher = DestinationMatcherIndex::new(vec![destination.clone()].into_iter()).unwrap();
358
359 let exact_req = build_request("api.example.com", "/billing/invoices");
360 let exact_destination = matcher.resolve(&exact_req).unwrap();
361 assert_eq!(exact_destination.destination.name, "api");
362
363 let regex_req = build_request("api.example.com", "/regex/search");
364 let regex_destination = matcher.resolve(®ex_req).unwrap();
365 assert_eq!(regex_destination.destination.name, "api");
366 }
367
368 #[test]
369 fn matches_path_prefix() {
370 let hostless = build_destination(
371 "helpdesk",
372 None,
373 Some(DestinationMatchValue::String("/helpdesk".into())),
374 None,
375 );
376
377 let matcher = DestinationMatcherIndex::new(vec![hostless.clone()].into_iter()).unwrap();
378 let req = build_request("any.example.com", "/helpdesk/ticket");
379
380 let resolved = matcher.resolve(&req).unwrap();
381 assert_eq!(resolved.destination.name, "helpdesk");
382 }
383
384 #[test]
385 fn matches_path_regex() {
386 let hostless = build_destination(
387 "reports",
388 None,
389 Some(DestinationMatchValue::Regex {
390 regex: "^/reports/(daily|weekly)".into(),
391 }),
392 None,
393 );
394
395 let matcher = DestinationMatcherIndex::new(vec![hostless.clone()].into_iter()).unwrap();
396 let req = build_request("other.example.com", "/reports/daily/summary");
397
398 let resolved = matcher.resolve(&req).unwrap();
399 assert_eq!(resolved.destination.name, "reports");
400 }
401
402 #[test]
403 fn respects_path_exact() {
404 let host = build_destination(
405 "status",
406 Some(DestinationMatchValue::String("status.example.com".into())),
407 None,
408 Some("/healthz"),
409 );
410
411 let matcher = DestinationMatcherIndex::new(vec![host.clone()].into_iter()).unwrap();
412 let req = build_request("status.example.com", "/healthz");
413
414 assert!(matcher.resolve(&req).is_some());
415
416 let req_non_matching = build_request("status.example.com", "/healthz/extra");
417 assert!(matcher.resolve(&req_non_matching).is_none());
418 }
419
420 #[test]
421 fn host_priority_before_hostless() {
422 let host_destination = build_destination(
423 "api",
424 Some(DestinationMatchValue::String("api.example.com".into())),
425 None,
426 None,
427 );
428 let hostless = build_destination("fallback", None, None, None);
429
430 let matcher = DestinationMatcherIndex::new(
431 vec![hostless.clone(), host_destination.clone()].into_iter(),
432 )
433 .unwrap();
434 let req = build_request("api.example.com", "/anything");
435
436 let resolved = matcher.resolve(&req).unwrap();
437 assert_eq!(resolved.destination.name, "api");
438 }
439}