//! The router — itself a plug. //! //! v1: linear scan over a Vec of compiled route patterns. The spec calls for //! a radix trie eventually; for "good enough to build a CRUD app" with //! typical route counts (<50), the linear scan is dominant noise next to //! everything else on the request path. Replace with a trie when there's //! evidence it matters. //! //! Pattern syntax: //! - `/users` — literal //! - `/users/:id` — `id` param //! - `/users/:id/posts/:n` — multiple params //! //! No wildcards in v1. They are not needed for CRUD. use crate::conn::{Conn, Method, Params}; use crate::plug::{Next, Plug}; use std::sync::Arc; // --------------------------------------------------------------------------- // Pattern // --------------------------------------------------------------------------- /// A compiled URL pattern: a list of segments, each either a literal or a /// named parameter. Segment compilation happens at `Router::get(...)` time; /// matching at request time is a single-pass loop. #[derive(Debug, Clone)] struct Pattern { segments: Vec, } #[derive(Debug, Clone)] enum Segment { Literal(String), Param(String), } impl Pattern { fn compile(path: &str) -> Self { // Treat empty path the same as "/" — single empty segment. let trimmed = path.trim_start_matches('/'); if trimmed.is_empty() { return Pattern { segments: Vec::new() }; } let segments = trimmed .split('/') .map(|s| { if let Some(name) = s.strip_prefix(':') { Segment::Param(name.to_string()) } else { Segment::Literal(s.to_string()) } }) .collect(); Pattern { segments } } /// Match `path` against this pattern. Returns extracted params on /// success, `None` on miss. Strict path equality — trailing slashes /// matter; that's standard HTTP behaviour and we don't paper over it. fn match_path(&self, path: &str) -> Option { let trimmed = path.trim_start_matches('/'); if trimmed.is_empty() { return if self.segments.is_empty() { Some(Params::new()) } else { None }; } let parts: Vec<&str> = trimmed.split('/').collect(); if parts.len() != self.segments.len() { return None; } let mut params = Params::new(); for (seg, part) in self.segments.iter().zip(parts.iter()) { match seg { Segment::Literal(lit) => { if lit != part { return None; } } Segment::Param(name) => { params.put(name.clone(), (*part).to_string()); } } } Some(params) } } // --------------------------------------------------------------------------- // Route // --------------------------------------------------------------------------- struct Route { method: Method, pattern: Pattern, handler: Arc, } // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- pub struct Router { routes: Vec, } impl Router { pub fn new() -> Self { Self { routes: Vec::new() } } fn add(mut self, method: Method, path: &str, plug: impl Plug) -> Self { self.routes.push(Route { method, pattern: Pattern::compile(path), handler: Arc::new(plug), }); self } pub fn get(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Get, path, plug) } pub fn post(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Post, path, plug) } pub fn put(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Put, path, plug) } pub fn delete(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Delete, path, plug) } pub fn patch(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Patch, path, plug) } pub fn head(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Head, path, plug) } pub fn options(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Options, path, plug) } } impl Default for Router { fn default() -> Self { Self::new() } } impl Plug for Router { fn call(&self, conn: Conn, next: Next) -> Conn { // Two-pass dispatch: first match on method+path; if no method-and- // path matches but a same-path-different-method does, return 405. // Otherwise pass through to `next` so outer pipelines can layer a // 404 handler (or skip and let the connection actor emit a default). // // Matching runs under the `router` causal site (RFC 007); the // winning handler and the `next` fall-through run outside it, so // the site measures dispatch, not what it dispatches to. let mut path_seen = false; let mut hit = None; { #[cfg(feature = "smarm-causal")] let _g = smarm::causal_site!("router"); for (i, route) in self.routes.iter().enumerate() { if let Some(params) = route.pattern.match_path(&conn.path) { if route.method == conn.method { hit = Some((i, params)); break; } path_seen = true; } } } if let Some((i, params)) = hit { let conn = conn.put_params(params); return self.routes[i].handler.call(conn, next); } if path_seen { // Path is known, method isn't — RFC 7231 §6.5.5. conn.put_status(405) .put_header("content-length", "0") .halt() } else { // No route matched at all; let outer plugs / default handler // deal with it. next.run(conn) } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::plug::Pipeline; fn ok_handler(_label: &'static str) -> impl Plug { move |conn: Conn, _next: Next| conn.put_status(200).put_body(_label) } #[test] fn literal_match() { let r = Router::new().get("/ping", ok_handler("pong")); let mut c = Conn::new(); c.method = Method::Get; c.path = "/ping".into(); let out = Pipeline::new().plug(r).run(c); assert_eq!(out.status, Some(200)); } #[test] fn param_match() { let r = Router::new().get("/users/:id", |conn: Conn, _n: Next| { let id = conn.params.get("id").unwrap_or("").to_string(); conn.put_status(200).put_body(id) }); let mut c = Conn::new(); c.method = Method::Get; c.path = "/users/42".into(); let out = Pipeline::new().plug(r).run(c); assert_eq!(out.status, Some(200)); match &out.resp_body { crate::conn::RespBody::Bytes(b) => assert_eq!(b, b"42"), _ => panic!("expected bytes"), } } #[test] fn method_mismatch_yields_405() { let r = Router::new().get("/users", ok_handler("list")); let mut c = Conn::new(); c.method = Method::Post; c.path = "/users".into(); let out = Pipeline::new().plug(r).run(c); assert_eq!(out.status, Some(405)); } #[test] fn no_route_falls_through() { // Router returns conn unchanged; next plug (a 404 default) handles it. let r = Router::new().get("/users", ok_handler("list")); let default_404 = |conn: Conn, _n: Next| conn.put_status(404).put_body("not found"); let mut c = Conn::new(); c.method = Method::Get; c.path = "/nowhere".into(); let out = Pipeline::new().plug(r).plug(default_404).run(c); assert_eq!(out.status, Some(404)); } }