| name | vespera |
| description | Build APIs with Vespera - FastAPI-like DX for Rust/Axum. Covers route handlers, Schema derivation, and OpenAPI generation. |
Vespera Usage Guide
Vespera = FastAPI DX for Rust. Zero-config OpenAPI 3.1 generation via compile-time macro scanning.
Quick Start
use vespera::{vespera, Serve, Schema, Validated, axum::Json};
use axum::extract::Path;
use serde::{Deserialize, Serialize};
use garde::Validate;
#[derive(Serialize, Deserialize, Schema, Validate)]
pub struct CreateUser {
#[garde(length(min = 3, max = 32))]
pub name: String,
#[garde(email)]
pub email: String,
}
#[vespera::route(get, path = "/{id}", tags = ["users"])]
pub async fn get_user(Path(id): Path<u32>) -> Json<CreateUser> { }
#[vespera::route(post, tags = ["users"])]
pub async fn create_user(
Validated(Json(req)): Validated<Json<CreateUser>>,
) -> Json<&'static str> {
Json("ok")
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
vespera!(
openapi = "openapi.json",
title = "My API",
version = "1.0.0",
docs_url = "/docs",
redoc_url = "/redoc"
)
.serve("0.0.0.0:3000")
.await
}
Request Validation (Validated<T> → 422)
Wrap any extractor with Validated<...> to enforce garde::Validate before
the handler runs. Vespera converts validation failures into a canonical
422 Unprocessable Entity response — no per-handler error mapping.
use vespera::{Validated, axum::Json};
#[vespera::route(post)]
pub async fn create(
Validated(Json(req)): Validated<Json<CreateUser>>,
) -> Json<&'static str> {
Json("ok")
}
Response on validation failure (status 422, content-type application/json):
{
"errors": [
{ "path": "name", "message": "length is lower than 3" },
{ "path": "email", "message": "not a valid email" }
]
}
Supported wrappers
| Wrapper | Validates |
|---|
Validated<Json<T>> | JSON body |
Validated<Form<T>> | URL-encoded form body |
Validated<Query<T>> | URL query string |
Validated<Path<T>> | Path parameters |
Requirements
T (or the inner type of Json<T>, Form<T>, …) must implement
garde::Validate<Context = ()>.
- Derive
garde::Validate and annotate fields with #[garde(...)] rules
(length, email, range, pattern, custom, …).
- Vespera's
#[derive(Schema)] continues to drive the OpenAPI spec — the two
derives compose cleanly on the same struct.
JNI / Binary wire integration
When a Validated rejection crosses the JNI boundary, the JSON envelope
({"errors":[...]}) is hoisted into the binary wire-format header as
"validation_errors": [...]. Java decoders inspect the field directly
without re-parsing the body. See
crates/vespera/tests/jni_validation.rs for the pinned contract.
One-Liner Server Startup (Serve)
vespera::Serve is an extension trait on axum::Router. It replaces the
standard TcpListener::bind + axum::serve(...) dance with a single chained
call:
use vespera::{vespera, Serve};
#[tokio::main]
async fn main() -> std::io::Result<()> {
vespera!(title = "My API")
.serve("0.0.0.0:3000")
.await
}
addr accepts anything tokio::net::ToSocketAddrs accepts — strings
("0.0.0.0:3000"), tuples (("127.0.0.1", 8080)), SocketAddr, etc.
- Works on any
axum::Router, including the output of Router::merge,
Router::nest, or vespera!(...) itself.
- Returns
std::io::Result<()> — propagate with ? from main.
Type Mapping Reference
| Rust Type | OpenAPI Schema | Notes |
|---|
String, &str | string | |
i8-i128, u8-u128 | integer | |
f32, f64 | number | |
bool | boolean | |
Vec<T> | array + items | |
BTreeSet<T>, HashSet<T> | array + items + uniqueItems: true | Set types |
Option<T> | T (nullable context) | Parent marks as optional |
HashMap<K,V> | object + additionalProperties | |
Uuid | string + format: uuid | |
Decimal | string + format: decimal | |
NaiveDate | string + format: date | |
NaiveTime | string + format: time | |
DateTime, DateTimeWithTimeZone | string + format: date-time | |
FieldData<NamedTempFile> | string + format: binary | File upload field |
() | empty response | 204 No Content |
| Custom struct | $ref | Must derive Schema |
Extractor Mapping Reference
| Axum Extractor | OpenAPI Location | Notes |
|---|
Path<T> | path parameter | T can be tuple or struct |
Query<T> | query parameters | Struct fields become params |
Json<T> | requestBody | application/json |
Form<T> | requestBody | application/x-www-form-urlencoded |
TypedMultipart<T> | requestBody | multipart/form-data — typed with schema |
Multipart | requestBody | multipart/form-data — untyped, generic object |
State<T> | ignored | Internal, not API |
Extension<T> | ignored | Internal, not API |
TypedHeader<T> | header parameter | |
HeaderMap | ignored | Too dynamic |
Route Handler Requirements
async fn get_users() -> Json<Vec<User>> { ... }
pub fn get_users() -> Json<Vec<User>> { ... }
pub async fn get_users() -> Json<Vec<User>> { ... }
File Structure → URL Mapping
src/routes/
├── mod.rs → / (root routes)
├── users.rs → /users
├── posts.rs → /posts
└── admin/
├── mod.rs → /admin
└── stats.rs → /admin/stats
Handler path is: {file_path} + {#[route] path}
#[vespera::route(get, path = "/{id}")]
pub async fn get_user(...)
Serde Integration
Vespera respects serde attributes:
#[derive(Serialize, Deserialize, Schema)]
#[serde(rename_all = "camelCase")]
pub struct UserResponse {
user_id: u32,
#[serde(rename = "fullName")]
name: String,
#[serde(default)]
bio: Option<String>,
#[serde(skip)]
internal_id: u64,
}
Debugging Tips
Schema Not Appearing
- Check
#[derive(Schema)] on the type
- Check type is used in a route handler's input/output
- Check for generic types - all type params need Schema
#[derive(Schema)]
struct Paginated<T: Schema> {
items: Vec<T>,
total: u32,
}
Macro Expansion
cargo expand
npx @apidevtools/swagger-cli validate openapi.json
Environment Variables
| Variable | Purpose | Default |
|---|
VESPERA_DIR | Route folder name | routes |
VESPERA_OPENAPI | OpenAPI output path | none |
VESPERA_TITLE | API title | API |
VESPERA_VERSION | API version | CARGO_PKG_VERSION |
VESPERA_DOCS_URL | Swagger UI path | none |
VESPERA_REDOC_URL | ReDoc path | none |
VESPERA_SERVER_URL | Server URL | http://localhost:3000 |
schema_type! Macro (RECOMMENDED)
ALWAYS prefer schema_type! over manually defining request/response structs.
Benefits:
- Single source of truth (your model)
- Auto-generated
From impl for easy conversion
- Automatic type resolution (enums, custom types → absolute paths)
- SeaORM relation support (HasOne, BelongsTo, HasMany)
- No manual field synchronization
Best Practices
| DO | DON'T |
|---|
Use pick to select only needed fields | Define manual structs that duplicate Model fields |
Use omit to exclude sensitive fields | Use name parameter unnecessarily |
Use full crate::models::... paths | Rely on implicit module resolution |
| Define schema near route handlers | Scatter schemas across unrelated files |
Primary Parameters (USE THESE):
pick = [...] - Allowlist: include ONLY these fields
omit = [...] - Denylist: exclude these fields
omit_default - Auto-omit fields with DB defaults (primary_key, default_value)
Advanced Parameters (USE SPARINGLY):
partial - For PATCH endpoints only
rename - Only when API naming differs from model
add - Only when truly new fields needed (breaks From impl)
name - AVOID unless same-file Model reference (see below)
Why Not Manual Structs?
#[derive(Serialize, Deserialize, Schema)]
pub struct UserResponse {
pub id: i32,
pub name: String,
pub email: String,
}
schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]);
Basic Syntax
schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]);
schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash", "internal_id"]);
schema_type!(UpdateUserRequest from crate::models::user::Model, pick = ["name"], add = [("id": i32)]);
schema_type!(UserDTO from crate::models::user::Model, rename = [("id", "user_id")]);
schema_type!(UserPatch from crate::models::user::Model, partial);
schema_type!(UserPatch from crate::models::user::Model, partial = ["name", "email"]);
schema_type!(CreatePostRequest from crate::models::post::Model, omit_default);
schema_type!(CreateItemRequest from crate::models::item::Model, omit_default, add = [("tags": Vec<String>)]);
schema_type!(UserSnakeCase from crate::models::user::Model, rename_all = "snake_case");
schema_type!(Schema from Model, name = "UserSchema");
schema_type!(InternalDTO from Model, ignore);
schema_type!(LargeResponse from SomeType, clone = false);
Same-File Model Reference (When to Use name)
The name parameter is ONLY needed for same-file Model references.
For cross-file references, use full paths and descriptive struct names instead.
When defining Schema in the same file as Model (common for SeaORM entities):
pub struct Model {
pub id: i32,
pub name: String,
pub status: UserStatus,
}
pub enum UserStatus { Active, Inactive }
vespera::schema_type!(Schema from Model, name = "UserSchema");
Why avoid name for cross-file references?
- The struct name itself becomes the OpenAPI schema name
UserResponse is clearer than Schema with name = "UserResponse"
- Less parameters = less complexity
Cross-File References
Reference structs from other files using full module paths:
use vespera::schema_type;
schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]);
The macro reads the source file at compile time - no special annotations needed on the source struct.
Auto-Generated From Impl
When add is NOT used, schema_type! generates a From impl for easy conversion:
schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]);
pub struct UserResponse { id, name, email, created_at }
impl From<crate::models::user::Model> for UserResponse {
fn from(source: crate::models::user::Model) -> Self {
Self { id: source.id, name: source.name, ... }
}
}
let model: Model = db.find_user(id).await?;
Json(model.into())
Note: From is NOT generated when add is used (can't auto-populate added fields).
Parameters
Recommended (Primary):
| Parameter | Description | Example |
|---|
pick | Include only these fields | pick = ["name", "email"] |
omit | Exclude these fields | omit = ["password"] |
omit_default | Auto-omit fields with DB defaults | omit_default (bare keyword) |
Situational (Use When Needed):
| Parameter | Description | When to Use |
|---|
partial | Make fields optional | PATCH endpoints only |
rename | Rename fields | API naming differs from model |
rename_all | Serde rename strategy | Different casing needed |
add | Add new fields | New fields not in model (breaks From impl) |
multipart | Derive Multipart | Multipart form-data endpoints |
Avoid (Special Cases Only):
| Parameter | Description | When to Use |
|---|
name | Custom OpenAPI schema name | Same-file Model reference only |
ignore | Skip Schema derive | Internal DTOs not for OpenAPI |
clone | Control Clone derive | Large structs where Clone is expensive |
SeaORM Integration (RECOMMENDED)
schema_type! has first-class SeaORM support with automatic relation handling:
#[derive(Clone, Debug, DeriveEntityModel)]
#[sea_orm(table_name = "memo")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
pub user_id: i32,
pub status: MemoStatus,
pub user: BelongsTo<super::user::Entity>,
pub comments: HasMany<super::comment::Entity>,
pub created_at: DateTimeWithTimeZone,
}
#[derive(EnumIter, DeriveActiveEnum, Serialize, Deserialize, Schema)]
pub enum MemoStatus { Draft, Published, Archived }
vespera::schema_type!(Schema from Model, name = "MemoSchema");
Automatic Type Conversions:
| SeaORM Type | Generated Type | Notes |
|---|
HasOne<Entity> | Box<Schema> or Option<Box<Schema>> | Based on FK nullability |
BelongsTo<Entity> | Option<Box<Schema>> | Always optional |
HasMany<Entity> | Vec<Schema> | |
DateTimeWithTimeZone | vespera::chrono::DateTime<FixedOffset> | No SeaORM import needed |
| Custom enums | crate::module::EnumName | Auto-resolved to absolute path |
Circular Reference Handling: Automatically detected and handled by inlining fields.
Database Defaults in OpenAPI: Fields with #[sea_orm(default_value = "...")] or #[sea_orm(primary_key)] automatically get default values in the generated OpenAPI schema. SQL functions like NOW() and gen_random_uuid() are mapped to type-appropriate defaults.
Required Logic: required is determined solely by nullability (Option<T>). Fields with #[serde(default)] or #[serde(skip_serializing_if)] are still required unless they are Option<T>.
Same-File Relation Adapters
When a route file defines a local response DTO for a relation, Vespera can preserve unchanged handler code while still generating the right OpenAPI.
Example:
#[derive(Serialize, vespera::Schema)]
#[serde(rename_all = "camelCase")]
pub struct UserInArticle {
pub id: Uuid,
pub name: String,
pub email: String,
pub profile_image: Option<String>,
}
#[derive(Serialize, vespera::Schema)]
#[serde(rename_all = "camelCase")]
pub struct CategoryInArticle {
pub id: i64,
pub name: String,
pub parent_category_id: Option<i64>,
pub is_active: bool,
pub is_menu: bool,
}
schema_type!(
ArticleResponse from crate::models::article::Model,
add = [("article_review_users": Vec<ArticleReviewUserInArticle>)]
);
Ok(ArticleResponse {
user: user.into(),
category: category.into(),
article_review_users,
..
})
Rules:
- Only applies to single-value relations (
HasOne / BelongsTo)
- The local DTO name must follow
{RelationNamePascal}In{ResponseBase}
user on ArticleResponse → UserInArticle
category on ArticleResponse → CategoryInArticle
- Vespera generates local compile adapters so
Option<Model>.into() works without changing the route
- The adapter wrapper is hidden from OpenAPI; the spec still references the original related schema (
UserSchema, CategorySchema)
HasMany relations remain excluded by default unless explicitly picked or added
Complete Example
#[derive(Clone, Debug, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub email: String,
pub status: UserStatus,
pub password_hash: String,
pub created_at: DateTimeWithTimeZone,
}
vespera::schema_type!(Schema from Model, name = "UserSchema");
use vespera::schema_type;
schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]);
schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]);
schema_type!(UserPatch from crate::models::user::Model, omit = ["password_hash", "id"], partial);
#[vespera::route(get, path = "/{id}")]
pub async fn get_user(Path(id): Path<i32>, State(db): State<DbPool>) -> Json<UserResponse> {
let user = User::find_by_id(id).one(&db).await.unwrap().unwrap();
Json(user.into())
}
#[vespera::route(patch, path = "/{id}")]
pub async fn patch_user(
Path(id): Path<i32>,
Json(patch): Json<UserPatch>,
) -> Json<UserResponse> {
}
Multipart Mode (multipart)
Generate Multipart structs from existing multipart request types:
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 thumbnail: Option<FieldData<NamedTempFile>>,
#[form_data(limit = "50MiB")]
pub document: Option<FieldData<NamedTempFile>>,
pub tags: Option<String>,
}
schema_type!(PatchUploadRequest from CreateUploadRequest, multipart, partial, omit = ["document"]);
What multipart mode changes:
| Aspect | Normal Mode | Multipart Mode |
|---|
| Derives | Serialize, Deserialize | Multipart |
| Struct attrs | #[serde(rename_all=...)] | None |
| Field attrs | #[serde(...)] preserved | #[form_data(...)] preserved |
| Relation fields | Included (BelongsTo/HasOne) | Skipped (can't represent in forms) |
From impl | Auto-generated | Not generated |
OpenAPI rename alignment: The schema parser reads #[form_data(field_name = "...")] and #[serde(rename_all = "...")] for multipart structs, ensuring OpenAPI field names match runtime multipart parsing.
Dependencies required in your Cargo.toml:
vespera = "0.1"
tempfile = "3"
Quick Reference
schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]);
schema_type!(CreatePostRequest from crate::models::post::Model, omit_default);
schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]);
schema_type!(UserListItem from crate::models::user::Model, pick = ["id", "name"]);
schema_type!(PatchUpload from CreateUploadRequest, multipart, partial);
schema_type!(SmallUpload from CreateUploadRequest, multipart, omit = ["document"]);
schema_type!(UserPatch from crate::models::user::Model, partial);
schema_type!(Schema from Model, name = "UserSchema");
schema_type!(Schema from crate::models::user::Model, name = "UserResponse");
Merging Multiple Vespera Apps
Combine routes and OpenAPI specs from multiple apps at compile time.
export_app! Macro
Export an app for merging:
mod routes;
vespera::export_app!(ThirdApp);
vespera::export_app!(ThirdApp, dir = "api");
Generates:
ThirdApp::OPENAPI_SPEC: &'static str - OpenAPI JSON
ThirdApp::router() -> Router - Axum router
merge Parameter
Merge child apps in parent:
let app = vespera!(
openapi = "openapi.json",
docs_url = "/docs",
merge = [third::ThirdApp, other::OtherApp]
)
.with_state(state);
What happens:
- Child routers merged into parent router
- OpenAPI specs merged (paths, schemas, tags)
- Swagger UI shows all routes
How It Works (Compile-Time)
Child compilation (export_app!):
1. Scan routes/ folder
2. Generate OpenAPI spec
3. Write to target/vespera/{Name}.openapi.json
Parent compilation (vespera! with merge):
1. Generate parent OpenAPI spec
2. Read child specs from target/vespera/
3. Merge all specs together
4. Write merged openapi.json