Files
urus/src/router.rs
T
Claude ecaddc579a feat(causal): instrument the request hot path — five sites and a progress point
RFC 007 causal-site coverage for the Phase 2 bench (and any downstream
profiling): parse (head parsing attempts in read_head), router (dispatch
matching only — the winning handler and the next fall-through run outside
the guard, so the site measures dispatch, not what it dispatches to;
call() restructured match-then-dispatch for that, semantics preserved
incl. first-path+method-match-wins and the 405 two-pass), pipeline (the
plug chain inside catch_unwind; nested sites like router take over
attribution during their span, so this reads as chain overhead + handler
code outside inner sites), serialize (response head+bytes-body
serialisation; chunked stream framing is not covered — it interleaves
with writes in pump_stream and the bench doesn't stream), and
socket-write (all of write_all, writability parks included: a park
inside a site is exactly what the park-gated resume credit attributes).

progress!("responses") fires once per response fully on the wire, at
both completion points (the common path and the HTTP/1.0 EOF-stream
early return).

Site guards are #[cfg(feature = "smarm-causal")]-gated: the macros are
no-ops featureless, but binding the unit expansion would trip clippy's
let_unit_value. progress! is bare — its featureless expansion is an
empty block, free and lint-clean. Featureless build byte-behavior is
unchanged; both configs clippy-clean, full suite green both ways.
2026-07-13 08:30:53 +00:00

237 lines
8.1 KiB
Rust

//! 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<Segment>,
}
#[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<Params> {
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<dyn Plug>,
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
pub struct Router {
routes: Vec<Route>,
}
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));
}
}