use std::collections::BTreeMap;
use std::sync::OnceLock;
use url::Url;
use crate::generated::routes::ROUTES;
use crate::http::Method;
use crate::route::Route;
pub struct Router {
patterns: Vec<Pattern>,
}
struct Pattern {
pattern: &'static str,
routes: Vec<&'static Route>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Match {
pub pattern: &'static str,
pub resource_type: &'static str,
pub operations: BTreeMap<String, &'static str>,
pub params: Vec<(&'static str, String)>,
}
impl Match {
pub fn operation(&self) -> Option<&'static str> {
self.operations
.get(Method::GET.as_str())
.or_else(|| self.operations.values().min())
.copied()
}
pub fn account_id(&self) -> Option<&str> {
self.params
.iter()
.find(|(name, _)| *name == "accountId")
.map(|(_, value)| value.as_str())
}
pub fn resource_id(&self) -> Option<&str> {
self.params
.last()
.filter(|(name, _)| *name != "accountId")
.map(|(_, value)| value.as_str())
}
}
impl Router {
pub fn new() -> Router {
Router::over(ROUTES)
}
pub fn over(routes: &[&'static Route]) -> Router {
let mut by_pattern: BTreeMap<&'static str, Vec<&'static Route>> = BTreeMap::new();
for route in routes {
by_pattern.entry(route.pattern).or_default().push(route);
}
let mut patterns: Vec<Pattern> = by_pattern
.into_iter()
.map(|(pattern, routes)| Pattern { pattern, routes })
.collect();
patterns.sort_by(|a, b| {
let literals =
|pattern: &str| pattern.split('/').filter(|s| !s.starts_with('{')).count();
let depth = |pattern: &str| pattern.matches('/').count();
depth(b.pattern)
.cmp(&depth(a.pattern))
.then_with(|| literals(b.pattern).cmp(&literals(a.pattern)))
.then_with(|| a.pattern.cmp(b.pattern))
});
Router { patterns }
}
pub fn recognize(&self, path_or_url: &str) -> Option<Match> {
let path = match Url::parse(path_or_url) {
Ok(url) if !url.cannot_be_a_base() => url.path().to_string(),
_ => path_or_url
.split(['?', '#'])
.next()
.unwrap_or_default()
.to_string(),
};
let path = path.trim_end_matches('/');
let path = path.strip_suffix(".json").unwrap_or(path);
self.patterns
.iter()
.find_map(|pattern| pattern.recognize(path))
}
}
impl Default for Router {
fn default() -> Router {
Router::new()
}
}
impl Pattern {
fn recognize(&self, path: &str) -> Option<Match> {
let params = self.routes[0].recognize(path)?;
let operations = self
.routes
.iter()
.map(|route| (route.method.to_string(), route.id))
.collect();
Some(Match {
pattern: self.pattern,
resource_type: self.routes[0].resource_type,
operations,
params,
})
}
}
pub fn router() -> &'static Router {
static ROUTER: OnceLock<Router> = OnceLock::new();
ROUTER.get_or_init(Router::new)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn recognizes_a_pasted_card_url() {
let matched = router()
.recognize("https://fizzy.do/999/cards/42?x=1")
.unwrap();
assert_eq!(matched.operation(), Some("GetCard"));
assert_eq!(matched.resource_type, "card");
assert_eq!(matched.account_id(), Some("999"));
assert_eq!(matched.resource_id(), Some("42"));
}
#[test]
fn captured_parameters_come_back_decoded() {
let matched = router().recognize("/999/cards/a%3Ab").unwrap();
assert_eq!(matched.resource_id(), Some("a:b"));
}
#[test]
fn literal_segments_win_over_parameters_at_the_same_depth() {
let closed = router()
.recognize("/999/boards/b1/columns/closed.json")
.unwrap();
assert_eq!(closed.operation(), Some("ListClosedCards"));
let column = router().recognize("/999/boards/b1/columns/c9").unwrap();
assert_eq!(column.operation(), Some("GetColumn"));
assert_eq!(column.operations["DELETE"], "DeleteColumn");
assert_eq!(
column.params,
vec![
("accountId", "999".to_string()),
("boardId", "b1".to_string()),
("columnId", "c9".to_string())
]
);
}
#[test]
fn account_free_paths_and_unknown_paths() {
assert_eq!(
router().recognize("/my/identity").unwrap().operation(),
Some("GetMyIdentity")
);
assert_eq!(
router().recognize("/session").unwrap().operation(),
Some("CreateSession")
);
assert!(router().recognize("/nothing/here/at/all/really").is_none());
assert!(router().recognize("/999//cards").is_none());
}
#[test]
fn a_router_over_a_chosen_set_recognizes_only_those() {
let router = Router::over(&[&crate::generated::routes::GET_BOARD]);
assert_eq!(
router.recognize("/999/boards/b1").unwrap().operation(),
Some("GetBoard")
);
assert!(router.recognize("/999/cards/1").is_none());
}
}