Pavex, progress report #1: laying the foundations

👋 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

It has been almost two months since I announced pavex on this blog.
The community reaction has been strong, but definitely highly polarised (HackerNews, r/rust). The vision resonated with some people, while others just can't stand it.

I am relieved.
I have spent months on working on this project, and I had started to wonder—is this a good idea?
That was the whole point of the announcement: to poke the community and see if there is an appetite for this kind of framework. I now know that is a niche out there that is willing to take a bet on something that looks very different from what the current generation of Rust web frameworks has to offer.

Motivation is sorted, now it's just a matter of building it!

There is a lot to be done ahead of the first alpha release. Going forward, I'll be publishing monthly progress reports. It's a strategy to hold myself accountable and keep the community in the loop.

pavex is developed in the open. You can follow the project on GitHub as well!

This is the first installment, looking back at the development work done in January and the first half of February 2023.

Table of Contents

What's new

IntoResponse design

We finalised the design of the IntoResponse trait:

pub trait IntoResponse {
    /// Convert `self` into an HTTP response.
    fn into_response(self) -> Response;
}

The IntoResponse trait is a quality of life improvement: it allows you to return arbitrary types from request handlers or error handlers, as long as they can be converted into an HTTP response.

// This is a valid error handler in `pavex`, as long as `Home` implements `IntoResponse`.
pub async fn get_home(req: Request<Body>) -> Home { /* */ }

The trait definition itself should look pretty familiar—it's the same you'll find in axum and, with minor variations, in all other Rust web frameworks.

There is a twist though: pavex does not implement IntoResponse for Result<T, E> where T: IntoResponse and E: IntoResponse.
Implementing IntoResponse for Result looks like an ergonomic win, but things don't usually play out that way in non-trivial projects—at least based on my personal experience building Rust applications.

Where should the logic to convert an error into a HTTP response live, if Result<T, E> implements IntoResponse?

In pavex, where possible, we lean towards providing one way to do things1.
As a consequence, we do not implement IntoResponse for Result and provide a single mechanism to work with fallible request handlers and constructors: registering an error handler for their error type.

// This is a valid error handler in `pavex`, as long as `Home` implements `IntoResponse`
// **and** there is a registered error handler for `GetHomeError`. 
pub async fn get_home(req: Request<Body>) -> Result<Home, GetHomeError> { /* */ }
// All error handlers are expected to take, as input, a reference to the error they want to handle.
pub fn home_error(e: &HomeError) -> Response { /* */ }

pub fn blueprint() -> AppBlueprint {
    let mut bp = AppBlueprint::new();
    // [...]
    // The error handler is associated with the request handler here.
    bp.route(f!(crate::get_home), "/home").error_handler(f!(crate::home_error));
}

This buys us some flexibility—you can, for example, choose to provide different error handlers for the same error type, depending on the context, without having to create unnecessary new-type wrappers. Some conveniences will be provided in order to register a single error handler for all occurrences of the same error type, but that's a story for another progress report.

Speaking of error handling...

Error handling

The error handling story was barely sketched out in December. The implementation had a very narrow happy path.
We are now in a much better place. You can:

// A fallible constructor for `PathBuf`.
pub async fn extract_path(req: Request<Body>) -> Result<PathBuf, ExtractPathError> { /* */ }
// All error handlers support dependency injection, just like constructors and request handlers!
pub fn handle_extract_path_error(e: &ExtractPathError, logger: Logger) -> Response { /* */ }

pub fn blueprint() -> AppBlueprint {
    let mut bp = AppBlueprint::new();
    // [...]
    bp.constructor(f!(crate::extract_path), Lifecycle::RequestScoped)
        // The error handler is associated with the constructor here.
        .error_handler(f!(crate::handle_extract_path_error));
}

We have added better guard rails as well. pavex will now validate that the error handler is compatible with the request handler/constructor you tried to associate it with.
Code generation will fail if you register an error handler for an infallible constructor or if the error type does not line up. This is what the error would look like in the first case:

