Step 2 of 7
Routing by method and path
A server receives every request in one place and must decide which code handles it. That's routing: look at the method and the path, and pick a handler. In Rust, a match on a tuple of the method and the path segments does it beautifully, thanks to slice patterns:
#[derive(Debug, Clone, Copy, PartialEq)]
enum Method {
Get,
Post,
Delete,
}
fn route(method: Method, path: &str) -> (u16, String) {
let path = path.split('?').next().unwrap_or("");
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
match (method, segments.as_slice()) {
(Method::Get, ["health"]) => (200, String::from("ok")),
(Method::Get, ["players"]) => (200, String::from("list players")),
(Method::Post, ["players"]) => (201, String::from("create a player")),
(Method::Get, ["players", id]) => match id.parse::<u32>() {
Ok(id) => (200, format!("show player {id}")),
Err(_) => (400, format!("bad id {id:?}")),
},
(Method::Delete, ["players", id]) => (204, format!("delete player {id}")),
(_, ["health"] | ["players"] | ["players", _]) => (405, String::from("method not allowed")),
_ => (404, String::from("not found")),
}
}
fn main() {
let tries = [
(Method::Get, "/players"),
(Method::Get, "/players/42?fields=name"),
(Method::Get, "/players/ferris"),
(Method::Post, "/players/"),
(Method::Delete, "/players/7"),
(Method::Delete, "/health"),
(Method::Get, "/teams"),
];
for (method, path) in tries {
let (status, body) = route(method, path);
println!("{method:?} {path} -> {status} {body}");
}
}
Get /players -> 200 list players
Get /players/42?fields=name -> 200 show player 42
Get /players/ferris -> 400 bad id "ferris"
Post /players/ -> 201 create a player
Delete /players/7 -> 204 delete player 7
Delete /health -> 405 method not allowed
Get /teams -> 404 not found
Piece by piece:
- Everything after
?is the query string (?fields=name), which isn't part of the route, so we cut it off first. split('/')on/players/42gives"","players","42"; filtering out empty pieces also makes a trailing slash (/players/) harmless.segments.as_slice()lets us match with slice patterns:["players", id]matches exactly two segments, the first being"players", and binds the second toid.- A path parameter like
idis still text. Parse it, and answer400when it isn't a valid id: a bad id is the client's mistake, not a missing resource. - The second-to-last arm catches known paths with the wrong method (
405); the last catches everything else (404). Arm order matters:matchtakes the first that fits.
Common mistakes
- Putting the catch-all
_arm first: every request hits it. The compiler warnsunreachable patternfor the arms after it. - Answering 404 for a wrong method. Clients and API tools rely on 405 to tell "this path doesn't exist" from "use another method".
- Comparing whole path strings (
path == "/players"): then/players/and/players?x=1fail, and ids need manual slicing. Segments and slice patterns handle both.
Your task
Write the router for a books API. route(method, path) returns a Route: GET /books is ListBooks, POST /books is CreateBook, GET /books/{id} is GetBook(id), DELETE /books/{id} is DeleteBook(id), GET /books/{id}/reviews is ListReviews(id). An id that isn't a u32 gives BadRequest; one of those paths with another method gives MethodNotAllowed (whatever the id); everything else is NotFound. Ignore query strings and trailing slashes.
Challenge 2
Query strings carry options like filters and pages: /search?q=crab&page=2. Write fn parse_query(target: &str) -> (&str, BTreeMap<String, String>) returning the path and the parameters. Parameters are separated by &; each is key=value (split at the first =), and a + in a value means a space. A key without = maps to an empty string; empty pieces (a=1&&b=2) are skipped.
Challenge 3
List endpoints take paging options. Write fn page_params(query: &BTreeMap<String, String>) -> Result<(usize, usize), String> that reads page (default 1, must be at least 1) and per_page (default 20, must be 1 to 100) and returns (offset, limit) for a database query, where offset is (page - 1) * per_page. Errors: invalid page "x" and per_page must be 1 to 100.