Skip to main content

hey_sdk/
url.rs

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
10/// Recognizes HEY paths and URLs, as pasted from the web app, and names the operation,
11/// resource and ids they refer to. Works offline.
12pub struct Router {
13    patterns: Vec<Pattern>,
14}
15
16struct Pattern {
17    pattern: &'static str,
18    routes: Vec<&'static Route>,
19}
20
21/// What a recognized path refers to.
22#[derive(Debug, Clone, PartialEq, Eq)]
23#[non_exhaustive]
24pub struct Match {
25    /// The path template the path matched: `/boxes/{boxId}/groups/{groupId}`.
26    pub pattern: &'static str,
27    /// The part of HEY the path belongs to, as the model titles it: `Boxes`.
28    pub resource: &'static str,
29    /// The operations served on this path, by method.
30    pub operations: BTreeMap<String, &'static str>,
31    /// The path parameters in the order they appear.
32    pub params: Vec<(&'static str, String)>,
33}
34
35impl Match {
36    /// The read on this path, or the alphabetically first operation when there is none.
37    ///
38    /// # Panics
39    ///
40    /// When `operations` is empty. Recognition never produces such a match; only emptying
41    /// the field by hand reaches this.
42    #[allow(clippy::expect_used)] // the empty case is unreachable through `Router::recognize`; see Panics
43    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    /// The last path parameter: the id of the record the path names, if any.
52    pub fn resource_id(&self) -> Option<&str> {
53        self.params.last().map(|(_, value)| value.as_str())
54    }
55}
56
57impl Router {
58    /// A router over every modelled route.
59    pub fn new() -> Router {
60        Router::over(ROUTES)
61    }
62
63    /// A router over a chosen set of routes, for a caller that recognizes only part of HEY
64    /// — one service's paths, say.
65    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    /// Recognizes a path such as `/topics/456` or a full URL such as
84    /// `https://app.hey.com/topics/456.json?foo=bar`. Trailing slashes, a `.json` suffix,
85    /// the query and the fragment are ignored.
86    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
126/// The process-wide router.
127pub 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}