Next Stop - Ihcblog!

Some creations and thoughts sharing | sub site:ihc.im

0%

Rust HTTP Framework Design - Taking Axum 0.6 as an Example

This article also has a Chinese version.

An HTTP Server internally includes many parts: protocol implementation (h1, h2, compression, etc.), connection state management (keepalive), request distribution, middleware, business logic, and more. Users could implement all of these themselves, however, apart from the business logic, the rest are fairly common capabilities. By decoupling these generic capabilities from the user’s business logic, we arrive at what is known as an HTTP framework.

In the Rust ecosystem, the hyper library already offers a relatively complete implementation of the HTTP protocol. Therefore, building an HTTP framework on top of hyper mainly requires adding capabilities such as routing, shared state, middleware, etc.

This article discusses from the design perspective of an HTTP framework, using the new version of Axum as an example, how to provide rational abstractions and type constraints in Rust’s HTTP frameworks. Levering Rust’s powerful type system, we can write code that is both efficient and correct.

Handler Abstraction

Simple Routing

Usually, our behavior is independent for different Methods on different request Paths.

Dispatching based on Path and Method is one of the universal capabilities that an HTTP framework needs to provide: for example, we need the ability to dispatch GET / and POST /login to different handler functions.

The simplest routing can be implemented using a HashMap. As shown below, the Key is a combination of Path and Method, and the Value is the corresponding handler function. We can represent Path and Method with a simple String, but how should we express the handler function?

1
HashMap<(Path, Method), Handler>

First, let’s address the Handler problem, and later we will revisit a more reasonable routing design.

Since we need to store the Handler in a HashMap, we need a fixed type to represent it (otherwise, the size of the HashMap’s Value would be indeterminate). In Rust, we often use Box<dyn Trait> to unify different concrete types as the same type (which corresponds to a pointer in other languages).

This requires us to define a Trait to describe the processing logic.

The Simplest Solution: Requiring the Handler Function to Implement a Fixed Fn

Since processing logic invariably involves handling an HTTP request and obtaining an HTTP response, a direct approach is to take the HTTP Request struct decoded by hyper as an argument, with the requirement that the function return an HTTP Response.

If a handler internally needs a particular request parameter, then it has to extract it from the HTTP Request on its own, and may need to perform deserialization processes (like Json).

1
2
add_route(handler: H, ...)
where H: Fn(req: http::Request) -> http::Response { ... }

A More User-Friendly Interface

The drawback of the previous solution is that it is very inconvenient for the user to extract parameters. A friendlier implementation is for the framework to handle parameter extraction, with the user simply needing to declare what they need in the parameters and describe their business logic.

What we want to achieve is:

1
2
3
4
5
async fn index() -> &'static str {
"it works!"
}

async fn login(form: Form<...>) -> (StatusCode, String) { ... }

To implement a unified Trait for these Handlers, we need to solve several issues:

  1. Ensure that each parameter can be extracted from the HTTP Request.
  2. Ensure that the function’s return value can be converted into an HTTP Response.
  3. Support variable-length arguments (different Handlers can have different numbers of parameters).
  4. Support Async Fn.

Parameter Constraints

We hope to ensure through compile-time checking that these parameters can be extracted from the Request. We can define a Trait:

1
2
3
4
5
// framework code
pub trait FromRequest {
type Error;
fn from_request(req: &http::Request) -> Result<Self, Self::Error>;
}

For instance, for the need to extract parameters from an HTTP Query, users just need to implement code like this:

1
2
3
4
5
6
7
8
// user code
#[derive(Deserialize)]
struct Filter {
keyword: String,
count: u32,
}

async fn search(Query(f): Query<Filter>) -> (StatusCode, String) { ... }

The framework can implement a helper structure Query to extract from the Query parameters.

1
2
3
4
5
6
7
8
// framework code
pub struct Query<Q>(pub Q);

impl<Q> FromRequest for Query<Q>
where ... {
type Error = ...;
fn from_request(req: &http::Request) -> Result<Self, Self::Error> { ... }
}

In addition to using built-in auxiliary structures like Query or Form provided by the framework, users can also implement their own FromRequest.

Through this approach, we decouple request parameter extraction from the business logic and ensure type constraints, guaranteeing that the structure must be implemented on how to extract from the Request. Only then can the function be implemented as a Handler and be registered to the Router.

