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.
237 lines
8.1 KiB
Rust
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));
|
|
}
|
|
}
|