1use std::collections::BTreeMap;
4use std::sync::OnceLock;
5
6use url::Url;
7
8use crate::generated::routes::ROUTES;
9use crate::http::Method;
10use crate::route::Route;
11
12pub struct Router {
15 patterns: Vec<Pattern>,
16}
17
18struct Pattern {
19 pattern: &'static str,
20 routes: Vec<&'static Route>,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct Match {
27 pub pattern: &'static str,
29 pub resource_type: &'static str,
31 pub operations: BTreeMap<String, &'static str>,
33 pub params: Vec<(&'static str, String)>,
35}
36
37impl Match {
38 pub fn operation(&self) -> Option<&'static str> {
40 self.operations
41 .get(Method::GET.as_str())
42 .or_else(|| self.operations.values().min())
43 .copied()
44 }
45
46 pub fn account_id(&self) -> Option<&str> {
48 self.params
49 .iter()
50 .find(|(name, _)| *name == "accountId")
51 .map(|(_, value)| value.as_str())
52 }
53
54 pub fn resource_id(&self) -> Option<&str> {
56 self.params
57 .last()
58 .filter(|(name, _)| *name != "accountId")
59 .map(|(_, value)| value.as_str())
60 }
61}
62
63impl Router {
64 pub fn new() -> Router {
66 Router::over(ROUTES)
67 }
68
69 pub fn over(routes: &[&'static Route]) -> Router {
72 let mut by_pattern: BTreeMap<&'static str, Vec<&'static Route>> = BTreeMap::new();
73 for route in routes {
74 by_pattern.entry(route.pattern).or_default().push(route);
75 }
76 let mut patterns: Vec<Pattern> = by_pattern
77 .into_iter()
78 .map(|(pattern, routes)| Pattern { pattern, routes })
79 .collect();
80 patterns.sort_by(|a, b| {
81 let literals =
82 |pattern: &str| pattern.split('/').filter(|s| !s.starts_with('{')).count();
83 let depth = |pattern: &str| pattern.matches('/').count();
84 depth(b.pattern)
85 .cmp(&depth(a.pattern))
86 .then_with(|| literals(b.pattern).cmp(&literals(a.pattern)))
87 .then_with(|| a.pattern.cmp(b.pattern))
88 });
89 Router { patterns }
90 }
91
92 pub fn recognize(&self, path_or_url: &str) -> Option<Match> {
96 let path = match Url::parse(path_or_url) {
97 Ok(url) if !url.cannot_be_a_base() => url.path().to_string(),
98 _ => path_or_url
99 .split(['?', '#'])
100 .next()
101 .unwrap_or_default()
102 .to_string(),
103 };
104 let path = path.trim_end_matches('/');
105 let path = path.strip_suffix(".json").unwrap_or(path);
106 self.patterns
107 .iter()
108 .find_map(|pattern| pattern.recognize(path))
109 }
110}
111
112impl Default for Router {
113 fn default() -> Router {
114 Router::new()
115 }
116}
117
118impl Pattern {
119 fn recognize(&self, path: &str) -> Option<Match> {
120 let params = self.routes[0].recognize(path)?;
121 let operations = self
122 .routes
123 .iter()
124 .map(|route| (route.method.to_string(), route.id))
125 .collect();
126 Some(Match {
127 pattern: self.pattern,
128 resource_type: self.routes[0].resource_type,
129 operations,
130 params,
131 })
132 }
133}
134
135pub fn router() -> &'static Router {
137 static ROUTER: OnceLock<Router> = OnceLock::new();
138 ROUTER.get_or_init(Router::new)
139}
140
141#[cfg(test)]
142#[allow(clippy::unwrap_used)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn recognizes_a_pasted_card_url() {
148 let matched = router()
149 .recognize("https://fizzy.do/999/cards/42?x=1")
150 .unwrap();
151 assert_eq!(matched.operation(), Some("GetCard"));
152 assert_eq!(matched.resource_type, "card");
153 assert_eq!(matched.account_id(), Some("999"));
154 assert_eq!(matched.resource_id(), Some("42"));
155 }
156
157 #[test]
158 fn captured_parameters_come_back_decoded() {
159 let matched = router().recognize("/999/cards/a%3Ab").unwrap();
160 assert_eq!(matched.resource_id(), Some("a:b"));
161 }
162
163 #[test]
164 fn literal_segments_win_over_parameters_at_the_same_depth() {
165 let closed = router()
166 .recognize("/999/boards/b1/columns/closed.json")
167 .unwrap();
168 assert_eq!(closed.operation(), Some("ListClosedCards"));
169 let column = router().recognize("/999/boards/b1/columns/c9").unwrap();
170 assert_eq!(column.operation(), Some("GetColumn"));
171 assert_eq!(column.operations["DELETE"], "DeleteColumn");
172 assert_eq!(
173 column.params,
174 vec![
175 ("accountId", "999".to_string()),
176 ("boardId", "b1".to_string()),
177 ("columnId", "c9".to_string())
178 ]
179 );
180 }
181
182 #[test]
183 fn account_free_paths_and_unknown_paths() {
184 assert_eq!(
185 router().recognize("/my/identity").unwrap().operation(),
186 Some("GetMyIdentity")
187 );
188 assert_eq!(
189 router().recognize("/session").unwrap().operation(),
190 Some("CreateSession")
191 );
192 assert!(router().recognize("/nothing/here/at/all/really").is_none());
193 assert!(router().recognize("/999//cards").is_none());
194 }
195
196 #[test]
197 fn a_router_over_a_chosen_set_recognizes_only_those() {
198 let router = Router::over(&[&crate::generated::routes::GET_BOARD]);
199 assert_eq!(
200 router.recognize("/999/boards/b1").unwrap().operation(),
201 Some("GetBoard")
202 );
203 assert!(router.recognize("/999/cards/1").is_none());
204 }
205}