use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathSegment {
Literal(String),
Param(String),
}
#[derive(Debug, Clone)]
pub struct RestEndpoint<T> {
pub method: String,
pub segments: Vec<PathSegment>,
pub payload: T,
}
#[derive(Debug, Clone)]
pub struct MatchResult<T> {
pub payload: T,
pub path_params: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub enum MatchOutcome<T> {
Found(MatchResult<T>),
NotFound,
Ambiguous,
}
pub fn try_match(request: &[&str], template: &[PathSegment]) -> Option<HashMap<String, String>> {
if request.len() != template.len() {
return None;
}
let mut params = HashMap::new();
for (req, tmpl) in request.iter().zip(template.iter()) {
match tmpl {
PathSegment::Literal(lit) => {
if req != &lit.as_str() {
return None;
}
}
PathSegment::Param(name) => {
if name.is_empty() {
return None;
}
params.insert(name.clone(), (*req).to_string());
}
}
}
Some(params)
}
pub fn match_endpoint<T: Clone>(
method: &str,
path: &str,
endpoints: &[RestEndpoint<T>],
) -> MatchOutcome<T> {
let request_segments: Vec<&str> = path
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
let mut best_specificity: Option<usize> = None;
let mut best_index: Option<usize> = None;
let mut best_params: Option<HashMap<String, String>> = None;
let mut ambiguous = false;
for (idx, ep) in endpoints.iter().enumerate() {
if ep.method != method {
continue;
}
let Some(params) = try_match(&request_segments, &ep.segments) else {
continue;
};
let specificity = ep
.segments
.iter()
.filter(|s| matches!(s, PathSegment::Literal(_)))
.count();
match best_specificity {
None => {
best_specificity = Some(specificity);
best_index = Some(idx);
best_params = Some(params);
ambiguous = false;
}
Some(prev) if specificity > prev => {
best_specificity = Some(specificity);
best_index = Some(idx);
best_params = Some(params);
ambiguous = false;
}
Some(prev) if specificity == prev => {
if best_index == Some(idx) {
continue;
}
ambiguous = true;
}
_ => {}
}
}
if ambiguous {
return MatchOutcome::Ambiguous;
}
let (idx, params) = match (best_index, best_params) {
(Some(i), Some(p)) => (i, p),
_ => return MatchOutcome::NotFound,
};
MatchOutcome::Found(MatchResult {
payload: endpoints[idx].payload.clone(),
path_params: params,
})
}
pub fn parse_path_template(template: &str) -> Vec<PathSegment> {
template
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.map(|seg| {
if let Some(inner) = seg.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
PathSegment::Param(inner.to_string())
} else {
PathSegment::Literal(seg.to_string())
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn ep(method: &str, template: &str) -> RestEndpoint<String> {
RestEndpoint {
method: method.to_string(),
segments: parse_path_template(template),
payload: template.to_string(), }
}
#[test]
fn exact_match_wins_over_template() {
let endpoints = vec![ep("GET", "/users/{id}"), ep("GET", "/users/me")];
let result = match match_endpoint("GET", "/users/me", &endpoints) {
MatchOutcome::Found(m) => m,
other => panic!("expected Found, got {:?}", other),
};
assert_eq!(result.payload, "/users/me".to_string());
assert!(result.path_params.is_empty());
}
#[test]
fn template_match_extracts_params() {
let endpoints = vec![ep("GET", "/users/{id}")];
let result = match match_endpoint("GET", "/users/42", &endpoints) {
MatchOutcome::Found(m) => m,
other => panic!("expected Found, got {:?}", other),
};
assert_eq!(result.payload, "/users/{id}".to_string());
assert_eq!(result.path_params.get("id"), Some(&"42".to_string()));
}
#[test]
fn different_methods_same_path() {
let endpoints = vec![ep("GET", "/users"), ep("POST", "/users")];
let get = match match_endpoint("GET", "/users", &endpoints) {
MatchOutcome::Found(m) => m,
other => panic!("expected Found, got {:?}", other),
};
let post = match match_endpoint("POST", "/users", &endpoints) {
MatchOutcome::Found(m) => m,
other => panic!("expected Found, got {:?}", other),
};
assert_eq!(get.payload, "/users".to_string());
assert_eq!(post.payload, "/users".to_string());
}
#[test]
fn no_match_returns_notfound() {
let endpoints = vec![ep("GET", "/users")];
assert!(matches!(
match_endpoint("DELETE", "/users", &endpoints),
MatchOutcome::NotFound
));
assert!(matches!(
match_endpoint("GET", "/orders", &endpoints),
MatchOutcome::NotFound
));
assert!(matches!(
match_endpoint("GET", "/users/42", &endpoints),
MatchOutcome::NotFound
));
}
#[test]
fn trailing_slash_is_normalized() {
let endpoints = vec![ep("GET", "/users/{id}")];
let result = match match_endpoint("GET", "/users/42/", &endpoints) {
MatchOutcome::Found(m) => m,
other => panic!("expected Found, got {:?}", other),
};
assert_eq!(result.path_params.get("id"), Some(&"42".to_string()));
}
#[test]
fn ambiguous_two_templates_same_specificity_is_reported() {
let endpoints = vec![ep("GET", "/users/{id}"), ep("GET", "/users/{name}")];
assert!(matches!(
match_endpoint("GET", "/users/42", &endpoints),
MatchOutcome::Ambiguous
));
}
#[test]
fn non_overlapping_literals_are_not_ambiguous() {
let endpoints = vec![ep("GET", "/users/{id}"), ep("GET", "/admins/{id}")];
let result = match match_endpoint("GET", "/users/42", &endpoints) {
MatchOutcome::Found(m) => m,
other => panic!("expected Found, got {:?}", other),
};
assert_eq!(result.payload, "/users/{id}".to_string());
}
#[test]
fn try_match_length_mismatch() {
let template = vec![PathSegment::Literal("users".into())];
assert!(try_match(&["users", "42"], &template).is_none());
assert!(try_match(&[], &template).is_none());
}
#[test]
fn try_match_literal_mismatch() {
let template = vec![
PathSegment::Literal("users".into()),
PathSegment::Param("id".into()),
];
assert!(try_match(&["orders", "42"], &template).is_none());
}
#[test]
fn try_match_param_captures_value() {
let template = vec![
PathSegment::Literal("users".into()),
PathSegment::Param("id".into()),
];
let params = try_match(&["users", "abc"], &template).unwrap();
assert_eq!(params.get("id"), Some(&"abc".to_string()));
}
#[test]
fn parse_path_template_segments() {
let segs = parse_path_template("/users/{id}/orders/{orderId}");
assert_eq!(
segs,
vec![
PathSegment::Literal("users".into()),
PathSegment::Param("id".into()),
PathSegment::Literal("orders".into()),
PathSegment::Param("orderId".into()),
]
);
}
#[test]
fn parse_path_template_literal_only() {
let segs = parse_path_template("/users/me");
assert_eq!(
segs,
vec![
PathSegment::Literal("users".into()),
PathSegment::Literal("me".into())
]
);
}
#[test]
fn specificity_picks_literals_over_params() {
let endpoints = vec![ep("GET", "/users/{id}"), ep("GET", "/users/me")];
let r = match match_endpoint("GET", "/users/me", &endpoints) {
MatchOutcome::Found(m) => m,
other => panic!("expected Found, got {:?}", other),
};
assert_eq!(r.payload, "/users/me");
let r = match match_endpoint("GET", "/users/42", &endpoints) {
MatchOutcome::Found(m) => m,
other => panic!("expected Found, got {:?}", other),
};
assert_eq!(r.payload, "/users/{id}".to_string());
assert_eq!(r.path_params.get("id"), Some(&"42".to_string()));
}
}