It's nice when some classes of bugs cannot be fabricated. For example, some languages like Kotlin and Rust bake the concept of "empty" especially in the language and standard library to remind and force you (the developer) to deal with the case when something is missing.
This post is about a library I wrote that brings a similar compile-verified assurance to URL construction when writing web servers in Rust. 🦀
The problem it solves is: in a web server, you usually declare some "handlers" (like web pages, assets or api endpoints) and hook them to a global path router. It works well, but what about when you want to link from one page to another? How to do that correctly and let the compiler help you keep all links correct as your app evolves?
To illustrate, consider this basic example using the library axum in Rust to create a website with a home page and a
dynamic "posts" page, with the home linking to a post:
use axum::extract::Path;
use axum::response::Html;
use axum::routing::get;
#[tokio::main]
async fn main() {
let app = axum::Router::new()
.route("/", get(home))
.route("/post/{id}", get(about));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn home() -> Html<String> {
Html("Hello! <a href='/post/17'>See post</a>".to_string())
}
async fn about(Path(id): Path<u32>) -> Html<String> {
Html(format!("Thanks for visiting post {}", id))
}
Note how the home page has a hard-coded link to the other page. This doesn't work well for bigger apps because the paths can evolve, and also the parameters have to be correctly escaped.
The dynamic URL resolution
A typical solution in some frameworks is to give each route a name and then use some function to produce the URL given the route name and parameters.
Vue.js does this:
const routes = [{path: '/post/:id', name: 'post'}]
<router-link :to="{ name: 'post', params: { id: 17 } }">
See post
</router-link>
Django does this:
urlpatterns = [
path("post/<int:id>", views.post, name="post"),
]
<a href="{% url 'post' 17 %}">See post</a>
This dynamic approach works and solves two problems:
- if route URLs evolve, the links will point to the new URL
- URL path and query parameters are correctly escaped
However, it still has some problems:
- looking up the route can fail at runtime if the route doesn't exist, for example, because of a typo or some distant code evolution
- producing the URL may fail at runtime if parameters are missing or unexpected
- the produced URL may ultimately fail when the user navigates to it if the type of the parameter is wrong, for example, the post's id was confounded with the post's title
Most of the time, these runtime errors are noticed by the developer during manual or automatic testing. However this post is not about leaving it to change! This post is about writing code that cannot be wrong, at least not in the ways described above 😜
The compile-time URL resolution
Enter typed_web_routes and its derive macro to help you:
use typed_web_routes::WebRoutes;
#[derive(WebRoutes)]
enum Routes {
#[route(pattern = "/")]
Home,
#[route(pattern = "/post/{id}")]
Post { id: u32 },
}
With this, you can now use Routes::builder() to produce URLs:
async fn home() -> Html<String> {
Html(format!(
"Hello! Check <a href='{}'>this post</a>",
Routes::builder().post(17u32)
))
}
Side note: the example is manually gluing strings to build HTML. This is bad, and you should be using some templating
like minijinja or maud.
The #[derive(WebRoutes)] marker will write some Rust code to you:
- it creates the
builder()static method that returns a URL builder - this builder has one method for each route
- each method takes all the required route parameters, with the expected types
Let's look how it holds against Vue.js' and Django's solutions:
- looking up the route can fail at runtime if the route doesn't exist: not possible because
Routes::builder().pooost(17)would not compile: no such method "pooost ()" - producing the URL may fail at runtime if parameters are missing or unexpected: not possible because
Routes::builder().post()would not compile: missing method argument - the produced URL may ultimately fail when the user navigates to it if the type of the parameter is wrong: harder to
happen, because
Routes::builder().post(post_title)would not compile: expected u32, got String. However, this problem can still happen if the types happen to be compatible.
I'm now using WebRoutes
in nidimages,
and it brings a nice touch of compile-time check!
How fast is it? A non-scientific micro benchmark showed around 75 ns for Routes::builder().post(id), that is enough to
produce around 10 million URLs per second.