FastAPI-like developer experience for Rust. Zero-config OpenAPI 3.1 generation for Axum.
// That's it. Swagger UI at /docs, OpenAPI at openapi.json let app = vespera!(openapi = "openapi.json", docs_url = "/docs");| Feature | Vespera | Manual Approach |
|---|---|---|
| Route registration | Automatic (file-based) | Manual Router::new().route(...) |
| OpenAPI spec | Generated at compile time | Hand-written or runtime generation |
| Schema extraction | From Rust types | Manual JSON Schema |
| Swagger UI | Built-in | Separate setup |
| Type safety | Compile-time verified | Runtime errors |
[dependencies] vespera = "0.1" axum = "0.8" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] }src/ ├── main.rs └── routes/ └── users.rs src/routes/users.rs:
use axum::{Json, Path}; use serde::{Deserialize, Serialize}; use vespera::Schema; #[derive(Serialize, Deserialize, Schema)] pub struct User { pub id: u32, pub name: String, } /// Get user by ID #[vespera::route(get, path = "/{id}", tags = ["users"])] pub async fn get_user(Path(id): Path<u32>) -> Json<User> { Json(User { id, name: "Alice".into() }) } /// Create a new user #[vespera::route(post, tags = ["users"])] pub async fn create_user(Json(user): Json<User>) -> Json<User> { Json(user) }src/main.rs:
use vespera::vespera; #[tokio::main] async fn main() { let app = vespera!( openapi = "openapi.json", title = "My API", docs_url = "/docs" ); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); println!("Swagger UI: http://localhost:3000/docs"); axum::serve(listener, app).await.unwrap(); }cargo run # Open http://localhost:3000/docsFile structure maps to URL paths automatically:
src/routes/ ├── mod.rs → / ├── users.rs → /users ├── posts.rs → /posts └── admin/ ├── mod.rs → /admin └── stats.rs → /admin/stats Handlers must be pub async fn with the #[vespera::route] attribute:
// GET /users (default method) #[vespera::route] pub async fn list_users() -> Json<Vec<User>> { ... } // POST /users #[vespera::route(post)] pub async fn create_user(Json(user): Json<User>) -> Json<User> { ... } // GET /users/{id} #[vespera::route(get, path = "/{id}")] pub async fn get_user(Path(id): Path<u32>) -> Json<User> { ... } // Full options #[vespera::route(put, path = "/{id}", tags = ["users"], description = "Update user")] pub async fn update_user(...) -> ... { ... }Derive Schema on types used in request/response bodies:
#[derive(Serialize, Deserialize, vespera::Schema)] #[serde(rename_all = "camelCase")] // Serde attributes are respected pub struct CreateUserRequest { pub user_name: String, // → "userName" in OpenAPI pub email: String, #[serde(default)] pub bio: Option<String>, // Optional field }| Extractor | OpenAPI Mapping |
|---|---|
Path<T> | Path parameters |
Query<T> | Query parameters |
Json<T> | Request body (application/json) |
Form<T> | Request body (application/x-www-form-urlencoded) |
TypedMultipart<T> | Request body (multipart/form-data) — typed with schema |
Multipart | Request body (multipart/form-data) — untyped, generic object |
TypedHeader<T> | Header parameters |
State<T> | Ignored (internal) |
Upload files using vespera's built-in TypedMultipart extractor:
use vespera::multipart::{FieldData, TypedMultipart}; use vespera::{Multipart, Schema}; use tempfile::NamedTempFile; #[derive(Multipart, Schema)] pub struct CreateUploadRequest { pub name: String, #[form_data(limit = "10MiB")] pub file: Option<FieldData<NamedTempFile>>, } #[vespera::route(post, tags = ["uploads"])] pub async fn create_upload( TypedMultipart(req): TypedMultipart<CreateUploadRequest>, ) -> Json<UploadResponse> { ... }Vespera automatically generates multipart/form-data content type in OpenAPI, and maps FieldData<NamedTempFile> to { "type": "string", "format": "binary" }.
For dynamic multipart handling where the fields aren't known at compile time, use axum's built-in Multipart extractor:
use axum::extract::Multipart; #[vespera::route(post, tags = ["uploads"])] pub async fn upload(mut multipart: Multipart) -> Json<UploadResponse> { while let Some(field) = multipart.next_field().await.unwrap() { let name = field.name().unwrap_or("unknown").to_string(); let data = field.bytes().await.unwrap(); // Process each field dynamically... } Json(UploadResponse { success: true }) }This generates a multipart/form-data request body with a generic { "type": "object" } schema in OpenAPI, since the fields are not statically known.
#[derive(Serialize, Schema)] pub struct ApiError { pub message: String, } #[vespera::route(get, path = "/{id}")] pub async fn get_user(Path(id): Path<u32>) -> Result<Json<User>, (StatusCode, Json<ApiError>)> { if id == 0 { return Err((StatusCode::NOT_FOUND, Json(ApiError { message: "Not found".into() }))); } Ok(Json(User { id, name: "Alice".into() })) }let app = vespera!( dir = "routes", // Route folder (default: "routes") openapi = "openapi.json", // Output path (writes file at compile time) title = "My API", // OpenAPI info.title version = "1.0.0", // OpenAPI info.version (default: CARGO_PKG_VERSION) docs_url = "/docs", // Swagger UI endpoint redoc_url = "/redoc", // ReDoc endpoint servers = [ // OpenAPI servers { url = "https://api.example.com", description = "Production" }, { url = "http://localhost:3000", description = "Development" } ], merge = [crate1::App1, crate2::App2] // Merge child vespera apps );Export a vespera app for merging into other apps:
// Basic usage (scans "routes" folder by default) vespera::export_app!(MyApp); // Custom directory vespera::export_app!(MyApp, dir = "api");Generates a struct with:
MyApp::OPENAPI_SPEC: &'static str- The OpenAPI JSON specMyApp::router() -> Router- Function returning the Axum router
All parameters support environment variable fallbacks:
| Parameter | Environment Variable |
|---|---|
dir | VESPERA_DIR |
openapi | VESPERA_OPENAPI |
title | VESPERA_TITLE |
version | VESPERA_VERSION |
docs_url | VESPERA_DOCS_URL |
redoc_url | VESPERA_REDOC_URL |
servers | VESPERA_SERVER_URL + VESPERA_SERVER_DESCRIPTION |
Priority: Macro parameter > Environment variable > Default
Generate request/response types from existing structs. Perfect for creating API types from database models.
use vespera::schema_type; // Pick specific fields only schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]); // Omit specific fields schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); // Add new fields schema_type!(UpdateUserRequest from crate::models::user::Model, pick = ["name"], add = [("id": i32)]);When the model is in the same file, you can use a simple name with name parameter:
// In src/models/user.rs pub struct Model { pub id: i32, pub name: String, pub email: String, } // Simple `Model` path works when using `name` parameter vespera::schema_type!(Schema from Model, name = "UserSchema");The macro infers the module path from the file location, so relation types like HasOne<super::user::Entity> are resolved correctly.
Reference structs from other files using full module paths:
// In src/routes/users.rs - references src/models/user.rs schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]);When add is NOT used, a From impl is automatically generated:
schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); // Now you can do: let model: Model = db.find_user(id).await?; Json(model.into()) // Automatic conversion!Use partial to make fields optional for PATCH-style updates:
// All fields become Option<T> schema_type!(UserPatch from User, partial); // Only specific fields become Option<T> schema_type!(UserPatch from User, partial = ["name", "email"]);Apply serde rename_all strategy:
// Convert field names to camelCase in JSON schema_type!(UserDTO from User, rename_all = "camelCase"); // Available: "camelCase", "snake_case", "PascalCase", "SCREAMING_SNAKE_CASE", etc.Automatically omit fields that have database-level defaults — perfect for create DTOs where the database handles id, created_at, etc.:
#[derive(DeriveEntityModel)] #[sea_orm(table_name = "posts")] pub struct Model { #[sea_orm(primary_key)] // ← has default (auto-increment) pub id: i32, pub title: String, pub content: String, #[sea_orm(default_value = "NOW()")] // ← has default (SQL function) pub created_at: DateTimeWithTimeZone, } // Omits `id` (primary_key) and `created_at` (default_value) automatically schema_type!(CreatePostRequest from crate::models::post::Model, omit_default); // Generated struct only has: title, contentomit_default detects fields with:
#[sea_orm(primary_key)]— auto-increment / generated IDs#[sea_orm(default_value = "...")]— SQL defaults likeNOW(),gen_random_uuid(), literals
Can be combined with other parameters:
// omit_default + add extra fields schema_type!(CreateItemRequest from Model, omit_default, add = [("tags": Vec<String>)]);Fields with database defaults automatically get default values in the generated OpenAPI schema:
| SeaORM Attribute | OpenAPI Default |
|---|---|
primary_key (Uuid) | "00000000-0000-0000-0000-000000000000" |
primary_key (i32/i64) | 0 |
default_value = "NOW()" | "1970-01-01T00:00:00+00:00" |
default_value = "gen_random_uuid()" | "00000000-0000-0000-0000-000000000000" |
default_value = "true" | true (literal passthrough) |
Note:
requiredis determined solely by nullability (Option<T>). Fields with defaults are stillrequiredunless they areOption<T>.
schema_type! has first-class support for SeaORM models with relations:
use sea_orm::entity::prelude::*; #[derive(Clone, Debug, DeriveEntityModel)] #[sea_orm(table_name = "memos")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub title: String, pub user_id: i32, pub user: BelongsTo<super::user::Entity>, // → Option<Box<UserSchema>> pub comments: HasMany<super::comment::Entity>, // → Vec<CommentSchema> } // Generates Schema with proper relation types vespera::schema_type!(Schema from Model, name = "MemoSchema");Relation Type Conversions:
| SeaORM Type | Generated Schema Type |
|---|---|
HasOne<Entity> | Box<Schema> or Option<Box<Schema>> |
BelongsTo<Entity> | Option<Box<Schema>> |
HasMany<Entity> | Vec<Schema> |
DateTimeWithTimeZone | chrono::DateTime<FixedOffset> |
Circular Reference Handling: When schemas reference each other (e.g., User ↔ Memo), the macro automatically detects and handles circular references by inlining fields to prevent infinite recursion.
Generate Multipart structs from existing types using the multipart keyword:
#[derive(vespera::Multipart, vespera::Schema)] pub struct CreateUploadRequest { pub name: String, #[form_data(limit = "10MiB")] pub file: Option<FieldData<NamedTempFile>>, pub description: Option<String>, } // Generates a Multipart struct (no serde derives), all fields Optional schema_type!(PatchUploadRequest from CreateUploadRequest, multipart, partial, omit = ["file"]);When multipart is enabled:
- Derives
Multipartinstead ofSerialize/Deserialize - Suppresses
#[serde(...)]attributes (multipart parsing is not serde-based) - Preserves
#[form_data(...)]attributes from source struct - Skips SeaORM relation fields (nested objects can't be represented in multipart forms)
- Does not generate
Fromimpl
| Parameter | Description |
|---|---|
pick | Include only specified fields |
omit | Exclude specified fields |
rename | Rename fields: rename = [("old", "new")] |
add | Add new fields (disables auto From impl) |
clone | Control Clone derive (default: true) |
partial | Make fields optional: partial or partial = ["field1"] |
name | Custom OpenAPI schema name: name = "UserSchema" |
rename_all | Serde rename strategy: rename_all = "camelCase" |
ignore | Skip Schema derive (bare keyword, no value) |
multipart | Derive Multipart instead of serde (bare keyword) |
omit_default | Auto-omit fields with DB defaults: primary_key, default_value (bare keyword) |
Get a Schema value at runtime with optional field filtering. Useful for programmatic schema access.
use vespera::{Schema, schema}; #[derive(Schema)] pub struct User { pub id: i32, pub name: String, pub password: String, } // Full schema let full: vespera::schema::Schema = schema!(User); // With fields omitted let safe: vespera::schema::Schema = schema!(User, omit = ["password"]); // With only specified fields let summary: vespera::schema::Schema = schema!(User, pick = ["id", "name"]);Note: For creating request/response types, use
schema_type!instead - it generates actual struct types withFromimpl.
Schedule background tasks with #[vespera::cron]. Uses tokio-cron-scheduler under the hood.
[dependencies] vespera = { version = "0.1", features = ["cron"] }Place #[vespera::cron("...")] on any pub async fn with zero parameters. The function can live anywhere in your project — no special directory required.
// src/cron/cleanup.rs, src/tasks.rs, or even src/routes/users.rs — anywhere works #[vespera::cron("1/10 * * * * *")] pub async fn cleanup_sessions() { println!("Running cleanup every 10 seconds"); } #[vespera::cron("0 0 * * * *")] pub async fn hourly_report() { println!("Running hourly report"); }#[cron("...")]registers the job at compile time (like#[route])vespera!()auto-discovers all registered cron jobs — no extra parameters needed- A background scheduler spawns via
tokio::spawnwhen the app starts
// No cron-specific config — just works let app = vespera!(docs_url = "/docs");Uses 6-field cron expressions (sec min hour day month weekday):
| Expression | Schedule |
|---|---|
0 */5 * * * * | Every 5 minutes |
0 0 * * * * | Every hour |
0 0 0 * * * | Daily at midnight |
1/10 * * * * * | Every 10 seconds |
0 30 9 * * Mon-Fri | Weekdays at 9:30 AM |
- Functions must be
pub async fn - Functions must take no parameters (no
State, no extractors) - The
cronfeature must be enabled
let app = vespera!(docs_url = "/docs") .with_state(AppState { db: pool });let app = vespera!(docs_url = "/docs") .layer(CorsLayer::permissive()) .layer(TraceLayer::new_for_http());let app = vespera!( openapi = ["openapi.json", "docs/api-spec.json"] );// Scans src/api/ instead of src/routes/ let app = vespera!("api"); // Or explicitly let app = vespera!(dir = "api");Combine routes and OpenAPI specs from multiple vespera apps at compile time:
Child app (e.g., third crate):
// src/lib.rs mod routes; // Export app for merging (dir defaults to "routes") vespera::export_app!(ThirdApp); // Or with custom directory // vespera::export_app!(ThirdApp, dir = "api");Parent app:
// src/main.rs use vespera::vespera; let app = vespera!( openapi = "openapi.json", docs_url = "/docs", merge = [third::ThirdApp] // Merges router AND OpenAPI spec ) .with_state(app_state);This automatically:
- Merges all routes from child apps into the parent router
- Combines OpenAPI specs (paths, schemas, tags) into a single spec
- Makes Swagger UI show all routes from all apps
| Rust Type | OpenAPI Schema |
|---|---|
String, &str | string |
i32, u64, etc. | integer |
f32, f64 | number |
bool | boolean |
Vec<T> | array with items |
Option<T> | nullable T |
HashMap<K, V> | object with additionalProperties |
BTreeSet<T>, HashSet<T> | array with uniqueItems: true |
Uuid | string with format: uuid |
Decimal | string with format: decimal |
NaiveDate | string with format: date |
NaiveTime | string with format: time |
DateTime, DateTimeWithTimeZone | string with format: date-time |
FieldData<NamedTempFile> | string with format: binary |
| Custom struct | $ref to components/schemas |
vespera/ ├── crates/ │ ├── vespera/ # Main crate - re-exports everything │ ├── vespera_core/ # OpenAPI types and abstractions │ └── vespera_macro/ # Proc-macros (compile-time magic) └── examples/ └── axum-example/ # Complete example application git clone https://github.com/dev-five-git/vespera.git cd vespera # Build & test cargo build cargo test --workspace # Run example cd examples/axum-example cargo run # → http://localhost:3000/docsSee SKILL.md for development guidelines and architecture details.
- Vespera: Zero-config, file-based routing, compile-time generation
- utoipa: Manual annotations, more control, works with any router
- Vespera: Automatic discovery, built-in Swagger UI
- aide: More flexible, supports multiple doc formats
- Vespera: Axum-first, modern OpenAPI 3.1
- paperclip: Actix-focused, OpenAPI 2.0/3.0
Apache-2.0
Inspired by FastAPI's developer experience and Next.js's file-based routing.