Skip to content

Wrapping

Pre-processing and post-processing middlewares can take you a long way, but they can't do everything. It is impossible, for example, to enforce a request-wide timeout or attach a tracing span to the request processing pipeline using only pre-processing and post-processing middlewares.

To overcome these limitations, Pavex provides wrapping middlewares.

Wrapping middlewares can execute logic before and after the rest of the request processing pipeline. But, most importantly, they give you access to Next, a Future representing the rest of the request processing pipeline—a prerequisite for advanced use cases.

Defining pre-processing middlewares

Use the #[wrap] attribute to define a new wrapping middleware:

use pavex::Response;
use pavex::middleware::Next;
use pavex::wrap;
use tokio::time::error::Elapsed;

#[wrap]
pub async fn timeout<C>(next: Next<C>) -> Result<Response, Elapsed>
where
    C: IntoFuture<Output = Response>, // (1)!
{
    let max_duration = std::time::Duration::from_secs(20);
    tokio::time::timeout(max_duration, next.into_future()).await
}
  1. This trait bound is always required when working with Next.

Signature

Pavex accepts a wide range of function signatures for wrapping middlewares, as long as they satisfy these requirements:

Other than that, you have a lot of freedom in how you define your wrapping middlewares:

Registration

Invoke Blueprint::wrap to register a wrapping middleware:

use crate::timeout::TIMEOUT;
use pavex::Blueprint;

pub fn blueprint() -> Blueprint {
    let mut bp = Blueprint::new();
    bp.wrap(TIMEOUT); // (1)!
    // [...]
}
  1. TIMEOUT is a strongly-typed constant generated by the #[wrap] attribute on the timeout function.
    Check out the documentation on component ids for more details.

The middleware will be invoked for all request handlers registered after it. Check out the scoping section for more details.

Middlewares can fail

Wrapping middlewares can return a Result, as shown by timeout in the example for the top-level section. Check out the error handling guide for more details on how to handle the error case.

Next

Wrapping middlewares wrap around the rest of the request processing pipeline. They are invoked before the route handler and all the other middlewares that were registered later. The remaining request processing pipeline is represented by the Next type.

All middlewares must take an instance of Next as input. To invoke the rest of the request processing pipeline, you .await the Next instance.

use pavex::Response;
use pavex::middleware::Next;
use pavex::wrap;

#[wrap]
pub async fn debug_wrapper<C>(next: Next<C>) -> Response
where
    C: IntoFuture<Output = Response>,
{
    println!("Before the handler");
    let response = next.await; // (1)!
    println!("After the handler");
    response
}

You can also choose to go through the intermediate step of converting Next into a Future via the IntoFuture trait. This can be useful when you need to invoke APIs that wrap around a Future (e.g. tokio::time::timeout for timeouts or tracing's .instrument() for logging).

use pavex::Response;
use pavex::middleware::Next;
use pavex::wrap;
use tracing::Instrument;

#[wrap]
pub async fn logger<C>(next: Next<C>) -> Response
where
    C: IntoFuture<Output = Response>,
{
    let span = tracing::info_span!("Request processing");
    next.into_future().instrument(span).await
}

Dependency injection

Middlewares can take advantage of dependency injection.

You must specify the dependencies of your middleware as input parameters in its function signature.
Those inputs are going to be built and injected by the framework, according to the constructors you have registered.

Check out the dependency injection guide for more details on how the process works.
Check out the request data guide for an overview of the data you can extract from the request using Pavex's first-party extractors.

Use with caution

You should only use wrapping middlewares when you need to access the future representing the rest of the request processing pipeline. In all other cases, you should prefer pre-processing and post-processing middlewares.

"But why? Wrapping middlewares can do everything, why not use them all the time?"

Good question! It's because wrapping middlewares and Rust's borrow checker are an explosive combination.

Every time you inject a reference as an input parameter to a wrapping middleware, you are borrowing that reference 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 unpack what that means with an example.

Example

Consider this scenario: you registered a constructor for MyType, a request-scoped dependency. You also registered a wrapping middleware that takes &MyType as an input parameter. You now want to work with MyType in your request handler:

  • If the request handler takes &mut MyType as an input parameter, you'll get an error: the immutable reference to MyType borrowed by the wrapping middleware is still alive when the request handler is executed.
  • If the request handler takes MyType by value, Pavex is forced to clone the value to satisfy the borrow checker. That's inefficient. If MyType isn't cloneable, you'll get an error.
  • If the request handler takes &MyType as an input parameter, all is well. You can have as many immutable references to MyType as you want.

You wouldn't have these problems with pre-processing or post-processing middlewares: whatever you inject into them is going to be borrowed only while the middleware is executed. You are then free to work with those types in your request handlers/other middlewares as you please.

No &mut references

The scenario we explored above is why Pavex doesn't let you mutate request-scoped types in wrapping middlewares, a restriction that doesn't apply to request handlers, pre-processing and post-processing middlewares.
It's so easy to shoot yourself in the foot that it's better to avoid &mut references altogether in wrapping middlewares.