Build APIs with Vespera - FastAPI-like DX for Rust/Axum. Covers route handlers, Schema derivation, and OpenAPI generation.
Vespera = FastAPI DX for Rust. Zero-config OpenAPI 3.1 generation via compile-time macro scanning.
use vespera::{vespera, Serve, Schema, Validated, axum::Json};
use axum::extract::Path;
use serde::{Deserialize, Serialize};
use garde::Validate;
// 1. Custom types โ derive Schema for OpenAPI inclusion.
// Add `garde::Validate` to opt into 422 validation.
#[derive(Serialize, Deserialize, Schema, Validate)]
pub struct CreateUser {
#[garde(length(min = 3, max = 32))]
pub name: String,
#[garde(email)]
pub email: String,
}
// 2. Route handlers โ MUST be `pub async fn`.
#[vespera::route(get, path = "/{id}", tags = ["users"])]
pub async fn get_user(Path(id): Path<u32>) -> Json<CreateUser> { /* ... */ }
// 3. Validated extractor โ automatic 422 on bad input.
#[vespera::route(post, tags = ["users"])]
pub async fn create_user(
Validated(Json(req)): Validated<Json<CreateUser>>,
) -> Json<&'static str> {
// `req` already passed validation. Failures never reach here.
Json("ok")
}
// 4. Main โ one-liner `.serve()` from the `Serve` extension trait.
#[tokio::main]
async fn main() -> std::io::Result<()> {
vespera!(
openapi = "openapi.json", // writes file at compile time
title = "My API",
version = "1.0.0",
docs_url = "/docs", // Swagger UI
redoc_url = "/redoc" // ReDoc alternative
)
.serve("0.0.0.0:3000")
.await
}
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" }
]
}
| 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 |
T (or the inner type of Json<T>, Form<T>, โฆ) must implement
garde::Validate<Context = ()>.garde::Validate and annotate fields with #[garde(...)] rules
(length, email, range, pattern, custom, โฆ).#[derive(Schema)] continues to drive the OpenAPI spec โ the two
derives compose cleanly on the same struct.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.
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.axum::Router, including the output of Router::merge,
Router::nest, or vespera!(...) itself.std::io::Result<()> โ propagate with ? from main.| 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 |
| 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 |
// โ Private function - NOT discovered
async fn get_users() -> Json<Vec<User>> { ... }
// โ Non-async function - NOT supported
pub fn get_users() -> Json<Vec<User>> { ... }
// โ
Must be pub async fn
pub async fn get_users() -> Json<Vec<User>> { ... }
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}
// In src/routes/users.rs
#[vespera::route(get, path = "/{id}")]
pub async fn get_user(...) // โ GET /users/{id}
Vespera respects serde attributes:
#[derive(Serialize, Deserialize, Schema)]
#[serde(rename_all = "camelCase")] // โ
Respected in schema
pub struct UserResponse {
user_id: u32, // โ "userId" in JSON Schema
#[serde(rename = "fullName")] // โ
Respected
name: String, // โ "fullName" in JSON Schema
#[serde(default)] // โ
Recognized (does NOT affect `required` โ only Option<T> does)
bio: Option<String>,
#[serde(skip)] // โ
Excluded from schema
internal_id: u64,
}
#[derive(Schema)] on the type// Generic types need Schema on all params
#[derive(Schema)]
struct Paginated<T: Schema> { // T must also derive Schema
items: Vec<T>,
total: u32,
}
# See what vespera! generates
cargo expand
# Validate OpenAPI output
npx @apidevtools/swagger-cli validate openapi.json
| 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 |
ALWAYS prefer
schema_type!over manually defining request/response structs.Benefits:
- Single source of truth (your model)
- Auto-generated
Fromimpl for easy conversion- Automatic type resolution (enums, custom types โ absolute paths)
- SeaORM relation support (HasOne, BelongsTo, HasMany)
- No manual field synchronization
| 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 fieldsomit = [...] - Denylist: exclude these fieldsomit_default - Auto-omit fields with DB defaults (primary_key, default_value)Advanced Parameters (USE SPARINGLY):
partial - For PATCH endpoints onlyrename - Only when API naming differs from modeladd - Only when truly new fields needed (breaks From impl)name - AVOID unless same-file Model reference (see below)// โ BAD: Manual struct definition - requires sync with Model
#[derive(Serialize, Deserialize, Schema)]
pub struct UserResponse {
pub id: i32,
pub name: String,
pub email: String,
// Forgot to add new field? Schema out of sync!
}
// โ
GOOD: Derive from Model - always in sync
schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]);
// Pick specific fields
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", "internal_id"]);
// Add new fields (NOTE: no From impl generated when using add)
schema_type!(UpdateUserRequest from crate::models::user::Model, pick = ["name"], add = [("id": i32)]);
// Rename fields
schema_type!(UserDTO from crate::models::user::Model, rename = [("id", "user_id")]);
// Partial updates (all fields become Option<T>)
schema_type!(UserPatch from crate::models::user::Model, partial);
// Partial updates (specific fields only)
schema_type!(UserPatch from crate::models::user::Model, partial = ["name", "email"]);
// Auto-omit fields with DB defaults (primary_key, default_value = "...")
schema_type!(CreatePostRequest from crate::models::post::Model, omit_default);
// Combine omit_default with add
schema_type!(CreateItemRequest from crate::models::item::Model, omit_default, add = [("tags": Vec<String>)]);
// Custom serde rename strategy
schema_type!(UserSnakeCase from crate::models::user::Model, rename_all = "snake_case");
// Custom OpenAPI schema name
schema_type!(Schema from Model, name = "UserSchema");
// Skip Schema derive (won't appear in OpenAPI)
schema_type!(InternalDTO from Model, ignore);
// Disable Clone derive
schema_type!(LargeResponse from SomeType, clone = false);
name)The
nameparameter 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):
// In src/models/user.rs
pub struct Model {
pub id: i32,
pub name: String,
pub status: UserStatus, // Custom enum - auto-resolved to absolute path
}
pub enum UserStatus { Active, Inactive }
// โ
CORRECT: Same-file reference - use `name` for OpenAPI schema name
vespera::schema_type!(Schema from Model, name = "UserSchema");
// โ WRONG: Using `name` for cross-file reference
// schema_type!(Schema from crate::models::user::Model, name = "UserResponse");
// โ
CORRECT: Use descriptive struct name instead
// schema_type!(UserResponse from crate::models::user::Model, omit = ["password"]);
Why avoid name for cross-file references?
UserResponse is clearer than Schema with name = "UserResponse"Reference structs from other files using full module paths:
// In src/routes/users.rs
use vespera::schema_type;
// Reference model from src/models/user.rs
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.
When add is NOT used, schema_type! generates a From impl for easy conversion:
// This:
schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]);
// Generates:
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, ... }
}
}
// Usage:
let model: Model = db.find_user(id).await?;
Json(model.into()) // Easy conversion!
Note: From is NOT generated when add is used (can't auto-populate added fields).
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 |
schema_type! has first-class SeaORM support with automatic relation handling:
// src/models/memo.rs
#[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, // Custom enum
pub user: BelongsTo<super::user::Entity>, // โ Option<Box<UserSchema>>
pub comments: HasMany<super::comment::Entity>, // โ Vec<CommentSchema>
pub created_at: DateTimeWithTimeZone, // โ chrono::DateTime<FixedOffset>
}
#[derive(EnumIter, DeriveActiveEnum, Serialize, Deserialize, Schema)]
pub enum MemoStatus { Draft, Published, Archived }
// Generates Schema with proper types - no imports needed!
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>.
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,
relation_adapters = [
("user", UserInArticle),
("category", CategoryInArticle),
],
add = [("article_review_users": Vec<ArticleReviewUserInArticle>)]
);
Ok(ArticleResponse {
user: user.into(),
category: category.into(),
article_review_users,
..
})
Rules:
HasOne / BelongsTo)relation_adapters = [("field", AdapterStruct)]Option<Model>.into() works without changing the routeUserInArticle, CategoryInArticle), honoring any #[schema(name = "...")] overriderelation_adapters keep the default base-schema relation typeHasMany relations remain excluded by default unless explicitly picked or added// ============================================
// src/models/user.rs (SeaORM entity)
// ============================================
#[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, // Never expose!
pub created_at: DateTimeWithTimeZone,
}
// โ
Same-file: use `name` parameter for OpenAPI schema name
vespera::schema_type!(Schema from Model, name = "UserSchema");
// ============================================
// src/routes/users.rs (Route handlers)
// ============================================
use vespera::schema_type;
// โ
Cross-file: use descriptive struct names + pick/omit
// NO `name` parameter needed - struct name = OpenAPI schema name
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()) // From impl handles conversion
}
#[vespera::route(patch, path = "/{id}")]
pub async fn patch_user(
Path(id): Path<i32>,
Json(patch): Json<UserPatch>, // All fields are Option<T>
) -> Json<UserResponse> {
// Apply partial update...
}
multipart)Generate Multipart structs from existing multipart request types:
use vespera::multipart::{FieldData, TypedMultipart};
use vespera::{Multipart, Schema};
use tempfile::NamedTempFile;
// Base multipart struct (manually defined)
#[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>,
}
// Derive a partial update struct via schema_type!
// - Derives Multipart (not serde)
// - All fields become Option<T> (partial)
// - "document" field excluded
// - #[form_data(limit = "10MiB")] preserved from source
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" # Includes multipart support natively
tempfile = "3" # For NamedTempFile file uploads
// โ
RECOMMENDED PATTERNS
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"]);
// โ
MULTIPART PATTERNS
schema_type!(PatchUpload from CreateUploadRequest, multipart, partial);
schema_type!(SmallUpload from CreateUploadRequest, multipart, omit = ["document"]);
// โ ๏ธ USE SPARINGLY
schema_type!(UserPatch from crate::models::user::Model, partial); // PATCH only
schema_type!(Schema from Model, name = "UserSchema"); // Same-file only
// โ AVOID
schema_type!(Schema from crate::models::user::Model, name = "UserResponse"); // Use struct name!
Combine routes and OpenAPI specs from multiple apps at compile time.
Export an app for merging:
// Child crate (e.g., third/src/lib.rs)
mod routes;
// Basic - scans "routes" folder by default
vespera::export_app!(ThirdApp);
// Custom directory
vespera::export_app!(ThirdApp, dir = "api");
Generates:
ThirdApp::OPENAPI_SPEC: &'static str - OpenAPI JSONThirdApp::router() -> Router - Axum routerMerge child apps in parent:
let app = vespera!(
openapi = "openapi.json",
docs_url = "/docs",
merge = [third::ThirdApp, other::OtherApp]
)
.with_state(state);
What happens:
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