Skip to main content

featherbit/routing/
mod.rs

1//! Route matching: decides which configured route (and thus which policy
2//! graph) handles an incoming request, based on path, method, header, and
3//! host rules from `gateway.yaml`.
4
5use crate::config::MatchRule;
6
7/// Matches an incoming request against a route's match rule.
8///
9/// All criteria present in the rule must match (logical AND); absent
10/// criteria match anything. Semantics per criterion:
11/// - **path** — exact match, trailing `/*` prefix wildcard, or `*` path
12///   segment wildcards (see `match_path`);
13/// - **methods** — case-insensitive; an empty list matches any method;
14/// - **headers** — every required key must be present with an exactly equal
15///   value (header names compared case-insensitively, values exactly);
16/// - **host** — case-insensitive equality.
17pub fn matches_route(
18    rule: &MatchRule,
19    path: &str,
20    method: &str,
21    headers: &[(String, String)],
22    host: &str,
23) -> bool {
24    // Path matching
25    if let Some(ref pattern) = rule.path {
26        if !match_path(pattern, path) {
27            return false;
28        }
29    }
30
31    // Method matching
32    if !rule.methods.is_empty() {
33        let method_upper = method.to_uppercase();
34        if !rule
35            .methods
36            .iter()
37            .any(|m| m.to_uppercase() == method_upper)
38        {
39            return false;
40        }
41    }
42
43    // Header matching
44    for (required_key, required_value) in &rule.headers {
45        let key_lower = required_key.to_lowercase();
46        let found = headers
47            .iter()
48            .any(|(k, v)| k.to_lowercase() == key_lower && v == required_value);
49        if !found {
50            return false;
51        }
52    }
53
54    // Host matching
55    if let Some(ref required_host) = rule.host {
56        if !host.eq_ignore_ascii_case(required_host) {
57            return false;
58        }
59    }
60
61    true
62}
63
64/// Matches a path pattern against an actual path.
65/// Supports:
66///   - Exact match: `/api/v1/users`
67///   - Prefix with wildcard: `/api/v1/*`
68///   - Glob segments: `/api/*/users`
69///
70/// A trailing `/*` matches the bare prefix itself (`/api/v1`) and anything
71/// below it (`/api/v1/users/123`), but not sibling prefixes (`/api/v10`).
72/// A `*` segment matches exactly one path segment, so segment patterns
73/// require the same number of segments as the path.
74fn match_path(pattern: &str, path: &str) -> bool {
75    if pattern == path {
76        return true;
77    }
78
79    // Trailing wildcard: /api/v1/* matches /api/v1/anything
80    if let Some(prefix) = pattern.strip_suffix("/*") {
81        return path == prefix || path.starts_with(&format!("{}/", prefix));
82    }
83
84    // Segment wildcards: /api/*/users
85    let pattern_parts: Vec<&str> = pattern.split('/').collect();
86    let path_parts: Vec<&str> = path.split('/').collect();
87
88    if pattern_parts.len() != path_parts.len() {
89        return false;
90    }
91
92    pattern_parts
93        .iter()
94        .zip(path_parts.iter())
95        .all(|(p, s)| *p == "*" || p == s)
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use std::collections::HashMap;
102
103    fn rule(path: Option<&str>, methods: &[&str]) -> MatchRule {
104        MatchRule {
105            path: path.map(String::from),
106            methods: methods.iter().map(|s| s.to_string()).collect(),
107            headers: HashMap::new(),
108            host: None,
109        }
110    }
111
112    #[test]
113    fn test_exact_path() {
114        let r = rule(Some("/api/v1/users"), &[]);
115        assert!(matches_route(&r, "/api/v1/users", "GET", &[], ""));
116        assert!(!matches_route(&r, "/api/v1/other", "GET", &[], ""));
117    }
118
119    #[test]
120    fn test_wildcard_path() {
121        let r = rule(Some("/api/v1/*"), &[]);
122        assert!(matches_route(&r, "/api/v1/users", "GET", &[], ""));
123        assert!(matches_route(&r, "/api/v1/users/123", "GET", &[], ""));
124        assert!(matches_route(&r, "/api/v1", "GET", &[], ""));
125        assert!(!matches_route(&r, "/api/v2/users", "GET", &[], ""));
126    }
127
128    #[test]
129    fn test_segment_wildcard() {
130        let r = rule(Some("/api/*/users"), &[]);
131        assert!(matches_route(&r, "/api/v1/users", "GET", &[], ""));
132        assert!(matches_route(&r, "/api/v2/users", "GET", &[], ""));
133        assert!(!matches_route(&r, "/api/v1/posts", "GET", &[], ""));
134    }
135
136    #[test]
137    fn test_method_filter() {
138        let r = rule(Some("/api/*"), &["GET", "POST"]);
139        assert!(matches_route(&r, "/api/users", "GET", &[], ""));
140        assert!(matches_route(&r, "/api/users", "POST", &[], ""));
141        assert!(!matches_route(&r, "/api/users", "DELETE", &[], ""));
142    }
143
144    #[test]
145    fn test_header_filter() {
146        let mut r = rule(Some("/api/*"), &[]);
147        r.headers
148            .insert("x-api-version".to_string(), "1".to_string());
149
150        let headers = vec![("x-api-version".to_string(), "1".to_string())];
151        assert!(matches_route(&r, "/api/users", "GET", &headers, ""));
152
153        let headers = vec![("x-api-version".to_string(), "2".to_string())];
154        assert!(!matches_route(&r, "/api/users", "GET", &headers, ""));
155    }
156
157    #[test]
158    fn test_host_filter() {
159        let mut r = rule(Some("/api/*"), &[]);
160        r.host = Some("example.com".to_string());
161
162        assert!(matches_route(&r, "/api/users", "GET", &[], "example.com"));
163        assert!(!matches_route(&r, "/api/users", "GET", &[], "other.com"));
164    }
165}