This month in Pavex, #10

👋 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's time for another progress report on Pavex, covering what has been done in March.
In February, after releasing biscotti, I thought adding cookie support to Pavex would be a breeze. A few hours of work on the integration, a couple more on the documentation, and we'd be done. Boy, was I wrong!

It took me a whole month to get the cookie integration right—it has just been released.
I had to add a couple of foundational features to our dependency injection engine to get something that felt idiomatic, Rust-y: the ability to inject mutable references, new middleware types.
That's where the bulk of my time went this month—but it's a good thing! Cookies were just the forcing function: I have had those middleware designs in my head almost since the beginning of Pavex, and now they're finally fully implemented.
But enough with the pleasantries, let's dive into the changes!

You can discuss this update on r/rust.

Table of Contents

  1. Closed beta
  2. One size doesn't fit all: middleware design
  3. Mutable references, revisited
  4. Cookie support
  5. ConnectionInfo
  6. RustNation UK
  7. What's next?

Closed beta

Invites to Pavex's closed beta keep going out!
In March I sent out 102 new invites, and 88 of those joined Pavex's Discord server to try out the framework.

If you're still waiting for an invite, don't worry: it's coming!

One size doesn't fit all: middleware design

Pavex has had support for middlewares almost since the beginning, in the form of wrapping middlewares:

use pavex::middleware::Next;
use pavex::response::Response;
use std::future::IntoFuture;

pub async fn middleware<C>(next: Next<C>) -> Response
where
    C: IntoFuture<Output = Response>,
{
    // Some pre-processing logic
    let response = next.await;
    // Some post-processing logic
    response
}

Wrapping middlewares lets you execute logic before and after the rest of the request processing pipeline. They also give you access to the Future representing the rest of the request processing pipeline (via the Next type), a prerequisite for attaching a tracing::Span to the request processing pipeline or enforcing timeouts.

That's quite similar to the middleware interface you get in Actix Web or axum (via tower::Service). You "decorate" an inner service, which in turn represents downstream middlewares or the request handler itself.

Downsides of wrapping middlewares

In a way, wrapping middlewares are as powerful as you can get: you can do anything you want before and after the request handler, and modify the request processing pipeline as you see fit.
They have an issue, though: they don't play nice with the borrow checker.

Every time you inject a reference as an input parameter to a wrapping middleware, you are borrowing that type for the whole duration of the downstream request processing pipeline. This can easily lead to borrow checker errors, especially if you are working with request-scoped dependencies.
Let's look at an example:

use pavex::middleware::Next;
use pavex::response::Response;
use std::future::IntoFuture;

pub async fn middleware<C>(next: Next<C>, my_type: &MyType) -> Response
where
    C: IntoFuture<Output = Response>,
{
    // Some pre-processing logic
    let response = next.await;
    // Some post-processing logic
    response
}

MyType is a request-scoped dependency: its constructor is invoked at most once for every incoming request.
middleware is taking advantage of Pavex's dependency injection engine to access a reference to MyType. As a consequence, MyType is locked for the whole duration of the request processing pipeline.

Suppose you want to work with MyType in your request handler, which is invoked by next.await:

The only scenario that doesn't lead to borrow checker errors is the one where the request handler takes &MyType as an input parameter. You can have as many immutable references to MyType as you want, so all is well.

Mutable references to request-scoped dependencies

That's an annoying issue since I want you to be able to work with mutable references.
Going back to cookies, I want this to be possible:

use pavex::cookie::{ResponseCookie, ResponseCookies};
use pavex::response::Response;
use pavex::time::{format_description::well_known::Iso8601, OffsetDateTime};

pub fn handler(response_cookies: &mut ResponseCookies) -> Response {
    let now = OffsetDateTime::now_utc().format(&Iso8601::DEFAULT).unwrap();
    let cookie = ResponseCookie::new("last_visited", now).set_path("/web");
    response_cookies.insert(cookie);

    // [...]
}

You need to add a new cookie to the response, therefore you get a mutable reference to ResponseCookies, the collection where we are accumulating the cookies that must be sent back to the client.
It feels very Rust-y: you want to mutate the collection, you get a mutable reference to it.

This wouldn't work with a wrapping middleware, though. We'd need a wrapping middleware to borrow (or consume) ResponseCookies in order to inject the Set-Cookie headers in the response.

use pavex::middleware::Next;
use pavex::response::Response;
use pavex::cookie::{ResponseCookies, Processor};
use pavex::cookie::errors::InjectResponseCookiesError;
use std::future::IntoFuture;

// The middleware to inject the cookie headers in the response.
pub async fn inject_response_cookies<C>(
    next: Next<C>,
    response_cookies: ResponseCookies,
    processor: &Processor,
) -> Result<Response, InjectResponseCookiesError>
where
    C: IntoFuture<Output = Response>,
{
    let mut response = next.await;
    for value in response_cookies.header_values(processor) {
        let value = HeaderValue::from_str(&value).map_err(/* ... */)?;
        response = response.append_header(SET_COOKIE, value);
    }
    Ok(response)
}

This design would prevent us from working with mutable references to ResponseCookies in the request handler.

