FastAPI-like developer experience for Rust. Zero-config OpenAPI 3.1 generation for Axum.
// That's it. Swagger UI at /docs, OpenAPI at openapi.jsonlet 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 (#[derive(Schema)]) | Manual JSON Schema |
| Request validation | Validated<T> extractor → auto 422 | Manual checks in every handler |
| Server startup | .serve("0.0.0.0:3000") one-liner | TcpListener::bind + axum::serve |
| 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)]pubstructUser{pubid:u32,pubname:String,}/// Get user by ID#[vespera::route(get, path = "/{id}", tags = ["users"])]pubasyncfnget_user(Path(id):Path<u32>) -> Json<User>{Json(User{ id,name:"Alice".into()})}/// Create a new user#[vespera::route(post, tags = ["users"])]pubasyncfncreate_user(Json(user):Json<User>) -> Json<User>{Json(user)}src/main.rs:
use vespera::{vespera,Serve};#[tokio::main]asyncfnmain() -> std::io::Result<()>{println!("Swagger UI: http://localhost:3000/docs");vespera!(
openapi = "openapi.json",
title = "My API",
docs_url = "/docs").serve("0.0.0.0:3000").await}
.serve(addr)is a vespera-provided extension trait onaxum::Router— it replaces the usualTcpListener::bind+axum::serve(...)dance with a single chained call.addraccepts anythingtokio::net::ToSocketAddrstakes (strings, tuples,SocketAddr).
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]pubasyncfnlist_users() -> Json<Vec<User>>{ ...}// POST /users#[vespera::route(post)]pubasyncfncreate_user(Json(user):Json<User>) -> Json<User>{ ...}// GET /users/{id}#[vespera::route(get, path = "/{id}")]pubasyncfnget_user(Path(id):Path<u32>) -> Json<User>{ ...}// Full options#[vespera::route(put, path = "/{id}", tags = ["users"], description = "Update user")]pubasyncfnupdate_user(...) -> ... { ...}Derive Schema on types used in request/response bodies:
#[derive(Serialize,Deserialize, vespera::Schema)]#[serde(rename_all = "camelCase")]// Serde attributes are respectedpubstructCreateUserRequest{pubuser_name:String,// → "userName" in OpenAPIpubemail:String,#[serde(default)]pubbio:Option<String>,// Optional field}Validated<T> wraps any axum extractor (Json, Form, Query, Path) and
runs the inner type's [garde::Validate] impl before the handler is
called. Validation failures are converted to 422 Unprocessable Entity
with a canonical JSON envelope — no manual error mapping per handler.
use vespera::{Validated,Schema, axum::Json};use garde::Validate;#[derive(serde::Deserialize,Schema,Validate)]pubstructCreateUser{#[garde(length(min = 3, max = 32))]pubusername:String,#[garde(email)]pubemail:String,#[garde(range(min = 18, max = 120))]pubage:u8,}#[vespera::route(post, tags = ["users"])]pubasyncfncreate_user(Validated(Json(req)):Validated<Json<CreateUser>>,) -> Json<&'staticstr>{// `req` has already passed garde validation.Json("ok")}Failure response (HTTP/1.1 422 Unprocessable Entity):
{
"errors": [
{ "path": "username", "message": "length is lower than 3" },
{ "path": "email", "message": "not a valid email" }
]
}Works with every common extractor — same 422 envelope on the wire:
| Extractor | Validates |
|---|---|
Validated<Json<T>> | JSON body |
Validated<Form<T>> | URL-encoded form body |
Validated<Query<T>> | URL query parameters |
Validated<Path<T>> | Path parameters |
Under JNI, the same 422 body is hoisted into the binary wire header as
"validation_errors": [...] — Java decoders consume validation errors
without parsing the body. See crates/vespera/tests/jni_validation.rs.
| 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)]pubstructCreateUploadRequest{pubname:String,#[form_data(limit = "10MiB")]pubfile:Option<FieldData<NamedTempFile>>,}#[vespera::route(post, tags = ["uploads"])]pubasyncfncreate_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"])]pubasyncfnupload(mutmultipart:Multipart) -> Json<UploadResponse>{whileletSome(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)]pubstructApiError{pubmessage:String,}#[vespera::route(get, path = "/{id}")]pubasyncfnget_user(Path(id):Path<u32>) -> Result<Json<User>,(StatusCode,Json<ApiError>)>{if id == 0{returnErr((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 onlyschema_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 fieldsschema_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.rspubstructModel{pubid:i32,pubname:String,pubemail: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.rsschema_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 JSONschema_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")]pubstructModel{#[sea_orm(primary_key)]// ← has default (auto-increment)pubid:i32,pubtitle:String,pubcontent:String,#[sea_orm(default_value = "NOW()")]// ← has default (SQL function)pubcreated_at:DateTimeWithTimeZone,}// Omits `id` (primary_key) and `created_at` (default_value) automaticallyschema_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 fieldsschema_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")]pubstructModel{#[sea_orm(primary_key)]pubid:i32,pubtitle:String,pubuser_id:i32,pubuser:BelongsTo<super::user::Entity>,// → Option<Box<UserSchema>>pubcomments: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.
For response DTOs that live in the same route file, schema_type! can now keep the handler code unchanged even when a SeaORM relation should be exposed through a custom local DTO.
Example:
#[derive(Serialize, vespera::Schema)]#[serde(rename_all = "camelCase")]pubstructUserInArticle{pubid:Uuid,pubname:String,pubemail:String,pubprofile_image:Option<String>,}#[derive(Serialize, vespera::Schema)]#[serde(rename_all = "camelCase")]pubstructCategoryInArticle{pubid:i64,pubname:String,pubparent_category_id:Option<i64>,pubis_active:bool,pubis_menu:bool,}schema_type!(ArticleResponse from crate::models::article::Model,
add = [("article_review_users":Vec<ArticleReviewUserInArticle>)]);// Existing handler code stays valid.Ok(ArticleResponse{user: user.into(),category: category.into(),
article_review_users,
..
})How it works:
schema_type!looks for same-file DTOs named{RelationNamePascal}In{ResponseBase}useronArticleResponse→UserInArticlecategoryonArticleResponse→CategoryInArticle
- It generates local compile adapters so
Option<Model>.into()works unchanged in the handler - Those adapters stay internal to Rust typing
- OpenAPI does not expose the generated adapter wrapper names; the spec still points at the original related schemas (
UserSchema,CategorySchema)
Use this when you want route-local response DTOs for single-value relations (HasOne / BelongsTo) without rewriting the route construction logic.
Generate Multipart structs from existing types using the multipart keyword:
#[derive(vespera::Multipart, vespera::Schema)]pubstructCreateUploadRequest{pubname:String,#[form_data(limit = "10MiB")]pubfile:Option<FieldData<NamedTempFile>>,pubdescription:Option<String>,}// Generates a Multipart struct (no serde derives), all fields Optionalschema_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)]pubstructUser{pubid:i32,pubname:String,pubpassword:String,}// Full schemalet full: vespera::schema::Schema = schema!(User);// With fields omittedlet safe: vespera::schema::Schema = schema!(User, omit = ["password"]);// With only specified fieldslet 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 * * * * *")]pubasyncfncleanup_sessions(){println!("Running cleanup every 10 seconds");}#[vespera::cron("0 0 * * * *")]pubasyncfnhourly_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 workslet 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 explicitlylet app = vespera!(dir = "api");Combine routes and OpenAPI specs from multiple vespera apps at compile time:
Child app (e.g., third crate):
// src/lib.rsmod 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.rsuse 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 |
Embed your Vespera router inside a Java/Spring application — no TCP, no JSON envelope overhead.
// Cargo.toml// vespera = { version = "0.1", features = ["jni"] }pubfncreate_app() -> axum::Router{vespera!(title = "My API")}
vespera::jni_app!(create_app);@SpringBootApplication@ComponentScan(basePackages = {"com.example.app", "com.devfive.vespera.bridge"})
publicclassMyApp {
publicstaticvoidmain(String[] args) {
VesperaBridge.init("my_rust_lib");
SpringApplication.run(MyApp.class, args);
}
}The VesperaProxyController auto-registers as a catch-all and forwards every HTTP request through a length-prefixed binary wire format ([u32 BE | UTF-8 JSON header | raw body]) — multipart uploads, PDFs, and images travel raw, with zero base64 overhead.
See libs/vespera-bridge for the Java library docs and examples/rust-jni-demo for a complete end-to-end demo.
vespera/
├── crates/
│ ├── vespera/ # Main crate - re-exports everything
│ ├── vespera_core/ # OpenAPI types and abstractions
│ ├── vespera_macro/ # Proc-macros (compile-time magic)
│ ├── vespera_inprocess/ # In-process axum dispatch + binary wire API
│ └── vespera_jni/ # JNI glue (Runtime + JNI symbol)
├── libs/
│ └── vespera-bridge/ # Java library (kr.devfive:vespera-bridge)
└── examples/
├── axum-example/ # Standalone OpenAPI server
└── rust-jni-demo/ # Rust + Spring Boot JNI integration
git clone https://github.com/dev-five-git/vespera.git
cd vespera
# Build & test
cargo build
cargo test --workspace
# Run examplecd 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.