We built a Rust web framework around one idea: everything about an endpoint belongs together. Feedback wanted

Why We Built Hotaru: Rethinking Rust Web Framework Syntax

TL;DR: Hotaru is a Rust web framework with macro-based syntax that keeps URL, middleware, and handler in one block. If you're building web services in Rust and find attribute macros scattered, this might click for you. Looking for feedback on whether the endpoint!/middleware! syntax feels intuitive or hides too much.

Repo: GitHub - Field-of-Dreams-Studio/hotaru: Small, sweet, easy framework for full-stack Rust web applications supporing multiple & user-defined protocol


I came from Python. Flask, FastAPI, that world. When I moved to Rust, I was sold on the safety story—memory safety, no null pointer exceptions, the compiler catching bugs before runtime. That part delivered. (I do miss Python's auto type conversion though lol)

But when I started looking at web frameworks, I noticed a pattern I wasn't thrilled about:

#[get("/users/<id>")]
#[middleware::auth]
#[middleware::rate_limit(100)]
async fn get_user(...) -> impl Responder {

The attribute macro approach works, and plenty of people ship production apps with it. For me personally, having configuration scattered above the function felt similar to the decorator patterns I'd moved away from. I wanted something where I could see everything about an endpoint in one place.

So we tried a different approach.

The Hotaru Approach

We built Hotaru around one idea: everything about an endpoint belongs together. URL, middleware, config, handler—one block, one place to look.

endpoint! {
    APP.url("/users/<int:id>"),
    middleware = [.., auth_check, rate_limit],
    config = [HttpSafety::new().with_allowed_methods(vec![GET, POST])],

    pub get_user <HTTP> {
        let user_id = req.param("id").unwrap_or_default();
        let user = db.find_user(&user_id).await?;
        json_response(object!({
            id: user.id,
            name: user.name,
            email: user.email
        }))
    }
}

That's the full syntax. URL pattern (with typed params like <int:id>), middleware stack, security config, and handler body—all in one place. req.param("id") returns a value you can call .unwrap_or_default() or .string() on for the raw string. No separate registration step. The macro expands to standard async Rust at compile time.

Middleware Definition

Ok so here's where I think we really nailed something. Defining middleware in most frameworks involves implementing traits, wrapping services, dealing with futures that return futures. It's a lot.

In Hotaru:

middleware! {
    pub LogRequest <HTTP> {
        println!("[LOG] {} {}", req.method(), req.path());
        let start = std::time::Instant::now();

        let result = next(req).await;

        println!("[LOG] Completed in {:?}", start.elapsed());
        result
    }
}

That's it. You get req (the HttpContext), you call next(req).await to continue the chain, you can modify the result on the way back out. Want to short-circuit? Don't call next():

middleware! {
    pub AuthCheck <HTTP> {
        let token = req.headers().get("Authorization");

        if token.is_none() {
            req.response = json_response(object!({
                error: "unauthorized"
            })).status(StatusCode::UNAUTHORIZED);
            return req;
        }

        // Pass typed data downstream via locals
        req.locals.set("user_id", "user-123".to_string());
        next(req).await
    }
}

For passing data between middleware and handlers:

  • req.locals.set(key, value) / req.locals.get::<T>(key) — named key-value storage for strings, numbers, or any Clone type
  • req.params.set(value) / req.params.get::<T>() — type-keyed storage when you have one value per type (like a UserContext struct)

The .. Pattern

Here's something we borrowed from Rust's struct update syntax. In most frameworks, middleware composition is all-or-nothing or requires careful ordering in a builder chain.

// Global middleware on the app
pub static APP: SApp = Lazy::new(|| {
    App::new()
        .binding("127.0.0.1:3000")
        .append_middleware::<Logger>()
        .append_middleware::<Metrics>()
        .build()
});

// Just global middleware
endpoint! {
    APP.url("/health"),
    middleware = [..],
    pub health <HTTP> { text_response("ok") }
}

// Global + auth
endpoint! {
    APP.url("/api/users"),
    middleware = [.., auth_required],
    pub users <HTTP> { /* ... */ }
}

// Sandwich: timing runs first, then global, then cache check
endpoint! {
    APP.url("/api/cached"),
    middleware = [timing, .., cache_layer],
    pub cached <HTTP> { /* ... */ }
}

// Skip global entirely
endpoint! {
    APP.url("/raw"),
    middleware = [custom_only],
    pub raw <HTTP> { /* ... */ }
}

The .. expands to your global middleware. Put stuff before it, after it, or skip it. You see exactly what runs on each route just by looking at the endpoint definition.

One Port, Multiple Protocols

This started as an experiment and became central to the architecture. Hotaru can serve HTTP, WebSocket, and custom TCP protocols on the same port.

(We're actually working on wrapping this into a cleaner macro—new syntax coming soon)

pub static APP: SApp = Lazy::new(|| {
    App::new()
        .binding("127.0.0.1:3000")
        .handle(
            HandlerBuilder::new()
                .protocol(ProtocolBuilder::new(HTTP::server(HttpSafety::default())))
                .protocol(ProtocolBuilder::new(WebSocketProtocol::new()))
                .protocol(ProtocolBuilder::new(CustomProtocol::new()))
        )
        .build()
});

The framework inspects incoming bytes and routes to the correct handler. REST API, WebSocket, custom binary protocol—same port, shared state.

Handlers look the same regardless of protocol:

endpoint! {
    APP.url("/chat"),
    pub chat_http <HTTP> {
        html_response(include_str!("chat.html"))
    }
}

endpoint! {
    APP.url("/chat"),
    pub chat_ws <WebSocket> {
        // Same URL, different protocol
        ws.on_message(|msg| { /* ... */ }).await
    }
}

Akari: Our Helper Crate

We ship a lightweight utility crate called Akari that handles JSON and templating without pulling in serde. The object! macro builds JSON inline:

json_response(object!({
    status: "success",
    data: {
        users: [{id: 1, name: "Alice"}, {id: 2, name: "Bob"}],
        total: 2
    }
}))

No derive macros, no struct definitions for throwaway responses. You can still use serde if you want—it's not forbidden, just not required.

Early Performance Numbers

We ran some initial benchmarks to sanity-check that the macro approach doesn't add runtime overhead. These are early numbers on a single machine—take them as directional, not definitive.

Framework Requests/sec (JSON) Relative
Hotaru 173,254 100%
Rocket 171,904 99.2%
Actix-web 149,244 86.1%
Axum 148,934 86.0%

Tested on Apple M-series, single-threaded, simple JSON response. We'll publish full methodology and code soon.

The macros expand to the same async functions you'd write by hand. The compiler sees normal Rust after expansion, optimizes accordingly.

What's Still Rough

Look, we're at v0.7. The API is stabilizing but not frozen. Docs are getting better but have gaps. The ecosystem is tiny compared to established frameworks.

What we are is an opinionated take on syntax. We think handlers should read like what they do.

Try It

use hotaru::prelude::*;
use hotaru::http::*;

pub static APP: SApp = Lazy::new(|| {
    App::new().binding("127.0.0.1:3000").build()
});

endpoint! {
    APP.url("/"),
    pub index <HTTP> {
        text_response("Hello from Hotaru")
    }
}

#[tokio::main]
async fn main() {
    APP.clone().run().await;
}

Repository: GitHub - Field-of-Dreams-Studio/hotaru: Small, sweet, easy framework for full-stack Rust web applications supporing multiple & user-defined protocol

Video Tutorial: Building your first APP using the new Hotaru Web Framework!


Curious what people think. Does the macro syntax feel intuitive or does it hide too much? Is the .. middleware pattern clever or confusing? We're betting that a little macro magic trades well for less boilerplate.

Disclosure: I'm one of the authors of Hotaru. Posting here for feedback from the community.

Fell free to discuss/use/contribute for our project!

3 Likes

For macro based syntax, I was hoping for a more uniform and consistent JSON-like format. It is only my expectation though. Looking at the APP.url("/") for the path, middleware = ... for middleware, and almost function-like for the handler are too much to remember for me. I imagine will look on docs or example often esp when revisiting the project.

Honestly, yes.

Like this one, I dont know where req and db come from. Also what are available, what not. As a programmer, I prefer a more explicit references.

The syntax almost doesn't resemble function in Rust. Also the <HTTP> part may as well be hidden (as default).

Also, macro is sensitive to build time. I hope you measure and optimize build time performance too.

That's some of my opinions. Cheers :victory_hand:

2 Likes

I like its declarative syntax. Just define what you want, and add a little of code and the job done. Certainly, it's only way software should be developed.

Just curious, what kind of tool you used to get performance numbers?

Database integration looks awesome. It is exactly how it should be. Just describe objects you want to store, and it automatically generates a SQL schema. And then you just describe queries, as find_user(), no knowledge of SQL at all.

TBH, my biggest thought is that I don't understand how this is materially different from putting these things in attributes. That seem just as "belongs together", to me.

And I worry that that pub get_user <HTTP> { is just going to make all my tooling work worse since it's no longer a "normal" function.

3 Likes

Thanks for your reply!
For the newest version, we only keep req (since we really build multi-protocol), and sorry I forgot I removed that concept. We need to use req.params:: for the new version if a db middleware is added.
The Req quite different from other framework. The type is determined during compile time which refers back to how the protocol (<HTTP> in our case) defines its context. Since we are supporting multiple protocols we cannot make an enum saying that the type of it is xxx (a certain one) so we decide to make it comes out of void.
The only 2 things that comes from no where in our framework is req and next, where next is in the middleware chain config.

The biggest challenge is to automatically register the endpoint to appropriate endpoints (which most frameworks done manually) and also, as you say, make it looks belongs together, as one code block. One more thing forces me to do this decision is that to support multiple protocol (<HTTP> thing), as we introduce the concept of pseudo functions.

We call those pub xxx <xxx> {...} thing pseudo functions in our framework. Those function automatically put req and next with appropriate type (referring to the protocol type) in and also register it in the correct endpoint. Actually, it is still a normal function. You can run this function if you plug in req and next parameter according to the protocol you use. But it will be too much verbose.

I've seen the source and you did do a lot in these framework. Keep it up :slightly_smiling_face:

Just wanted to share what I had in mind:

static app = ...;
endpoint!{
    app,
    url: "/user/<id:int>",
    config: {},
    middlewares: [ custom_mw1 ],
    pub fn echo(req: Req<HTTP>) {
        response
    }
}
2 Likes

Actually, I that's a good design. I will enable users to explicitly write in Rust function style, or the simplified style - what about that?
Maybe I will argue for 2 things in the design.

  1. I think we don't need to explicitly say the APP since UrlPattern which is created by APP.url("/users/<int:id>") is directly on the APP's Url Tree. Where we provide another constructor called APP.lit_url(...) as for static literal urls.
  2. I still think [] will be better for config since config is a list of data of any type (maybe you think it must be HttpSafety type since we only demonstrated that in examples)
    So maybe the refined design will be
endpoint!{
    url: APP.url("/users/<int:id>"), 
    config: [ User {1, 2}, "This is a static String can be read" ],
    middlewares: [ custom_mw1, .. ], // .. means the APP's middleware 

    /// Doc comment of the endpoint 
    pub fn echo(req: Req<HTTP>) {
        response
    }
}

where for simplicity url keyword can be omitted, the fn keyword can be omitted, the function name can be changed into _ if anonymous wanted. Do you think this design makes things clearer?

2 Likes

Oh that's more convenient. Also, using From<&str> it can even be simpler e.g. url: "/user/<id:int>".into().

I'm still waiting for Rust to have unique ergonomic web framework, so something like this project will push the ecosystem.

Actually, I would like to ask whether it will be a good idea if I change the function part into a closure? Since that will make the syntax shorter or it would be better to use the standard function style

Agreed, that would be shorter. Moreover, I don't see the function name getting referenced. So that's a good idea.

Actually I am considering whether it would be possible to keep Middleware, Endpoint and also we are planning to build a syntax for send outbound requests for multiple protocols. I am considering how we are able to keep all those things symmetric

Hotaru Update: Two Endpoint/Middleware Styles (Same Semantics)

We now accept both the concise DSL form and a Rusty fn form with an explicit request name. They expand to the same async code. Looking for feedback on whether this helps or just adds inconsistency.

Endpoint (two styles)

endpoint! {
    APP.url("/"),
    pub index <HTTP> {
        text_response("Hello from Hotaru")
    }
}

endpoint! {
    APP.url("/new_syntax/<arg>"),
    middleware: [..], // : and = are both acceptable 
    config = ["ConfigString"],

    pub fn new_syntax_endpoint(ctx: HTTP) {
        let arg = ctx.pattern("arg").unwrap_or_default();
        text_response(format!("New syntax endpoint called with arg: {}", arg))
    }
}

Middleware (two styles)

middleware! {
    pub Logger <HTTP> {
        println!("[LOG] {} {}", req.method(), req.path());
        next(req).await
    }
}

middleware! {
    pub fn Logger(req: HTTP) {
        println!("[LOG] {} {}", req.method(), req.path());
        next(req).await
    }
}

Both forms compile down to the same generated async functions. The fn form is just a style indicator and lets you name the request variable.

Do you think this becomes better?

When building an intuitive framework, IMO, basically we need to understand our target / potential audience (e.g. people learning Rust or web) and minimize things they need to remember (e.g. JSON-like, TOML-like). Of course, that doesn't guarantee adoption, but will potentially bring more user that can relates.

With that regard, the function syntax is better for me. But the syntax variation doesn't. The APP.url also feels out of place.

Also rephrasing scottmcm comment,

To Rust user, attribute is a consistent syntax and very Rust-y. They will not switch to wildly different syntax without reason. You need to set some value / core idea that guide your syntax.

As an example, when I pick JSON-like, I can make the function JavaScript-styled

endpoint! {
    middlewares: [],
    action(req) {
        // ...
    }
}

So that JavaScript user relates and eager to try / use.

ExpressJs also a popular one:

route! {
    app.get("/users/:id", [ middlewaresHere ], (req) => {
        // ...
    });
}

Just a nitpick, I guess this will allocate on the heap once for each endpoint:

vec![GET, POST]

If you change your method signature to accept this, it would allocate it at compile time inside the binary (probably only once per combination.)

&[GET, POST]

Or make it a direct configurable:

    methods = [GET, POST],

I'll take the suggestion to make it into &[GET, POST] for optimising. However, since we need to support multi-protocol, which is one of the core feature of our framework, we are not able to make methods as one of the directly configureable field since not all framework have that. So we can only put into config field which I think it is free enough so that any protocol can use that field

I find that there is a lot of dyn thing around the middleware-related source code. The middleware-pattern is much like some situations I met before, which I figured out how to use generics to conduct static dispatch instead of dynamic vtable lookup (which would lose a lot of optimizing opportunities by function inlining). My approach is described in lloydmeta/frunk#243, and I'm not sure if this could fit in your situation, just put it up for your reference.

1 Like

Very well presented. Nicely laid out ideas and differences with other frameworks. Was pleasant to read.

Following are my first impressions to answer your call, no filter.

First thing reading on macro syntax, i did not understand why use

endpoint! {
    APP.url("/users/<int:id>"),
    // .. etc.

and not

endpoint! {
    url ="/users/<int:id>",
    // .. etc.

This is a macro magic anyways so just url seems cleaner, since we must have url always. Maybe even it could be minimized down even more, like:

endpoint! {
    "/users/<int:id>",
    // .. etc.

Following further to reduce boilerplate, why do we need to write pub keyword and not just:

endpoint! {
    // .. 
    get_user <HTTP> {
    // .. etc.
}

And in place of pub, since most probably it is pub anyways, i would prefer to use fn instead. Or why does a function even need a name? Endpoint kind of already defines this function and most probably it will not be reusable from anywhere else. So it could be boiled down to just protocol like:

<HTTP> {
    // .. function body
}

or to be concise, maybe it would be better to even write like:

endpoint! {
    // .. etc.
    protocol = "HTTP"
    endpoint_logic = {
    }
}

Mixed syntax is confusing. It is easier to understand if macro is like a function or like match statement.

When thinking more about this syntax, in a way i kind of don't see so big difference from decorator pattern, just that the location has changed where configuration/decoration is placed, instead of writing decorator on top of function, there is like a match arm that matches given customization. It could be possible to write decorators i.e.

#[endpoint ("/users/<id>", middleware = ".., auth_check, rate_limit", config = ""]
async fn get_user(...) -> impl Responder {

just that maybe nobody does it that way currently.

Decorator syntax to me seems cleaner, plus is - one indentation less. The biggest drawback for macros is when something does not expand as expected, then getting crytptic errors like "trait bounds not satisfied" from there and there is hard to get over. Line which causes the problem is hard to track, etc. Although there are somewhat similar problem with Axum as well.

Other than that, syntax seems quiet clean and even not knowing the framework one can catch up fast with what is meant. The middleware syntax with .. i did not get what it means at first read, but when explained it seemed clear. I usually prefer things to be explicit that even a person that knows nothing gets that .. will be expanded to globaly configured list of values.

For middleware syntax to me it was not intuitive that in endpoint's middleware list snake_case is used, but then within middleware macro PascalCase was used. I would prefer snake_case in both places.

I like that it is possible to return json_response in-place that there is no need to define struct + serialization. But the biggest advantage for defined struct is that it can be reused in multiple locations and ensures that in all places same objects will have same definition of fields. But to write endpoint with as little code as possible, Hotrau's syntax seems neater.

Writing a middleware seems clean. Having req variable always available and with determined name i think saves time. Since it most of the times is needed anyways.

Reading across comments and JSON alike syntax ideas... I would prefer that = sign is used as is. It clearly separates each option like match arms does; most macros i have used have similar pattern. JSON is not human readable, well, you can, but it is not beautiful.

Framework seems nice. Will try to play with it if i have some spare time.

1 Like