use std::collections::HashMap;
use http::Method;
use super::openapi::OperationSpec;
#[derive(Debug, Clone)]
pub struct PathRouter {
root: RadixNode,
}
#[derive(Debug)]
pub struct RouteMatch {
pub operation_index: usize,
pub path_params: HashMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SegmentMatcher {
Literal(String),
Param(String),
}
#[derive(Debug, Clone, Default)]
struct RadixNode {
children: Vec<RadixEdge>,
operations: Vec<(Method, usize)>,
}
#[derive(Debug, Clone)]
struct RadixEdge {
segment: SegmentMatcher,
child: RadixNode,
}
impl PathRouter {
pub fn build(operations: &[OperationSpec]) -> Self {
let mut root = RadixNode::default();
for (index, operation) in operations.iter().enumerate() {
let segments = parse_segments(&operation.path_template);
insert(&mut root, &segments, operation.method.clone(), index);
}
Self { root }
}
pub fn match_route(&self, method: &Method, path: &str) -> Option<RouteMatch> {
let segments = split_path(path);
let mut params = HashMap::new();
let node = walk(&self.root, &segments, &mut params)?;
node.operations
.iter()
.find(|(m, _)| m == method)
.map(|&(_, operation_index)| RouteMatch { operation_index, path_params: params })
}
}
fn parse_segments(template: &str) -> Vec<SegmentMatcher> {
template
.trim_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.map(|s| {
if s.starts_with('{') && s.ends_with('}') && s.len() > 2 {
SegmentMatcher::Param(s[1..s.len() - 1].to_owned())
} else {
SegmentMatcher::Literal(s.to_owned())
}
})
.collect()
}
fn split_path(path: &str) -> Vec<&str> {
path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect()
}
fn insert(node: &mut RadixNode, segments: &[SegmentMatcher], method: Method, index: usize) {
if segments.is_empty() {
node.operations.push((method, index));
return;
}
let Some((head, tail)) = segments.split_first() else {
return;
};
for edge in &mut node.children {
if &edge.segment == head {
insert(&mut edge.child, tail, method, index);
return;
}
}
let mut child = RadixNode::default();
insert(&mut child, tail, method, index);
node.children.push(RadixEdge { segment: head.clone(), child });
}
fn walk<'a>(
node: &'a RadixNode,
segments: &[&str],
params: &mut HashMap<String, String>,
) -> Option<&'a RadixNode> {
if segments.is_empty() {
return Some(node);
}
let (head, tail) = segments.split_first()?;
for edge in &node.children {
if let SegmentMatcher::Literal(ref lit) = edge.segment &&
lit == head &&
let Some(result) = walk(&edge.child, tail, params)
{
return Some(result);
}
}
for edge in &node.children {
if let SegmentMatcher::Param(ref name) = edge.segment {
let mut candidate_params = params.clone();
candidate_params.insert(name.clone(), (*head).to_owned());
if let Some(result) = walk(&edge.child, tail, &mut candidate_params) {
*params = candidate_params;
return Some(result);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn op(method: Method, path: &str) -> OperationSpec {
OperationSpec {
method,
path_template: path.to_owned(),
operation_id: None,
parameters: vec![],
request_body_schema: None,
request_body_required: false,
responses: vec![],
callbacks: vec![],
}
}
#[test]
fn literal_match() {
let ops = vec![op(Method::GET, "/pets")];
let router = PathRouter::build(&ops);
let m = router.match_route(&Method::GET, "/pets");
assert!(m.is_some());
let m = m.unwrap_or_else(|| unreachable!());
assert_eq!(m.operation_index, 0);
assert!(m.path_params.is_empty());
}
#[test]
fn param_match() {
let ops = vec![op(Method::GET, "/pets/{petId}")];
let router = PathRouter::build(&ops);
let m = router.match_route(&Method::GET, "/pets/42");
assert!(m.is_some());
let m = m.unwrap_or_else(|| unreachable!());
assert_eq!(m.operation_index, 0);
assert_eq!(m.path_params.get("petId").map(String::as_str), Some("42"));
}
#[test]
fn no_match_wrong_path() {
let ops = vec![op(Method::GET, "/pets")];
let router = PathRouter::build(&ops);
assert!(router.match_route(&Method::GET, "/dogs").is_none());
}
#[test]
fn no_match_wrong_method() {
let ops = vec![op(Method::GET, "/pets")];
let router = PathRouter::build(&ops);
assert!(router.match_route(&Method::POST, "/pets").is_none());
}
#[test]
fn method_disambiguation() {
let ops = vec![
op(Method::GET, "/pets"),
op(Method::POST, "/pets"),
op(Method::DELETE, "/pets/{petId}"),
];
let router = PathRouter::build(&ops);
let get = router.match_route(&Method::GET, "/pets");
assert!(get.is_some());
assert_eq!(get.unwrap_or_else(|| unreachable!()).operation_index, 0);
let post = router.match_route(&Method::POST, "/pets");
assert!(post.is_some());
assert_eq!(post.unwrap_or_else(|| unreachable!()).operation_index, 1);
let delete = router.match_route(&Method::DELETE, "/pets/7");
assert!(delete.is_some());
let delete = delete.unwrap_or_else(|| unreachable!());
assert_eq!(delete.operation_index, 2);
assert_eq!(delete.path_params.get("petId").map(String::as_str), Some("7"));
}
#[test]
fn coexisting_paths() {
let ops = vec![
op(Method::GET, "/pets"),
op(Method::GET, "/pets/{petId}"),
op(Method::GET, "/pets/{petId}/toys"),
op(Method::GET, "/pets/{petId}/toys/{toyId}"),
];
let router = PathRouter::build(&ops);
let m0 = router.match_route(&Method::GET, "/pets");
assert_eq!(m0.unwrap_or_else(|| unreachable!()).operation_index, 0);
let m1 = router.match_route(&Method::GET, "/pets/3");
let m1 = m1.unwrap_or_else(|| unreachable!());
assert_eq!(m1.operation_index, 1);
assert_eq!(m1.path_params.get("petId").map(String::as_str), Some("3"));
let m2 = router.match_route(&Method::GET, "/pets/3/toys");
let m2 = m2.unwrap_or_else(|| unreachable!());
assert_eq!(m2.operation_index, 2);
assert_eq!(m2.path_params.get("petId").map(String::as_str), Some("3"));
let m3 = router.match_route(&Method::GET, "/pets/3/toys/99");
let m3 = m3.unwrap_or_else(|| unreachable!());
assert_eq!(m3.operation_index, 3);
assert_eq!(m3.path_params.get("petId").map(String::as_str), Some("3"));
assert_eq!(m3.path_params.get("toyId").map(String::as_str), Some("99"));
}
#[test]
fn trailing_slash_normalisation() {
let ops = vec![op(Method::GET, "/pets")];
let router = PathRouter::build(&ops);
assert!(router.match_route(&Method::GET, "/pets/").is_some());
let ops2 = vec![op(Method::GET, "/pets/")];
let router2 = PathRouter::build(&ops2);
assert!(router2.match_route(&Method::GET, "/pets").is_some());
}
#[test]
fn literal_preferred_over_param() {
let ops = vec![op(Method::GET, "/pets/{petId}"), op(Method::GET, "/pets/mine")];
let router = PathRouter::build(&ops);
let mine = router.match_route(&Method::GET, "/pets/mine");
let mine = mine.unwrap_or_else(|| unreachable!());
assert_eq!(mine.operation_index, 1);
assert!(mine.path_params.is_empty());
let other = router.match_route(&Method::GET, "/pets/42");
let other = other.unwrap_or_else(|| unreachable!());
assert_eq!(other.operation_index, 0);
assert_eq!(other.path_params.get("petId").map(String::as_str), Some("42"));
}
#[test]
fn root_path() {
let ops = vec![op(Method::GET, "/")];
let router = PathRouter::build(&ops);
assert!(router.match_route(&Method::GET, "/").is_some());
}
#[test]
fn multi_param_segments() {
let ops = vec![op(Method::GET, "/orgs/{orgId}/repos/{repoId}/issues/{issueId}")];
let router = PathRouter::build(&ops);
let m = router.match_route(&Method::GET, "/orgs/acme/repos/widget/issues/123");
let m = m.unwrap_or_else(|| unreachable!());
assert_eq!(m.path_params.get("orgId").map(String::as_str), Some("acme"));
assert_eq!(m.path_params.get("repoId").map(String::as_str), Some("widget"));
assert_eq!(m.path_params.get("issueId").map(String::as_str), Some("123"));
}
#[test]
fn extra_segments_no_match() {
let ops = vec![op(Method::GET, "/pets")];
let router = PathRouter::build(&ops);
assert!(router.match_route(&Method::GET, "/pets/1/extra").is_none());
}
#[test]
fn fewer_segments_no_match() {
let ops = vec![op(Method::GET, "/pets/{petId}/toys")];
let router = PathRouter::build(&ops);
assert!(router.match_route(&Method::GET, "/pets/1").is_none());
}
}