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

⚓ Rust    📅 2025-12-30    👤 surdeus    👁️ 6      

surdeus

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!

1 post - 1 participant

Read full topic

🏷️ Rust_feed