#router #axum #axum-router #filesystem #web

macro astrea-macro

Procedural macros for Astrea - file-system based routing framework

3 releases (breaking)

Uses new Rust 2024

new 0.2.1 Feb 12, 2026
0.2.0 Feb 12, 2026
0.1.1 Feb 12, 2026
0.0.2 Feb 10, 2026

#476 in Procedural macros


Used in astrea

MIT license

90KB
1K SLoC

Astrea

A file-system based routing framework for Axum.
Inspired by Nitro and H3.

crates.io docs.rs MIT License

简体中文


What Is Astrea?

Astrea turns your file structure into API routes — at compile time, with zero runtime cost. Drop a .rs file into the src/routes/ folder, and it becomes an HTTP endpoint. No manual route registration, no build.rs, no boilerplate.

Every handler looks the same:

#[route]
async fn handler(event: Event) -> Result<Response> {
    // your logic here
}

That's it. No complex extractor signatures. No learning curve for each parameter type.

Features

  • 📁 File-based routing — file name = route path, generated at compile time
  • 🎯 Unified handler signature — every handler is async fn(Event) -> Result<Response>
  • 🔧 Simple extractorsget_param(), get_query_param(), get_body() — just call a function
  • 🧅 Scoped middleware_middleware.rs files with inherit (extend) or replace (override) modes
  • 📝 OpenAPI auto-gen — optional Swagger UI + OpenAPI 3.0 spec from your code (feature flag openapi)
  • 🔄 Axum compatible — works with all existing Axum middleware and the Tower ecosystem
  • 📦 Zero extra deps — re-exports axum, tokio, serde, tower, etc. — just depend on astrea

Quick Start

1. Create a new project

cargo new my-api
cd my-api

2. Add Astrea

cargo add astrea

Or in Cargo.toml:

[package]
name = "my-api"
edition = "2024"

[dependencies]
astrea = "0.0.1"

Note: Astrea requires Rust edition 2024 (Rust ≥ 1.85).

3. Create your route files

my-api/
├── src/
│   ├── main.rs
│   └── routes/
│       ├── index.get.rs          # GET /
│       └── users/
│           ├── index.get.rs      # GET /users
│           ├── index.post.rs     # POST /users
│           └── [id].get.rs       # GET /users/:id

src/routes/index.get.rs

use astrea::prelude::*;

#[route]
pub async fn handler(event: Event) -> Result<Response> {
    json(json!({ "message": "Hello, World!" }))
}

src/routes/users/[id].get.rs

use astrea::prelude::*;

#[route]
pub async fn handler(event: Event) -> Result<Response> {
    let id = get_param_required(&event, "id")?;
    json(json!({ "user_id": id }))
}

4. Write main.rs

mod routes {
    astrea::generate_routes!();
}

#[tokio::main]
async fn main() {
    let app = routes::create_router();
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Listening on http://localhost:3000");
    astrea::serve(listener, app).await.unwrap();
}

5. Run

cargo run

Done. You will see a beautiful startup log:

┌─────────────────────────────────────────────────────────────────────┐
│                        🚀 Astrea Router                            │
├────────┬──────────────────────────────┬─────────────────────────────┤
│ Method │ Path                         │ Middleware                  │
├────────┼──────────────────────────────┼─────────────────────────────┤
│ GET    │ /                            │ (none)                      │
│ GET    │ /users                       │ (none)                      │
│ POST   │ /users                       │ (none)                      │
│ GET    │ /users/:id                   │ (none)                      │
└────────┴──────────────────────────────┴─────────────────────────────┘
✅ 4 route(s), 0 middleware scope(s) loaded

And GET http://localhost:3000/ returns {"message":"Hello, World!"}.


Route File Naming Convention

