3 releases (breaking)
Uses new Rust 2024
| new 0.2.1 | Feb 12, 2026 |
|---|---|
| 0.2.0 |
|
| 0.1.1 | Feb 12, 2026 |
| 0.0.2 | Feb 10, 2026 |
#476 in Procedural macros
Used in astrea
90KB
1K
SLoC
Astrea
A file-system based routing framework for Axum.
Inspired by Nitro and H3.
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 extractors —
get_param(),get_query_param(),get_body()— just call a function - 🧅 Scoped middleware —
_middleware.rsfiles 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 onastrea
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 indexis 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 specGET /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 pageGET /api/users— list usersPOST /api/users— create userGET /api/users/:id— get user by IDPUT /api/users/:id— update userDELETE /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