Skip to content

Repository files navigation

Vespera

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

Crates.ioDocumentationLicenseCICodecov

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

Why Vespera?

FeatureVesperaManual Approach
Route registrationAutomatic (file-based)Manual Router::new().route(...)
OpenAPI specGenerated at compile timeHand-written or runtime generation
Schema extractionFrom Rust types (#[derive(Schema)])Manual JSON Schema
Request validationValidated<T> extractor → auto 422Manual checks in every handler
Server startup.serve("0.0.0.0:3000") one-linerTcpListener::bind + axum::serve
Swagger UIBuilt-inSeparate setup
Type safetyCompile-time verifiedRuntime 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)]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)}

3. Setup Main

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 on axum::Router — it replaces the usual TcpListener::bind + axum::serve(...) dance with a single chained call. addr accepts anything tokio::net::ToSocketAddrs takes (strings, tuples, SocketAddr).

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

Schema Derivation

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}

Request Validation (Validated<T>422)

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:

ExtractorValidates
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.

Supported Extractors

ExtractorOpenAPI 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
MultipartRequest 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)]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" }.

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

Error Handling

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

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:

ParameterEnvironment Variable
dirVESPERA_DIR
openapiVESPERA_OPENAPI
titleVESPERA_TITLE
versionVESPERA_VERSION
docs_urlVESPERA_DOCS_URL
redoc_urlVESPERA_REDOC_URL
serversVESPERA_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 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)]);

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

Cross-File References

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

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 JSONschema_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")]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, 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 fieldsschema_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 AttributeOpenAPI 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")]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 TypeGenerated Schema Type
HasOne<Entity>Box<Schema> or Option<Box<Schema>>
BelongsTo<Entity>Option<Box<Schema>>
HasMany<Entity>Vec<Schema>
DateTimeWithTimeZonechrono::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.

Same-File Relation Adapters

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}
    • user on ArticleResponseUserInArticle
    • category on ArticleResponseCategoryInArticle
  • 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.

Multipart Mode

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

ParameterDescription
pickInclude only specified fields
omitExclude specified fields
renameRename fields: rename = [("old", "new")]
addAdd new fields (disables auto From impl)
cloneControl Clone derive (default: true)
partialMake fields optional: partial or partial = ["field1"]
nameCustom OpenAPI schema name: name = "UserSchema"
rename_allSerde rename strategy: rename_all = "camelCase"
ignoreSkip Schema derive (bare keyword, no value)
multipartDerive Multipart instead of serde (bare keyword)
omit_defaultAuto-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)]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 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 * * * * *")]pubasyncfncleanup_sessions(){println!("Running cleanup every 10 seconds");}#[vespera::cron("0 0 * * * *")]pubasyncfnhourly_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 workslet app = vespera!(docs_url = "/docs");

Cron Expression Format

Uses 6-field cron expressions (sec min hour day month weekday):

ExpressionSchedule
0 */5 * * * *Every 5 minutes
0 0 * * * *Every hour
0 0 0 * * *Daily at midnight
1/10 * * * * *Every 10 seconds
0 30 9 * * Mon-FriWeekdays 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 explicitlylet 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.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

Type Mapping

Rust TypeOpenAPI Schema
String, &strstring
i32, u64, etc.integer
f32, f64number
boolboolean
Vec<T>array with items
Option<T>nullable T
HashMap<K, V>object with additionalProperties
BTreeSet<T>, HashSet<T>array with uniqueItems: true
Uuidstring with format: uuid
Decimalstring with format: decimal
NaiveDatestring with format: date
NaiveTimestring with format: time
DateTime, DateTimeWithTimeZonestring with format: date-time
FieldData<NamedTempFile>string with format: binary
Custom struct$ref to components/schemas

JNI / Java Integration

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.

Project Structure

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

Contributing

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

27 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages