A type-safe way to declare web routes and convert them to their URL, based on path and query parameters https://docs.rs/typed_web_routes/latest/typed_web_routes/
Find a file
2026-09-15 17:29:20 +02:00
typed_web_routes Fix readme path 2026-09-15 17:29:20 +02:00
typed_web_routes_macro Fix readme path 2026-09-15 17:29:20 +02:00
.gitignore Mark Builder as Send Sync static 2026-09-15 15:23:51 +02:00
Cargo.lock Mark Builder as Send Sync static 2026-09-15 15:23:51 +02:00
Cargo.toml Move and adapt implementation from https://git.sitegui.dev/sitegui/rust-commons 2026-09-15 15:02:04 +02:00
LICENSE Add LICENSE and update metadata 2026-09-15 17:21:34 +02:00
README.md Add LICENSE and update metadata 2026-09-15 17:21:34 +02:00
rust-toolchain Add LICENSE and update metadata 2026-09-15 17:21:34 +02:00

Typed web routes

This crate provides a type-safe way to declare web routes and convert them to their URL, based on path and query parameters. This crate is not tied with any specific web framework but works especially well with axum.

Think of something along the line of Django's urls.reverse(), but verified by the compiler at compile time :)

To declare the routes, decorate an enum with #[derive(WebRoutes)] and use #[route(pattern = "")] to describe the path. For example, these three routes:

use typed_web_routes::WebRoutes;

#[derive(WebRoutes)]
enum Routes {
    #[route(pattern = "/")]
    Home,
    #[route(pattern = "/post/{post}")]
    Post { post: u32 },
    #[route(pattern = "/search")]
    Search { term: String, page: Option<i32> },
}

This crate adds a builder() method to the enum. This builder has one method and one struct for each route:

fn example() {
    let builder = Routes::builder();

    assert_eq!(builder.home().to_string(), "/");
    assert_eq!(builder.post(17).to_string(), "/post/17");
    assert_eq!(builder.search("buga!").to_string(), "/search?term=buga%21");
    assert_eq!(builder.search("abc").with_page(2).to_string(), "/search?term=abc&page=2");
}

You can also generate absolute URLs:

fn example() {
    let builder = Routes::builder_with_url_prefix("http://localhost:8080");

    assert_eq!(builder.post(17).to_string(), "http://localhost:8080/post/17");
}

All non-Option<_> fields (like post and term in the example) are required by the builder method. All Option<_> fields (like page in the example) can be provided using .with_() methods.

Path parameters and query names and values are URL escaped: any byte other than 0-9 a-z A-Z - . _ ~ are percentage encoded.

The syntax of the URL pattern is the same as the one used by matchit:

  • path parameter like /photo-{id}.jpg
  • catch-all parameter like /post/{*rest}
  • escaping braces like /not{{a parameter}}

Dynamic usage

This crate also provides information about the routes that can be used to dynamically generate the URLs:

fn example() {
    let route = Routes::info_by_name("Search").unwrap();

    let url = route.format(
        Some("http://localhost:8080"),
        &[("term", &"abc"), ("page", &2)]
    ).unwrap();

    assert_eq!(url, "http://localhost:8080/search?term=abc&page=2");
}

When using the type-safe alternative (like builder.search()) no error can occur, since the compiler will check the name, types and number of parameters. When using the dynamic alternative (like info_by_name() and format()), errors can occur, like invalid route name or missing required argument.

Use with axum

Activate the crate feature axum to have access to the extractor PathQuery<T>, that behaves like Path<T> and Query<T> together:

use typed_web_routes::PathQuery;

async fn main() {
    let app = axum::routing::Router::new()
        .route(Routes::HOME.pattern, get(home))
        .route(Routes::POST.pattern, get(post))
        .route(Routes::SEARCH.pattern, get(search));

    axum::serve(listener, app).await;
}

async fn home() {}

async fn post(PathQuery(request): PathQuery<Routes_Post>) {
    // request.post was parsed from the path 
}

async fn search(PathQuery(request): PathQuery<Routes_Search>) {
    // request.term and request.page were parsed from the query
}

The WebRoutes derive macro in details

The #[derive(WebRoutes)] can only be applied to an enum. Each enum variant must be either empty (like Home in the example) or a struct variant with fields.

It will generate code to:

  • implement the trait WebRoutes for your enum
  • declare a new struct YourEnum__Builder

Then for each enum variant it will generate code to:

  • add a constant YourEnum::VARIANT with information about that route
  • declare a new struct YourEnum_Variant with the same fields as the enum variant, if any. This struct implements the trait WebRoute
  • add a method in YourEnum__Builder::variant() that takes as parameters all the non-Option<_> fields of the variant
  • add one method in YourEnum_Variant::with_field() for each Option<_> field of the variant
  • if needed, declare new structs to deserialize the path and query

The #[route(...)] attribute can be used in three places:

#[derive(WebRoutes)]
#[route(typed_web_routes = some_other_name)] // <-- enum
enum Routes {
    #[route(rename = "show-post", pattern = "/post/{post}")] // <-- variant
    Post {
        #[route(rename = "post")] // <-- field
        post_id: u32
    },
}
  • enum:
    • typed_web_routes (optional): the name of this crate, if you imported it into your project with another name
  • variant:
    • rename (optional): the name of this route. This impacts the string used in Routes::info_by_name()
    • pattern (required): the URL pattern used for this route
  • field:
    • rename (optional): the name of this field in the path or query.

For reference, the following simple example:

#[derive(WebRoutes)]
enum Routes {
    #[route(pattern = "/post/{post}")]
    Post { post: u32 }
}

expands to something like:

impl WebRoutes for Routes {
    const ROUTES: &'static [RouteInfo] = &[Routes_Post::INFO];

    type Builder = Routes__Builder;

    fn builder() -> Routes__Builder {
        Routes__Builder { url_prefix: None }
    }

    fn builder_with_url_prefix(url_prefix: impl Into<String>) -> Routes__Builder {
        Routes__Builder { url_prefix: Some(url_prefix.into()) }
    }
}

#[derive(Clone, Debug)]
struct Routes__Builder {
    url_prefix: Option<String>,
}

impl Routes {
    pub const POST: RouteInfo = Routes_Post::INFO;
}

#[derive(Debug)]
struct Routes_Post {
    _url_prefix: Option<String>,
    pub post: u32,
}

impl Display for Routes_Post {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result { /* ... */ }
}

impl WebRoute for Routes_Post {
    type Path = Routes_Post__Path;
    type Query = ();

    const INFO: RouteInfo = RouteInfo {
        name: "Post",
        pattern: "/post/{post}",
        pattern_tokens: &[
            PatternToken::Fixed("/post/"),
            PatternToken::Parameter { name: "post", is_catch_all: false },
        ],
        path_terms: &["post"],
        query_terms: &[],
    };

    fn from_path_query(path: Self::Path, query: Self::Query) -> Self {
        Self { _url_prefix: None, post: path.post }
    }

    fn with_url_prefix(mut self, url_prefix: impl Into<String>) -> Self {
        self._url_prefix = Some(url_prefix.into());
        self
    }
}

impl Routes__Builder {
    pub fn post(&self, post: impl Into<u32>) -> Routes_Post {
        Routes_Post {
            _url_prefix: self.url_prefix.clone(),
            post: post.into(),
        }
    }
}

#[derive(Debug, Deserialize)]
struct Routes_Post__Path {
    pub post: u32,
}