Pavex, progress report #2: route all the things
- 1767 words
- 9 min
π Hi!
It's Luca here, the author of "Zero to production in Rust".
This is a progress report aboutpavex
, a new Rust web framework that I have been working on. It is currently in the early stages of development, working towards its first alpha release.Check out the announcement post to learn more about the vision!
Overview
March is coming to an end: time for another progress report on pavex
!
The previous update ended with an outline of the work I wanted to pick up in March:
giving pavex
a request router worth of its ambitions.
The existing implementation was an extremely basic placeholder:
let mut bp = Blueprint::new();
bp.route(f!(crate::my_handler), "/home");
You could not specify an HTTP method, nor a templated path segment (e.g. /users/:id
) and the generated application
would panic if sent a request to a path that did not match any of the registered handlers.
That was not going to cut it!
After several weeks of work, we are now much closer to what I had in mind:
use pavex_builder::{f, router::GET, Blueprint, Lifecycle};
pub fn blueprint() -> Blueprint {
let mut bp = Blueprint::new();
// The handler will only be invoked if the request method is `GET`.
// We are also registering a route parameter, `home_id`..
bp.route(GET, "/home/:home_id", f!(crate::get_home));
// [...]
}
// ..which we can retrieve in the request handler, using the `RouteParams` extractor.
pub fn get_home(params: RouteParams<HomeRouteParams>) -> String {
format!("Welcome to {}", params.0.home_id)
}
#[derive(serde::Deserialize)]
pub struct HomeRouteParams {
pub home_id: u32,
}
But the API on its own does not tell the whole story! Let's dive into the details!
Table of Contents
What's new
Method guards
As you have seen in the overview example, pavex
now support specifying method guards on routesβi.e. invoke the
request handler only if the request method matches the one specified in the route registration.
use pavex_builder::{f, router::*, Blueprint};
use pavex_runtime::http::Method;
pub fn blueprint() -> Blueprint {
let mut bp = Blueprint::new();
// Single method guards, for all the well-known HTTP methods.
bp.route(GET, "/get", f!(crate::handler));
bp.route(POST, "/post", f!(crate::handler));
// ..as well as PUT, CONNECT, DELETE, HEAD, OPTIONS, PATCH, and TRACE.
// You can still match a path regardless of HTTP method, using the `ANY` guard.
bp.route(ANY, "/any", f!(crate::handler));
// Or you can specify multiple methods, building your own `MethodGuard` type.
bp.route(
MethodGuard::new([Method::PATCH, Method::POST]),
"/mixed",
f!(crate::handler),
);
}
So far, not particularly excitingβyou've most likely seen a very similar API in other web frameworks.
Things get interesting when you make a mistakeβfor example, try to register two different request handlers
for the same path-method combination:
pub fn blueprint() -> Blueprint {
let mut bp = Blueprint::new();
bp.route(ANY, "/home", f!(crate::handler_1));
bp.route(GET, "/home", f!(crate::handler_2));
bp
}
Overlapping routes may look like a minor annoyance, but they can be a source of subtle bugs in large applications, where routes are being registered all over the place. Even more so if the web framework chooses to silently "resolve" the conflict by picking one of the handlers according to some obscure set of route precedence rules.
pavex
takes a "fail fast" stance:
-ERROR:
Γ I don't know how to route incoming `GET /home` requests: you have
β registered 2 different request handlers for this path+method combination.
β
β ββ[src/lib.rs:16:1]
β 16 β let mut bp = Blueprint::new();
β 17 β bp.route(ANY, "/home", f!(crate::handler_1));
β Β· βββββββββββ¬βββββββββ
β Β· β°ββ The first conflicting handler
β 18 β bp.route(GET, "/home", f!(crate::handler_2));
β β°ββββ
β Γ
β ββ[src/lib.rs:17:1]
β 17 β bp.route(ANY, "/home", f!(crate::handler_1));
β 18 β bp.route(GET, "/home", f!(crate::handler_2));
β Β· βββββββββββ¬βββββββββ
β Β· β°ββ The second conflicting handler
β 19 β bp
β β°ββββ
β help: You can only register one request handler for each path+method
β combination. Remove all but one of the conflicting request handlers.
It examines all registered routes ahead of code generation, detects a conflict and returns you an error message explaining what is wrong and where. I think that's pretty neat!
Route parameters
Let's talk about route parameters!
pub fn blueprint() -> Blueprint {
let mut bp = Blueprint::new();
bp.route(GET, "/home/:home_id", f!(crate::get_home));
// [...]
}
You can specify a route parameter by prefixing the path segment with a colon (:
).
The syntax should look familiar: pavex
's routing is built on top of the excellent matchit
crate
by @ibraheemdev, the same crate used under the hood by axum
.
Route parameters are used to bind segments in the URL of an incoming request to a name (home_id
, in our example).
The same name is then used to retrieve the bound values in the corresponding request handler.
In pavex
, you can use the RawRouteParams
extractor to access the raw values that have been bound to
each route parameter:
pub fn get_home(params: &RawRouteParams) -> String {
format!("Welcome to {}", params.get("home_id").unwrap())
}
The RawRouteParams
extractor is a thin wrapper around the matchit::Params
type, which is a HashMap
-like
data structure that associates route parameter names to their raw values.
Having RawRouteParams
around is handy, but it's too low-level for most use cases. That's where
RouteParams
comes in!
pub fn get_home(params: &RouteParams<HomeRouteParams>) -> String {
format!("Welcome to {}", params.0.home_id)
}
#[derive(serde::Deserialize)]
pub struct HomeRouteParams {
pub home_id: u32,
}
It percent-decodes all the extracted parameters and then tries to deserialize them according to the type
you have specifiedβe.g. u32
for home_id
in our example.
No tuples, please!
RouteParams
is where pavex
diverges from the approach of other Rust web frameworks: the T
in RouteParams<T>
must be a plain struct with named fields.
We do not allow tuples, tuple structs or vectors. Only plain structs with named fields.
This is a deliberate design choice: pavex
strives to enable local reasoning, whenever the tradeoff makes sense.
It should be easy to understand what each extracted route parameter represents without having to jump back and forth
between multiple files.
Structs with named fields are ideal in this regard: by looking at the field name you can immediately understand which
route parameter is being extractedβthey are self-documenting. The same is not true for tuplesβe.g.
(String, u64, u32)
βwhere you have to go and check the routeβs template to understand what each entry represents.
I anticipate that many people will expect tuples to be supported in pavex
just as they are in other Rust web
frameworks, therefore I made an effort to catch incorrect usage at compile time and provide a helpful error message.
For example, if you try to use a tuple:
pub fn blueprint() -> Blueprint {
let mut bp = Blueprint::new();
bp.route(GET, "/home/:home_id/room/:room_id", f!(crate::get_room));
// [...]
}
pub fn get_room(params: &RouteParams<(u32, u32)>) -> String {
format!("Welcome to {}", params.0.home_id)
}
You'll be greeted by this error:
-ERROR:
Γ Route parameters must be extracted using a plain struct with named fields,
β where the name of each field matches one of the route parameters specified
β in the route for the respective request handler.
β `app::get_room` is trying to extract `RouteParams<(u32, u32)>`, but `(u32,
β u32)` is a tuple, not a plain struct type. I don't support this: the
β extraction would fail at runtime, when trying to process an incoming
β request.
β
β ββ[src/lib.rs:56:1]
β 57 β bp.route(GET, "/home/:home_id/room/:room_id", f!(crate::get_room));
β Β· βββββββββ¬ββββββββββ
β Β· The request handler asking for `RouteParams<(u32, u32)>`
β 58 β // [...]
β β°ββββ
β help: Use a plain struct with named fields to extract route parameters.
β Check out `RouteParams`' documentation for all the details!
I could make it even better by sketching out what the struct should look like in the help
message, but that'll
require a bit more work on the error message formatting side of things. One more item in the backlog!
Unroutable requests
pavex
will now behave as you'd expect when it receives a request for a route that does not have a registered
request handler:
- It returns a
404 Not Found
status code if there is no request handler registered for the requested path. - It returns a
405 Method Not Allowed
status code if there is at least one request handler registered for the same path, but with a different HTTP method. E.g. you have aGET /home
handler, but you sent aPOST /home
request. The response includes anAllow
header with the list of allowed methods for the requested path.
Better foundations
As the framework expands, I am constantly refactoring the internals to refine our abstractions
and expand the capabilities of our compile-time reflection engine.
During this iteration I've added preliminary support in the reflection engine for:
- generic types;
- non-
'static
lifetime parameters; - non-static methods (i.e. methods that take
&self
as the first parameter).
RouteParams::extract
required 1., while 2. was necessary for RawRouteParams<'server, 'request>
βwe perform zero-copy
deserialization of route parameters where possible (i.e. when the parameter value is not URL-encoded), which requires
being able to borrow from the incoming request (which does not live for 'static
).
With 1. and 2. in place, 3. was a fairly straightforward addition.
What's next?
The router is definitely in a much better shape now, but we are not done yet!
There a few more features I'd like to add before moving on:
- Scopes: a way to group routes together and apply a common prefix to all of them.
- Compile-time parameter validation: we should be able to verify that all fields in the
RouteParams
type are actually present in the route template. - Compile-time detection of common pitfallsβe.g. using
&str
instead ofCow<'_, str>
for route parameters, which will cause a runtime panic if the parameter value is URL-encoded. - A
MatchedRoute
extractor that provides access to the template that has been matched for the incoming request, primarily useful for logging purposes.
The foundations for some of these features are already in place, so I don't expect major blockers in getting them over the lineβ"just" a matter of putting in the work.
See you next month!
Subscribe to the newsletter if you don't want to miss the next update!
You can also follow the development ofpavex
on GitHub.