Skip to main content

fizzy_sdk/
url.rs

1//! Recognizing pasted Fizzy URLs offline.
2
3use 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
12/// Recognizes Fizzy paths and URLs, as pasted from the web app, and names the operation,
13/// resource and ids they refer to. Works offline.
14pub struct Router {
15    patterns: Vec<Pattern>,
16}
17
18struct Pattern {
19    pattern: &'static str,
20    routes: Vec<&'static Route>,
21}
22
23/// What a recognized path refers to.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct Match {
27    /// The route pattern that matched.
28    pub pattern: &'static str,
29    /// The kind of record the path names.
30    pub resource_type: &'static str,
31    /// The operations served on this path, by method.
32    pub operations: BTreeMap<String, &'static str>,
33    /// The path parameters in the order they appear, the account first.
34    pub params: Vec<(&'static str, String)>,
35}
36
37impl Match {
38    /// The read on this path, or the alphabetically first operation when there is none.
39    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    /// The account the path is under, when it is under one.
47    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    /// The last path parameter: the id of the record the path names, if any.
55    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    /// A router over every modelled route.
65    pub fn new() -> Router {
66        Router::over(ROUTES)
67    }
68
69    /// A router over a chosen set of routes, for a caller that recognizes only part of
70    /// Fizzy — one service's paths, say.
71    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    /// Recognizes a path such as `/999/boards/abc` or a full URL such as
93    /// `https://fizzy.do/999/cards/12.json?x=1`. Trailing slashes, a `.json` suffix, the
94    /// query and the fragment are ignored.
95    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
135/// The process-wide router.
136pub 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}