Return Value Constraints

Return value constraints are similar to parameter constraints; we constrain the return value to be convertible to a Response:

1
2
3
pub trait IntoResponse {
fn into_response(self) -> http::Response;
}

The framework can provide some implementations:

1
2
3
impl IntoResponse for &'static str {...}
impl IntoResponse for String {...}
impl IntoResponse for (StatusCode, String) {...}

With the help of IntoResponse, we can support custom structures as user handler returns, decoupling Response construction from business logic.

Supporting Variable-Length Arguments

Some handlers do not need any input parameters (such as the index interface mentioned earlier that returns “hello world”), while others may require one or more parameters extracted from the Request.

So, how can we abstract such a diverse range of functions into a unified form? In Rust, the common practice is to define a Trait and then implement this Trait for functions (Fns) that meet certain conditions. For Fns with varying numbers of parameters, we need to manually implement the Trait for each one.

Since this task is tedious and repetitive, it is often accomplished using macros, making the resulting code block resemble a “trapezoid”.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// from axum
macro_rules! all_the_tuples {
($name:ident) => {
$name!([], T1);
$name!([T1], T2);
$name!([T1, T2], T3);
$name!([T1, T2, T3], T4);
$name!([T1, T2, T3, T4], T5);
$name!([T1, T2, T3, T4, T5], T6);
$name!([T1, T2, T3, T4, T5, T6], T7);
$name!([T1, T2, T3, T4, T5, T6, T7], T8);
$name!([T1, T2, T3, T4, T5, T6, T7, T8], T9);
$name!([T1, T2, T3, T4, T5, T6, T7, T8, T9], T10);
$name!([T1, T2, T3, T4, T5, T6, T7, T8, T9, T10], T11);
$name!([T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11], T12);
$name!([T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12], T13);
$name!([T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13], T14);
$name!([T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14], T15);
$name!([T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15], T16);
};
}

We require a unified calling interface, which is achieved using macros:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
pub trait Handler {
fn call(&self, req: http::Request) -> http::Response;
}

// illustrate for macro expand
impl<F, O> Handler for F
where F: Fn() -> O, O: IntoResponse { ... }

// illustrate for macro expand result
impl<F, O, T1> Handler for F
where F: Fn(T1) -> O, O: IntoResponse, T1: FromRequest {
fn call(&self, req: http::Request) -> http::Response {
let param1 = match T1::from_request(&req) {
Ok(p) => p,
Err(e) => return e.into(),
};
let resp = (self)(param1).into_response();
resp
}
}

// illustrate for macro expand
impl<F, O, T1, T2> Handler for F
where F: Fn(T1, T2) -> O, O: IntoResponse, T1: FromRequest, T1: FromRequest { ... }

// other impl blocks ...

Ultimately, we still call by directly passing an http::Request to obtain an http::Response.

Ownership Issue with Request

In the previous interface, we constrained our request extractors to only have read access to a reference of Request, because we might need to extract multiple parameters. Here, we can make a small optimization: If there is only one parameter extractor, then pass ownership; otherwise, for the last one, pass ownership, and for the previous ones, pass references.

In Axum, there are two relevant traits: FromRequestParts and FromRequest. FromRequestParts can extract content from the HTTP Request Head (passing a reference), while FromRequest can extract from both the HTTP Request Head and Body (passing ownership of the entire Request). FromRequest is very friendly for extractors that need to consume the Body, but it will only be applied to the last extractor.

Supporting Async Fn

With the Handler Trait from the previous section, we’ve already made it quite friendly to define handlers. However, often our handlers will involve database reads/writes, downstream RPC calls, etc., all of which are network operations that are asynchronous. Thus, handlers inevitably have to be implemented as async fn.

An Async Fn is essentially a Fn. The main difference is that, after the async syntax is desugared, it turns the defined return value into an anonymous structure that implements Future. For example:

1
2
3
4
5
6
7
8
9
10
// before desuger
async fn example() -> String { ... }

// after desuger
fn example() -> AnonymousFut { ... }
struct AnonymousFut { ... }
impl Future for AnonymousFut {
type Output = String;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { ... }
}

Extended Reading: For some foundational knowledge about Rust’s asynchronous system, you can refer to this article series.

