The Magic behind Extractors & Handlers

The Rust web frameworks axum, actix-web and the game engine bevy use a language pattern to set / register seemingly any function to be passed in as an argument. This pattern is also sometimes known as magic function parameters. Rest assured it's not magic, but rather a neat use of traits and the static type system of Rust to make that happen.

The main goal of this article is to build a good understanding how this pattern works, what its building blocks are & also to show how you can implement it for your own purposes.

Example program

Let's start with the Hello World example from axum to see what the pattern looks like in practice. The following code example starts a HTTP web service with a single API route.

use axum::{response::Html, routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(handler));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn handler() -> Html<&'static str> {
    Html("<h1>Hello, World!</h1>")
}

Even though this example is fairly small due to the ergonomic API, there is a lot going on. First, a single endpoint is registered for root path /, a TcpListener is configured to listen on localhost at port 3000 to serve incoming requests, then the server is started. Calling the running HTTP server at address 127.0.0.1:3000 will call the handler function, which returns a simple HTML page with the content Hello World. The interesting part for our purpose is, how the function handler is registered in the Router.

The call of get(handler) is a shortcut for on(MethodFilter::GET, handler) which itself is expanded to:

MethodRouter::new().on(MethodFilter::GET, handler)

The MethodRouter is a type to store seperate handler functions associated with different HTTP methods (e.g. GET, POST, PUT) for the same API endpoint path, e.g. /. The elegant part is that function handlers can have completely different function parameters.

Motivation

The Rust language does not provide support for variadic arguments (ignoring macros), meaning the set of function parameters is fixed. Rust also does not support method overloading like Java where different methods by the same name can have different sets of function parameters. Rust disallows functions in the same scope or impl block to have the same name, to avoid any name collision.

In order to implement the pattern that axum or actix-web employ, let's build this pattern step by step, first by starting with an example what our API could look like, then adding concepts until the result matches the pattern. The code below is a minimal example of such an API:

fn handler(...) {
    // TODO:
}

fn extract_i32(value: i32) {
    println!("Value of i32 is {value}");
}

fn main() {
    handler(extract_i32);
}

In the example above function extract_i32 is provided as an argument to the handler function that someshow should accept it and later use it. The idea is to allow functions without fixed parameters to be passed in. Please note this code does not compile, as we have left a few things out for now.

Traits & functions

One important Rust feature we need to explain first are traits. A trait in Rust is roughly equivalent to an interface in Java. It abstracts some functionality over a type. Traits are used to share behavior between separate types. A trait has one or more methods associated with it, a trait can have associated types & type arguments, etc. For a more thorough introduction of traits check the chapter Traits: Defining Shared Behavior of the Rust Programming Language book or check out Rob Ede's excellent talk on Youtube from Rust Nation UK (2023), explaining different aspects of traits & actix-web's extractors.

What is the least we can do to implement the handler example above to satisfy the compiler? A naive approach is to set the function parameter in the signature of handler directly to the same type of the extract_i32 function, as given in the code below:

fn handler(func: fn(i32)) {
    func(42);
}

fn extract_i32(value: i32) {
    println!("Value of i32 is {value}");
}

fn main() {
    handler(extract_i32);
}

The code compiles and prints the output "Value of i32 is 42". Type fn(i32) in the handler function is a function pointer. This works well when the passed type itself is a function (as defined above in extract_i32), but does not work necessarily for closures. A closure can have the same argument types, but may capture its environment. Let's see the difference between two types of closures.

fn main() {
    // this closure does not capture its environment and can be passed
    handler(|x| { let _ = x * 2; });
    // this closure captures its environment and cannot be passed.
    let y = 2;
    handler(|x| { let _ = x + y; });
}

The program does not compile. The first closure can be passed, because it does not capture its environment and therefore can be coerced into a function pointer to match the expected type fn(i32). The closure in the second handler call cannot be coerced, as it captures the variable y from its environment. The Rust compiler prints the following error:

13 |     handler(|x| { let _ = x + y; });
   |     ------- ^^^^^^^^^^^^^^^^^^^^^^ expected fn pointer, found closure
   |     |
   |     arguments to this function are incorrect
   |
   = note: expected fn pointer `fn(i32)`
                 found closure `{closure@src/main.rs:13:18: 13:21}`
