Lesson 1 of 5
A Router, in Miniature
"A request-line begins with a method token, followed by a single space (SP), the request-target, another single space (SP), the protocol version, and ends with CRLF." — RFC 7230, HTTP/1.1: Message Syntax and Routing (2014)
Web frameworks are where Rust feels most like magic. You write async fn show(Path(id): Path<u32>) -> String and hand it to axum, and somehow a function whose signature you invented gets called with a parsed integer pulled out of a URL. Nobody registered anything. There is no reflection in Rust, no annotations, no runtime type information. It should not work.
It works because of traits, and the whole trick fits in one chapter. This part builds the web stack small before using it large, and we start with routing because it is the piece that looks most magical and is in fact the most ordinary.
The plan is to write a router by hand with no dependencies at all — not a sketch, but something that answers real sockets. HTTP is a text protocol; a route is a pattern match over path segments; an extractor is a trait with one method; and the thing that lets functions of different shapes share a route table is a single clever type parameter. By the end of the chapter none of it is mysterious, and axum reads like code somebody wrote rather than a framework that does things to you.
The epigraph is there because it is the actual specification, and the first block below implements it literally. That is the character of this part: the standards and the libraries are written by people, and reading them is allowed.
Rust has no reflection. So how can a framework accept fn(), fn(Path<u32>) and fn(Path<u32>, Query) as handlers for the same router, and call each one correctly?
The protocol is text
Before routing, the thing being routed.
// HTTP is a text protocol. No framework, no dependencies.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn handle(mut stream: TcpStream) {
let mut reader = BufReader::new(stream.try_clone().unwrap());
// The request line: METHOD SP TARGET SP VERSION CRLF
let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap();
let mut parts = request_line.trim_end().split(' ');
let method = parts.next().unwrap_or("").to_string();
let target = parts.next().unwrap_or("").to_string();
// Headers until a blank line. Names are case-insensitive.
let mut content_length = 0usize;
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let line = line.trim_end();
if line.is_empty() { break; }
let (name, value) = line.split_once(": ").unwrap_or((line, ""));
if name.eq_ignore_ascii_case("content-length") {
content_length = value.parse().unwrap_or(0);
}
}
// The body, whose length the headers told us.
let mut body = vec![0u8; content_length];
reader.read_exact(&mut body).unwrap();
println!("server saw: {method} {target} with {} body byte(s)", body.len());
let payload = format!("you asked for {target}");
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{}",
payload.len(), payload,
);
stream.write_all(response.as_bytes()).unwrap();
}
fn main() {
// Port 0 asks the OS for any free port, so this never collides.
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
handle(stream);
});
let mut client = TcpStream::connect(addr).unwrap();
client.write_all(b"POST /users/42 HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello").unwrap();
let mut reply = String::new();
client.read_to_string(&mut reply).unwrap();
let (head, body) = reply.split_once("\r\n\r\n").unwrap();
println!("client got status: {}", head.lines().next().unwrap());
println!("client got body: {body}");
}server saw: POST /users/42 with 5 body byte(s)
client got status: HTTP/1.1 200 OK
client got body: you asked for /users/42
A working HTTP server and a client, in the standard library, in sixty lines. There is no framework here and no dependency — TcpListener, a BufReader, and the knowledge that the first line of a request is three tokens separated by spaces.
Three details carry most of the weight. The request line is the epigraph made literal: method, target, version, terminated by CRLF. Headers run until a blank line, and their names are case-insensitive, which is why the lookup uses eq_ignore_ascii_case rather than ==. The body's length comes from a header — TCP is a stream with no message boundaries, so Content-Length is how the server knows when to stop reading rather than blocking forever.
Binding to port 0 asks the operating system for any free port, which is worth knowing for tests: it removes the entire class of flaky failures caused by a fixed port already being in use.
This is not a production server. It handles one connection, trusts every input, allocates a body buffer of whatever size the client claims, and would fall over on a malformed request. Those are the things a real implementation spends its code on — but none of them are the routing problem, which is what we came for.
A web framework is a sorting office, and this chapter is a tour of it. The envelope arrives with an address written in a standard format — that is the request line, and the first block reads it. Sorting the envelope into the right pigeonhole by its address is routing. Opening it and checking the contents are the right shape before handing them to the person who deals with that kind of letter is what an extractor does. The reason it all looks magical from outside is that you normally only see the letter arriving on the right desk, already opened, with the relevant form filled in. Once you have stood in the sorting office, there is nothing left to be impressed by — which is the point of standing in it.
Matching a path
Routing is pattern matching with one wrinkle.
// Routing is pattern matching over path segments.
use std::collections::HashMap;
#[derive(Debug, PartialEq)]
enum Seg { Static(String), Param(String) }
fn parse_pattern(pattern: &str) -> Vec<Seg> {
pattern.trim_matches('/').split('/').filter(|s| !s.is_empty())
.map(|s| match s.strip_prefix(':') {
Some(name) => Seg::Param(name.to_string()),
None => Seg::Static(s.to_string()),
})
.collect()
}
/// Match a concrete path against one pattern, capturing any parameters.
fn match_path<'a>(pattern: &[Seg], path: &'a str) -> Option<HashMap<String, &'a str>> {
let actual: Vec<&str> = path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect();
if actual.len() != pattern.len() { return None; }
let mut params = HashMap::new();
for (seg, got) in pattern.iter().zip(actual) {
match seg {
Seg::Static(want) if want == got => {}
Seg::Static(_) => return None,
Seg::Param(name) => { params.insert(name.clone(), got); }
}
}
Some(params)
}
/// Fewer parameters means more specific. /users/me must beat /users/:id.
fn specificity(pattern: &[Seg]) -> usize {
pattern.iter().filter(|s| matches!(s, Seg::Static(_))).count()
}
fn main() {
let patterns = ["/users", "/users/:id", "/users/me", "/users/:id/posts/:post"];
let mut routes: Vec<(&str, Vec<Seg>)> =
patterns.iter().map(|p| (*p, parse_pattern(p))).collect();
// Sort most specific first, so a literal always wins over a parameter.
routes.sort_by_key(|(_, segs)| std::cmp::Reverse(specificity(segs)));
println!("tried in order: {:?}", routes.iter().map(|(p, _)| *p).collect::<Vec<_>>());
for path in ["/users", "/users/42", "/users/me", "/users/7/posts/9", "/users/7/posts"] {
let hit = routes.iter().find_map(|(p, segs)| match_path(segs, path).map(|v| (*p, v)));
match hit {
Some((pattern, params)) => {
let mut kv: Vec<_> = params.into_iter().collect();
kv.sort();
println!("{path:18} -> {pattern:22} {kv:?}");
}
None => println!("{path:18} -> 404"),
}
}
}tried in order: ["/users/me", "/users/:id/posts/:post", "/users", "/users/:id"]
/users -> /users []
/users/42 -> /users/:id [("id", "42")]
/users/me -> /users/me []
/users/7/posts/9 -> /users/:id/posts/:post [("id", "7"), ("post", "9")]
/users/7/posts -> 404
Split on /, compare segment by segment, and a segment beginning with : matches anything and records what it matched. That is the entire algorithm, and it is the same one behind every framework you have used.
The wrinkle is ambiguity. Both /users/me and /users/:id match the path /users/me, and the answer cannot be "whichever was registered first", because then the behaviour of your API depends on the order of lines in a file. The convention everybody converged on is that a literal beats a parameter, which this block implements by sorting on the number of static segments before matching.
That heuristic is crude and worth being honest about. Counting static segments puts /users/:id/posts/:post ahead of /users, which happens to be harmless here but is not a principled ordering. Real routers compare segment by segment, left to right, preferring a literal at the first position where the patterns differ. The sort is also doing work on every request that could be done once at startup.
The deeper limitation is the linear scan: every request tries every route. With twenty routes nobody notices. With two thousand it is measurable, which is why production routers build a tree keyed by segment — matchit, the crate axum uses, is a radix trie, so lookup cost depends on the depth of the path rather than the number of routes.
The colon syntax for path parameters is not from a standard; it comes from Sinatra, the Ruby microframework released in 2007, and spread from there to Express, Flask, Laravel and most of what followed. HTTP itself has no concept of a path parameter at all — as far as RFC 7230 is concerned a target is an opaque string, and everything in this section is a convention layered on top by frameworks that all happened to copy the same one. That is worth keeping in mind, because it explains why the details differ everywhere: whether trailing slashes matter, whether :id or {id}, how wildcards work and which pattern wins a tie are all unspecified, and every framework answers them slightly differently.
Extractors
The second trait, and the one that does the type conversion.
// An extractor is a type that knows how to build itself from a request.
use std::collections::HashMap;
use std::str::FromStr;
#[derive(Clone)]
struct Request {
method: String,
params: HashMap<String, String>,
query: HashMap<String, String>,
body: String,
}
/// The one trait the whole idea rests on.
trait FromRequest: Sized {
fn from_request(req: &Request) -> Result<Self, String>;
}
struct Path<T>(T);
struct Query(HashMap<String, String>);
struct Body(String);
struct Method(String);
impl<T: FromStr> FromRequest for Path<T> {
fn from_request(req: &Request) -> Result<Self, String> {
let raw = req.params.values().next().ok_or("no path parameter")?;
raw.parse::<T>().map(Path).map_err(|_| format!("cannot parse {raw:?}"))
}
}
impl FromRequest for Query {
fn from_request(req: &Request) -> Result<Self, String> { Ok(Query(req.query.clone())) }
}
impl FromRequest for Body {
fn from_request(req: &Request) -> Result<Self, String> { Ok(Body(req.body.clone())) }
}
impl FromRequest for Method {
fn from_request(req: &Request) -> Result<Self, String> { Ok(Method(req.method.clone())) }
}
/// Anything optional becomes an Option of itself, for free.
impl<T: FromRequest> FromRequest for Option<T> {
fn from_request(req: &Request) -> Result<Self, String> { Ok(T::from_request(req).ok()) }
}
fn main() {
let req = Request {
method: "POST".into(),
params: HashMap::from([("id".to_string(), "42".to_string())]),
query: HashMap::from([("verbose".to_string(), "1".to_string())]),
body: "{\"name\":\"ada\"}".into(),
};
let Path(id) = Path::<u32>::from_request(&req).unwrap();
println!("Path<u32> -> {id}");
let Method(m) = Method::from_request(&req).unwrap();
println!("Method -> {m}");
let Query(q) = Query::from_request(&req).unwrap();
println!("Query -> verbose={:?}", q.get("verbose"));
let Body(b) = Body::from_request(&req).unwrap();
println!("Body -> {b}");
// The same extractor with a type that cannot parse "42" as an IP.
match Path::<std::net::IpAddr>::from_request(&req) {
Ok(_) => println!("Path<IpAddr> -> parsed"),
Err(e) => println!("Path<IpAddr> -> rejected: {e}"),
}
// And Option<T> turns a rejection into None rather than an error.
let opt = Option::<Path<std::net::IpAddr>>::from_request(&req).unwrap();
println!("Option<Path> -> is_none {}", opt.is_none());
}Path<u32> -> 42
Method -> POST
Query -> verbose=Some("1")
Body -> {"name":"ada"}
Path<IpAddr> -> rejected: cannot parse "42"
Option<Path> -> is_none true
An extractor is nothing more than a type implementing a trait with one method: given a request, produce me or explain why not. Path<T>, Query, Body and Method are four implementations of the same two-line idea, and that is genuinely all axum's extractors are.
Two things make it more than a naming convention. First, Path<T> is generic and delegates to FromStr, so Path<u32> and Path<IpAddr> are the same code with different parsing, and asking for Path<IpAddr> on a path segment of 42 produces a clean rejection rather than a panic. The type you write in the signature is the validation.
Second, and more elegant: the blanket impl for Option<T> makes every extractor optional for free. One impl, four lines, and Option<Path<u32>>, Option<Query> and anything added later all become optional with no further work. The same trick gives Result<T, E> for handlers that want to see the rejection. This is the Part III material — generics over traits — doing something that genuinely looks like a language feature.
Writing this, I made Path<T> take the first parameter in the map, which is wrong in a way worth confessing because the real design is the interesting part. With HashMap iteration order being unspecified, "the first parameter" is not even deterministic for a route with two of them — my own test only passed because it had one. The real axum deserialises all the parameters into the target type, so Path<(u32, String)> takes them positionally and Path<MyStruct> takes them by name, which is why it needs serde and why I could not reproduce it honestly in forty lines without one. The lesson I took is that the simplification was fine for teaching the mechanism and would have been a real bug in anything shipped — and that the place a miniature lies to you is exactly where the production version is doing its most interesting work.
One table, many signatures
The answer to the big question.
// How one route table holds functions with different signatures.
use std::collections::HashMap;
use std::str::FromStr;
struct Request { params: HashMap<String, String>, query: HashMap<String, String> }
trait FromRequest: Sized { fn from_request(req: &Request) -> Result<Self, String>; }
struct Path<T>(T);
struct Query(HashMap<String, String>);
impl<T: FromStr> FromRequest for Path<T> {
fn from_request(req: &Request) -> Result<Self, String> {
let raw = req.params.values().next().ok_or("no path parameter")?;
raw.parse::<T>().map(Path).map_err(|_| format!("cannot parse {raw:?}"))
}
}
impl FromRequest for Query {
fn from_request(req: &Request) -> Result<Self, String> { Ok(Query(req.query.clone())) }
}
/// The trick: the ARGUMENT TUPLE is a type parameter of the trait. Without
/// it these impls would overlap and the compiler would reject them.
trait Handler<Args> {
fn call(&self, req: &Request) -> String;
}
impl<F> Handler<()> for F where F: Fn() -> String {
fn call(&self, _req: &Request) -> String { self() }
}
impl<F, A> Handler<(A,)> for F where F: Fn(A) -> String, A: FromRequest {
fn call(&self, req: &Request) -> String {
match A::from_request(req) { Ok(a) => self(a), Err(e) => format!("400 {e}") }
}
}
impl<F, A, B> Handler<(A, B)> for F where F: Fn(A, B) -> String, A: FromRequest, B: FromRequest {
fn call(&self, req: &Request) -> String {
let (a, b) = match (A::from_request(req), B::from_request(req)) {
(Ok(a), Ok(b)) => (a, b),
(Err(e), _) | (_, Err(e)) => return format!("400 {e}"),
};
self(a, b)
}
}
/// Erase the signature so every route can live in one Vec.
type Erased = Box<dyn Fn(&Request) -> String>;
fn erase<A, H: Handler<A> + 'static>(h: H) -> Erased {
Box::new(move |req| h.call(req))
}
// Three handlers, three different signatures.
fn index() -> String { "the index".into() }
fn show(Path(id): Path<u32>) -> String { format!("user {id}") }
fn search(Path(id): Path<u32>, Query(q): Query) -> String {
let mut keys: Vec<_> = q.keys().cloned().collect();
keys.sort();
format!("user {id} with query keys {keys:?}")
}
fn main() {
let routes: Vec<(&str, Erased)> = vec![
("/", erase(index)),
("/users/:id", erase(show)),
("/users/:id/search", erase(search)),
];
let req = Request {
params: HashMap::from([("id".to_string(), "42".to_string())]),
query: HashMap::from([("q".to_string(), "rust".to_string())]),
};
for (pattern, handler) in &routes {
println!("{pattern:20} -> {}", handler(&req));
}
// A handler whose extractor fails becomes a 400 rather than a panic.
let bad = Request { params: HashMap::from([("id".to_string(), "abc".to_string())]), query: HashMap::new() };
println!("{:20} -> {}", "/users/abc", routes[1].1(&bad));
}/ -> the index
/users/:id -> user 42
/users/:id/search -> user 42 with query keys ["q"]
/users/abc -> 400 cannot parse "abc"
There is the magic, and it is one type parameter: Handler<Args> is generic over the argument tuple. Three functions with three different signatures all implement Handler, each for a different Args — (), (A,) and (A, B) — and because those are different types, the three impls do not overlap and the compiler accepts all of them.
Remove that parameter and write trait Handler with three blanket impls over F, and it fails immediately: conflicting implementations, because nothing distinguishes F: Fn() -> String from F: Fn(A) -> String at the level the coherence checker works. The Args parameter exists purely so that the impls differ in their types rather than only in their bounds. This is exactly what axum does, and in axum the impls are generated by a macro for arities up to sixteen.
The second half is type erasure. erase takes any H: Handler<A> and boxes it into a Box<dyn Fn(&Request) -> String>, which has one concrete type, so every route can live in one Vec. The generic machinery is resolved at compile time and what survives into the table is a uniform closure. It is the static-to-dynamic dispatch move from Part III, applied where it earns its keep.
Note what happens at the boundary: a failed extraction becomes 400 cannot parse "abc" instead of a panic. In axum the equivalent is the IntoResponse trait and a rejection type per extractor, but the shape is the same — the extractor returns a Result and the framework turns an Err into a response.
You will use axum, which is this design with the corners finished. Its Handler trait is generic over the argument tuple exactly as above; its FromRequestParts and FromRequest split the cheap extractors from the one that consumes the body, which is why Json<T> must come last in a signature; it routes with matchit's radix trie rather than a linear scan; and it integrates with tower so middleware is a Service wrapping a Service. The practical payoff of this chapter is diagnostic: when axum tells you a handler does not implement Handler, you now know it means one of your parameters does not implement the extractor trait, or you put the body extractor in the wrong position, or the function is not Send — and that error, which is famously unhelpful, becomes readable.
The whole thing
Routing, extraction and dispatch, answering real sockets.
// Everything so far, wired together and answering real sockets.
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::str::FromStr;
use std::thread;
struct Request { params: HashMap<String, String> }
trait FromRequest: Sized { fn from_request(r: &Request) -> Result<Self, String>; }
struct Path<T>(T);
impl<T: FromStr> FromRequest for Path<T> {
fn from_request(r: &Request) -> Result<Self, String> {
let raw = r.params.values().next().ok_or("no path parameter")?;
raw.parse::<T>().map(Path).map_err(|_| format!("cannot parse {raw:?}"))
}
}
trait Handler<A> { fn call(&self, r: &Request) -> String; }
impl<F: Fn() -> String> Handler<()> for F {
fn call(&self, _: &Request) -> String { self() }
}
impl<F: Fn(A) -> String, A: FromRequest> Handler<(A,)> for F {
fn call(&self, r: &Request) -> String {
match A::from_request(r) { Ok(a) => self(a), Err(e) => format!("400 {e}") }
}
}
type Erased = Box<dyn Fn(&Request) -> String + Send + Sync>;
fn erase<A, H: Handler<A> + Send + Sync + 'static>(h: H) -> Erased {
Box::new(move |r| h.call(r))
}
/// method, pattern segments, handler. ':' marks a parameter.
struct Route { method: &'static str, pattern: &'static str, handler: Erased }
fn segments(s: &str) -> Vec<&str> {
s.trim_matches('/').split('/').filter(|p| !p.is_empty()).collect()
}
fn route<'a>(routes: &'a [Route], method: &str, path: &str) -> Option<(&'a Route, HashMap<String, String>)> {
let actual = segments(path);
routes.iter().find_map(|rt| {
if rt.method != method { return None; }
let want = segments(rt.pattern);
if want.len() != actual.len() { return None; }
let mut params = HashMap::new();
for (w, got) in want.iter().zip(&actual) {
match w.strip_prefix(':') {
Some(name) => { params.insert(name.to_string(), got.to_string()); }
None if w == got => {}
None => return None,
}
}
Some((rt, params))
})
}
fn main() {
let routes: Vec<Route> = vec![
Route { method: "GET", pattern: "/", handler: erase(|| "the index".to_string()) },
Route { method: "GET", pattern: "/users/:id",
handler: erase(|Path(id): Path<u32>| format!("user {id}")) },
];
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
thread::spawn(move || {
for incoming in listener.incoming().take(3) {
let mut stream = incoming.unwrap();
let mut line = String::new();
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
let mut parts = line.trim_end().split(' ');
let (method, target) = (parts.next().unwrap_or(""), parts.next().unwrap_or("/"));
let (status, body) = match route(&routes, method, target) {
Some((rt, params)) => {
let req = Request { params };
(200, (rt.handler)(&req))
}
None => (404, format!("no route for {method} {target}")),
};
let res = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n\r\n{body}", body.len());
stream.write_all(res.as_bytes()).unwrap();
}
});
for (method, target) in [("GET", "/"), ("GET", "/users/42"), ("GET", "/nope")] {
let mut c = TcpStream::connect(addr).unwrap();
write!(c, "{method} {target} HTTP/1.1\r\nHost: x\r\n\r\n").unwrap();
let mut reply = String::new();
c.read_to_string(&mut reply).unwrap();
let (head, body) = reply.split_once("\r\n\r\n").unwrap();
println!("{method} {target:12} -> {} {body}", head.lines().next().unwrap());
}
}GET / -> HTTP/1.1 200 the index
GET /users/42 -> HTTP/1.1 200 user 42
GET /nope -> HTTP/1.1 404 no route for GET /nope
A real server, real sockets, real routing, real extraction, and a closure with a typed parameter being called with a u32 parsed out of a URL. No dependencies, no macros, no reflection — a trait with one method, a trait generic over its argument tuple, and Box<dyn Fn>.
The honest accounting of what separates this from axum is worth stating, because none of it is mystery: a radix trie instead of a linear scan; async handlers and a runtime to drive them; IntoResponse so handlers can return more than String; serde for real deserialisation; shared state via an extractor; middleware through tower; and a correct HTTP implementation underneath, with chunked encoding, HTTP/2, timeouts and limits. Each is a day's reading. None of them changes the skeleton you have just built.
Common Mistakes
Assuming reflection must be involved. Rust has none; the whole effect comes from a trait generic over the argument tuple plus a blanket impl per arity.
Registering routes in an order that decides behaviour. If
/users/meand/users/:idboth match, the winner must follow a documented rule, not source order.Scanning every route for every request. Fine at twenty routes, measurable at two thousand — production routers use a radix trie keyed by segment.
Comparing header names with
==. HTTP header names are case-insensitive, soContent-Lengthandcontent-lengthare the same header.Reading a body without
Content-Length. TCP is a stream with no message boundaries, so a read with no length either blocks forever or truncates.Trusting a client-supplied length. Allocating a buffer of whatever size the request claims is a denial-of-service waiting to happen; real servers cap it.
Panicking on a bad parameter. An extractor should return
Errand become a 400; a panic in a handler takes down more than the request.Returning 404 when only the method was wrong. A path that exists with a different method is a 405 with an
Allowheader, and clients distinguish them.Binding a fixed port in tests. Port 0 asks the OS for a free one and removes a whole class of flaky failures.
Writing extractors that each re-parse the request. Parse once into a request struct; extractors should read from it, not repeat the work.
Interview Corner
TODO 1 is a real defect in the code as written: a path that exists with the wrong method reports 404, and telling a client "no such thing" when you mean "not that way" breaks their retry logic. TODO 2 looks like a small addition until you work out where a catch-all has to sit in the ordering for it not to swallow everything. TODO 3 is the performance question with the important half attached — estimate the crossover before you measure it, then check. TODO 4 is the ambiguity rule, and the marking is on the defence rather than the implementation.
use std::collections::HashMap;
struct Route { method: &'static str, pattern: &'static str }
fn segments(s: &str) -> Vec<&str> {
s.trim_matches('/').split('/').filter(|p| !p.is_empty()).collect()
}
fn resolve<'a>(routes: &'a [Route], method: &str, path: &str)
-> Option<(&'a Route, HashMap<String, String>)>
{
let actual = segments(path);
routes.iter().find_map(|rt| {
if rt.method != method { return None; }
let want = segments(rt.pattern);
if want.len() != actual.len() { return None; }
let mut params = HashMap::new();
for (w, got) in want.iter().zip(&actual) {
match w.strip_prefix(':') {
Some(name) => { params.insert(name.to_string(), got.to_string()); }
None if w == got => {}
None => return None,
}
}
Some((rt, params))
})
}
fn main() {
let routes = [
Route { method: "GET", pattern: "/users" },
Route { method: "POST", pattern: "/users" },
Route { method: "GET", pattern: "/users/:id" },
];
for (m, p) in [("GET", "/users/9"), ("DELETE", "/users/9"), ("GET", "/users/9/posts")] {
match resolve(&routes, m, p) {
Some((rt, params)) => {
let mut kv: Vec<_> = params.into_iter().collect();
kv.sort();
println!("{m:6} {p:16} -> {} {kv:?}", rt.pattern);
}
None => println!("{m:6} {p:16} -> 404"),
}
}
// TODO 1: DELETE /users/9 reports 404, but the path exists and only the
// method is wrong. That should be 405 with an Allow header.
// Change resolve to tell the two apart.
// TODO 2: add a catch-all segment, so "/static/*rest" matches any depth
// and captures the remainder. Where must it sit in the ordering?
// TODO 3: this scans every route for every request. Build the patterns
// into a tree keyed by segment instead, and say at what number
// of routes you would expect the difference to be measurable.
// TODO 4: two routes, "/users/me" and "/users/:id", both match
// "/users/me". Decide which should win, implement it, and
// defend the rule you chose.
}GET /users/9 -> /users/:id [("id", "9")]
DELETE /users/9 -> 404
GET /users/9/posts -> 404
Summary
- HTTP is a text protocol — a request line of method, target and version, headers until a blank line, and a body whose length a header declares.
- Header names are case-insensitive, and a body cannot be framed without
Content-Lengthor chunked encoding, because TCP has no message boundaries. - Routing is segment-by-segment matching, where a
:namesegment matches anything and captures it; the colon convention comes from Sinatra, not from any standard. - Ambiguity needs a documented rule, conventionally that a literal beats a parameter — never registration order.
- A linear scan is fine small and wrong large; production routers use a radix trie keyed by segment, as
matchitdoes foraxum. - An extractor is a trait with one method: given a request, produce me or explain why not, with the failure becoming a 400 rather than a panic.
- Generic extractors make the signature the validation —
Path<T>delegating toFromStrmeans the handler's type performs the parsing and rejection. - A blanket impl for
Option<T>makes every extractor optional for free, including ones written later. - A trait generic over the argument tuple is how functions of different shapes share a table: one impl per arity, non-overlapping because
Argsdiffers. - Type erasure finishes the job — box each handler into one
Box<dyn Fn>so the generic machinery stays at compile time and the table stays uniform.
In the next chapter, An Executor, in Miniature, we do the same demystifying to the runtime: Part V's block_on drove a single future, and the step from that to spawning many tasks is where a scheduler, a task queue and a waker per task actually appear.
There is a habit worth taking from this chapter that outlives anything specific about routers. When a tool feels magical, the feeling is information: it means there is a mechanism you have not yet located, and the mechanism is nearly always smaller than the awe surrounding it. Here it turned out to be one type parameter on a trait. That does not make axum less impressive — the work in a real framework is in the hundred details this miniature skips, and those details are what make it safe to use on the open internet. What it does is move the framework from the category of things you trust to the category of things you understand, and the practical difference between those two shows up at three in the morning, when a trait bound error that used to be unreadable turns out to be telling you exactly what is wrong.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.