1use std::collections::BTreeMap;
2use std::sync::OnceLock;
3
4use crate::http::Method;
5use url::Url;
6
7use crate::generated::routes::ROUTES;
8use crate::route::Route;
9
10pub struct Router {
13 patterns: Vec<Pattern>,
14}
15
16struct Pattern {
17 pattern: &'static str,
18 routes: Vec<&'static Route>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23#[non_exhaustive]
24pub struct Match {
25 pub pattern: &'static str,
27 pub resource: &'static str,
29 pub operations: BTreeMap<String, &'static str>,
31 pub params: Vec<(&'static str, String)>,
33}
34
35impl Match {
36 #[allow(clippy::expect_used)] pub fn operation(&self) -> &'static str {
44 self.operations
45 .get(Method::GET.as_str())
46 .or_else(|| self.operations.values().min())
47 .copied()
48 .expect("a matched pattern serves at least one operation")
49 }
50
51 pub fn resource_id(&self) -> Option<&str> {
53 self.params.last().map(|(_, value)| value.as_str())
54 }
55}
56
57impl Router {
58 pub fn new() -> Router {
60 Router::over(ROUTES)
61 }
62
63 pub fn over(routes: &[&'static Route]) -> Router {
66 let mut by_pattern: BTreeMap<&'static str, Vec<&'static Route>> = BTreeMap::new();
67 for route in routes {
68 by_pattern.entry(route.pattern).or_default().push(route);
69 }
70 let mut patterns: Vec<Pattern> = by_pattern
71 .into_iter()
72 .map(|(pattern, routes)| Pattern { pattern, routes })
73 .collect();
74 patterns.sort_by(|a, b| {
75 let depth = |pattern: &str| pattern.matches('/').count();
76 depth(b.pattern)
77 .cmp(&depth(a.pattern))
78 .then_with(|| a.pattern.cmp(b.pattern))
79 });
80 Router { patterns }
81 }
82
83 pub fn recognize(&self, path_or_url: &str) -> Option<Match> {
87 let path = match Url::parse(path_or_url) {
88 Ok(url) if !url.cannot_be_a_base() => url.path().to_string(),
89 _ => path_or_url
90 .split(['?', '#'])
91 .next()
92 .unwrap_or_default()
93 .to_string(),
94 };
95 let path = path.trim_end_matches('/');
96 let path = path.strip_suffix(".json").unwrap_or(path);
97 self.patterns
98 .iter()
99 .find_map(|pattern| pattern.recognize(path))
100 }
101}
102
103impl Default for Router {
104 fn default() -> Router {
105 Router::new()
106 }
107}
108
109impl Pattern {
110 fn recognize(&self, path: &str) -> Option<Match> {
111 let params = self.routes[0].recognize(path)?;
112 let operations = self
113 .routes
114 .iter()
115 .map(|route| (route.method.to_string(), route.id))
116 .collect();
117 Some(Match {
118 pattern: self.pattern,
119 resource: self.routes[0].resource,
120 operations,
121 params,
122 })
123 }
124}
125
126pub fn router() -> &'static Router {
128 static ROUTER: OnceLock<Router> = OnceLock::new();
129 ROUTER.get_or_init(Router::new)
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn recognizes_a_pasted_topic_url() {
138 let matched = router()
139 .recognize("https://app.hey.com/topics/456?x=1")
140 .unwrap();
141 assert_eq!(matched.operation(), "GetTopic");
142 assert_eq!(matched.resource, "Topics");
143 assert_eq!(matched.resource_id(), Some("456"));
144 }
145
146 #[test]
147 fn deeper_patterns_win_and_methods_are_listed() {
148 let matched = router().recognize("/boxes/24090/groups/9.json").unwrap();
149 assert_eq!(matched.pattern, "/boxes/{boxId}/groups/{groupId}");
150 assert_eq!(matched.operation(), "GetBoxGroup");
151 assert_eq!(matched.operations["DELETE"], "DeleteBoxGroup");
152 assert_eq!(
153 matched.params,
154 vec![("boxId", "24090".to_string()), ("groupId", "9".to_string())]
155 );
156 }
157
158 #[test]
159 fn a_router_over_a_chosen_set_recognizes_only_those() {
160 let router = Router::over(&[&crate::generated::routes::GET_TOPIC]);
161
162 assert_eq!(
163 router.recognize("/topics/456").unwrap().operation(),
164 "GetTopic"
165 );
166 assert!(router.recognize("/imbox").is_none());
167 }
168
169 #[test]
170 fn prefers_the_read_and_falls_back_alphabetically() {
171 assert_eq!(
172 router().recognize("/postings/seen").unwrap().operation(),
173 "MarkPostingsSeen"
174 );
175 assert_eq!(
176 router().recognize("/postings/mutings").unwrap().operation(),
177 "MutePostings"
178 );
179 assert_eq!(
180 router().recognize("/imbox/").unwrap().operation(),
181 "GetImbox"
182 );
183 assert!(router().recognize("/nothing/here").is_none());
184 assert!(router().recognize("/boxes//groups").is_none());
185 }
186}