To support async fn implementations of the Handler Trait, we need to make some modifications:

1
2
3
pub trait Handler {
fn call(&self, req: http::Request) -> BoxFuture<'static, http::Response>;
}

Because it involves specific types of Future, here we actually have two approaches: one is to use type Future: Future<Output = http::Response> as the associated type for the Trait, another is to directly use BoxFuture to erase the type.

Which to use mainly depends on whether or not one wants to erase the type, and where they want to perform the type erasure. Generally speaking, in scenarios where nested calls may occur (such as nested tower::Service), it is best to perform the type erasure at the end. This can minimize the usage of Box, maximize inlineable code segments, and achieve better performance.

We’ve modified the Trait, and likewise, we need to modify the implementations. Below is a demo example after macro expansion:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// illustrate for macro expand result
impl<F, O, T1> Handler for F
where F: Fn(T1) -> Fut, Fut: Future<Output = O>,
O: IntoResponse, T1: FromRequest {
fn call(&self, req: http::Request) -> BoxFuture<'static, http::Response> {
Box::pin(async move {
let param1 = match T1::from_request(&req) {
Ok(p) => p,
Err(e) => return e.into(),
};
let resp = (self)(param1).into_response();
resp
})
}
}

Note: This restriction implies that we must use an asynchronous interface. If we want to implement Handler for structures that return T: IntoResponse, there would be an implementation conflict (because both are blanket implementations).

To summarize this section, we have defined the Handler Trait clearly and automatically implemented it for the user-defined async fn / fn that satisfy certain conditions. Users can use various extractors and generators on the function’s parameters and return values, allowing the handler itself to focus on business logic.

Shared State

Handlers might need access to some external quantities, like an initialized database or a HashMap for caching. We generally allow users to attach some State (or Data) when creating a Route, and extract this structure in the handler function’s parameters.

Extracting Shared State

We can fully treat shared state as part of the Request.

This allows us to reuse the previously defined FromRequest. But how can we differentiate the user-defined type S that we want to extract—where should it come from? Or what if the S that the user wants to extract is the same S we use for extracting request parameters like Query? If S is put directly in the parameters, there will be semantic ambiguity.

To solve this ambiguity, we can create a State type.

1
pub struct State<S>(pub S);

After implementing FromRequest for State, we then know for certain that this S is to be extracted from shared state.

Storing Shared State

Following our previous train of thought, since we are treating shared state as part of the Request, we either place the state somewhere in the Request, define a new Request that carries the state, or introduce it through a new parameter.

Storing State Using Request Itself

In Actix and earlier versions of Axum, state storage adopted this approach. The Request internally has a type map called an extension; we can insert a KV pair directly: key = S; val = S{...}. When extracting, we can find the val we inserted before calling the handler using the S type as the key.

This seems convenient and easy to implement, but this solution forgoes the opportunity for static type checking. If the State required in the user handler’s parameters conflicts with the State attached to the Route, it can only be handled at runtime. This may cause code that can compile but is guaranteed to fail at runtime.

Passing Through a New Parameter and Adding Type Constraints

Using the Rust type system, we can expose the issues mentioned earlier during compile time.

We can add a generic S to Handler, FromRequest, and Route, and when adding a route with Route<S>, we make restrictions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
pub trait Handler<S> {
fn call(&self, req: http::Request, state: S) -> BoxFuture<'static, http::Response>;
}
pub trait FromRequest<S> {
type Error;
fn from_request(req: &http::Request, state: &S) -> Result<Self, Self::Error>;
}

pub struct Route<S> { ... }
impl<S> Route<S> {
pub fn new(state: S) -> Self { ... }
// we can only add Handler<S> to our self, not Handler<A> or Handler<B>
pub fn route<H: Handler<S>>(self, hander: H) -> Self { ... }
}

// illustrate for macro expand result
impl<F, O, S, T1> Handler<S> for F
where F: Fn(T1) -> Fut, Fut: Future<Output = O>,
O: IntoResponse, T1: FromRequest {
fn call(&self, req: http::Request, state: S) -> BoxFuture<'static, http::Response> {
Box::pin(async move {
let param1 = match T1::from_request(&req, &state) {
Ok(p) => p,
Err(e) => return e.into(),
};
let resp = (self)(param1).into_response();
resp
})
}
}

