Skip to content

A routes! macro

Level: 301 · deep dive

One line: routes! { GET "/users/{id}" => fn get_user(id: &str) -> String { … } } checks every path against its handler while compiling — a {param} the handler does not take, a parameter the path does not capture, two routes that match the same requests — and turns the table into one match on the method and the path's segments, with no router crate at run time.

A table, and the requests it routes

demo/routes_app/src/main.rs
use routes_macro::routes;

routes! {
    GET "/users" => fn list_users() -> String {
        String::from("every user")
    }
    GET "/users/{id}" => fn get_user(id: &str) -> String {
        String::from("user ") + id
    }
    POST "/users" => fn create_user() -> String {
        String::from("a new user")
    }
    GET "/users/{id}/orders/{order}" => fn get_order(id: &str, order: &str) -> String {
        String::from("order ") + order + " of user " + id
    }
}

fn main() {
    let requests = [
        ("GET", "/users"),
        ("GET", "/users/42"),
        ("POST", "/users"),
        ("GET", "/users/42/orders/7"),
        ("DELETE", "/users/42"),
        ("GET", "/teams"),
    ];
    for (method, path) in requests {
        match route(method, path) {
            Some((handler, body)) => println!("{method:<6} {path:<19} -> {handler}: {body}"),
            None => println!("{method:<6} {path:<19} -> no route"),
        }
    }
}

Verified output of cargo run -q -p routes_app — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

GET    /users              -> list_users: every user
GET    /users/42           -> get_user: user 42
POST   /users              -> create_user: a new user
GET    /users/42/orders/7  -> get_order: order 7 of user 42
DELETE /users/42           -> no route
GET    /teams              -> no route

route is not written anywhere in main.rs. The macro generated it, with the four handlers, from the table.

The grammar

demo/routes_macro/src/route.rs
//! What `routes!` reads: `METHOD "path" => fn handler(..) { .. }`, repeated.

use syn::parse::{Parse, ParseStream};
use syn::{Ident, ItemFn, LitStr, Token};

pub struct Routes(pub Vec<Route>);

pub struct Route {
    pub method: Ident,
    pub path: LitStr,
    pub handler: ItemFn,
}

impl Parse for Routes {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let mut routes = Vec::new();
        while !input.is_empty() {
            routes.push(input.parse()?);
        }
        Ok(Routes(routes))
    }
}

impl Parse for Route {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let method = input.parse()?;
        let path = input.parse()?;
        input.parse::<Token![=>]>()?;
        let handler = input.parse()?;
        Ok(Route { method, path, handler })
    }
}

/// The text between two slashes: matched as written, or captured by name.
pub enum Segment {
    Literal(String),
    Param(String),
}

/// Splits `/users/{id}` into `users` and `{id}`. It returns messages rather
/// than `syn::Error`s: the string's contents have no spans of their own.
pub fn segments(path: &str) -> Result<Vec<Segment>, String> {
    let Some(rest) = path.strip_prefix('/') else {
        return Err(format!("a path starts with `/`: {path:?}"));
    };
    let segment = |piece: &str| match piece.strip_prefix('{').and_then(|p| p.strip_suffix('}')) {
        Some(name) if syn::parse_str::<Ident>(name).is_ok() => Ok(Segment::Param(name.to_string())),
        Some(name) => Err(format!("`{name}` is not a parameter name")),
        None if piece.contains(['{', '}']) => Err(format!("unmatched brace in `{piece}`")),
        None => Ok(Segment::Literal(piece.to_string())),
    };
    rest.split('/').map(segment).collect()
}

/// The names of a handler's parameters, with the identifier each was written as.
pub fn parameters(handler: &ItemFn) -> syn::Result<Vec<&Ident>> {
    let names = handler.sig.inputs.iter().map(|input| match input {
        syn::FnArg::Typed(typed) => match &*typed.pat {
            syn::Pat::Ident(name) => Ok(&name.ident),
            other => Err(syn::Error::new_spanned(other, "a handler parameter must be a plain name")),
        },
        syn::FnArg::Receiver(receiver) => {
            Err(syn::Error::new_spanned(receiver, "a handler cannot take `self`"))
        }
    });
    names.collect()
}

A route is three things syn can already parse, Ident, LitStr and ItemFn, with Token![=>] between the second and the third. The handlers are written inside the macro call for one reason: a function-like macro receives the tokens between its delimiters and nothing else. Given GET "/users/{id}" => get_user, the macro would have a name and no signature, so it could not know whether get_user takes an id. The other design puts an attribute on each handler, which receives the function it is attached to (Parse, tweak, re-emit).

segments reads inside the string literal, which is the part no macro_rules! matcher can reach (When macro_rules! runs out). It returns plain messages rather than syn::Errors: LitStr::value() is a String, and the text in it has no spans of its own.

