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¶
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¶
//! 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¶
//! 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¶
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:
pageis underlined exactly. It is anIdentthe parser kept, and its span is the four characters inmain.rs.{id}is not. The best a stable procedural macro can do for text inside a string is the whole literal; a narrower span needsLiteral::subspan, which is still unstable (the same limit, next toformat_args!). The message names{id}to make up for it.- "the earlier route is here" is a fourth
error, not anote.syn::Error::combineadds a message, and every message becomes its own compile error. An attached note needsproc_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¶
//! `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
matchon 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 andidbinds whatever is there. A request with a different number of segments matches no arm of that length, which is how/usersand/users/42stay 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 asget_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
routecame out as#[doc = r" …"]: insidequote!,///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.
//! 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:
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
:idwith 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 withoutidfails only then, withTypeError: get_user() got an unexpected keyword argument 'id'(what Python 3.14 raises forget_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@PathVariableparameter are strings and annotations that the framework reads by reflection while the program runs;javacchecks 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
SICFand a handler class implementingIF_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¶
- Parsing arbitrary tokens —
Parse,lookahead1andPunctuated, which this grammar uses the simplest parts of - Why
println!is a macro —format_args!checks{name}against its arguments the wayroutes!checks{param}against a handler - Errors: from
panic!tosyn::Error—syn::Error,combineand spans in depth - Testing with
trybuild— the.stderrworkflow - Re-emit the item on error — what the caller loses when a macro returns only errors
- Match ergonomics — why
idbinds as&&str
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