Initial commit
This commit is contained in:
+223
@@ -0,0 +1,223 @@
|
||||
//! 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).
|
||||
let mut path_seen = false;
|
||||
for route in &self.routes {
|
||||
if let Some(params) = route.pattern.match_path(&conn.path) {
|
||||
if route.method == conn.method {
|
||||
let conn = conn.put_params(params);
|
||||
return route.handler.call(conn, next);
|
||||
}
|
||||
path_seen = true;
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user