The checks

demo/routes_macro/src/check.rs
//! Everything `routes!` checks before it writes a line of code. Each problem
//! becomes a `syn::Error` on the token responsible, and all of them are
//! returned together, so one build reports every mistake in the table.

use crate::route::{Route, Segment, parameters, segments};
use syn::Error;

pub fn check(routes: &[Route]) -> syn::Result<()> {
    let mut errors = Vec::new();
    // Each route's method and path shape, `GET users/{}`, in table order.
    let mut shapes: Vec<(String, &Route)> = Vec::new();
    for route in routes {
        let path = route.path.value();
        let segments = match segments(&path) {
            Ok(segments) => segments,
            Err(message) => {
                errors.push(Error::new(route.path.span(), message));
                continue;
            }
        };
        let taken = match parameters(&route.handler) {
            Ok(taken) => taken,
            Err(error) => {
                errors.push(error);
                continue;
            }
        };
        let handler = &route.handler.sig.ident;
        for segment in &segments {
            if let Segment::Param(name) = segment
                && !taken.iter().any(|ident| *ident == name)
            {
                let message = format!("the path captures `{{{name}}}`, but `{handler}` takes no `{name}`");
                errors.push(Error::new(route.path.span(), message));
            }
        }
        for ident in &taken {
            if !segments.iter().any(|s| matches!(s, Segment::Param(name) if *ident == name)) {
                let message = format!("`{ident}` is not captured by {path:?}; add `{{{ident}}}` to the path");
                errors.push(Error::new(ident.span(), message));
            }
        }
        let shape = segments.iter().map(|segment| match segment {
            Segment::Literal(text) => text.as_str(),
            Segment::Param(_) => "{}",
        });
        let shape = format!("{} {}", route.method, shape.collect::<Vec<_>>().join("/"));
        if let Some((_, earlier)) = shapes.iter().find(|(seen, _)| *seen == shape) {
            let message = format!("this route matches the same requests as {:?}", earlier.path.value());
            let mut error = Error::new(route.path.span(), message);
            error.combine(Error::new(earlier.path.span(), "the earlier route is here"));
            errors.push(error);
        } else {
            shapes.push((shape, route));
        }
    }
    let mut errors = errors.into_iter();
    match errors.next() {
        None => Ok(()),
        Some(mut first) => {
            first.extend(errors);
            Err(first)
        }
    }
}

Three things are checked for every route, after the path has been split:

Mistake The error is put on
{id} in the path, and no id among the handler's parameters the path literal
a handler parameter, page, that the path does not capture the parameter's own identifier
a route with the same method and the same shape as an earlier one, whatever its {names} are the later path, with a second message on the earlier one

The checks do not stop at the first problem. Each one pushes a syn::Error, and at the end extend folds them into one Error that holds every message, so a single build reports every mistake in the table.

A table with every mistake in it

demo/routes_mistakes/src/main.rs
use routes_macro::routes;

routes! {
    GET "/users/{id}" => fn get_user() -> String {
        String::from("which user?")
    }
    GET "/users/{id}/orders" => fn list_orders(id: &str, page: &str) -> String {
        String::from("orders of user ") + id + ", page " + page
    }
    GET "/users/{user_id}" => fn find_user(user_id: &str) -> String {
        String::from("user ") + user_id
    }
}

fn main() {}

Verified output of cargo build -q -p routes_mistakes, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error: the path captures `{id}`, but `get_user` takes no `id`
 --> routes_mistakes/src/main.rs:4:9
  |
