1use std::collections::HashMap;
7
8use http::Method;
9
10use super::openapi::OperationSpec;
11
12#[derive(Debug, Clone)]
18pub struct PathRouter {
19 root: RadixNode,
20}
21
22#[derive(Debug)]
24pub struct RouteMatch {
25 pub operation_index: usize,
27 pub path_params: HashMap<String, String>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum SegmentMatcher {
34 Literal(String),
36 Param(String),
38}
39
40#[derive(Debug, Clone, Default)]
46struct RadixNode {
47 children: Vec<RadixEdge>,
49 operations: Vec<(Method, usize)>,
51}
52
53#[derive(Debug, Clone)]
55struct RadixEdge {
56 segment: SegmentMatcher,
57 child: RadixNode,
58}
59
60impl PathRouter {
65 pub fn build(operations: &[OperationSpec]) -> Self {
71 let mut root = RadixNode::default();
72
73 for (index, operation) in operations.iter().enumerate() {
74 let segments = parse_segments(&operation.path_template);
75 insert(&mut root, &segments, operation.method.clone(), index);
76 }
77
78 Self { root }
79 }
80
81 pub fn match_route(&self, method: &Method, path: &str) -> Option<RouteMatch> {
85 let segments = split_path(path);
86 let mut params = HashMap::new();
87 let node = walk(&self.root, &segments, &mut params)?;
88
89 node.operations
90 .iter()
91 .find(|(m, _)| m == method)
92 .map(|&(_, operation_index)| RouteMatch { operation_index, path_params: params })
93 }
94}
95
96fn parse_segments(template: &str) -> Vec<SegmentMatcher> {
102 template
103 .trim_matches('/')
104 .split('/')
105 .filter(|s| !s.is_empty())
106 .map(|s| {
107 if s.starts_with('{') && s.ends_with('}') && s.len() > 2 {
108 SegmentMatcher::Param(s[1..s.len() - 1].to_owned())
109 } else {
110 SegmentMatcher::Literal(s.to_owned())
111 }
112 })
113 .collect()
114}
115
116fn split_path(path: &str) -> Vec<&str> {
118 path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect()
119}
120
121fn insert(node: &mut RadixNode, segments: &[SegmentMatcher], method: Method, index: usize) {
126 if segments.is_empty() {
127 node.operations.push((method, index));
128 return;
129 }
130
131 let Some((head, tail)) = segments.split_first() else {
132 return;
133 };
134
135 for edge in &mut node.children {
137 if &edge.segment == head {
138 insert(&mut edge.child, tail, method, index);
139 return;
140 }
141 }
142
143 let mut child = RadixNode::default();
145 insert(&mut child, tail, method, index);
146 node.children.push(RadixEdge { segment: head.clone(), child });
147}
148
149fn walk<'a>(
157 node: &'a RadixNode,
158 segments: &[&str],
159 params: &mut HashMap<String, String>,
160) -> Option<&'a RadixNode> {
161 if segments.is_empty() {
162 return Some(node);
163 }
164
165 let (head, tail) = segments.split_first()?;
166
167 for edge in &node.children {
170 if let SegmentMatcher::Literal(ref lit) = edge.segment &&
171 lit == head &&
172 let Some(result) = walk(&edge.child, tail, params)
173 {
174 return Some(result);
175 }
176 }
177
178 for edge in &node.children {
180 if let SegmentMatcher::Param(ref name) = edge.segment {
181 let mut candidate_params = params.clone();
182 candidate_params.insert(name.clone(), (*head).to_owned());
183 if let Some(result) = walk(&edge.child, tail, &mut candidate_params) {
184 *params = candidate_params;
185 return Some(result);
186 }
187 }
188 }
189
190 None
191}
192
193#[cfg(test)]
198mod tests {
199 use super::*;
200
201 fn op(method: Method, path: &str) -> OperationSpec {
203 OperationSpec {
204 method,
205 path_template: path.to_owned(),
206 operation_id: None,
207 parameters: vec![],
208 request_body_schema: None,
209 request_body_required: false,
210 responses: vec![],
211 callbacks: vec![],
212 }
213 }
214
215 #[test]
216 fn literal_match() {
217 let ops = vec![op(Method::GET, "/pets")];
218 let router = PathRouter::build(&ops);
219
220 let m = router.match_route(&Method::GET, "/pets");
221 assert!(m.is_some());
222 let m = m.unwrap_or_else(|| unreachable!());
223 assert_eq!(m.operation_index, 0);
224 assert!(m.path_params.is_empty());
225 }
226
227 #[test]
228 fn param_match() {
229 let ops = vec![op(Method::GET, "/pets/{petId}")];
230 let router = PathRouter::build(&ops);
231
232 let m = router.match_route(&Method::GET, "/pets/42");
233 assert!(m.is_some());
234 let m = m.unwrap_or_else(|| unreachable!());
235 assert_eq!(m.operation_index, 0);
236 assert_eq!(m.path_params.get("petId").map(String::as_str), Some("42"));
237 }
238
239 #[test]
240 fn no_match_wrong_path() {
241 let ops = vec![op(Method::GET, "/pets")];
242 let router = PathRouter::build(&ops);
243 assert!(router.match_route(&Method::GET, "/dogs").is_none());
244 }
245
246 #[test]
247 fn no_match_wrong_method() {
248 let ops = vec![op(Method::GET, "/pets")];
249 let router = PathRouter::build(&ops);
250 assert!(router.match_route(&Method::POST, "/pets").is_none());
251 }
252
253 #[test]
254 fn method_disambiguation() {
255 let ops = vec![
256 op(Method::GET, "/pets"),
257 op(Method::POST, "/pets"),
258 op(Method::DELETE, "/pets/{petId}"),
259 ];
260 let router = PathRouter::build(&ops);
261
262 let get = router.match_route(&Method::GET, "/pets");
263 assert!(get.is_some());
264 assert_eq!(get.unwrap_or_else(|| unreachable!()).operation_index, 0);
265
266 let post = router.match_route(&Method::POST, "/pets");
267 assert!(post.is_some());
268 assert_eq!(post.unwrap_or_else(|| unreachable!()).operation_index, 1);
269
270 let delete = router.match_route(&Method::DELETE, "/pets/7");
271 assert!(delete.is_some());
272 let delete = delete.unwrap_or_else(|| unreachable!());
273 assert_eq!(delete.operation_index, 2);
274 assert_eq!(delete.path_params.get("petId").map(String::as_str), Some("7"));
275 }
276
277 #[test]
278 fn coexisting_paths() {
279 let ops = vec![
280 op(Method::GET, "/pets"),
281 op(Method::GET, "/pets/{petId}"),
282 op(Method::GET, "/pets/{petId}/toys"),
283 op(Method::GET, "/pets/{petId}/toys/{toyId}"),
284 ];
285 let router = PathRouter::build(&ops);
286
287 let m0 = router.match_route(&Method::GET, "/pets");
288 assert_eq!(m0.unwrap_or_else(|| unreachable!()).operation_index, 0);
289
290 let m1 = router.match_route(&Method::GET, "/pets/3");
291 let m1 = m1.unwrap_or_else(|| unreachable!());
292 assert_eq!(m1.operation_index, 1);
293 assert_eq!(m1.path_params.get("petId").map(String::as_str), Some("3"));
294
295 let m2 = router.match_route(&Method::GET, "/pets/3/toys");
296 let m2 = m2.unwrap_or_else(|| unreachable!());
297 assert_eq!(m2.operation_index, 2);
298 assert_eq!(m2.path_params.get("petId").map(String::as_str), Some("3"));
299
300 let m3 = router.match_route(&Method::GET, "/pets/3/toys/99");
301 let m3 = m3.unwrap_or_else(|| unreachable!());
302 assert_eq!(m3.operation_index, 3);
303 assert_eq!(m3.path_params.get("petId").map(String::as_str), Some("3"));
304 assert_eq!(m3.path_params.get("toyId").map(String::as_str), Some("99"));
305 }
306
307 #[test]
308 fn trailing_slash_normalisation() {
309 let ops = vec![op(Method::GET, "/pets")];
310 let router = PathRouter::build(&ops);
311
312 assert!(router.match_route(&Method::GET, "/pets/").is_some());
314 let ops2 = vec![op(Method::GET, "/pets/")];
316 let router2 = PathRouter::build(&ops2);
317 assert!(router2.match_route(&Method::GET, "/pets").is_some());
318 }
319
320 #[test]
321 fn literal_preferred_over_param() {
322 let ops = vec![op(Method::GET, "/pets/{petId}"), op(Method::GET, "/pets/mine")];
323 let router = PathRouter::build(&ops);
324
325 let mine = router.match_route(&Method::GET, "/pets/mine");
326 let mine = mine.unwrap_or_else(|| unreachable!());
327 assert_eq!(mine.operation_index, 1);
329 assert!(mine.path_params.is_empty());
330
331 let other = router.match_route(&Method::GET, "/pets/42");
333 let other = other.unwrap_or_else(|| unreachable!());
334 assert_eq!(other.operation_index, 0);
335 assert_eq!(other.path_params.get("petId").map(String::as_str), Some("42"));
336 }
337
338 #[test]
339 fn root_path() {
340 let ops = vec![op(Method::GET, "/")];
341 let router = PathRouter::build(&ops);
342 assert!(router.match_route(&Method::GET, "/").is_some());
343 }
344
345 #[test]
346 fn multi_param_segments() {
347 let ops = vec![op(Method::GET, "/orgs/{orgId}/repos/{repoId}/issues/{issueId}")];
348 let router = PathRouter::build(&ops);
349
350 let m = router.match_route(&Method::GET, "/orgs/acme/repos/widget/issues/123");
351 let m = m.unwrap_or_else(|| unreachable!());
352 assert_eq!(m.path_params.get("orgId").map(String::as_str), Some("acme"));
353 assert_eq!(m.path_params.get("repoId").map(String::as_str), Some("widget"));
354 assert_eq!(m.path_params.get("issueId").map(String::as_str), Some("123"));
355 }
356
357 #[test]
358 fn extra_segments_no_match() {
359 let ops = vec![op(Method::GET, "/pets")];
360 let router = PathRouter::build(&ops);
361 assert!(router.match_route(&Method::GET, "/pets/1/extra").is_none());
362 }
363
364 #[test]
365 fn fewer_segments_no_match() {
366 let ops = vec![op(Method::GET, "/pets/{petId}/toys")];
367 let router = PathRouter::build(&ops);
368 assert!(router.match_route(&Method::GET, "/pets/1").is_none());
369 }
370}