Pavex, progress report #1: laying the foundations
- 1956 words
- 10 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
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
?
- If it's simple enough, an
IntoResponse
implementation would usually be the way to go. - Often enough
IntoResponse
is too limited to express your conversion logic: you need to perform some async processing or refer to some data that lives in the application state, which you do not have access to. As a consequence, you move the conversion logic back into your request handler. - Last but least, it is common to have specific requirements around the representation of errors on the wire. Requirements that
conflict with the implementation of
IntoResponse
provided out of the box for the error types returned by the extractors that you are importing from third-party libraries. To make sure you don't accidentally break your API contract, you either need to newtype all error (in order to customize theIntoResponse
implementation) or you need to implement the conversion logic in your request handler (again).
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:
- Associate an error handler with a fallible request handler.
- Associate an error handler with a fallible constructor, no matter the
Lifecycle
. - Associate an error handler with both synchronous and asynchronous request handlers and constructors.
// 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:
- Qualified paths (e.g.
my_crate::my_module::MyType
), whereMyType
is either a struct or an enum; - Shared references to the above, as long as they had no lifetime qualifiers, e.g.
&MyType
but not&'static MyType
.
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:
- All primitive types (e.g.
i32
,bool
,char
,str
,u8
, etc.); - Static references (e.g.
&'static str
); - Slices (e.g.
&[u8]
); - Tuples (e.g.
(i32, MyType)
); - Generic lifetime parameters, as long as they are set to
'static
(e.g.Cow<&'static, str>
); - Type aliases (e.g.
type MyInteger = usize
).
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 ofpavex
on GitHub.
"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.