Pavex, progress report #2: route all the things

πŸ‘‹ Hi!
It's Luca here, the author of "Zero to production in Rust".
This is a progress report about pavex, 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:

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:

  1. generic types;
  2. non-'static lifetime parameters;
  3. 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:

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 of pavex on GitHub.