We thus can constrain the type of State on the handler function. Next, we implement the State helper structure for parameter extraction:

1
2
3
4
5
6
7
8
9
10
pub struct State<S>(pub S);

impl<S> FromRequest<S> for State<S>
where S: Clone {
type Error = Infallible;

fn from_request(req: &http::Request, state: &S) -> Result<Self, Self::Error> {
Ok(State(state.clone()))
}
}

Great success! We can now confidently use shared state without worrying about passing the wrong type and running into problems at runtime. If it compiles, it works.

More User-Friendly Shared State

Just like we discussed extracting from Request, users might only need a part of Request or State. To make it more user-friendly, we can allow users to receive only part of the state. For example:

1
2
3
4
5
6
7
8
struct MyShared {
db: DBHandler,
cache: Cache,
counter: Counter,
}

async fn get_cache(State(cache): State<Cache>) -> String { ... }
async fn update_cache(State(cache): State<Cache>, State(db): State<DBHandler>) -> String { ... }

Similar to FromRequest, we can define a trait to allow derivation of a sub-structure from a larger one.

1
2
3
4
5
6
7
8
9
// In 'from' style
pub trait FromRef<T> {
fn from_ref(input: &T) -> Self;
}

// In 'into' style
pub trait Param<T> {
fn param(&self) -> T;
}

Comparison between FromRef and Param Styles

Similar to the relationship between From and Into, we can provide these two stylistic traits. Both are equivalent; one simply needs to choose which to use.

Inside Axum, the FromRef definition is used, while in linkerd2-proxy we can find the Param style definition.

Which of these two is better? That depends on personal preference (the standard library advocates for defining From)~

Note: This doesn’t violate the orphan rule, as although neither FromRef nor String are defined by us, because Example is, FromRef<Example> isn’t considered a foreign type.

1
2
3
4
struct Example {
demo: String,
}
impl FromRef<Example> for String { ... }

Or:

1
2
3
4
struct Example {
demo: String,
}
impl Param<String> for Example { ... }

We choose the Into style Param trait to implement the functionality of this section:

1
2
3
4
5
6
7
8
impl<S, I> FromRequest<S> for State<I>
where S: Param<I> {
type Error = Infallible;

fn from_request(req: &http::Request, state: &S) -> Result<Self, Self::Error> {
Ok(State(state.param()))
}
}

Routing

Routing is a system for combining and distributing Handlers.

We introduced the concept of handlers earlier with a simple route and discussed how the Handler trait constrains and automatically implements Handlers for functions defined by users using macros. But there’s still a distance to go before we arrive at truly user-friendly routing.

Route Lookup and Merging

For route lookup, we can use the matchit library. With matchit, it allows us to register Paths and their corresponding Values. When we request a match for a certain path, it returns the Value corresponding to the matching Path to us. Matchit is implemented based on a radix trie, which, compared to a HashMap, supports prefix matching and parameter extraction (like /user/:id).

A simple implementation would be to wrap matchit for path matching. However, matchit does not support iterating to get the Paths and Values registered which is a downside. Therefore, to support more features (like merging), we need to maintain our own complete mapping relationship.

In Axum, Handler is represented as the Endpoint type. The Router maintains two pieces of information:

  1. RouteIdEndpoint
  2. Node
    1. matchit Router<RouteId> for path matching
    2. RouteIdPath
    3. PathRouteId

When looking up a path, we simply give it to the matchit Router to find the corresponding RouteId, then use RouteId to find the Endpoint.

When merging routes, suppose we want to merge B into A, we will traverse all routing information in B to get all the (RouteId, Path, Endpoint) tuples, then try to merge them into A under the same Path (for different Methods with the same Path). If it doesn’t exist, we will create a new one.

For example, when merging /login and /logout, we would insert the corresponding Path and RouteId into the matchit Router. However, when merging GET /login and POST /login, there’s no need to modify the matchit Router, we just merge the corresponding Endpoint.

Endpoint

Axum defines Endpoint to support nested Routes and dispatching based on Methods.

1
2
3
4
5
enum Endpoint<S, B> {
MethodRouter(MethodRouter<S, B>),
Route(Route<B>),
NestedRouter(BoxedIntoRoute<S, B, Infallible>),
}

image

Route

1
pub struct Route<B = Body, E = Infallible>(BoxCloneService<Request<B>, Response, E>);

