Just added: Algorithms you can run and practice
Updated Jul 10, 2026 Edit on GitHub

Routing & Dynamic Paths

Phase 1 stood up a server that answered one fixed URL. Real APIs aren't fixed - /books/42 and /books/99 go to the same handler, with 42 and 99 riding along as data. This phase is about that wiring: how a URL turns into a function call with the right arguments already filled in.

The path string declares the shape of the URL, and the function parameters receive the pieces. Write #[get("/books/<id>")] and <id> is a hole in the path; the function needs a parameter literally named id to catch what lands there. Two rules fall out: the names must line up (matched by name, not position), and the type does the parsing - Rocket takes the raw text from the URL and tries to turn it into whatever type you declared, via a trait called FromParam.

📝 We're building a books API all the way through this guide. The data shape is a simple Book { id, title, author }. This phase focuses purely on routing - getting the right values into the right handlers. Real JSON comes in Phase 4; for now handlers return plain strings so the wiring stays visible.

Method attributes: the verb and the path together

A Rocket route attribute carries two things at once - the HTTP method and the URL path. The method is the attribute name; the path is its argument.

#[get("/books")]
fn list() -> &'static str {
    "all books"
}

#[post("/books")]
fn create() -> &'static str {
    "created a book"
}

#[get("/books/<id>")]
fn show(id: u32) -> String {
    format!("book #{id}")
}

#[put("/books/<id>")]
fn update(id: u32) -> String {
    format!("updated book #{id}")
}

#[delete("/books/<id>")]
fn delete(id: u32) -> String {
    format!("deleted book #{id}")
}

What just happened: Five handlers, five HTTP verbs. /books appears twice - once for #[get] (list them), once for #[post] (create one) - and that's not a conflict: Rocket keys on method + path together, so GET /books and POST /books are entirely separate routes. The three /books/<id> routes likewise differ by verb. This is the REST shape you'll grow over the rest of the guide.

Dynamic path segments: <id> to id: u32

#[get("/books/<id>")] fn show(id: u32): the <id> says "this segment is a variable, call it id," and the function declares id: u32. When a request for /books/42 arrives, Rocket pulls the text "42" out of that segment and asks: can this become a u32? It can, so show runs with id = 42.

That question is the whole game. The parameter type must implement FromParam - the trait that knows how to build a value from one URL segment. u32, i64, String, bool, and many more already implement it. String accepts anything, while u32 only accepts digits that fit.

// /books/42   -> id = 42, show() runs
// /books/9999 -> id = 9999, show() runs
// /books/abc  -> "abc" is not a u32, so this route does NOT match

What just happened: That last line is the part people trip on. When FromParam parsing fails, Rocket doesn't error out - it decides this route doesn't match and moves on to try the next route. If nothing else matches /books/abc, the request ends in a 404. Your id: u32 does double duty: it extracts the number and quietly rejects non-numbers, before a single line of your handler body runs.

💡 This is a feature, not a quirk. Parsing happens at the routing layer, so your handler body only ever sees a valid u32 - no defensive if let Ok(n) = id.parse(). Let the signature be your validation.

More than one segment, and "catch the rest"

Nothing stops you from having several dynamic segments. Match them all by name:

#[get("/order/<a>/<b>")]
fn order(a: u32, b: String) -> String {
    format!("a={a}, b={b}")
}

What just happened: /order/7/express gives a = 7 (parsed as a number) and b = "express" (any text). Each <...> lines up with the parameter of the same name; mixing types per segment is fine.

To swallow everything after a point - serving files under a folder, say - use the trailing multi-segment form <name..>, which collects the rest of the path into a PathBuf:

use std::path::PathBuf;

#[get("/files/<path..>")]
fn files(path: PathBuf) -> String {
    format!("you asked for {}", path.display())
}

What just happened: /files/covers/2024/rust.png puts covers/2024/rust.png into path as a single PathBuf. The .. makes it greedy across multiple segments; without it, <path> only matches one segment.

⚠️ <path..> hands you a PathBuf built from untrusted URL input. If you use it to read real files, guard against ../ directory-traversal tricks - Rocket has helpers for serving static files safely, pointed at in Phase 8. Treat the segment as raw user input.

Query params: the part after the ?

Path segments are the URL's skeleton. The query string - everything after ? - is for optional extras: pagination, search terms, filters. Declare query params in the attribute with ?<name> and receive them as parameters, just like path segments. The difference is they're usually optional, expressed with Option<T>.

#[get("/books?<page>&<q>")]
fn list_books(page: Option<u32>, q: Option<String>) -> String {
    let page = page.unwrap_or(1);
    match q {
        Some(term) => format!("page {page}, searching for {term}"),
        None => format!("page {page}, no search"),
    }
}

What just happened: The path is still /books, but the attribute also declares two query params, joined with & exactly like a real URL. GET /books gives both as None. GET /books?page=3 gives page = Some(3), q = None. GET /books?q=rust&page=2 fills both. Because they're Option<T>, a missing param isn't an error - it's None, and you decide the default (here, page 1).

