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 Foundstatus code if there is no request handler registered for the requested path. - It returns a
405 Method Not Allowedstatus 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 /homehandler, but you sent aPOST /homerequest. The response includes anAllowheader 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-
'staticlifetime parameters; - non-static methods (i.e. methods that take
&selfas 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
RouteParamstype are actually present in the route template. - Compile-time detection of common pitfallsβe.g. using
&strinstead ofCow<'_, str>for route parameters, which will cause a runtime panic if the parameter value is URL-encoded. - A
MatchedRouteextractor 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 ofpavexon GitHub.