Route is an async fn(Request) -> Result<Response, Error>, which can be used directly to handle requests. When we do not need to dispatch based on the Method, or when dispatching has already been completed, we can direct the requests straight to the corresponding Route for handling.

BoxedIntoRoute

1
2
3
4
5
6
pub(crate) struct BoxedIntoRoute<S, B, E>(Box<dyn ErasedIntoRoute<S, B, E>>);
pub(crate) trait ErasedIntoRoute<S, B, E>: Send {
fn clone_box(&self) -> Box<dyn ErasedIntoRoute<S, B, E>>;
fn into_route(self: Box<Self>, state: S) -> Route<B, E>;
fn call_with_state(self: Box<Self>, request: Request<B>, state: S) -> RouteFuture<B, E>;
}

BoxedIntoRoute erases the Handler type, so we cannot find any signs related to Handler on its generics, nor any associated types of Handler.

Its State is to be filled in. We can understand BoxedIntoRoute as a Route without an attached State. Once the State is attached, it becomes a real Route, which is what the “Into Route” in the name implies.

MethodRouter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub struct MethodRouter<S = (), B = Body, E = Infallible> {
get: MethodEndpoint<S, B, E>,
head: MethodEndpoint<S, B, E>,
delete: MethodEndpoint<S, B, E>,
options: MethodEndpoint<S, B, E>,
patch: MethodEndpoint<S, B, E>,
post: MethodEndpoint<S, B, E>,
put: MethodEndpoint<S, B, E>,
trace: MethodEndpoint<S, B, E>,
fallback: Fallback<S, B, E>,
allow_header: AllowHeader,
}

enum MethodEndpoint<S, B, E> {
None,
Route(Route<B, E>),
BoxedHandler(BoxedIntoRoute<S, B, E>),
}

The MethodRouter contains various MethodEndpoints corresponding to different Methods. A MethodEndpoint might correspond to None (not set on the method), or it could be the previously mentioned Route or BoxedIntoRoute.

Routing Composition

Let’s take a look at an example borrowed from axum to see how the Router is actually created:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async fn main() {
...
let app = Router::new()
.route("/keys", get(list_keys))
.nest("/admin", admin_routes())
.with_state(Arc::clone(&shared_state));
...
}

async fn list_keys(State(state): State<SharedState>) -> String { ... }
fn admin_routes() -> Router<SharedState> {
async fn delete_all_keys(State(state): State<SharedState>) { ... }
async fn remove_key(Path(key): Path<String>, State(state): State<SharedState>) { ... }

Router::new()
.route("/keys", delete(delete_all_keys))
.route("/key/:key", delete(remove_key))
.layer(RequireAuthorizationLayer::bearer("secret-token"))
}
  1. First is .route("/keys", get(list_keys)), where the get function encapsulates a function into a Box, which is then transformed into MethodRouter<S, B, Infallible>. This MethodRouter is subsequently inserted into Endpoint::MethodRouter via the route method. Since no State is attached at this point, its internal type corresponds to BoxedIntoRoute, which is the type that lacks State.

  2. Next, let’s look at .with_state(Arc::clone(&shared_state)):

1
pub fn with_state<S2>(self, state: S) -> Router<S2, B> { ... }

Here, an S type of state is attached, and the generic type is rewritten as the new type S2 (S2 is unconstrained at this time).

Inside with_state, all BoxedIntoRoute are attached with State and are transformed into the Route type.

  1. The .nest method accepts a prefix and a sub-router. At this point, all that is needed is to insert the sub-router as a NestedRouter. What’s important to note here is that before passing it to the sub-router, the prefix has to be additionally stripped off.

Middleware

Tower Service

Before delving into the routing system, let us first become familiar with the components involved.

Tower is a component for describing general logic within the Rust ecosystem. Tower provides a Service Trait that describes an asynchronous logic which takes an input Request and outputs a Result<Response, Error>. Essentially, any logic where one input corresponds to one response can be described with this Trait.

1
2
3
4
5
6
7
8
pub trait Service<Request> {
type Response;
type Error;
type Future: Future<Output = Result<Self::Response, Self::Error>>;

fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;
fn call(&mut self, req: Request) -> Self::Future;
}