One possible solution to this dilemma would be to leverage interior mutability for ResponseCookies—make it a Rc<RefCell<..>>, thus allowing you to mutate the collection even if you only have an immutable reference to it.
That can work, but it's not ideal: you lose the ability to leverage the borrow checker to catch bugs at compile time, and it feels like incidental complexity.

Pre-processing and post-processing middlewares

The real issue is that we are over-borrowing.
In inject_response_cookies we don't need to borrow ResponseCookies for the whole duration of the request processing pipeline. We only need to borrow/consume ResponseCookies after the request handler has been executed, to inject the Set-Cookie headers in the response.

That's why I've introduced a new kind of middleware: pre-processing and post-processing middlewares.
A post-processing middleware for cookies, for example, looks like this:

use pavex::response::Response;
use pavex::cookie::{ResponseCookies, Processor};
use pavex::cookie::errors::InjectResponseCookiesError;

// The middleware to inject the cookie headers in the response.
pub async fn inject_response_cookies<C>(
    mut response: Response,
    response_cookies: ResponseCookies,
    processor: &Processor,
) -> Result<Response, InjectResponseCookiesError>
{
    for value in response_cookies.header_values(processor) {
        let value = HeaderValue::from_str(&value).map_err(/* ... */)?;
        response = response.append_header(SET_COOKIE, value);
    }
    Ok(response)
}

You get a Response as an input parameter, and you return a Response (or a Result<Response, _>). As all components in Pavex, it can also take advantage of the dependency injection engine to access the dependencies it needs.
This is not a toy example either: the cookie injector middleware in Pavex is implemented exactly like this.

Pre-processing middlewares, on the other hand, are executed before the request handler is invoked. They can be used to short-circuit the request processing pipeline—e.g. to enforce some kind of access control or rate limiting.

use pavex::http::{HeaderValue, header::LOCATION};
use pavex::middleware::Processing;
use pavex::request::RequestHead;
use pavex::response::Response;

/// If the request path ends with a `/`,
/// redirect to the same path without the trailing `/`.
pub fn redirect_to_normalized(request_head: &RequestHead) -> Processing
{
   let Some(normalized_path) = request_head.target.path().strip_suffix('/') else {
      // No need to redirect, we continue processing the request.
      return Processing::Continue;
   };
   let location = HeaderValue::from_str(normalized_path).unwrap();
   let redirect = Response::temporary_redirect().insert_header(LOCATION, location);
   // Short-circuit the request processing pipeline and return the redirect response
   // to the client without invoking downstream middlewares and the request handler.
   Processing::EarlyReturn(redirect)
}

Pre-processing middlewares are expected to return a Processing instance, or a Result<Processing, _> if they are fallible.

Pre-processing and post-processing middlewares look less powerful than wrapping middlewares, but they offer a simpler interface (e.g. no need to deal with Next<C>) and let you scope borrows to the portion of the request processing pipeline where they are strictly needed.

They complete Pavex's middleware story, as I had originally envisioned it.

Mutable references, revisited

I've been talking about mutable references for a few sections, but up until now they weren't allowed in Pavex! The dependency injection engine would only let you inject immutable references to dependencies or consume them by value.

That's no longer the case: you can now inject mutable references to dependencies in your request handlers, pre-processing, and post-processing middlewares.
They remain forbidden in constructors, since their invocation order is not guaranteed, and in wrapping middlewares, for the reasons we've discussed.

Thanks to all this preparatory work (pre- and post-processing middlewares, support for mutable references), I was able to release a nice cookie integration in Pavex.
You can find all the details in Pavex's documentation!

use pavex::cookie::{RemovalCookie, ResponseCookies};
use pavex::response::Response;

pub fn handler(response_cookies: &mut ResponseCookies) -> Response {
    let cookie = RemovalCookie::new("last_visited").set_path("/web");
    response_cookies.insert(cookie);

    // Further processing...
}

ConnectionInfo

Pavex has added a new framework primitive: ConnectionInfo.

use pavex::connection::ConnectionInfo;
use pavex::response::Response;

pub fn handler(conn: ConnectionInfo) -> Response {
    let addr = conn.peer_addr();
    Response::ok().set_typed_body(format!("Your address is {addr}"))
}

You can use it to retrieve the peer address of the client that established the connection to the server, an information that's often logged.
More importantly, it's the first large contribution by another developer to Pavex—up until now, I've been the only one working on the framework. Thanks Harry!

RustNation UK

Before Easter I had the pleasure to give a talk at RustNation UK on Pavex: why I started working on it, where it's going, and the rationale behind some of the design decisions I made.

The conference was incredibly fast and the recording of the talk is already available on YouTube.

I'm quite happy with how it came out, especially the live coding part. Check it out!

What's next?

I want to launch Pavex's open beta before the summer.
I'm also a firm believer in dogfooding—I want to use Pavex to build Pavex's admin panel, and there are still a few features missing to make that possible.
In April I plan to close those gaps and, if all goes well, have a rudimentary version of the admin panel up and running.

That's all for this month!

You can discuss this update on r/rust.

Subscribe to the newsletter if you don't want to miss the next update!
You can also follow the development of Pavex on GitHub.