-ERROR: 
  × You registered an error handler for a constructor that does not return a `Result`.
    ╭─[src/lib.rs:22:1]
 22 │     bp.constructor(f!(crate::infallible_constructor), Lifecycle::RequestScoped)
 23 │         .error_handler(f!(crate::error_handler));
    ·                        ────────────┬───────────
    ·                                    ╰── The unnecessary error handler was registered here
    ╰────
  help: Remove the error handler, it is not needed. The constructor is infallible!

Support for more types

The compile-time reflection engine sits at the core of pavex. It's the mechanism that allows us to understand the graph of dependencies between all the components of your application.

In December, we only had support for two families of types:

pavex would politely bail out when it encountered a type outside of its comfort zone:

-Error: 
  × I do not know how to handle the type returned by `app::c`.
    ╭─[src/lib.rs:8:1]
  8 │     let mut bp = AppBlueprint::new();
  9 │     bp.route(f!(crate::c), "/home");
    ·              ──────┬─────
    ·                    ╰── The request handler was registered here
    ╰────

   ╭─[src/lib.rs:2:1]
 2 │ 
 3 │ pub fn c() -> (usize, usize) {
   ·               ───────┬──────
   ·                      ╰── The output type that I cannot handle
   ╰────

The work on error handling demanded a much broader variety of types: at the very least, we should be able to handle all the types that implement our IntoResponse trait—e.g. &'static str, Cow<'static, u8>, &[u8], etc.

Now we do!
We have added support for:

As a result, we can now handle all the types that implement IntoResponse!

// `pavex` does not choke on any of these types and will generate the correct code!
pub fn blueprint() -> AppBlueprint {
    let mut bp = AppBlueprint::new();
    bp.route(f!(crate::response), "/response");
    bp.route(f!(crate::static_str), "/static_str");
    bp.route(f!(crate::string), "/string");
    bp.route(f!(crate::vec_u8), "/vec_u8");
    bp.route(f!(crate::cow_static_str), "/cow_static_str");
    bp.route(f!(crate::bytes), "/bytes");
    bp.route(f!(crate::bytes_mut), "/bytes_mut");
    bp.route(f!(crate::empty), "/empty");
    bp.route(f!(crate::status_code), "/status_code");
    bp.route(f!(crate::parts), "/parts");
    bp.route(f!(crate::full), "/full");
    bp.route(f!(crate::static_u8_slice), "/static_u8_slice");
    bp.route(f!(crate::cow_static_u8_slice), "/cow_static_u8_slice");
    bp
}

Avoid bailing out on the first error

pavex used to bail out as soon as it encountered an error, which made for a frustrating experience when debugging a failing build: fix one error, get another, fix that, get another, how many are still missing? Who knows.

We have changed this behaviour and now pavex will collect as many diagnostics as possible before exiting.

Better foundations

The internals of pavex have gone through a major overhaul—a full rewrite, if you will. We have broken down the monolithic compiler implementation into a set of smaller analysis passes, each responsible for a specific task and for keeping track of a certain type of component.
This has allowed us to simplify the thornier parts of the codebase (e.g. the creation of the dependency graph for each request handler) as well as reducing the boilerplate required to emit good error diagnostics—one of our driving goals and features.

This would have been impossible to achieve if we had not invested early on in a solid suite of black-box end-to-end tests.
There are almost no tests depending on the internal APIs of the compiler, everything goes through the (very narrow) API of the CLI: you provide the Rust code of a pavex blueprint as input and make assertion against the generated code (or the compiler errors you expect to run into). This decoupling empowers us to refactor aggressively and evolve the internal architecture to meet our needs as the project grows in scope and complexity.

What's next?

In March we will focus on the request router.
It is currently extremely basic—it does not even allow you to specify the HTTP method!
We will be adding support for gating request handlers based on the HTTP method, as well as the ability to specify (and extract) templated path segments (e.g. id in /users/{id}).

This will require some internal changes to the compiler logic—we currently assume that the set of injectables types is the same for all the request handlers, but this will no longer be the case. This work will be the foundation for allowing more advanced composition when laying down your application blueprints (e.g. nested routers, sub-blueprints, etc.).

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.


1

"There should be one—and preferably only one—obvious way to do it", one of the Zen of Python maxims that I happen to agree with.