How does a router store handler functions with different parameters?

Imagine that the router has the following method:

pub fn route<I, O, F>(&mut self, path: &'static str, handler: F)
    I: DeserializeOwned,
    O: Serialize,
    // For simplicity's case, assume the handler is sync & infallible.
    F: Fn(I) -> O,
{ /* ... */ }

Whenever handling a request, it would deserialize I from the request body (and let's assume it'll always be encoded as JSON), pass it to the handler function, and serialize its output, O, into JSON and set it as the response body.

How should the handler be stored? What should the Router struct look like?

Since Fn is a trait, dynamic dispatch is necessary, so the handler must be boxed. The function output is straightforward, too: Box<dyn Serialize>. But what about the input? We want some generic value that can hold different types, but we also need to know the type so we can deserialize the request body into it.

pub struct Router {
    routes: HashMap<&'static str, Box<dyn Fn(/* What goes here? */) -> Box<dyn Serialize>>>,

I tried inspecting axum::Router but got lost before I could get anywhere. It's a labyrinth of traits. If anyone knows a simpler example, please, let me know.

No, that's the wrong place to apply type erasure. As you observed, you need both the input and the output to be statically-typed, otherwise you can't perform (de)serialization.

You want to apply type erasure on the handler itself, where the implementation of each handler wraps the concrete static types and forwards to/from something (de)serializeable.


Oh, that's clever! Thanks.

I knew serde::Serialize isn't object-safe, but then I discovered erased_serde::Serialize and figured that would solve the problem. I guess that's something different? My understanding of serde is shaky, so I admit I skimmed the example code without seeing how it actually worked.

Sorry, I'm not sure what you are asking. Different from what (as opposed to what)?

From serializing the output inside the closure. But don't worry about it. I just asked off-handedly.

Out of curiosity, is it possible to support async handlers using the same approach?

I thought it could be done like this, but that doesn't compile. I couldn't figure out how to cast an async block into a dyn Future.

Yes. (If it weren't possible, there wouldn't be any async web frameworks.)

The type of the async block wasn't the biggest problem, though. For that, you just have to be explicit with the return type in order to force a coercion.

The bigger problem is the requirement for reconciling Fn (which is callable multiple times as it only borrows) with the fact that the async block can't just borrow the handler (since it's passed by value and will long be gone by the time the wrapping closure is actually invoked). For that, you'll have to use shared ownership, ie. Arc.

Finally, to answer your // TODO: comment in the code: no, removing the Box from the return type is impossible. Every future (async fn or block) has its own concrete type, just like every closure has its own concrete type. The only way to treat an open, unrestricted set of these unnameable, compiler-generated types uniformly is therefore dynamic dispatch. (If you were writing your own Future, you could always just return a concrete type or an enum of many types, but for language builtins, you don't get to choose the type, the compiler does.)

(You could of course technically replace the Box with Arc or something similar, but I'm sure that's not what you are asking – the point would be to remove the heap allocation and the dynamic dispatch, which just doesn't fly.)

1 Like

Thanks for the detailed explanation! What about using FnOnce instead? It's simpler and compiles.

If I understand it correctly, FnOnce may only be called once, which precludes closures from being used as handlers (which are called multiple times by definition), but the compiler still allowed me to pass in a regular (non-closure) function, which surprised me.

Why? FnOnce is the least restricted function trait. If something is going to be callable at all, then it must implement at least FnOnce.

You should actually try calling it and you'll see that it's impossible.