The Service trait not only helps align interfaces between different components, but we can also utilize some auxiliary components from tower to compose logic. Tower provides Layer for decorating a Service, and additionally offers Stack to facilitate nesting between Layers.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pub trait Layer<S> {
type Service;

fn layer(&self, inner: S) -> Self::Service;
}

pub struct Stack<Inner, Outer> {
inner: Inner,
outer: Outer,
}

impl<S, Inner, Outer> Layer<S> for Stack<Inner, Outer>
where
Inner: Layer<S>,
Outer: Layer<Inner::Service>,
{
type Service = Outer::Service;

fn layer(&self, service: S) -> Self::Service {
let inner = self.inner.layer(service);

self.outer.layer(inner)
}
}

To summarize briefly:

  1. Service describes a generic logic pattern of async fn (Request) → Result<Response, Error>.
  2. Layer acts as a decorator for a Service; it takes a Service and can return a decorated Service.
  3. Stack is a composition of Layers and also implements the Layer trait itself. When applying a .layer, it will sequentially invoke all the layers contained within.

Since our architecture utilizes Service middleware, and because Route itself implements the Service trait, we can encapsulate the Route by receiving a layer.

For the currently State-lacking BoxedIntoRoute, we first temporarily store the layers, awaiting the filling of the State before we wrap them with the layer.

Server Creation and Usage

By combining the aforementioned routing, Handlers, and middleware, we create a Server. The Server allows users to bind and serve a MakeService:

1
2
3
axum::Server::bind(&addr)
.serve(router.into_make_service())
.await;

IntoMakeService<S> implements Service<T, Response = S, Error = Infallible>.

Creating a Server using Server::bind(…).serve(…) results in a Server that implements Future, hence it can be directly awaited. In its Future implementation, it first accepts connections and utilizes IntoMakeService<S> to generate an S, which is the Service for handling requests (our router).

Subsequently, the IO (connection) together with the handling Service is wrapped in a hyper::Connecting structure, forming a hyper::NewSvcTask that is spawned.

The reading and writing on the connection, parsing, and other operations are handled by hyper, which ultimately uses the parsed http::Request to call our passed-in Service, that is, Router.

image

Router and State Generic Tricks

By this point, the article has essentially analyzed the following:

  1. How to constrain Handlers.
  2. How to perform routing lookup and management.
  3. How to support shared state.

While reading through the code of Axum, a clever use of generics regarding State caught my attention.

If you carefully read the previous sections about shared state, you would find that my approach is like this:

1
2
3
async fn index() -> String { ... }

let route = Route::new(state).route("index", get(index));

In Axum, however, the following method is supported:

1
2
3
4
5
6
7
8
let route = Route::new()
.route("1", get(index1))
.with_state(s1)
.route("2", get(index2))
.route("2", post(index2))
.with_state(s2)
.route("3", post(index3))
.with_state(s3)

In Axum, it’s possible to chain different Handlers and States in a fluent way. How is this achieved?

The new method of Router does not impose direct constraints on S, implying that S can be understood as any type (Any). When the route method is called, the implementation of Handler might impose constraints on S (or might not). When calling the with_state method, the type of S is determined, at this point:

  1. It checks if the new S satisfies the constraints imposed earlier.
  2. It resets the generic S to S2 (thus resetting associations and clearing constraints).

Ultimately, when the router is being used, into_make_service is called, and Router must implement Service. Both these implementations are for Router<()>, meaning S can be ().

Let’s consider a few scenarios:

  1. If a previously registered handler imposed constraints on S, but with_state was not called: then S has constraints (usually not () since it would be meaningless). If we forget to attach state or attach state of a wrong type, compilation will fail.
  2. If a previously registered handler did not impose constraints on S, and with_state was also not called: then S has no constraints and can indeed be (). Thus, even without calling with_state, the code can compile.
  3. If a previously registered handler imposed constraints on S, and with_state was subsequently called: with_state will reset the constraints on S, therefore S can be () and compile.
  4. As in the previous chain-call example: each call to with_state clears the previous constraints, and calling .route afterward may impose new constraints, yet as long as finally with_state is called or the last series of .route do not impose any constraints on S, then S can be () and the compilation passes.

Through this trick, Axum achieves static type checking for State, and supports not attaching State when it is not needed.

Note: This article was co-authored by @suikammd and myself.

Welcome to my other publishing channels