💡 A plain u32 instead of Option<u32> makes a query param required - a request missing it won't match the route. Use plain types only for params you truly always need.

When you have a bunch of related query params, listing them one by one gets noisy. Group them into a struct that derives FromForm, then receive the whole struct with the trailing <params..> form:

use rocket::form::FromForm;

#[derive(FromForm)]
struct Filter {
    page: Option<u32>,
    author: Option<String>,
}

#[get("/books?<params..>")]
fn filtered(params: Filter) -> String {
    format!("page={:?}, author={:?}", params.page, params.author)
}

What just happened: Same query string (?page=2&author=hopper), but collected into one tidy Filter value instead of a long parameter list. FromForm maps query (and HTML form) fields onto struct fields by name - you'll meet it again for POST bodies in Phase 3, same machinery.

The trap: a route that exists but never runs

The single most common "why is my endpoint 404-ing?" in Rocket has nothing to do with the path. Writing #[get(...)] fn whatever() does not put the route into your application - it only defines it. You still have to register every handler in routes![...] and mount it onto Rocket.

#[launch]
fn rocket() -> _ {
    rocket::build().mount(
        "/",
        routes![list, create, show, update, delete, files, list_books],
    )
}

What just happened: Every handler we wrote is named in routes![...]. Add a new #[get] and forget to list it here, and Rocket compiles cleanly and starts fine - but that URL just 404s, with no error pointing at the cause.

⚠️ A handler missing from routes! fails silently. No compile error, no warning, no log line. When an endpoint mysteriously 404s, check routes![...] first, before touching the path string. This catches more people than any actual routing bug.

When two routes could both match: ranking

Sometimes more than one route fits a URL, and Rocket chooses by ranking: more specific routes (static segments like /books/new) outrank less specific ones (dynamic segments like /books/<id>). A request to /books/new prefers the literal new route over the <id> catch-all - almost always what you want.

#[get("/books/new")]      // static -> ranked ahead
fn new_form() -> &'static str { "new book form" }

#[get("/books/<id>")]     // dynamic -> ranked behind
fn show(id: u32) -> String { format!("book #{id}") }

What just happened: /books/new hits new_form (the literal match wins); /books/42 falls through to show. Rocket worked this ordering out for you. To override it, add rank = N to the attribute (lower numbers tried first) - reach for that only when the defaults genuinely don't fit.

Recap

  • A route attribute names both the method and the path: #[get("/books")], #[post("/books")], #[put("/books/<id>")]. Same path, different verb = different route.
  • A dynamic segment <id> pairs with a same-named function parameter; the type implements FromParam, which parses the text and, on failure, makes the route not match (eventually a 404) instead of erroring.
  • Use multiple <...> segments by name; use the trailing <path..> to capture the rest of the URL into a PathBuf (treat it as untrusted input).
  • Query params go in the attribute as ?<page>&<q> and arrive as parameters - Option<T> for optional, a plain type for required, or a #[derive(FromForm)] struct via <params..> to group them.
  • Every handler must be listed in routes![...] and mounted, or it silently won't serve. Overlapping routes are resolved by ranking (specific beats dynamic), overridable with rank.

Quick check

[
  {
    "q": "A request hits /books/abc but your only matching route is #[get(\"/books/<id>\")] fn show(id: u32). What happens?",
    "choices": ["show() runs with id set to 0", "Rocket panics at runtime", "The route doesn't match (abc isn't a u32), so it falls through - likely a 404", "It compiles but returns a 500 error"],
    "answer": 2,
    "explain": "FromParam parsing of \"abc\" into u32 fails, so the route does not match. Rocket tries other routes and, finding none, returns 404. Your handler body never runs."
  },
  {
    "q": "You added #[get(\"/health\")] fn health() but it 404s in the running app. The path is correct. What's the most likely cause?",
    "choices": ["You forgot to add `health` to routes![...] and mount it", "You need a semicolon after the attribute", "Rocket requires health checks to use #[post]", "The function name must match the path"],
    "answer": 0,
    "explain": "Defining a handler doesn't register it. It must be named in routes![...] and mounted. This omission fails silently - no compile error - so it's the first thing to check on a mystery 404."
  },
  {
    "q": "Which signature makes a `page` query param optional, defaulting to your own value when it's missing?",
    "choices": ["fn list(page: u32)", "fn list(page: Option<u32>)", "fn list(page: PathBuf)", "fn list(page: bool)"],
    "answer": 1,
    "explain": "Option<u32> means the param may be absent (None) without breaking the match, letting you supply a default. A plain u32 would make the param required, so a request without it wouldn't match the route."
  }
]

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. A request hits /books/abc but your only matching route is #[get("/books/<id>")] fn show(id: u32). What happens?

2. You added #[get("/health")] fn health() but it 404s in the running app. The path is correct. What's the most likely cause?

3. Which signature makes a `page` query param optional, defaulting to your own value when it's missing?