4 |     GET "/users/{id}" => fn get_user() -> String {
  |         ^^^^^^^^^^^^^

error: `page` is not captured by "/users/{id}/orders"; add `{page}` to the path
 --> routes_mistakes/src/main.rs:7:58
  |
7 |     GET "/users/{id}/orders" => fn list_orders(id: &str, page: &str) -> String {
  |                                                          ^^^^

error: this route matches the same requests as "/users/{id}"
  --> routes_mistakes/src/main.rs:10:9
   |
10 |     GET "/users/{user_id}" => fn find_user(user_id: &str) -> String {
   |         ^^^^^^^^^^^^^^^^^^

error: the earlier route is here
 --> routes_mistakes/src/main.rs:4:9
  |
4 |     GET "/users/{id}" => fn get_user() -> String {
  |         ^^^^^^^^^^^^^

error: could not compile `routes_mistakes` (bin "routes_mistakes") due to 4 previous errors

Three things to read in that output:

  • page is underlined exactly. It is an Ident the parser kept, and its span is the four characters in main.rs.
  • {id} is not. The best a stable procedural macro can do for text inside a string is the whole literal; a narrower span needs Literal::subspan, which is still unstable (the same limit, next to format_args!). The message names {id} to make up for it.
  • "the earlier route is here" is a fourth error, not a note. syn::Error::combine adds a message, and every message becomes its own compile error. An attached note needs proc_macro::Diagnostic, which in 1.98.0 is #[unstable(feature = "proc_macro_diagnostic", issue = "54140")].

main is empty on purpose. When a check fails the macro returns only the errors, so no route function exists to call. Re-emit the item on error is about that trade-off.

The code it generates

demo/routes_macro/src/lib.rs
//! `routes!`: a table of `METHOD "path" => fn handler(..) { .. }` lines,
//! checked while compiling and turned into one `route` function.

mod check;
mod route;

use proc_macro::TokenStream;
use quote::{format_ident, quote};
use route::{Routes, Segment, parameters, segments};
use syn::parse_macro_input;

#[proc_macro]
pub fn routes(input: TokenStream) -> TokenStream {
    let Routes(routes) = parse_macro_input!(input as Routes);
    if let Err(errors) = check::check(&routes) {
        return errors.into_compile_error().into();
    }
    let arms = routes.iter().map(|route| {
        let method = route.method.to_string();
        let patterns = segments(&route.path.value()).unwrap().into_iter().map(|segment| match segment {
            Segment::Literal(text) => quote!(#text),
            Segment::Param(name) => {
                let name = format_ident!("{}", name);
                quote!(#name)
            }
        });
        let handler = &route.handler.sig.ident;
        let name = handler.to_string();
        let arguments = parameters(&route.handler).unwrap();
        quote! {
            (#method, [#(#patterns),*]) => Some((#name, #handler(#(#arguments),*))),
        }
    });
    let handlers = routes.iter().map(|route| &route.handler);
    quote! {
        #(#handlers)*

        /// The handler for `method` and `path`, and what it returned.
        pub fn route(method: &str, path: &str) -> Option<(&'static str, String)> {
            let segments: Vec<&str> = path.split('/').skip(1).collect();
            match (method, segments.as_slice()) {
                #(#arms)*
                _ => None,
            }
        }
    }
    .into()
}

cargo expand on routes_app shows the result (recorded with the command underneath it, cargo rustc -- -Zunpretty=expanded):

Verified output of cargo rustc -q -p routes_app --profile=check -- -Zunpretty=expanded — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

#![feature(prelude_import)]
extern crate std;
#[prelude_import]
use std::prelude::rust_2024::*;
use routes_macro::routes;

fn list_users() -> String { String::from("every user") }
fn get_user(id: &str) -> String { String::from("user ") + id }
fn create_user() -> String { String::from("a new user") }
fn get_order(id: &str, order: &str) -> String {
    String::from("order ") + order + " of user " + id
}
#[doc = r" The handler for `method` and `path`, and what it returned."]
pub fn route(method: &str, path: &str) -> Option<(&'static str, String)> {
    let segments: Vec<&str> = path.split('/').skip(1).collect();
    match (method, segments.as_slice()) {
        ("GET", ["users"]) => Some(("list_users", list_users())),
        ("GET", ["users", id]) => Some(("get_user", get_user(id))),
        ("POST", ["users"]) => Some(("create_user", create_user())),
        ("GET", ["users", id, "orders", order]) =>
            Some(("get_order", get_order(id, order))),
        _ => None,
    }
}

fn main() {
    let requests =
        [("GET", "/users"), ("GET", "/users/42"), ("POST", "/users"),
                ("GET", "/users/42/orders/7"), ("DELETE", "/users/42"),
                ("GET", "/teams")];
    for (method, path) in requests {
        match route(method, path) {
            Some((handler, body)) => {
                ::std::io::_print(format_args!("{0:<6} {1:<19} -> {2}: {3}\n",
                        method, path, handler, body));
            }
            None => {
                ::std::io::_print(format_args!("{0:<6} {1:<19} -> no route\n",
                        method, path));
            }
        }
    }
}
  • Each route is one arm of a match on a tuple: the method string, and the path's segments as a slice. "/users/{id}" became the slice pattern ["users", id], where "users" must be equal and id binds whatever is there. A request with a different number of segments matches no arm of that length, which is how /users and /users/42 stay apart without any code that counts.
  • The binding is named after the {param}, and the call passes the handler's parameters in the handler's order. get_order(id: &str, order: &str) is called as get_order(id, order) whatever order the path names them in. The bindings are &&str, since the match is on a &[&str] (match ergonomics), and the call coerces them to &str.
  • The first arm that matches wins. That is why a duplicate is an error: a second GET users/{} arm could never run. A table with "/users/{id}" and then "/users/me" passes the check, since their shapes differ, and "/users/me" would still never be reached; ordering the arms literal-first would be the next check to add.
  • The doc comment on route came out as #[doc = r" …"]: inside quote!, /// is the attribute it always stands for.

Nothing here needs a crate at run time. routes_app depends on routes_macro alone, and that crate runs inside the compiler; the program gets the match the macro wrote.

Keeping the errors under test

The recorded build above shows the errors today. trybuild keeps them that way: it compiles the same file and compares what rustc printed with a copy saved beside it.

demo/routes_macro/tests/ui.rs
//! Compiles the table with every mistake in it, and fails if the errors differ
//! from the copy recorded beside it in `main.stderr`.

#[test]
fn rejected_routes() {
    let cases = trybuild::TestCases::new();
    cases.compile_fail("../routes_mistakes/src/main.rs");
}

TRYBUILD=overwrite cargo test wrote the copy the first time:

demo/routes_mistakes/src/main.stderr
error: the path captures `{id}`, but `get_user` takes no `id`
 --> $WORKSPACE/routes_mistakes/src/main.rs
  |
  |     GET "/users/{id}" => fn get_user() -> String {
  |         ^^^^^^^^^^^^^

error: `page` is not captured by "/users/{id}/orders"; add `{page}` to the path
 --> $WORKSPACE/routes_mistakes/src/main.rs
  |
  |     GET "/users/{id}/orders" => fn list_orders(id: &str, page: &str) -> String {
  |                                                          ^^^^

error: this route matches the same requests as "/users/{id}"
 --> $WORKSPACE/routes_mistakes/src/main.rs
  |
  |     GET "/users/{user_id}" => fn find_user(user_id: &str) -> String {
  |         ^^^^^^^^^^^^^^^^^^

error: the earlier route is here
 --> $WORKSPACE/routes_mistakes/src/main.rs
  |
  |     GET "/users/{id}" => fn get_user() -> String {
  |         ^^^^^^^^^^^^^

It is the same four errors, with the path rewritten to $WORKSPACE and the line numbers removed, so the file survives a move and an edit above the mistake. Since then the test only compares:

Verified output of cargo test -q -p routes_macro --test ui — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in <elapsed>

Testing with trybuild covers the tool itself.

If you are coming from another language

  • C. A router in C is typically a table of structs holding a method, a pattern string and a function pointer, matched at run time. The compiler checks that each pointer has the right type, and never looks inside the pattern: a :id with no matching argument is found by whoever sends that request. routes! is the same table, read while compiling.
  • Python. Flask's @app.route("/users/<id>") registers the handler when the module is imported, and passes the captured values as keyword arguments when a request arrives. A handler without id fails only then, with TypeError: get_user() got an unexpected keyword argument 'id' (what Python 3.14 raises for get_user(**{"id": "42"})). The decorator form keeps the path next to the function it describes, which is the attribute-macro design; routes! keeps the whole table in one place.
  • Java. In annotation-based routing, @GetMapping("/users/{id}") and a @PathVariable parameter are strings and annotations that the framework reads by reflection while the program runs; javac checks the annotation's syntax and not what {id} refers to. An annotation processor could make the check at build time, which is the procedural-macro position.
  • ABAP. (Not machine-checked — CI cannot run ABAP.) An ICF service is a path configured in transaction SICF and a handler class implementing IF_HTTP_EXTENSION. The path is configuration rather than code, so nothing ties its segments to the handler at compile time; the handler reads the path from the request object when it runs.

See also

Po polsku

routes! to małe DSL w makrze funkcyjnym: tabela METODA "ścieżka" => fn handler(…), z której makro generuje jedną funkcję route z match na krotce (metoda, segmenty ścieżki). "/users/{id}" zamienia się we wzorzec wycinka ["users", id], więc w czasie działania nie trzeba żadnej biblioteki routera. Handlery są zapisane wewnątrz wywołania, bo makro funkcyjne widzi tylko tokeny między swoimi nawiasami; sama nazwa funkcji nie powiedziałaby mu nic o jej parametrach.

Sprawdzanie odbywa się podczas kompilacji: {param} w ścieżce, którego handler nie przyjmuje, parametr handlera, którego ścieżka nie przechwytuje, i dwie trasy pasujące do tych samych żądań. Każdy błąd to syn::Error przypięty do odpowiedzialnego tokenu, a wszystkie trafiają do jednego raportu. Parametr handlera podkreślony jest co do znaku, natomiast {id} wewnątrz literału tylko jako cały literał, bo Literal::subspan jest wciąż niestabilne. Z tego samego powodu „the earlier route is here” to osobny błąd, a nie notatka: proc_macro::Diagnostic nie jest stabilne. trybuild pilnuje, żeby te komunikaty się nie zmieniły.

Szukaj po polsku: makro routes! w Ruście · DSL w makrze proceduralnym · rust proc macro compile time validation · syn::Error combine · trybuild compile_fail stderr · rust slice patterns match