File name Route
src/routes/index.get.rs GET /
src/routes/users.get.rs GET /users
src/routes/users/index.post.rs POST /users
src/routes/users/[id].get.rs GET /users/:id
src/routes/users/[id].delete.rs DELETE /users/:id
src/routes/posts/[...slug].get.rs GET /posts/*slug (catch-all)

Rules:

  • File name format: <name>.<method>.rs
  • index is a special name — it maps to the directory itself (no extra path segment)
  • [param] → dynamic path parameter
  • [...param] → catch-all parameter (matches everything after)

Extracting Request Data

Astrea replaces complex Axum extractor signatures with simple function calls:

#[route]
pub async fn handler(event: Event, bytes: Bytes) -> Result<Response> {
    // Path parameters: /users/:id
    let id = get_param(&event, "id");                   // Option<&str>
    let id = get_param_required(&event, "id")?;          // &str (or 400 error)

    // Query parameters: /search?q=rust&page=2
    let q = get_query_param(&event, "q");                // Option<String>
    let all_query = get_query(&event);                   // &HashMap<String, String>

    // Request body (JSON)
    let body: MyStruct = get_body(&event, &bytes)?;      // deserialized struct

    // Headers
    let auth = get_header(&event, "authorization");      // Option<String>

    // Metadata
    let method = get_method(&event);                     // &Method
    let path = get_path(&event);                         // &str

    // Application state
    let db = get_state::<DatabasePool>(&event)?;         // your custom state

    json(json!({ "ok": true }))
}

Response Helpers

// JSON (application/json)
json(json!({ "key": "value" }))?

// Plain text (text/plain)
text("Hello!")

// HTML (text/html)
html("<h1>Hello</h1>")

// Redirect (302 Found)
redirect("/login")?

// No Content (204)
no_content()

// Raw bytes
bytes(vec![0x89, 0x50, 0x4E, 0x47]).content_type("image/png")

// Streaming
stream(Body::from_stream(my_stream))

All responses support chaining:

json(data)?
    .status(StatusCode::CREATED)
    .header("X-Request-Id", "abc123")

Error Handling

Return errors naturally — they become proper HTTP responses automatically:

#[route]
pub async fn handler(event: Event) -> Result<Response> {
    let id = get_param_required(&event, "id")?;       // 400 if missing

    if id == "0" {
        return Err(RouteError::not_found("User not found"));  // 404
    }

    // Third-party errors auto-convert to 500 via anyhow
    let data = some_fallible_operation()?;

    json(data)
}

Built-in error variants:

Method Status Code
RouteError::bad_request(msg) 400
RouteError::unauthorized(msg) 401
RouteError::forbidden(msg) 403
RouteError::not_found(msg) 404
RouteError::conflict(msg) 409
RouteError::validation(msg) 422
RouteError::rate_limit(msg) 429
RouteError::custom(StatusCode, msg) any
? on any anyhow-compatible error 500

All errors are returned as JSON: {"error": "...", "status": 404}.


Middleware

Create _middleware.rs files anywhere in the src/routes/ directory. They scope to the folder they live in + all subfolders.

src/routes/
├── _middleware.rs            # applies to ALL routes
├── api/
│   ├── _middleware.rs        # applies to /api/* (stacks on root)
│   ├── users.get.rs          # ← root + api middleware
│   └── public/
│       ├── _middleware.rs    # OVERRIDES parent middleware
│       └── health.get.rs    # ← public middleware only

Extend mode (default) — stack on parent

// src/routes/_middleware.rs
use astrea::middleware::*;

pub fn middleware() -> Middleware {
    Middleware::new()
        .wrap(|router| {
            router
                .layer(tower_http::trace::TraceLayer::new_for_http())
                .layer(tower_http::cors::CorsLayer::permissive())
        })
}

Override mode — replace parent middleware

// src/routes/api/public/_middleware.rs
use astrea::middleware::*;

pub fn middleware() -> Middleware {
    Middleware::override_parent()
        .wrap(|router| {
            router.layer(tower::limit::ConcurrencyLimitLayer::new(100))
        })
}

OpenAPI (Optional)

Enable the openapi feature to get automatic API documentation:

[dependencies]
astrea = { version = "0.0.1", features = ["openapi"] }

Then merge the OpenAPI router:

let app = routes::create_router()
    .merge(astrea::openapi::router("My API", "1.0.0"));

This gives you:

  • GET /openapi.json — the OpenAPI 3.0 spec
  • GET /swagger — Swagger UI

Application State

Share state across handlers (database pools, config, etc.):

#[derive(Clone)]
struct AppState {
    db: DatabasePool,
}

// In handler:
#[route]
pub async fn handler(event: Event) -> Result<Response> {
    let state = get_state::<AppState>(&event)?;
    // use state.db ...
}

Full Example

my-api/
├── Cargo.toml
└── src/
    ├── main.rs
    └── routes/
        ├── _middleware.rs
        ├── index.get.rs
        └── api/
            ├── _middleware.rs
            ├── users.get.rs
            ├── users.post.rs
            └── users/
                ├── [id].get.rs
                ├── [id].put.rs
                └── [id].delete.rs

This generates:

  • GET / — root page
  • GET /api/users — list users
  • POST /api/users — create user
  • GET /api/users/:id — get user by ID
  • PUT /api/users/:id — update user
  • DELETE /api/users/:id — delete user

Root middleware → all routes. API middleware → /api/* routes.


Why Astrea?

Astrea Plain Axum
Route definition Drop a file Manual .route() calls
Handler signature Always (Event) -> Result<Response> Varies per extractor combo
Parameter access get_param(&event, "id") Path(id): Path<String>
Error handling Built-in JSON errors DIY
Middleware File-based scoping Manual nesting
OpenAPI Auto-generated Manual or third-party

Minimum Supported Rust Version

Rust 1.85 or later (edition 2024).

License

MIT © TNXG (Asahi Shiori)

Dependencies

~140–560KB
~13K SLoC