note: closures can only be coerced to `fn` types if they do not capture any variables
  --> src/main.rs:13:36
   |
13 |     handler(|x| { let _ = x + y; });
   |                               ^ `y` captured here
note: function defined here
  --> src/main.rs:1:4
   |
1  | fn handler(func: fn(i32)) {
   |    ^^^^^^^ -------------

Let's refactor the signature of handler to alleviate this issue using the appropriate Fn definition:

fn handler(function: impl Fn(i32)) {
    function(123);
}

fn main() {
    handler(|x| { let _ = x * 2; });
    let y = 2;
    handler(|x| { let _ = x + y; });
}

With this change the same code compiles, accepting both closures. The Fn type is a trait itself. One property of it is that the Fn trait is implemented automatically for closures (with some restrictions) and function pointers that have the same function parameters. In this sense it's more flexible than fn(i32).

What happens when a new function with a different function signature is added that we also want to pass to the handler function as an argument? Let's add a new function named extract_f32 with a parameter type f32:

fn extract_f32(value: f32) {
    println!("Value of f32 is {value}")
}

fn main() {
    handler(extract_i32);
    handler(extract_f32); // Compile error
}

The updated program will not compile, because the function signature of extract_f32 is different from what is expected by function handler, the argument does not match Fn(i32). The function signature needs to change in order to accept both functions. As a first step the handler function will be refactored to illustrate a more appropriate way. For now the call to handler(extract_f32) will be commented out. After the refactoring we'll come back to it.

The Handler

Until now the handler function accepted a function with the parameter i32. Instead of using the Fn trait, we'll introduce a new trait, named Handler. The name is chosen to reflect the name given in axum and actix-web for the similar purpose. First we define the new trait as:

pub trait Handler {
    fn call(&self);
}

The Handler trait has a single method call. Please note, the trait currently does not have any generic type arguments, this will be added later. Let's update the handler function accordingly to accept the new trait instead:

pub trait Handler {
    fn call(&self);
}

fn handler(handler: impl Handler) {
    handler.call();
}

Instead of the former Fn(i32) trait, the Handler trait is defined as a parameter, therefore the function accepts a type that implements that trait. Compiling the code produces the following error:

18 |     handler(extract_i32);
   |     ------- ^^^^^^^^^ the trait `Handler` is not implemented for fn item `fn(i32) {extract_i32}`
   |     |
   |     required by a bound introduced by this call
   |
help: this trait has no implementations, consider adding one

This signals that the fn(i32) does not implement the trait Handler, therefore it cannot accept function extract_i32. Interestingly Rust allows us to define an implementation for function pointer fn(i32) directly. This is basically an extension trait to the fn(i32) type. The implementation of the trait for fn(i32) looks as follows:

impl Handler for fn(i32) {
    fn call(&self) {
        self(123)
    }
}

The program still does not compile and displays the same error as before, but provides a suggestion:

   |
11 | fn handler(handler: impl Handler) {
   |                          ^^^^^^^ required by this bound in `handler`
help: the trait `Handler` is implemented for fn pointer `fn(i32)`, try casting using `as`
   |
24 |     handler(extract_i32 as fn(i32));
   |                         ++++++++++

When we update the call to:

fn main() {
    handler(extract_i32 as fn(i32));
    // handler(extract_f32);
}

the code now compiles and prints Value of i32 is 123. The parameter extract_i32 is a function pointer that has to be coerced via as in order for the handler function to match the associated Handler implementation.

This brings us a step closer to specify different implementations for different function signatures. When we re-enable the line handler(extract_f32) in main, and then compile the program again, it still fails to compile. We add the missing Handler implementation for the function pointer fn(f32) as well. Below is the full listing of our current program:

pub trait Handler {
    fn call(&self);
}

impl Handler for fn(i32) {
    fn call(&self) {
        self(123);
    }
}

impl Handler for fn(f32) {
    fn call(&self) {
        self(1.23);
    }
}

fn handler(handler: impl Handler) {
    handler.call();
}

fn extract_f32(value: f32) {
    println!("Value of f32 is {value}");
}

fn extract_i32(value: i32) {
    println!("Value of i32 is {value}");
}

fn main() {
    handler(extract_i32 as fn(i32));
    handler(extract_f32 as fn(f32));
}

The next useful step is to find a way to elimnate the function pointer coersions & prepare the Handler trait to be more flexible. Let's update the implementations to implement Handler for functions of the form Fn(T):

pub trait Handler {
    fn call(&self);
}

impl<F: Fn(i32)> Handler for F {
    fn call(&self) {
        self(123);
    }
}

impl<F: Fn(f32)> Handler for F {
    fn call(&self) {
        self(1.23);
    }
}

The code above results in the following compile error:

   |
5  | impl<F: Fn(i32)> Handler for F {
   | ------------------------------ first implementation here
...
11 | impl<F: Fn(f32)> Handler for F {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ conflicting implementation

This means there are two conflicting implementations of the Handler trait for template argument F. The Rust compiler cannot distinguish between the different implementations for the template argument F, even though it's defined for different function signatures.

To solve this situation we add a generic template argument to the Handler trait itself. By providing distinct types to the generic argument of the Handler trait the compiler is then able to determine the correct implementation, avoiding the conflict.

We add a generic type argument T to the Handler trait, the updated version and its implementations changes to:

pub trait Handler<T> {
    fn call(&self);
}

impl<F: Fn(i32)> Handler<i32> for F {
    fn call(&self) {
        self(123);
    }
}

impl<F: Fn(f32)> Handler<f32> for F {
    fn call(&self) {
        self(1.23);
    }
}

fn handler<T>(handler: impl Handler<T>) {
    handler.call();
}

fn main() {
    handler(extract_i32);
    handler(extract_f32);
}

This eliminates the coersions for Fn(i32) and Fn(f32) in the main function. The updated implementations of the Handler trait are specific for their associated function signatures Fn(i32) and Fn(f32). The template argument T in Handler<T> allows us to differ between separate implementations, by providing types. It's worth re-iterating that the Handler is implemented for explicit functions of the form Fn(..), where Fn is the trait.

Unfortunately the types i32 & f32 are still hard-coded in the Handler implementations, so are the values 123 and 1.23. Another limitation is that we would need to provide an implementation for each possible function signature. Doing this is not really flexible, especially when more arguments are involved or the order of function parameters is not important. It further does not support the ability to pass in unknown client side types. In order to be more flexible, we need a way to capture & constrain passing in arguments to the handler function.

The Extractor

The Handler trait on its own is quite inflexible at the moment. The frameworks axum and actix-web use the concept of extractors that are passed into the Handler. An extractor is a trait that extracts some value or object, often from a larger object. The web frameworks typically have a request type that contains information on URL, path, HTTP headers, body etc, for example HttpRequest in actix-web. Extractors are used to pick & transform information from this Request-like object into other more specifc types. Instead of dealing with the large object directly, extractors use dedicated types to contain specific data. For example some common extractors in axum are Json, Path, Query or State, all extracted or constructed from the same object. For a more detailed introduction into extractors read the Extractors chapter in the actix-web documentation.

An extractor formalizes the type that is passed into the Handler. Instead of using a Request-like object let's introduce our own custom type that represents the same idea for our existing data named Context:

pub struct Context {
    a: i32,
    b: f32,
}

The Context is our version of a complex data type (ok not too complex). It contains fields for i32 and f32. This new struct needs to work somehow with the Handler trait. As a first step we update the Handler trait to accept the Context as a function argument:

pub trait Handler {
    fn call(&self, context: &Context);
}

We need to update the implementations and pass a reference to the new Context type. The updated code looks then as:

pub struct Context {
    a: i32,
    b: f32,
}

pub trait Handler<T> {
    fn call(&self, context: &Context);
}

impl<F: Fn(i32)> Handler<i32> for F {
    fn call(&self, context: &Context) {
        self(context.a);
    }
}

impl<F: Fn(f32)> Handler<f32> for F {
    fn call(&self, context: &Context) {
        self(context.b);
    }
}

fn handler<T>(context: &Context, handler: impl Handler<T>) {
    handler.call(context);
}
    
fn extract_i32(value: i32) {
    println!("Value of i32 is {value}");
}

fn extract_f32(value: f32) {
    println!("Value of f32 is {value}");
}

fn main() {
    let context = Context { a: 42, b: 1.23 };
    handler(&context, extract_i32);
    handler(&context, extract_f32);
}

This is somewhat better, because the values are not hard-coded anymore. Each Handler implementation delegates the call to a field of Context. The next step is to introduce the Extractor trait, to extract some data from Context, named FromContext in our case:

pub trait FromContext {
    fn from_context(context: &Context) -> Self;
}

A type that implements FromContext returns itself, for example i32. It indicates that we extract some value or object from the given Context. Let's add implementations of FromContext for our existing types i32 and f32 as follows:

impl FromContext for i32 {
    fn from_context(context: &Context) -> Self {
        context.a
    }
}

impl FromContext for f32 {
    fn from_context(context: &Context) -> Self {
        context.b
    }
}

Both implementations are separate extractors. The code above simply delegates the calls to Context fields as before. Typically the internal logic of an extractor implementation is a lot more complex, but for purposes it's sufficient to just return the field. For example a Json extractor would read the HTTP body and transform it into a JSON representation.

The generic type argument T in the Handler trait serves an important purpose here. By using this template argument, we are able to leverage different sets of function signatures. Let's refactor the Handler implementation to require the type argument to implement the FromContext trait. Instead of having two separate implementations for i32 and f32, a single implementation then covers both:

impl<F, T> Handler<T> for F
where
    F: Fn(T),
    T: FromContext
{
    fn call(&self, context: &Context) {
        (self)(T::from_context(context))
    }
}

It may not be immediately clear what changed, therefore let's check details of the implementation. The template argument F is the function the Handler is implemented for, in this case Fn(T), while the template argument T requires a type to implement FromContext. Inside the call function the type is extracted from the given &Context. This concept is what makes handling different functions possible.

One advantage of the change is that, each type that implements FromContext can be given as a function argument. It can also be easily implemented for more types, especially important for client-side code. One drawback is, this Handler implementation in the example above only works for functions with a single function parameter. To allow functions that accept two parameters, for example for both i32 and f32, a separate implementation of the Handler trait is required that accepts two arguments, as given in the following example:

impl<F, T1, T2> Handler<(T1, T2)> for F
where
    F: Fn(T1, T2),
    T1: FromContext,
    T2: FromContext,
{
    fn call(&self, context: &Context) {
        (self)(T1::from_context(context), T2::from_context(context))
    }
}

/// Supports a function with two arguments
fn extract_both(first: f32, second: i32) {
    println!("Both values are {first} and {second}");
}

As mentioned before the generic trait argument T in Handler enables the compiler to distinguish between different implementations. The tuple (T1, T2) is treated as a single generic argument. To provide functions with even more arguments, the number of entries in the tuple () increases. The web frameworks axum and actix-web use macros to generate these implementations automatically for functions with 0 to N arguments, while all arguments are required to implement the extractor trait.

Combining the traits Handler and FromContext (the extractor) is what makes passing in functions with different signatures possible. Check the full code on Rust Playground.

Hopefully this sheds some light on how handlers and extractors work together. To illustrate how our Handler trait compares to other versions, let's have a look at the version in actix-web. Shown below is the handler function with two parameters:

impl<Func, Fut, T1, T2> Handler<(T1, T2)> for Func
where
    Func: Fn(T1, T2) -> Fut + Clone + 'static
    Fut: Future
{
    type Output = Fut::Output;
    type Future = Fut;

    fn call(&self, (T1, T2): (T1, T2)) -> Self::Future {
        (self)(T1, T2)
    }
}

There is an additional type argument Fut that allows this Handler to be used in an async context, but otherwise the trait looks very similar. In this implementation the given function argument is a tuple of (T1, T2), that is transformed outside using the FromRequest trait for each entry, and then passed to the call method, but the principle is the same.

How to store handlers

The current code has one disadvantage compared to how handlers are used in axum or actix-web. The handler functions are called immediately in handler. This section explains how to store heterogenous handler functions in the same collection.

Let's outline a new container type that registers and stores the handler functions:

#[derive(Default)]
struct HandlerContainer {
    pub list: Vec<...>,
}

impl HandlerContainer {
    pub fn register<H, T>(&mut handler: H)
    where
        H: Handler<T>
    {
        // TODO
    }
}

A few details have been left out, therefore the code does not compile yet. Rust requires that all elements in a Vec have to be of the same type. Defining the Vec in HandlerContainer as follows:

#[derive(Default)]
struct HandlerContainer {
    pub list: Vec<Handler<T>>,
}

won't compile, because type argument T is not declared. It results in a compiler error:

   |
51 |     pub list: Vec<Handler<T>>,
   |                           ^ not found in this scope
   |
help: you might be missing a type parameter
   |
50 | struct HandlerContainer<T> {
   |                        +++

Would we add the template argument T on HandlerContainer as well, it would allow us to declare different instances of the HandlerContainer for different types, for example HandlerContainer<i32> and HandlerContainer<f32>, but we could not store mixed handlers in the same Vec.

There is a way to eliminate this restriction by storing the handlers without the specific type on Handler. The process of eliminating the type argument is named type erasure, where compile-time information on traits are erased. Check out Type-erasing trait parameters in Rust for an excellent technical introduction.

To achieve storing handler functions, we need to have a look at Rust's dynamic feature contained in the std::any module, in particular the std::any::Any type. The Any type is a trait to emulate dynamic typing in Rust. Most types in Rust implement this trait. Using Any it's also possible to get a TypeId of a type, a unique type identifier. When using Any as borrowed trait object in the form of &dyn Any it provides methods to check if the contained value is of a specific type, and to cast a reference to the inner value to a type. &dyn Any is limited to test whether a value is of a specific concrete type, and cannot be used to test wether a type implements a trait.

Let's see a brief example:

use std::any::Any;

fn main() {
    let x: i32 = 42;
    println!("type id is {:?}", x.type_id());

    let y = &x as &dyn Any;
    match y.downcast_ref::<i32>() {
        Some(_value) => println!("X is a i32"),
        None => println!("X is not an i32"),
    }
}

This program produces the following output:

type id is TypeId(0x56ced5e4a15bd89050bb9674fa2df013)
X is a i32

The TypeId is a long unique identifier, different for each type. The downcast_ref method casts a &dyn Any into a given target type, if the inner type is the same. The call to y.downcast_ref::<i32>() returns Some(i32), while a call to y.downcast_ref::<f32> would return None.

How can the dynamic type logic help us store different function handlers? We will wrap the Handler trait implementation in a new struct type that will erase the specific template argument T. Let's introduce the new struct called ErasedHandler.

use std::any::Any;

struct ErasedHandler<T>
where
    T: Any,
{
    handler: Box<dyn Handler<T>>,
}

The struct takes a template argument T, and requires that it implements Any, which applies for most types in Rust. The handler instance is stored in a Box, because Handler in itself is noted Sized and therefore its size in memory is not known at compile-time. Let's add a constructor to ErasedHandler to pass in an exsting Handler type.

impl<T> ErasedHandler<T>
where
    T: 'static
{
    pub fn new<'a, H>(handler: H) -> Self
    where
        H: Handler<T> + 'static,
    {
        Self {
            handler: Box::new(handler),
        }
    }
}

The template argument T requires the 'static lifetime. An alternative is to declare T as T: Any, to require the template argument to be of Any type. The latter also works, because the Any trait inherits the 'static lifetime. The inner template argument H for the Handler also requires the 'static lifetime to indicate that each handler needs to be known at compile time, otherwise H may not live long enough.

We'll expand the HandlerContainer to store the Handler instances now. The updated type and implementation is given in the code below:

#[derive(Default)]
struct HandlerContainer {
    pub list: Vec<Box<dyn Handler<Box<dyn Any>>>>,
}

impl HandlerContainer {
    /// Store handler in `Vec`.
    pub fn register<H, T: 'static>(&mut self, handler: H)
    where
        H: Handler<T> + 'static,
    {
        self.list.push(Box::new(ErasedHandler::new(handler)));
    }
}

The field list stores the Handler implementations erased of their types now by coercing the specific type T to dyn Any. This makes it possible to cast Any back to the concrete underlying type of the form Fn(..). The call to ErasedHandler::new(handler) creates a new instance of the boxed erased type. This reads a bit unwieldy, therefore let's add a type alias to provide a better name.

type BoxedErasedHandler = Box<dyn Handler<Box<dyn Any>>>;

#[derive(Default)]
struct HandlerContainer {
    pub list: Vec<BoxedErasedHandler>,
}

The code still does not compile and returns with the following error:

   |
59 |         self.list.push(Box::new(ErasedHandler::new(handler)));
   |                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Fn(Box<(dyn Any + 'static)>)` is not implemented for `ErasedHandler<T>`
   |
note: required for `ErasedHandler<T>` to implement `Handler<Box<(dyn Any + 'static)>>`
  --> src/main.rs:28:12
   |
28 | impl<F, T> Handler<T> for F
   |            ^^^^^^^^^^     ^
29 | where
30 |     F: Fn(T),
   |        ----- unsatisfied trait bound introduced here
   = note: required for the cast from `Box<ErasedHandler<T>>` to `Box<(dyn Handler<Box<(dyn Any + 'static)>> + 'static)>`

It's missing an implementation of Handler for the ErasedHandler<T> type. Remember, the list stores types that match Handler<Box<dyn Any>>, and ErasedHandler is not implementing it yet. Let's add an implementation of the Handler trait for ErasedHandler.

impl<T: 'static> Handler<Box<dyn Any>> for ErasedHandler<T> {
    fn call(&self, context: &Context) {
        self.handler.call(context);
    }
}

This implements Handler<Box<dyn Any>> (the expected type signature in field list) for any type argument T for the ErasedHandler struct. The call method simply delegates the call to the inner handler field.

At this point the HandlerContainer type can register different handlers. The current version of types ErasedHandler, HandlerContainer and the updated main function is shown below:

type BoxedErasedHandler = Box<dyn Handler<Box<dyn Any>>>;

#[derive(Default)]
struct HandlerContainer {
    pub list: Vec<BoxedErasedHandler>,
}

/// Implement Handler for all possible `T` that `ErasedHandler` encapsulates over.
impl<T: 'static> Handler<Box<dyn Any>> for ErasedHandler<T> {
    fn call(&self, context: &Context) {
        self.handler.call(context);
    }
}

impl HandlerContainer {
    pub fn register<H, T: 'static>(&mut self, handler: H)
    where
        H: Handler<T> + 'static,
    {
        self.list.push(Box::new(ErasedHandler::new(handler)));
    }
}

struct ErasedHandler<T>
where
    T: Any,
{
    handler: Box<dyn Handler<T>>,
}

impl<T: 'static> ErasedHandler<T> {
    pub fn new<'a, H>(handler: H) -> Self
    where
        H: 'static + Handler<T>,
    {
        Self {
            handler: Box::new(handler),
        }
    }
}

fn main() {
    let mut container = HandlerContainer::default();
    container.register(extract_i32);
    container.register(extract_f32);
    container.register(extract_both);
    
    // then execute all handlers
    let context = Context { a: 42, b: 1.23 };
    for handler in container.list.iter() {
        handler.call(&context);
    }
}

First, all handler functions are registered in main, then executed afterwards to show how they can be called. Each call extracts the inner field from the given Context. Crates or programs employing the extractor and handler pattern may use their own technique to call a certain handler, for example axum stores one handler function for each HTTP method in their MethodRouter. Calling downcast_ref on a dyn Any intance can be used to cast it back to a concrete type during runtime.

The complete listing of the code above can be found here.

Conclusion

Hopefully the article offers an insight into how this pattern works. It's a pattern that makes sense when the same workflow is employed, for a different set of client-side functions. The web frameworks axum & actix-web make good use of the pattern, because they both use handler functions to be called for API routes. Each API endpoint defines what it requires, for example one handler may read a submitted form request, other handlers require access to the database, etc. By implementing different extractors they allow other use cases as well, ones that may not be present in the framework directly, e.g. for authorization & authentication.

A more complex system using this pattern can also be found in the game engine bevy, the Module documentation on its Entity Component System (ECS) provides a good overview. For a good introduction check out Pascal Hertleif's article The Rust features that make Bevy's systems work.

Th kind of pattern requires more code on the library-level, but in the end provides a more ergonomic & minimal API for client side code. :)