Skip to content

dev-five-git/vespera

Repository files navigation

Vespera

FastAPI-like developer experience for Rust. Zero-config OpenAPI 3.1 generation for Axum.

Crates.io Documentation License CI Codecov

// That's it. Swagger UI at /docs, OpenAPI at openapi.json let app = vespera!(openapi = "openapi.json", docs_url = "/docs");

Why Vespera?

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

Quick Start

1. Add Dependencies

[dependencies] vespera = "0.1" axum = "0.8" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] }

2. Create Route Handler

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) }

3. Setup Main

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(); }

4. Run

cargo run # Open http://localhost:3000/docs

Core Concepts

File-Based Routing

File structure maps to URL paths automatically:

src/routes/ ├── mod.rs → / ├── users.rs → /users ├── posts.rs → /posts └── admin/ ├── mod.rs → /admin └── stats.rs → /admin/stats 

Route Handlers

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(...) -> ... { ... }

Schema Derivation

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 }

Supported Extractors

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)

Multipart Form Data

Typed Multipart (Recommended)

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" }.

Raw Multipart (Untyped)

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.

Error Handling

#[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() })) }

vespera! Macro Reference

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_app! Macro Reference

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 spec
  • MyApp::router() -> Router - Function returning the Axum router

Environment Variable Fallbacks

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


schema_type! Macro

Generate request/response types from existing structs. Perfect for creating API types from database models.

Basic Usage

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)]);

Same-File Model Reference

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.

Cross-File References

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"]);

Auto-Generated From Impl

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!

Partial Updates (PATCH)

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"]);

Serde Rename All

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.

Omit Fields with Database Defaults (omit_default)

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, content

omit_default detects fields with:

  • #[sea_orm(primary_key)] — auto-increment / generated IDs
  • #[sea_orm(default_value = "...")] — SQL defaults like NOW(), 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>)]);

Database Defaults in OpenAPI

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: required is determined solely by nullability (Option<T>). Fields with defaults are still required unless they are Option<T>.

SeaORM Integration

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.

Multipart Mode

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 Multipart instead of Serialize/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 From impl

Parameters

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)

schema! Macro

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 with From impl.


Cron Jobs

Schedule background tasks with #[vespera::cron]. Uses tokio-cron-scheduler under the hood.

Enable Feature

[dependencies] vespera = { version = "0.1", features = ["cron"] }

Define Cron Jobs

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"); }

How It Works

  1. #[cron("...")] registers the job at compile time (like #[route])
  2. vespera!() auto-discovers all registered cron jobs — no extra parameters needed
  3. A background scheduler spawns via tokio::spawn when the app starts
// No cron-specific config — just works let app = vespera!(docs_url = "/docs");

Cron Expression Format

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

Requirements

  • Functions must be pub async fn
  • Functions must take no parameters (no State, no extractors)
  • The cron feature must be enabled

Advanced Usage

Adding State

let app = vespera!(docs_url = "/docs") .with_state(AppState { db: pool });

Adding Middleware

let app = vespera!(docs_url = "/docs") .layer(CorsLayer::permissive()) .layer(TraceLayer::new_for_http());

Multiple OpenAPI Files

let app = vespera!( openapi = ["openapi.json", "docs/api-spec.json"] );

Custom Route Folder

// Scans src/api/ instead of src/routes/ let app = vespera!("api"); // Or explicitly let app = vespera!(dir = "api");

Merging Multiple Vespera Apps

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

Type Mapping

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

Project Structure

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 

Contributing

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/docs

See SKILL.md for development guidelines and architecture details.


Comparison

vs. utoipa

  • Vespera: Zero-config, file-based routing, compile-time generation
  • utoipa: Manual annotations, more control, works with any router

vs. aide

  • Vespera: Automatic discovery, built-in Swagger UI
  • aide: More flexible, supports multiple doc formats

vs. paperclip

  • Vespera: Axum-first, modern OpenAPI 3.1
  • paperclip: Actix-focused, OpenAPI 2.0/3.0

License

Apache-2.0


Acknowledgments

Inspired by FastAPI's developer experience and Next.js's file-based routing.

About

A fully automated OpenAPI engine for Axum with zero-config route and schema discovery

Resources

Stars

Watchers

Forks

Sponsor this project

  •  

Packages

 
 
 

Contributors