use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Segment {
Static(String),
Param(String),
}
struct Route<H> {
segments: Vec<Segment>,
handler: H,
}
pub struct Router<H> {
routes: Vec<Route<H>>,
}
impl<H> fmt::Debug for Router<H> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Router")
.field("route_count", &self.routes.len())
.finish()
}
}
impl<H> Default for Router<H> {
fn default() -> Self {
Self { routes: Vec::new() }
}
}
impl<H> Router<H> {
pub fn new() -> Self {
Self::default()
}
pub fn route(mut self, pattern: &str, handler: H) -> Result<Self, RouterError> {
let segments = parse_pattern(pattern)?;
self.routes.push(Route { segments, handler });
Ok(self)
}
pub fn resolve(&self, path: &str) -> Option<RouteMatch<'_, H>> {
let path_without_query = match path.split_once('?') {
Some((before, _)) => before,
None => path,
};
let request_segments = split_path(path_without_query)?;
for route in &self.routes {
if route.segments.len() != request_segments.len() {
continue;
}
if let Some(params) = match_segments(&route.segments, &request_segments) {
return Some(RouteMatch {
handler: &route.handler,
params,
});
}
}
None
}
}
pub struct RouteMatch<'a, H> {
pub handler: &'a H,
pub params: Params,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Params(Vec<(String, String)>);
impl Params {
pub fn get(&self, name: &str) -> Option<&str> {
self.0
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouterError {
MissingLeadingSlash(String),
EmptySegment(String),
EmptyParamName(String),
DuplicateParamName {
pattern: String,
name: String,
},
}
impl fmt::Display for RouterError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RouterError::MissingLeadingSlash(pattern) => {
write!(f, "route pattern must start with '/': {pattern:?}")
}
RouterError::EmptySegment(pattern) => {
write!(f, "route pattern contains an empty segment: {pattern:?}")
}
RouterError::EmptyParamName(pattern) => {
write!(
f,
"route pattern has a ':' segment with no parameter name: {pattern:?}"
)
}
RouterError::DuplicateParamName { pattern, name } => {
write!(
f,
"route pattern declares parameter {name:?} more than once: {pattern:?}"
)
}
}
}
}
impl std::error::Error for RouterError {}
fn parse_pattern(pattern: &str) -> Result<Vec<Segment>, RouterError> {
let rest = pattern
.strip_prefix('/')
.ok_or_else(|| RouterError::MissingLeadingSlash(pattern.to_string()))?;
if rest.is_empty() {
return Ok(Vec::new());
}
let mut segments = Vec::new();
let mut seen_params: Vec<&str> = Vec::new();
for part in rest.split('/') {
if part.is_empty() {
return Err(RouterError::EmptySegment(pattern.to_string()));
}
if let Some(name) = part.strip_prefix(':') {
if name.is_empty() {
return Err(RouterError::EmptyParamName(pattern.to_string()));
}
if seen_params.contains(&name) {
return Err(RouterError::DuplicateParamName {
pattern: pattern.to_string(),
name: name.to_string(),
});
}
seen_params.push(name);
segments.push(Segment::Param(name.to_string()));
} else {
segments.push(Segment::Static(part.to_string()));
}
}
Ok(segments)
}
fn split_path(path: &str) -> Option<Vec<&str>> {
let rest = path.strip_prefix('/')?;
if rest.is_empty() {
return Some(Vec::new());
}
let segments: Vec<&str> = rest.split('/').collect();
if segments.iter().any(|s| s.is_empty()) {
return None;
}
Some(segments)
}
fn match_segments(pattern: &[Segment], request: &[&str]) -> Option<Params> {
let mut params = Vec::new();
for (segment, actual) in pattern.iter().zip(request.iter()) {
match segment {
Segment::Static(expected) => {
if expected != actual {
return None;
}
}
Segment::Param(name) => {
params.push((name.clone(), (*actual).to_string()));
}
}
}
Some(Params(params))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_req7_baseline_routes() {
let router: Router<&str> = Router::new()
.route("/", "home")
.unwrap()
.route("/items/:id", "item_detail")
.unwrap()
.route("/search", "search")
.unwrap();
let home = router.resolve("/").expect("root should match");
assert_eq!(*home.handler, "home");
assert_eq!(home.params.get("id"), None);
let search = router.resolve("/search").expect("search should match");
assert_eq!(*search.handler, "search");
}
#[test]
fn extracts_param_from_items_id() {
let router: Router<&str> = Router::new().route("/items/:id", "item_detail").unwrap();
let matched = router.resolve("/items/2").expect("should match");
assert_eq!(*matched.handler, "item_detail");
assert_eq!(matched.params.get("id"), Some("2"));
}
#[test]
fn query_string_is_stripped_before_matching() {
let router: Router<&str> = Router::new().route("/items/:id", "item_detail").unwrap();
let matched = router
.resolve("/items/2?ref=list&utm=abc")
.expect("should match ignoring query string");
assert_eq!(matched.params.get("id"), Some("2"));
}
#[test]
fn unregistered_path_does_not_match() {
let router: Router<&str> = Router::new()
.route("/", "home")
.unwrap()
.route("/items/:id", "item_detail")
.unwrap();
assert!(router.resolve("/nope").is_none());
}
#[test]
fn extra_trailing_segment_does_not_match() {
let router: Router<&str> = Router::new().route("/items/:id", "item_detail").unwrap();
assert!(router.resolve("/items/1/extra").is_none());
}
#[test]
fn trailing_slash_is_not_normalized_and_does_not_match() {
let router: Router<&str> = Router::new().route("/items/:id", "item_detail").unwrap();
assert!(router.resolve("/items/1/").is_none());
}
#[test]
fn xss_payload_like_path_is_captured_as_raw_string() {
let router: Router<&str> = Router::new().route("/items/:id", "item_detail").unwrap();
let payload = "<img src=x onerror=alert(1)>";
let path = format!("/items/{payload}");
let matched = router.resolve(&path).expect("should match");
assert_eq!(matched.params.get("id"), Some(payload));
let escaped = fandhe_frontend_core::render(&fandhe_frontend_core::text(
matched.params.get("id").unwrap(),
));
assert!(!escaped.contains("<img"));
assert!(escaped.contains("<img"));
}
#[test]
fn params_iter_yields_all_registered_pairs() {
let router: Router<&str> = Router::new()
.route("/items/:id/reviews/:review_id", "review_detail")
.unwrap();
let matched = router.resolve("/items/2/reviews/9").expect("should match");
let pairs: Vec<(&str, &str)> = matched.params.iter().collect();
assert_eq!(pairs, vec![("id", "2"), ("review_id", "9")]);
}
#[test]
fn duplicate_items_first_registration_wins() {
let router: Router<&str> = Router::new()
.route("/items/:id", "first")
.unwrap()
.route("/items/:id", "second")
.unwrap();
let matched = router.resolve("/items/9").expect("should match");
assert_eq!(*matched.handler, "first");
}
#[test]
fn rejects_pattern_without_leading_slash() {
let router: Router<&str> = Router::new();
let err = router.route("items", "x").unwrap_err();
assert_eq!(err, RouterError::MissingLeadingSlash("items".to_string()));
}
#[test]
fn rejects_pattern_with_empty_segment() {
let router: Router<&str> = Router::new();
let err = router.route("/items//id", "x").unwrap_err();
assert_eq!(err, RouterError::EmptySegment("/items//id".to_string()));
}
#[test]
fn rejects_pattern_with_empty_param_name() {
let router: Router<&str> = Router::new();
let err = router.route("/items/:", "x").unwrap_err();
assert_eq!(err, RouterError::EmptyParamName("/items/:".to_string()));
}
#[test]
fn rejects_pattern_with_duplicate_param_name() {
let router: Router<&str> = Router::new();
let err = router.route("/items/:id/reviews/:id", "x").unwrap_err();
assert_eq!(
err,
RouterError::DuplicateParamName {
pattern: "/items/:id/reviews/:id".to_string(),
name: "id".to_string(),
}
);
}
#[test]
fn resolved_param_can_look_up_item() {
let router: Router<&str> = Router::new().route("/items/:id", "item_detail").unwrap();
let items = crate::demo_items();
let matched = router.resolve("/items/2").expect("should match");
let found = items
.iter()
.find(|item| Some(item.id.as_str()) == matched.params.get("id"));
assert!(found.is_some());
}
}